mind-palace-graph 0.3.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 (51) hide show
  1. package/INSTALL.md +387 -0
  2. package/README.md +602 -0
  3. package/dist/api.d.ts +682 -0
  4. package/dist/api.js +660 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/cli.d.ts +95 -0
  7. package/dist/cli.js +856 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/format.d.ts +16 -0
  10. package/dist/format.js +199 -0
  11. package/dist/format.js.map +1 -0
  12. package/dist/fuzzy.d.ts +45 -0
  13. package/dist/fuzzy.js +150 -0
  14. package/dist/fuzzy.js.map +1 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +528 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/mcp-server.d.ts +24 -0
  19. package/dist/mcp-server.js +187 -0
  20. package/dist/mcp-server.js.map +1 -0
  21. package/dist/mind-palace.d.ts +148 -0
  22. package/dist/mind-palace.js +780 -0
  23. package/dist/mind-palace.js.map +1 -0
  24. package/dist/nodes.d.ts +57 -0
  25. package/dist/nodes.js +220 -0
  26. package/dist/nodes.js.map +1 -0
  27. package/dist/pagination.d.ts +41 -0
  28. package/dist/pagination.js +63 -0
  29. package/dist/pagination.js.map +1 -0
  30. package/dist/palace-format.d.ts +30 -0
  31. package/dist/palace-format.js +146 -0
  32. package/dist/palace-format.js.map +1 -0
  33. package/dist/rg.d.ts +34 -0
  34. package/dist/rg.js +288 -0
  35. package/dist/rg.js.map +1 -0
  36. package/dist/sources.d.ts +87 -0
  37. package/dist/sources.js +457 -0
  38. package/dist/sources.js.map +1 -0
  39. package/dist/tokens.d.ts +35 -0
  40. package/dist/tokens.js +95 -0
  41. package/dist/tokens.js.map +1 -0
  42. package/dist/types.d.ts +236 -0
  43. package/dist/types.js +8 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +67 -0
  46. package/skills/mpg-context/SKILL.md +556 -0
  47. package/skills/mpg-context/references/anti-patterns.md +133 -0
  48. package/skills/mpg-context/references/integration.md +123 -0
  49. package/skills/mpg-context/references/mind-palace.md +217 -0
  50. package/skills/mpg-context/references/multi-agent.md +147 -0
  51. package/skills/mpg-context/references/sources.md +120 -0
