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,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
|
+
}
|