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.
- package/README.md +123 -392
- package/bin/aegiscode.js +197 -0
- package/package.json +30 -83
- package/scripts/demo.mjs +92 -0
- package/scripts/predist.mjs +86 -0
- package/src/app.js +543 -0
- package/src/art.js +45 -0
- package/src/commands.js +163 -0
- package/src/deps.js +65 -0
- package/src/format.js +54 -0
- package/src/render.js +281 -0
- package/src/screen.js +177 -0
- package/src/theme.js +120 -0
- package/vendor/client/aegis.js +809 -0
- package/vendor/client/foreign-memory.js +666 -0
- package/vendor/desktop/renderer/usage.js +42 -0
- package/vendor/mcp/tools.js +356 -0
- package/LICENSE +0 -21
- package/bin/cli.js +0 -2362
- package/scripts/download-binary.mjs +0 -155
- package/scripts/empty-stub.mjs +0 -1
- package/scripts/ensure-node-version.mjs +0 -27
- package/scripts/install-alacritty.ps1 +0 -149
- package/scripts/install-alacritty.sh +0 -164
- package/scripts/install.sh +0 -121
- package/scripts/make-bin.mjs +0 -17
- package/scripts/release-local.sh +0 -68
- package/scripts/repro-compact.ts +0 -59
- package/scripts/repro-tokencount.ts +0 -45
- package/scripts/sharp-stub/index.js +0 -9
- package/scripts/sharp-stub/package.json +0 -5
|
@@ -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
|
+
};
|