mnemos-capture 1.7.0 → 1.8.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,546 @@
1
+ import { createHash, createHmac } from "crypto";
2
+ import { execFileSync } from "child_process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { dirname, join, posix } from "path";
6
+ export const MAX_FILES = 3000;
7
+ export const MAX_BYTES = 120 * 1024;
8
+ const MAP_URL = "https://xkg.xmu.sh/api/code-map";
9
+ const SYNC_TIMEOUT_MS = 3000;
10
+ const RESEND_AFTER_MS = 24 * 60 * 60 * 1000;
11
+ /** Bump when what is read from a file changes, so entries cached by an older CLI are read again. */
12
+ const CACHE_VERSION = 2;
13
+ /** Larger files are mapped by path only: they are almost always generated. */
14
+ const MAX_SOURCE_BYTES = 1024 * 1024;
15
+ /** Bytes of file contents read at once; each batch's names are taken before the next is read. */
16
+ const BLOB_BATCH_BYTES = 32 * 1024 * 1024;
17
+ export const SKIP_DIR = /(^|\/)(node_modules|dist|build|\.next|\.nuxt|vendor|coverage|target|\.venv|venv|__pycache__|\.gradle|\.dart_tool|cdk\.out|__tests__|tests)\//;
18
+ export const SKIP_FILE = /\.(test|spec)\.[^/]+$|(^|\/)test_[^/]+\.py$|_test\.go$|(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum)$|\.lockb?$|\.(png|jpe?g|gif|svg|ico|webp|avif|bmp|tiff?|mp4|mov|webm|mp3|wav|ogg|pdf|woff2?|ttf|otf|eot|zip|gz|tgz|bz2|xz|7z|rar|jar|wasm|exe|dll|so|dylib|bin|class|pyc)$/i;
19
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
20
+ // The server's limits per file (src/lib/code-map.ts): a file over them would
21
+ // make it refuse the whole map.
22
+ const MAX_EXPORTS_PER_FILE = 1000;
23
+ const MAX_NAME_LENGTH = 200;
24
+ const MAX_PATH_LENGTH = 512;
25
+ const JS_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
26
+ function langOf(path) {
27
+ const ext = posix.extname(path).toLowerCase();
28
+ if (JS_EXTS.includes(ext))
29
+ return "js";
30
+ return { ".py": "py", ".go": "go", ".rs": "rs" }[ext] ?? null;
31
+ }
32
+ function git(cwd, ...args) {
33
+ return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
34
+ }
35
+ /** `owner/name` from an origin URL in any common form, without credentials. */
36
+ export function repoFromRemote(url) {
37
+ const path = url.trim()
38
+ .replace(/^[a-z+]+:\/\/[^/]*\//i, "") // scheme://user:pass@host[:port]/
39
+ .replace(/^[^@/]+@[^:/]+:/, "") // git@host:
40
+ .replace(/\.git\/?$/, "")
41
+ .replace(/\/+$/, "");
42
+ const parts = path.split("/").filter(Boolean);
43
+ if (parts.length < 2)
44
+ return null;
45
+ const repo = parts.slice(-2).join("/");
46
+ return /^[\w.-]+\/[\w.-]+$/.test(repo) ? repo : null;
47
+ }
48
+ // Line-start patterns use [ \t]*, never \s*: comments are blanked to spaces
49
+ // first, and \s* would rescan every blank line below from each line start.
50
+ const EXPORT_PATTERNS = {
51
+ js: [
52
+ /^export\s+(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:abstract\s+)?(?:function\s*\*?|class|const|let|var|type|interface|enum|namespace)\s*([A-Za-z_$][\w$]*)/gm,
53
+ ],
54
+ py: [/^(?:async\s+)?def\s+([A-Za-z]\w*)/gm, /^class\s+([A-Za-z]\w*)/gm],
55
+ go: [/^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)/gm, /^type\s+([A-Z]\w*)/gm],
56
+ rs: [/^[ \t]*pub\s+(?:async\s+)?(?:unsafe\s+)?(?:fn|struct|enum|trait|type)\s+([A-Za-z_]\w*)/gm],
57
+ };
58
+ const SYNTAX = {
59
+ js: { line: "//", block: true, multi: ["`"], single: ["'", '"'] },
60
+ py: { line: "#", block: false, multi: ['"""', "'''"], single: ["'", '"'] },
61
+ go: { line: "//", block: true, multi: ["`"], single: ["'", '"'] },
62
+ // ' starts a char ('"') or a lifetime ('a), so only a whole char is skipped.
63
+ rs: { line: "//", block: true, multi: ['"'], single: [], char: /'(?:\\.[^']*|[^\\'])'/y },
64
+ };
65
+ /**
66
+ * The source with comments and multi-line strings blanked (newlines kept), so a
67
+ * declaration or import in a comment or docstring is not taken for a real one.
68
+ * Single-line strings are kept, since import paths are in them. Regex literals
69
+ * are not parsed: one with a quote or backtick can blank code until the next.
70
+ */
71
+ function maskComments(lang, source) {
72
+ const { line, block, multi, single, char } = SYNTAX[lang];
73
+ const out = source.split("");
74
+ const blank = (from, to) => {
75
+ for (let k = from; k < to; k++)
76
+ if (out[k] !== "\n")
77
+ out[k] = " ";
78
+ };
79
+ for (let i = 0; i < source.length;) {
80
+ const c = source[i];
81
+ let end = -1;
82
+ if (c === "\\") {
83
+ i += 2;
84
+ continue;
85
+ }
86
+ if (char) {
87
+ char.lastIndex = i;
88
+ if (char.test(source)) {
89
+ i = char.lastIndex;
90
+ continue;
91
+ }
92
+ }
93
+ if (source.startsWith(line, i)) {
94
+ end = source.indexOf("\n", i);
95
+ }
96
+ else if (block && source.startsWith("/*", i)) {
97
+ end = source.indexOf("*/", i + 2);
98
+ if (end >= 0)
99
+ end += 2;
100
+ }
101
+ else {
102
+ const quote = multi.find((q) => source.startsWith(q, i));
103
+ if (quote) {
104
+ let j = i + quote.length;
105
+ while (j < source.length && !source.startsWith(quote, j))
106
+ j += source[j] === "\\" ? 2 : 1;
107
+ end = Math.min(source.length, j + quote.length);
108
+ }
109
+ else if (single.includes(c)) {
110
+ let j = i + 1;
111
+ while (j < source.length && source[j] !== c && source[j] !== "\n")
112
+ j += source[j] === "\\" ? 2 : 1;
113
+ i = j + 1;
114
+ continue;
115
+ }
116
+ else {
117
+ i++;
118
+ continue;
119
+ }
120
+ }
121
+ if (end < 0)
122
+ end = source.length;
123
+ blank(i, end);
124
+ i = end;
125
+ }
126
+ return out.join("");
127
+ }
128
+ /** The top-level names a file exports, in source order. Empty for languages without a pattern. */
129
+ export function exportedNames(path, source) {
130
+ const lang = langOf(path);
131
+ if (!lang)
132
+ return [];
133
+ source = maskComments(lang, source);
134
+ const found = [];
135
+ for (const re of EXPORT_PATTERNS[lang]) {
136
+ for (const m of source.matchAll(re))
137
+ found.push([m.index, m[1]]);
138
+ }
139
+ if (lang === "js") {
140
+ for (const m of source.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
141
+ for (const item of m[1].split(",")) {
142
+ const name = item.trim().split(/\s+as\s+/).pop()?.trim() ?? "";
143
+ if (name && name !== "default")
144
+ found.push([m.index, name]);
145
+ }
146
+ }
147
+ }
148
+ found.sort((a, b) => a[0] - b[0]);
149
+ return [...new Set(found.map(([, name]) => name).filter((n) => IDENTIFIER.test(n) && n.length <= MAX_NAME_LENGTH))].slice(0, MAX_EXPORTS_PER_FILE);
150
+ }
151
+ /** Raw import specifiers of a file. Kept only in the local cache; only resolved paths are ever sent. */
152
+ function importSpecifiers(lang, source) {
153
+ source = maskComments(lang, source);
154
+ const out = [];
155
+ if (lang === "js") {
156
+ for (const re of [/\bfrom\s+['"]([^'"\n]+)['"]/g, /^[ \t]*import\s+['"]([^'"\n]+)['"]/gm, /\b(?:require|import)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g]) {
157
+ for (const m of source.matchAll(re))
158
+ out.push(m[1]);
159
+ }
160
+ }
161
+ else if (lang === "py") {
162
+ for (const m of source.matchAll(/^[ \t]*from\s+(\.*[\w.]*)\s+import\s+([^\n#]+)/gm)) {
163
+ const mod = m[1];
164
+ out.push(mod);
165
+ for (const name of m[2].replace(/[()\\]/g, "").split(",")) {
166
+ const n = name.trim().split(/\s+as\s+/)[0].trim();
167
+ if (/^\w+$/.test(n))
168
+ out.push(mod.endsWith(".") ? mod + n : `${mod}.${n}`);
169
+ }
170
+ }
171
+ for (const m of source.matchAll(/^[ \t]*import\s+([\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+(?:\s+as\s+\w+)?)*)/gm)) {
172
+ for (const part of m[1].split(","))
173
+ out.push(part.trim().split(/\s+as\s+/)[0]);
174
+ }
175
+ }
176
+ return [...new Set(out)];
177
+ }
178
+ /** tsconfig/jsconfig `paths` and `baseUrl`, so `@/lib/db` resolves like the compiler does. */
179
+ function parseTsconfig(text) {
180
+ let config;
181
+ try {
182
+ config = JSON.parse(stripJsonComments(text));
183
+ }
184
+ catch {
185
+ return { aliases: [], baseUrl: null };
186
+ }
187
+ const opts = config.compilerOptions ?? {};
188
+ const base = opts.baseUrl ?? ".";
189
+ const aliases = Object.entries(opts.paths ?? {}).map(([pattern, targets]) => {
190
+ const star = pattern.indexOf("*");
191
+ return {
192
+ prefix: star === -1 ? pattern : pattern.slice(0, star),
193
+ suffix: star === -1 ? "" : pattern.slice(star + 1),
194
+ wildcard: star !== -1,
195
+ targets: (Array.isArray(targets) ? targets : []).map((t) => posix.join(base, t)),
196
+ };
197
+ });
198
+ return { aliases, baseUrl: opts.baseUrl !== undefined ? posix.normalize(opts.baseUrl) : null };
199
+ }
200
+ function stripJsonComments(text) {
201
+ let out = "";
202
+ for (let i = 0, inString = false; i < text.length; i++) {
203
+ const c = text[i];
204
+ if (inString) {
205
+ out += c;
206
+ if (c === "\\")
207
+ out += text[++i] ?? "";
208
+ else if (c === '"')
209
+ inString = false;
210
+ }
211
+ else if (c === '"') {
212
+ inString = true;
213
+ out += c;
214
+ }
215
+ else if (c === "/" && text[i + 1] === "/") {
216
+ while (i < text.length && text[i] !== "\n")
217
+ i++;
218
+ out += "\n";
219
+ }
220
+ else if (c === "/" && text[i + 1] === "*") {
221
+ i += 2;
222
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
223
+ i++;
224
+ i++;
225
+ }
226
+ else {
227
+ out += c;
228
+ }
229
+ }
230
+ return out.replace(/,(\s*[}\]])/g, "$1");
231
+ }
232
+ // The sources the TypeScript compiler tries for an import that names a
233
+ // JavaScript extension, in its order (tryAddingExtensions).
234
+ const TS_SOURCES = { ".js": [".ts", ".tsx"], ".jsx": [".tsx", ".ts"], ".mjs": [".mts"], ".cjs": [".cts"] };
235
+ /** Files an import of `base` can mean; from a TypeScript file, `./b.js` means b.ts first, as the compiler resolves it. */
236
+ function jsCandidates(base, fromTs) {
237
+ const out = [base];
238
+ const ext = posix.extname(base);
239
+ if (TS_SOURCES[ext]) {
240
+ const stem = base.slice(0, -ext.length);
241
+ const source = TS_SOURCES[ext].map((e) => stem + e);
242
+ if (fromTs)
243
+ out.unshift(...source);
244
+ else
245
+ out.push(...source);
246
+ }
247
+ for (const e of JS_EXTS)
248
+ out.push(base + e);
249
+ for (const e of JS_EXTS)
250
+ out.push(`${base}/index${e}`);
251
+ return out;
252
+ }
253
+ function resolveJs(from, spec, files, resolver) {
254
+ const bases = [];
255
+ if (spec === "." || spec === ".." || spec.startsWith("./") || spec.startsWith("../")) {
256
+ bases.push(posix.join(posix.dirname(from), spec));
257
+ }
258
+ else {
259
+ // One alias applies, as in the compiler (matchPatternOrExact): an exact
260
+ // pattern, else the wildcard with the longest prefix, the first on a tie.
261
+ const best = resolver.aliases.find((a) => !a.wildcard && spec === a.prefix) ??
262
+ resolver.aliases
263
+ .filter((a) => a.wildcard && spec.startsWith(a.prefix) && spec.endsWith(a.suffix) && spec.length >= a.prefix.length + a.suffix.length)
264
+ .reduce((b, a) => (!b || a.prefix.length > b.prefix.length ? a : b), undefined);
265
+ if (best) {
266
+ const match = best.wildcard ? spec.slice(best.prefix.length, spec.length - best.suffix.length) : "";
267
+ for (const t of best.targets)
268
+ bases.push(posix.normalize(t.replaceAll("*", match)));
269
+ }
270
+ if (resolver.baseUrl !== null)
271
+ bases.push(posix.join(resolver.baseUrl, spec));
272
+ }
273
+ for (const base of bases) {
274
+ if (base.startsWith("..") || base.startsWith("/"))
275
+ continue;
276
+ const hit = jsCandidates(base, /\.[mc]?tsx?$/.test(from)).find((c) => files.has(c));
277
+ if (hit)
278
+ return hit;
279
+ }
280
+ return null;
281
+ }
282
+ function resolvePy(from, spec, files) {
283
+ const dots = /^\.*/.exec(spec)[0].length;
284
+ const rel = spec.slice(dots).split(".").filter(Boolean).join("/");
285
+ let roots = ["", "src"];
286
+ if (dots > 0) {
287
+ let dir = posix.dirname(from);
288
+ for (let k = 1; k < dots; k++)
289
+ dir = posix.dirname(dir);
290
+ roots = [dir === "." ? "" : dir];
291
+ }
292
+ for (const root of roots) {
293
+ const base = [root, rel].filter(Boolean).join("/");
294
+ const candidates = rel ? [`${base}.py`, `${base}/__init__.py`] : [base ? `${base}/__init__.py` : "__init__.py"];
295
+ const hit = candidates.find((c) => files.has(c));
296
+ if (hit)
297
+ return hit;
298
+ }
299
+ return null;
300
+ }
301
+ /**
302
+ * Blob contents by id, a batch at a time, each batch at most `budget` bytes.
303
+ * Blobs over 1 MB come back empty, in the first batch.
304
+ */
305
+ export function* readBlobBatches(cwd, ids, budget = BLOB_BATCH_BYTES) {
306
+ if (ids.length === 0)
307
+ return;
308
+ const sizes = execFileSync("git", ["cat-file", "--batch-check"], { cwd, input: ids.join("\n") + "\n", encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
309
+ const skipped = new Map();
310
+ const batches = [];
311
+ let bytes = Infinity;
312
+ for (const line of sizes.split("\n")) {
313
+ const [id, type, size] = line.split(" ");
314
+ if (!id || type !== "blob")
315
+ continue;
316
+ const len = Number(size);
317
+ if (len > MAX_SOURCE_BYTES) {
318
+ skipped.set(id, "");
319
+ continue;
320
+ }
321
+ if (bytes + len > budget) {
322
+ batches.push([]);
323
+ bytes = 0;
324
+ }
325
+ batches.at(-1).push(id);
326
+ bytes += len;
327
+ }
328
+ if (skipped.size)
329
+ yield skipped;
330
+ for (const batch of batches) {
331
+ const raw = execFileSync("git", ["cat-file", "--batch"], { cwd, input: batch.join("\n") + "\n", maxBuffer: budget + MAX_SOURCE_BYTES + batch.length * 128 });
332
+ const out = new Map();
333
+ for (let pos = 0; pos < raw.length;) {
334
+ const nl = raw.indexOf(0x0a, pos);
335
+ if (nl === -1)
336
+ break;
337
+ const [id, type, size] = raw.subarray(pos, nl).toString("utf-8").split(" ");
338
+ if (type !== "blob") {
339
+ pos = nl + 1;
340
+ continue;
341
+ }
342
+ const len = Number(size);
343
+ out.set(id, raw.subarray(nl + 1, nl + 1 + len).toString("utf-8"));
344
+ pos = nl + 1 + len + 1;
345
+ }
346
+ yield out;
347
+ }
348
+ }
349
+ /** The repo's map, or null when it has no origin remote to name it by. */
350
+ export function buildCodeMap(cwd, opts = {}) {
351
+ let repo;
352
+ try {
353
+ repo = repoFromRemote(git(cwd, "remote", "get-url", "origin"));
354
+ }
355
+ catch {
356
+ return null;
357
+ }
358
+ if (!repo)
359
+ return null;
360
+ // Paths are relative to the repo root wherever the command runs.
361
+ cwd = git(cwd, "rev-parse", "--show-toplevel");
362
+ const tracked = new Map();
363
+ const listing = execFileSync("git", ["ls-files", "-s", "-z"], { cwd, encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 });
364
+ for (const entry of listing.split("\0")) {
365
+ const m = /^(\d+) ([0-9a-f]+) \d\t(.+)$/s.exec(entry);
366
+ if (!m || m[1] === "160000" || tracked.has(m[3]))
367
+ continue;
368
+ tracked.set(m[3], m[2]);
369
+ }
370
+ // Paths the server refuses (see parseCodeMap) would fail the whole map.
371
+ const paths = [...tracked.keys()].filter((p) => !SKIP_DIR.test(p) && !SKIP_FILE.test(p) && p.length <= MAX_PATH_LENGTH && !p.includes("\\"));
372
+ const mapped = new Set(paths);
373
+ const cache = opts.cache ?? {};
374
+ cache.repos ??= {};
375
+ const previous = cache.repos[repo]?.blobs ?? {};
376
+ const blobs = {};
377
+ const keyOf = (path, kind) => `${tracked.get(path)}:${kind}:${CACHE_VERSION}`;
378
+ const configPath = ["tsconfig.json", "jsconfig.json"].find((p) => tracked.has(p));
379
+ // Files not in the cache, by blob id. Names are taken from each batch of
380
+ // contents before the next is read, so a large repo never sits in memory at once.
381
+ const toRead = new Map();
382
+ const need = (p, kind, make) => {
383
+ const key = keyOf(p, kind);
384
+ if (previous[key])
385
+ return;
386
+ const id = tracked.get(p);
387
+ toRead.set(id, [...(toRead.get(id) ?? []), { key, make }]);
388
+ };
389
+ for (const p of paths) {
390
+ const lang = langOf(p);
391
+ if (lang)
392
+ need(p, lang, (src) => ({ e: exportedNames(p, src), s: importSpecifiers(lang, src) }));
393
+ }
394
+ if (configPath)
395
+ need(configPath, "config", (src) => ({ r: parseTsconfig(src) }));
396
+ const fresh = {};
397
+ for (const batch of (opts.readBlobs ?? readBlobBatches)(cwd, [...toRead.keys()])) {
398
+ for (const [id, src] of batch)
399
+ for (const { key, make } of toRead.get(id) ?? [])
400
+ fresh[key] ??= make(src);
401
+ }
402
+ for (const jobs of toRead.values())
403
+ for (const { key, make } of jobs)
404
+ fresh[key] ??= make("");
405
+ const entryFor = (p, kind) => {
406
+ const key = keyOf(p, kind);
407
+ const entry = previous[key] ?? fresh[key];
408
+ blobs[key] = entry;
409
+ return entry;
410
+ };
411
+ const resolver = configPath ? entryFor(configPath, "config").r : { aliases: [], baseUrl: null };
412
+ const files = paths.map((p) => {
413
+ const lang = langOf(p);
414
+ if (!lang)
415
+ return { p };
416
+ const entry = entryFor(p, lang);
417
+ const file = { p };
418
+ if (entry.e?.length)
419
+ file.e = entry.e;
420
+ const imports = new Set();
421
+ for (const spec of entry.s ?? []) {
422
+ const hit = lang === "js" ? resolveJs(p, spec, mapped, resolver) : lang === "py" ? resolvePy(p, spec, mapped) : null;
423
+ if (hit && hit !== p)
424
+ imports.add(hit);
425
+ }
426
+ if (imports.size)
427
+ file.i = [...imports];
428
+ return file;
429
+ });
430
+ cache.repos[repo] = { ...cache.repos[repo], blobs };
431
+ let head7 = "";
432
+ try {
433
+ // --short can return more than 7 characters; the server takes exactly 7.
434
+ head7 = git(cwd, "rev-parse", "HEAD").slice(0, 7);
435
+ }
436
+ catch {
437
+ // No commit yet
438
+ }
439
+ return { repo, head7, ...capMap(files) };
440
+ }
441
+ const size = (files) => Buffer.byteLength(JSON.stringify(files));
442
+ const depth = (p) => p.split("/").length;
443
+ /**
444
+ * Fits a map in 3,000 files and 120 KB: drop imports, then exports; still
445
+ * over, keep the shallowest paths (in their original order) and mark it truncated.
446
+ */
447
+ export function capMap(files) {
448
+ if (files.length <= MAX_FILES && size(files) <= MAX_BYTES)
449
+ return { files, truncated: false };
450
+ const noImports = files.map(({ i: _i, ...rest }) => rest);
451
+ if (files.length <= MAX_FILES && size(noImports) <= MAX_BYTES)
452
+ return { files: noImports, truncated: false };
453
+ const pathsOnly = files.map(({ p }) => ({ p }));
454
+ if (files.length <= MAX_FILES && size(pathsOnly) <= MAX_BYTES)
455
+ return { files: pathsOnly, truncated: false };
456
+ const order = pathsOnly.map((f, n) => ({ f, n })).sort((a, b) => depth(a.f.p) - depth(b.f.p) || (a.f.p < b.f.p ? -1 : 1));
457
+ const kept = [];
458
+ let bytes = 2;
459
+ for (const item of order) {
460
+ const add = Buffer.byteLength(JSON.stringify(item.f)) + 1;
461
+ if (kept.length >= MAX_FILES || bytes + add > MAX_BYTES)
462
+ break;
463
+ kept.push(item);
464
+ bytes += add;
465
+ }
466
+ kept.sort((a, b) => a.n - b.n);
467
+ return { files: kept.map((k) => k.f), truncated: true };
468
+ }
469
+ /** Changes only when what the server would store changes (not on every commit). */
470
+ export function mapHash(map) {
471
+ return createHash("sha256").update(JSON.stringify({ repo: map.repo, files: map.files, truncated: map.truncated })).digest("hex");
472
+ }
473
+ function loadCache(path) {
474
+ try {
475
+ return JSON.parse(readFileSync(path, "utf-8"));
476
+ }
477
+ catch {
478
+ return {};
479
+ }
480
+ }
481
+ function saveCache(path, cache) {
482
+ try {
483
+ if (!existsSync(dirname(path)))
484
+ mkdirSync(dirname(path), { recursive: true });
485
+ writeFileSync(path, JSON.stringify(cache));
486
+ }
487
+ catch {
488
+ // The cache only saves work; a failed write costs a re-read next time
489
+ }
490
+ }
491
+ /** Builds the map and sends it to xkg if it changed. Never throws; gives up after 3 s. */
492
+ export async function syncCodeMap(apiKey, cwd = process.cwd(), opts = {}) {
493
+ const cachePath = opts.cachePath ?? join(homedir(), ".xkg", "maps.json");
494
+ try {
495
+ const cache = loadCache(cachePath);
496
+ const map = buildCodeMap(cwd, { cache });
497
+ if (!map) {
498
+ let inRepo = true;
499
+ try {
500
+ git(cwd, "rev-parse", "--is-inside-work-tree");
501
+ }
502
+ catch {
503
+ inRepo = false;
504
+ }
505
+ return {
506
+ status: "skipped",
507
+ reason: inRepo
508
+ ? "This repo has no origin remote, so xkg can't tell which repo it is."
509
+ : "Not a git repository. Run this inside the repo you code in.",
510
+ };
511
+ }
512
+ // The server keeps a map per account, so a new key means a new upload.
513
+ // Keying the stamp with the API key changes it per account without storing the key.
514
+ const hash = createHmac("sha256", apiKey).update(mapHash(map)).digest("hex");
515
+ const entry = cache.repos[map.repo];
516
+ // Re-send daily so the server's copy stays fresh and comes back if it was dropped.
517
+ if (!opts.force && entry.hash === hash && Date.now() - (entry.sentAt ?? 0) < RESEND_AFTER_MS) {
518
+ saveCache(cachePath, cache);
519
+ return { status: "unchanged", repo: map.repo, files: map.files.length };
520
+ }
521
+ let res;
522
+ try {
523
+ res = await fetch(MAP_URL, {
524
+ method: "POST",
525
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
526
+ body: JSON.stringify(map),
527
+ signal: AbortSignal.timeout(opts.timeoutMs ?? SYNC_TIMEOUT_MS),
528
+ });
529
+ }
530
+ catch (err) {
531
+ saveCache(cachePath, cache);
532
+ return { status: "failed", repo: map.repo, reason: `Could not reach xkg: ${err instanceof Error ? err.message : String(err)}` };
533
+ }
534
+ if (!res.ok) {
535
+ saveCache(cachePath, cache);
536
+ return { status: "failed", repo: map.repo, reason: `xkg answered HTTP ${res.status}.` };
537
+ }
538
+ entry.hash = hash;
539
+ entry.sentAt = Date.now();
540
+ saveCache(cachePath, cache);
541
+ return { status: "synced", repo: map.repo, files: map.files.length };
542
+ }
543
+ catch (err) {
544
+ return { status: "failed", reason: err instanceof Error ? err.message : String(err) };
545
+ }
546
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * xkg CLI config — stores which AI assistant kos should drive, and an
2
+ * xkg CLI config — stores which AI assistant `xkg agent` should drive, and an
3
3
  * optional API key, in ~/.xkg/config.json.
4
4
  * Falls back to ~/.xmu/config.json then ~/.mnemos/config.json.
5
5
  */