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/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)
|
|
@@ -409,6 +410,7 @@ back to the default rather than failing.
|
|
|
409
410
|
| `sessionExcerptChars` | `400` | Context characters per transcript hit. |
|
|
410
411
|
| `sessionCacheBytes` | `33554432` | In-memory budget for parsed transcripts. |
|
|
411
412
|
| `sessionIncludeTools` | `false` | Include tool calls/results and compaction summaries by default. |
|
|
413
|
+
| `sessionRipgrep` | `true` | Use a ripgrep prefilter to skip match-free transcripts when `rg` is available; without it the slower built-in scanner runs. |
|
|
412
414
|
|
|
413
415
|
## Scopes: personal, project, read-only
|
|
414
416
|
|
|
@@ -495,14 +497,30 @@ What it does and does not touch:
|
|
|
495
497
|
| Content | User and assistant **text** by default. Thinking blocks are never extracted. Tool calls/results, compaction summaries and extension payloads only with `include_tools: true`. |
|
|
496
498
|
| Output | Bounded excerpts plus `file:line`; never a whole transcript dump. |
|
|
497
499
|
| Writes | None. Transcripts are read-only and `read` refuses paths outside the session roots. |
|
|
498
|
-
| Cost |
|
|
499
|
-
| Coverage | Exhaustive unless a budget stops it. `partial` (shown in the tool output, the recall block and `/memoria sessions`) means the scan stopped early: the hits are real, but older sessions were not read. Newest sessions are scanned first, already-parsed files stay cached (so
|
|
500
|
+
| Cost | With `rg`, only transcripts containing the query terms are parsed; without it, a full cold scan of 110 sessions / 13 MB ≈ 120 ms, repeats ≈ 5 ms from the parse cache. |
|
|
501
|
+
| Coverage | Exhaustive unless a budget stops it. `partial` (shown in the tool output, the recall block and `/memoria sessions`) means the scan stopped early: the hits are real, but older sessions were not read. Newest sessions are scanned first, already-parsed files stay cached while they fit `sessionCacheBytes` (so retries get further on corpora that fit the cache), and concurrent searches share one in-flight parse per transcript (so several `memoria_sessions` calls in one turn do not multiply the work). An explicit `memoria_sessions` call uses the larger `sessionScanMs` budget. |
|
|
500
502
|
| Privacy | Nothing leaves the machine; the excerpts go to the model only when the feature runs. |
|
|
501
503
|
|
|
502
504
|
`sessionRoots` exists for archives or a second agent directory. Set
|
|
503
505
|
`sessionSearch: false` to disable the feature entirely, or
|
|
504
506
|
`sessionFallback: false` to keep the tool while never searching automatically.
|
|
505
507
|
|
|
508
|
+
When `rg` is available (pi ships it and puts it on `PATH`), a selective search
|
|
509
|
+
asks ripgrep which transcripts contain the query terms and parses only those, so
|
|
510
|
+
even a gigabyte-scale history is answered within the normal budget. If `rg` is
|
|
511
|
+
missing or fails, memoria falls back to reading every transcript itself and says
|
|
512
|
+
so: the result carries a `ripgrep is not installed` note, and interactive mode
|
|
513
|
+
shows it once per session. Large histories may then be only partially covered.
|
|
514
|
+
|
|
515
|
+
Without `rg` (or for a deliberately disabled accelerator), the default
|
|
516
|
+
`sessionScanMs` budget and `sessionCacheBytes` cache cover only part of a large
|
|
517
|
+
corpus: searches report `partial`, and repeats are cold scans because the cache
|
|
518
|
+
cannot retain the parsed files. Raise `sessionScanMs` so an explicit
|
|
519
|
+
`memoria_sessions` call can cover the whole corpus in one pass, and
|
|
520
|
+
`sessionCacheBytes` only if you have the RAM for the parsed messages (at this
|
|
521
|
+
scale that means gigabytes). The automatic fallback remains capped at 400 ms,
|
|
522
|
+
so on such a corpus it is always a partial scan.
|
|
523
|
+
|
|
506
524
|
## Backup and migration
|
|
507
525
|
|
|
508
526
|
`memoria_export` writes JSONL — one record per line, notes plus `MEMORY.md` — to
|
|
@@ -554,10 +572,13 @@ a burst of edits costs one refresh.
|
|
|
554
572
|
- **Files, not a database.** Memoria never owns your data: notes are markdown
|
|
555
573
|
with frontmatter, deletes are soft (moved to `.trash/`), and the index is
|
|
556
574
|
derived and can be rebuilt with `/memoria reindex`.
|
|
557
|
-
- **Crash-safe moves.** Category moves and
|
|
558
|
-
|
|
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.
|
|
559
578
|
- **Atomic writes.** Every write goes through a temp file and rename; there is no
|
|
560
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.
|
|
561
582
|
- **Secrets:** memoria does not scan for credentials, and the learn prompt tells
|
|
562
583
|
the model not to store them. If you keep secrets in the store anyway, remember
|
|
563
584
|
that recall may surface them in model context — treat the store like your shell
|
|
@@ -583,7 +604,7 @@ a burst of edits costs one refresh.
|
|
|
583
604
|
|
|
584
605
|
```bash
|
|
585
606
|
npm install # dev-only: pi host packages + typescript
|
|
586
|
-
npm test #
|
|
607
|
+
npm test # node:test, no build step
|
|
587
608
|
npm run typecheck # tsc --noEmit
|
|
588
609
|
npm run eval # recall@1/@5/MRR over the fixture corpus
|
|
589
610
|
npm run bench # latency at 1k and 5k notes
|
|
@@ -630,8 +651,8 @@ src/commands.ts /memoria subcommands
|
|
|
630
651
|
`"optional": true` in `peerDependenciesMeta`, keeping them in
|
|
631
652
|
`devDependencies` for local tests only. Without that, npm installs a ~450 MB
|
|
632
653
|
duplicate of pi into the package.
|
|
633
|
-
4. Tag releases (`git tag
|
|
634
|
-
`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>`.
|
|
635
656
|
5. `npm pack --dry-run` should list only `index.ts`, `src/**`, `skills/**`,
|
|
636
657
|
`README.md`, `LICENSE` and `package.json`.
|
|
637
658
|
|
package/index.ts
CHANGED
|
@@ -25,47 +25,53 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
25
25
|
import { MemoriaRuntime } from "./src/runtime.ts";
|
|
26
26
|
import { registerMemoriaTools } from "./src/tools.ts";
|
|
27
27
|
import { registerMemoriaCommand, setStatus } from "./src/commands.ts";
|
|
28
|
-
import { RECALL_CUSTOM_TYPE, buildRecallParts, renderRecallBlock, renderStatusLine, renderSystemSection } from "./src/recall.ts";
|
|
28
|
+
import { RECALL_CUSTOM_TYPE, buildRecallParts, renderRecallBlock, renderStatusLine, renderSystemSection, ripgrepNotice } from "./src/recall.ts";
|
|
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
|
+
/** The missing-ripgrep warning is shown once per session, not on every prompt. */
|
|
43
|
+
let ripgrepWarned = false;
|
|
42
44
|
/** Auto-learn bookkeeping: user turns since the last extraction, and when. */
|
|
43
45
|
let turnsSinceLearn = 0;
|
|
44
46
|
let lastLearnAt = 0;
|
|
45
47
|
let learnInFlight = false;
|
|
46
48
|
|
|
47
49
|
/** Resolve (and lazily initialize) the runtime for this working directory. */
|
|
48
|
-
const getRuntime =
|
|
50
|
+
const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
|
|
49
51
|
if (!runtime || runtimeCwd !== ctx.cwd) {
|
|
50
|
-
|
|
52
|
+
const previous = runtime;
|
|
53
|
+
runtime = undefined;
|
|
54
|
+
runtimeCwd = undefined;
|
|
55
|
+
await previous?.dispose().catch(() => {});
|
|
51
56
|
// Follow pi's own agent directory (honours PI_CODING_AGENT_DIR) so the
|
|
52
57
|
// store lives at `<agent dir>/memoria`.
|
|
53
|
-
|
|
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;
|
|
54
66
|
runtimeCwd = ctx.cwd;
|
|
55
67
|
hotCache = undefined;
|
|
56
68
|
recentPrompts = [];
|
|
57
69
|
turnsSinceLearn = 0;
|
|
58
70
|
lastLearnAt = 0;
|
|
59
71
|
learnInFlight = false;
|
|
60
|
-
initPromise = runtime.init(true).catch((error) => {
|
|
61
|
-
// Allow a later attempt to retry instead of caching the rejection forever.
|
|
62
|
-
initPromise = undefined;
|
|
63
|
-
throw error;
|
|
64
|
-
});
|
|
65
72
|
}
|
|
66
|
-
await initPromise;
|
|
67
73
|
return runtime;
|
|
68
|
-
};
|
|
74
|
+
});
|
|
69
75
|
|
|
70
76
|
/** Read MEMORY.md, reusing the cached copy until its mtime changes. */
|
|
71
77
|
const readHotCached = async (memoria: MemoriaRuntime): Promise<{ content: string; chars: number; limit: number; over: boolean }> => {
|
|
@@ -137,6 +143,11 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
137
143
|
if (tokens.length < 2 && !/\d/.test(prompt)) return undefined;
|
|
138
144
|
const parts = buildRecallParts(prompt, recentPrompts, memoria_.config.autoRecallLastTurns, memoria_.config.autoRecallPriorWeight);
|
|
139
145
|
const result = await memoria_.recall(oneLine(prompt, 400), { limit: memoria_.config.autoRecallLimit, scope: "all", parts });
|
|
146
|
+
const rgNotice = ripgrepNotice(result.sessionStats?.ripgrep);
|
|
147
|
+
if (rgNotice && !ripgrepWarned) {
|
|
148
|
+
ripgrepWarned = true;
|
|
149
|
+
ctx.ui.notify(`memoria: ${rgNotice}.`, "warning");
|
|
150
|
+
}
|
|
140
151
|
recentPrompts = [...recentPrompts, prompt].slice(-RECENT_PROMPT_LIMIT);
|
|
141
152
|
const hits = result.hits.filter((hit) => hit.score >= memoria_.config.autoRecallMinScore);
|
|
142
153
|
// Nothing in the library? Show what earlier conversations said instead
|
|
@@ -230,13 +241,14 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
230
241
|
} catch {
|
|
231
242
|
// Shutdown must never throw.
|
|
232
243
|
}
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
});
|
|
240
252
|
});
|
|
241
253
|
|
|
242
254
|
pi.registerMessageRenderer(RECALL_CUSTOM_TYPE, (message, options, theme) => {
|
package/package.json
CHANGED
|
@@ -30,6 +30,9 @@ memoria_sessions { action: "search", query: "niri scale", include_tools: true, s
|
|
|
30
30
|
- Every hit comes with a `file:` and `line:`, its role, project and score. If
|
|
31
31
|
nothing matches, the result says how many sessions and messages were scanned —
|
|
32
32
|
quote that when reporting, and try different wording before concluding.
|
|
33
|
+
- If the result says ripgrep is not installed (or that a scan was `partial`),
|
|
34
|
+
the search used the slower built-in scanner and may not have reached the
|
|
35
|
+
oldest sessions. Say so when coverage matters, and prefer narrower queries.
|
|
33
36
|
|
|
34
37
|
The extension also searches transcripts **automatically** when the memory
|
|
35
38
|
library returns nothing and `sessionFallback` is on. That injection is a hint,
|
package/src/commands.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
|
13
13
|
import type { MemoriaRuntime } from "./runtime.ts";
|
|
14
14
|
import type { RuntimeGetter } from "./tools.ts";
|
|
15
15
|
import { harvestSession } from "./learn.ts";
|
|
16
|
+
import { ripgrepNotice } from "./recall.ts";
|
|
16
17
|
import { formatSessionTime } from "./sessions.ts";
|
|
17
18
|
import { describeAge } from "./usage.ts";
|
|
18
19
|
import { atomicWriteFile, oneLine } from "./util.ts";
|
|
@@ -25,7 +26,7 @@ const SUBCOMMANDS: Array<{ value: string; label: string; description: string }>
|
|
|
25
26
|
{ value: "search", label: "search <query>", description: "Search long-term memory" },
|
|
26
27
|
{ value: "store", label: "store <text>", description: "Quickly save a memory to the inbox" },
|
|
27
28
|
{ value: "read", label: "read <id|path>", description: "Read a full memory note" },
|
|
28
|
-
{ 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" },
|
|
29
30
|
{ value: "hot", label: "hot", description: "Show MEMORY.md and its budget" },
|
|
30
31
|
{ value: "reindex", label: "reindex", description: "Rebuild the search index from disk" },
|
|
31
32
|
{ value: "index", label: "index", description: "Regenerate library/INDEX.md table of contents" },
|
|
@@ -182,12 +183,13 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
182
183
|
}
|
|
183
184
|
case "move": {
|
|
184
185
|
const [ref, ...rest] = remainder.split(/\s+/).filter(Boolean);
|
|
185
|
-
const
|
|
186
|
+
const merge = rest.includes("--merge");
|
|
187
|
+
const topic = rest.filter((word) => word !== "--merge").join(" ").trim();
|
|
186
188
|
if (!ref || !topic) {
|
|
187
|
-
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");
|
|
188
190
|
return;
|
|
189
191
|
}
|
|
190
|
-
const moved = await runtime.move(ref, { topic }, "all");
|
|
192
|
+
const moved = await runtime.move(ref, { topic, merge }, "all");
|
|
191
193
|
if (!moved) {
|
|
192
194
|
ctx.ui.notify(`Memory not found: ${ref}`, "error");
|
|
193
195
|
return;
|
|
@@ -271,7 +273,7 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
271
273
|
lines.push("**Merge candidates** (same subject, two files):");
|
|
272
274
|
for (const merge of merges) {
|
|
273
275
|
lines.push(
|
|
274
|
-
`- \`${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\``,
|
|
275
277
|
);
|
|
276
278
|
}
|
|
277
279
|
}
|
|
@@ -318,11 +320,12 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
318
320
|
});
|
|
319
321
|
const stats = result.stats;
|
|
320
322
|
const header = `${result.hits.length} match${result.hits.length === 1 ? "" : "es"} in ${stats.files} sessions / ${stats.messages} messages (${result.tookMs}ms${stats.partial ? ", partial scan" : ""}${stats.skipped > 0 ? `, ${stats.skipped} unreadable records skipped` : ""}).`;
|
|
323
|
+
const notice = ripgrepNotice(stats.ripgrep);
|
|
321
324
|
if (result.hits.length === 0) {
|
|
322
|
-
emit(pi, `memoria sessions: ${oneLine(query, 60)}`, `${header}\n\nNo saved conversation matched. Try other wording, a name or a date.`);
|
|
325
|
+
emit(pi, `memoria sessions: ${oneLine(query, 60)}`, `${header}${notice ? `\n\n_Note: ${notice}._` : ""}\n\nNo saved conversation matched. Try other wording, a name or a date.`);
|
|
323
326
|
return;
|
|
324
327
|
}
|
|
325
|
-
const lines = [header, "", "_Evidence from raw transcripts, not curated memory._", ""];
|
|
328
|
+
const lines = [header, ...(notice ? [`_Note: ${notice}._`] : []), "", "_Evidence from raw transcripts, not curated memory._", ""];
|
|
326
329
|
for (const hit of result.hits) {
|
|
327
330
|
lines.push(`- **[${hit.role}] ${formatSessionTime(hit.timestamp)}** · ${hit.projectName} · score ${hit.score}${hit.exact ? " · exact" : ""}`);
|
|
328
331
|
lines.push(` ${oneLine(hit.excerpt, 400)}`);
|
package/src/config.ts
CHANGED
|
@@ -74,6 +74,7 @@ export const DEFAULT_CONFIG: MemoriaConfig = {
|
|
|
74
74
|
sessionExcerptChars: 400,
|
|
75
75
|
sessionCacheBytes: 32 * 1024 * 1024,
|
|
76
76
|
sessionIncludeTools: false,
|
|
77
|
+
sessionRipgrep: true,
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
/** Filesystem context used to resolve store paths. */
|
|
@@ -168,6 +169,7 @@ function coerceConfig(raw: unknown): Partial<MemoriaConfig> {
|
|
|
168
169
|
if (number(input.sessionScanMs) !== undefined) out.sessionScanMs = Math.max(50, Math.min(60_000, number(input.sessionScanMs)!));
|
|
169
170
|
if (number(input.sessionExcerptChars) !== undefined) out.sessionExcerptChars = Math.max(80, Math.min(4000, number(input.sessionExcerptChars)!));
|
|
170
171
|
if (number(input.sessionCacheBytes) !== undefined) out.sessionCacheBytes = Math.max(0, number(input.sessionCacheBytes)!);
|
|
172
|
+
if (bool(input.sessionRipgrep) !== undefined) out.sessionRipgrep = bool(input.sessionRipgrep);
|
|
171
173
|
if (Array.isArray(input.sessionRoots)) out.sessionRoots = input.sessionRoots.filter((item): item is string => typeof item === "string");
|
|
172
174
|
if (input.synonyms && typeof input.synonyms === "object" && !Array.isArray(input.synonyms)) {
|
|
173
175
|
const table: Record<string, string[]> = {};
|
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/recall.ts
CHANGED
|
@@ -87,11 +87,25 @@ export function renderSystemSection(input: SystemSectionInput): string {
|
|
|
87
87
|
* Sessions are raw evidence rather than curated memory, so the block says so
|
|
88
88
|
* explicitly and points at the verification step.
|
|
89
89
|
*/
|
|
90
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Warn that transcript search fell back to the slower built-in scanner.
|
|
92
|
+
*
|
|
93
|
+
* `undefined` for every status where the fallback was intentional (`disabled`)
|
|
94
|
+
* or the accelerator was simply not applicable (`unsupported`, `skipped`).
|
|
95
|
+
*/
|
|
96
|
+
export function ripgrepNotice(status: SessionScanStats["ripgrep"]): string | undefined {
|
|
97
|
+
if (status === "missing") return "ripgrep is not installed, so transcripts are searched with the slower built-in scanner (large histories may be only partially covered)";
|
|
98
|
+
if (status === "error") return "ripgrep failed, so transcripts are searched with the slower built-in scanner";
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderSessionFallback(hits: SessionHit[], stats?: Pick<SessionScanStats, "files" | "messages" | "partial" | "ripgrep">): string {
|
|
91
103
|
const lines: string[] = [];
|
|
92
104
|
lines.push(
|
|
93
105
|
`No memory note matched. Found ${hits.length} possibly relevant message${hits.length === 1 ? "" : "s"} in saved earlier conversations${stats ? ` (scanned ${stats.files} sessions, ${stats.messages} messages${stats.partial ? ", partial scan" : ""})` : ""}:`,
|
|
94
106
|
);
|
|
107
|
+
const notice = stats ? ripgrepNotice(stats.ripgrep) : undefined;
|
|
108
|
+
if (notice) lines.push(`Note: ${notice}.`);
|
|
95
109
|
for (const hit of hits) {
|
|
96
110
|
lines.push(`- [${hit.role}, ${formatSessionTime(hit.timestamp)}] ${hit.projectName}: ${oneLine(hit.excerpt, 320)}`);
|
|
97
111
|
lines.push(` file: ${hit.path}:${hit.line} (score ${hit.score}, matched: ${hit.matched.join(", ") || "-"})`);
|
|
@@ -113,7 +127,7 @@ export interface RecallRenderInput {
|
|
|
113
127
|
timeWindow?: TimeWindow;
|
|
114
128
|
/** Transcript excerpts returned when the library had no match. */
|
|
115
129
|
sessionHits?: SessionHit[];
|
|
116
|
-
sessionStats?: Pick<SessionScanStats, "files" | "messages" | "partial">;
|
|
130
|
+
sessionStats?: Pick<SessionScanStats, "files" | "messages" | "partial" | "ripgrep">;
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/** Render the per-prompt recall block injected as a custom message. */
|