@@ -0,0 +1,780 @@
1
+ /**
2
+ * Mind Palace — the LLM's instantiable short-term memory.
3
+ *
4
+ * The metaphor: an LLM harness doing multi-step investigation needs
5
+ * named, addressable memory slots it can write to, read from, and
6
+ * compose. The mind palace provides exactly that.
7
+ *
8
+ * --mp-stash <name> <note> instantiate a slot: run the search,
9
+ * stash the result, output as normal
10
+ * --mp-from <name> read from a slot as search input
11
+ * (re-runs the search fresh, scoped to
12
+ * the stashed file paths)
13
+ * --mp-compose <a> <b> ... read from multiple slots at once
14
+ * --mp-list [--tag t] inspect: what slots exist
15
+ * --mp-get <name> inspect: full contents of a slot
16
+ * --mp-drop <name> destroy: free a slot
17
+ *
18
+ * Storage: a JSON file (default `./.mpg/mind-palace.json`, project-
19
+ * scoped). Use `--mp-path` to point at a different file for isolated
20
+ * sessions. The LLM can have multiple palaces (one per task) just by
21
+ * pointing `--mp-path` at different files.
22
+ */
23
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
24
+ import { randomBytes } from "node:crypto";
25
+ import { dirname, resolve as resolvePath, join } from "node:path";
26
+ export const PALACE_VERSION = 1;
27
+ export const DEFAULT_PALACE_FILENAME = "mind-palace.json";
28
+ export const DEFAULT_PALACE_DIR = ".mpg";
29
+ function emptyPalace() {
30
+ return { version: PALACE_VERSION, stashes: {} };
31
+ }
32
+ /** Walk up from `start` looking for a mind-palace.json. Returns null if none found. */
33
+ export function findExistingPalace(start = process.cwd()) {
34
+ let dir = resolvePath(start);
35
+ // Limit the search depth to prevent runaway walks on weird FS layouts.
36
+ for (let i = 0; i < 16; i++) {
37
+ const candidate = join(dir, DEFAULT_PALACE_DIR, DEFAULT_PALACE_FILENAME);
38
+ if (existsSync(candidate))
39
+ return candidate;
40
+ const parent = dirname(dir);
41
+ if (parent === dir)
42
+ break;
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+ /** Resolve the default palace path: env override, then git root, then
48
+ * nearest existing palace walking up, then CWD/.mpg/.
49
+ * This ensures the mind palace is project-scoped by default — even
50
+ * when invoked from a deep subdirectory. */
51
+ export function defaultPalacePath() {
52
+ const envPath = process.env.MPG_MIND_PALACE;
53
+ if (envPath)
54
+ return resolvePath(envPath);
55
+ // Try the git root first (most reliable project boundary).
56
+ const gitRoot = findGitRoot();
57
+ if (gitRoot) {
58
+ return join(gitRoot, DEFAULT_PALACE_DIR, DEFAULT_PALACE_FILENAME);
59
+ }
60
+ const existing = findExistingPalace();
61
+ if (existing)
62
+ return existing;
63
+ return resolvePath(process.cwd(), DEFAULT_PALACE_DIR, DEFAULT_PALACE_FILENAME);
64
+ }
65
+ /** Find the root of a git repository by walking up from start.
66
+ * Returns null if not inside a git repo. */
67
+ function findGitRoot(start = process.cwd()) {
68
+ let dir = resolvePath(start);
69
+ for (let i = 0; i < 32; i++) {
70
+ if (existsSync(join(dir, ".git")))
71
+ return dir;
72
+ const parent = dirname(dir);
73
+ if (parent === dir)
74
+ break;
75
+ dir = parent;
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * `loadPalace` is called from many places (every `mpg` invocation that
81
+ * touches the palace). Two pathologies we must avoid:
82
+ *
83
+ * 1. Returning `emptyPalace()` on JSON.parse failure and letting the
84
+ * next `savePalace` clobber the user's real data.
85
+ * 2. Returning a partially-deserialized object that looks valid but
86
+ * drops fields silently.
87
+ *
88
+ * So: on parse failure, we copy the corrupt file aside, emit a loud
89
+ * stderr warning, and refuse to clobber on subsequent saves *for the
90
+ * lifetime of this process* by marking the in-memory palace tainted.
91
+ * The caller still gets an empty palace so reads (--mp-list etc.)
92
+ * work, but any save will throw unless MPG_FORCE_RESET=1.
93
+ */
94
+ const TAINTED = Symbol.for("mpg.palace.tainted");
95
+ /**
96
+ * Full snapshot of the palace contents at load time. `savePalace` uses
97
+ * it to compute the **diff** this process made (added X / modified Y /
98
+ * removed Z), then re-applies that diff on top of whatever's actually
99
+ * on disk at save time. Without this, two parallel processes could
100
+ * each load a stale view, do disjoint mutations, save in sequence, and
101
+ * silently lose one writer's changes (or worse — resurrect a stash the
102
+ * other process dropped).
103
+ *
104
+ * Concretely: if process A's in-memory copy is missing stash X, that
105
+ * could mean (a) A loaded after X was created elsewhere — we should
106
+ * NOT drop X, or (b) A explicitly dropped X — we MUST drop it. The
107
+ * snapshot tells us which.
108
+ */
109
+ const SNAPSHOT = Symbol.for("mpg.palace.snapshot");
110
+ function deepCloneStashes(stashes) {
111
+ // JSON round-trip is the cheapest correct deep clone for our shape;
112
+ // stashes are plain JSON-serializable.
113
+ return JSON.parse(JSON.stringify(stashes));
114
+ }
115
+ export function loadPalace(path) {
116
+ if (!existsSync(path))
117
+ return emptyPalace();
118
+ let raw;
119
+ try {
120
+ raw = readFileSync(path, "utf8");
121
+ }
122
+ catch (err) {
123
+ process.stderr.write(`mpg: cannot read mind palace at ${path}: ${err.message}\n`);
124
+ const tainted = emptyPalace();
125
+ tainted[TAINTED] = true;
126
+ return tainted;
127
+ }
128
+ // Empty file is OK — first save will populate it.
129
+ if (raw.trim().length === 0)
130
+ return emptyPalace();
131
+ try {
132
+ const parsed = JSON.parse(raw);
133
+ if (typeof parsed !== "object" || parsed === null) {
134
+ throw new Error("top-level not an object");
135
+ }
136
+ if (typeof parsed.stashes !== "object" || parsed.stashes === null) {
137
+ parsed.stashes = {};
138
+ }
139
+ if (typeof parsed.version !== "number")
140
+ parsed.version = PALACE_VERSION;
141
+ // Snapshot the loaded state. savePalace diffs current vs snapshot
142
+ // to know what THIS process intentionally added or removed.
143
+ parsed[SNAPSHOT] = {
144
+ version: parsed.version,
145
+ stashes: deepCloneStashes(parsed.stashes),
146
+ };
147
+ return parsed;
148
+ }
149
+ catch (err) {
150
+ // Preserve the corrupt file for forensics rather than silently
151
+ // overwriting it with the next save.
152
+ const backupPath = `${path}.corrupt.${Date.now()}`;
153
+ try {
154
+ writeFileSync(backupPath, raw, "utf8");
155
+ }
156
+ catch { /* if even the backup fails, we still need to warn */ }
157
+ process.stderr.write(`mpg: WARNING — mind palace at ${path} is corrupt ` +
158
+ `(${err.message}). Saved a copy to ${backupPath}. ` +
159
+ `Saves will refuse to overwrite this file unless ` +
160
+ `MPG_FORCE_RESET=1 is set. Fix the file or move it aside.\n`);
161
+ const tainted = emptyPalace();
162
+ tainted[TAINTED] = true;
163
+ return tainted;
164
+ }
165
+ }
166
+ /**
167
+ * Atomic save: write to a sibling temp file then rename into place.
168
+ * Cross-process concurrency is bounded by a simple lock file. The lock
169
+ * file is created with `wx` so collisions surface as EEXIST; we retry
170
+ * with backoff up to ~2s before giving up. A stale lock (older than
171
+ * 30s) is forcibly broken to handle crashed callers.
172
+ */
173
+ const LOCK_STALE_MS = 30_000;
174
+ const LOCK_MAX_WAIT_MS = 2_000;
175
+ function acquireLock(path) {
176
+ const lockPath = `${path}.lock`;
177
+ const start = Date.now();
178
+ let attempt = 0;
179
+ while (true) {
180
+ try {
181
+ const fd = openSync(lockPath, "wx");
182
+ writeSync(fd, `${process.pid}\n`);
183
+ closeSync(fd);
184
+ return {
185
+ release: () => { try {
186
+ unlinkSync(lockPath);
187
+ }
188
+ catch { /* ignore */ } },
189
+ };
190
+ }
191
+ catch (err) {
192
+ const code = err.code;
193
+ if (code !== "EEXIST")
194
+ throw err;
195
+ // Check for a stale lock and force-break it.
196
+ try {
197
+ const { statSync } = require("node:fs");
198
+ const st = statSync(lockPath);
199
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
200
+ try {
201
+ unlinkSync(lockPath);
202
+ }
203
+ catch { /* ignore */ }
204
+ continue;
205
+ }
206
+ }
207
+ catch { /* lock disappeared between EEXIST and stat — retry */ }
208
+ if (Date.now() - start > LOCK_MAX_WAIT_MS) {
209
+ throw new Error(`mpg: could not acquire lock on ${lockPath} after ${LOCK_MAX_WAIT_MS}ms. ` +
210
+ `Another mpg process may be writing the palace, or a stale lock exists. ` +
211
+ `Delete ${lockPath} manually if no other mpg is running.`);
212
+ }
213
+ // Exponential-ish backoff with jitter.
214
+ const sleep = Math.min(50 * (1 << Math.min(attempt, 5)), 250);
215
+ const end = Date.now() + sleep + Math.floor(Math.random() * 20);
216
+ while (Date.now() < end) { /* spin — short enough that setTimeout overhead would hurt */ }
217
+ attempt++;
218
+ }
219
+ }
220
+ }
221
+ export function savePalace(path, palace) {
222
+ if (palace[TAINTED] && !process.env.MPG_FORCE_RESET) {
223
+ throw new Error(`mpg: refusing to save over a tainted palace at ${path}. ` +
224
+ `The on-disk file was unreadable or corrupt; saving now would ` +
225
+ `destroy whatever data was there. Inspect the *.corrupt.* backup, ` +
226
+ `then either fix the file or set MPG_FORCE_RESET=1 to overwrite.`);
227
+ }
228
+ const dir = dirname(path);
229
+ if (!existsSync(dir)) {
230
+ mkdirSync(dir, { recursive: true });
231
+ }
232
+ const lock = acquireLock(path);
233
+ try {
234
+ // Compute the diff this process intentionally made: which stashes
235
+ // it added, modified, or removed relative to the snapshot it
236
+ // loaded. Then re-read whatever's actually on disk RIGHT NOW
237
+ // (which may have moved on since we loaded) and replay our diff on
238
+ // top of it. This is the only model that's correct under
239
+ // concurrent writers:
240
+ //
241
+ // - A "removed by us" stash MUST stay removed even if another
242
+ // writer's stale in-memory copy would otherwise re-add it.
243
+ // - An "added by us" stash MUST land.
244
+ // - A "modified by us" stash MUST take precedence over a
245
+ // concurrent modification (last-writer-wins for the same name).
246
+ // - A stash neither we nor another writer touched is untouched.
247
+ // - A stash another writer added or modified, that we didn't
248
+ // touch, is preserved.
249
+ //
250
+ // The previous v0.2.4 attempt at this merged the on-disk file back
251
+ // INTO our in-memory copy. That was wrong: it resurrected dropped
252
+ // stashes (the on-disk version still had them; we had removed them
253
+ // in memory; the merge "filled in the missing entry"). The fix is
254
+ // to merge in the other direction — our diff onto disk — and
255
+ // anchor "what did we do" to a snapshot taken at load time.
256
+ const snapshot = palace[SNAPSHOT];
257
+ const snapshotStashes = snapshot?.stashes ?? {};
258
+ const removedByUs = new Set();
259
+ for (const name of Object.keys(snapshotStashes)) {
260
+ if (!(name in palace.stashes))
261
+ removedByUs.add(name);
262
+ }
263
+ const touchedByUs = new Set(); // added OR modified by us
264
+ for (const [name, stash] of Object.entries(palace.stashes)) {
265
+ const before = snapshotStashes[name];
266
+ if (!before) {
267
+ touchedByUs.add(name); // added
268
+ continue;
269
+ }
270
+ // Modified iff the serialized form changed. JSON compare is
271
+ // adequate for our shape and avoids reference-equality false
272
+ // negatives after deepCloneStashes.
273
+ if (JSON.stringify(before) !== JSON.stringify(stash)) {
274
+ touchedByUs.add(name);
275
+ }
276
+ }
277
+ let merged;
278
+ if (existsSync(path)) {
279
+ try {
280
+ const onDiskRaw = readFileSync(path, "utf8");
281
+ if (onDiskRaw.trim().length === 0) {
282
+ merged = { version: palace.version, stashes: {} };
283
+ }
284
+ else {
285
+ const onDisk = JSON.parse(onDiskRaw);
286
+ if (!onDisk || typeof onDisk !== "object" || !onDisk.stashes) {
287
+ throw new Error("on-disk palace has no stashes object");
288
+ }
289
+ merged = {
290
+ version: typeof onDisk.version === "number" ? onDisk.version : palace.version,
291
+ stashes: { ...onDisk.stashes },
292
+ };
293
+ }
294
+ }
295
+ catch {
296
+ // The on-disk file went corrupt between load and save. We
297
+ // hold the lock; fall back to our in-memory copy and warn.
298
+ process.stderr.write(`mpg: on-disk palace at ${path} became unparseable between ` +
299
+ `load and save; overwriting with in-memory copy.\n`);
300
+ merged = { version: palace.version, stashes: { ...palace.stashes } };
301
+ }
302
+ }
303
+ else {
304
+ merged = { version: palace.version, stashes: {} };
305
+ }
306
+ // Apply our diff on top of the freshest on-disk state.
307
+ for (const name of removedByUs) {
308
+ delete merged.stashes[name];
309
+ }
310
+ for (const name of touchedByUs) {
311
+ merged.stashes[name] = palace.stashes[name];
312
+ }
313
+ // Persist the merge result back into the in-memory palace so the
314
+ // caller can keep operating on a coherent view after savePalace.
315
+ palace.stashes = merged.stashes;
316
+ palace.version = merged.version;
317
+ palace[SNAPSHOT] = {
318
+ version: merged.version,
319
+ stashes: deepCloneStashes(merged.stashes),
320
+ };
321
+ const tmpPath = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
322
+ writeFileSync(tmpPath, JSON.stringify(palace, null, 2) + "\n", "utf8");
323
+ try {
324
+ renameSync(tmpPath, path);
325
+ }
326
+ catch (err) {
327
+ try {
328
+ unlinkSync(tmpPath);
329
+ }
330
+ catch { /* ignore */ }
331
+ throw err;
332
+ }
333
+ }
334
+ finally {
335
+ lock.release();
336
+ }
337
+ }
338
+ /** Convert full Node objects into the compact StashedNode form. */
339
+ export function stashNodes(nodes) {
340
+ return nodes.map((n) => ({
341
+ source: n.source.id,
342
+ file_path: n.source.type === "file" ? n.source.id : null,
343
+ source_type: n.source.type,
344
+ match_line: n.match_line,
345
+ start_line: n.start_line,
346
+ end_line: n.end_line,
347
+ context_before: n.context_before,
348
+ match_text: n.match_text,
349
+ context_after: n.context_after,
350
+ tokens: n.tokens,
351
+ }));
352
+ }
353
+ /** Reverse: turn StashedNodes back into Sources (unique file paths). */
354
+ export function stashToSources(stash) {
355
+ const seen = new Set();
356
+ const out = [];
357
+ for (const n of stash.nodes) {
358
+ if (seen.has(n.source))
359
+ continue;
360
+ seen.add(n.source);
361
+ out.push({ id: n.source, type: "file" });
362
+ }
363
+ return out;
364
+ }
365
+ /**
366
+ * Derive file-only paths from the canonical nodes (which carry source
367
+ * type info). Falls back to the legacy string-heuristic over `sources`
368
+ * only when no nodes are present — preserves behavior for callers that
369
+ * pass an empty nodes list.
370
+ */
371
+ function deriveFilePaths(nodes, sources) {
372
+ const filesFromNodes = new Set();
373
+ for (const n of nodes) {
374
+ if (n.source.type === "file")
375
+ filesFromNodes.add(n.source.id);
376
+ }
377
+ if (filesFromNodes.size > 0)
378
+ return [...filesFromNodes];
379
+ // Fallback: tolerate a sources-only call by skipping anything that
380
+ // *clearly* isn't a file path (`cmd:...`, `http(s)://...`, `stdin`).
381
+ return dedup(sources.filter((s) => !s.startsWith("cmd:") &&
382
+ !s.startsWith("http://") &&
383
+ !s.startsWith("https://") &&
384
+ s !== "stdin"));
385
+ }
386
+ /** Add or merge a stash into the palace. Merge dedupes by (source, match_line). */
387
+ export function addStash(palace, name, note, nodes, meta, sources, tags = [], options = {}) {
388
+ const now = new Date().toISOString();
389
+ const existing = palace.stashes[name];
390
+ const expiresAt = options.ttl ? expiryFromNow(options.ttl) : null;
391
+ const newNodes = options.locations
392
+ ? stashNodesLocations(nodes)
393
+ : stashNodes(nodes);
394
+ const newFilePaths = deriveFilePaths(nodes, sources);
395
+ if (!existing) {
396
+ const stash = {
397
+ name,
398
+ note,
399
+ tags: [...tags],
400
+ created_at: now,
401
+ updated_at: now,
402
+ expires_at: expiresAt,
403
+ search: meta,
404
+ sources: dedup(sources),
405
+ nodes: newNodes,
406
+ file_paths: newFilePaths,
407
+ relations: [],
408
+ };
409
+ palace.stashes[name] = stash;
410
+ return { stash, action: "created" };
411
+ }
412
+ if (options.replace) {
413
+ existing.note = note;
414
+ existing.tags = [...tags];
415
+ existing.updated_at = now;
416
+ existing.expires_at = expiresAt;
417
+ existing.search = meta;
418
+ existing.sources = dedup(sources);
419
+ existing.nodes = newNodes;
420
+ existing.file_paths = newFilePaths;
421
+ return { stash: existing, action: "replaced" };
422
+ }
423
+ // Merge: dedupe by (source, match_line), keep first occurrence.
424
+ const seen = new Set();
425
+ for (const n of existing.nodes)
426
+ seen.add(`${n.source}:${n.match_line}`);
427
+ for (const n of newNodes) {
428
+ const key = `${n.source}:${n.match_line}`;
429
+ if (!seen.has(key)) {
430
+ existing.nodes.push(n);
431
+ seen.add(key);
432
+ }
433
+ }
434
+ existing.sources = dedup([...existing.sources, ...sources]);
435
+ existing.file_paths = dedup([...existing.file_paths, ...newFilePaths]);
436
+ if (tags.length > 0) {
437
+ const tagSet = new Set([...existing.tags, ...tags]);
438
+ existing.tags = [...tagSet];
439
+ }
440
+ if (note)
441
+ existing.note = note; // overwrite note on merge
442
+ if (expiresAt)
443
+ existing.expires_at = expiresAt;
444
+ existing.updated_at = now;
445
+ return { stash: existing, action: "merged" };
446
+ }
447
+ export function getStash(palace, name) {
448
+ return palace.stashes[name] ?? null;
449
+ }
450
+ export function dropStash(palace, name) {
451
+ if (!(name in palace.stashes))
452
+ return false;
453
+ delete palace.stashes[name];
454
+ return true;
455
+ }
456
+ export function listStashes(palace, tagFilter) {
457
+ const all = Object.values(palace.stashes);
458
+ if (!tagFilter || tagFilter.length === 0)
459
+ return all;
460
+ return all.filter((s) => tagFilter.every((t) => s.tags.includes(t)));
461
+ }
462
+ /** Compose multiple stashes into a single set of unique Sources. */
463
+ export function composeToSources(palace, names) {
464
+ const seen = new Set();
465
+ const out = [];
466
+ const missing = [];
467
+ for (const name of names) {
468
+ const stash = palace.stashes[name];
469
+ if (!stash) {
470
+ missing.push(name);
471
+ continue;
472
+ }
473
+ for (const s of stashToSources(stash)) {
474
+ if (!seen.has(s.id)) {
475
+ seen.add(s.id);
476
+ out.push(s);
477
+ }
478
+ }
479
+ }
480
+ if (missing.length > 0) {
481
+ throw new Error(`Unknown stashes: ${missing.join(", ")}. ` +
482
+ `Run 'mpg --mp-list' to see available stashes.`);
483
+ }
484
+ return out;
485
+ }
486
+ /** Set difference: files in `a` but not in any of `b`. */
487
+ export function exceptToSources(palace, a, b) {
488
+ const base = palace.stashes[a];
489
+ if (!base) {
490
+ throw new Error(`Unknown stash: ${a}. Run 'mpg --mp-list' to see available stashes.`);
491
+ }
492
+ const excludeIds = new Set();
493
+ for (const name of b) {
494
+ const stash = palace.stashes[name];
495
+ if (!stash) {
496
+ throw new Error(`Unknown stash: ${name}. Run 'mpg --mp-list' to see available stashes.`);
497
+ }
498
+ for (const s of stashToSources(stash))
499
+ excludeIds.add(s.id);
500
+ }
501
+ const out = [];
502
+ const seen = new Set();
503
+ for (const s of stashToSources(base)) {
504
+ if (excludeIds.has(s.id))
505
+ continue;
506
+ if (seen.has(s.id))
507
+ continue;
508
+ seen.add(s.id);
509
+ out.push(s);
510
+ }
511
+ return out;
512
+ }
513
+ /** Set intersection: files in ALL of the given stashes. */
514
+ export function intersectToSources(palace, names) {
515
+ if (names.length === 0)
516
+ return [];
517
+ const fileSets = [];
518
+ for (const name of names) {
519
+ const stash = palace.stashes[name];
520
+ if (!stash) {
521
+ throw new Error(`Unknown stash: ${name}. Run 'mpg --mp-list' to see available stashes.`);
522
+ }
523
+ fileSets.push(new Set(stashToSources(stash).map((s) => s.id)));
524
+ }
525
+ // Start with the first set's files, keep only those present in all others.
526
+ const [first, ...rest] = fileSets;
527
+ const out = [];
528
+ for (const id of first) {
529
+ if (rest.every((s) => s.has(id))) {
530
+ out.push({ id, type: "file" });
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+ function dedup(arr) {
536
+ return [...new Set(arr)];
537
+ }
538
+ /** Lightweight stash: only (source, line, match_text), no context buffers. */
539
+ export function stashNodesLocations(nodes) {
540
+ return nodes.map((n) => ({
541
+ source: n.source.id,
542
+ file_path: n.source.type === "file" ? n.source.id : null,
543
+ source_type: n.source.type,
544
+ match_line: n.match_line,
545
+ start_line: n.match_line,
546
+ end_line: n.match_line,
547
+ context_before: [],
548
+ match_text: n.match_text,
549
+ context_after: [],
550
+ tokens: 0,
551
+ }));
552
+ }
553
+ // ─── Timestamp utilities ─────────────────────────────────────────────
554
+ /** Parse a human-readable duration into milliseconds.
555
+ * Accepts: "30s", "10m", "2h", "7d", "14d", or bare number (ms). */
556
+ export function parseDuration(s) {
557
+ const m = s.trim().match(/^([\d.]+)\s*(s|sec|m|min|h|hr|d|day|ms)?$/i);
558
+ if (!m)
559
+ throw new Error(`Invalid duration: ${s}. Use e.g. 30s, 10m, 2h, 7d.`);
560
+ const n = parseFloat(m[1]);
561
+ const unit = (m[2] || "ms").toLowerCase();
562
+ switch (unit) {
563
+ case "s":
564
+ case "sec": return n * 1000;
565
+ case "m":
566
+ case "min": return n * 60 * 1000;
567
+ case "h":
568
+ case "hr": return n * 3600 * 1000;
569
+ case "d":
570
+ case "day": return n * 86400 * 1000;
571
+ default: return n; // raw ms
572
+ }
573
+ }
574
+ /** Format an ISO timestamp as a relative time string for display. */
575
+ export function formatRelativeTime(iso) {
576
+ const ms = Date.now() - new Date(iso).getTime();
577
+ if (ms < 0)
578
+ return "just now";
579
+ const s = Math.floor(ms / 1000);
580
+ if (s < 10)
581
+ return "just now";
582
+ if (s < 60)
583
+ return `${s}s ago`;
584
+ const m = Math.floor(s / 60);
585
+ if (m < 60)
586
+ return `${m}m ago`;
587
+ const h = Math.floor(m / 60);
588
+ if (h < 24)
589
+ return `${h}h ago`;
590
+ const d = Math.floor(h / 24);
591
+ return `${d}d ago`;
592
+ }
593
+ /** Compute an expiry timestamp from now + duration. */
594
+ export function expiryFromNow(duration) {
595
+ return new Date(Date.now() + parseDuration(duration)).toISOString();
596
+ }
597
+ /** Remove stashes whose updated_at is older than `duration`. */
598
+ export function pruneOlderThan(palace, duration, dryRun = false) {
599
+ const cutoff = Date.now() - parseDuration(duration);
600
+ const names = [];
601
+ for (const [name, stash] of Object.entries(palace.stashes)) {
602
+ if (new Date(stash.updated_at).getTime() < cutoff) {
603
+ names.push(name);
604
+ }
605
+ }
606
+ if (!dryRun)
607
+ for (const n of names)
608
+ delete palace.stashes[n];
609
+ return { removed: names.length, names, dry_run: dryRun };
610
+ }
611
+ /** Remove stashes whose expires_at is in the past. */
612
+ export function pruneExpired(palace, dryRun = false) {
613
+ const now = Date.now();
614
+ const names = [];
615
+ for (const [name, stash] of Object.entries(palace.stashes)) {
616
+ if (stash.expires_at && new Date(stash.expires_at).getTime() < now) {
617
+ names.push(name);
618
+ }
619
+ }
620
+ if (!dryRun)
621
+ for (const n of names)
622
+ delete palace.stashes[n];
623
+ return { removed: names.length, names, dry_run: dryRun };
624
+ }
625
+ /** Keep the N most recently updated stashes, remove the rest. */
626
+ export function pruneKeep(palace, n, dryRun = false) {
627
+ const sorted = Object.values(palace.stashes).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
628
+ const toRemove = sorted.slice(n);
629
+ const names = toRemove.map((s) => s.name);
630
+ if (!dryRun)
631
+ for (const n of names)
632
+ delete palace.stashes[n];
633
+ return { removed: names.length, names, dry_run: dryRun };
634
+ }
635
+ /** Remove all stashes with the given tag. */
636
+ export function pruneTag(palace, tag, dryRun = false) {
637
+ const names = [];
638
+ for (const [name, stash] of Object.entries(palace.stashes)) {
639
+ if (stash.tags.includes(tag)) {
640
+ names.push(name);
641
+ }
642
+ }
643
+ if (!dryRun)
644
+ for (const n of names)
645
+ delete palace.stashes[n];
646
+ return { removed: names.length, names, dry_run: dryRun };
647
+ }
648
+ /** Remove all stashes. Requires explicit confirmation. */
649
+ export function pruneAll(palace, confirmed, dryRun = false) {
650
+ const names = Object.keys(palace.stashes);
651
+ if (!confirmed) {
652
+ throw new Error(`This would remove ${names.length} stashes. ` +
653
+ `Pass --mp-prune-confirm to actually delete them. ` +
654
+ `Use --mp-prune-dry-run to see what would be removed.`);
655
+ }
656
+ if (!dryRun)
657
+ palace.stashes = {};
658
+ return { removed: names.length, names, dry_run: dryRun };
659
+ }
660
+ // ─── Relationships ──────────────────────────────────────────────────
661
+ /** Add a directed relationship from `from` stash to `to` stash. */
662
+ export function addRelation(palace, from, to, type, note) {
663
+ const source = palace.stashes[from];
664
+ if (!source)
665
+ throw new Error(`Unknown stash: ${from}`);
666
+ if (!palace.stashes[to])
667
+ throw new Error(`Unknown stash: ${to}`);
668
+ if (from === to)
669
+ throw new Error(`Cannot link a stash to itself.`);
670
+ const rel = {
671
+ target: to,
672
+ type,
673
+ note,
674
+ created_at: new Date().toISOString(),
675
+ };
676
+ // Dedup: replace any existing relation with the same target+type.
677
+ source.relations = source.relations.filter((r) => !(r.target === to && r.type === type));
678
+ source.relations.push(rel);
679
+ source.updated_at = new Date().toISOString();
680
+ return rel;
681
+ }
682
+ /** Remove a relationship from `from` to `to`. */
683
+ export function removeRelation(palace, from, to) {
684
+ const source = palace.stashes[from];
685
+ if (!source)
686
+ throw new Error(`Unknown stash: ${from}`);
687
+ const before = source.relations.length;
688
+ source.relations = source.relations.filter((r) => r.target !== to);
689
+ if (source.relations.length < before) {
690
+ source.updated_at = new Date().toISOString();
691
+ return true;
692
+ }
693
+ return false;
694
+ }
695
+ /** Get all stashes related to `name` (both outbound and inbound edges). */
696
+ export function getRelated(palace, name) {
697
+ if (!palace.stashes[name])
698
+ return [];
699
+ const out = [];
700
+ // Outbound: relationships FROM this stash.
701
+ for (const r of palace.stashes[name].relations) {
702
+ const target = palace.stashes[r.target];
703
+ if (target)
704
+ out.push({ stash: target, direction: "outbound", relation: r });
705
+ }
706
+ // Inbound: relationships TO this stash from others.
707
+ for (const [otherName, otherStash] of Object.entries(palace.stashes)) {
708
+ if (otherName === name)
709
+ continue;
710
+ for (const r of otherStash.relations) {
711
+ if (r.target === name) {
712
+ out.push({ stash: otherStash, direction: "inbound", relation: r });
713
+ }
714
+ }
715
+ }
716
+ return out;
717
+ }
718
+ /** Traverse the relationship graph from `name` up to `maxDepth` levels. */
719
+ export function traversalGraph(palace, name, maxDepth) {
720
+ if (!palace.stashes[name])
721
+ return [];
722
+ const visited = new Set([name]);
723
+ const out = [];
724
+ const queue = [];
725
+ // Seed with outbound edges from the starting node.
726
+ for (const r of palace.stashes[name].relations) {
727
+ if (!palace.stashes[r.target])
728
+ continue;
729
+ queue.push({
730
+ target: r.target,
731
+ depth: 1,
732
+ direction: "outbound",
733
+ via: name,
734
+ relation: r,
735
+ });
736
+ }
737
+ // Also seed inbound edges.
738
+ for (const [otherName, otherStash] of Object.entries(palace.stashes)) {
739
+ if (otherName === name)
740
+ continue;
741
+ for (const r of otherStash.relations) {
742
+ if (r.target === name) {
743
+ queue.push({
744
+ target: otherName,
745
+ depth: 1,
746
+ direction: "inbound",
747
+ via: name,
748
+ relation: r,
749
+ });
750
+ }
751
+ }
752
+ }
753
+ // BFS traversal.
754
+ while (queue.length > 0) {
755
+ const item = queue.shift();
756
+ if (visited.has(item.target))
757
+ continue;
758
+ visited.add(item.target);
759
+ const stash = palace.stashes[item.target];
760
+ if (!stash)
761
+ continue;
762
+ out.push({ stash, depth: item.depth, direction: item.direction, via: item.via, relation: item.relation });
763
+ if (item.depth >= maxDepth)
764
+ continue;
765
+ // Enqueue neighbors.
766
+ for (const r of stash.relations) {
767
+ if (!visited.has(r.target)) {
768
+ queue.push({
769
+ target: r.target,
770
+ depth: item.depth + 1,
771
+ direction: "outbound",
772
+ via: item.target,
773
+ relation: r,
774
+ });
775
+ }
776
+ }
777
+ }
778
+ return out;
779
+ }
780
+ //# sourceMappingURL=mind-palace.js.map