// Relate space worker: one space = one process. Connectors → store → Living State / todos / RAG / MCP / // dashboard. The hub (hub.js) writes this space's config and starts it with RELATE_CONFIG=. import http from 'node:http'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, extname } from 'node:path'; import { CFG, ROOT, DATA_DIR, CONTEXT_TYPE, IS_BIZLIKE } from './lib/config.js'; import { createSources } from './lib/sources.js'; import { activatePlugins, pluginConnectorTypes } from './lib/plugins.js'; import * as S from './lib/store.js'; import { analyzeBatch, extractTodos, reconcileTodos, dedupeTodos, draftReply, askAssistant, askAssistantStream, askWithData, censusAnalyze, chatTitle, relationshipCheckup, regenerateArchive, regeneratePerspectives, observeLiving, groundLiving, groundBusiness, aiStats, aiBudget, aiDaily } from './lib/ai.js'; import { detectCensus, collectCensus } from './lib/census.js'; import * as chats from './lib/chats.js'; import { listNotes, addNote, deleteNote, notesText, extractRemember } from './lib/notes.js'; import { registerTool, executeTool, hasTool } from './lib/tools.js'; import { setExecutor, listApprovals, pendingApprovals, pendingCount, approveAction, rejectAction, addApproval } from './lib/approvals.js'; import { living, loadLiving, saveLiving, dismissLiving, activeMemory, dismissedList } from './lib/living.js'; import { sendPush } from './lib/notify.js'; import { vapidPublic, addSub, subCount } from './lib/webpush.js'; import * as auth from './lib/auth.js'; import { reminders, loadReminders, mergeExtracted, applyReconcile, collapseDuplicates, mergeAway, retireSensitive, markDone, clearDone, listReminders } from './lib/reminders.js'; import { windowDocsFrom } from './lib/windows.js'; import { Corpus } from './lib/rag.js'; import { initMcp, handleMcp } from './lib/mcp.js'; import { computeAnalytics, behaviorSnapshot } from './lib/analytics.js'; import { relTrends, trendText } from './lib/reltrends.js'; import { bizTrends, bizMood, bizMoodText } from './lib/biztrends.js'; import { computeGame } from './lib/game.js'; const PUB = join(ROOT, 'public'); const clients = new Set(); let sources = null; // connector runtime (lib/sources.js) let plugins = { route: () => null, contextText: () => '', names: [] }; // lib/plugins.js runtime let corpus = new Corpus(); const readData = (f, dflt = null) => { try { return JSON.parse(readFileSync(join(DATA_DIR, f), 'utf8')); } catch { return dflt; } }; const writeData = (f, v) => { try { writeFileSync(join(DATA_DIR, f), JSON.stringify(v)); } catch {} }; let gameState = readData('game.json', { questDone: {} }); let narrative = readData('narrative.json'); let regen = readData('regen.json'); let checkup = readData('checkup.json'); // the space's chat allowlist (the hub writes each entry's store session id) + a name map for labeling const SESSIONS = new Map(); // sid -> {name, kind} const NAMES = {}; for (const c of CFG.chats || []) { const sid = c.session || c.chatId; SESSIONS.set(sid, { name: c.name || sid, kind: c.kind || 'dm' }); if (c.name) NAMES[sid] = c.name; } let groundTimer = null; function bumpGroundSoon() { if (groundTimer) return; groundTimer = setTimeout(() => { groundTimer = null; groundBizTick(); }, 90000); } // a "deep" question gets more retrieved windows + a longer memory slice const DEEP_RE = /深入|详细|彻底|全面|系统|完整|仔细|好好|认真|所有|每一|每个|逐条|统计|挨个|分析一下|深挖|deep|thorough|detailed|comprehensive|in depth/i; const isDeep = (q) => DEEP_RE.test(String(q || '')) || String(q || '').length > 40; // Assemble the grounding context the advisor/checkup reasons from: Living State + real stats. function assembleCtx() { let snapshot = null; // behavioral snapshot uses the relationship baseline — skip it for multi-chat spaces if (!IS_BIZLIKE) { try { snapshot = behaviorSnapshot(computeAnalytics(S.store.messages), CFG.target, S.store.messages); } catch {} } // the actual latest messages, verbatim, so "right now" questions see the real exchange (not just a summary) const recent = S.store.messages.slice(-60) .filter((m) => String(m.content || '').trim()) .slice(-30) .map((m) => ({ who: m.isSend ? CFG.me.name : (m.senderName || CFG.target?.name || '对方'), text: (m.kind && m.sessionName ? `[${m.sessionName}] ` : '') + String(m.content).slice(0, 200), ts: m.ts, })); const fin = finishedItems(); const done = fin.length ? '【最近已完成/已处理(别再当作待办、承诺、未完成或重复提醒)】\n' + fin.map((t) => '· ' + t).join('\n') : null; let trends = null, contacts = null; if (!IS_BIZLIKE) { try { trends = trendText(relTrends(S.store.messages, Date.now())) || null; } catch {} } else { try { contacts = bizMoodText(bizMood(S.store.messages, SESSIONS, Date.now())) || null; } catch {} } return { memory: living.memory, narrative: (narrative?.narrative || living.narrative), observation: living.observation, snapshot, recent, self: CFG.me.about || null, metrics: plugins.contextText() || null, notes: notesText() || null, done, trends, contacts }; } // recently finished work — ticked-off Living-State lines + completed todos — so the AI treats them as done function finishedItems() { const dismissed = dismissedList().map((d) => d.text); let doneTodos = []; try { doneTodos = (listReminders().done || []).slice(0, 12).map((r) => r.text); } catch {} return [...new Set([...dismissed, ...doneTodos])].filter(Boolean).slice(0, 20); } let voiceChatId = null; // persistent chat for the voice endpoint (continuous conversation) // flatten markdown to plain text so a voice assistant reads it naturally (no "asterisk asterisk", pipes, headers…) function stripMarkdown(s) { return String(s || '') .replace(/```[\s\S]*?```/g, '').replace(/`([^`]*)`/g, '$1') .replace(/^\s*#{1,6}\s*/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1') .replace(/^\s*[-*+]\s+/gm, '').replace(/^\s*\d+\.\s+/gm, '') .replace(/^\s*[-:|\s]{3,}\s*$/gm, '').replace(/\|/g, ' ') .replace(/\[(\d+)\]/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); } function log(...a) { console.log(new Date().toISOString().slice(11, 19), ...a); } function broadcast(event, data) { const p = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const res of clients) { try { res.write(p); } catch {} } } function buildCorpus() { const docs = windowDocsFrom(S.store.messages); corpus = new Corpus(); for (const d of docs) corpus.add(d); sources?.reindexEpisodes(corpus); corpus.finalize(); log(`RAG corpus ${corpus.size} conversation windows · ${S.store.messages.length} total messages`); } // ---- AI loops (low frequency, hourly-capped) ---- let busy = { an: false, sum: false, rem: false }; async function analysisTick() { if (busy.an || !CFG.ai.enabled || noViewer()) return; busy.an = true; // pause when nobody's watching (cost) try { const batch = S.pendingAnalysis(CFG.ai.batchSize); if (batch.length) { const r = await analyzeBatch(batch); let k = 0; r.forEach((x, i) => { if (x) { batch[i].analysis = x; k++; } }); if (k) broadcast('stats', S.snapshot()); } } catch (e) { log('analysis err', e.message); } finally { busy.an = false; } } // one-shot burst: analyze recent unanalyzed messages fast (fills the sentiment trend now instead of // waiting for the slow loop). Newest-first, bounded by weeks + a batch cap. let backfilling = false; async function backfillAnalysis(weeks = 8, maxBatches = 600) { if (backfilling || !CFG.ai.enabled) return { ok: false }; backfilling = true; let done = 0; const cutoff = Date.now() - weeks * 7 * 86400000; try { for (let n = 0; n < maxBatches; n++) { const batch = S.pendingAnalysis(CFG.ai.batchSize).filter((m) => m.ts >= cutoff); if (!batch.length) break; const r = await analyzeBatch(batch); let k = 0; r.forEach((x, i) => { if (x) { batch[i].analysis = x; k++; } }); if (!k) break; // rate-limited / failing — stop rather than spin done += k; if (n % 8 === 0) broadcast('stats', S.snapshot()); } } finally { backfilling = false; } broadcast('stats', S.snapshot()); log(`backfill analysis: +${done} msgs (${weeks}w window)`); return { ok: true, analyzed: done }; } // pause the expensive AI loops when nobody is viewing the dashboard (huge idle-cost saver) const noViewer = () => CFG.ai.pauseWhenNoViewer && clients.size === 0; let sumMark = -1, remMark = -1; async function summaryTick() { if (busy.sum || !CFG.ai.enabled) return; // seed the Living State once on boot even with no viewer (so the board/observation isn't empty); // after that, pause when nobody's watching to save cost. Multi-chat spaces use groundBizTick instead. const seedOnce = !living.at && !IS_BIZLIKE; if (!seedOnce && noViewer()) return; if (S.store.messages.length === sumMark) return; // idle-skip: nothing new sumMark = S.store.messages.length; busy.sum = true; try { // incremental: fold recent messages into the Living State memory + produce 实时观察. // multi-chat spaces need a wider recent window than a 1:1 relationship. const tail = S.store.messages.slice(IS_BIZLIKE ? -60 : -20); const r = await observeLiving(living.memory, tail); if (r) { living.memory = r.memory; living.at = Date.now(); if (r.observation) { living.observation = r.observation; saveLiving(); S.store.ai.insight = r.observation; S.store.ai.insightAt = Date.now(); broadcast('insight', { insight: r.observation, at: S.store.ai.insightAt }); } else saveLiving(); } else log('observe: null (parse/empty/rate-limited)'); } catch (e) { log('observe err', e.message); } finally { busy.sum = false; } } // adaptive 实时观察 cadence: calm ~5min, warming ~2min, heat/crisis ~30s (near real-time) let summaryTimer = null, summaryFireAt = 0; function recentHeat() { const now = Date.now(); let h = 0, care = false; for (const m of S.store.messages) if (m.ts >= now - 8 * 60000) { h += (m._tag?.conflict || 0); if (m._tag?.care) care = true; } return { h, care }; } function summaryDelay() { const { h, care } = recentHeat(); if (care || h >= 15) return CFG.ai.summaryHeatMs || 30000; // 激烈/危机 → 近实时 if (h >= 5) return 120000; // 升温 → 2分钟 return CFG.ai.summaryCalmMs || 300000; // 平静 → 5分钟 } function armSummary(delay) { if (summaryTimer) clearTimeout(summaryTimer); summaryFireAt = Date.now() + delay; summaryTimer = setTimeout(async () => { await summaryTick(); armSummary(summaryDelay()); }, delay); } // event-driven: a conflict/care message just arrived → pull the next observation in soon function bumpSummaryOnHeat(tag) { if (!tag || !(tag.conflict > 0 || tag.care)) return; const soon = Date.now() + (CFG.ai.summaryHeatMs || 30000); if (soon < summaryFireAt) armSummary(CFG.ai.summaryHeatMs || 30000); } async function reminderTick() { if (busy.rem || !CFG.ai.enabled) return; const seedOnce = remMark === -1 && !reminders.size; // seed todos once on boot even w/o viewer if (!seedOnce && noViewer()) return; if (S.store.messages.length === remMark) return; // idle-skip: nothing new remMark = S.store.messages.length; busy.rem = true; try { const label = CFG.target.name || CFG.name || ''; const items = await extractTodos(S.store.messages, label, !IS_BIZLIKE); const added = items.length ? mergeExtracted(items, Date.now(), 'main', label) : 0; if (added) log(`+${added} todos`); broadcast('reminders', listReminders()); } catch (e) { log('reminder err', e.message); } finally { busy.rem = false; } } // daily re-grounding: rebuild Living State memory from raw + refresh 近期动态 (1 AI call) let busyNar = false; async function narrativeTick() { if (busyNar || !CFG.ai.enabled) return; busyNar = true; log('ground/narrative run'); try { const recent = S.store.messages.filter((m) => m.ts >= Date.now() - 7 * 86400000); const r = await groundLiving(recent.length >= 5 ? recent : S.store.messages); if (!r) { log('ground: null (parse/empty)'); return; } living.memory = r.memory; living.narrative = r.narrative; living.groundedAt = Date.now(); saveLiving(); narrative = r.narrative; writeData('narrative.json', r.narrative); broadcast('narrative', r.narrative); log('living re-grounded + narrative refreshed'); } catch (e) { log('ground err', e.message); } finally { busyNar = false; } } // FULL regeneration of 档案 + 视角 (2 AI calls). Runs on boot if stale + daily at 20:00. let busyRegen = false; async function regenTick() { if (busyRegen || !CFG.ai.enabled) return; busyRegen = true; log('regenTick: regenerating 档案 + 视角'); try { const recent = S.store.messages.filter((m) => m.ts >= Date.now() - 30 * 86400000); const [archive, perspectives] = await Promise.all([regenerateArchive(recent), regeneratePerspectives(recent)]); regen = { archive: archive || regen?.archive || null, perspectives: perspectives || regen?.perspectives || null, at: Date.now() }; writeData('regen.json', regen); broadcast('regen', regen); log(`regen done: ${archive?.episodes?.length || 0} episodes, perspectives ${perspectives ? 'ok' : 'fail'}`); } catch (e) { log('regen err', e.message); } finally { busyRegen = false; } } let busyCheckup = false; async function checkupTick() { if (busyCheckup || !CFG.ai.enabled) return; busyCheckup = true; log('checkupTick: generating 关系体检'); try { // sample broadly: recent windows + conflict/care-heavy windows for evidence const windows = corpus ? corpus.search('吵架 冲突 生气 难过 开心 想你 道歉 在乎 期待 未来', 12) : []; const r = await relationshipCheckup(assembleCtx(), windows); if (r && r.report) { checkup = r; writeData('checkup.json', checkup); broadcast('checkup', checkup); log('checkup done'); } } catch (e) { log('checkup err', e.message); } finally { busyCheckup = false; } } function msUntil(hour) { const now = new Date(); const t = new Date(now); t.setHours(hour, 0, 0, 0); if (t <= now) t.setDate(t.getDate() + 1); return t - now; } // reconcile open todos against your subsequent replies — auto-stash addressed/stale (1 call) let busyRec = false, recMark = -1; async function reconcileTick() { if (busyRec || !CFG.ai.enabled) return; const collapsed = collapseDuplicates(Date.now()); // local, no AI — drain reworded duplicates if (collapsed) { log(`collapsed ${collapsed} duplicate todos`); broadcast('reminders', listReminders()); } const seedOnce = recMark === -1; // run once on boot even with no viewer (clean the seed) if (!seedOnce && noViewer()) return; // otherwise pause when nobody's watching // AI dedupe: merge semantically-duplicate todos the bigram heuristic misses (keep highest-scored per group) try { const openNow = listReminders().open.slice(0, 40); if (openNow.length >= 3) { const groups = await dedupeTodos(openNow.map((r, i) => ({ i, text: r.text }))); let dm = 0; for (const g of groups) { const items = g.map((idx) => openNow[idx]).filter(Boolean); if (items.length < 2) continue; items.sort((a, b) => (b.score || 0) - (a.score || 0)); // keep the best-scored, stale the rest dm += mergeAway(items.slice(1).map((r) => r.id), Date.now()); } if (dm) { log(`AI-merged ${dm} duplicate todos`); broadcast('reminders', listReminders()); } } } catch (e) { log('dedupe err', e.message); } if (S.store.messages.length === recMark) return; // idle-skip the AI pass // reconcile the RESOLVABLE ones first: reply/discuss (answered?) > promise (done?) > sensitive const pri = { reply: 0, discuss: 1, promise: 2, sensitive: 3 }; const open = listReminders().open .sort((a, b) => (pri[a.kind] - pri[b.kind]) || ((b.lastSeen || 0) - (a.lastSeen || 0))).slice(0, 20); if (open.length < 2) return; recMark = S.store.messages.length; busyRec = true; try { const todos = open.map((r, i) => ({ i, kind: r.kind, text: r.text, id: r.id })); const idByIndex = Object.fromEntries(todos.map((t) => [t.i, t.id])); // RAG: pull corpus-wide evidence for each todo so completion isn't limited to the recent tail const evidence = {}; if (corpus) for (const t of todos) { const h = corpus.search(t.text, 4); if (h.length) evidence[t.i] = h.map((x) => String(x.text).replace(/\s+/g, ' ').slice(0, 140)).join(' / '); } const verdicts = await reconcileTodos(todos, S.store.messages, evidence); log(`reconcile verdict: addressed=${JSON.stringify(verdicts.addressed)} stale=${JSON.stringify(verdicts.stale)} (of ${todos.length})`); const changed = applyReconcile(verdicts, idByIndex, Date.now()); if (changed) { log(`reconciled ${changed} todos (addressed/stale)`); broadcast('reminders', listReminders()); } } catch (e) { log('reconcile err', e.message); } finally { busyRec = false; } } // multi-chat grounding: build the full per-chat state (feeds the 概览 board). boot + daily. let busyGround = false; async function groundBizTick() { if (busyGround || !CFG.ai.enabled) return; busyGround = true; try { const r = await groundBusiness(S.store.messages, finishedItems()); if (r && (r.memory || r.narrative)) { if (r.memory) { living.memory = r.memory; living.at = Date.now(); } if (r.narrative) { living.narrative = r.narrative; narrative = { narrative: r.narrative, at: Date.now() }; broadcast('narrative', narrative); } const obs = { headline: String(r.memory?.focus || '当前状态').slice(0, 20), summary: r.narrative || '', mood: '', activeTopics: [], alerts: (r.memory?.watchNow || []).slice(0, 3) }; living.observation = obs; S.store.ai.insight = obs; S.store.ai.insightAt = Date.now(); saveLiving(); broadcast('insight', { insight: obs, at: S.store.ai.insightAt }); log('ground: multi-chat state grounded'); } } catch (e) { log('ground err', e.message); } finally { busyGround = false; } } // ---- HTTP ---- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml' }; function serveFile(res, full) { const ext = extname(full); // live dashboard: HTML/JS/CSS must always revalidate so UI updates aren't stuck on stale cache. const noCache = ext === '.html' || ext === '.js' || ext === '.css'; res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': noCache ? 'no-cache, must-revalidate' : 'public, max-age=86400', }); res.end(readFileSync(full)); } function json(res, obj, code = 200) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); } function body(req) { return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); }); } // a request straight from this computer: loopback socket, loopback Host, and no proxy/tunnel headers function isLocalRequest(req) { const ip = req.socket.remoteAddress || ''; const host = String(req.headers.host || '').replace(/:\d+$/, ''); const proxied = ['x-forwarded-for', 'x-forwarded-host', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'tailscale-user-login'].some((h) => req.headers[h]); return /^(127\.|::1$|::ffff:127\.)/.test(ip) && /^(127\.0\.0\.1|localhost|\[::1\])$/.test(host) && !proxied; } const NEEDS_PASSWORD_HTML = `Relate

