aegiscode 5.2.32 → 6.0.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.
@@ -0,0 +1,666 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * foreign-memory.js — scan the local memory/transcript stores that *other* AI
5
+ * coding tools leave on disk, and normalise them into AEGIS cloud memory
6
+ * entries (the shape `POST /api/memory/save {entries}` accepts).
7
+ *
8
+ * Why this file exists: AEGIS memory is only as useful as what's in it. A user
9
+ * arriving from Claude Code (or Codex, Cursor, Gemini CLI, …) already has
10
+ * months of durable context sitting in a proprietary directory that AEGIS
11
+ * cannot see. This module is the read-only importer for that.
12
+ *
13
+ * Design rules (all of them load-bearing):
14
+ *
15
+ * 1. Zero runtime deps, pure Node — like client/aegis.js it is shared verbatim
16
+ * between the CLI/MCP server (requires ../client/…) and AEGIS Desktop
17
+ * (vendored to desktop/vendor/ by scripts/predist.mjs, because
18
+ * electron-builder cannot reach outside the app dir).
19
+ *
20
+ * 2. READ-ONLY. Nothing here writes, moves, or deletes inside a foreign
21
+ * tool's directory. Ever.
22
+ *
23
+ * 3. Probe-and-skip, never assume. Every source declares its candidate roots
24
+ * and is simply absent if those paths don't exist. A missing source is a
25
+ * normal result (`present: false`), not an error — the whole point is that
26
+ * we cannot know which of these tools a given machine has.
27
+ *
28
+ * 4. Deterministic ids. `/api/memory/save` upserts on (user_id, id), so an id
29
+ * derived from the *content* makes a re-scan idempotent instead of
30
+ * duplicating every entry on every run.
31
+ *
32
+ * 5. Bounded session fan-out. aegis1's free tier meters distinct
33
+ * memory_entries.session values via _memory_sync_access(). If we minted one
34
+ * session per foreign transcript, a single import would blow the free
35
+ * session limit and 402 the entire batch. So every entry from a source
36
+ * collapses into ONE session, `import:<source>`. Importing from ten tools
37
+ * costs at most ten sessions, and re-runs cost zero (upsert).
38
+ *
39
+ * Local-only and offline: this module never opens a socket. Scanning and
40
+ * uploading are separate steps (see mcp/server.js `aegis_memory_import`),
41
+ * precisely so `scan()` can be dry-run and unit-tested with no network.
42
+ */
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const os = require('node:os');
47
+ const crypto = require('node:crypto');
48
+
49
+ // --- bounds ---------------------------------------------------------------
50
+ // A memory store is not a transcript dump. These keep one import from turning
51
+ // into a 100k-entry upload that helps nobody.
52
+ const DEFAULTS = {
53
+ minChars: 24, // skip "ok", "yes", "continue" — fragments carry no memory value
54
+ maxChars: 2000, // truncate long agent replies
55
+ maxEntriesPerSource: 500,
56
+ maxFilesPerSource: 400,
57
+ maxFileBytes: 8 * 1024 * 1024, // don't read a multi-GB transcript into RAM
58
+ };
59
+
60
+ // Noise that shows up in these stores and is never worth remembering.
61
+ const NOISE = [
62
+ /^<command-name>/i,
63
+ /^<local-command-stdout>/i,
64
+ /^\[Request interrupted by user/i,
65
+ /^Caveat: The messages below were generated by the user while running local commands/i,
66
+ /^(ok|okay|yes|no|yep|nope|sure|thanks|thank you|continue|proceed|go on|done)[.!]?$/i,
67
+ ];
68
+
69
+ function isNoise(text) {
70
+ return NOISE.some((re) => re.test(text));
71
+ }
72
+
73
+ function homeDir(home) {
74
+ return home || os.homedir();
75
+ }
76
+
77
+ // --- per-platform root layout ---------------------------------------------
78
+ // Every source below declares its roots through these helpers instead of
79
+ // hardcoding `path.join(home, '.config', name)`. That hardcoding was a
80
+ // cross-platform bug, not a style choice: `~/.config` is the XDG convention on
81
+ // Linux only. On Windows the equivalent trees are `%APPDATA%` (Roaming) and
82
+ // `%LOCALAPPDATA%` (Local), and several of these tools use
83
+ // `~/Library/Application Support` on macOS. Resolving only `~/.config` meant
84
+ // the entire scan silently reported `present: false` for every source on a
85
+ // Windows install — the app packaged and ran fine, it simply found nothing to
86
+ // import, with no error to explain why.
87
+ //
88
+ // `platform` is a parameter (not a bare `process.platform` read) so the
89
+ // Windows and macOS layouts stay unit-testable from Linux CI.
90
+
91
+ function platformOf(platform) {
92
+ return platform || process.platform;
93
+ }
94
+
95
+ /** Roaming app data (`%APPDATA%`), or null off Windows. */
96
+ function roamingDir(home, plat) {
97
+ if (plat !== 'win32') return null;
98
+ // A redirected profile can move %APPDATA% off the home dir, so prefer the
99
+ // real env var — but only when scanning the real home. A caller-supplied
100
+ // home (tests, overrides) is the source of truth.
101
+ if (home === os.homedir() && process.env.APPDATA) return process.env.APPDATA;
102
+ return path.join(home, 'AppData', 'Roaming');
103
+ }
104
+
105
+ /** Local app data / cache (`%LOCALAPPDATA%`), or null off Windows. */
106
+ function localDir(home, plat) {
107
+ if (plat !== 'win32') return null;
108
+ if (home === os.homedir() && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
109
+ return path.join(home, 'AppData', 'Local');
110
+ }
111
+
112
+ /** macOS `~/Library/Application Support`, or null elsewhere. */
113
+ function macSupportDir(home, plat) {
114
+ return plat === 'darwin' ? path.join(home, 'Library', 'Application Support') : null;
115
+ }
116
+
117
+ /**
118
+ * Candidate roots for one app, across all three platform layouts.
119
+ *
120
+ * Deliberately additive: the POSIX paths are always included, so a source can
121
+ * never *lose* a root it used to match, and the Windows/macOS alternates are
122
+ * appended only when they apply. `present` is still a probe of existing
123
+ * directories, so extra candidates that don't exist change nothing.
124
+ *
125
+ * @param {boolean} [opts.config] include `<config>/<name>` layouts
126
+ * @param {boolean} [opts.data] include `<data>/<name>` (XDG share) layouts
127
+ * @param {boolean} [opts.macSupport] include `~/Library/Application Support/<name>`
128
+ */
129
+ function appRoots(home, plat, name, { config = true, data = false, macSupport = false } = {}) {
130
+ const roots = [];
131
+ if (config) roots.push(path.join(home, '.config', name)); // linux (and XDG-following mac apps)
132
+ if (data) roots.push(path.join(home, '.local', 'share', name)); // linux XDG data
133
+ const roaming = roamingDir(home, plat);
134
+ if (roaming && config) roots.push(path.join(roaming, name)); // windows %APPDATA%
135
+ const local = localDir(home, plat);
136
+ if (local) roots.push(path.join(local, name)); // windows %LOCALAPPDATA%
137
+ if (macSupport) {
138
+ const support = macSupportDir(home, plat);
139
+ if (support) roots.push(path.join(support, name)); // macOS
140
+ }
141
+ return roots;
142
+ }
143
+
144
+ /** Collapse whitespace, strip control chars, trim. */
145
+ function cleanText(value) {
146
+ if (typeof value !== 'string') return '';
147
+ // eslint-disable-next-line no-control-regex
148
+ return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ' ').replace(/\s+/g, ' ').trim();
149
+ }
150
+
151
+ /** Content-addressed id: same text from the same place always maps to one row. */
152
+ function stableId(...parts) {
153
+ return `import-${crypto.createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 32)}`;
154
+ }
155
+
156
+ function readJsonl(file) {
157
+ const out = [];
158
+ let raw;
159
+ try {
160
+ raw = fs.readFileSync(file, 'utf8');
161
+ } catch {
162
+ return out;
163
+ }
164
+ for (const line of raw.split('\n')) {
165
+ const trimmed = line.trim();
166
+ if (!trimmed) continue;
167
+ try {
168
+ out.push(JSON.parse(trimmed));
169
+ } catch {
170
+ /* torn/partial line — skip, never throw on foreign data */
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Depth-first file walk, bounded. Skips node_modules/.git and any directory
178
+ * the source marks as off-limits. Never follows symlinks (a foreign store is
179
+ * untrusted input; a symlink loop must not hang the importer).
180
+ */
181
+ function walkFiles(root, { ext, limit }) {
182
+ const found = [];
183
+ const stack = [root];
184
+ while (stack.length && found.length < limit) {
185
+ const dir = stack.pop();
186
+ let items;
187
+ try {
188
+ items = fs.readdirSync(dir, { withFileTypes: true });
189
+ } catch {
190
+ continue;
191
+ }
192
+ for (const item of items) {
193
+ if (found.length >= limit) break;
194
+ if (item.isSymbolicLink()) continue;
195
+ const full = path.join(dir, item.name);
196
+ if (item.isDirectory()) {
197
+ if (item.name === 'node_modules' || item.name === '.git') continue;
198
+ stack.push(full);
199
+ } else if (item.isFile()) {
200
+ if (ext && !ext.some((e) => item.name.endsWith(e))) continue;
201
+ found.push(full);
202
+ }
203
+ }
204
+ }
205
+ return found;
206
+ }
207
+
208
+ function fileSize(file) {
209
+ try {
210
+ return fs.statSync(file).size;
211
+ } catch {
212
+ return 0;
213
+ }
214
+ }
215
+
216
+ function isFile(p) {
217
+ try {
218
+ return fs.statSync(p).isFile();
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ function isDir(p) {
225
+ try {
226
+ return fs.statSync(p).isDirectory();
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ // --- extractors -----------------------------------------------------------
233
+
234
+ /**
235
+ * Claude Code / Claude Desktop style transcript line:
236
+ * {type:'user', message:{content: 'string' | [{type:'text',text}]}}
237
+ * {type:'assistant', message:{content:[{type:'text'|'tool_use'|'thinking'}]}}
238
+ * Only human text and assistant *text* blocks are memory-worthy; tool calls,
239
+ * results and hidden reasoning are not (and would flood the store).
240
+ */
241
+ function textFromClaudeMessage(message) {
242
+ if (!message) return '';
243
+ const content = message.content;
244
+ if (typeof content === 'string') return cleanText(content);
245
+ if (!Array.isArray(content)) return '';
246
+ return content
247
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
248
+ .map((block) => block.text)
249
+ .join('\n');
250
+ }
251
+
252
+ function extractClaudeJsonl(file, ctx) {
253
+ const out = [];
254
+ const rel = ctx.rel(file);
255
+ for (const [index, row] of readJsonl(file).entries()) {
256
+ const type = row && row.type;
257
+ const role = type === 'user' ? 'user' : type === 'assistant' ? 'assistant' : null;
258
+ if (!role) continue;
259
+ if (row.isSidechain) continue; // subagent chatter, not user context
260
+ const text = cleanText(textFromClaudeMessage(row.message));
261
+ if (!text) continue;
262
+ out.push({ role, text, timestamp: row.timestamp || '', where: `${rel}#${index}` });
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Best-effort generic `{role, content}` JSONL — the shape most OpenAI-compatible
269
+ * CLIs (and several agent frameworks) persist. We only claim what we can prove:
270
+ * a row must carry a recognisable role AND string content.
271
+ */
272
+ function extractGenericJsonl(file, ctx) {
273
+ const out = [];
274
+ const rel = ctx.rel(file);
275
+ for (const [index, row] of readJsonl(file).entries()) {
276
+ if (!row || typeof row !== 'object') continue;
277
+ const role = row.role || (row.message && row.message.role);
278
+ if (role !== 'user' && role !== 'assistant') continue;
279
+ const content = row.content != null ? row.content : row.message && row.message.content;
280
+ const text = cleanText(typeof content === 'string' ? content : textFromClaudeMessage({ content }));
281
+ if (!text) continue;
282
+ out.push({ role, text, timestamp: row.timestamp || row.created_at || '', where: `${rel}#${index}` });
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /** A markdown/text note file is itself one memory entry. */
288
+ function extractMarkdown(file, ctx) {
289
+ if (fileSize(file) > DEFAULTS.maxFileBytes) return [];
290
+ let raw;
291
+ try {
292
+ raw = fs.readFileSync(file, 'utf8');
293
+ } catch {
294
+ return [];
295
+ }
296
+ const text = cleanText(raw);
297
+ if (!text) return [];
298
+ return [{ role: 'user', text, timestamp: '', where: ctx.rel(file) }];
299
+ }
300
+
301
+ /** A JSON memory file: array of entries, or {entries|memories|memory: [...]}. */
302
+ function extractJsonMemory(file, ctx) {
303
+ if (fileSize(file) > DEFAULTS.maxFileBytes) return [];
304
+ let data;
305
+ try {
306
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
307
+ } catch {
308
+ return [];
309
+ }
310
+ const list = Array.isArray(data)
311
+ ? data
312
+ : data && Array.isArray(data.entries)
313
+ ? data.entries
314
+ : data && Array.isArray(data.memories)
315
+ ? data.memories
316
+ : data && Array.isArray(data.memory)
317
+ ? data.memory
318
+ : [];
319
+ const rel = ctx.rel(file);
320
+ return list
321
+ .map((item, index) => {
322
+ if (typeof item === 'string') {
323
+ return { role: 'user', text: cleanText(item), timestamp: '', where: `${rel}#${index}` };
324
+ }
325
+ const text = cleanText(item && (item.content || item.text || item.value || item.fact));
326
+ return { role: 'user', text, timestamp: (item && (item.timestamp || item.createdAt)) || '', where: `${rel}#${index}` };
327
+ })
328
+ .filter((e) => e.text);
329
+ }
330
+
331
+ // --- source registry ------------------------------------------------------
332
+
333
+ /**
334
+ * Every known foreign store. `roots` are candidates — a source is present only
335
+ * if at least one root exists, and only the roots that exist are walked.
336
+ *
337
+ * `verified` marks how much trust the extractor has earned:
338
+ * verified: true — format confirmed against real files on this machine (or
339
+ * in a test fixture), schema-pinned.
340
+ * verified: false — best-effort probe over a documented/observed layout.
341
+ * Counts from these should be treated as a lower bound.
342
+ */
343
+ const SOURCES = [
344
+ {
345
+ id: 'claude-code',
346
+ label: 'Claude Code',
347
+ verified: true,
348
+ // ~/.claude/projects/<slug>/<session-uuid>.jsonl is the canonical store.
349
+ roots: (home) => [path.join(home, '.claude', 'projects')],
350
+ files: (home) => [path.join(home, '.claude', 'history.jsonl')],
351
+ extract: { '**/*.jsonl': extractClaudeJsonl, '*.jsonl': extractClaudeJsonl },
352
+ },
353
+ {
354
+ id: 'claude-memory',
355
+ label: 'Claude memory files',
356
+ verified: true,
357
+ roots: (home) => [path.join(home, '.claude', 'memory')],
358
+ files: (home) => [path.join(home, '.claude', 'memory.json')],
359
+ extract: { '.json': extractJsonMemory, '.md': extractMarkdown, '.txt': extractMarkdown },
360
+ },
361
+ {
362
+ id: 'aegiscodex',
363
+ label: 'AEGIS Codex engine',
364
+ verified: true,
365
+ roots: (home) => [path.join(home, '.aegiscodex', 'memory')],
366
+ files: () => [],
367
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
368
+ },
369
+ {
370
+ id: 'codex',
371
+ label: 'OpenAI Codex CLI',
372
+ verified: false,
373
+ roots: (home) => [path.join(home, '.codex')],
374
+ files: () => [],
375
+ extract: { '.jsonl': extractGenericJsonl, '.json': extractJsonMemory, '.md': extractMarkdown },
376
+ },
377
+ {
378
+ id: 'gemini-cli',
379
+ label: 'Gemini CLI',
380
+ verified: false,
381
+ roots: (home) => [path.join(home, '.gemini')],
382
+ files: () => [],
383
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
384
+ },
385
+ {
386
+ id: 'cursor',
387
+ label: 'Cursor',
388
+ verified: false,
389
+ roots: (home) => [path.join(home, '.cursor')],
390
+ files: () => [],
391
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
392
+ },
393
+ {
394
+ id: 'continue',
395
+ label: 'Continue',
396
+ verified: false,
397
+ roots: (home) => [path.join(home, '.continue')],
398
+ files: () => [],
399
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
400
+ },
401
+ {
402
+ id: 'goose',
403
+ label: 'Goose',
404
+ verified: false,
405
+ roots: (home, plat) => appRoots(home, plat, 'goose', { data: true }),
406
+ files: () => [],
407
+ extract: { '.jsonl': extractGenericJsonl, '.json': extractJsonMemory, '.md': extractMarkdown },
408
+ },
409
+ {
410
+ id: 'opencode',
411
+ label: 'opencode',
412
+ verified: false,
413
+ roots: (home, plat) => appRoots(home, plat, 'opencode', { data: true }),
414
+ files: () => [],
415
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
416
+ },
417
+ {
418
+ id: 'windsurf',
419
+ label: 'Windsurf / Codeium',
420
+ verified: false,
421
+ // Home-relative dot-dirs on every platform; macOS additionally stores the
422
+ // app under Application Support.
423
+ roots: (home, plat) => [
424
+ path.join(home, '.windsurf'),
425
+ path.join(home, '.codeium'),
426
+ ...appRoots(home, plat, 'Windsurf', { config: false, macSupport: true }),
427
+ ...appRoots(home, plat, 'Codeium', { config: false, macSupport: true }),
428
+ ],
429
+ files: () => [],
430
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl, '.md': extractMarkdown },
431
+ },
432
+ {
433
+ id: 'zed',
434
+ label: 'Zed',
435
+ verified: false,
436
+ // Zed follows XDG on Linux, uses %APPDATA% on Windows, and keeps
437
+ // ~/.config/zed on macOS too — `config: true` covers all three.
438
+ roots: (home, plat) => appRoots(home, plat, 'zed'),
439
+ files: () => [],
440
+ extract: { '.json': extractJsonMemory, '.jsonl': extractGenericJsonl },
441
+ },
442
+ {
443
+ id: 'claude-desktop',
444
+ label: 'Claude Desktop app',
445
+ verified: false,
446
+ roots: (home, plat) => appRoots(home, plat, 'Claude', { macSupport: true }),
447
+ files: () => [],
448
+ extract: { '.json': extractJsonMemory, '.jsonl': extractClaudeJsonl, '.md': extractMarkdown },
449
+ },
450
+ ];
451
+
452
+ /**
453
+ * Reduce an extractor pattern to its literal trailing filename suffix.
454
+ * '**\/*.jsonl' -> '.jsonl' '*.jsonl' -> '.jsonl' '.json' -> '.json'
455
+ * Everything before the last `*` is a glob, not part of the literal suffix.
456
+ */
457
+ function suffixOf(pattern) {
458
+ const star = pattern.lastIndexOf('*');
459
+ return star === -1 ? pattern : pattern.slice(star + 1);
460
+ }
461
+
462
+ /** Pick the extractor whose suffix matches the file; first match wins. */
463
+ function extractorFor(extract, file) {
464
+ for (const [pattern, fn] of Object.entries(extract)) {
465
+ if (file.endsWith(suffixOf(pattern))) return fn;
466
+ }
467
+ return null;
468
+ }
469
+
470
+ function sourceById(id) {
471
+ return SOURCES.find((s) => s.id === id) || null;
472
+ }
473
+
474
+ /**
475
+ * Which sources exist on this machine. Cheap (stat only) — safe to call from a
476
+ * UI before the user commits to an import.
477
+ */
478
+ function listSources(home, platform) {
479
+ const h = homeDir(home);
480
+ const plat = platformOf(platform);
481
+ return SOURCES.map((source) => {
482
+ const roots = source.roots(h, plat).filter(isDir);
483
+ const files = (source.files ? source.files(h) : []).filter(isFile);
484
+ return {
485
+ id: source.id,
486
+ label: source.label,
487
+ verified: source.verified,
488
+ roots: roots.concat(files),
489
+ present: roots.length > 0 || files.length > 0,
490
+ };
491
+ });
492
+ }
493
+
494
+ /**
495
+ * Scan foreign stores into normalised AEGIS memory entries.
496
+ *
497
+ * @param {object} [opts]
498
+ * @param {string} [opts.home] override home dir (tests)
499
+ * @param {string[]} [opts.sources] restrict to these source ids
500
+ * @param {number} [opts.limit] max entries returned overall
501
+ * @param {number} [opts.minChars] drop entries shorter than this
502
+ * @param {number} [opts.maxChars] truncate entries longer than this
503
+ * @param {number} [opts.maxEntriesPerSource]
504
+ * @param {string} [opts.platform] override platform (tests); defaults to process.platform
505
+ * @returns {{entries: Array, sources: Array, totals: object, scannedAt: string}}
506
+ */
507
+ function scan(opts = {}) {
508
+ const home = homeDir(opts.home);
509
+ const plat = platformOf(opts.platform);
510
+ const minChars = opts.minChars != null ? opts.minChars : DEFAULTS.minChars;
511
+ const maxChars = opts.maxChars != null ? opts.maxChars : DEFAULTS.maxChars;
512
+ const perSource = opts.maxEntriesPerSource != null ? opts.maxEntriesPerSource : DEFAULTS.maxEntriesPerSource;
513
+ const overall = opts.limit != null ? opts.limit : Infinity;
514
+ const wanted = opts.sources && opts.sources.length ? new Set(opts.sources) : null;
515
+
516
+ const results = [];
517
+ const entries = [];
518
+ const seen = new Set(); // dedupe identical text within one scan
519
+
520
+ for (const source of SOURCES) {
521
+ if (wanted && !wanted.has(source.id)) continue;
522
+
523
+ const roots = source.roots(home, plat).filter(isDir);
524
+ const looseFiles = (source.files ? source.files(home) : []).filter(isFile);
525
+ const present = roots.length > 0 || looseFiles.length > 0;
526
+
527
+ const record = {
528
+ id: source.id,
529
+ label: source.label,
530
+ verified: source.verified,
531
+ present,
532
+ files: 0,
533
+ scanned: 0,
534
+ count: 0,
535
+ skipped: 0,
536
+ roots,
537
+ session: `import:${source.id}`,
538
+ };
539
+
540
+ if (!present) {
541
+ results.push(record);
542
+ continue;
543
+ }
544
+
545
+ const candidates = looseFiles.slice();
546
+ for (const root of roots) {
547
+ // Literal suffixes only — a glob like '**/*.jsonl' is not a filename
548
+ // suffix, and passing it to endsWith() silently matched nothing.
549
+ const suffixes = Object.keys(source.extract).map(suffixOf);
550
+ candidates.push(...walkFiles(root, { ext: suffixes, limit: DEFAULTS.maxFilesPerSource }));
551
+ }
552
+
553
+ const ctx = { rel: (f) => path.relative(home, f) };
554
+
555
+ for (const file of candidates) {
556
+ if (record.count >= perSource || entries.length >= overall) break;
557
+ if (fileSize(file) > DEFAULTS.maxFileBytes) {
558
+ record.skipped += 1;
559
+ continue;
560
+ }
561
+ const extractor = extractorFor(source.extract, file);
562
+ if (!extractor) continue;
563
+ record.files += 1;
564
+
565
+ let found;
566
+ try {
567
+ found = extractor(file, ctx);
568
+ } catch {
569
+ continue; // foreign data is untrusted: a bad file must not kill the scan
570
+ }
571
+
572
+ for (const item of found) {
573
+ if (record.count >= perSource || entries.length >= overall) break;
574
+ record.scanned += 1;
575
+ let text = item.text;
576
+ if (text.length > maxChars) text = `${text.slice(0, maxChars)}…`;
577
+ if (text.length < minChars || isNoise(text)) {
578
+ record.skipped += 1;
579
+ continue;
580
+ }
581
+ // Include role in the id so a prompt and an identical reply stay distinct.
582
+ const id = stableId(source.id, item.where, item.role, text);
583
+ if (seen.has(id)) continue;
584
+ seen.add(id);
585
+
586
+ entries.push({
587
+ id,
588
+ content: text,
589
+ role: item.role,
590
+ source: source.id,
591
+ session: record.session, // bounded fan-out: one session per source
592
+ tags: ['imported', source.id],
593
+ importance: 5,
594
+ timestamp: item.timestamp || new Date().toISOString(),
595
+ });
596
+ record.count += 1;
597
+ }
598
+ }
599
+
600
+ results.push(record);
601
+ }
602
+
603
+ return {
604
+ entries,
605
+ sources: results,
606
+ totals: {
607
+ entries: entries.length,
608
+ sourcesPresent: results.filter((r) => r.present).length,
609
+ sourcesWithEntries: results.filter((r) => r.count > 0).length,
610
+ scanned: results.reduce((n, r) => n + r.scanned, 0),
611
+ skipped: results.reduce((n, r) => n + r.skipped, 0),
612
+ },
613
+ scannedAt: new Date().toISOString(),
614
+ };
615
+ }
616
+
617
+ /** Split entries into upload-sized chunks (server takes a batch per request). */
618
+ function chunk(entries, size = 200) {
619
+ const out = [];
620
+ for (let i = 0; i < entries.length; i += size) out.push(entries.slice(i, i + size));
621
+ return out;
622
+ }
623
+
624
+ /** One-line human summary — shared by the MCP tool and the desktop UI. */
625
+ function describe(report) {
626
+ const present = report.sources.filter((s) => s.present);
627
+ if (!present.length) {
628
+ return 'No other AI tool memory found on this machine.';
629
+ }
630
+ const lines = present.map((s) => {
631
+ const tag = s.verified ? '' : ' (best-effort layout)';
632
+ return ` ${s.label}${tag}: ${s.count} entries from ${s.files} file(s), ${s.skipped} skipped`;
633
+ });
634
+ const header = `Found ${report.totals.entries} importable entries across ${report.totals.sourcesWithEntries} source(s):`;
635
+ return [header, ...lines].join('\n');
636
+ }
637
+
638
+ module.exports = {
639
+ DEFAULTS,
640
+ SOURCES,
641
+ scan,
642
+ listSources,
643
+ describe,
644
+ chunk,
645
+ stableId,
646
+ sourceById,
647
+ // exported for tests
648
+ _internal: {
649
+ walkFiles,
650
+ extractClaudeJsonl,
651
+ extractGenericJsonl,
652
+ extractJsonMemory,
653
+ extractMarkdown,
654
+ cleanText,
655
+ isNoise,
656
+ suffixOf,
657
+ // Platform-layout resolution: exported so the Windows/macOS branch can be
658
+ // exercised from a Linux CI runner (the roots themselves are probed with
659
+ // isDir, so they are unobservable on the host that lacks them).
660
+ platformOf,
661
+ roamingDir,
662
+ localDir,
663
+ macSupportDir,
664
+ appRoots,
665
+ },
666
+ };