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 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, and notes can be re-filed later without breaking
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.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.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 → facts are **merged** in (duplicates skipped, nothing
237
- overwritten) and the old file goes to `.trash/`. `merge: false` reports the
238
- conflict instead.
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, merges when it is not. |
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 under a better broad topic
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 renames are journaled, so an
575
- interrupted move is completed on the next start instead of leaving two copies.
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 # 144 tests, node:test, no build step
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 v0.1.1 && git push --tags`) and install by tag:
651
- `pi install git:github.com/<you>/pi-memoria@v0.1.1`.
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
- let initPromise: Promise<void> | undefined;
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 = async (ctx: ExtensionContext): Promise<MemoriaRuntime> => {
50
+ const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
51
51
  if (!runtime || runtimeCwd !== ctx.cwd) {
52
- await runtime?.dispose().catch(() => {});
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
- runtime = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
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 runtime?.flushUsageNow().catch(() => {});
241
- await runtime?.dispose().catch(() => {});
242
- runtime = undefined;
243
- runtimeCwd = undefined;
244
- initPromise = undefined;
245
- hotCache = undefined;
246
- recentPrompts = [];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memoria",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Agent-first long-term memory for pi: a categorized markdown library with automatic, sub-millisecond recall.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 topic = rest.join(" ").trim();
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
  }
@@ -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
- return this.idToIdx.get(id);
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
- if (trimmed.startsWith("mem_")) {
509
- const idx = this.idToIdx.get(trimmed);
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.idToIdx.get(trimmed);
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
- const index = new MemoryIndex(root, {
266
- bodyCacheBytes: this.config.bodyCacheBytes,
267
- maxScanIntervalMs: this.config.scanIntervalMs,
268
- exclude: this.config.exclude,
269
- indexFormat: this.config.indexFormat,
270
- });
271
- const saver = createIndexSaver(index);
272
- state = {
273
- root,
274
- index,
275
- saver,
276
- usageSaver: debounce(() => {
277
- void this.flushUsage(root, state!).catch(() => {});
278
- }, 4000),
279
- pendingTouch: new Set(),
280
- };
281
- this.stores.set(root, state);
282
- const recovered = await recoverJournal(root).catch(() => undefined);
283
- if (recovered) state.recovered = recovered;
284
- await index.load();
285
- if (this.config.usageTracking) state.usage = await UsageStore.load(root).catch(() => undefined);
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
- roots.map(async (root) => {
371
- const state = await this.storeFor(root);
395
+ states.map(async (state, indexInRoots) => {
396
+ const root = state.root;
372
397
  const index = state.index;
373
- const refreshed = options.refresh ? await index.refresh(true) : await index.refresh(false);
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 index.refresh(true);
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 && Object.keys(this.config.synonyms).length === 0) return state.synonyms.table;
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 index.refresh(false);
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 index.refresh(false);
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.refresh(false);
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 index.refresh(false);
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 index.refresh(false);
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.refresh(false);
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
- for (const record of records.records) {
1315
- if (record.type === "hot") {
1316
- if (options.dryRun) continue;
1317
- // Already inside the root lock: call the store helpers directly.
1318
- await writeHot(root, record.content);
1319
- result.hotImported = true;
1320
- continue;
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 index.refresh(true);
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 index.refresh(false);
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 index.refresh(false);
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
- const root = this.roots.find((candidate) => target === resolve(candidate) || target.startsWith(`${resolve(candidate)}${sep}`));
769
- if (!root || !existsSync(target)) return undefined;
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 { existsSync } from "node:fs";
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 (default) merges this note into
1094
- * it, false refuses and reports the conflict.
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 === false) {
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
- if (parsed && parsed.op === "move" && typeof parsed.from === "string" && typeof parsed.to === "string") return parsed;
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 = join(root, entry.from);
1282
- const toPath = join(root, entry.to);
1315
+ const fromPath = await safeMemoryPath(root, entry.from);
1316
+ const toPath = await safeMemoryPath(root, entry.to);
1283
1317
  let recovered: string | undefined;
1284
- if (existsSync(fromPath) && existsSync(toPath)) {
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 = trimmed.startsWith("/") ? trimmed : resolve(root, trimmed);
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
- /** Read a range of lines from a file path inside the store, guarding traversal. */
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 = relOrAbs.startsWith("/") ? relOrAbs : resolve(root, relOrAbs);
1637
- const normalizedRoot = resolve(root);
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 (default) folds this note's facts into it, false refuses so you can choose another topic.",
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
- await atomicWriteFile(join(this.root, INDEX_DIR, USAGE_FILE), JSON.stringify(payload));
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
- const target = join(trashDir, `${stamp}__${base}`);
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
  }