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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. package/templates/total-recall-server.service +20 -0
@@ -0,0 +1,850 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { atomicWrite } from './vault.mjs';
6
+ import { logger } from './logger.mjs';
7
+
8
+ /**
9
+ * Total Recall Session Watcher
10
+ *
11
+ * Watches known IDE agent conversation log directories for new files,
12
+ * parses them through source-specific adapters, and writes unified
13
+ * SSSS session JSONL to .agent/skills/total-recall/sessions/.
14
+ *
15
+ * Supported sources:
16
+ * - Claude Code (~/.claude/projects/) JSONL
17
+ * - OpenAI Codex (~/.codex/sessions/) JSONL
18
+ * - Gemini CLI (~/.gemini/tmp/) JSON
19
+ * - Antigravity (~/.gemini/antigravity/brain/) plaintext overview.txt
20
+ * - Cursor (~/.cursor/projects/) JSONL
21
+ */
22
+
23
+ // ─── Source Definitions ─────────────────────────────────────────────────────────
24
+
25
+ const HOME = os.homedir();
26
+
27
+ /**
28
+ * Each source specifies a root directory to watch, a glob/filter for relevant
29
+ * files, and an adapter function that converts the source format to an array
30
+ * of unified SSSS session entries.
31
+ */
32
+ const SOURCES = [
33
+ {
34
+ name: 'claude-code',
35
+ root: path.join(HOME, '.claude', 'projects'),
36
+ filter: (filename) => filename.endsWith('.jsonl'),
37
+ adapter: parseClaudeCode,
38
+ },
39
+ {
40
+ name: 'codex',
41
+ root: path.join(HOME, '.codex', 'sessions'),
42
+ filter: (filename) => filename.endsWith('.jsonl'),
43
+ adapter: parseCodex,
44
+ },
45
+ {
46
+ name: 'gemini-cli',
47
+ root: path.join(HOME, '.gemini', 'tmp'),
48
+ filter: (filename) => filename.endsWith('.json'),
49
+ adapter: parseGeminiCli,
50
+ },
51
+ {
52
+ name: 'antigravity',
53
+ root: path.join(HOME, '.gemini', 'antigravity', 'brain'),
54
+ filter: (filename) => filename === 'overview.txt' || filename === 'transcript.jsonl',
55
+ adapter: parseAntigravity,
56
+ },
57
+ {
58
+ name: 'cursor',
59
+ root: path.join(HOME, '.cursor', 'projects'),
60
+ filter: (filename) => filename.endsWith('.jsonl'),
61
+ adapter: parseCursor,
62
+ },
63
+ {
64
+ name: 'vscode',
65
+ // VS Code Copilot Chat stores sessions per workspace under globalStorage
66
+ root: (() => {
67
+ if (process.platform === 'darwin') {
68
+ return path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage');
69
+ } else if (process.platform === 'win32') {
70
+ return path.join(HOME, 'AppData', 'Roaming', 'Code', 'User', 'workspaceStorage');
71
+ } else {
72
+ return path.join(HOME, '.config', 'Code', 'User', 'workspaceStorage');
73
+ }
74
+ })(),
75
+ filter: (filename) => filename.endsWith('.jsonl'),
76
+ // Only descend into chatSessions subdirs, ignore everything else
77
+ dirFilter: (dirName) => dirName === 'chatSessions',
78
+ adapter: parseVSCode,
79
+ },
80
+ ];
81
+
82
+ // ─── Unified Session Entry Format ───────────────────────────────────────────────
83
+
84
+ /**
85
+ * Create a unified SSSS session entry.
86
+ *
87
+ * @param {object} opts
88
+ * @param {string} opts.id Unique entry ID
89
+ * @param {string|null} opts.parentId Parent entry ID (null for root)
90
+ * @param {string} opts.type Entry type: task | tool_call | observation | branch_summary
91
+ * @param {string} opts.ts ISO 8601 timestamp
92
+ * @param {string} opts.content The text content
93
+ * @param {string} opts.role Original role: user | assistant | system | tool
94
+ * @param {string} opts.source Source adapter name
95
+ * @returns {object}
96
+ */
97
+ export function createSessionEntry({
98
+ id,
99
+ parentId = null,
100
+ type = 'observation',
101
+ ts,
102
+ content,
103
+ role = 'assistant',
104
+ source = 'unknown',
105
+ }) {
106
+ return {
107
+ id: id || crypto.randomBytes(4).toString('hex'),
108
+ parentId,
109
+ type,
110
+ ts: ts || new Date().toISOString(),
111
+ content: content || '',
112
+ role,
113
+ source,
114
+ };
115
+ }
116
+
117
+ // ─── Adapters ───────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Claude Code adapter.
121
+ *
122
+ * Claude Code JSONL format (actual, verified from ~/.claude/projects/):
123
+ * { parentUuid, uuid, timestamp, type, message: { role, content }, sessionId, cwd, ... }
124
+ *
125
+ * The content field inside message follows the Anthropic API format:
126
+ * - string (user messages)
127
+ * - array of content blocks: [{ type: 'text', text: '...' }, { type: 'tool_use', ... }]
128
+ *
129
+ * Legacy lines may have role/content at the top level — we fall back gracefully.
130
+ */
131
+ export function parseClaudeCode(filePath) {
132
+ const raw = fs.readFileSync(filePath, 'utf8');
133
+ const lines = raw.split('\n').filter(Boolean);
134
+ const entries = [];
135
+ // Build uuid→id map so we can preserve the real parent DAG
136
+ const uuidToId = new Map();
137
+
138
+ for (const line of lines) {
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(line);
142
+ } catch {
143
+ continue; // skip malformed lines
144
+ }
145
+
146
+ const id = crypto.randomBytes(4).toString('hex');
147
+
148
+ // ── Resolve parent ──────────────────────────────────────────────────────
149
+ const parentUuid = parsed.parentUuid || null;
150
+ let parentId = parentUuid ? (uuidToId.get(parentUuid) ?? null) : null;
151
+ if (!parentId && entries.length > 0) {
152
+ parentId = entries[entries.length - 1].id;
153
+ }
154
+ if (parsed.uuid) uuidToId.set(parsed.uuid, id);
155
+
156
+ // ── Extract role & content ───────────────────────────────────────────────
157
+ // Real Claude Code format: role/content inside parsed.message
158
+ // Legacy/fallback: role/content at top level
159
+ const msgObj = parsed.message || parsed;
160
+ const role = msgObj.role || parsed.type || 'assistant';
161
+ const rawContent = msgObj.content ?? '';
162
+
163
+ let content = '';
164
+ if (typeof rawContent === 'string') {
165
+ content = rawContent;
166
+ } else if (Array.isArray(rawContent)) {
167
+ content = rawContent
168
+ .map((block) => {
169
+ if (block.type === 'text') return block.text || '';
170
+ if (block.type === 'tool_use') return `[tool_use: ${block.name}]`;
171
+ if (block.type === 'tool_result') {
172
+ const c = block.content;
173
+ if (typeof c === 'string') return `[tool_result] ${c}`;
174
+ if (Array.isArray(c)) return c.filter(b => b.type === 'text').map(b => b.text).join(' ');
175
+ }
176
+ return '';
177
+ })
178
+ .filter(Boolean)
179
+ .join('\n');
180
+ }
181
+
182
+ // Inspect tool_use or tool_calls payloads
183
+ const toolUse = msgObj.tool_use || parsed.tool_use || null;
184
+ const toolCalls = msgObj.tool_calls || parsed.tool_calls || null;
185
+
186
+ if (toolUse && toolUse.name) {
187
+ const toolSig = `[tool: ${toolUse.name}]`;
188
+ if (!content.includes(toolSig)) {
189
+ content = content ? `${toolSig} ${content}` : toolSig;
190
+ }
191
+ }
192
+
193
+ if (Array.isArray(toolCalls)) {
194
+ for (const tc of toolCalls) {
195
+ const name = tc.function?.name || tc.name;
196
+ if (name) {
197
+ const toolSig = `[tool: ${name}]`;
198
+ if (!content.includes(toolSig)) {
199
+ content = content ? `${toolSig} ${content}` : toolSig;
200
+ }
201
+ }
202
+ }
203
+ } else if (toolCalls && typeof toolCalls === 'object') {
204
+ const name = toolCalls.function?.name || toolCalls.name;
205
+ if (name) {
206
+ const toolSig = `[tool: ${name}]`;
207
+ if (!content.includes(toolSig)) {
208
+ content = content ? `${toolSig} ${content}` : toolSig;
209
+ }
210
+ }
211
+ }
212
+
213
+ // ── Map role → SSSS entry type ───────────────────────────────────────────
214
+ let type = 'observation';
215
+ if (role === 'user') {
216
+ type = 'task';
217
+ } else if (role === 'tool' || role === 'tool_result' || toolUse || toolCalls || (role === 'assistant' && (content.includes('[tool_use:') || content.includes('[tool:')))) {
218
+ type = 'tool_call';
219
+ }
220
+
221
+ if (!content.trim()) continue; // skip empty lines (sidechain warmups, etc.)
222
+
223
+ entries.push(
224
+ createSessionEntry({
225
+ id,
226
+ parentId,
227
+ type,
228
+ ts: parsed.timestamp || msgObj.timestamp || new Date().toISOString(),
229
+ content: content.slice(0, 5000), // cap individual entries
230
+ role,
231
+ source: 'claude-code',
232
+ }),
233
+ );
234
+ }
235
+
236
+ return entries;
237
+ }
238
+
239
+ /**
240
+ * OpenAI Codex adapter.
241
+ * Codex writes JSONL to ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
242
+ * Each line has type, content, and tool call results.
243
+ */
244
+ export function parseCodex(filePath) {
245
+ const raw = fs.readFileSync(filePath, 'utf8');
246
+ const lines = raw.split('\n').filter(Boolean);
247
+ const entries = [];
248
+ let prevId = null;
249
+
250
+ for (const line of lines) {
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(line);
254
+ } catch {
255
+ continue;
256
+ }
257
+
258
+ const id = crypto.randomBytes(4).toString('hex');
259
+ const role = parsed.role || 'assistant';
260
+
261
+ let type = 'observation';
262
+ if (role === 'user') type = 'task';
263
+ else if (parsed.type === 'tool_call' || parsed.tool_calls) type = 'tool_call';
264
+
265
+ let content = '';
266
+ if (typeof parsed.content === 'string') {
267
+ content = parsed.content;
268
+ } else if (parsed.message) {
269
+ content = typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message);
270
+ }
271
+ if (parsed.tool_calls) {
272
+ const toolNames = parsed.tool_calls.map((tc) => tc.function?.name || 'unknown').join(', ');
273
+ content = `[tools: ${toolNames}] ${content}`;
274
+ }
275
+
276
+ entries.push(
277
+ createSessionEntry({
278
+ id,
279
+ parentId: prevId,
280
+ type,
281
+ ts: parsed.timestamp || parsed.ts || new Date().toISOString(),
282
+ content: content.slice(0, 5000),
283
+ role,
284
+ source: 'codex',
285
+ }),
286
+ );
287
+ prevId = id;
288
+ }
289
+
290
+ return entries;
291
+ }
292
+
293
+ /**
294
+ * Gemini CLI adapter.
295
+ * Gemini CLI saves chat sessions as JSON in ~/.gemini/tmp/<hash>/chats/
296
+ * The JSON contains a messages[] array.
297
+ */
298
+ export function parseGeminiCli(filePath) {
299
+ let data;
300
+ try {
301
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
302
+ } catch {
303
+ return [];
304
+ }
305
+
306
+ const messages = data.messages || data.history || [];
307
+ if (!Array.isArray(messages)) return [];
308
+
309
+ const entries = [];
310
+ let prevId = null;
311
+
312
+ for (const msg of messages) {
313
+ const id = crypto.randomBytes(4).toString('hex');
314
+ const role = msg.role || 'model';
315
+ const type = role === 'user' ? 'task' : 'observation';
316
+
317
+ let content = '';
318
+ if (typeof msg.parts === 'string') {
319
+ content = msg.parts;
320
+ } else if (Array.isArray(msg.parts)) {
321
+ content = msg.parts
322
+ .filter((p) => typeof p === 'string' || p.text)
323
+ .map((p) => (typeof p === 'string' ? p : p.text))
324
+ .join('\n');
325
+ } else if (typeof msg.content === 'string') {
326
+ content = msg.content;
327
+ }
328
+
329
+ entries.push(
330
+ createSessionEntry({
331
+ id,
332
+ parentId: prevId,
333
+ type,
334
+ ts: msg.timestamp || msg.create_time || new Date().toISOString(),
335
+ content: content.slice(0, 5000),
336
+ role: role === 'model' ? 'assistant' : role,
337
+ source: 'gemini-cli',
338
+ }),
339
+ );
340
+ prevId = id;
341
+ }
342
+
343
+ return entries;
344
+ }
345
+
346
+ /**
347
+ * Antigravity adapter.
348
+ * Antigravity stores conversation artifacts at:
349
+ * ~/.gemini/antigravity/brain/<conversation-id>/.system_generated/logs/overview.txt
350
+ * These are plaintext transcripts that we parse heuristically.
351
+ */
352
+ export function parseAntigravity(filePath) {
353
+ let raw;
354
+ try {
355
+ raw = fs.readFileSync(filePath, 'utf8');
356
+ } catch {
357
+ return [];
358
+ }
359
+
360
+ if (path.basename(filePath) === 'transcript.jsonl') {
361
+ const lines = raw.split('\n').filter(Boolean);
362
+ const entries = [];
363
+ let prevId = null;
364
+
365
+ for (const line of lines) {
366
+ let data;
367
+ try {
368
+ data = JSON.parse(line);
369
+ } catch {
370
+ continue;
371
+ }
372
+
373
+ if (data.type === 'USER_INPUT') {
374
+ const id = crypto.randomBytes(4).toString('hex');
375
+ let cleanContent = data.content || '';
376
+ if (cleanContent.includes('<USER_REQUEST>')) {
377
+ const match = cleanContent.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
378
+ if (match) {
379
+ cleanContent = match[1].trim();
380
+ }
381
+ }
382
+ if (!cleanContent.trim()) continue;
383
+
384
+ entries.push(
385
+ createSessionEntry({
386
+ id,
387
+ parentId: prevId,
388
+ type: 'task',
389
+ ts: data.timestamp || new Date().toISOString(),
390
+ content: cleanContent.slice(0, 5000),
391
+ role: 'user',
392
+ source: 'antigravity',
393
+ })
394
+ );
395
+ prevId = id;
396
+ } else if (
397
+ data.source === 'MODEL' &&
398
+ (data.type === 'PLANNER_RESPONSE' || data.type === 'RESPONSE' || data.type === 'model_response')
399
+ ) {
400
+ let content = data.content || '';
401
+ if (!content.trim()) continue;
402
+
403
+ const id = crypto.randomBytes(4).toString('hex');
404
+ entries.push(
405
+ createSessionEntry({
406
+ id,
407
+ parentId: prevId,
408
+ type: 'observation',
409
+ ts: data.timestamp || new Date().toISOString(),
410
+ content: content.slice(0, 5000),
411
+ role: 'assistant',
412
+ source: 'antigravity',
413
+ })
414
+ );
415
+ prevId = id;
416
+ }
417
+ }
418
+ return entries;
419
+ }
420
+
421
+ // overview.txt is a line-by-line action log
422
+ // Each line typically starts with a role indicator or action description
423
+ const lines = raw.split('\n').filter(Boolean);
424
+ const entries = [];
425
+ let prevId = null;
426
+
427
+ for (const line of lines) {
428
+ const id = crypto.randomBytes(4).toString('hex');
429
+ const trimmed = line.trim();
430
+ if (!trimmed) continue;
431
+
432
+ // Heuristic: detect role from line patterns
433
+ let role = 'assistant';
434
+ let type = 'observation';
435
+
436
+ if (trimmed.startsWith('USER:') || trimmed.startsWith('User:') || trimmed.startsWith('[user]')) {
437
+ role = 'user';
438
+ type = 'task';
439
+ } else if (trimmed.startsWith('TOOL:') || trimmed.startsWith('[tool]') || trimmed.includes('tool_call')) {
440
+ role = 'tool';
441
+ type = 'tool_call';
442
+ }
443
+
444
+ entries.push(
445
+ createSessionEntry({
446
+ id,
447
+ parentId: prevId,
448
+ type,
449
+ content: trimmed.slice(0, 5000),
450
+ role,
451
+ source: 'antigravity',
452
+ }),
453
+ );
454
+ prevId = id;
455
+ }
456
+
457
+ return entries;
458
+ }
459
+
460
+ /**
461
+ * Cursor adapter.
462
+ * Newer Cursor versions write JSONL transcripts to ~/.cursor/projects/
463
+ * Format is similar to Claude Code.
464
+ */
465
+ export function parseCursor(filePath) {
466
+ const raw = fs.readFileSync(filePath, 'utf8');
467
+ const lines = raw.split('\n').filter(Boolean);
468
+ const entries = [];
469
+ let prevId = null;
470
+
471
+ for (const line of lines) {
472
+ let parsed;
473
+ try {
474
+ parsed = JSON.parse(line);
475
+ } catch {
476
+ continue;
477
+ }
478
+
479
+ const id = crypto.randomBytes(4).toString('hex');
480
+ const role = parsed.role || 'assistant';
481
+
482
+ let type = 'observation';
483
+ if (role === 'user') type = 'task';
484
+ else if (parsed.type === 'tool_call') type = 'tool_call';
485
+
486
+ const content = typeof parsed.content === 'string'
487
+ ? parsed.content
488
+ : JSON.stringify(parsed.content || '');
489
+
490
+ entries.push(
491
+ createSessionEntry({
492
+ id,
493
+ parentId: prevId,
494
+ type,
495
+ ts: parsed.timestamp || parsed.ts || new Date().toISOString(),
496
+ content: content.slice(0, 5000),
497
+ role,
498
+ source: 'cursor',
499
+ }),
500
+ );
501
+ prevId = id;
502
+ }
503
+
504
+ return entries;
505
+ }
506
+
507
+ /**
508
+ * VS Code Copilot Chat adapter.
509
+ * Stores sessions as JSONL under:
510
+ * ~/Library/Application Support/Code/User/workspaceStorage/<id>/chatSessions/<session>.jsonl
511
+ *
512
+ * Each file is a sequence of delta records:
513
+ * kind:0 — session header (initialises the session object)
514
+ * kind:1 — patch (key/value updates to session state)
515
+ * kind:2 — requests array update (the actual chat turns)
516
+ */
517
+ export function parseVSCode(filePath) {
518
+ const raw = fs.readFileSync(filePath, 'utf8');
519
+ const lines = raw.split('\n').filter(Boolean);
520
+ const entries = [];
521
+ let sessionTitle = '';
522
+ let prevId = null;
523
+
524
+ // Reconstruct the full requests list by replaying kind:2 patches
525
+ const requests = [];
526
+ for (const line of lines) {
527
+ let rec;
528
+ try { rec = JSON.parse(line); } catch { continue; }
529
+
530
+ if (rec.kind === 1 && Array.isArray(rec.k) && rec.k.includes('customTitle')) {
531
+ sessionTitle = rec.v || '';
532
+ }
533
+ if (rec.kind === 2 && Array.isArray(rec.k) && rec.k.includes('requests')) {
534
+ // Merge into requests array
535
+ const incoming = Array.isArray(rec.v) ? rec.v : [];
536
+ for (const req of incoming) {
537
+ if (!requests.find((r) => r.requestId === req.requestId)) {
538
+ requests.push(req);
539
+ }
540
+ }
541
+ }
542
+ }
543
+
544
+ for (const req of requests) {
545
+ const ts = req.timestamp ? new Date(req.timestamp).toISOString() : new Date().toISOString();
546
+
547
+ // User turn
548
+ const userText = typeof req.message?.text === 'string' ? req.message.text : '';
549
+ if (userText) {
550
+ const id = crypto.randomBytes(4).toString('hex');
551
+ entries.push(createSessionEntry({
552
+ id, parentId: prevId, type: 'task', ts,
553
+ content: userText.slice(0, 5000), role: 'user', source: 'vscode',
554
+ }));
555
+ prevId = id;
556
+ }
557
+
558
+ // Assistant response(s)
559
+ const responseText = req.response?.response?.value || req.response?.value || '';
560
+ if (responseText) {
561
+ const id = crypto.randomBytes(4).toString('hex');
562
+ entries.push(createSessionEntry({
563
+ id, parentId: prevId, type: 'observation', ts,
564
+ content: responseText.slice(0, 5000), role: 'assistant', source: 'vscode',
565
+ }));
566
+ prevId = id;
567
+ }
568
+ }
569
+
570
+ return entries;
571
+ }
572
+
573
+ // ─── Session Writer ─────────────────────────────────────────────────────────────
574
+
575
+ // ─── Content-Hash Deduplication ─────────────────────────────────────────────────
576
+
577
+ const CONTENT_HASH_INDEX = 'content-hashes.jsonl';
578
+
579
+ /**
580
+ * SHA-256 fingerprint of an entry's content field.
581
+ * Two entries with identical content get the same fingerprint regardless of
582
+ * source, timestamp, or id — so the same fact from two IDE sources collapses.
583
+ */
584
+ export function contentFingerprint(entry) {
585
+ return crypto.createHash('sha256').update(String(entry.content || '')).digest('hex');
586
+ }
587
+
588
+ /**
589
+ * Load the set of already-seen content hashes from the derived index.
590
+ * Returns a Set<string>.
591
+ */
592
+ export function loadSeenHashes(derivedDir) {
593
+ const indexFile = path.join(derivedDir, CONTENT_HASH_INDEX);
594
+ if (!fs.existsSync(indexFile)) return new Set();
595
+ const seen = new Set();
596
+ for (const line of fs.readFileSync(indexFile, 'utf8').split('\n')) {
597
+ if (!line.trim()) continue;
598
+ try { seen.add(JSON.parse(line).sha256); } catch { /* corrupt line */ }
599
+ }
600
+ return seen;
601
+ }
602
+
603
+ /**
604
+ * Filter `entries` to remove those whose content hash is already in `seen`.
605
+ * Appends new hashes to the derived index file and returns the unique entries.
606
+ *
607
+ * @param {object[]} entries Session entries to deduplicate
608
+ * @param {string} derivedDir Path to .agent/skills/total-recall/memory-derived/
609
+ * @param {string} [source] Source name for index provenance
610
+ * @returns {{ unique: object[], duplicates: number }}
611
+ */
612
+ export function deduplicateByContent(entries, derivedDir, source = 'unknown') {
613
+ if (!derivedDir) return { unique: entries, duplicates: 0 };
614
+ const seen = loadSeenHashes(derivedDir);
615
+ const unique = [];
616
+ const newLines = [];
617
+
618
+ for (const entry of entries) {
619
+ const sha256 = contentFingerprint(entry);
620
+ if (seen.has(sha256)) continue;
621
+ seen.add(sha256);
622
+ unique.push(entry);
623
+ newLines.push(JSON.stringify({ sha256, source, ts: new Date().toISOString() }));
624
+ }
625
+
626
+ if (newLines.length) {
627
+ if (!fs.existsSync(derivedDir)) fs.mkdirSync(derivedDir, { recursive: true });
628
+ fs.appendFileSync(path.join(derivedDir, CONTENT_HASH_INDEX), newLines.join('\n') + '\n', 'utf8');
629
+ }
630
+
631
+ return { unique, duplicates: entries.length - unique.length };
632
+ }
633
+
634
+ /**
635
+ * Write parsed session entries to the sessions directory as JSONL.
636
+ *
637
+ * @param {object[]} entries Array of unified session entries
638
+ * @param {string} sessionsDir Path to .agent/skills/total-recall/sessions/
639
+ * @param {string} sourceFile Original source file path (used for dedup)
640
+ * @returns {string} Path to the written session file
641
+ */
642
+ export function writeSession(entries, sessionsDir, sourceFile) {
643
+ if (!entries.length) return null;
644
+ if (!fs.existsSync(sessionsDir)) {
645
+ fs.mkdirSync(sessionsDir, { recursive: true });
646
+ }
647
+
648
+ // Generate a deterministic session ID from the source file path
649
+ // so re-ingesting the same file doesn't create duplicates
650
+ const sourceHash = crypto.createHash('sha256').update(sourceFile).digest('hex').slice(0, 12);
651
+ const sessionFile = path.join(sessionsDir, `${sourceHash}.jsonl`);
652
+
653
+ const jsonl = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
654
+
655
+ // Skip if already ingested and unchanged
656
+ if (fs.existsSync(sessionFile)) {
657
+ try {
658
+ if (fs.readFileSync(sessionFile, 'utf8') === jsonl) {
659
+ return null;
660
+ }
661
+ } catch { /* ignore */ }
662
+ }
663
+
664
+ atomicWrite(sessionFile, jsonl);
665
+
666
+ logger.info({
667
+ subsystem: 'session-watcher',
668
+ message: `Ingested ${entries.length} entries from ${path.basename(sourceFile)} → ${path.basename(sessionFile)}`,
669
+ });
670
+
671
+ return sessionFile;
672
+ }
673
+
674
+ // ─── File Discovery ─────────────────────────────────────────────────────────────
675
+
676
+ /**
677
+ * Recursively find files matching a filter under a root directory.
678
+ * Limits depth to 5 levels to avoid runaway scans.
679
+ */
680
+ function findFiles(root, filter, maxDepth = 5, dirFilter = null) {
681
+ const results = [];
682
+ if (!fs.existsSync(root)) return results;
683
+
684
+ function walk(dir, depth) {
685
+ if (depth > maxDepth) return;
686
+ let entries;
687
+ try {
688
+ entries = fs.readdirSync(dir, { withFileTypes: true });
689
+ } catch {
690
+ return; // permission denied, etc.
691
+ }
692
+ for (const entry of entries) {
693
+ const fullPath = path.join(dir, entry.name);
694
+ if (entry.isDirectory()) {
695
+ // If a dirFilter is set, only recurse into matching dirs (or any dir at depth 0)
696
+ if (!dirFilter || depth === 0 || dirFilter(entry.name)) {
697
+ walk(fullPath, depth + 1);
698
+ }
699
+ } else if (entry.isFile() && filter(entry.name)) {
700
+ results.push(fullPath);
701
+ }
702
+ }
703
+ }
704
+
705
+ walk(root, 0);
706
+ return results;
707
+ }
708
+
709
+ // ─── Scan & Ingest ──────────────────────────────────────────────────────────────
710
+
711
+ /**
712
+ * Scan all known IDE sources and ingest any new conversation files.
713
+ *
714
+ * @param {string} sessionsDir Path to .agent/skills/total-recall/sessions/
715
+ * @param {object} [opts]
716
+ * @param {string[]} [opts.enabledSources] Only scan these sources (default: all)
717
+ * @returns {{ ingested: number, sources: object[] }}
718
+ */
719
+ export function scanAndIngest(sessionsDir, opts = {}) {
720
+ const enabledSources = opts.enabledSources || SOURCES.map((s) => s.name);
721
+ const derivedDir = opts.derivedDir || null;
722
+ const results = [];
723
+ let totalIngested = 0;
724
+ let totalDuplicates = 0;
725
+
726
+ for (const source of SOURCES) {
727
+ if (!enabledSources.includes(source.name)) continue;
728
+ if (!fs.existsSync(source.root)) {
729
+ results.push({ name: source.name, status: 'not-found', files: 0 });
730
+ continue;
731
+ }
732
+
733
+ const files = findFiles(source.root, source.filter, 5, source.dirFilter || null);
734
+ let ingested = 0;
735
+
736
+ for (const file of files) {
737
+ try {
738
+ let entries = source.adapter(file);
739
+ if (entries.length > 0) {
740
+ // Content-hash dedup: collapse identical facts from multiple sources
741
+ if (derivedDir) {
742
+ const { unique, duplicates } = deduplicateByContent(entries, derivedDir, source.name);
743
+ totalDuplicates += duplicates;
744
+ entries = unique;
745
+ }
746
+ if (entries.length > 0) {
747
+ const written = writeSession(entries, sessionsDir, file);
748
+ if (written) {
749
+ ingested++;
750
+ totalIngested++;
751
+ }
752
+ }
753
+ }
754
+ } catch (err) {
755
+ logger.info({
756
+ subsystem: 'session-watcher',
757
+ message: `Failed to ingest ${file}: ${err.message}`,
758
+ });
759
+ }
760
+ }
761
+
762
+ results.push({ name: source.name, status: 'scanned', files: ingested });
763
+ }
764
+
765
+ return { ingested: totalIngested, duplicates: totalDuplicates, sources: results };
766
+ }
767
+
768
+ // ─── File Watcher (Daemon Mode) ─────────────────────────────────────────────────
769
+
770
+ /**
771
+ * Start watching all known IDE source directories for new conversation files.
772
+ * Returns a cleanup function to stop all watchers.
773
+ *
774
+ * @param {string} sessionsDir Path to .agent/skills/total-recall/sessions/
775
+ * @param {object} [opts]
776
+ * @param {string[]} [opts.enabledSources] Only watch these sources (default: all)
777
+ * @returns {{ stop: () => void }}
778
+ */
779
+ export function startWatching(sessionsDir, opts = {}) {
780
+ const enabledSources = opts.enabledSources || SOURCES.map((s) => s.name);
781
+ const watchers = [];
782
+
783
+ for (const source of SOURCES) {
784
+ if (!enabledSources.includes(source.name)) continue;
785
+ if (!fs.existsSync(source.root)) continue;
786
+
787
+ try {
788
+ let watchOpts = {};
789
+ if (process.platform === 'darwin' || process.platform === 'win32') {
790
+ watchOpts.recursive = true;
791
+ }
792
+
793
+ let watcher;
794
+ const callback = (eventType, filename) => {
795
+ if (!filename || !source.filter(path.basename(filename))) return;
796
+
797
+ const fullPath = path.join(source.root, filename);
798
+ if (!fs.existsSync(fullPath)) return; // deleted
799
+
800
+ // Small delay to let the file finish writing
801
+ setTimeout(() => {
802
+ try {
803
+ const entries = source.adapter(fullPath);
804
+ if (entries.length > 0) {
805
+ writeSession(entries, sessionsDir, fullPath);
806
+ }
807
+ } catch (err) {
808
+ logger.info({
809
+ subsystem: 'session-watcher',
810
+ message: `Watch handler error for ${filename}: ${err.message}`,
811
+ });
812
+ }
813
+ }, 500);
814
+ };
815
+
816
+ try {
817
+ watcher = fs.watch(source.root, watchOpts, callback);
818
+ } catch (watchErr) {
819
+ logger.info({
820
+ subsystem: 'session-watcher',
821
+ message: `Recursive watch fallback on ${process.platform}: ${watchErr.message}`,
822
+ });
823
+ watcher = fs.watch(source.root, {}, callback);
824
+ }
825
+
826
+ watchers.push(watcher);
827
+ logger.info({
828
+ subsystem: 'session-watcher',
829
+ message: `Watching ${source.name}: ${source.root}`,
830
+ });
831
+ } catch (err) {
832
+ logger.info({
833
+ subsystem: 'session-watcher',
834
+ message: `Cannot watch ${source.name} (${source.root}): ${err.message}`,
835
+ });
836
+ }
837
+ }
838
+
839
+ return {
840
+ stop() {
841
+ for (const w of watchers) {
842
+ try { w.close(); } catch { /* ignore */ }
843
+ }
844
+ logger.info({
845
+ subsystem: 'session-watcher',
846
+ message: `Stopped ${watchers.length} watchers.`,
847
+ });
848
+ },
849
+ };
850
+ }