Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aad90630b1 |
@@ -26,9 +26,14 @@ const APPWRITE_PROJECT_ID = process.env.APPWRITE_SELF_HOSTED_PROJECT_ID || proce
|
|||||||
const APPWRITE_KEY = process.env.APPWRITE_SELF_HOSTED_API_KEY || process.env.APPWRITE_LOCAL_API_KEY
|
const APPWRITE_KEY = process.env.APPWRITE_SELF_HOSTED_API_KEY || process.env.APPWRITE_LOCAL_API_KEY
|
||||||
const APPWRITE_DATABASE_ID = process.env.APPWRITE_MEMORY_DATABASE_ID || 'freecastle'
|
const APPWRITE_DATABASE_ID = process.env.APPWRITE_MEMORY_DATABASE_ID || 'freecastle'
|
||||||
const APPWRITE_COLLECTION_ID = process.env.APPWRITE_MEMORY_COLLECTION_ID || 'memory_files'
|
const APPWRITE_COLLECTION_ID = process.env.APPWRITE_MEMORY_COLLECTION_ID || 'memory_files'
|
||||||
|
const MEMORY_SLACK_CHANNEL_ID = process.env.MEMORY_SLACK_CHANNEL_ID || process.env.LLM_MEMORIES_SLACK_CHANNEL_ID || ''
|
||||||
|
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ''
|
||||||
|
const IMPORT_MAX_CHARS = 80_000
|
||||||
|
const FETCH_MAX_CHARS = 220_000
|
||||||
|
|
||||||
const APPWRITE_ENV = {
|
const APPWRITE_ENV = {
|
||||||
...process.env,
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
APPWRITE_ENDPOINT,
|
APPWRITE_ENDPOINT,
|
||||||
APPWRITE_PROJECT_ID,
|
APPWRITE_PROJECT_ID,
|
||||||
APPWRITE_KEY,
|
APPWRITE_KEY,
|
||||||
@@ -74,6 +79,40 @@ function readUtf8File(filePath) {
|
|||||||
return fs.readFileSync(filePath, 'utf8')
|
return fs.readFileSync(filePath, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateText(value = '', max = 240) {
|
||||||
|
const clean = String(value || '').replace(/\s+/g, ' ').trim()
|
||||||
|
return clean.length > max ? `${clean.slice(0, max).trim()}…` : clean
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value = '') {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
||||||
|
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeFence(value = '') {
|
||||||
|
return String(value || '').replace(/```/g, 'ʼʼʼ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSensitive(value = '') {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/(standard_[a-f0-9]{24,})/gi, '[redacted-appwrite-key]')
|
||||||
|
.replace(/(xox[baprs]-[A-Za-z0-9-]+)/g, '[redacted-slack-token]')
|
||||||
|
.replace(/(ghp_[A-Za-z0-9_]+)/g, '[redacted-github-token]')
|
||||||
|
.replace(/(cf[a-zA-Z0-9_\-]{20,})/g, '[redacted-cloudflare-token]')
|
||||||
|
.replace(/((?:password|pass|token|api[_-]?key|secret)\s*[:=]\s*)([^\n\s`]+)/gi, '$1[redacted]')
|
||||||
|
.replace(/((?:admin user|user|login)\*?\*?:?[^\n]{0,120}\/\s*`?)([^\n\s`]+)/gi, '$1[redacted]')
|
||||||
|
}
|
||||||
|
|
||||||
function wordCount(content) {
|
function wordCount(content) {
|
||||||
return content.trim().split(/\s+/).filter(Boolean).length
|
return content.trim().split(/\s+/).filter(Boolean).length
|
||||||
}
|
}
|
||||||
@@ -154,6 +193,15 @@ function normalizeTag(value) {
|
|||||||
return TAG_ALIASES[cleaned] || cleaned
|
return TAG_ALIASES[cleaned] || cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenize(value = '') {
|
||||||
|
return String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9+\-\s]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((entry) => normalizeTag(entry))
|
||||||
|
.filter((entry) => entry && entry.length > 2 && !TAG_STOPWORDS.has(entry) && !isProbablyNoiseTag(entry))
|
||||||
|
}
|
||||||
|
|
||||||
function generateTags(content, filename) {
|
function generateTags(content, filename) {
|
||||||
const tags = []
|
const tags = []
|
||||||
const scores = new Map()
|
const scores = new Map()
|
||||||
@@ -246,6 +294,10 @@ function buildCollections(files = []) {
|
|||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectionsForFile(file, collections = []) {
|
||||||
|
return collections.filter((collection) => collection.filenames?.includes(file.filename))
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateLabel(filename) {
|
function formatDateLabel(filename) {
|
||||||
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.md$/)
|
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.md$/)
|
||||||
if (!match) return filename
|
if (!match) return filename
|
||||||
@@ -262,7 +314,7 @@ function formatDateLabel(filename) {
|
|||||||
async function runAppwrite(args) {
|
async function runAppwrite(args) {
|
||||||
const { stdout } = await execFileAsync('appwrite', args, {
|
const { stdout } = await execFileAsync('appwrite', args, {
|
||||||
env: APPWRITE_ENV,
|
env: APPWRITE_ENV,
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 12 * 1024 * 1024,
|
||||||
})
|
})
|
||||||
return stdout?.trim() || ''
|
return stdout?.trim() || ''
|
||||||
}
|
}
|
||||||
@@ -272,6 +324,58 @@ async function runAppwriteJson(args) {
|
|||||||
return stdout ? JSON.parse(stdout) : null
|
return stdout ? JSON.parse(stdout) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAppwriteScopeError(error) {
|
||||||
|
const message = `${error?.message || ''} ${error?.response?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`
|
||||||
|
return /missing scopes|forbidden|not authorized|unauthorized/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function appwriteRest(method, pathname, body = null) {
|
||||||
|
if (!APPWRITE_ENDPOINT || !APPWRITE_PROJECT_ID || !APPWRITE_KEY) {
|
||||||
|
throw new Error('Appwrite REST credentials are not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${APPWRITE_ENDPOINT}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Appwrite-Project': APPWRITE_PROJECT_ID,
|
||||||
|
'X-Appwrite-Key': APPWRITE_KEY,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const payload = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(payload.message || `Appwrite REST ${method} ${pathname} failed with HTTP ${response.status}`)
|
||||||
|
error.status = response.status
|
||||||
|
error.response = payload
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppwriteNotFound(error) {
|
||||||
|
const message = `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''} ${error?.response?.message || ''}`
|
||||||
|
return error?.status === 404 || /document_not_found|404|not found|could not be found/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalDocument(filename) {
|
||||||
|
const filePath = resolveMemoryPath(filename)
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) return null
|
||||||
|
const stats = await fsp.stat(filePath)
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
kind: kindForFilename(filename),
|
||||||
|
content: await fsp.readFile(filePath, 'utf8'),
|
||||||
|
$updatedAt: stats.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLocalDocuments() {
|
||||||
|
const filenames = ['MEMORY.md', ...fs.readdirSync(MEMORY_DIR).filter(isValidDailyMemoryFilename).sort()]
|
||||||
|
return Promise.all(filenames.map((filename) => getLocalDocument(filename))).then((entries) => entries.filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
async function getDocument(documentId) {
|
async function getDocument(documentId) {
|
||||||
try {
|
try {
|
||||||
return await runAppwriteJson([
|
return await runAppwriteJson([
|
||||||
@@ -281,46 +385,49 @@ async function getDocument(documentId) {
|
|||||||
'--document-id', documentId,
|
'--document-id', documentId,
|
||||||
])
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const stderr = `${error.stderr || ''}${error.stdout || ''}${error.message || ''}`
|
const message = `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`
|
||||||
if (/document_not_found|404|not found|could not be found/i.test(stderr)) return null
|
if (isAppwriteScopeError(error)) return getLocalDocument(documentId)
|
||||||
|
if (/document_not_found|404|not found|could not be found/i.test(message)) return null
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listDocuments() {
|
async function listDocuments() {
|
||||||
const result = await runAppwriteJson([
|
try {
|
||||||
'databases', 'list-documents',
|
const result = await runAppwriteJson([
|
||||||
'--database-id', APPWRITE_DATABASE_ID,
|
'databases', 'list-documents',
|
||||||
'--collection-id', APPWRITE_COLLECTION_ID,
|
'--database-id', APPWRITE_DATABASE_ID,
|
||||||
])
|
'--collection-id', APPWRITE_COLLECTION_ID,
|
||||||
return result?.documents || []
|
])
|
||||||
|
return result?.documents || []
|
||||||
|
} catch (error) {
|
||||||
|
if (isAppwriteScopeError(error)) return listLocalDocuments()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertDocument(filename, content) {
|
async function upsertDocument(filename, content) {
|
||||||
const data = JSON.stringify({
|
const data = {
|
||||||
filename,
|
filename,
|
||||||
kind: kindForFilename(filename),
|
kind: kindForFilename(filename),
|
||||||
content,
|
content,
|
||||||
})
|
|
||||||
|
|
||||||
const existing = await getDocument(filename)
|
|
||||||
if (existing) {
|
|
||||||
return runAppwriteJson([
|
|
||||||
'databases', 'update-document',
|
|
||||||
'--database-id', APPWRITE_DATABASE_ID,
|
|
||||||
'--collection-id', APPWRITE_COLLECTION_ID,
|
|
||||||
'--document-id', filename,
|
|
||||||
'--data', data,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return runAppwriteJson([
|
const documentPath = `/databases/${encodeURIComponent(APPWRITE_DATABASE_ID)}/collections/${encodeURIComponent(APPWRITE_COLLECTION_ID)}/documents/${encodeURIComponent(filename)}`
|
||||||
'databases', 'create-document',
|
try {
|
||||||
'--database-id', APPWRITE_DATABASE_ID,
|
return await appwriteRest('PATCH', documentPath, { data })
|
||||||
'--collection-id', APPWRITE_COLLECTION_ID,
|
} catch (error) {
|
||||||
'--document-id', filename,
|
if (isAppwriteNotFound(error)) {
|
||||||
'--data', data,
|
return appwriteRest('POST', `/databases/${encodeURIComponent(APPWRITE_DATABASE_ID)}/collections/${encodeURIComponent(APPWRITE_COLLECTION_ID)}/documents`, {
|
||||||
])
|
documentId: filename,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isAppwriteScopeError(error)) {
|
||||||
|
return { filename, content, fallback: true }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncFileToAppwrite(filename) {
|
async function syncFileToAppwrite(filename) {
|
||||||
@@ -337,8 +444,17 @@ async function syncWorkspaceToAppwrite() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const filename of filenames) {
|
for (const filename of filenames) {
|
||||||
await syncFileToAppwrite(filename)
|
try {
|
||||||
|
await syncFileToAppwrite(filename)
|
||||||
|
} catch (error) {
|
||||||
|
if (isAppwriteScopeError(error)) {
|
||||||
|
return { fallback: true, reason: 'appwrite-scope-missing' }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { fallback: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
let syncPromise = syncWorkspaceToAppwrite().catch((error) => {
|
let syncPromise = syncWorkspaceToAppwrite().catch((error) => {
|
||||||
@@ -365,6 +481,7 @@ function buildFileMeta(doc) {
|
|||||||
const mtimeMs = doc.$updatedAt ? new Date(doc.$updatedAt).getTime() : Date.now()
|
const mtimeMs = doc.$updatedAt ? new Date(doc.$updatedAt).getTime() : Date.now()
|
||||||
return {
|
return {
|
||||||
filename: doc.filename,
|
filename: doc.filename,
|
||||||
|
dateStamp: String(doc.filename || '').replace(/\.md$/, ''),
|
||||||
mtimeMs,
|
mtimeMs,
|
||||||
size: Buffer.byteLength(content, 'utf8'),
|
size: Buffer.byteLength(content, 'utf8'),
|
||||||
wordCount: wordCount(content),
|
wordCount: wordCount(content),
|
||||||
@@ -375,10 +492,227 @@ function buildFileMeta(doc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateValueForFile(file) {
|
||||||
|
const parsed = Date.parse(`${file?.dateStamp || String(file?.filename || '').replace(/\.md$/, '')}T12:00:00`)
|
||||||
|
return Number.isFinite(parsed) ? parsed : file?.mtimeMs || Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSemanticSnippet(content = '', tokens = []) {
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const exact = lines.find((line) => tokens.some((token) => line.toLowerCase().includes(token)))
|
||||||
|
const candidate = exact || lines.find((line) => !line.startsWith('#')) || ''
|
||||||
|
return candidate.slice(0, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSemanticResults(docs = []) {
|
||||||
|
const dailyFiles = docs
|
||||||
|
.filter((doc) => isValidDailyMemoryFilename(doc.filename))
|
||||||
|
.sort((a, b) => String(b.filename).localeCompare(String(a.filename)))
|
||||||
|
.map((doc) => ({
|
||||||
|
...buildFileMeta(doc),
|
||||||
|
content: doc.content || '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const collections = buildCollections(dailyFiles)
|
||||||
|
|
||||||
|
return function querySemantic(rawQuery = '') {
|
||||||
|
const normalizedQuery = String(rawQuery || '').trim().toLowerCase()
|
||||||
|
const tokens = tokenize(normalizedQuery)
|
||||||
|
if (!normalizedQuery || (!tokens.length && normalizedQuery.length < 2)) return []
|
||||||
|
|
||||||
|
const tokenSet = new Set(tokens)
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const file of dailyFiles) {
|
||||||
|
const collectionHits = collectionsForFile(file, collections)
|
||||||
|
const contentLower = file.content.toLowerCase()
|
||||||
|
let score = 0
|
||||||
|
const reasons = []
|
||||||
|
|
||||||
|
for (const tag of file.tags || []) {
|
||||||
|
if (tokenSet.has(tag)) {
|
||||||
|
score += 36
|
||||||
|
reasons.push(`tag:${tag}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const collection of collectionHits) {
|
||||||
|
if (normalizedQuery.includes(collection.label.toLowerCase()) || collection.tags.some((tag) => tokenSet.has(tag))) {
|
||||||
|
score += 26
|
||||||
|
reasons.push(`collection:${collection.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedQuery.includes(file.mood)) {
|
||||||
|
score += 12
|
||||||
|
reasons.push(`mood:${file.mood}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (contentLower.includes(token)) {
|
||||||
|
score += 9
|
||||||
|
reasons.push(`text:${token}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchIndex = normalizedQuery.length >= 2 ? contentLower.indexOf(normalizedQuery) : -1
|
||||||
|
if (matchIndex >= 0) {
|
||||||
|
score += 18
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!score) continue
|
||||||
|
|
||||||
|
const windowStart = Math.max(0, (matchIndex >= 0 ? matchIndex : 0) - 70)
|
||||||
|
const windowEnd = Math.min(file.content.length, (matchIndex >= 0 ? matchIndex : 110) + 130)
|
||||||
|
const snippet = (matchIndex >= 0 ? file.content.slice(windowStart, windowEnd) : pickSemanticSnippet(file.content, tokens))
|
||||||
|
.replace(/\n/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
score += Math.max(0, 12 - Math.floor((Date.now() - dateValueForFile(file)) / 86400000))
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
filename: file.filename,
|
||||||
|
dateLabel: file.dateLabel,
|
||||||
|
dateStamp: file.dateStamp,
|
||||||
|
tags: file.tags,
|
||||||
|
mood: file.mood,
|
||||||
|
wordCount: file.wordCount,
|
||||||
|
entryCount: file.entryCount,
|
||||||
|
score,
|
||||||
|
snippet: `${windowStart > 0 ? '…' : ''}${snippet}${windowEnd < file.content.length ? '…' : ''}`,
|
||||||
|
why: reasons.slice(0, 2).join(' · ') || 'semantic overlap',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => b.score - a.score || b.wordCount - a.wordCount).slice(0, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDocumentForQuery(doc, tokens = []) {
|
||||||
|
const content = doc.content || ''
|
||||||
|
const lower = content.toLowerCase()
|
||||||
|
const tags = generateTags(content, doc.filename)
|
||||||
|
if (!tokens.length) return doc.filename === 'MEMORY.md' ? 30 : 8
|
||||||
|
|
||||||
|
let score = doc.filename === 'MEMORY.md' ? 12 : 0
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tokens.includes(tag)) score += 22
|
||||||
|
}
|
||||||
|
for (const token of tokens) {
|
||||||
|
const count = lower.split(token).length - 1
|
||||||
|
if (count > 0) score += Math.min(24, count * 6)
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippetForQuery(content = '', tokens = [], max = 900) {
|
||||||
|
const lower = content.toLowerCase()
|
||||||
|
const firstIndex = tokens.map((token) => lower.indexOf(token)).filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? 0
|
||||||
|
const start = Math.max(0, firstIndex - 180)
|
||||||
|
return content.slice(start, start + max).replace(/\n{3,}/g, '\n\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyMemoryChannel(event = {}) {
|
||||||
|
if (!SLACK_BOT_TOKEN || !MEMORY_SLACK_CHANNEL_ID) return { skipped: true }
|
||||||
|
|
||||||
|
const title = event.title || 'Memory replicated'
|
||||||
|
const filename = event.filename ? `\n• File: ${event.filename}` : ''
|
||||||
|
const source = event.source ? `\n• Source: ${event.source}` : ''
|
||||||
|
const summary = event.summary ? `\n• ${truncateText(event.summary, 220)}` : ''
|
||||||
|
const text = `🧠 ${title}${filename}${source}${summary}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://slack.com/api/chat.postMessage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ channel: MEMORY_SLACK_CHANNEL_ID, text }),
|
||||||
|
})
|
||||||
|
const payload = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || payload.ok === false) {
|
||||||
|
console.warn('Memory Slack notification failed:', payload.error || response.statusText)
|
||||||
|
return { ok: false, error: payload.error || response.statusText }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Memory Slack notification failed:', error.message)
|
||||||
|
return { ok: false, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImportSource(sourceUrl) {
|
||||||
|
if (!sourceUrl) return ''
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(sourceUrl)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Source URL is invalid')
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw new Error('Only http(s) imports are supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'FreeclawMemoryCloud/1.0 (+memory.friborg.uk)' },
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Import fetch failed: HTTP ${response.status}`)
|
||||||
|
const raw = (await response.text()).slice(0, FETCH_MAX_CHARS)
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
return contentType.includes('html') ? stripHtml(raw) : raw.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendDailyEntry({ heading, body, notify }) {
|
||||||
|
const filename = `${todayDateStamp()}.md`
|
||||||
|
const filePath = resolveDailyMemoryPath(filename)
|
||||||
|
const existing = filePath && fs.existsSync(filePath) ? await fsp.readFile(filePath, 'utf8') : ''
|
||||||
|
const prefix = existing && !existing.endsWith('\n') ? '\n' : ''
|
||||||
|
const content = `${existing}${prefix}${body}`
|
||||||
|
await writeThrough(filename, content, notify)
|
||||||
|
return { filename, heading }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLlmContextPayload(docs = [], query = '', limit = 6) {
|
||||||
|
const tokens = tokenize(query)
|
||||||
|
const ranked = docs
|
||||||
|
.filter((doc) => isValidMemoryFilename(doc.filename))
|
||||||
|
.map((doc) => {
|
||||||
|
const content = doc.content || ''
|
||||||
|
const tags = generateTags(content, doc.filename)
|
||||||
|
const score = scoreDocumentForQuery(doc, tokens)
|
||||||
|
return {
|
||||||
|
filename: doc.filename,
|
||||||
|
kind: kindForFilename(doc.filename),
|
||||||
|
dateLabel: doc.filename === 'MEMORY.md' ? 'Core memory' : formatDateLabel(doc.filename),
|
||||||
|
tags,
|
||||||
|
mood: detectMood(content, tags),
|
||||||
|
score,
|
||||||
|
snippet: redactSensitive(snippetForQuery(content, tokens, doc.filename === 'MEMORY.md' ? 1200 : 800)),
|
||||||
|
updatedAt: doc.$updatedAt || null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.score > 0 || !tokens.length)
|
||||||
|
.sort((a, b) => b.score - a.score || String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
||||||
|
.slice(0, Math.max(1, Math.min(Number(limit) || 6, 12)))
|
||||||
|
|
||||||
|
const context = ranked.map((entry) => [
|
||||||
|
`## ${entry.dateLabel} (${entry.filename})`,
|
||||||
|
`Tags: ${entry.tags.join(', ') || 'none'} | Mood: ${entry.mood} | Score: ${entry.score}`,
|
||||||
|
entry.snippet,
|
||||||
|
].join('\n')).join('\n\n---\n\n')
|
||||||
|
|
||||||
|
return { query, generatedAt: new Date().toISOString(), count: ranked.length, results: ranked, context }
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/health', async (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureSync()
|
await ensureSync()
|
||||||
await runAppwrite(['databases', 'get-collection', '--database-id', APPWRITE_DATABASE_ID, '--collection-id', APPWRITE_COLLECTION_ID])
|
await runAppwrite(['databases', 'get-document', '--database-id', APPWRITE_DATABASE_ID, '--collection-id', APPWRITE_COLLECTION_ID, '--document-id', 'MEMORY.md'])
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
appwrite: true,
|
appwrite: true,
|
||||||
@@ -386,6 +720,15 @@ app.get('/api/health', async (req, res) => {
|
|||||||
mainMemory: fs.existsSync(MAIN_MEMORY_FILE),
|
mainMemory: fs.existsSync(MAIN_MEMORY_FILE),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAppwriteScopeError(error)) {
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
appwrite: false,
|
||||||
|
fallback: true,
|
||||||
|
memoryDir: fs.existsSync(MEMORY_DIR),
|
||||||
|
mainMemory: fs.existsSync(MAIN_MEMORY_FILE),
|
||||||
|
})
|
||||||
|
}
|
||||||
res.status(500).json({ ok: false, error: error.message })
|
res.status(500).json({ ok: false, error: error.message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -469,11 +812,14 @@ app.get('/api/main-memory', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function writeThrough(filename, content) {
|
async function writeThrough(filename, content, notify = null) {
|
||||||
const filePath = resolveMemoryPath(filename)
|
const filePath = resolveMemoryPath(filename)
|
||||||
if (!filePath) throw new Error('Invalid memory filename')
|
if (!filePath) throw new Error('Invalid memory filename')
|
||||||
await fsp.writeFile(filePath, content, 'utf8')
|
await fsp.writeFile(filePath, content, 'utf8')
|
||||||
await upsertDocument(filename, content)
|
await upsertDocument(filename, content)
|
||||||
|
if (notify) {
|
||||||
|
await notifyMemoryChannel({ filename, ...notify })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.put('/api/main-memory', async (req, res) => {
|
app.put('/api/main-memory', async (req, res) => {
|
||||||
@@ -483,7 +829,7 @@ app.put('/api/main-memory', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Content is required' })
|
return res.status(400).json({ error: 'Content is required' })
|
||||||
}
|
}
|
||||||
await ensureSync()
|
await ensureSync()
|
||||||
await writeThrough('MEMORY.md', content)
|
await writeThrough('MEMORY.md', content, { title: 'Core memory replicated', summary: 'MEMORY.md was updated from the memory cockpit.' })
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message })
|
res.status(500).json({ error: error.message })
|
||||||
@@ -501,7 +847,7 @@ app.put('/api/memories/:filename', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Content is required' })
|
return res.status(400).json({ error: 'Content is required' })
|
||||||
}
|
}
|
||||||
await ensureSync()
|
await ensureSync()
|
||||||
await writeThrough(filename, content)
|
await writeThrough(filename, content, { title: 'Daily memory replicated', summary: `${filename} was updated from the memory cockpit.` })
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message })
|
res.status(500).json({ error: error.message })
|
||||||
@@ -541,6 +887,7 @@ app.get('/api/search', async (req, res) => {
|
|||||||
results.push({
|
results.push({
|
||||||
filename: doc.filename,
|
filename: doc.filename,
|
||||||
dateLabel: formatDateLabel(doc.filename),
|
dateLabel: formatDateLabel(doc.filename),
|
||||||
|
dateStamp: String(doc.filename || '').replace(/\.md$/, ''),
|
||||||
entryCount: getEntryCount(content),
|
entryCount: getEntryCount(content),
|
||||||
wordCount: wordCount(content),
|
wordCount: wordCount(content),
|
||||||
tags,
|
tags,
|
||||||
@@ -558,6 +905,23 @@ app.get('/api/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/semantic', async (req, res) => {
|
||||||
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return res.json({ query, results: [], total: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSync()
|
||||||
|
const docs = await listDocuments()
|
||||||
|
const querySemantic = buildSemanticResults(docs)
|
||||||
|
const results = querySemantic(query)
|
||||||
|
res.json({ query, results, total: results.length })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/log', async (req, res) => {
|
app.post('/api/log', async (req, res) => {
|
||||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -571,7 +935,7 @@ app.post('/api/log', async (req, res) => {
|
|||||||
const existing = filePath && fs.existsSync(filePath) ? await fsp.readFile(filePath, 'utf8') : ''
|
const existing = filePath && fs.existsSync(filePath) ? await fsp.readFile(filePath, 'utf8') : ''
|
||||||
const prefix = existing && !existing.endsWith('\n') ? '\n' : ''
|
const prefix = existing && !existing.endsWith('\n') ? '\n' : ''
|
||||||
const content = `${existing}${prefix}- [${currentTimeStamp()}] ${message}\n`
|
const content = `${existing}${prefix}- [${currentTimeStamp()}] ${message}\n`
|
||||||
await writeThrough(filename, content)
|
await writeThrough(filename, content, { title: 'Memory note replicated', summary: message })
|
||||||
res.json({ success: true, mode: 'appwrite' })
|
res.json({ success: true, mode: 'appwrite' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message })
|
res.status(500).json({ error: error.message })
|
||||||
@@ -655,13 +1019,92 @@ app.post('/api/distill', async (req, res) => {
|
|||||||
const dateStr = todayDateStamp()
|
const dateStr = todayDateStamp()
|
||||||
const sourceTag = sourceFilename ? ` (from ${sourceFilename})` : ''
|
const sourceTag = sourceFilename ? ` (from ${sourceFilename})` : ''
|
||||||
const distillEntry = `\n- **[${dateStr}]**${sourceTag} ${note.trim()}\n`
|
const distillEntry = `\n- **[${dateStr}]**${sourceTag} ${note.trim()}\n`
|
||||||
await writeThrough('MEMORY.md', `${existing}${distillEntry}`)
|
await writeThrough('MEMORY.md', `${existing}${distillEntry}`, { title: 'Distilled note replicated', source: sourceFilename || 'memory review', summary: note.trim() })
|
||||||
res.json({ success: true, appended: distillEntry.trim() })
|
res.json({ success: true, appended: distillEntry.trim() })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message })
|
res.status(500).json({ error: error.message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/platform', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureSync()
|
||||||
|
const docs = await listDocuments()
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
appwrite: true,
|
||||||
|
documents: docs.length,
|
||||||
|
notifications: {
|
||||||
|
slack: Boolean(SLACK_BOT_TOKEN && MEMORY_SLACK_CHANNEL_ID),
|
||||||
|
channelId: MEMORY_SLACK_CHANNEL_ID || null,
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
contextEndpoint: '/api/llm/context?q=your+question',
|
||||||
|
semanticEndpoint: '/api/semantic?q=your+question',
|
||||||
|
},
|
||||||
|
imports: {
|
||||||
|
enabled: true,
|
||||||
|
maxChars: IMPORT_MAX_CHARS,
|
||||||
|
supportsUrl: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ ok: false, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/llm/context', async (req, res) => {
|
||||||
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
||||||
|
const limit = Number.parseInt(req.query.limit || '6', 10)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSync()
|
||||||
|
const docs = await listDocuments()
|
||||||
|
res.json(buildLlmContextPayload(docs, query, limit))
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/import', async (req, res) => {
|
||||||
|
const { sourceTitle, sourceUrl, content, tags } = req.body || {}
|
||||||
|
const title = truncateText(sourceTitle || sourceUrl || 'Imported knowledge', 120)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSync()
|
||||||
|
const fetched = typeof content === 'string' && content.trim()
|
||||||
|
? content.trim()
|
||||||
|
: await fetchImportSource(sourceUrl)
|
||||||
|
|
||||||
|
const normalized = String(fetched || '').trim().slice(0, IMPORT_MAX_CHARS)
|
||||||
|
if (!normalized || normalized.length < 8) {
|
||||||
|
return res.status(400).json({ error: 'Import content is empty or too short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagLine = Array.isArray(tags) && tags.length
|
||||||
|
? `\n- Tags: ${tags.map((tag) => String(tag).trim()).filter(Boolean).join(', ')}`
|
||||||
|
: ''
|
||||||
|
const sourceLine = sourceUrl ? `\n- Source: ${sourceUrl}` : ''
|
||||||
|
const body = [
|
||||||
|
`\n## Knowledge import — ${title}\n`,
|
||||||
|
`- [${currentTimeStamp()}] Imported into Memory Hub.${sourceLine}${tagLine}\n`,
|
||||||
|
'```text\n',
|
||||||
|
escapeFence(normalized.slice(0, IMPORT_MAX_CHARS)),
|
||||||
|
'\n```\n',
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const result = await appendDailyEntry({
|
||||||
|
heading: title,
|
||||||
|
body,
|
||||||
|
notify: { title: 'Knowledge import replicated', source: sourceUrl || 'manual paste', summary: title },
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, filename: result.filename, title, importedChars: normalized.length })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.use(express.static(DIST_DIR))
|
app.use(express.static(DIST_DIR))
|
||||||
app.get(/.*/, (req, res) => {
|
app.get(/.*/, (req, res) => {
|
||||||
res.sendFile(path.join(DIST_DIR, 'index.html'))
|
res.sendFile(path.join(DIST_DIR, 'index.html'))
|
||||||
|
|||||||
+208
-32
@@ -26,6 +26,12 @@ html, body, #root { min-height: 100%; }
|
|||||||
body { background: var(--bg); color: var(--text); }
|
body { background: var(--bg); color: var(--text); }
|
||||||
button, input, textarea { font: inherit; }
|
button, input, textarea { font: inherit; }
|
||||||
button { border: 0; }
|
button { border: 0; }
|
||||||
|
mark {
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 92%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 0.28rem;
|
||||||
|
padding: 0 0.14rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -55,7 +61,8 @@ button { border: 0; }
|
|||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.hero-grid,
|
.hero-grid,
|
||||||
.main-grid {
|
.main-grid,
|
||||||
|
.insight-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@@ -84,7 +91,7 @@ button { border: 0; }
|
|||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
max-width: 42rem;
|
max-width: 52rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,9 @@ button { border: 0; }
|
|||||||
.result-item,
|
.result-item,
|
||||||
.memory-content,
|
.memory-content,
|
||||||
.memory-editor,
|
.memory-editor,
|
||||||
.viewer-header {
|
.viewer-header,
|
||||||
|
.semantic-card,
|
||||||
|
.timeline-chip {
|
||||||
background: linear-gradient(180deg, rgba(23,26,29,0.96), rgba(20,23,27,0.96));
|
background: linear-gradient(180deg, rgba(23,26,29,0.96), rgba(20,23,27,0.96));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -112,7 +121,9 @@ button { border: 0; }
|
|||||||
.result-item,
|
.result-item,
|
||||||
.viewer-header,
|
.viewer-header,
|
||||||
.memory-content,
|
.memory-content,
|
||||||
.memory-editor {
|
.memory-editor,
|
||||||
|
.semantic-card,
|
||||||
|
.timeline-chip {
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +131,21 @@ button { border: 0; }
|
|||||||
.ghost-btn,
|
.ghost-btn,
|
||||||
.primary-btn,
|
.primary-btn,
|
||||||
.tag-pill,
|
.tag-pill,
|
||||||
.rail-row {
|
.rail-row,
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
.collection-card,
|
||||||
|
.semantic-card,
|
||||||
|
.timeline-chip,
|
||||||
|
.constellation-node {
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive-card:hover,
|
.interactive-card:hover,
|
||||||
.ghost-btn:hover,
|
.ghost-btn:hover,
|
||||||
.primary-btn:hover,
|
.primary-btn:hover,
|
||||||
.rail-row:hover {
|
.rail-row:hover,
|
||||||
|
.collection-card:hover,
|
||||||
|
.semantic-card:hover,
|
||||||
|
.timeline-chip:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
|
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
|
||||||
box-shadow: 0 16px 34px rgba(0,0,0,0.2);
|
box-shadow: 0 16px 34px rgba(0,0,0,0.2);
|
||||||
@@ -138,6 +156,11 @@ button { border: 0; }
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.panel { padding: 1rem; }
|
.panel { padding: 1rem; }
|
||||||
.accent-panel { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
|
.accent-panel { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
|
||||||
|
|
||||||
@@ -150,12 +173,23 @@ button { border: 0; }
|
|||||||
.lead-meta,
|
.lead-meta,
|
||||||
.file-age,
|
.file-age,
|
||||||
.result-meta,
|
.result-meta,
|
||||||
.theme-copy { color: var(--muted-2); font-size: 0.82rem; }
|
.theme-copy,
|
||||||
|
.semantic-empty,
|
||||||
|
.file-collection-line,
|
||||||
|
.collection-meta,
|
||||||
|
.timeline-readout,
|
||||||
|
.timeline-snippet,
|
||||||
|
.constellation-hover { color: var(--muted-2); font-size: 0.82rem; }
|
||||||
.lead-meta { margin-top: 0.65rem; }
|
.lead-meta { margin-top: 0.65rem; }
|
||||||
.lead-row,
|
.lead-row,
|
||||||
.theme-metrics,
|
.theme-metrics,
|
||||||
.viewer-meta,
|
.viewer-meta,
|
||||||
.collection-head {
|
.collection-head,
|
||||||
|
.timeline-focus,
|
||||||
|
.timeline-chip-meta,
|
||||||
|
.semantic-search-row,
|
||||||
|
.semantic-card-foot,
|
||||||
|
.constellation-scale {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -171,7 +205,11 @@ button { border: 0; }
|
|||||||
.result-head,
|
.result-head,
|
||||||
.file-card-top,
|
.file-card-top,
|
||||||
.section-heading,
|
.section-heading,
|
||||||
.compact-heading {
|
.compact-heading,
|
||||||
|
.file-stats,
|
||||||
|
.semantic-score,
|
||||||
|
.timeline-strip,
|
||||||
|
.timeline-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -189,13 +227,18 @@ button { border: 0; }
|
|||||||
.result-snippet,
|
.result-snippet,
|
||||||
.slash-hint,
|
.slash-hint,
|
||||||
.recap-line,
|
.recap-line,
|
||||||
.rail-metrics span { color: var(--muted); }
|
.rail-metrics span,
|
||||||
|
.timeline-meta span,
|
||||||
|
.timeline-chip-date,
|
||||||
|
.timeline-chip-meta span { color: var(--muted); }
|
||||||
.stat-row strong,
|
.stat-row strong,
|
||||||
.rail-metrics strong { font-size: 1.15rem; font-weight: 620; }
|
.rail-metrics strong,
|
||||||
|
.semantic-score { font-size: 1.15rem; font-weight: 620; }
|
||||||
|
|
||||||
.cluster-panel,
|
.cluster-panel,
|
||||||
.search-block,
|
.search-block,
|
||||||
.main-grid { margin-top: 1rem; }
|
.main-grid,
|
||||||
|
.semantic-panel { margin-top: 1rem; }
|
||||||
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
|
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
|
||||||
.collection-grid {
|
.collection-grid {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -203,26 +246,21 @@ button { border: 0; }
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
.collection-card {
|
.collection-card,
|
||||||
|
.semantic-card,
|
||||||
|
.timeline-chip {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: rgba(28,32,36,0.88);
|
background: rgba(28,32,36,0.88);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.95rem;
|
padding: 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
|
||||||
}
|
}
|
||||||
.collection-card:hover,
|
.collection-card.active,
|
||||||
.collection-card.active {
|
.timeline-chip.active {
|
||||||
transform: translateY(-2px);
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--accent-soft) 55%, rgba(28,32,36,0.92));
|
background: color-mix(in srgb, var(--accent-soft) 55%, rgba(28,32,36,0.92));
|
||||||
}
|
}
|
||||||
.collection-meta {
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.section-heading h2,
|
.section-heading h2,
|
||||||
.compact-heading h2 {
|
.compact-heading h2 {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
@@ -244,7 +282,8 @@ button { border: 0; }
|
|||||||
.rail-copy { min-width: 0; }
|
.rail-copy { min-width: 0; }
|
||||||
.rail-date,
|
.rail-date,
|
||||||
.file-label,
|
.file-label,
|
||||||
.result-name { font-weight: 620; letter-spacing: -0.01em; }
|
.result-name,
|
||||||
|
.timeline-date { font-weight: 620; letter-spacing: -0.01em; }
|
||||||
.rail-metrics { display: grid; text-align: right; min-width: 8rem; }
|
.rail-metrics { display: grid; text-align: right; min-width: 8rem; }
|
||||||
.rail-bar {
|
.rail-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -261,6 +300,77 @@ button { border: 0; }
|
|||||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.constellation-panel,
|
||||||
|
.timeline-panel,
|
||||||
|
.semantic-panel { min-height: 100%; }
|
||||||
|
.constellation-map {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: radial-gradient(circle at 30% 28%, rgba(255,255,255,0.03), transparent 18%);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.constellation-node { cursor: pointer; }
|
||||||
|
.constellation-node:hover { transform: scale(1.03); }
|
||||||
|
.constellation-scale {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.timeline-focus {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
background: rgba(12, 15, 19, 0.72);
|
||||||
|
}
|
||||||
|
.timeline-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.timeline-strip {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
.timeline-chip-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semantic-results {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.semantic-search-row {
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.semantic-loading {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
min-width: 4.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.semantic-card-foot {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.semantic-card-foot .tag-strip {
|
||||||
|
margin-top: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.semantic-empty { margin-top: 1rem; line-height: 1.55; }
|
||||||
|
|
||||||
.composer-form { margin-top: 1rem; display: flex; gap: 0.75rem; }
|
.composer-form { margin-top: 1rem; display: flex; gap: 0.75rem; }
|
||||||
.search-section { position: relative; margin: 1rem 0; }
|
.search-section { position: relative; margin: 1rem 0; }
|
||||||
.search-input,
|
.search-input,
|
||||||
@@ -309,7 +419,8 @@ button { border: 0; }
|
|||||||
.file-date { color: var(--accent-strong); font-size: 0.82rem; }
|
.file-date { color: var(--accent-strong); font-size: 0.82rem; }
|
||||||
.file-snippet,
|
.file-snippet,
|
||||||
.result-snippet { margin-top: 0.55rem; line-height: 1.6; }
|
.result-snippet { margin-top: 0.55rem; line-height: 1.6; }
|
||||||
.file-stats { margin-top: 0.75rem; display: flex; gap: 0.6rem; font-size: 0.82rem; }
|
.file-stats { margin-top: 0.75rem; gap: 0.6rem; font-size: 0.82rem; }
|
||||||
|
.file-collection-line { margin-top: 0.65rem; }
|
||||||
|
|
||||||
.tag-strip {
|
.tag-strip {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
@@ -410,21 +521,86 @@ kbd {
|
|||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.highlighted-content mark { box-shadow: inset 0 -1.1em 0 rgba(255,255,255,0.02); }
|
||||||
.muted-panel { color: var(--muted); }
|
.muted-panel { color: var(--muted); }
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.hero,
|
.hero,
|
||||||
.hero-grid,
|
.hero-grid,
|
||||||
.main-grid { grid-template-columns: 1fr; }
|
.main-grid,
|
||||||
|
.insight-grid { grid-template-columns: 1fr; }
|
||||||
.hero-actions { align-items: flex-start; }
|
.hero-actions { align-items: flex-start; }
|
||||||
|
.composer-form,
|
||||||
|
.semantic-search-row,
|
||||||
|
.timeline-focus,
|
||||||
|
.timeline-meta { flex-direction: column; align-items: stretch; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.app,
|
.app,
|
||||||
.viewer-shell { padding-left: 0.9rem; padding-right: 0.9rem; }
|
.viewer-shell { padding-left: 1rem; padding-right: 1rem; }
|
||||||
.composer-form,
|
.viewer-header { grid-template-columns: 1fr; }
|
||||||
.viewer-header { display: grid; grid-template-columns: 1fr; }
|
|
||||||
.files-grid,
|
.files-grid,
|
||||||
.search-results { grid-template-columns: 1fr; }
|
.search-results,
|
||||||
.rail-row { grid-template-columns: 1fr; }
|
.semantic-results,
|
||||||
|
.collection-grid,
|
||||||
|
.timeline-strip { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-panel { margin-top: 1rem; }
|
||||||
|
.platform-status {
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.platform-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.platform-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(28,32,36,0.72);
|
||||||
|
}
|
||||||
|
.platform-card-title {
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.platform-card p,
|
||||||
|
.platform-mini {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.platform-card code {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.28);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.import-card { align-content: start; }
|
||||||
|
.import-textarea {
|
||||||
|
min-height: 9.5rem;
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: rgba(8,10,13,0.78);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.platform-grid { grid-template-columns: 1fr; }
|
||||||
|
.platform-status { white-space: normal; }
|
||||||
}
|
}
|
||||||
|
|||||||
+355
-11
@@ -7,6 +7,10 @@ function formatDate(ts) {
|
|||||||
return new Date(ts).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
return new Date(ts).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateLong(ts) {
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelative(ts) {
|
function formatRelative(ts) {
|
||||||
const diff = Date.now() - ts
|
const diff = Date.now() - ts
|
||||||
const m = Math.floor(diff / 60000)
|
const m = Math.floor(diff / 60000)
|
||||||
@@ -45,6 +49,27 @@ function moodLabel(mood) {
|
|||||||
}[mood] || 'Steady'
|
}[mood] || 'Steady'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateValueForFile(file) {
|
||||||
|
const fromMeta = file?.dateStamp || file?.filename?.replace(/\.md$/, '')
|
||||||
|
const parsed = Date.parse(`${fromMeta}T12:00:00`)
|
||||||
|
return Number.isFinite(parsed) ? parsed : file?.mtimeMs || Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionMembership(file, collections = []) {
|
||||||
|
return collections.filter((collection) => collection.filenames?.includes(file.filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightText(text, query) {
|
||||||
|
if (!query?.trim()) return text
|
||||||
|
const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
if (!escaped) return text
|
||||||
|
const regex = new RegExp(`(${escaped})`, 'ig')
|
||||||
|
const parts = String(text || '').split(regex)
|
||||||
|
return parts.map((part, index) => regex.test(part)
|
||||||
|
? <mark key={`${part}-${index}`}>{part}</mark>
|
||||||
|
: <span key={`${part}-${index}`}>{part}</span>)
|
||||||
|
}
|
||||||
|
|
||||||
function TagStrip({ tags = [], onPick, activeTag }) {
|
function TagStrip({ tags = [], onPick, activeTag }) {
|
||||||
if (!tags.length) return null
|
if (!tags.length) return null
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +93,8 @@ function MoodBadge({ mood }) {
|
|||||||
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
|
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileCard({ file, onClick, onPickTag, activeTag }) {
|
function FileCard({ file, onClick, onPickTag, activeTag, collections = [] }) {
|
||||||
|
const membership = collectionMembership(file, collections)
|
||||||
return (
|
return (
|
||||||
<button className="file-card interactive-card" onClick={onClick}>
|
<button className="file-card interactive-card" onClick={onClick}>
|
||||||
<div className="file-card-top">
|
<div className="file-card-top">
|
||||||
@@ -78,6 +104,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
|
|||||||
<div className="file-label">{file.dateLabel}</div>
|
<div className="file-label">{file.dateLabel}</div>
|
||||||
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
|
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
|
||||||
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
|
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
|
||||||
|
{membership.length > 0 && <div className="file-collection-line">{membership.map((entry) => entry.label).join(' · ')}</div>}
|
||||||
<div className="file-stats">
|
<div className="file-stats">
|
||||||
<span>{file.wordCount.toLocaleString()} words</span>
|
<span>{file.wordCount.toLocaleString()} words</span>
|
||||||
<span>{file.entryCount} entries</span>
|
<span>{file.entryCount} entries</span>
|
||||||
@@ -87,7 +114,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
function Viewer({ selected, onBack, activeTag, onPickTag, highlightQuery }) {
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -158,7 +185,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
|||||||
) : isMainMemory ? (
|
) : isMainMemory ? (
|
||||||
<textarea className="memory-editor" value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false} />
|
<textarea className="memory-editor" value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false} />
|
||||||
) : (
|
) : (
|
||||||
<pre className="memory-content">{content}</pre>
|
<pre className="memory-content highlighted-content">{highlightText(content, highlightQuery)}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -241,6 +268,244 @@ function CollectionsPanel({ collections = [], onOpenCollection, activeCollection
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConstellationMap({ files = [], collections = [], activeCollection, onSelect }) {
|
||||||
|
const [hovered, setHovered] = useState(null)
|
||||||
|
|
||||||
|
const points = useMemo(() => {
|
||||||
|
if (!files.length) return []
|
||||||
|
const ordered = [...files].sort((a, b) => dateValueForFile(a) - dateValueForFile(b))
|
||||||
|
const minDate = dateValueForFile(ordered[0])
|
||||||
|
const maxDate = dateValueForFile(ordered[ordered.length - 1])
|
||||||
|
const maxWords = Math.max(...ordered.map((file) => file.wordCount), 1)
|
||||||
|
const moods = { focused: 0.2, playful: 0.34, reflective: 0.52, warm: 0.68, tense: 0.84, steady: 0.5 }
|
||||||
|
|
||||||
|
return ordered.map((file, index) => {
|
||||||
|
const memberships = collectionMembership(file, collections)
|
||||||
|
const timeRatio = maxDate === minDate ? 0.5 : (dateValueForFile(file) - minDate) / (maxDate - minDate)
|
||||||
|
const moodBase = moods[file.mood] ?? 0.5
|
||||||
|
const tagWeight = Math.min((file.tags?.length || 0) / 8, 0.16)
|
||||||
|
const yRatio = Math.max(0.12, Math.min(0.88, moodBase - tagWeight + ((index % 3) - 1) * 0.04))
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
memberships,
|
||||||
|
x: 56 + timeRatio * 728,
|
||||||
|
y: 42 + yRatio * 214,
|
||||||
|
r: 7 + Math.min(file.entryCount, 8) * 1.4 + Math.min(file.wordCount / maxWords, 1) * 8,
|
||||||
|
active: activeCollection ? activeCollection.filenames?.includes(file.filename) : false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [files, collections, activeCollection])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel constellation-panel interactive-card">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">constellation mode</div>
|
||||||
|
<h2>Clusters, moods, and density across time.</h2>
|
||||||
|
</div>
|
||||||
|
{hovered ? <div className="constellation-hover">{hovered.file.dateLabel} · {hovered.file.wordCount} words</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg className="constellation-map" viewBox="0 0 840 300" role="img" aria-label="Memory constellation map">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="constellation-line" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(255,255,255,0.06)" />
|
||||||
|
<stop offset="50%" stopColor="var(--accent)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(255,255,255,0.06)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M56 246 H784" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
|
||||||
|
<path d="M56 42 V246" stroke="rgba(255,255,255,0.06)" strokeWidth="1" />
|
||||||
|
{[0.22, 0.5, 0.78].map((ratio) => <path key={ratio} d={`M56 ${42 + 214 * ratio} H784`} stroke="rgba(255,255,255,0.04)" strokeWidth="1" />)}
|
||||||
|
{points.map((point, index) => {
|
||||||
|
const next = points[index + 1]
|
||||||
|
if (!next) return null
|
||||||
|
return <line key={`${point.file.filename}-${next.file.filename}`} x1={point.x} y1={point.y} x2={next.x} y2={next.y} stroke="url(#constellation-line)" strokeWidth="1.2" opacity="0.38" />
|
||||||
|
})}
|
||||||
|
{points.map((point) => (
|
||||||
|
<g
|
||||||
|
key={point.file.filename}
|
||||||
|
className="constellation-node"
|
||||||
|
onMouseEnter={() => setHovered(point)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
onClick={() => onSelect(point.file)}
|
||||||
|
>
|
||||||
|
{point.active ? <circle cx={point.x} cy={point.y} r={point.r + 11} fill="var(--accent-soft)" opacity="0.55" /> : null}
|
||||||
|
<circle cx={point.x} cy={point.y} r={point.r + 4} fill="var(--accent-glow)" opacity="0.28" />
|
||||||
|
<circle cx={point.x} cy={point.y} r={point.r} fill="var(--panel-2)" stroke="var(--accent-strong)" strokeWidth={point.active ? 2.5 : 1.3} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="constellation-scale">
|
||||||
|
<span>older</span>
|
||||||
|
<span>now</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineScrubber({ files = [], activeCollection, onSelect }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIndex(0)
|
||||||
|
}, [files.length, activeCollection?.id])
|
||||||
|
|
||||||
|
const focused = files[index] || null
|
||||||
|
const windowed = useMemo(() => {
|
||||||
|
if (!files.length) return []
|
||||||
|
const start = Math.max(0, index - 2)
|
||||||
|
return files.slice(start, start + 5)
|
||||||
|
}, [files, index])
|
||||||
|
|
||||||
|
if (!files.length || !focused) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel timeline-panel interactive-card">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">timeline scrubber</div>
|
||||||
|
<h2>{activeCollection ? `${activeCollection.label}, over time.` : 'Slide through the archive.'}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-readout">{focused.dateLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="timeline-slider"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={Math.max(files.length - 1, 0)}
|
||||||
|
value={index}
|
||||||
|
onChange={(event) => setIndex(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="timeline-focus">
|
||||||
|
<div>
|
||||||
|
<div className="timeline-date">{formatDateLong(dateValueForFile(focused))}</div>
|
||||||
|
<div className="timeline-snippet">{focused.tags?.[0] ? `It bent toward ${focused.tags[0]}.` : 'A quieter node in the archive.'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-meta">
|
||||||
|
<MoodBadge mood={focused.mood} />
|
||||||
|
<span>{focused.wordCount} words</span>
|
||||||
|
<button className="ghost-btn small-btn" onClick={() => onSelect(focused)}>Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline-strip">
|
||||||
|
{windowed.map((file) => (
|
||||||
|
<button key={file.filename} className={`timeline-chip ${file.filename === focused.filename ? 'active' : ''}`} onClick={() => setIndex(files.findIndex((entry) => entry.filename === file.filename))}>
|
||||||
|
<div className="timeline-chip-date">{file.dateLabel}</div>
|
||||||
|
<div className="timeline-chip-meta">
|
||||||
|
<MoodBadge mood={file.mood} />
|
||||||
|
<span>{file.entryCount} entries</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SemanticView({ query, onQueryChange, results = [], loading, onSelect, onPickTag, activeTag }) {
|
||||||
|
return (
|
||||||
|
<section className="panel semantic-panel interactive-card">
|
||||||
|
<div className="section-heading compact-heading">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">semantic view</div>
|
||||||
|
<h2>Show me notes about…</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="semantic-search-row">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder="appwrite migration, family stuff, auth fixes, design streak…"
|
||||||
|
/>
|
||||||
|
{loading ? <span className="semantic-loading">Thinking…</span> : null}
|
||||||
|
</div>
|
||||||
|
{query.trim().length < 2 ? (
|
||||||
|
<div className="semantic-empty">Try a fuzzy prompt. It leans on tags, collections, moods, and text, not just exact matches.</div>
|
||||||
|
) : results.length ? (
|
||||||
|
<div className="semantic-results">
|
||||||
|
{results.map((result) => (
|
||||||
|
<button key={result.filename} className="semantic-card" onClick={() => onSelect(result)}>
|
||||||
|
<div className="result-head">
|
||||||
|
<div>
|
||||||
|
<div className="result-name">{result.dateLabel}</div>
|
||||||
|
<div className="result-meta">{result.why || 'Strong semantic match'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="semantic-score">{result.score}</div>
|
||||||
|
</div>
|
||||||
|
<div className="result-snippet">{result.snippet}</div>
|
||||||
|
<div className="semantic-card-foot">
|
||||||
|
<MoodBadge mood={result.mood} />
|
||||||
|
<TagStrip tags={result.tags} onPick={onPickTag} activeTag={activeTag} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="semantic-empty">No strong semantic threads for “{query}” yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformPanel({ platform, importDraft, setImportDraft, importState, onImport }) {
|
||||||
|
return (
|
||||||
|
<section className="panel platform-panel interactive-card">
|
||||||
|
<div className="section-heading compact-heading">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">llm memory platform</div>
|
||||||
|
<h2>Feed the archive. Let local models ask it questions.</h2>
|
||||||
|
</div>
|
||||||
|
<div className="platform-status">
|
||||||
|
{platform?.notifications?.slack ? 'Slack notifications on' : 'Slack notifications pending'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="platform-grid">
|
||||||
|
<div className="platform-card">
|
||||||
|
<div className="platform-card-title">Local LLM context bridge</div>
|
||||||
|
<p>Point agents, LibreChat tools, or local model routers at this endpoint to retrieve memory-shaped context.</p>
|
||||||
|
<code>{platform?.llm?.contextEndpoint || '/api/llm/context?q=your+question'}</code>
|
||||||
|
<div className="platform-mini">Semantic search stays available at <code>/api/semantic</code>.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="platform-card import-card" onSubmit={onImport}>
|
||||||
|
<div className="platform-card-title">Import knowledge</div>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Title or source name"
|
||||||
|
value={importDraft.sourceTitle}
|
||||||
|
onChange={(event) => setImportDraft((draft) => ({ ...draft, sourceTitle: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
type="url"
|
||||||
|
placeholder="Optional source URL"
|
||||||
|
value={importDraft.sourceUrl}
|
||||||
|
onChange={(event) => setImportDraft((draft) => ({ ...draft, sourceUrl: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="import-textarea"
|
||||||
|
placeholder="Paste notes, docs, prompts, model findings, or leave blank to import the URL text."
|
||||||
|
value={importDraft.content}
|
||||||
|
onChange={(event) => setImportDraft((draft) => ({ ...draft, content: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<button className="primary-btn" type="submit" disabled={importState === 'saving' || (!importDraft.content.trim() && !importDraft.sourceUrl.trim())}>
|
||||||
|
{importState === 'saving' ? 'Importing…' : importState === 'saved' ? 'Imported' : importState === 'error' ? 'Retry import' : 'Import into memory'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [meta, setMeta] = useState(null)
|
const [meta, setMeta] = useState(null)
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
@@ -255,11 +520,18 @@ export default function App() {
|
|||||||
const [activeTag, setActiveTag] = useState('')
|
const [activeTag, setActiveTag] = useState('')
|
||||||
const [activeCollection, setActiveCollection] = useState(null)
|
const [activeCollection, setActiveCollection] = useState(null)
|
||||||
const [pointer, setPointer] = useState({ x: 18, y: 12 })
|
const [pointer, setPointer] = useState({ x: 18, y: 12 })
|
||||||
|
const [semanticQuery, setSemanticQuery] = useState('')
|
||||||
|
const [semanticResults, setSemanticResults] = useState([])
|
||||||
|
const [semanticLoading, setSemanticLoading] = useState(false)
|
||||||
|
const [platform, setPlatform] = useState(null)
|
||||||
|
const [importDraft, setImportDraft] = useState({ sourceTitle: '', sourceUrl: '', content: '' })
|
||||||
|
const [importState, setImportState] = useState('idle')
|
||||||
const searchRef = useRef(null)
|
const searchRef = useRef(null)
|
||||||
|
|
||||||
const refreshMeta = () => {
|
const refreshMeta = () => {
|
||||||
fetch(`${API}/api/meta`).then((r) => r.json()).then(setMeta)
|
fetch(`${API}/api/meta`).then((r) => r.json()).then(setMeta)
|
||||||
fetch(`${API}/api/review-count`).then((r) => r.json()).then((d) => setReviewCount(d.count ?? 0)).catch(() => setReviewCount(null))
|
fetch(`${API}/api/review-count`).then((r) => r.json()).then((d) => setReviewCount(d.count ?? 0)).catch(() => setReviewCount(null))
|
||||||
|
fetch(`${API}/api/platform`).then((r) => r.json()).then(setPlatform).catch(() => setPlatform(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -311,6 +583,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeCollection) {
|
if (activeCollection) {
|
||||||
setQuery(activeCollection.tags.join(' '))
|
setQuery(activeCollection.tags.join(' '))
|
||||||
|
setSemanticQuery(activeCollection.label)
|
||||||
}
|
}
|
||||||
}, [activeCollection])
|
}, [activeCollection])
|
||||||
|
|
||||||
@@ -344,6 +617,26 @@ export default function App() {
|
|||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!semanticQuery.trim() || semanticQuery.trim().length < 2) {
|
||||||
|
setSemanticResults([])
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
setSemanticLoading(true)
|
||||||
|
try {
|
||||||
|
const payload = await fetch(`${API}/api/semantic?q=${encodeURIComponent(semanticQuery)}`).then((r) => r.json())
|
||||||
|
setSemanticResults(payload.results || [])
|
||||||
|
} catch {
|
||||||
|
setSemanticResults([])
|
||||||
|
}
|
||||||
|
setSemanticLoading(false)
|
||||||
|
}, 220)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [semanticQuery])
|
||||||
|
|
||||||
const heroStats = useMemo(() => {
|
const heroStats = useMemo(() => {
|
||||||
if (!meta?.dailyFiles?.length) return null
|
if (!meta?.dailyFiles?.length) return null
|
||||||
const latest = meta.dailyFiles[0]
|
const latest = meta.dailyFiles[0]
|
||||||
@@ -373,6 +666,13 @@ export default function App() {
|
|||||||
'--pointer-y': `${pointer.y}%`,
|
'--pointer-y': `${pointer.y}%`,
|
||||||
}), [meta, pointer])
|
}), [meta, pointer])
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => {
|
||||||
|
const files = meta?.dailyFiles || []
|
||||||
|
if (!activeCollection) return files
|
||||||
|
const allowed = new Set(activeCollection.filenames || [])
|
||||||
|
return files.filter((file) => allowed.has(file.filename))
|
||||||
|
}, [meta, activeCollection])
|
||||||
|
|
||||||
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
|
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
|
||||||
|
|
||||||
const submitLog = async (e) => {
|
const submitLog = async (e) => {
|
||||||
@@ -396,8 +696,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const submitImport = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!importDraft.content.trim() && !importDraft.sourceUrl.trim()) return
|
||||||
|
setImportState('saving')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(importDraft),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('import failed')
|
||||||
|
setImportDraft({ sourceTitle: '', sourceUrl: '', content: '' })
|
||||||
|
setImportState('saved')
|
||||||
|
refreshMeta()
|
||||||
|
setTimeout(() => setImportState('idle'), 1800)
|
||||||
|
} catch {
|
||||||
|
setImportState('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return <Viewer selected={selected} onBack={() => setSelected(null)} activeTag={activeTag} onPickTag={setActiveTag} />
|
return <Viewer selected={selected} onBack={() => setSelected(null)} activeTag={activeTag} onPickTag={setActiveTag} highlightQuery={query || semanticQuery} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -408,7 +729,7 @@ export default function App() {
|
|||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<div className="eyebrow">freeclaw memory cloud</div>
|
<div className="eyebrow">freeclaw memory cloud</div>
|
||||||
<h1>Memory with a pulse.</h1>
|
<h1>Memory with a pulse.</h1>
|
||||||
<p className="subtitle">Now with merged tags, mood sensing, and self-assembling collections.</p>
|
<p className="subtitle">Now with constellation mode, a scrub-through timeline, semantic memory views, and cleaner bridges between the archive and the ideas inside it.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<button
|
<button
|
||||||
@@ -457,8 +778,13 @@ export default function App() {
|
|||||||
<TagStrip tags={smartClusters} onPick={setActiveTag} activeTag={activeTag} />
|
<TagStrip tags={smartClusters} onPick={setActiveTag} activeTag={activeTag} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="insight-grid">
|
||||||
|
<ConstellationMap files={filteredFiles} collections={meta?.collections} activeCollection={activeCollection} onSelect={openFile} />
|
||||||
|
<TimelineScrubber files={filteredFiles} activeCollection={activeCollection} onSelect={openFile} />
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="main-grid">
|
<div className="main-grid">
|
||||||
<ActivityRail files={meta?.dailyFiles} onSelect={openFile} onPickTag={setActiveTag} activeTag={activeTag} />
|
<ActivityRail files={filteredFiles} onSelect={openFile} onPickTag={setActiveTag} activeTag={activeTag} />
|
||||||
|
|
||||||
<section className="panel composer-panel interactive-card">
|
<section className="panel composer-panel interactive-card">
|
||||||
<div className="section-heading compact-heading">
|
<div className="section-heading compact-heading">
|
||||||
@@ -469,7 +795,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<form className="composer-form" onSubmit={submitLog}>
|
<form className="composer-form" onSubmit={submitLog}>
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
|
||||||
className="search-input composer-input"
|
className="search-input composer-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="A note, decision, odd clue, or tiny win…"
|
placeholder="A note, decision, odd clue, or tiny win…"
|
||||||
@@ -486,18 +811,37 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PlatformPanel
|
||||||
|
platform={platform}
|
||||||
|
importDraft={importDraft}
|
||||||
|
setImportDraft={setImportDraft}
|
||||||
|
importState={importState}
|
||||||
|
onImport={submitImport}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SemanticView
|
||||||
|
query={semanticQuery}
|
||||||
|
onQueryChange={setSemanticQuery}
|
||||||
|
results={semanticResults}
|
||||||
|
loading={semanticLoading}
|
||||||
|
onSelect={(result) => openFile(result)}
|
||||||
|
onPickTag={setActiveTag}
|
||||||
|
activeTag={activeTag}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="search-block">
|
<section className="search-block">
|
||||||
<div className="section-heading compact-heading">
|
<div className="section-heading compact-heading">
|
||||||
<div>
|
<div>
|
||||||
<div className="section-kicker">search</div>
|
<div className="section-kicker">search</div>
|
||||||
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
|
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
|
||||||
</div>
|
</div>
|
||||||
{(activeTag || activeCollection) && (
|
{(activeTag || activeCollection || semanticQuery) && (
|
||||||
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery('') }}>Clear filters</button>
|
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery(''); setSemanticQuery('') }}>Clear filters</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="search-section">
|
<div className="search-section">
|
||||||
<input
|
<input
|
||||||
|
ref={searchRef}
|
||||||
className="search-input"
|
className="search-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search memory files…"
|
placeholder="Search memory files…"
|
||||||
@@ -528,8 +872,8 @@ export default function App() {
|
|||||||
|
|
||||||
{searchResults.length === 0 && !searching && query.length < 2 && meta && (
|
{searchResults.length === 0 && !searching && query.length < 2 && meta && (
|
||||||
<div className="files-grid">
|
<div className="files-grid">
|
||||||
{meta.dailyFiles.map((file) => (
|
{filteredFiles.map((file) => (
|
||||||
<FileCard key={file.filename} file={file} onClick={() => openFile(file)} onPickTag={setActiveTag} activeTag={activeTag} />
|
<FileCard key={file.filename} file={file} collections={meta?.collections} onClick={() => openFile(file)} onPickTag={setActiveTag} activeTag={activeTag} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user