pi-memoria 0.1.2 → 0.1.3
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 +19 -15
- package/index.ts +24 -19
- package/package.json +1 -1
- package/src/commands.ts +6 -5
- package/src/index-engine.ts +32 -6
- package/src/runtime.ts +95 -56
- package/src/sessions.ts +24 -3
- package/src/store.ts +69 -16
- package/src/tools.ts +1 -1
- package/src/usage.ts +15 -1
- package/src/util.ts +5 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# memoria
|
|
1
|
+
# pi-memoria
|
|
2
2
|
|
|
3
3
|
Agent-first long-term memory for [pi](https://github.com/badlogic/pi-mono).
|
|
4
4
|
|
|
@@ -17,8 +17,8 @@ a millisecond.
|
|
|
17
17
|
- **Broad topics, never fact files.** `likes-apples.md` is a defect; facts
|
|
18
18
|
accumulate in `food-preferences.md`.
|
|
19
19
|
- **Names are handles.** Aliases make every nickname, full name or former name
|
|
20
|
-
resolve to the same note
|
|
21
|
-
references.
|
|
20
|
+
resolve to the same note. Re-filing or consolidating notes also keeps retired
|
|
21
|
+
note ids usable as references.
|
|
22
22
|
- **Zero runtime dependencies.** No database, no server, no embedding service, no
|
|
23
23
|
build step. Retrieval is a BM25 index kept in memory.
|
|
24
24
|
|
|
@@ -83,11 +83,11 @@ Pi discovers `extensions/memoria/index.ts` on the next start (or after
|
|
|
83
83
|
### Install as a pi package
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
|
-
pi install git:github.com/rcsaquino/pi-memoria@v0.1.
|
|
86
|
+
pi install git:github.com/rcsaquino/pi-memoria@v0.1.3
|
|
87
87
|
pi install npm:pi-memoria # if published to npm
|
|
88
88
|
pi install ./path/to/pi-memoria # or from a local checkout
|
|
89
89
|
pi list
|
|
90
|
-
pi remove git:github.com/rcsaquino/pi-memoria@v0.1.
|
|
90
|
+
pi remove git:github.com/rcsaquino/pi-memoria@v0.1.3
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
`pi install` records the package in `~/.pi/agent/settings.json` (or
|
|
@@ -233,9 +233,10 @@ When the better name *is* known, `memoria_move` re-files the note:
|
|
|
233
233
|
|
|
234
234
|
- Target free → the file is **renamed**; the note keeps its `id`, so ids read
|
|
235
235
|
earlier in the session stay valid.
|
|
236
|
-
- Target exists →
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
- Target exists → the move reports a conflict. Set `merge: true` to combine the
|
|
237
|
+
facts and links (duplicates skipped, nothing overwritten) and move the old
|
|
238
|
+
file to `.trash/`. A merge refuses to discard aliases or links when their
|
|
239
|
+
limits are full.
|
|
239
240
|
- The previous topic, title and file name are added to the target's aliases, so
|
|
240
241
|
old references keep working.
|
|
241
242
|
|
|
@@ -308,7 +309,7 @@ folder). Unknown keys are preserved verbatim through updates.
|
|
|
308
309
|
| `memoria_recall` | Search long-term memory. Filters by category, tags, priority, recency, scope; options for `include_body`, `min_score`, `explain`, `rerank`, `drop_superseded`, prefix/fuzzy control. |
|
|
309
310
|
| `memoria_write` | Add a fact to a broad **topic note** (`topic`, `content`, optional `label`, category, tags, aliases, related, supersedes, summary, priority). Creates the note on first use and appends afterwards. |
|
|
310
311
|
| `memoria_read` | Read a full note by id, path or alias, with its aliases, related notes and superseding notes. |
|
|
311
|
-
| `memoria_move` | Re-file a note under a better broad topic (`from`, `topic`, optional `category`, `merge`, `keep_alias`). Renames when the target is free
|
|
312
|
+
| `memoria_move` | Re-file a note under a better broad topic (`from`, `topic`, optional `category`, `merge`, `keep_alias`). Renames when the target is free; set `merge: true` to combine with an occupied target. |
|
|
312
313
|
| `memoria_sessions` | Search the transcripts of earlier conversations (`action: "search"`, plus `since_days`, `project`, `include_tools`, `user_only`) and read the surrounding dialogue (`action: "read"`, `path`, `line`, `window`) before quoting it. |
|
|
313
314
|
| `memoria_hot` | Read/add/remove/replace/compact `MEMORY.md`, the always-in-context briefing. |
|
|
314
315
|
| `memoria_list` | Browse categories, titles, ids, tags, aliases and summaries. |
|
|
@@ -326,7 +327,7 @@ have to ask.
|
|
|
326
327
|
/memoria search <query> Ranked results with snippets (--primary/--project/--all)
|
|
327
328
|
/memoria store <text> Quick capture into library/inbox
|
|
328
329
|
/memoria read <id|path> Print a full note
|
|
329
|
-
/memoria move <ref> <topic> Re-file a note
|
|
330
|
+
/memoria move <ref> <topic> [--merge] Re-file a note; opt into merging an occupied target
|
|
330
331
|
/memoria hot Show MEMORY.md
|
|
331
332
|
/memoria topics Review notes: facts, size, usage, merge candidates
|
|
332
333
|
/memoria diff [days] Memories created or updated recently (default 7 days)
|
|
@@ -571,10 +572,13 @@ a burst of edits costs one refresh.
|
|
|
571
572
|
- **Files, not a database.** Memoria never owns your data: notes are markdown
|
|
572
573
|
with frontmatter, deletes are soft (moved to `.trash/`), and the index is
|
|
573
574
|
derived and can be rebuilt with `/memoria reindex`.
|
|
574
|
-
- **Crash-safe moves.** Category moves and
|
|
575
|
-
|
|
575
|
+
- **Crash-safe moves.** Category moves, renames and merges are journaled. On
|
|
576
|
+
restart, a merge only removes its source after the target has all its facts
|
|
577
|
+
and the source's former id.
|
|
576
578
|
- **Atomic writes.** Every write goes through a temp file and rename; there is no
|
|
577
579
|
window where a note is half-written.
|
|
580
|
+
- **Scoped reads.** `memoria_read` only follows paths inside the memory library;
|
|
581
|
+
transcript window reads refuse paths or symlinks outside configured roots.
|
|
578
582
|
- **Secrets:** memoria does not scan for credentials, and the learn prompt tells
|
|
579
583
|
the model not to store them. If you keep secrets in the store anyway, remember
|
|
580
584
|
that recall may surface them in model context — treat the store like your shell
|
|
@@ -600,7 +604,7 @@ a burst of edits costs one refresh.
|
|
|
600
604
|
|
|
601
605
|
```bash
|
|
602
606
|
npm install # dev-only: pi host packages + typescript
|
|
603
|
-
npm test #
|
|
607
|
+
npm test # node:test, no build step
|
|
604
608
|
npm run typecheck # tsc --noEmit
|
|
605
609
|
npm run eval # recall@1/@5/MRR over the fixture corpus
|
|
606
610
|
npm run bench # latency at 1k and 5k notes
|
|
@@ -647,8 +651,8 @@ src/commands.ts /memoria subcommands
|
|
|
647
651
|
`"optional": true` in `peerDependenciesMeta`, keeping them in
|
|
648
652
|
`devDependencies` for local tests only. Without that, npm installs a ~450 MB
|
|
649
653
|
duplicate of pi into the package.
|
|
650
|
-
4. Tag releases (`git tag
|
|
651
|
-
`pi install git:github.com/<you>/pi-memoria@
|
|
654
|
+
4. Tag releases (`git tag v<version> && git push --tags`) and install by tag:
|
|
655
|
+
`pi install git:github.com/<you>/pi-memoria@v<version>`.
|
|
652
656
|
5. `npm pack --dry-run` should list only `index.ts`, `src/**`, `skills/**`,
|
|
653
657
|
`README.md`, `LICENSE` and `package.json`.
|
|
654
658
|
|
package/index.ts
CHANGED
|
@@ -29,14 +29,14 @@ import { RECALL_CUSTOM_TYPE, buildRecallParts, renderRecallBlock, renderStatusLi
|
|
|
29
29
|
import { buildTranscriptText, harvestSession } from "./src/learn.ts";
|
|
30
30
|
import { createModelReranker } from "./src/rerank.ts";
|
|
31
31
|
import { tokenizeRaw } from "./src/tokenize.ts";
|
|
32
|
-
import { oneLine } from "./src/util.ts";
|
|
32
|
+
import { KeyedMutex, oneLine } from "./src/util.ts";
|
|
33
33
|
|
|
34
34
|
const RECENT_PROMPT_LIMIT = 6;
|
|
35
35
|
|
|
36
36
|
export default function memoria(pi: ExtensionAPI) {
|
|
37
37
|
let runtime: MemoriaRuntime | undefined;
|
|
38
38
|
let runtimeCwd: string | undefined;
|
|
39
|
-
|
|
39
|
+
const runtimeLock = new KeyedMutex();
|
|
40
40
|
let hotCache: { content: string; chars: number; limit: number; over: boolean; mtimeMs: number } | undefined;
|
|
41
41
|
let recentPrompts: string[] = [];
|
|
42
42
|
/** The missing-ripgrep warning is shown once per session, not on every prompt. */
|
|
@@ -47,27 +47,31 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
47
47
|
let learnInFlight = false;
|
|
48
48
|
|
|
49
49
|
/** Resolve (and lazily initialize) the runtime for this working directory. */
|
|
50
|
-
const getRuntime =
|
|
50
|
+
const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
|
|
51
51
|
if (!runtime || runtimeCwd !== ctx.cwd) {
|
|
52
|
-
|
|
52
|
+
const previous = runtime;
|
|
53
|
+
runtime = undefined;
|
|
54
|
+
runtimeCwd = undefined;
|
|
55
|
+
await previous?.dispose().catch(() => {});
|
|
53
56
|
// Follow pi's own agent directory (honours PI_CODING_AGENT_DIR) so the
|
|
54
57
|
// store lives at `<agent dir>/memoria`.
|
|
55
|
-
|
|
58
|
+
const next = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
|
|
59
|
+
try {
|
|
60
|
+
await next.init(true);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
await next.dispose().catch(() => {});
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
runtime = next;
|
|
56
66
|
runtimeCwd = ctx.cwd;
|
|
57
67
|
hotCache = undefined;
|
|
58
68
|
recentPrompts = [];
|
|
59
69
|
turnsSinceLearn = 0;
|
|
60
70
|
lastLearnAt = 0;
|
|
61
71
|
learnInFlight = false;
|
|
62
|
-
initPromise = runtime.init(true).catch((error) => {
|
|
63
|
-
// Allow a later attempt to retry instead of caching the rejection forever.
|
|
64
|
-
initPromise = undefined;
|
|
65
|
-
throw error;
|
|
66
|
-
});
|
|
67
72
|
}
|
|
68
|
-
await initPromise;
|
|
69
73
|
return runtime;
|
|
70
|
-
};
|
|
74
|
+
});
|
|
71
75
|
|
|
72
76
|
/** Read MEMORY.md, reusing the cached copy until its mtime changes. */
|
|
73
77
|
const readHotCached = async (memoria: MemoriaRuntime): Promise<{ content: string; chars: number; limit: number; over: boolean }> => {
|
|
@@ -237,13 +241,14 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
237
241
|
} catch {
|
|
238
242
|
// Shutdown must never throw.
|
|
239
243
|
}
|
|
240
|
-
await
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
await runtimeLock.run("runtime", async () => {
|
|
245
|
+
await runtime?.flushUsageNow().catch(() => {});
|
|
246
|
+
await runtime?.dispose().catch(() => {});
|
|
247
|
+
runtime = undefined;
|
|
248
|
+
runtimeCwd = undefined;
|
|
249
|
+
hotCache = undefined;
|
|
250
|
+
recentPrompts = [];
|
|
251
|
+
});
|
|
247
252
|
});
|
|
248
253
|
|
|
249
254
|
pi.registerMessageRenderer(RECALL_CUSTOM_TYPE, (message, options, theme) => {
|
package/package.json
CHANGED
package/src/commands.ts
CHANGED
|
@@ -26,7 +26,7 @@ const SUBCOMMANDS: Array<{ value: string; label: string; description: string }>
|
|
|
26
26
|
{ value: "search", label: "search <query>", description: "Search long-term memory" },
|
|
27
27
|
{ value: "store", label: "store <text>", description: "Quickly save a memory to the inbox" },
|
|
28
28
|
{ value: "read", label: "read <id|path>", description: "Read a full memory note" },
|
|
29
|
-
{ value: "move", label: "move <ref> <topic>", description: "Re-file a note under a better broad topic" },
|
|
29
|
+
{ value: "move", label: "move <ref> <topic> [--merge]", description: "Re-file a note under a better broad topic" },
|
|
30
30
|
{ value: "hot", label: "hot", description: "Show MEMORY.md and its budget" },
|
|
31
31
|
{ value: "reindex", label: "reindex", description: "Rebuild the search index from disk" },
|
|
32
32
|
{ value: "index", label: "index", description: "Regenerate library/INDEX.md table of contents" },
|
|
@@ -183,12 +183,13 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
183
183
|
}
|
|
184
184
|
case "move": {
|
|
185
185
|
const [ref, ...rest] = remainder.split(/\s+/).filter(Boolean);
|
|
186
|
-
const
|
|
186
|
+
const merge = rest.includes("--merge");
|
|
187
|
+
const topic = rest.filter((word) => word !== "--merge").join(" ").trim();
|
|
187
188
|
if (!ref || !topic) {
|
|
188
|
-
ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic>", "warning");
|
|
189
|
+
ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic> [--merge]", "warning");
|
|
189
190
|
return;
|
|
190
191
|
}
|
|
191
|
-
const moved = await runtime.move(ref, { topic }, "all");
|
|
192
|
+
const moved = await runtime.move(ref, { topic, merge }, "all");
|
|
192
193
|
if (!moved) {
|
|
193
194
|
ctx.ui.notify(`Memory not found: ${ref}`, "error");
|
|
194
195
|
return;
|
|
@@ -272,7 +273,7 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
272
273
|
lines.push("**Merge candidates** (same subject, two files):");
|
|
273
274
|
for (const merge of merges) {
|
|
274
275
|
lines.push(
|
|
275
|
-
`- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title}\``,
|
|
276
|
+
`- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title} --merge\``,
|
|
276
277
|
);
|
|
277
278
|
}
|
|
278
279
|
}
|
package/src/index-engine.ts
CHANGED
|
@@ -150,6 +150,8 @@ export class MemoryIndex {
|
|
|
150
150
|
|
|
151
151
|
private termIds = new Map<string, number>();
|
|
152
152
|
private idToIdx = new Map<string, number>();
|
|
153
|
+
/** Built only for lookups that miss canonical ids; aliases are not the hot path. */
|
|
154
|
+
private aliasToIdx: Map<string, number> | undefined;
|
|
153
155
|
private relPathToIdx = new Map<string, number>();
|
|
154
156
|
private bodyCache: BodyCache;
|
|
155
157
|
private trigramIndex: Map<string, number[]> | undefined;
|
|
@@ -237,7 +239,20 @@ export class MemoryIndex {
|
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
idxForId(id: string): number | undefined {
|
|
240
|
-
|
|
242
|
+
const direct = this.idToIdx.get(id);
|
|
243
|
+
if (direct !== undefined) return direct;
|
|
244
|
+
// Imports may have ids that do not use the mem_ prefix. A merge records the
|
|
245
|
+
// retired id as an alias, so every former id must remain usable.
|
|
246
|
+
if (!this.aliasToIdx) {
|
|
247
|
+
this.aliasToIdx = new Map();
|
|
248
|
+
for (let idx = 0; idx < this.docs.length; idx += 1) {
|
|
249
|
+
if (!this.alive[idx]) continue;
|
|
250
|
+
for (const alias of this.docs[idx].aliases) {
|
|
251
|
+
if (!this.aliasToIdx.has(alias)) this.aliasToIdx.set(alias, idx);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return this.aliasToIdx.get(id);
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
idxForRelPath(relPath: string): number {
|
|
@@ -323,6 +338,7 @@ export class MemoryIndex {
|
|
|
323
338
|
this.docTfs.push(tfs);
|
|
324
339
|
this.alive.push(true);
|
|
325
340
|
this.idToIdx.set(doc.id, idx);
|
|
341
|
+
this.aliasToIdx = undefined;
|
|
326
342
|
this.relPathToIdx.set(meta.relPath, idx);
|
|
327
343
|
this.bodyCache.set(doc.path, doc.body);
|
|
328
344
|
this.linkCache = undefined;
|
|
@@ -335,6 +351,7 @@ export class MemoryIndex {
|
|
|
335
351
|
const doc = this.docs[idx];
|
|
336
352
|
this.alive[idx] = false;
|
|
337
353
|
this.idToIdx.delete(doc.id);
|
|
354
|
+
this.aliasToIdx = undefined;
|
|
338
355
|
this.relPathToIdx.delete(doc.relPath);
|
|
339
356
|
this.bodyCache.delete(doc.path);
|
|
340
357
|
const termIds = this.docTerms[idx] ?? [];
|
|
@@ -414,6 +431,7 @@ export class MemoryIndex {
|
|
|
414
431
|
this.docTfs = newDocTfs;
|
|
415
432
|
this.alive = new Array(newDocs.length).fill(true);
|
|
416
433
|
this.idToIdx = new Map();
|
|
434
|
+
this.aliasToIdx = undefined;
|
|
417
435
|
this.relPathToIdx = new Map();
|
|
418
436
|
for (let i = 0; i < newDocs.length; i += 1) {
|
|
419
437
|
this.idToIdx.set(newDocs[i].id, i);
|
|
@@ -437,6 +455,7 @@ export class MemoryIndex {
|
|
|
437
455
|
this.alive = [];
|
|
438
456
|
this.termIds = new Map();
|
|
439
457
|
this.idToIdx = new Map();
|
|
458
|
+
this.aliasToIdx = undefined;
|
|
440
459
|
this.relPathToIdx = new Map();
|
|
441
460
|
this.trigramIndex = undefined;
|
|
442
461
|
this.fuzzyCache.clear();
|
|
@@ -505,10 +524,8 @@ export class MemoryIndex {
|
|
|
505
524
|
const resolve = (ref: string): number | undefined => {
|
|
506
525
|
const trimmed = ref.trim();
|
|
507
526
|
if (!trimmed) return undefined;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (idx !== undefined) return idx;
|
|
511
|
-
}
|
|
527
|
+
const byId = this.idxForId(trimmed);
|
|
528
|
+
if (byId !== undefined) return byId;
|
|
512
529
|
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
513
530
|
if (byPath !== undefined) return byPath;
|
|
514
531
|
return titles.get(trimmed.toLowerCase());
|
|
@@ -542,7 +559,7 @@ export class MemoryIndex {
|
|
|
542
559
|
const titles = this.links().titles;
|
|
543
560
|
const trimmed = ref.trim();
|
|
544
561
|
if (!trimmed) return undefined;
|
|
545
|
-
const byId = this.
|
|
562
|
+
const byId = this.idxForId(trimmed);
|
|
546
563
|
if (byId !== undefined && this.alive[byId]) return byId;
|
|
547
564
|
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
548
565
|
if (byPath !== undefined && this.alive[byPath]) return byPath;
|
|
@@ -827,6 +844,7 @@ export class MemoryIndex {
|
|
|
827
844
|
}
|
|
828
845
|
this.alive = new Array(docCount).fill(true);
|
|
829
846
|
this.idToIdx = new Map();
|
|
847
|
+
this.aliasToIdx = undefined;
|
|
830
848
|
this.relPathToIdx = new Map();
|
|
831
849
|
for (let i = 0; i < docCount; i += 1) {
|
|
832
850
|
this.idToIdx.set(this.docs[i].id, i);
|
|
@@ -935,6 +953,14 @@ export class MemoryIndex {
|
|
|
935
953
|
async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
936
954
|
const started = performance.now();
|
|
937
955
|
await this.refresh(false);
|
|
956
|
+
const result = this.searchWarm(query, options);
|
|
957
|
+
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Score an index the caller has already refreshed. */
|
|
962
|
+
searchWarm(query: string, options: SearchOptions = {}): SearchResult {
|
|
963
|
+
const started = performance.now();
|
|
938
964
|
const result = searchIndex(this, query, options);
|
|
939
965
|
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
940
966
|
return result;
|
package/src/runtime.ts
CHANGED
|
@@ -143,6 +143,8 @@ export class MemoriaRuntime {
|
|
|
143
143
|
config: MemoriaConfig = { ...DEFAULT_CONFIG };
|
|
144
144
|
roots: ResolvedRoots;
|
|
145
145
|
private stores = new Map<string, StoreState>();
|
|
146
|
+
/** Share one first load per root; callers never see a half-loaded index. */
|
|
147
|
+
private loadingStores = new Map<string, Promise<StoreState>>();
|
|
146
148
|
/** Serializes read-modify-write sequences per store root. */
|
|
147
149
|
private locks = new KeyedMutex();
|
|
148
150
|
private initialized = false;
|
|
@@ -262,27 +264,39 @@ export class MemoriaRuntime {
|
|
|
262
264
|
if (startWatcher) this.ensureWatcher(state);
|
|
263
265
|
return state;
|
|
264
266
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
267
|
+
let loading = this.loadingStores.get(root);
|
|
268
|
+
if (!loading) {
|
|
269
|
+
loading = (async (): Promise<StoreState> => {
|
|
270
|
+
const index = new MemoryIndex(root, {
|
|
271
|
+
bodyCacheBytes: this.config.bodyCacheBytes,
|
|
272
|
+
maxScanIntervalMs: this.config.scanIntervalMs,
|
|
273
|
+
exclude: this.config.exclude,
|
|
274
|
+
indexFormat: this.config.indexFormat,
|
|
275
|
+
});
|
|
276
|
+
const saver = createIndexSaver(index);
|
|
277
|
+
const loaded: StoreState = {
|
|
278
|
+
root,
|
|
279
|
+
index,
|
|
280
|
+
saver,
|
|
281
|
+
usageSaver: debounce(() => {
|
|
282
|
+
void this.flushUsage(root, loaded).catch(() => {});
|
|
283
|
+
}, 4000),
|
|
284
|
+
pendingTouch: new Set(),
|
|
285
|
+
};
|
|
286
|
+
const recovered = await recoverJournal(root).catch(() => undefined);
|
|
287
|
+
if (recovered) loaded.recovered = recovered;
|
|
288
|
+
await index.load();
|
|
289
|
+
if (this.config.usageTracking) loaded.usage = await UsageStore.load(root).catch(() => undefined);
|
|
290
|
+
this.stores.set(root, loaded);
|
|
291
|
+
return loaded;
|
|
292
|
+
})();
|
|
293
|
+
this.loadingStores.set(root, loading);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
state = await loading;
|
|
297
|
+
} finally {
|
|
298
|
+
if (this.loadingStores.get(root) === loading) this.loadingStores.delete(root);
|
|
299
|
+
}
|
|
286
300
|
if (startWatcher) this.ensureWatcher(state);
|
|
287
301
|
return state;
|
|
288
302
|
}
|
|
@@ -338,6 +352,11 @@ export class MemoriaRuntime {
|
|
|
338
352
|
this.searchCache.clear();
|
|
339
353
|
}
|
|
340
354
|
|
|
355
|
+
/** Every refresh that changes an index must also invalidate cached recall. */
|
|
356
|
+
private async refreshIndex(index: MemoryIndex, force = false): Promise<void> {
|
|
357
|
+
if (await index.refresh(force)) this.bumpCacheGeneration();
|
|
358
|
+
}
|
|
359
|
+
|
|
341
360
|
/* ---------------------------------------------------------------- */
|
|
342
361
|
/* Search */
|
|
343
362
|
/* ---------------------------------------------------------------- */
|
|
@@ -358,6 +377,12 @@ export class MemoriaRuntime {
|
|
|
358
377
|
return { hits: [], total: 0, tookMs: 0, truncated: false, missing: [], byRoot: [] };
|
|
359
378
|
}
|
|
360
379
|
const limit = Math.max(1, options.limit ?? 8);
|
|
380
|
+
// A cached result is only valid after each root has had a chance to observe
|
|
381
|
+
// watcher events or its periodic freshness scan. Both checks are cheap when
|
|
382
|
+
// the index is already fresh.
|
|
383
|
+
const states = await Promise.all(roots.map((root) => this.storeFor(root)));
|
|
384
|
+
await Promise.all(states.map((state) => this.refreshIndex(state.index, options.refresh === true)));
|
|
385
|
+
const synonymTables = await Promise.all(states.map((state) => this.synonymsFor(state.root)));
|
|
361
386
|
const cacheKey = this.searchCacheKey(query, options);
|
|
362
387
|
const cached = this.lookupCache(cacheKey);
|
|
363
388
|
if (cached) {
|
|
@@ -367,16 +392,13 @@ export class MemoriaRuntime {
|
|
|
367
392
|
}
|
|
368
393
|
|
|
369
394
|
const perRoot = await Promise.all(
|
|
370
|
-
|
|
371
|
-
const
|
|
395
|
+
states.map(async (state, indexInRoots) => {
|
|
396
|
+
const root = state.root;
|
|
372
397
|
const index = state.index;
|
|
373
|
-
const
|
|
374
|
-
if (refreshed) this.bumpCacheGeneration();
|
|
375
|
-
const synonyms = await this.synonymsFor(root);
|
|
376
|
-
const result = await index.search(query, {
|
|
398
|
+
const result = index.searchWarm(query, {
|
|
377
399
|
...options,
|
|
378
400
|
limit: Math.max(limit, 8),
|
|
379
|
-
synonyms,
|
|
401
|
+
synonyms: synonymTables[indexInRoots],
|
|
380
402
|
synonymWeight: this.config.synonymWeight,
|
|
381
403
|
snippetChars: this.config.snippetChars,
|
|
382
404
|
relatedHits: options.relatedHits ?? this.config.relatedHits,
|
|
@@ -474,7 +496,9 @@ export class MemoriaRuntime {
|
|
|
474
496
|
private searchCacheKey(query: string, options: SearchOptions & { scope?: Scope }): string {
|
|
475
497
|
const normalized = {
|
|
476
498
|
q: query,
|
|
499
|
+
parts: options.parts ?? [],
|
|
477
500
|
limit: options.limit ?? 8,
|
|
501
|
+
root: options.root ?? "",
|
|
478
502
|
category: options.category ?? "",
|
|
479
503
|
tags: options.tags ?? [],
|
|
480
504
|
anyTags: options.anyTags ?? [],
|
|
@@ -485,6 +509,10 @@ export class MemoriaRuntime {
|
|
|
485
509
|
prefix: options.prefix !== false,
|
|
486
510
|
fuzzy: options.fuzzy !== false,
|
|
487
511
|
expandRelated: options.expandRelated !== false,
|
|
512
|
+
relatedHits: options.relatedHits ?? this.config.relatedHits,
|
|
513
|
+
relatedBoost: options.relatedBoost ?? this.config.relatedBoost,
|
|
514
|
+
dropSuperseded: options.dropSuperseded === true,
|
|
515
|
+
timeHints: options.timeHints ?? this.config.timeHints,
|
|
488
516
|
explain: Boolean(options.explain),
|
|
489
517
|
scope: options.scope ?? "all",
|
|
490
518
|
};
|
|
@@ -967,7 +995,7 @@ export class MemoriaRuntime {
|
|
|
967
995
|
const out: Array<{ root: string; files: number }> = [];
|
|
968
996
|
for (const root of roots) {
|
|
969
997
|
const index = await this.indexFor(root);
|
|
970
|
-
await
|
|
998
|
+
await this.refreshIndex(index, true);
|
|
971
999
|
const docs: IndexListingDoc[] = index.liveDocs().map(({ meta }) => ({
|
|
972
1000
|
relPath: meta.relPath,
|
|
973
1001
|
title: meta.title,
|
|
@@ -994,9 +1022,10 @@ export class MemoriaRuntime {
|
|
|
994
1022
|
async synonymsFor(root: string): Promise<Record<string, string[]>> {
|
|
995
1023
|
const state = await this.storeFor(root);
|
|
996
1024
|
const now = Date.now();
|
|
997
|
-
if (state.synonyms && now - state.synonyms.loadedAt < 30_000
|
|
1025
|
+
if (state.synonyms && now - state.synonyms.loadedAt < 30_000) return state.synonyms.table;
|
|
998
1026
|
const file = await loadSynonymsFile(root).catch(() => ({}));
|
|
999
1027
|
const table = expandSynonymTable(mergeSynonymTables(this.config.synonyms, file));
|
|
1028
|
+
if (state.synonyms && JSON.stringify(state.synonyms.table) !== JSON.stringify(table)) this.bumpCacheGeneration();
|
|
1000
1029
|
state.synonyms = { table, loadedAt: now };
|
|
1001
1030
|
return table;
|
|
1002
1031
|
}
|
|
@@ -1038,7 +1067,7 @@ export class MemoriaRuntime {
|
|
|
1038
1067
|
const out: MergeSuggestion[] = [];
|
|
1039
1068
|
for (const root of this.activeRoots(scope)) {
|
|
1040
1069
|
const index = await this.indexFor(root);
|
|
1041
|
-
await
|
|
1070
|
+
await this.refreshIndex(index);
|
|
1042
1071
|
out.push(...suggestMerges(this.similarityDocs(index), { threshold: this.config.dedupeThreshold, limit }));
|
|
1043
1072
|
}
|
|
1044
1073
|
return out.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
@@ -1109,7 +1138,7 @@ export class MemoriaRuntime {
|
|
|
1109
1138
|
const out: Contradiction[] = [];
|
|
1110
1139
|
for (const root of this.activeRoots(scope)) {
|
|
1111
1140
|
const index = await this.indexFor(root);
|
|
1112
|
-
await
|
|
1141
|
+
await this.refreshIndex(index);
|
|
1113
1142
|
const units: Array<{ id: string; relPath: string; label?: string; text: string }> = [];
|
|
1114
1143
|
for (const { idx, meta } of index.liveDocs()) {
|
|
1115
1144
|
// Reading every body would make doctor O(store). A contradiction needs
|
|
@@ -1167,7 +1196,7 @@ export class MemoriaRuntime {
|
|
|
1167
1196
|
const out: TopicReportEntry[] = [];
|
|
1168
1197
|
for (const root of this.activeRoots(scope)) {
|
|
1169
1198
|
const state = await this.storeFor(root);
|
|
1170
|
-
await state.index
|
|
1199
|
+
await this.refreshIndex(state.index);
|
|
1171
1200
|
const staleThreshold = this.config.staleAfterDays * 86_400_000;
|
|
1172
1201
|
const now = Date.now();
|
|
1173
1202
|
for (const { idx, meta } of state.index.liveDocs()) {
|
|
@@ -1208,7 +1237,7 @@ export class MemoriaRuntime {
|
|
|
1208
1237
|
state.usageSaver();
|
|
1209
1238
|
if (!previous) return undefined;
|
|
1210
1239
|
const index = state.index;
|
|
1211
|
-
await
|
|
1240
|
+
await this.refreshIndex(index);
|
|
1212
1241
|
const created: DiffEntry[] = [];
|
|
1213
1242
|
const updated: DiffEntry[] = [];
|
|
1214
1243
|
for (const { meta } of index.liveDocs()) {
|
|
@@ -1237,7 +1266,7 @@ export class MemoriaRuntime {
|
|
|
1237
1266
|
const updated: DiffEntry[] = [];
|
|
1238
1267
|
for (const root of this.activeRoots(scope)) {
|
|
1239
1268
|
const index = await this.indexFor(root);
|
|
1240
|
-
await
|
|
1269
|
+
await this.refreshIndex(index);
|
|
1241
1270
|
for (const { meta } of index.liveDocs()) {
|
|
1242
1271
|
const entry: DiffEntry = {
|
|
1243
1272
|
id: meta.id,
|
|
@@ -1266,7 +1295,7 @@ export class MemoriaRuntime {
|
|
|
1266
1295
|
let notes = 0;
|
|
1267
1296
|
for (const root of roots) {
|
|
1268
1297
|
const state = await this.storeFor(root);
|
|
1269
|
-
await state.index
|
|
1298
|
+
await this.refreshIndex(state.index);
|
|
1270
1299
|
const hot = await readHot(root, this.config.hotLimit);
|
|
1271
1300
|
if (options.includeHot !== false && hot.exists && hot.content.trim()) {
|
|
1272
1301
|
lines.push(encodeJsonl({ type: "hot", root: this.displayPath(root), content: hot.content }));
|
|
@@ -1311,24 +1340,34 @@ export class MemoriaRuntime {
|
|
|
1311
1340
|
const records = decodeJsonl(text);
|
|
1312
1341
|
const result = { created: [] as string[], updated: [] as string[], skipped: 0, errors: records.errors, hotImported: false };
|
|
1313
1342
|
return this.locks.run(root, async () => {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1343
|
+
let changed = false;
|
|
1344
|
+
let completed = false;
|
|
1345
|
+
try {
|
|
1346
|
+
for (const record of records.records) {
|
|
1347
|
+
if (record.type === "hot") {
|
|
1348
|
+
if (options.dryRun) continue;
|
|
1349
|
+
// Already inside the root lock: call the store helpers directly.
|
|
1350
|
+
await writeHot(root, record.content);
|
|
1351
|
+
result.hotImported = true;
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
if (!options.dryRun) changed = true;
|
|
1355
|
+
const outcome = await this.importNote(root, state, record, mode, options.dryRun === true);
|
|
1356
|
+
if (!options.dryRun && outcome !== "skipped") changed = true;
|
|
1357
|
+
if (outcome === "created") result.created.push(record.id);
|
|
1358
|
+
else if (outcome === "updated") result.updated.push(record.id);
|
|
1359
|
+
else result.skipped += 1;
|
|
1360
|
+
}
|
|
1361
|
+
completed = true;
|
|
1362
|
+
return result;
|
|
1363
|
+
} finally {
|
|
1364
|
+
if (changed) {
|
|
1365
|
+
this.bumpCacheGeneration();
|
|
1366
|
+
if (!completed) state.index.dirty = true;
|
|
1367
|
+
state.index.builtAt = Date.now();
|
|
1368
|
+
state.saver.schedule();
|
|
1321
1369
|
}
|
|
1322
|
-
const outcome = await this.importNote(root, state, record, mode, options.dryRun === true);
|
|
1323
|
-
if (outcome === "created") result.created.push(record.id);
|
|
1324
|
-
else if (outcome === "updated") result.updated.push(record.id);
|
|
1325
|
-
else result.skipped += 1;
|
|
1326
|
-
}
|
|
1327
|
-
if (!options.dryRun) {
|
|
1328
|
-
state.index.builtAt = Date.now();
|
|
1329
|
-
state.saver.schedule();
|
|
1330
1370
|
}
|
|
1331
|
-
return result;
|
|
1332
1371
|
});
|
|
1333
1372
|
}
|
|
1334
1373
|
|
|
@@ -1418,7 +1457,7 @@ export class MemoriaRuntime {
|
|
|
1418
1457
|
for (const root of this.activeRoots("all")) {
|
|
1419
1458
|
const state = await this.storeFor(root);
|
|
1420
1459
|
const index = state.index;
|
|
1421
|
-
await
|
|
1460
|
+
await this.refreshIndex(index, true);
|
|
1422
1461
|
const label = this.displayPath(root);
|
|
1423
1462
|
if (state.recovered) {
|
|
1424
1463
|
recovered.push(`${label}: ${state.recovered}`);
|
|
@@ -1512,7 +1551,7 @@ export class MemoriaRuntime {
|
|
|
1512
1551
|
let docs = 0;
|
|
1513
1552
|
for (const root of roots) {
|
|
1514
1553
|
const index = await this.indexFor(root);
|
|
1515
|
-
await
|
|
1554
|
+
await this.refreshIndex(index);
|
|
1516
1555
|
const overview = index.overview(recentLimit);
|
|
1517
1556
|
docs += overview.docs;
|
|
1518
1557
|
for (const [category, count] of overview.categories) counts.set(category, (counts.get(category) ?? 0) + count);
|
|
@@ -1532,7 +1571,7 @@ export class MemoriaRuntime {
|
|
|
1532
1571
|
const seenIds = new Set<string>();
|
|
1533
1572
|
for (const root of roots) {
|
|
1534
1573
|
const index = await this.indexFor(root);
|
|
1535
|
-
await
|
|
1574
|
+
await this.refreshIndex(index);
|
|
1536
1575
|
for (const { meta } of index.liveDocs()) {
|
|
1537
1576
|
// Ids are unique per store, not across stores: list a shared note once.
|
|
1538
1577
|
if (seenIds.has(meta.id)) continue;
|
|
@@ -1545,6 +1584,7 @@ export class MemoriaRuntime {
|
|
|
1545
1584
|
|
|
1546
1585
|
/** Flush pending index writes and stop watchers. Idempotent. */
|
|
1547
1586
|
async dispose(): Promise<void> {
|
|
1587
|
+
await Promise.allSettled(this.loadingStores.values());
|
|
1548
1588
|
const stores = [...this.stores.values()];
|
|
1549
1589
|
this.stores = new Map();
|
|
1550
1590
|
this.initialized = false;
|
|
@@ -1636,4 +1676,3 @@ async function safeLoadConfig(root: string): Promise<MemoriaConfig> {
|
|
|
1636
1676
|
return { ...DEFAULT_CONFIG };
|
|
1637
1677
|
}
|
|
1638
1678
|
}
|
|
1639
|
-
|
package/src/sessions.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { accessSync, constants, existsSync } from "node:fs";
|
|
28
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
28
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
29
29
|
import { basename, delimiter, join, relative, resolve, sep } from "node:path";
|
|
30
30
|
import { isCjkChar, stem, tokenize } from "./tokenize.ts";
|
|
31
31
|
import { oneLine, truncateChars } from "./util.ts";
|
|
@@ -765,8 +765,29 @@ export class SessionStore {
|
|
|
765
765
|
*/
|
|
766
766
|
async readWindow(path: string, line: number, windowSize = 6): Promise<SessionReadResult | undefined> {
|
|
767
767
|
const target = resolve(path);
|
|
768
|
-
|
|
769
|
-
|
|
768
|
+
if (!target.endsWith(".jsonl")) return undefined;
|
|
769
|
+
let realTarget: string;
|
|
770
|
+
let stats;
|
|
771
|
+
try {
|
|
772
|
+
[realTarget, stats] = await Promise.all([realpath(target), stat(target)]);
|
|
773
|
+
} catch {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return undefined;
|
|
777
|
+
let root: string | undefined;
|
|
778
|
+
for (const candidate of this.roots) {
|
|
779
|
+
let realRoot: string;
|
|
780
|
+
try {
|
|
781
|
+
realRoot = await realpath(candidate);
|
|
782
|
+
} catch {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (target.startsWith(`${resolve(candidate)}${sep}`) && realTarget.startsWith(`${realRoot}${sep}`)) {
|
|
786
|
+
root = candidate;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (!root) return undefined;
|
|
770
791
|
if (!Number.isFinite(line) || line < 1) return undefined;
|
|
771
792
|
const size = Math.max(0, Math.min(40, Math.floor(windowSize)));
|
|
772
793
|
const from = Math.max(1, Math.floor(line) - size);
|
package/src/store.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* pure and testable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { mkdir, readdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
10
9
|
import { basename, join, relative, resolve } from "node:path";
|
|
11
10
|
import { parseDateValue, splitFrontmatter, stringifyWithFrontmatter } from "./frontmatter.ts";
|
|
12
11
|
import { accumulateTokens, stem, tokenize, tokenizeRaw } from "./tokenize.ts";
|
|
@@ -1090,8 +1089,8 @@ export interface MoveMemoryInput {
|
|
|
1090
1089
|
/** Target category folder. Defaults to the note's current category. */
|
|
1091
1090
|
category?: string;
|
|
1092
1091
|
/**
|
|
1093
|
-
* When the target topic already exists: true
|
|
1094
|
-
* it
|
|
1092
|
+
* When the target topic already exists: true explicitly merges this note into
|
|
1093
|
+
* it; omitted or false refuses and reports the conflict.
|
|
1095
1094
|
*/
|
|
1096
1095
|
merge?: boolean;
|
|
1097
1096
|
/** Record the note's previous names as aliases of the target (default true). */
|
|
@@ -1147,17 +1146,33 @@ export async function moveMemory(
|
|
|
1147
1146
|
}
|
|
1148
1147
|
|
|
1149
1148
|
const target = await readMemoryDoc(root, targetPath);
|
|
1150
|
-
if (target && input.merge
|
|
1149
|
+
if (target && input.merge !== true) {
|
|
1151
1150
|
throw new Error(
|
|
1152
1151
|
`${targetRelPath} already exists (topic "${target.topic}"), so moving ${doc.relPath} there would either overwrite or mix two subjects. Re-run with merge: true to combine them, or pick a different topic.`,
|
|
1153
1152
|
);
|
|
1154
1153
|
}
|
|
1155
1154
|
const previousAliases = target ? normalizeAliases([...target.aliases, ...parseStringList(target.frontmatter.aliases)]) : [];
|
|
1155
|
+
const historicalIds = normalizeAliases([...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases].filter((alias) => alias.startsWith("mem_")));
|
|
1156
|
+
if (target && historicalIds.length >= MAX_ALIASES) {
|
|
1157
|
+
throw new Error(`Cannot merge ${doc.relPath}: the target has reached the ${MAX_ALIASES}-alias limit for historical ids.`);
|
|
1158
|
+
}
|
|
1156
1159
|
const aliases = normalizeAliases([
|
|
1160
|
+
...historicalIds,
|
|
1161
|
+
...(target ? [doc.id] : []),
|
|
1157
1162
|
...previousAliases,
|
|
1158
1163
|
...doc.aliases,
|
|
1159
1164
|
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1160
1165
|
]);
|
|
1166
|
+
const requiredAliases = [
|
|
1167
|
+
...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases,
|
|
1168
|
+
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1169
|
+
];
|
|
1170
|
+
if (requiredAliases.some((alias) => {
|
|
1171
|
+
const normalized = normalizeAliases([alias])[0];
|
|
1172
|
+
return normalized && !aliases.some((saved) => saved.toLowerCase() === normalized.toLowerCase());
|
|
1173
|
+
})) {
|
|
1174
|
+
throw new Error(`Cannot move ${doc.relPath}: keeping its former names would exceed the ${MAX_ALIASES}-alias limit.`);
|
|
1175
|
+
}
|
|
1161
1176
|
const aliasesAdded = aliases.filter((alias) => !previousAliases.some((previous) => previous.toLowerCase() === alias.toLowerCase()));
|
|
1162
1177
|
|
|
1163
1178
|
if (target) {
|
|
@@ -1173,14 +1188,28 @@ export async function moveMemory(
|
|
|
1173
1188
|
}
|
|
1174
1189
|
const tags = [...new Set([...target.tags, ...doc.tags])];
|
|
1175
1190
|
const priority = priorityRank(doc.priority) > priorityRank(target.priority) ? doc.priority : target.priority;
|
|
1191
|
+
const related = normalizeLinks([...target.related, ...doc.related]);
|
|
1192
|
+
const supersedes = normalizeLinks([...target.supersedes, ...doc.supersedes]);
|
|
1193
|
+
const linksFit = (required: string[], saved: string[]): boolean => required.every((link) => {
|
|
1194
|
+
const normalized = normalizeLinks([link])[0];
|
|
1195
|
+
return !normalized || saved.some((entry) => entry.toLowerCase() === normalized.toLowerCase());
|
|
1196
|
+
});
|
|
1197
|
+
if (!linksFit([...target.related, ...doc.related], related) || !linksFit([...target.supersedes, ...doc.supersedes], supersedes)) {
|
|
1198
|
+
throw new Error(`Cannot merge ${doc.relPath}: keeping its links would exceed the ${MAX_LINKS}-link limit.`);
|
|
1199
|
+
}
|
|
1200
|
+
// The journal must exist before the target changes. Recovery only removes
|
|
1201
|
+
// the source after it verifies that the target contains all its facts and
|
|
1202
|
+
// its former id; an interrupted target write therefore cannot lose data.
|
|
1203
|
+
await writeJournal(root, { op: "move", from: doc.relPath, to: target.relPath, merge: true, at: Date.now() });
|
|
1176
1204
|
const updated = await updateMemory(root, target, {
|
|
1177
1205
|
content: body,
|
|
1178
1206
|
tags,
|
|
1179
1207
|
aliases,
|
|
1208
|
+
related,
|
|
1209
|
+
supersedes,
|
|
1180
1210
|
summary: extendSummary(target.summary, doc.summary),
|
|
1181
1211
|
priority,
|
|
1182
1212
|
});
|
|
1183
|
-
await writeJournal(root, { op: "move", from: doc.relPath, to: updated.relPath, at: Date.now() });
|
|
1184
1213
|
const trashPath = await deleteMemory(root, doc);
|
|
1185
1214
|
await clearJournal(root).catch(() => {});
|
|
1186
1215
|
return { ...base, doc: updated, moved: true, merged: mergedFacts > 0, aliasesAdded, trashPath };
|
|
@@ -1238,6 +1267,8 @@ export function countFacts(body: string): number {
|
|
|
1238
1267
|
|
|
1239
1268
|
export interface MoveJournalEntry {
|
|
1240
1269
|
op: "move";
|
|
1270
|
+
/** A merge keeps the target id and must be verified before source removal. */
|
|
1271
|
+
merge?: boolean;
|
|
1241
1272
|
/** Relative path of the source file. */
|
|
1242
1273
|
from: string;
|
|
1243
1274
|
/** Relative path of the destination file. */
|
|
@@ -1259,7 +1290,10 @@ export async function readJournal(root: string): Promise<MoveJournalEntry | unde
|
|
|
1259
1290
|
if (!raw) return undefined;
|
|
1260
1291
|
try {
|
|
1261
1292
|
const parsed = JSON.parse(raw) as MoveJournalEntry;
|
|
1262
|
-
|
|
1293
|
+
const safe = (path: string): boolean => isIndexableRelPath(path)
|
|
1294
|
+
&& !path.split("/").some((part) => part === "." || part === ".." || !part);
|
|
1295
|
+
if (parsed && parsed.op === "move" && typeof parsed.from === "string" && typeof parsed.to === "string"
|
|
1296
|
+
&& safe(parsed.from) && safe(parsed.to)) return parsed;
|
|
1263
1297
|
} catch {
|
|
1264
1298
|
// A corrupt journal is not worth failing over; the files themselves are
|
|
1265
1299
|
// authoritative and doctor reports duplicate ids.
|
|
@@ -1278,16 +1312,22 @@ export async function clearJournal(root: string): Promise<void> {
|
|
|
1278
1312
|
export async function recoverJournal(root: string): Promise<string | undefined> {
|
|
1279
1313
|
const entry = await readJournal(root);
|
|
1280
1314
|
if (!entry) return undefined;
|
|
1281
|
-
const fromPath =
|
|
1282
|
-
const toPath =
|
|
1315
|
+
const fromPath = await safeMemoryPath(root, entry.from);
|
|
1316
|
+
const toPath = await safeMemoryPath(root, entry.to);
|
|
1283
1317
|
let recovered: string | undefined;
|
|
1284
|
-
if (
|
|
1318
|
+
if (fromPath && toPath) {
|
|
1319
|
+
const [source, target] = await Promise.all([readMemoryDoc(root, fromPath), readMemoryDoc(root, toPath)]);
|
|
1320
|
+
const complete = source && target && (entry.merge
|
|
1321
|
+
? target.aliases.includes(source.id) && extractFactUnits(source.body).every((unit) => !appendFact(target.body, unit.label, unit.text).changed)
|
|
1322
|
+
: source.id === target.id);
|
|
1323
|
+
if (!complete) return `interrupted move kept source ${entry.from}: target is incomplete`;
|
|
1285
1324
|
await moveToTrash(fromPath, join(root, TRASH_DIR, "moved")).then(
|
|
1286
1325
|
() => {
|
|
1287
1326
|
recovered = `completed interrupted move ${entry.from} -> ${entry.to}`;
|
|
1288
1327
|
},
|
|
1289
1328
|
() => undefined,
|
|
1290
1329
|
);
|
|
1330
|
+
if (!recovered) return undefined;
|
|
1291
1331
|
}
|
|
1292
1332
|
await clearJournal(root).catch(() => {});
|
|
1293
1333
|
return recovered;
|
|
@@ -1614,8 +1654,8 @@ export async function resolveMemoryRef(
|
|
|
1614
1654
|
const trimmed = ref.trim();
|
|
1615
1655
|
if (!trimmed) return undefined;
|
|
1616
1656
|
if (trimmed.endsWith(".md")) {
|
|
1617
|
-
const candidate =
|
|
1618
|
-
const direct = await readMemoryDoc(root, candidate);
|
|
1657
|
+
const candidate = await safeMemoryPath(root, trimmed);
|
|
1658
|
+
const direct = candidate ? await readMemoryDoc(root, candidate) : undefined;
|
|
1619
1659
|
if (direct) return direct;
|
|
1620
1660
|
}
|
|
1621
1661
|
const needle = trimmed.toLowerCase();
|
|
@@ -1631,11 +1671,24 @@ export async function resolveMemoryRef(
|
|
|
1631
1671
|
return undefined;
|
|
1632
1672
|
}
|
|
1633
1673
|
|
|
1634
|
-
/**
|
|
1674
|
+
/** Resolve only note files inside this store, including through symlinks. */
|
|
1675
|
+
async function safeMemoryPath(root: string, ref: string): Promise<string | undefined> {
|
|
1676
|
+
const target = resolve(root, ref);
|
|
1677
|
+
if (!isIndexableRelPath(normalizePath(relative(root, target)))) return undefined;
|
|
1678
|
+
try {
|
|
1679
|
+
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]);
|
|
1680
|
+
if (!isIndexableRelPath(normalizePath(relative(realRoot, realTarget)))) return undefined;
|
|
1681
|
+
return target;
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1684
|
+
throw error;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/** Read a memory file inside this store, guarding traversal and symlinks. */
|
|
1635
1689
|
export async function readStoreFile(root: string, relOrAbs: string, maxChars = 40_000): Promise<{ path: string; content: string; truncated: boolean }> {
|
|
1636
|
-
const target =
|
|
1637
|
-
|
|
1638
|
-
if (!normalizePath(target).startsWith(normalizePath(normalizedRoot))) {
|
|
1690
|
+
const target = await safeMemoryPath(root, relOrAbs);
|
|
1691
|
+
if (!target) {
|
|
1639
1692
|
throw new Error(`Refusing to read outside the memoria root: ${relOrAbs}`);
|
|
1640
1693
|
}
|
|
1641
1694
|
const content = await readFile(target, "utf8");
|
package/src/tools.ts
CHANGED
|
@@ -138,7 +138,7 @@ const MoveParams = Type.Object({
|
|
|
138
138
|
category: Type.Optional(Type.String({ description: "Target category folder. Defaults to the note's current category." })),
|
|
139
139
|
merge: Type.Optional(
|
|
140
140
|
Type.Boolean({
|
|
141
|
-
description: "When a note already exists at the target topic: true
|
|
141
|
+
description: "When a note already exists at the target topic: set true to fold this note's facts into it. Omitted or false refuses the merge.",
|
|
142
142
|
}),
|
|
143
143
|
),
|
|
144
144
|
keep_alias: Type.Optional(Type.Boolean({ description: "Record the note's previous topic and file name as aliases of the target (default true)." })),
|
package/src/usage.ts
CHANGED
|
@@ -34,6 +34,7 @@ function coerceEntry(raw: unknown): UsageEntry | undefined {
|
|
|
34
34
|
export class UsageStore {
|
|
35
35
|
private docs = new Map<string, UsageEntry>();
|
|
36
36
|
private changed = false;
|
|
37
|
+
private saving?: Promise<void>;
|
|
37
38
|
private lastSessionAt = 0;
|
|
38
39
|
|
|
39
40
|
constructor(root: string) {
|
|
@@ -137,10 +138,23 @@ export class UsageStore {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async save(): Promise<void> {
|
|
141
|
+
if (this.saving) {
|
|
142
|
+
await this.saving;
|
|
143
|
+
return this.save();
|
|
144
|
+
}
|
|
140
145
|
if (!this.changed) return;
|
|
141
146
|
this.changed = false;
|
|
142
147
|
const payload: UsageFile = { version: USAGE_VERSION, docs: Object.fromEntries(this.docs), lastSessionAt: this.lastSessionAt };
|
|
143
|
-
|
|
148
|
+
const saving = atomicWriteFile(join(this.root, INDEX_DIR, USAGE_FILE), JSON.stringify(payload));
|
|
149
|
+
this.saving = saving;
|
|
150
|
+
try {
|
|
151
|
+
await saving;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.changed = true;
|
|
154
|
+
throw error;
|
|
155
|
+
} finally {
|
|
156
|
+
if (this.saving === saving) this.saving = undefined;
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
/** Notes with no recorded use in `staleMs`, oldest first. */
|
package/src/util.ts
CHANGED
|
@@ -92,8 +92,8 @@ export function debounce<A extends unknown[]>(fn: (...args: A) => void, waitMs:
|
|
|
92
92
|
export async function atomicWriteFile(path: string, data: string | Uint8Array): Promise<void> {
|
|
93
93
|
await mkdir(dirname(path), { recursive: true });
|
|
94
94
|
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
95
|
-
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
96
95
|
try {
|
|
96
|
+
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
97
97
|
await rename(tmp, path);
|
|
98
98
|
} catch (error) {
|
|
99
99
|
await rm(tmp, { force: true }).catch(() => {});
|
|
@@ -132,7 +132,10 @@ export async function moveToTrash(path: string, trashDir: string): Promise<strin
|
|
|
132
132
|
await mkdir(trashDir, { recursive: true });
|
|
133
133
|
const base = path.split("/").pop() ?? "memory.md";
|
|
134
134
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
135
|
-
|
|
135
|
+
// Different categories can contain the same basename and be removed within
|
|
136
|
+
// one millisecond. A random suffix prevents rename() from replacing the
|
|
137
|
+
// earlier trashed file on POSIX filesystems.
|
|
138
|
+
const target = join(trashDir, `${stamp}_${randomBytes(8).toString("hex")}__${base}`);
|
|
136
139
|
await rename(path, target);
|
|
137
140
|
return target;
|
|
138
141
|
}
|