total-recall-brain 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/sessions/* routes
|
|
3
|
+
*
|
|
4
|
+
* - GET /api/sessions list (limit, offset)
|
|
5
|
+
* - GET /api/sessions/:id read full session
|
|
6
|
+
* - POST /api/sessions/ingest ingest (raw file or pre-parsed)
|
|
7
|
+
* - DELETE /api/sessions/:id delete
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import express from 'express';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { requireAuth, requireScope, ingestRateLimiter } from '../auth.mjs';
|
|
15
|
+
import {
|
|
16
|
+
parseSessionFile,
|
|
17
|
+
sessionToEmbedChunks,
|
|
18
|
+
getEmbedding,
|
|
19
|
+
saveSessionEmbeddingToIndex,
|
|
20
|
+
removeSessionEmbeddingFromIndex,
|
|
21
|
+
} from '../../core/embeddings.mjs';
|
|
22
|
+
import {
|
|
23
|
+
AGENT_DIR,
|
|
24
|
+
BRAIN_DIR,
|
|
25
|
+
SESSIONS_DIR,
|
|
26
|
+
DERIVED_DIR,
|
|
27
|
+
notFound,
|
|
28
|
+
badRequest,
|
|
29
|
+
serverError,
|
|
30
|
+
} from './_shared.mjs';
|
|
31
|
+
|
|
32
|
+
const router = express.Router();
|
|
33
|
+
|
|
34
|
+
const activeEmbeddings = new Set();
|
|
35
|
+
|
|
36
|
+
async function drainActiveEmbeddings() {
|
|
37
|
+
if (activeEmbeddings.size === 0) return;
|
|
38
|
+
await Promise.allSettled(Array.from(activeEmbeddings));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
function listSessionFiles() {
|
|
43
|
+
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
44
|
+
return fs.readdirSync(SESSIONS_DIR)
|
|
45
|
+
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json') || f.endsWith('.md'))
|
|
46
|
+
.sort()
|
|
47
|
+
.reverse();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readSessionFile(filename) {
|
|
51
|
+
const filePath = path.join(SESSIONS_DIR, filename);
|
|
52
|
+
if (!fs.existsSync(filePath)) return null;
|
|
53
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
54
|
+
|
|
55
|
+
const id = filename.replace(/\.(jsonl|json|md)$/, '');
|
|
56
|
+
let meta = {};
|
|
57
|
+
|
|
58
|
+
// Parse SSSS frontmatter from .md files
|
|
59
|
+
let body = raw;
|
|
60
|
+
if (filename.endsWith('.md') && raw.startsWith('---')) {
|
|
61
|
+
const endIdx = raw.indexOf('---', 3);
|
|
62
|
+
if (endIdx !== -1) {
|
|
63
|
+
const frontmatter = raw.slice(3, endIdx).trim();
|
|
64
|
+
body = raw.slice(endIdx + 3).trim();
|
|
65
|
+
// Simple YAML parser for known fields
|
|
66
|
+
for (const line of frontmatter.split('\n')) {
|
|
67
|
+
const match = line.match(/^(\w+):\s*"?(.+?)"?\s*$/);
|
|
68
|
+
if (match) meta[match[1]] = match[2];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lines = body.split('\n').filter(Boolean);
|
|
74
|
+
const entries = lines.map(l => {
|
|
75
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
76
|
+
}).filter(Boolean);
|
|
77
|
+
|
|
78
|
+
let exchanges = entries;
|
|
79
|
+
if (entries.length === 1 && entries[0] && Array.isArray(entries[0].messages)) {
|
|
80
|
+
exchanges = entries[0].messages.map(m => ({
|
|
81
|
+
...m,
|
|
82
|
+
session_id: m.session_id || entries[0].id || entries[0].session_id || id
|
|
83
|
+
}));
|
|
84
|
+
} else {
|
|
85
|
+
exchanges = entries.map(m => ({
|
|
86
|
+
...m,
|
|
87
|
+
session_id: m.session_id || id
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
filename,
|
|
94
|
+
title: meta.title || id,
|
|
95
|
+
date: meta.date || null,
|
|
96
|
+
source: meta.source || null,
|
|
97
|
+
project: meta.project || null,
|
|
98
|
+
entries,
|
|
99
|
+
exchanges,
|
|
100
|
+
count: exchanges.length
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract human/assistant turns from raw IDE session file content.
|
|
106
|
+
* Handles JSONL (Claude Code, Codex, Cursor, VS Code) and plaintext (Antigravity overview).
|
|
107
|
+
*/
|
|
108
|
+
function parseRawSessionContent(content, source) {
|
|
109
|
+
const messages = [];
|
|
110
|
+
const lines = content.split('\n').filter(Boolean);
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
let rec;
|
|
114
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
115
|
+
|
|
116
|
+
if (source === 'antigravity' && (rec.type === 'USER_INPUT' || (rec.source === 'MODEL' && (rec.type === 'PLANNER_RESPONSE' || rec.type === 'RESPONSE' || rec.type === 'model_response')))) {
|
|
117
|
+
let role = rec.type === 'USER_INPUT' ? 'user' : 'assistant';
|
|
118
|
+
let text = rec.content || '';
|
|
119
|
+
if (role === 'user' && text.includes('<USER_REQUEST>')) {
|
|
120
|
+
const match = text.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
|
|
121
|
+
if (match) text = match[1].trim();
|
|
122
|
+
}
|
|
123
|
+
if (text.trim()) messages.push({ role, content: text.slice(0, 8000) });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (rec.role && (rec.content || rec.message)) {
|
|
128
|
+
const text = typeof rec.content === 'string' ? rec.content
|
|
129
|
+
: Array.isArray(rec.content) ? rec.content.filter(b => b.type === 'text').map(b => b.text).join('\n')
|
|
130
|
+
: typeof rec.message?.text === 'string' ? rec.message.text
|
|
131
|
+
: '';
|
|
132
|
+
if (text.trim()) messages.push({ role: rec.role, content: text.slice(0, 8000) });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (rec.kind === 2 && Array.isArray(rec.v)) {
|
|
137
|
+
for (const req of rec.v) {
|
|
138
|
+
const user = req.message?.text || '';
|
|
139
|
+
const asst = req.response?.response?.value || req.response?.value || '';
|
|
140
|
+
if (user) messages.push({ role: 'user', content: user.slice(0, 8000) });
|
|
141
|
+
if (asst) messages.push({ role: 'assistant', content: asst.slice(0, 8000) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (messages.length === 0 && content.trim()) {
|
|
147
|
+
if (source === 'antigravity') {
|
|
148
|
+
for (const line of content.split('\n').filter(Boolean)) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (!trimmed) continue;
|
|
151
|
+
let role = 'assistant';
|
|
152
|
+
if (trimmed.startsWith('USER:') || trimmed.startsWith('User:') || trimmed.startsWith('[user]')) {
|
|
153
|
+
role = 'user';
|
|
154
|
+
} else if (trimmed.startsWith('TOOL:') || trimmed.startsWith('[tool]') || trimmed.includes('tool_call')) {
|
|
155
|
+
role = 'tool';
|
|
156
|
+
}
|
|
157
|
+
messages.push({ role, content: trimmed.slice(0, 8000) });
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
messages.push({ role: 'assistant', content: content.slice(0, 20000) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return messages;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
router.get('/api/sessions', requireAuth, requireScope('memory:read'), (req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
const files = listSessionFiles();
|
|
170
|
+
const { limit = '50', offset = '0' } = req.query;
|
|
171
|
+
const off = parseInt(offset, 10) || 0;
|
|
172
|
+
const lim = Math.min(200, parseInt(limit, 10) || 50);
|
|
173
|
+
const page = files.slice(off, off + lim).map(f => {
|
|
174
|
+
const data = readSessionFile(f);
|
|
175
|
+
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
|
|
176
|
+
return {
|
|
177
|
+
id: data?.id,
|
|
178
|
+
filename: f,
|
|
179
|
+
title: data?.title || data?.id,
|
|
180
|
+
date: data?.date || stat.mtime.toISOString(),
|
|
181
|
+
source: data?.source || null,
|
|
182
|
+
project: data?.project || null,
|
|
183
|
+
count: data?.count || 0,
|
|
184
|
+
modified: stat.mtime.toISOString(),
|
|
185
|
+
size: stat.size
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
res.json({ total: files.length, offset: off, limit: lim, sessions: page });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
serverError(res, err);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
router.get('/api/sessions/:id', requireAuth, requireScope('memory:read'), (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const files = listSessionFiles();
|
|
197
|
+
const match = files.find(f => f.startsWith(req.params.id));
|
|
198
|
+
if (!match) return notFound(res, `Session not found: ${req.params.id}`);
|
|
199
|
+
res.json(readSessionFile(match));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
serverError(res, err);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
router.post('/api/sessions/ingest', ingestRateLimiter(), requireAuth, requireScope('memory:write'), (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const body = req.body || {};
|
|
208
|
+
const source = body.source || 'api';
|
|
209
|
+
|
|
210
|
+
let messages;
|
|
211
|
+
if (body.content && typeof body.content === 'string') {
|
|
212
|
+
messages = parseRawSessionContent(body.content, source);
|
|
213
|
+
} else if (Array.isArray(body.messages)) {
|
|
214
|
+
messages = body.messages;
|
|
215
|
+
} else {
|
|
216
|
+
return badRequest(res, 'Provide either {content} (raw file) or {messages:[{role,content}]}');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (messages.length === 0) {
|
|
220
|
+
return res.status(200).json({ ingested: false, reason: 'no extractable messages' });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Deduplicate by sha256 of the raw content (relay always sends sha256)
|
|
224
|
+
if (body.sha256) {
|
|
225
|
+
const hashIndex = path.join(BRAIN_DIR, 'memory-derived', 'relay-hashes.jsonl');
|
|
226
|
+
fs.mkdirSync(path.dirname(hashIndex), { recursive: true });
|
|
227
|
+
const seen = new Set(
|
|
228
|
+
fs.existsSync(hashIndex)
|
|
229
|
+
? fs.readFileSync(hashIndex, 'utf8').split('\n').filter(Boolean).map(l => {
|
|
230
|
+
try { return JSON.parse(l).sha256; } catch { return ''; }
|
|
231
|
+
})
|
|
232
|
+
: []
|
|
233
|
+
);
|
|
234
|
+
if (seen.has(body.sha256)) {
|
|
235
|
+
return res.status(200).json({ ingested: false, reason: 'duplicate' });
|
|
236
|
+
}
|
|
237
|
+
fs.appendFileSync(hashIndex, JSON.stringify({ sha256: body.sha256, ts: new Date().toISOString(), source }) + '\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Extract title from first user message
|
|
241
|
+
const firstUserMsg = messages.find(m => m.role === 'user');
|
|
242
|
+
const rawTitle = firstUserMsg?.content?.slice(0, 120)?.split('\n')[0]?.trim() || 'untitled session';
|
|
243
|
+
const cleanTitle = rawTitle
|
|
244
|
+
.replace(/<[^>]+>/g, '') // strip XML/HTML tags
|
|
245
|
+
.replace(/[^a-zA-Z0-9\s-]/g, '') // keep only alphanumeric, spaces, hyphens
|
|
246
|
+
.trim()
|
|
247
|
+
.slice(0, 80);
|
|
248
|
+
|
|
249
|
+
// Generate date-prefixed slug
|
|
250
|
+
const now = new Date();
|
|
251
|
+
const datePrefix = now.toISOString().slice(0, 10); // 2026-05-25
|
|
252
|
+
const titleSlug = cleanTitle.toLowerCase().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'untitled';
|
|
253
|
+
const uniqueSuffix = crypto.randomBytes(3).toString('hex');
|
|
254
|
+
const slug = `${datePrefix}-${source}-${titleSlug}-${uniqueSuffix}`;
|
|
255
|
+
const filename = `${slug}.md`;
|
|
256
|
+
|
|
257
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
258
|
+
const filePath = path.join(SESSIONS_DIR, filename);
|
|
259
|
+
|
|
260
|
+
// Detect project from relay metadata or path
|
|
261
|
+
const project = body.project || (body.path ? path.basename(path.resolve(body.path, '..', '..', '..')) : null);
|
|
262
|
+
|
|
263
|
+
// Build SSSS frontmatter
|
|
264
|
+
const frontmatter = [
|
|
265
|
+
'---',
|
|
266
|
+
'type: session',
|
|
267
|
+
`slug: ${slug}`,
|
|
268
|
+
`title: "${cleanTitle || 'Untitled session'}"`,
|
|
269
|
+
`source: ${source}`,
|
|
270
|
+
`date: ${now.toISOString()}`,
|
|
271
|
+
project ? `project: ${project}` : null,
|
|
272
|
+
'category: sessions',
|
|
273
|
+
'schema_version: 2',
|
|
274
|
+
`message_count: ${messages.length}`,
|
|
275
|
+
'---',
|
|
276
|
+
].filter(Boolean).join('\n');
|
|
277
|
+
|
|
278
|
+
// Build session body as JSONL
|
|
279
|
+
const sessionObj = {
|
|
280
|
+
id: slug,
|
|
281
|
+
source,
|
|
282
|
+
messages: messages.map(m => ({
|
|
283
|
+
role: m.role,
|
|
284
|
+
content: m.content,
|
|
285
|
+
source,
|
|
286
|
+
session_id: slug,
|
|
287
|
+
timestamp: m.timestamp || now.toISOString(),
|
|
288
|
+
}))
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
fs.writeFileSync(filePath, frontmatter + '\n' + JSON.stringify(sessionObj) + '\n', 'utf8');
|
|
292
|
+
|
|
293
|
+
// Best-effort: embed session immediately so it's searchable right away
|
|
294
|
+
const promise = (async () => {
|
|
295
|
+
if (process.isShuttingDown) return;
|
|
296
|
+
try {
|
|
297
|
+
const msgs = parseSessionFile(filePath);
|
|
298
|
+
const chunks = sessionToEmbedChunks(msgs);
|
|
299
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
300
|
+
if (process.isShuttingDown) return;
|
|
301
|
+
const key = chunks.length === 1 ? slug : `${slug}:chunk-${i}`;
|
|
302
|
+
const embedding = await getEmbedding(chunks[i]);
|
|
303
|
+
if (process.isShuttingDown) return;
|
|
304
|
+
saveSessionEmbeddingToIndex(DERIVED_DIR, key, chunks[i], embedding);
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
/* Ollama may not be running — non-fatal */
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
|
|
311
|
+
activeEmbeddings.add(promise);
|
|
312
|
+
promise.finally(() => {
|
|
313
|
+
activeEmbeddings.delete(promise);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
res.status(200).json({ ok: true, ingested: true, id: slug, filename, count: messages.length });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
serverError(res, err);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
router.delete('/api/sessions/:id', requireAuth, requireScope('memory:write'), (req, res) => {
|
|
323
|
+
try {
|
|
324
|
+
const files = listSessionFiles();
|
|
325
|
+
const match = files.find(f => f.startsWith(req.params.id));
|
|
326
|
+
if (!match) return notFound(res, `Session not found: ${req.params.id}`);
|
|
327
|
+
fs.unlinkSync(path.join(SESSIONS_DIR, match));
|
|
328
|
+
try { removeSessionEmbeddingFromIndex(DERIVED_DIR, req.params.id); } catch { /* non-fatal */ }
|
|
329
|
+
res.json({ deleted: true, id: req.params.id });
|
|
330
|
+
} catch (err) {
|
|
331
|
+
serverError(res, err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
export { router as sessionsRouter, drainActiveEmbeddings };
|
|
336
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/share routes
|
|
3
|
+
*
|
|
4
|
+
* POST /api/share — Universal share-to-brain endpoint.
|
|
5
|
+
* Accepts content from Chrome extension, CLI, or any external client
|
|
6
|
+
* and routes it to the correct action (remember or research).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { createMemoryNode, writeNode } from '../../core/vault.mjs';
|
|
12
|
+
import { invalidate } from '../../core/vault-cache.mjs';
|
|
13
|
+
import { addToQueue } from '../../core/research-queue.mjs';
|
|
14
|
+
import { requireAuth, requireScope } from '../auth.mjs';
|
|
15
|
+
import {
|
|
16
|
+
VAULT_DIR,
|
|
17
|
+
badRequest,
|
|
18
|
+
serverError,
|
|
19
|
+
resolveVaultFromQuery,
|
|
20
|
+
} from './_shared.mjs';
|
|
21
|
+
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the effective action from the auto-routing heuristic.
|
|
26
|
+
*
|
|
27
|
+
* - Has URL + no excerpt → 'research'
|
|
28
|
+
* - Has excerpt < 500 chars → 'remember', category = 'facts'
|
|
29
|
+
* - Has excerpt >= 500 chars → 'remember', category = 'concepts'
|
|
30
|
+
* - Has URL + excerpt → 'remember' with URL as citation
|
|
31
|
+
*/
|
|
32
|
+
function resolveAction({ url, excerpt, action }) {
|
|
33
|
+
if (action && action !== 'auto') {
|
|
34
|
+
// Explicit action — determine category based on excerpt length
|
|
35
|
+
if (action === 'remember') {
|
|
36
|
+
const category = excerpt && excerpt.length >= 500 ? 'concepts' : 'facts';
|
|
37
|
+
return { action: 'remember', category };
|
|
38
|
+
}
|
|
39
|
+
return { action, category: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Auto-routing heuristic
|
|
43
|
+
if (url && !excerpt) {
|
|
44
|
+
return { action: 'research', category: null };
|
|
45
|
+
}
|
|
46
|
+
if (excerpt && excerpt.length >= 500) {
|
|
47
|
+
return { action: 'remember', category: 'concepts' };
|
|
48
|
+
}
|
|
49
|
+
if (excerpt) {
|
|
50
|
+
return { action: 'remember', category: 'facts' };
|
|
51
|
+
}
|
|
52
|
+
// URL + excerpt (caught by excerpt checks above) or fallback
|
|
53
|
+
if (url) {
|
|
54
|
+
return { action: 'research', category: null };
|
|
55
|
+
}
|
|
56
|
+
return { action: 'remember', category: 'facts' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* POST /api/share
|
|
61
|
+
* Body: { url, title, excerpt, source, action, brainId, tags }
|
|
62
|
+
*/
|
|
63
|
+
router.post('/api/share', requireAuth, requireScope('memory:write'), (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const { url, title, excerpt, source, action: rawAction, tags } = req.body || {};
|
|
66
|
+
|
|
67
|
+
// Validate: at least a URL or excerpt is required
|
|
68
|
+
if (!url && !excerpt) {
|
|
69
|
+
return badRequest(res, 'At least one of url or excerpt is required');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { action, category } = resolveAction({ url, excerpt, action: rawAction });
|
|
73
|
+
|
|
74
|
+
if (action === 'research') {
|
|
75
|
+
// Queue a research project
|
|
76
|
+
const topic = title || url || 'Untitled research';
|
|
77
|
+
const item = addToQueue({
|
|
78
|
+
topic,
|
|
79
|
+
priority: 'medium',
|
|
80
|
+
notes: excerpt || '',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return res.status(201).json({
|
|
84
|
+
action_taken: 'research',
|
|
85
|
+
id: item.id,
|
|
86
|
+
slug: null,
|
|
87
|
+
message: `Queued research: "${topic}"`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// action === 'remember'
|
|
92
|
+
const vaultDir = resolveVaultFromQuery(req);
|
|
93
|
+
const slugBase = category || 'facts';
|
|
94
|
+
const slug = `${slugBase}-${crypto.randomBytes(4).toString('hex')}`;
|
|
95
|
+
const nodeTitle = title || (excerpt ? excerpt.slice(0, 80) : url || 'Untitled share');
|
|
96
|
+
|
|
97
|
+
const node = createMemoryNode({
|
|
98
|
+
slug,
|
|
99
|
+
title: nodeTitle,
|
|
100
|
+
category: category || 'facts',
|
|
101
|
+
content: excerpt || url || '',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Override source type from request
|
|
105
|
+
node.source.type = source || 'api';
|
|
106
|
+
|
|
107
|
+
// Set tags if provided
|
|
108
|
+
if (tags) {
|
|
109
|
+
node.tags = Array.isArray(tags) ? tags : String(tags).split(',').map(t => t.trim());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Add URL as citation if provided
|
|
113
|
+
if (url) {
|
|
114
|
+
node.x_citations = [{
|
|
115
|
+
url,
|
|
116
|
+
title: title || url,
|
|
117
|
+
source: 'share-to-brain',
|
|
118
|
+
published: node.created,
|
|
119
|
+
relevance: 1.0,
|
|
120
|
+
accessed: node.created,
|
|
121
|
+
}];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
writeNode(node, vaultDir);
|
|
125
|
+
invalidate();
|
|
126
|
+
|
|
127
|
+
return res.status(201).json({
|
|
128
|
+
action_taken: 'remember',
|
|
129
|
+
id: node.slug,
|
|
130
|
+
slug: `${category || 'facts'}/${node.slug}.md`,
|
|
131
|
+
message: `Saved as ${category || 'facts'}: ${category || 'facts'}/${node.slug}.md`,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
serverError(res, err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export { router as shareRouter };
|