pi-memoria 0.1.1 → 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/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,
@@ -439,9 +461,12 @@ export class MemoriaRuntime {
439
461
  // search budget; the explicit tool call can take longer.
440
462
  budgetMs: Math.min(this.config.sessionScanMs, 400),
441
463
  }).catch(() => undefined);
442
- if (fallback && fallback.hits.length > 0) {
443
- result.sessionHits = fallback.hits;
464
+ if (fallback) {
465
+ // Stats are kept even with no hits: they carry the ripgrep status the
466
+ // caller warns about, and the renderers only show the excerpts when
467
+ // `sessionHits` is non-empty.
444
468
  result.sessionStats = fallback.stats;
469
+ if (fallback.hits.length > 0) result.sessionHits = fallback.hits;
445
470
  }
446
471
  }
447
472
  const wanted = options.rerank ?? this.config.rerank;
@@ -471,7 +496,9 @@ export class MemoriaRuntime {
471
496
  private searchCacheKey(query: string, options: SearchOptions & { scope?: Scope }): string {
472
497
  const normalized = {
473
498
  q: query,
499
+ parts: options.parts ?? [],
474
500
  limit: options.limit ?? 8,
501
+ root: options.root ?? "",
475
502
  category: options.category ?? "",
476
503
  tags: options.tags ?? [],
477
504
  anyTags: options.anyTags ?? [],
@@ -482,6 +509,10 @@ export class MemoriaRuntime {
482
509
  prefix: options.prefix !== false,
483
510
  fuzzy: options.fuzzy !== false,
484
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,
485
516
  explain: Boolean(options.explain),
486
517
  scope: options.scope ?? "all",
487
518
  };
@@ -583,12 +614,15 @@ export class MemoriaRuntime {
583
614
  /** Lazily create the transcript store (keys on config + agent dir). */
584
615
  private sessions(): SessionStore {
585
616
  const roots = this.sessionRoots();
586
- const key = `${roots.join("|")}::${this.config.sessionCacheBytes}::${this.config.sessionExcerptChars}`;
617
+ const key = `${roots.join("|")}::${this.config.sessionCacheBytes}::${this.config.sessionExcerptChars}::${this.config.sessionRipgrep}`;
587
618
  if (!this.sessionStore || this.sessionStoreKey !== key) {
588
619
  this.sessionStore = new SessionStore({
589
620
  roots,
590
621
  cacheBytes: this.config.sessionCacheBytes,
591
622
  excerptChars: this.config.sessionExcerptChars,
623
+ rgPath: this.config.sessionRipgrep ? undefined : null,
624
+ // pi ships ripgrep under <agent dir>/bin and puts it on PATH.
625
+ rgCandidates: [join(this.pathContext.agentDir, "bin", process.platform === "win32" ? "rg.exe" : "rg")],
592
626
  });
593
627
  this.sessionStoreKey = key;
594
628
  }
@@ -961,7 +995,7 @@ export class MemoriaRuntime {
961
995
  const out: Array<{ root: string; files: number }> = [];
962
996
  for (const root of roots) {
963
997
  const index = await this.indexFor(root);
964
- await index.refresh(true);
998
+ await this.refreshIndex(index, true);
965
999
  const docs: IndexListingDoc[] = index.liveDocs().map(({ meta }) => ({
966
1000
  relPath: meta.relPath,
967
1001
  title: meta.title,
@@ -988,9 +1022,10 @@ export class MemoriaRuntime {
988
1022
  async synonymsFor(root: string): Promise<Record<string, string[]>> {
989
1023
  const state = await this.storeFor(root);
990
1024
  const now = Date.now();
991
- 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;
992
1026
  const file = await loadSynonymsFile(root).catch(() => ({}));
993
1027
  const table = expandSynonymTable(mergeSynonymTables(this.config.synonyms, file));
1028
+ if (state.synonyms && JSON.stringify(state.synonyms.table) !== JSON.stringify(table)) this.bumpCacheGeneration();
994
1029
  state.synonyms = { table, loadedAt: now };
995
1030
  return table;
996
1031
  }
@@ -1032,7 +1067,7 @@ export class MemoriaRuntime {
1032
1067
  const out: MergeSuggestion[] = [];
1033
1068
  for (const root of this.activeRoots(scope)) {
1034
1069
  const index = await this.indexFor(root);
1035
- await index.refresh(false);
1070
+ await this.refreshIndex(index);
1036
1071
  out.push(...suggestMerges(this.similarityDocs(index), { threshold: this.config.dedupeThreshold, limit }));
1037
1072
  }
1038
1073
  return out.sort((a, b) => b.score - a.score).slice(0, limit);
@@ -1103,7 +1138,7 @@ export class MemoriaRuntime {
1103
1138
  const out: Contradiction[] = [];
1104
1139
  for (const root of this.activeRoots(scope)) {
1105
1140
  const index = await this.indexFor(root);
1106
- await index.refresh(false);
1141
+ await this.refreshIndex(index);
1107
1142
  const units: Array<{ id: string; relPath: string; label?: string; text: string }> = [];
1108
1143
  for (const { idx, meta } of index.liveDocs()) {
1109
1144
  // Reading every body would make doctor O(store). A contradiction needs
@@ -1161,7 +1196,7 @@ export class MemoriaRuntime {
1161
1196
  const out: TopicReportEntry[] = [];
1162
1197
  for (const root of this.activeRoots(scope)) {
1163
1198
  const state = await this.storeFor(root);
1164
- await state.index.refresh(false);
1199
+ await this.refreshIndex(state.index);
1165
1200
  const staleThreshold = this.config.staleAfterDays * 86_400_000;
1166
1201
  const now = Date.now();
1167
1202
  for (const { idx, meta } of state.index.liveDocs()) {
@@ -1202,7 +1237,7 @@ export class MemoriaRuntime {
1202
1237
  state.usageSaver();
1203
1238
  if (!previous) return undefined;
1204
1239
  const index = state.index;
1205
- await index.refresh(false);
1240
+ await this.refreshIndex(index);
1206
1241
  const created: DiffEntry[] = [];
1207
1242
  const updated: DiffEntry[] = [];
1208
1243
  for (const { meta } of index.liveDocs()) {
@@ -1231,7 +1266,7 @@ export class MemoriaRuntime {
1231
1266
  const updated: DiffEntry[] = [];
1232
1267
  for (const root of this.activeRoots(scope)) {
1233
1268
  const index = await this.indexFor(root);
1234
- await index.refresh(false);
1269
+ await this.refreshIndex(index);
1235
1270
  for (const { meta } of index.liveDocs()) {
1236
1271
  const entry: DiffEntry = {
1237
1272
  id: meta.id,
@@ -1260,7 +1295,7 @@ export class MemoriaRuntime {
1260
1295
  let notes = 0;
1261
1296
  for (const root of roots) {
1262
1297
  const state = await this.storeFor(root);
1263
- await state.index.refresh(false);
1298
+ await this.refreshIndex(state.index);
1264
1299
  const hot = await readHot(root, this.config.hotLimit);
1265
1300
  if (options.includeHot !== false && hot.exists && hot.content.trim()) {
1266
1301
  lines.push(encodeJsonl({ type: "hot", root: this.displayPath(root), content: hot.content }));
@@ -1305,24 +1340,34 @@ export class MemoriaRuntime {
1305
1340
  const records = decodeJsonl(text);
1306
1341
  const result = { created: [] as string[], updated: [] as string[], skipped: 0, errors: records.errors, hotImported: false };
1307
1342
  return this.locks.run(root, async () => {
1308
- for (const record of records.records) {
1309
- if (record.type === "hot") {
1310
- if (options.dryRun) continue;
1311
- // Already inside the root lock: call the store helpers directly.
1312
- await writeHot(root, record.content);
1313
- result.hotImported = true;
1314
- 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();
1315
1369
  }
1316
- const outcome = await this.importNote(root, state, record, mode, options.dryRun === true);
1317
- if (outcome === "created") result.created.push(record.id);
1318
- else if (outcome === "updated") result.updated.push(record.id);
1319
- else result.skipped += 1;
1320
- }
1321
- if (!options.dryRun) {
1322
- state.index.builtAt = Date.now();
1323
- state.saver.schedule();
1324
1370
  }
1325
- return result;
1326
1371
  });
1327
1372
  }
1328
1373
 
@@ -1412,7 +1457,7 @@ export class MemoriaRuntime {
1412
1457
  for (const root of this.activeRoots("all")) {
1413
1458
  const state = await this.storeFor(root);
1414
1459
  const index = state.index;
1415
- await index.refresh(true);
1460
+ await this.refreshIndex(index, true);
1416
1461
  const label = this.displayPath(root);
1417
1462
  if (state.recovered) {
1418
1463
  recovered.push(`${label}: ${state.recovered}`);
@@ -1506,7 +1551,7 @@ export class MemoriaRuntime {
1506
1551
  let docs = 0;
1507
1552
  for (const root of roots) {
1508
1553
  const index = await this.indexFor(root);
1509
- await index.refresh(false);
1554
+ await this.refreshIndex(index);
1510
1555
  const overview = index.overview(recentLimit);
1511
1556
  docs += overview.docs;
1512
1557
  for (const [category, count] of overview.categories) counts.set(category, (counts.get(category) ?? 0) + count);
@@ -1526,7 +1571,7 @@ export class MemoriaRuntime {
1526
1571
  const seenIds = new Set<string>();
1527
1572
  for (const root of roots) {
1528
1573
  const index = await this.indexFor(root);
1529
- await index.refresh(false);
1574
+ await this.refreshIndex(index);
1530
1575
  for (const { meta } of index.liveDocs()) {
1531
1576
  // Ids are unique per store, not across stores: list a shared note once.
1532
1577
  if (seenIds.has(meta.id)) continue;
@@ -1539,6 +1584,7 @@ export class MemoriaRuntime {
1539
1584
 
1540
1585
  /** Flush pending index writes and stop watchers. Idempotent. */
1541
1586
  async dispose(): Promise<void> {
1587
+ await Promise.allSettled(this.loadingStores.values());
1542
1588
  const stores = [...this.stores.values()];
1543
1589
  this.stores = new Map();
1544
1590
  this.initialized = false;
@@ -1630,4 +1676,3 @@ async function safeLoadConfig(root: string): Promise<MemoriaConfig> {
1630
1676
  return { ...DEFAULT_CONFIG };
1631
1677
  }
1632
1678
  }
1633
-