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/README.md +38 -17
- package/index.ts +32 -20
- package/package.json +1 -1
- package/skills/memoria-sessions/SKILL.md +3 -0
- package/src/commands.ts +10 -7
- package/src/config.ts +2 -0
- package/src/index-engine.ts +32 -6
- package/src/recall.ts +16 -2
- package/src/runtime.ts +104 -59
- package/src/sessions.ts +276 -23
- package/src/store.ts +69 -16
- package/src/tools.ts +11 -8
- package/src/types.ts +16 -0
- package/src/usage.ts +15 -1
- package/src/util.ts +5 -2
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,
|
|
@@ -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
|
|
443
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|