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,102 @@
1
+ /**
2
+ * src/cli/import-rules.mjs
3
+ *
4
+ * CLI handler for: npx total-recall import [--dir <path>] [--force] [--dry-run]
5
+ *
6
+ * Detects existing agent rule files and imports them into the vault.
7
+ */
8
+
9
+ import path from 'node:path';
10
+ import { detectAndImport } from '../core/import-rules.mjs';
11
+ import { resolveAgentDir, resolveBrainDir } from './agent-dir.mjs';
12
+
13
+ export async function run(argv = []) {
14
+ const args = argv.slice(3); // strip node, script, 'import'
15
+
16
+ const dirs = [];
17
+ let force = false;
18
+ let dryRun = false;
19
+
20
+ for (let i = 0; i < args.length; i++) {
21
+ if ((args[i] === '--dir' || args[i] === '-d') && args[i + 1]) {
22
+ dirs.push(path.resolve(args[++i]));
23
+ } else if (args[i] === '--force' || args[i] === '-f') {
24
+ force = true;
25
+ } else if (args[i] === '--dry-run') {
26
+ dryRun = true;
27
+ } else if (args[i] === '--help' || args[i] === '-h') {
28
+ printHelp();
29
+ return;
30
+ }
31
+ }
32
+
33
+ // Default: search cwd
34
+ if (dirs.length === 0) dirs.push(process.cwd());
35
+
36
+ const brainDir = resolveBrainDir();
37
+ const vaultDir = path.join(brainDir, 'memory-vault');
38
+
39
+ console.log(`\n🔍 Scanning for rule files in: ${dirs.join(', ')}\n`);
40
+
41
+ const result = detectAndImport({ dirs, force, vaultDir, dryRun });
42
+
43
+ if (result.detected.length === 0) {
44
+ console.log('ℹ️ No rule files detected. Nothing to import.');
45
+ console.log(' (Looked for: AGENTS.md, CLAUDE.md, .cursorrules, .clauderules, etc.)\n');
46
+ return;
47
+ }
48
+
49
+ console.log(`📋 Detected ${result.detected.length} rule file(s):\n`);
50
+ for (const f of result.detected) {
51
+ const status = f.alreadyImported ? ' [already imported]' : '';
52
+ console.log(` ${f.alreadyImported ? '⚪' : '✅'} ${f.filename.padEnd(30)} ${f.label}${status}`);
53
+ console.log(` ${f.absolutePath}`);
54
+ console.log(` ${(f.size / 1024).toFixed(1)}KB — preview: ${f.preview.slice(0, 80).replace(/\n/g, ' ')}…\n`);
55
+ }
56
+
57
+ if (dryRun) {
58
+ console.log(`🔎 Dry run — would import ${result.toImport.length} file(s). Run without --dry-run to proceed.\n`);
59
+ return;
60
+ }
61
+
62
+ if (result.imported.length > 0) {
63
+ console.log(`\n✅ Imported ${result.imported.length} file(s) into vault:\n`);
64
+ for (const n of result.imported) {
65
+ console.log(` → ${n.slug} (${(n.size / 1024).toFixed(1)}KB)`);
66
+ }
67
+ }
68
+
69
+ if (result.skipped.length > 0) {
70
+ console.log(`\n⚪ Skipped ${result.skipped.length} (already imported — use --force to re-import)`);
71
+ }
72
+
73
+ if (result.failed.length > 0) {
74
+ console.log(`\n❌ Failed ${result.failed.length}:`);
75
+ for (const f of result.failed) console.log(` ${f.filename}: ${f.error}`);
76
+ }
77
+
78
+ if (result.imported.length > 0) {
79
+ console.log('\n💡 Run npx total-recall compile to rebuild INSTRUCTIONS.md with your imported rules.\n');
80
+ }
81
+ }
82
+
83
+ function printHelp() {
84
+ console.log(`
85
+ npx total-recall import [options]
86
+
87
+ Detects existing agent rule files (AGENTS.md, .cursorrules, CLAUDE.md, etc.)
88
+ and imports them into your Total Recall memory vault so they are included in
89
+ all future AI sessions.
90
+
91
+ Options:
92
+ --dir <path> Directory to search (default: current directory)
93
+ --force Re-import files that were previously imported
94
+ --dry-run Show what would be imported without writing anything
95
+ --help Show this help
96
+
97
+ Examples:
98
+ npx total-recall import
99
+ npx total-recall import --dir ~/my-project
100
+ npx total-recall import --force --dry-run
101
+ `);
102
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Google Takeout Ingestion — CLI entry point
3
+ *
4
+ * Orchestrates the ingestion of Google Takeout export data into the
5
+ * Total Recall memory vault. Walks the Takeout directory, detects
6
+ * available data types, runs parsers, deduplicates, and writes nodes.
7
+ *
8
+ * Usage:
9
+ * npx total-recall ingest google-takeout <path> [options]
10
+ *
11
+ * Options:
12
+ * --dry-run Preview what would be ingested without writing
13
+ * --types <comma-list> Only process specified data types
14
+ * --brain <id> Target brain ID (default: auto-detect)
15
+ * --max-age <duration> Only include entries newer than duration (e.g. 90d, 1y)
16
+ * --help, -h Show help
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { walkTakeoutDir } from './utils/takeout-walker.mjs';
22
+ import { dedup } from './utils/dedup.mjs';
23
+ import { parseSearchHistory } from './parsers/search-history.mjs';
24
+ import { parseChromeBookmarks } from './parsers/chrome-bookmarks.mjs';
25
+ import { parseGoogleKeep } from './parsers/google-keep.mjs';
26
+ import { parseYoutubeHistory } from './parsers/youtube-history.mjs';
27
+ import { writeNode, loadNodes } from '../../core/vault.mjs';
28
+ import { resolveBrainDir, parseLayerFlag, getBothBrains, defaultLayerForCategory } from '../../cli/agent-dir.mjs';
29
+
30
+ /**
31
+ * Parser registry: maps data type names to their directory detection
32
+ * signatures and parser functions.
33
+ */
34
+ const PARSERS = {
35
+ 'search-history': { detect: 'My Activity/Search', parser: parseSearchHistory },
36
+ 'chrome-bookmarks': { detect: 'Chrome/Bookmarks', parser: parseChromeBookmarks },
37
+ 'google-keep': { detect: 'Keep', parser: parseGoogleKeep },
38
+ 'youtube-history': { detect: 'YouTube and YouTube Music/history', parser: parseYoutubeHistory },
39
+ };
40
+
41
+ /**
42
+ * Parse a human-readable duration string into a Date threshold.
43
+ * Supports: 30d, 90d, 1y, 6m, etc.
44
+ *
45
+ * @param {string} duration
46
+ * @returns {Date}
47
+ */
48
+ function parseDuration(duration) {
49
+ const match = duration.match(/^(\d+)\s*(d|m|y|w)$/i);
50
+ if (!match) throw new Error(`Invalid duration format: "${duration}". Use e.g. 30d, 6m, 1y`);
51
+
52
+ const value = parseInt(match[1], 10);
53
+ const unit = match[2].toLowerCase();
54
+ const now = new Date();
55
+
56
+ switch (unit) {
57
+ case 'd': now.setDate(now.getDate() - value); break;
58
+ case 'w': now.setDate(now.getDate() - (value * 7)); break;
59
+ case 'm': now.setMonth(now.getMonth() - value); break;
60
+ case 'y': now.setFullYear(now.getFullYear() - value); break;
61
+ }
62
+
63
+ return now;
64
+ }
65
+
66
+ /**
67
+ * Parse CLI arguments for the google-takeout ingest command.
68
+ *
69
+ * @param {string[]} args — CLI arguments after 'ingest google-takeout'.
70
+ * @returns {{ takeoutPath: string|null, dryRun: boolean, types: string[]|null, brain: string|null, maxAge: Date|null, help: boolean, layer: string }}
71
+ */
72
+ function parseArgs(args) {
73
+ const { layer, remainingArgs } = parseLayerFlag(args);
74
+ const opts = { takeoutPath: null, dryRun: false, types: null, brain: null, maxAge: null, help: false, layer };
75
+
76
+ for (let i = 0; i < remainingArgs.length; i++) {
77
+ const arg = remainingArgs[i];
78
+ const val = remainingArgs[i + 1];
79
+
80
+ switch (arg) {
81
+ case '--dry-run':
82
+ opts.dryRun = true;
83
+ break;
84
+ case '--types':
85
+ if (val) { opts.types = val.split(',').map(t => t.trim()); i++; }
86
+ break;
87
+ case '--brain':
88
+ if (val) { opts.brain = val; i++; }
89
+ break;
90
+ case '--max-age':
91
+ if (val) { opts.maxAge = parseDuration(val); i++; }
92
+ break;
93
+ case '--help': case '-h':
94
+ opts.help = true;
95
+ break;
96
+ default:
97
+ if (!arg.startsWith('-') && !opts.takeoutPath) {
98
+ opts.takeoutPath = arg;
99
+ }
100
+ break;
101
+ }
102
+ }
103
+
104
+ return opts;
105
+ }
106
+
107
+ /**
108
+ * Print help text for the google-takeout ingest command.
109
+ */
110
+ function printHelp() {
111
+ console.log(`
112
+ total-recall ingest google-takeout — Ingest Google Takeout data
113
+
114
+ Walks a Google Takeout export directory, detects available data types,
115
+ parses them into SSSS memory nodes, deduplicates against the existing
116
+ vault, and writes new nodes.
117
+
118
+ Usage: total-recall ingest google-takeout <path> [options]
119
+
120
+ Supported data types:
121
+ search-history Google Search history (My Activity/Search/)
122
+ chrome-bookmarks Chrome bookmarks (Chrome/Bookmarks.html or .json)
123
+ google-keep Google Keep notes (Keep/*.json)
124
+ youtube-history YouTube watch history (YouTube and YouTube Music/history/)
125
+
126
+ Options:
127
+ --dry-run Preview what would be ingested without writing
128
+ --types <comma-list> Only process specified types (e.g. "search-history,google-keep")
129
+ --brain <id> Target brain ID
130
+ --max-age <duration> Only include entries newer than duration (30d, 6m, 1y)
131
+ --global Write to global brain
132
+ --project Write to project brain
133
+ --help, -h Show this help
134
+
135
+ Examples:
136
+ npx total-recall ingest google-takeout ~/Downloads/Takeout
137
+ npx total-recall ingest google-takeout ~/Downloads/Takeout --dry-run
138
+ npx total-recall ingest google-takeout ~/Downloads/Takeout --types search-history,google-keep
139
+ npx total-recall ingest google-takeout ~/Downloads/Takeout --max-age 90d
140
+ `);
141
+ }
142
+
143
+ /**
144
+ * Main ingestion function for Google Takeout data.
145
+ *
146
+ * @param {string} takeoutPath — Path to the Takeout export directory.
147
+ * @param {object} options
148
+ * @param {boolean} [options.dryRun=false] — Preview mode; don't write nodes.
149
+ * @param {string[]|null} [options.types=null] — Filter to specific data types.
150
+ * @param {string|null} [options.brain=null] — Target brain ID.
151
+ * @param {Date|null} [options.maxAge=null] — Only include entries newer than this.
152
+ * @param {string} [options.layer='auto'] — Brain layer target.
153
+ * @returns {Promise<{ detected: number, parsed: number, deduplicated: number, written: number, stats: object }>}
154
+ */
155
+ export async function ingestGoogleTakeout(takeoutPath, options = {}) {
156
+ const { dryRun = false, types = null, maxAge = null, layer = 'auto' } = options;
157
+
158
+ // Validate path exists
159
+ const resolvedPath = path.resolve(takeoutPath);
160
+ if (!fs.existsSync(resolvedPath)) {
161
+ throw new Error(`Takeout directory not found: ${resolvedPath}`);
162
+ }
163
+
164
+ // Resolve brain target
165
+ let effectiveLayer = layer;
166
+ if (effectiveLayer === 'auto') {
167
+ effectiveLayer = defaultLayerForCategory('facts');
168
+ const project = getBothBrains().project;
169
+ if (effectiveLayer === 'project' && !project) {
170
+ effectiveLayer = 'global';
171
+ }
172
+ }
173
+
174
+ const brainDir = resolveBrainDir(effectiveLayer);
175
+ const vaultDir = path.join(brainDir, 'memory-vault');
176
+
177
+ // Walk directory to detect available data types
178
+ console.error('\n 🔍 Scanning Takeout directory...\n');
179
+ const detected = walkTakeoutDir(resolvedPath);
180
+
181
+ if (detected.length === 0) {
182
+ console.error(' ⚠️ No recognized Takeout data types found.\n');
183
+ return { detected: 0, parsed: 0, deduplicated: 0, written: 0, stats: {} };
184
+ }
185
+
186
+ // Filter by requested types
187
+ const toProcess = types
188
+ ? detected.filter(d => types.includes(d.type))
189
+ : detected;
190
+
191
+ if (toProcess.length === 0) {
192
+ console.error(' ⚠️ No matching data types found for the specified filter.\n');
193
+ return { detected: detected.length, parsed: 0, deduplicated: 0, written: 0, stats: {} };
194
+ }
195
+
196
+ console.error(` 📦 Detected ${detected.length} data type(s):`);
197
+ for (const d of detected) {
198
+ const included = toProcess.some(t => t.type === d.type);
199
+ const icon = included ? '🟢' : '⚪';
200
+ console.error(` ${icon} ${d.type} → ${d.path}`);
201
+ }
202
+ console.error('');
203
+
204
+ // Load existing vault nodes for dedup
205
+ let existingNodes = [];
206
+ if (fs.existsSync(vaultDir)) {
207
+ existingNodes = loadNodes(vaultDir);
208
+ }
209
+
210
+ // Run parsers for each detected type
211
+ const stats = {};
212
+ let allNewNodes = [];
213
+
214
+ for (const source of toProcess) {
215
+ const parserEntry = PARSERS[source.type];
216
+ if (!parserEntry) {
217
+ stats[source.type] = { parsed: 0, error: 'No parser available' };
218
+ continue;
219
+ }
220
+
221
+ console.error(` ⏳ Parsing ${source.type}...`);
222
+ try {
223
+ const parserOpts = maxAge ? { maxAge } : {};
224
+ const nodes = parserEntry.parser(source.path, parserOpts);
225
+ stats[source.type] = { parsed: nodes.length };
226
+ allNewNodes.push(...nodes);
227
+ console.error(` ✅ ${nodes.length} entries parsed`);
228
+ } catch (err) {
229
+ stats[source.type] = { parsed: 0, error: err.message };
230
+ console.error(` ❌ Error: ${err.message}`);
231
+ }
232
+ }
233
+
234
+ // Dedup against existing vault
235
+ console.error('\n 🔄 Deduplicating against existing vault...');
236
+ const { unique, duplicateCount } = dedup(allNewNodes, existingNodes);
237
+ console.error(` Found ${duplicateCount} duplicate(s), ${unique.length} new node(s)\n`);
238
+
239
+ // Write nodes
240
+ let written = 0;
241
+ if (dryRun) {
242
+ console.error(' 🏷️ DRY RUN — No nodes written.\n');
243
+ } else {
244
+ // Ensure vault directory exists
245
+ if (!fs.existsSync(vaultDir)) {
246
+ fs.mkdirSync(vaultDir, { recursive: true });
247
+ }
248
+
249
+ for (const node of unique) {
250
+ try {
251
+ writeNode(node, vaultDir);
252
+ written++;
253
+ } catch (err) {
254
+ console.error(` ⚠️ Failed to write ${node.slug}: ${err.message}`);
255
+ }
256
+ }
257
+ console.error(` ✅ Wrote ${written} node(s) to vault\n`);
258
+ }
259
+
260
+ // Print summary report
261
+ const layerLabel = effectiveLayer === 'project' ? '[project]' : '[global]';
262
+ console.error(' ═══════════════════════════════════════');
263
+ console.error(' 📊 Ingestion Summary');
264
+ console.error(' ═══════════════════════════════════════');
265
+ console.error(` Brain: ${layerLabel} ${brainDir}`);
266
+ console.error(` Detected: ${detected.length} data type(s)`);
267
+ console.error(` Processed: ${toProcess.length} data type(s)`);
268
+ console.error(` Parsed: ${allNewNodes.length} total entries`);
269
+ console.error(` Duplicates: ${duplicateCount}`);
270
+ console.error(` Written: ${dryRun ? `${unique.length} (dry run)` : written}`);
271
+ console.error(' ───────────────────────────────────────');
272
+ for (const [type, info] of Object.entries(stats)) {
273
+ const status = info.error ? `❌ ${info.error}` : `${info.parsed} entries`;
274
+ console.error(` ${type}: ${status}`);
275
+ }
276
+ console.error(' ═══════════════════════════════════════\n');
277
+
278
+ return {
279
+ detected: detected.length,
280
+ parsed: allNewNodes.length,
281
+ deduplicated: duplicateCount,
282
+ written: dryRun ? 0 : written,
283
+ stats,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * CLI handler for `npx total-recall ingest google-takeout <path>`.
289
+ *
290
+ * @param {string[]} args — CLI arguments after 'ingest google-takeout'.
291
+ */
292
+ export async function runGoogleTakeout(args) {
293
+ const opts = parseArgs(args);
294
+
295
+ if (opts.help) {
296
+ printHelp();
297
+ return;
298
+ }
299
+
300
+ if (!opts.takeoutPath) {
301
+ console.error(' ❌ Error: No Takeout directory path specified.\n');
302
+ printHelp();
303
+ process.exit(1);
304
+ }
305
+
306
+ try {
307
+ await ingestGoogleTakeout(opts.takeoutPath, {
308
+ dryRun: opts.dryRun,
309
+ types: opts.types,
310
+ brain: opts.brain,
311
+ maxAge: opts.maxAge,
312
+ layer: opts.layer,
313
+ });
314
+ } catch (err) {
315
+ console.error(` ❌ Error: ${err.message}\n`);
316
+ process.exit(1);
317
+ }
318
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * chrome-bookmarks.mjs — Parse Chrome bookmarks from Takeout export
3
+ *
4
+ * Reads Chrome bookmarks from the Takeout "Chrome/" directory.
5
+ * Supports both JSON format (Chrome/Bookmarks) and HTML format
6
+ * (Chrome/Bookmarks.html) exported by Google Takeout.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+ import { collectFiles } from '../utils/takeout-walker.mjs';
13
+
14
+ /**
15
+ * Extract bookmarks from Chrome's JSON bookmark format.
16
+ * The structure nests bookmarks under roots.bookmark_bar / roots.other / roots.synced.
17
+ *
18
+ * @param {object} jsonData — Parsed JSON bookmark file.
19
+ * @returns {{ name: string, url: string, folder: string }[]}
20
+ */
21
+ function extractFromJson(jsonData) {
22
+ const bookmarks = [];
23
+
24
+ function walk(node, folderPath) {
25
+ if (!node) return;
26
+
27
+ if (node.type === 'url' && node.url) {
28
+ bookmarks.push({
29
+ name: node.name || 'Untitled',
30
+ url: node.url,
31
+ folder: folderPath || 'Other',
32
+ });
33
+ }
34
+
35
+ if (node.children && Array.isArray(node.children)) {
36
+ const currentFolder = node.name || folderPath;
37
+ for (const child of node.children) {
38
+ walk(child, currentFolder);
39
+ }
40
+ }
41
+ }
42
+
43
+ // Chrome bookmarks JSON has a "roots" key with bookmark_bar, other, synced
44
+ const roots = jsonData.roots || jsonData;
45
+ if (roots.bookmark_bar) walk(roots.bookmark_bar, 'Bookmarks Bar');
46
+ if (roots.other) walk(roots.other, 'Other Bookmarks');
47
+ if (roots.synced) walk(roots.synced, 'Mobile Bookmarks');
48
+
49
+ // Fallback: if none of the above, try treating the root as a node
50
+ if (bookmarks.length === 0) {
51
+ walk(roots, 'Bookmarks');
52
+ }
53
+
54
+ return bookmarks;
55
+ }
56
+
57
+ /**
58
+ * Extract bookmarks from Chrome's HTML export format.
59
+ * Parses <DT><A HREF="url">name</A> patterns and tracks folder hierarchy.
60
+ *
61
+ * @param {string} htmlContent — Raw HTML string.
62
+ * @returns {{ name: string, url: string, folder: string }[]}
63
+ */
64
+ function extractFromHtml(htmlContent) {
65
+ const bookmarks = [];
66
+ const folderStack = ['Bookmarks'];
67
+
68
+ const lines = htmlContent.split('\n');
69
+ for (const line of lines) {
70
+ const trimmed = line.trim();
71
+
72
+ // Detect folder headers: <DT><H3 ...>Folder Name</H3>
73
+ const folderMatch = trimmed.match(/<H3[^>]*>([^<]+)<\/H3>/i);
74
+ if (folderMatch) {
75
+ folderStack.push(folderMatch[1]);
76
+ continue;
77
+ }
78
+
79
+ // Detect end of folder list
80
+ if (trimmed === '</DL><p>' || trimmed === '</DL>') {
81
+ if (folderStack.length > 1) folderStack.pop();
82
+ continue;
83
+ }
84
+
85
+ // Detect bookmark links: <DT><A HREF="url" ...>name</A>
86
+ const linkMatch = trimmed.match(/<A\s+HREF="([^"]+)"[^>]*>([^<]*)<\/A>/i);
87
+ if (linkMatch) {
88
+ bookmarks.push({
89
+ name: linkMatch[2] || 'Untitled',
90
+ url: linkMatch[1],
91
+ folder: folderStack[folderStack.length - 1] || 'Bookmarks',
92
+ });
93
+ }
94
+ }
95
+
96
+ return bookmarks;
97
+ }
98
+
99
+ /**
100
+ * Generate a slug-safe string from a bookmark name.
101
+ *
102
+ * @param {string} name
103
+ * @returns {string}
104
+ */
105
+ function slugify(name) {
106
+ return name
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, '-')
109
+ .replace(/^-|-$/g, '')
110
+ .slice(0, 50);
111
+ }
112
+
113
+ /**
114
+ * Extract domain from a URL for tagging.
115
+ *
116
+ * @param {string} url
117
+ * @returns {string}
118
+ */
119
+ function extractDomain(url) {
120
+ try {
121
+ return new URL(url).hostname.replace(/^www\./, '');
122
+ } catch {
123
+ return 'unknown';
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Parse Chrome bookmarks from the given directory path.
129
+ *
130
+ * @param {string} dirPath — Path to "Chrome/" directory.
131
+ * @param {object} [options]
132
+ * @returns {object[]} Array of SSSS-compatible memory node objects.
133
+ */
134
+ export function parseChromeBookmarks(dirPath, options = {}) {
135
+ let rawBookmarks = [];
136
+
137
+ // Try JSON first
138
+ const jsonFiles = collectFiles(dirPath, ['.json']);
139
+ for (const file of jsonFiles) {
140
+ if (path.basename(file).toLowerCase().includes('bookmark')) {
141
+ try {
142
+ const raw = fs.readFileSync(file, 'utf8');
143
+ const data = JSON.parse(raw);
144
+ rawBookmarks.push(...extractFromJson(data));
145
+ } catch {
146
+ // Skip unparseable files
147
+ }
148
+ }
149
+ }
150
+
151
+ // Try HTML if no JSON results
152
+ if (rawBookmarks.length === 0) {
153
+ const htmlFiles = collectFiles(dirPath, ['.html']);
154
+ for (const file of htmlFiles) {
155
+ if (path.basename(file).toLowerCase().includes('bookmark')) {
156
+ try {
157
+ const raw = fs.readFileSync(file, 'utf8');
158
+ rawBookmarks.push(...extractFromHtml(raw));
159
+ } catch {
160
+ // Skip unparseable files
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ // Convert to SSSS nodes
167
+ const now = new Date().toISOString();
168
+ const nodes = [];
169
+ const seenUrls = new Set();
170
+
171
+ for (const bm of rawBookmarks) {
172
+ if (!bm.url || seenUrls.has(bm.url)) continue;
173
+ seenUrls.add(bm.url);
174
+
175
+ // Skip internal Chrome URLs
176
+ if (bm.url.startsWith('chrome://') || bm.url.startsWith('chrome-extension://')) continue;
177
+
178
+ const suffix = crypto.randomBytes(3).toString('hex');
179
+ const slugBase = slugify(bm.name) || 'bookmark';
180
+ const slug = `bookmark-${slugBase}-${suffix}`;
181
+ const domain = extractDomain(bm.url);
182
+ const folderTag = bm.folder
183
+ ? bm.folder.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
184
+ : '';
185
+
186
+ const tags = ['google-takeout', 'chrome-bookmarks'];
187
+ if (folderTag) tags.push(folderTag);
188
+
189
+ nodes.push({
190
+ type: 'memory',
191
+ slug,
192
+ title: bm.name,
193
+ category: 'facts',
194
+ body: `Bookmark: ${bm.name}\nURL: ${bm.url}\nFolder: ${bm.folder}\nDomain: ${domain}`,
195
+ status: 'active',
196
+ confidence: 0.9,
197
+ importance: 2,
198
+ created: now,
199
+ updated: now,
200
+ last_accessed: now,
201
+ source: {
202
+ type: 'chrome-bookmarks',
203
+ session_id: `takeout-bookmark-${suffix}`,
204
+ evidence_count: 1,
205
+ },
206
+ supersedes: [],
207
+ superseded_by: null,
208
+ contradicts: [],
209
+ tags,
210
+ related: [],
211
+ routes_to_skills: [],
212
+ sentiment_polarity: 'descriptive',
213
+ sentiment_target: bm.name.slice(0, 40),
214
+ modality: 'should',
215
+ subject: 'user',
216
+ predicate: 'bookmarked',
217
+ object: bm.name.slice(0, 120),
218
+ decay: { half_life_days: 90, access_count: 0 },
219
+ schema_version: 2,
220
+ x_temporal_context: now,
221
+ x_citations: [{
222
+ source: 'chrome-bookmarks',
223
+ title: bm.name,
224
+ url: bm.url,
225
+ published: now,
226
+ relevance: 1.0,
227
+ accessed: now,
228
+ }],
229
+ x_browser_context: {
230
+ url: bm.url,
231
+ domain,
232
+ title: bm.name,
233
+ },
234
+ });
235
+ }
236
+
237
+ return nodes;
238
+ }