先设置看板密码

这个空间还没有密码,所以只能在运行 Relate 的电脑上打开。请在控制台(hub)→ 空间 → 访问 里设置看板密码,保存并重启空间后再从手机访问。

Set a dashboard password first: this space has none, so it only opens on the computer running Relate. Hub → Space → Access → set a password, save and restart the space.

`; // public, non-secret description of this space for the dashboard (names, template, enabled plugins) function spaceInfo() { const t = CFG.target || {}; return { id: CFG.id || '', name: CFG.name || '', template: CONTEXT_TYPE, me: { name: CFG.me.name && CFG.me.name !== 'me' ? CFG.me.name : '' }, target: { name: t.name || '', pronoun: t.pronoun || '', togetherDate: t.togetherDate || '', firstContactDate: t.firstContactDate || '' }, org: { name: CFG.org?.name || '' }, plugins: plugins.names, }; } const server = http.createServer(async (req, res) => { const u = new URL(req.url, 'http://x'); const p = u.pathname; // ---- ingest: bridges push canonical messages/episodes with the connector's bearer token ---- if (req.method === 'POST' && (p === '/api/ingest' || p.startsWith('/api/ingest/'))) { const want = decodeURIComponent(p.slice('/api/ingest/'.length) || ''); const c = want ? sources?.get(want) : sources?.firstOfType('ingest'); if (!c || c.type !== 'ingest') return json(res, { ok: false, error: 'no ingest connector' + (want ? ` "${want}"` : '') }, 404); const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (!c.checkToken(bearer)) return json(res, { ok: false, error: 'bad or missing ingest token' }, 401); const r = c.accept(await body(req)); if (r.added) broadcast('stats', S.snapshot()); return json(res, r, r.ok ? 200 : 400); } // ---- no password = this computer only: a tunnel, proxy or LAN visitor never sees an unprotected space ---- if (!auth.authEnabled() && !isLocalRequest(req)) { res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(NEEDS_PASSWORD_HTML); } // ---- auth gate ---- if (p === '/login') { if (req.method === 'POST') { let pw = ''; try { pw = JSON.parse(await auth.readBody(req)).password; } catch {} if (auth.checkPassword(pw)) { auth.setAuthCookie(res); return json(res, { ok: true }); } return json(res, { ok: false }, 401); } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(auth.LOGIN_HTML); } if (auth.authEnabled() && !auth.isPublicPath(p) && !auth.isAuthed(req)) { if (p.startsWith('/api/') || p === '/stream' || p === '/mcp' || req.method !== 'GET') return json(res, { error: 'unauthorized' }, 401); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(auth.LOGIN_HTML); } // MCP service: every chat + the board, read-only, for Claude Code / Desktop / scripts (Bearer apiToken) if (p === '/mcp') { try { await handleMcp(req, res, req.method === 'POST' ? await body(req) : undefined); } catch (e) { log('mcp err', e.message); if (!res.headersSent) json(res, { error: 'mcp failure' }, 500); } return; } if (p === '/stream') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); res.write('retry: 3000\n\n'); res.write(`event: stats\ndata: ${JSON.stringify(S.snapshot())}\n\n`); res.write(`event: reminders\ndata: ${JSON.stringify(listReminders())}\n\n`); const wasEmpty = clients.size === 0; clients.add(res); // first viewer arrived → refresh soon so the dashboard isn't stale when you open it if (wasEmpty && CFG.ai.enabled) { armSummary(2000); setTimeout(reminderTick, 5000); setTimeout(reconcileTick, 9000); } const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20000); req.on('close', () => { clients.delete(res); clearInterval(ping); }); return; } { const h = plugins.route(req.method, p); if (h) { try { return await h(req, res); } catch (e) { log('plugin route err', e.message); return json(res, { error: e.message }, 500); } } } if (p === '/api/space') return json(res, spaceInfo()); if (p === '/api/state') return json(res, S.snapshot()); // ---- connectors, send, episodes ---- if (p === '/api/connectors') return json(res, sources ? sources.describe() : []); if (sources && p.startsWith('/api/connectors/')) { const [, , , cid, action] = p.split('/'); const c = sources.get(decodeURIComponent(cid)); if (!c) return json(res, { ok: false, error: 'unknown connector' }, 404); if (action === 'test' && req.method === 'POST') return json(res, c.test ? await c.test().catch((e) => ({ ok: false, error: e.message })) : { ok: true, info: 'no test available' }); if (action === 'chats') return json(res, c.listChats ? await c.listChats().catch(() => []) : []); return json(res, { error: 'not found' }, 404); } if (p === '/api/send' && req.method === 'POST') { if (!sources) return json(res, { ok: false, error: 'connectors not started' }, 503); const b = await body(req); return json(res, await sources.send({ chatId: b.chatId, text: b.text, connector: b.connector }).catch((e) => ({ ok: false, error: e.message }))); } if (p === '/api/episodes') return json(res, sources ? sources.listEpisodes(Number(u.searchParams.get('limit')) || 50) : []); if (p === '/api/analytics') return json(res, { ...computeAnalytics(S.store.messages), target: spaceInfo().target }); if (p === '/api/trends') return json(res, !IS_BIZLIKE ? relTrends(S.store.messages, Date.now()) : {}); if (p === '/api/contacts') return json(res, IS_BIZLIKE ? { contacts: bizTrends(S.store.messages, SESSIONS, Date.now()) } : { contacts: [] }); if (p === '/api/mood') return json(res, IS_BIZLIKE ? bizMood(S.store.messages, SESSIONS, Date.now()) : {}); if (p === '/api/analyze/backfill' && req.method === 'POST') { const b = await body(req); backfillAnalysis(b.weeks || 8).catch((e) => log('backfill err', e.message)); return json(res, { ok: true, note: '已开始补分析(后台进行,情绪趋势会陆续填充)' }); } if (p === '/api/game') return json(res, computeGame(computeAnalytics(S.store.messages), S.store.messages, gameState)); if (p === '/api/narrative') return json(res, narrative || living.narrative || {}); if (p === '/api/living') return json(res, { memory: activeMemory(), at: living.at, groundedAt: living.groundedAt }); if (p === '/api/living/dismiss' && req.method === 'POST') { const b = await body(req); const ok = dismissLiving(b.text); if (ok) broadcast('insight', { insight: living.observation, at: S.store.ai.insightAt }); return json(res, { ok }); } if (p === '/api/notify/test') { const r = await sendPush('Relate', '✅ 测试通知:推送已连通', { click: CFG.notify?.dashboardUrl }); return json(res, r); } if (p === '/api/push/vapid') return json(res, { key: vapidPublic(), subs: subCount() }); if (p === '/api/push/subscribe' && req.method === 'POST') { const b = await body(req); const n = addSub(b.subscription || b); log(`push subscription added (${n} total)`); return json(res, { ok: true, subs: n }); } if (p === '/api/regen') return json(res, regen || {}); if (p === '/api/regen/run' && req.method === 'POST') { regenTick(); return json(res, { ok: true, note: '已触发重新生成' }); } if (p === '/api/notes' && req.method === 'GET') return json(res, { notes: listNotes() }); if (p === '/api/notes' && req.method === 'POST') { const b = await body(req); const n = addNote(b.text); return json(res, n ? { ok: true, note: n } : { ok: false }, n ? 200 : 400); } if (p.startsWith('/api/notes/') && req.method === 'DELETE') { const ok = deleteNote(decodeURIComponent(p.slice('/api/notes/'.length))); return json(res, { ok }); } // approval queue: proposed side-effectful actions await your ✓ here before executing if (p === '/api/approvals') return json(res, { pending: pendingApprovals(), recent: listApprovals(30) }); if (p.startsWith('/api/approvals/') && p.endsWith('/approve') && req.method === 'POST') { const r = await approveAction(decodeURIComponent(p.slice('/api/approvals/'.length, -'/approve'.length))); broadcast('approvals', { pending: pendingCount() }); return json(res, r); } if (p.startsWith('/api/approvals/') && p.endsWith('/reject') && req.method === 'POST') { const r = rejectAction(decodeURIComponent(p.slice('/api/approvals/'.length, -'/reject'.length))); broadcast('approvals', { pending: pendingCount() }); return json(res, r); } if (p === '/api/checkup') return json(res, checkup || { report: '', at: null }); if (p === '/api/checkup/run' && req.method === 'POST') { checkupTick(); return json(res, { ok: true, note: '正在生成关系体检…' }); } if (p.startsWith('/api/game/quest/') && p.endsWith('/toggle') && req.method === 'POST') { const id = decodeURIComponent(p.slice('/api/game/quest/'.length, -'/toggle'.length)); const tk = new Date().toISOString().slice(0, 10); gameState.questDone = gameState.questDone || {}; gameState.questDone[tk] = gameState.questDone[tk] || {}; gameState.questDone[tk][id] = !gameState.questDone[tk][id]; writeData('game.json', gameState); return json(res, { ok: true }); } if (p === '/api/reminders') return json(res, listReminders()); if (p === '/api/ai') return json(res, { ...aiStats, ...aiBudget(), daily: aiDaily(), enabled: CFG.ai.enabled, model: CFG.ai.model, corpus: corpus.size }); // ---- multi-chat: list / open / create / delete persistent 问它 conversations ---- if (p === '/api/chats' && req.method === 'GET') return json(res, { chats: chats.listChats() }); if (p === '/api/chats' && req.method === 'POST') { const c = chats.createChat(); return json(res, { id: c.id, title: c.title, messages: [] }); } if (p.startsWith('/api/chats/') && req.method === 'GET') { const id = decodeURIComponent(p.slice('/api/chats/'.length)); const c = chats.getChat(id); if (!c) return json(res, { error: 'not found' }, 404); return json(res, { id: c.id, title: c.title, messages: c.messages }); } if (p.startsWith('/api/chats/') && req.method === 'DELETE') { chats.deleteChat(decodeURIComponent(p.slice('/api/chats/'.length))); return json(res, { ok: true }); } // append the finished exchange to a chat + auto-title on first exchange async function persistExchange(cid, q, answer, hits, cited) { if (!cid) return; chats.appendMessage(cid, { role: 'user', content: q }); chats.appendMessage(cid, { role: 'assistant', content: answer, hits: hits || null, cited: cited || null }); const c = chats.getChat(cid); // title in the BACKGROUND — never block the reply on it if (c && !c.title) { chatTitle(q, answer).then((t) => { if (t) chats.setTitle(cid, t); }).catch(() => {}); } } // data mode = the assistant may call tools to fetch numbers (e.g. the sql-readonly plugin's run_sql) const dataMode = () => IS_BIZLIKE && hasTool('run_sql'); if (p === '/api/ask') { let q = (u.searchParams.get('q') || '').trim(), history = []; let chatId = null; if (req.method === 'POST') { const b = await body(req); q = String(b.q || '').trim(); chatId = b.chatId || null; } if (!q) return json(res, { error: 'empty' }, 400); // resolve/create chat + server-authoritative history (consistent with the streaming endpoint) const cid = (chatId && chats.getChat(chatId)) ? chatId : chats.createChat().id; // "记住 X" → save a background fact instead of asking { const fact = extractRemember(q); if (fact) { const n = addNote(fact); const ans = n ? `✅ 记住了:${fact}\n\n(以后回答都会考虑这条。可在设置里查看/删除。)` : '这条太短,没记下来。'; await persistExchange(cid, q, ans, [], []); return json(res, { answer: ans, cited: [], hits: [], chatId: cid, remembered: !!n }); } } history = chats.historyFor(cid); // non-streaming fallback also handles census (returns the final report, no live progress) const census0 = detectCensus(q); if (census0) { try { const { report } = await censusAnalyze(census0, collectCensus(S.store.messages, census0), assembleCtx(), {}); await persistExchange(cid, q, report, [], []); return json(res, { answer: report, cited: [], hits: [], chatId: cid }); } catch (e) { return json(res, { answer: '(分析失败) ' + e.message, hits: [] }, 500); } } // retrieve on the last couple of turns + current q so follow-ups ("那怎么办") still find context const retrievalQ = [...history.filter((h) => h.role === 'user').slice(-2).map((h) => h.content), q].join(' '); const deep = isDeep(q); const hits = corpus.search(retrievalQ, deep ? 16 : 12); const hitMeta = hits.map((h, i) => ({ i, date: h.meta.date, dateEnd: h.meta.dateEnd, text: h.text.slice(0, 240), score: h.score })); try { const { answer, cited } = dataMode() ? await askWithData(q, hits, assembleCtx(), history, { deep }) : await askAssistant(q, hits, assembleCtx(), history, deep); await persistExchange(cid, q, answer, hitMeta, cited); return json(res, { answer, cited, hits: hitMeta, chatId: cid }); } catch (e) { return json(res, { answer: '(AI 调用失败) ' + e.message, hits: hitMeta }, 500); } } // voice endpoint (e.g. an iOS Shortcut): one persistent "voice" chat, concise no-markdown answers // for text-to-speech. Auth via cookie OR Bearer token. Returns { answer } as plain spoken text. if (p === '/api/voice' && (req.method === 'POST' || req.method === 'GET')) { let raw = ''; if (req.method === 'POST') { try { raw = await auth.readBody(req); } catch {} } let q = '', reset = false; try { const j = JSON.parse(raw); if (j && j.q != null) q = String(j.q); if (j && j.reset) reset = true; } catch {} // JSON body {q} if (!q && raw && !raw.trim().startsWith('{')) q = raw; // plain-text body if (!q) q = u.searchParams.get('q') || ''; // ?q= (also works for GET) q = q.trim(); if (reset) { voiceChatId = null; if (!q) return json(res, { answer: 'OK, starting fresh.' }); } if (!q) return json(res, { answer: "I didn't catch a question — say it again." }); // friendly, so the voice assistant speaks something if (!voiceChatId || !chats.getChat(voiceChatId)) voiceChatId = chats.createChat('🎙️ 语音').id; const cid = voiceChatId; { const fact = extractRemember(q); if (fact) { const n = addNote(fact); const ans = n ? `Got it — I'll remember that: ${fact}` : 'That was too short to save.'; await persistExchange(cid, q, ans, [], []); return json(res, { answer: ans, chatId: cid }); } } const history = chats.historyFor(cid); const retrievalQ = [...history.filter((h) => h.role === 'user').slice(-2).map((h) => h.content), q].join(' '); const hits = corpus.search(retrievalQ, 12); try { const vq = q + '\n\n[Answer in spoken English only — brief, no symbols. This is read aloud.]'; const { answer } = dataMode() ? await askWithData(vq, hits, assembleCtx(), history, { voice: true, fast: true }) : await askAssistant(vq, hits, assembleCtx(), history, false, true); const spoken = stripMarkdown(answer); await persistExchange(cid, q, spoken, [], []); return json(res, { answer: spoken, chatId: cid }); } catch (e) { return json(res, { answer: '出错了:' + e.message }, 500); } } // streaming ask: NDJSON — {type:'meta'|'token'|'progress'|'done'|'error'}. Headers flush immediately so // proxy timeouts never trigger and tokens render live in the browser. if (p === '/api/ask/stream' && req.method === 'POST') { const b = await body(req); const q = String(b.q || '').trim(); if (!q) return json(res, { error: 'empty' }, 400); // resolve/create the chat; history comes from the store (server-authoritative) const cid = (b.chatId && chats.getChat(b.chatId)) ? b.chatId : chats.createChat().id; const history = chats.historyFor(cid); res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no', 'Connection': 'keep-alive' }); const send = (o) => { try { res.write(JSON.stringify(o) + '\n'); } catch {} }; // "记住 X" → save a background fact, no AI call { const fact = extractRemember(q); if (fact) { const n = addNote(fact); const ans = n ? `✅ 记住了:${fact}\n\n(以后回答都会考虑这条。)` : '这条太短,没记下来。'; send({ type: 'meta', hits: [], chatId: cid }); send({ type: 'token', t: ans }); await persistExchange(cid, q, ans, [], []); send({ type: 'done', cited: [], chatId: cid }); return res.end(); } } // aggregation/census question ("analyze ALL apologies") → exhaustive enumeration, not top-k RAG const census = detectCensus(q); if (census) { send({ type: 'meta', hits: [], chatId: cid }); const collected = collectCensus(S.store.messages, census); try { const r = await censusAnalyze(census, collected, assembleCtx(), { onProgress: (msg) => send({ type: 'progress', msg }), onToken: (t) => send({ type: 'token', t }) }); await persistExchange(cid, q, r.report, [], []); send({ type: 'done', cited: [], chatId: cid }); } catch (e) { send({ type: 'error', message: String(e.message || e) }); } return res.end(); } const retrievalQ = [...history.filter((h) => h.role === 'user').slice(-2).map((h) => h.content), q].join(' '); const deep = isDeep(q); const hits = corpus.search(retrievalQ, deep ? 16 : 12); const hitMeta = hits.map((h, i) => ({ i, date: h.meta.date, dateEnd: h.meta.dateEnd, text: h.text.slice(0, 240), score: h.score })); send({ type: 'meta', hits: hitMeta, chatId: cid }); try { const { answer, cited } = dataMode() ? await askWithData(q, hits, assembleCtx(), history, { onToken: (t) => send({ type: 'token', t }), onProgress: (msg) => send({ type: 'progress', msg }), deep }) : await askAssistantStream(q, hits, assembleCtx(), history, (t) => send({ type: 'token', t }), deep); await persistExchange(cid, q, answer, hitMeta, cited); send({ type: 'done', cited, chatId: cid }); } catch (e) { send({ type: 'error', message: String(e.message || e) }); } return res.end(); } // draft a reply: scope=main → the space's main conversation; scope=other&chat= → that chat only if (p === '/api/reply-preview') { const scope = u.searchParams.get('scope') || 'main'; const chat = u.searchParams.get('chat') || ''; const isMain = scope === 'main'; const pool = isMain ? S.store.messages : S.store.messages.filter((m) => !chat || m.sessionName === chat); const tail = pool.slice(-16); const label = isMain ? (CFG.target.name || CFG.name || '对方') : (chat || '对方'); if (!tail.length) return json(res, { error: '没有可参考的对话' }, 400); const q = tail.slice(-6).map((m) => m.content).join(' '); const ragSnippets = isMain ? corpus.search(q, 6) : []; try { const d = await draftReply({ label, isMain, tail, ragSnippets }); return json(res, { ...d, basedOn: ragSnippets.map((s) => ({ date: s.meta.date, who: s.meta.who, text: s.text.slice(0, 120) })) }); } catch (e) { return json(res, { error: e.message }, 500); } } if (p.startsWith('/api/reminders/') && p.endsWith('/toggle') && req.method === 'POST') { markDone(decodeURIComponent(p.slice('/api/reminders/'.length, -'/toggle'.length)), Date.now()); broadcast('reminders', listReminders()); return json(res, { ok: true }); } if (p === '/api/reminders/clear-done' && req.method === 'POST') { clearDone(); broadcast('reminders', listReminders()); return json(res, { ok: true }); } const file = p === '/' ? '/index.html' : p; const full = join(PUB, file.replace(/\.\./g, '')); if (!full.startsWith(PUB) || full.startsWith(join(PUB, 'hub'))) { res.writeHead(404); return res.end('not found'); } if (existsSync(full) && extname(full)) return serveFile(res, full); res.writeHead(404); res.end('not found'); }); // ---- boot ---- async function main() { loadReminders(); const retired = retireSensitive(Date.now()); // 敏感点 now live in the Living State memory if (retired) log(`retired ${retired} sensitive todos -> Living State`); loadLiving(); // evolving Living State memory if (living.narrative) narrative = living.narrative; if (living.observation) { S.store.ai.insight = living.observation; S.store.ai.insightAt = living.at; } const BIZ = IS_BIZLIKE; const persisted = S.loadPersisted(); // messages captured by earlier runs (messages.jsonl) S.store.messages.sort((a, b) => a.ts - b.ts); log(`loaded ${persisted} persisted messages`); buildCorpus(); initMcp({ store: S.store, corpus: () => corpus, sessions: SESSIONS, living, activeMemory, listReminders, listNotes }); sources = createSources({ CFG, S, corpus: { add: (d) => corpus.add(d) }, broadcast, log, dataDir: DATA_DIR, extraTypes: await pluginConnectorTypes(), onMessage: (m) => { broadcast('message', m); broadcast('stats', S.snapshot()); if (m._tag?.care && !BIZ) broadcast('care', { at: m.ts, text: m.content.slice(0, 80), dir: m._tag.dir }); if (BIZ) bumpGroundSoon(); else bumpSummaryOnHeat(m._tag); }, }); await sources.startAll(); buildCorpus(); // core tools the assistant can call registerTool({ name: 'list_open_todos', readOnly: true, description: '列出当前未完成的待办/承诺/需要回复的事项(已排序)。问到"我还有什么没做/待办/承诺"时用。', progress: () => '读取待办清单…', run: async () => { const r = listReminders(); return { open: (r.topRanked || r.open || []).slice(0, 20).map((t) => ({ text: t.text, kind: t.kind, score: t.score })) }; }, }); // outbound messages from the assistant are side-effectful → approval queue (readOnly:false) registerTool({ name: 'send_message', readOnly: false, description: '通过已连接的平台给某个对话发送一条消息(需要用户在面板批准后才会真正发送)。只在用户明确要求发送时使用。', parameters: { type: 'object', properties: { chatId: { type: 'string', description: '对话 id(list 里的 session)' }, text: { type: 'string' } }, required: ['chatId', 'text'] }, preview: (a) => `发送到 ${NAMES[a.chatId] || a.chatId}:${String(a.text || '').slice(0, 120)}`, progress: () => '准备发送…', run: (a) => sources.send({ chatId: a.chatId, text: a.text }), }); setExecutor(executeTool); // approval queue runs actions through the tool registry once approved plugins = await activatePlugins({ CFG, log, broadcast, registerTool, dataDir: DATA_DIR, store: S.store, messages: () => S.store.messages, living: () => living, reminders: () => listReminders(), send: (chatId, text, connector) => sources.send({ chatId, text, connector }), notify: (title, text, opts = {}) => sendPush(title, text, { click: CFG.notify?.dashboardUrl, ...opts }), addApproval, }); setInterval(() => broadcast('stats', S.snapshot()), 4000); setInterval(() => S.saveSnapshotFile(), 60000); if (CFG.ai.enabled) { // todos + Living State run for every template (extract, score, auto-reconcile "what's done") armSummary(12000); setTimeout(analysisTick, 8000); setInterval(analysisTick, CFG.ai.analyzeIntervalMs || 20000); // per-message sentiment; viewer-gated setInterval(reminderTick, CFG.ai.reminderIntervalMs); setInterval(reconcileTick, CFG.ai.reconcileIntervalMs || CFG.ai.reminderIntervalMs); setTimeout(reminderTick, 22000); setTimeout(reconcileTick, 70000); if (!BIZ) { // relationship-only: twice-daily re-grounding + 档案/视角 + 关系体检 setInterval(narrativeTick, 12 * 3600000); if (!living.groundedAt || Date.now() - living.groundedAt > 12 * 3600000) setTimeout(narrativeTick, 35000); if (!regen || Date.now() - regen.at > 20 * 3600000) setTimeout(regenTick, 45000); if (!checkup || Date.now() - (checkup.at || 0) > 20 * 3600000) setTimeout(checkupTick, 90000); setTimeout(() => { regenTick(); setTimeout(checkupTick, 60000); setInterval(() => { regenTick(); setTimeout(checkupTick, 60000); }, 24 * 3600000); }, msUntil(20)); } else { // multi-chat: full per-chat grounding builds the 概览 board — on boot (if stale) + daily if (!living.at || Date.now() - living.at > 20 * 3600000) setTimeout(groundBizTick, 6000); setTimeout(() => { groundBizTick(); setInterval(groundBizTick, 24 * 3600000); }, msUntil(6)); } } // loopback only by default; set "host": "0.0.0.0" in the space config to expose it on your network server.listen(CFG.port, CFG.host || '127.0.0.1', () => log(`\n space "${CFG.name || CFG.id || CONTEXT_TYPE}" -> http://${CFG.host || '127.0.0.1'}:${CFG.port}\n history ${S.store.messages.length} msgs · template ${CONTEXT_TYPE} · AI ${CFG.ai.enabled ? CFG.ai.model : 'off'}\n`)); } process.on('unhandledRejection', (e) => log('unhandledRejection', String(e))); main();