@staix/agent-mem 0.6.0 → 0.7.0

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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -0,0 +1,100 @@
1
+ import { KINDS, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "./store.ts";
2
+ import type { StandalonePolicy } from "./policy.ts";
3
+
4
+ /**
5
+ * F6 (agent-mem#40): person note authoring. `amem note add` writes one curated note the person typed, through the configured policy. The note is authored by the
6
+ * person (`peer: "person"`, `source: "person"`), which is why the command is person-only like every ordinary command (an agent marker is refused before it runs).
7
+ *
8
+ * A `fail` note carries structured fields. Its body uses the same labelled lines an accepted `fail` candidate produces, so a note written here and one accepted
9
+ * from `amem_remember` read alike and `fail` search and the plan guard see one shape.
10
+ */
11
+ export const NOTE_MAX_CHARS = 4000, NOTE_TITLE_MAX = 200, FAIL_FIELD_MAX = 1000, FAIL_EVIDENCE_MAX = 16;
12
+
13
+ export interface FailFields { approach: string; symptom: string; cause: string; evidence: string[]; retryWhen: string }
14
+ export interface NoteAddInput { kind: Kind; tier: Tier; scope: Scope; text?: string; title?: string; fail?: Partial<FailFields> }
15
+ export interface NoteAddResult { id: string }
16
+
17
+ const refuse = (message: string): never => { throw new Error(message); };
18
+ const clean = (value: string | undefined): string => (value ?? "").replace(/\r\n?/g, "\n").trim();
19
+ /** Every character a reader may treat as a line break: LF, CR, VT, FF, NEL and the Unicode line and paragraph separators. A field with any of them could forge a labelled line. */
20
+ export const LINE_BREAKS = /[\n\r\v\f\u0085\u2028\u2029]/;
21
+ export const oneLine = (value: string): string => value.replace(/\s*[\n\r\v\f\u0085\u2028\u2029]+\s*/g, " ");
22
+
23
+ /** The labelled body of a `fail` note: the exact lines an accepted fail candidate has, without the "Accepted candidate" provenance header. */
24
+ export function failBody(fields: FailFields): string {
25
+ return `Approach: ${oneLine(fields.approach)}\nSymptom: ${oneLine(fields.symptom)}\nCause: ${oneLine(fields.cause)}\nEvidence: ${fields.evidence.map(oneLine).join(", ")}\nRetry when: ${oneLine(fields.retryWhen)}`;
26
+ }
27
+
28
+ /** Validate the structured fields and say which are missing, so the person can fix the command rather than guess. */
29
+ function failFields(raw: Partial<FailFields> | undefined): FailFields {
30
+ const missing = ["approach", "symptom", "cause", "retryWhen"].filter(key => !clean(raw?.[key as keyof FailFields] as string | undefined));
31
+ const evidence = (raw?.evidence ?? []).map(clean).filter(Boolean);
32
+ const flags = { approach: "--approach", symptom: "--symptom", cause: "--cause", retryWhen: "--retry-when" } as Record<string, string>;
33
+ if (missing.length || !evidence.length) refuse(`a fail note needs ${[...missing.map(key => flags[key]!), ...(evidence.length ? [] : ["--evidence"])].join(", ")} (approach, symptom, cause, evidence and retry-when are all required, so the next reader knows when the approach is worth retrying)`);
34
+ if (evidence.length > FAIL_EVIDENCE_MAX) refuse(`a fail note takes at most ${FAIL_EVIDENCE_MAX} --evidence values`);
35
+ const out: FailFields = { approach: clean(raw!.approach), symptom: clean(raw!.symptom), cause: clean(raw!.cause), evidence, retryWhen: clean(raw!.retryWhen) };
36
+ for (const [key, value] of Object.entries(out)) for (const item of Array.isArray(value) ? value : [value]) {
37
+ if (item.length > FAIL_FIELD_MAX) refuse(`a fail note's ${key} is over ${FAIL_FIELD_MAX} characters`);
38
+ if (item.includes("\0")) refuse(`a fail note's ${key} holds a NUL byte`);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** Build the note a command would write, without writing it (the preview and the write share this). */
44
+ export function buildNote(input: NoteAddInput, project: string): NoteInput {
45
+ if (!(KINDS as readonly string[]).includes(input.kind)) refuse(`--kind takes ${KINDS.join(", ")}`);
46
+ if (input.tier !== "short" && input.tier !== "long") refuse("--tier takes short or long");
47
+ if (input.scope !== "project" && input.scope !== "shared") refuse("--scope takes project or shared");
48
+ const title = input.title === undefined ? undefined : clean(input.title);
49
+ if (title !== undefined && (!title || title.length > NOTE_TITLE_MAX || title.includes("\0") || LINE_BREAKS.test(title))) refuse(`--title must be one line of 1 to ${NOTE_TITLE_MAX} characters`);
50
+ let text: string;
51
+ if (input.kind === "fail") {
52
+ // The structured fields ARE the body. A free text is refused with them so there is one answer to "what does this fail note say".
53
+ if (clean(input.text)) refuse("a fail note takes --approach, --symptom, --cause, --evidence and --retry-when, not free text");
54
+ text = failBody(failFields(input.fail));
55
+ } else {
56
+ if (input.fail && Object.values(input.fail).some(value => (Array.isArray(value) ? value.length : clean(value as string | undefined)))) refuse("--approach, --symptom, --cause, --evidence and --retry-when belong to --kind fail");
57
+ text = clean(input.text);
58
+ if (!text) refuse("a note needs text: pass it as an argument, `-` for stdin, or run in a terminal to use $EDITOR");
59
+ if (text.includes("\0")) refuse("the note text holds a NUL byte");
60
+ }
61
+ if (text.length > NOTE_MAX_CHARS) refuse(`the note is ${text.length} characters; the limit is ${NOTE_MAX_CHARS}`);
62
+ return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: "person", source: "person", ...(title ? { title } : {}), text, about: [] };
63
+ }
64
+
65
+ /**
66
+ * Write one note. The configured policy screens the whole note (title and text) first and a decline names no text; a long-term note the policy declines would not
67
+ * sync, so it is refused here instead of silently staying local. Nothing is written when anything fails.
68
+ */
69
+ export function noteAdd(store: MemoryStore, project: string, input: NoteAddInput, policy: StandalonePolicy): NoteAddResult {
70
+ const note = buildNote(input, project);
71
+ if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about }) /* no undefined field: the screen refuses one */) refuse("the note is refused by the configured privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json); edit it or the policy and try again");
72
+ return { id: store.note(note, input.tier === "short") };
73
+ }
74
+
75
+ /** What `$EDITOR` opens for a `fail` note given no field flags: five labelled lines the person fills in. */
76
+ export const FAIL_TEMPLATE = "Approach: \nSymptom: \nCause: \nEvidence: \nRetry when: \n";
77
+ const LABELS = [["approach", "Approach"], ["symptom", "Symptom"], ["cause", "Cause"], ["evidence", "Evidence"], ["retryWhen", "Retry when"]] as const;
78
+ /** Whether any line of `text` starts with one of the five labels (case-insensitive). Text that already carries a label is the person's own structure, so it opens as written. */
79
+ export function hasFailLabel(text: string): boolean {
80
+ return text.replace(/\r\n?|\u0085|\u2028|\u2029|\v|\f/g, "\n").split("\n").some(line => LABELS.some(([, label]) => line.toLowerCase().startsWith(`${label.toLowerCase()}:`)));
81
+ }
82
+ /** Read the labelled template back. A label's value runs to the next label (so it may span lines); evidence splits on commas and newlines. */
83
+ export function parseFailTemplate(raw: string): Partial<FailFields> {
84
+ const out: Record<string, string> = {};
85
+ let current: string | undefined;
86
+ for (const line of raw.replace(/\r\n?|\u0085|\u2028|\u2029|\v|\f/g, "\n").split("\n")) {
87
+ const hit = LABELS.find(([, label]) => line.toLowerCase().startsWith(`${label.toLowerCase()}:`));
88
+ if (hit) {
89
+ // A label given twice would silently keep only the last: refuse it, so no text the person typed is dropped without a word.
90
+ if (hit[0] in out) throw new Error(`the template repeats the "${hit[1]}:" label; each of the five labels takes one entry`);
91
+ current = hit[0]; out[current] = line.slice(hit[1].length + 1).trim();
92
+ }
93
+ else if (current) out[current] = `${out[current]}\n${line}`;
94
+ }
95
+ // Trim first, then test: a blank template line is "" after the trim, never a truthy "\n".
96
+ const value = (key: string) => (out[key] ?? "").trim();
97
+ const evidence = value("evidence").split(/[,\n]/).map(item => item.trim()).filter(Boolean);
98
+ return { ...(value("approach") ? { approach: value("approach") } : {}), ...(value("symptom") ? { symptom: value("symptom") } : {}), ...(value("cause") ? { cause: value("cause") } : {}),
99
+ ...(evidence.length ? { evidence } : {}), ...(value("retryWhen") ? { retryWhen: value("retryWhen") } : {}) };
100
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `pii.paths` matching, ported from agent-hub (`src/hub/path-policy.ts`: `policyPaths`, `patternScope`, `matchesPolicyLexically`; `src/hub/memory-policy.ts`:
3
+ * `mentionsPrivatePath`) so the repo scope screens with the semantics the project's own config was written for: exact paths and subtrees, `*`, `?` and
4
+ * whole-segment `**` globs, case and Unicode folded like a case-insensitive disk. amem imports no agent-hub code, so this is a port, and
5
+ * `test/path-policy-parity.test.ts` pins it to values produced by the hub's real matcher (`scripts/gen-path-policy-parity.ts`).
6
+ *
7
+ * Lexical only. The hub's `matchesPolicy` also resolves real paths and symlink aliases against the filesystem; a note merely names a path, so nothing here
8
+ * reads the disk. The port keeps the hub's `includeParents` stance (a directory that could hold a protected descendant counts), the over-protective side:
9
+ * for a file that exists the hub is narrower, never broader.
10
+ * ponytail: no symlink alias discovery. Upgrade: ask the host (agent-hub) for the verdict through the library API once amem has a host policy port for paths.
11
+ */
12
+ import { posix } from "node:path";
13
+ import { nodePath, pathCandidates } from "./project.ts";
14
+
15
+ /** One spelling per name, as a case-insensitive disk sees it (agent-hub `foldSegment`). */
16
+ const foldSegment = (segment: string): string => segment.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().toUpperCase().toLowerCase();
17
+
18
+ /** Bounded project-relative exact paths/subtrees or `*`, `?` and whole-segment `**` globs (agent-hub `policyPaths`). Throws on anything the hub would refuse. */
19
+ export function policyEntries(raw: unknown, field: string): string[] {
20
+ const bad = raw === undefined ? false : !Array.isArray(raw) || raw.length > 64 || raw.some(p => typeof p !== "string" || !p || p.length > 200 || posix.isAbsolute(p) || /^[A-Za-z]:/.test(p) || /[\[\]{}!]/.test(p) || /[\x00-\x1f\x7f]/.test(p) || p.split(/[\\/]/).includes("..")
21
+ || (/[?*]/.test(p) && (p.includes("\\") || (p.match(/[?*]/g)?.length ?? 0) > 16 || p.split("/").some(s => s.includes("**") && s !== "**"))));
22
+ if (bad) throw new Error(`${field} takes up to 64 bounded project-relative paths or *, ? and whole-segment ** patterns of at most 200 characters (no absolute paths, .., brackets, braces or !)`);
23
+ if (raw === undefined) return [];
24
+ return [...new Set((raw as string[]).map(p => p.replace(/\/+/g, "/").replace(/\/$/, "") || "."))];
25
+ }
26
+
27
+ const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
28
+ const beneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase()) || within(foldSegment(path), foldSegment(root));
29
+
30
+ /** Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands, the folded one catches NFD/NFC and compatibility aliases. */
31
+ const segmentMatches = (pattern: string, name: string): boolean => wildcard(pattern.toLowerCase(), name.toLowerCase()) || wildcard(foldSegment(pattern), foldSegment(name));
32
+ /** Linear-space wildcard DP, no regex backtracking on untrusted paths. */
33
+ function wildcard(pattern: string, name: string): boolean {
34
+ const chars = [...name];
35
+ let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
36
+ for (const char of pattern) {
37
+ const next = new Array<boolean>(chars.length + 1).fill(false);
38
+ next[0] = char === "*" && previous[0]!;
39
+ for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
40
+ previous = next;
41
+ }
42
+ return previous[chars.length]!;
43
+ }
44
+ function closure(pattern: string[], states: Set<number>): Set<number> {
45
+ const out = new Set(states);
46
+ for (const i of out) if (pattern[i] === "**") out.add(i + 1);
47
+ return out;
48
+ }
49
+ function advance(pattern: string[], states: Set<number>, name: string): Set<number> {
50
+ const next = new Set<number>();
51
+ for (const i of closure(pattern, states)) {
52
+ if (pattern[i] === "**") next.add(i);
53
+ else if (pattern[i] !== undefined && segmentMatches(pattern[i]!, name)) next.add(i + 1);
54
+ }
55
+ return closure(pattern, next);
56
+ }
57
+ function patternScope(pattern: string[], path: string, includeParents: boolean): boolean {
58
+ if (posix.isAbsolute(path) || path === ".." || path.startsWith("../")) return false;
59
+ let states = closure(pattern, new Set([0]));
60
+ if (states.has(pattern.length)) return true;
61
+ for (const name of path.split("/").filter(p => p && p !== ".")) {
62
+ states = advance(pattern, states, name);
63
+ if (states.has(pattern.length)) return true; // a matched directory protects its entire subtree
64
+ if (!states.size) return false;
65
+ }
66
+ return includeParents && states.size > 0; // this scope can contain a future protected descendant
67
+ }
68
+
69
+ const ROOT = "/project";
70
+ const segments = (entry: string) => entry.split("/").filter(p => p && p !== ".");
71
+
72
+ /** Whether `path` (project-relative as written in a note) falls under any entry: the hub's `matchesPolicy` without the filesystem. */
73
+ export function matchesEntries(path: string, entries: readonly string[], includeParents = true): boolean {
74
+ if (!entries.length) return false;
75
+ try {
76
+ if (path.length > 4096) return true;
77
+ const lexical = posix.resolve(ROOT, path), relative = posix.relative(ROOT, lexical);
78
+ return entries.some(entry => {
79
+ if (/[?*]/.test(entry)) return patternScope(segments(entry), relative, includeParents);
80
+ const target = posix.resolve(ROOT, entry);
81
+ return beneath(lexical, target) || includeParents && beneath(target, lexical) || patternScope([...segments(entry), "**"], relative, includeParents);
82
+ });
83
+ } catch { return true; }
84
+ }
85
+
86
+ /** Whether a note's text or its path links name a private path (agent-hub `mentionsPrivatePath`): written paths, and an entry's literal part written out. */
87
+ export function mentionsPrivatePath(text: string, about: readonly string[], entries: readonly string[]): boolean {
88
+ if (!entries.length) return false;
89
+ const nfc = (value: string) => value.normalize("NFC"), body = nfc(text), policy = entries.map(nfc);
90
+ const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
91
+ if (paths.some(path => matchesEntries(path, policy, true))) return true;
92
+ // The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
93
+ return policy.some(entry => { const literal = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(literal) && body.includes(literal); });
94
+ }
@@ -0,0 +1,47 @@
1
+ import { extractNodes, type View } from "./store.ts";
2
+ import { nodePath } from "./project.ts";
3
+
4
+ export interface PlanGuardContext { cwd: string; project: string; repo: string; deny?: string[]; now?: () => number }
5
+ /** Structural input accepts host task adapters without importing a host or a task board. */
6
+ export interface PlanGuardInput {
7
+ paths?: readonly string[]; symbols?: readonly string[];
8
+ refs?: { paths?: readonly string[] };
9
+ plan?: { paths?: readonly string[]; symbols?: readonly string[] };
10
+ }
11
+ export function normalizeMemorySymbolInput(value: string): string {
12
+ const at = value.lastIndexOf("@"), name = at < 0 ? value : value.slice(0, at);
13
+ const normalized = name.startsWith("section ") || name.startsWith("source ") ? name : name.replace(/\s*\(.*$/, "").trim();
14
+ return at < 0 ? normalized : `${normalized}@${value.slice(at + 1)}`;
15
+ }
16
+ /** Explicit inputs only. Per-input extraction retains more than v1's 64-node extraction cap. */
17
+ export function planGuardNodes(input: PlanGuardInput, context: PlanGuardContext): string[] {
18
+ const nodes = new Set<string>();
19
+ for (const path of [...input.paths ?? [], ...input.refs?.paths ?? [], ...input.plan?.paths ?? []]) {
20
+ for (const node of extractNodes("", { ...context, paths: [path], checkExists: false })) nodes.add(node);
21
+ }
22
+ const only = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
23
+ for (const symbol of [...input.symbols ?? [], ...input.plan?.symbols ?? []]) {
24
+ if (typeof symbol !== "string") continue;
25
+ const at = symbol.lastIndexOf("@");
26
+ const explicitPath = at < 0 ? undefined : extractNodes("", { ...context, paths: [symbol.slice(at + 1)], checkExists: false })[0];
27
+ if (at >= 0 && !explicitPath) continue;
28
+ const qualified = explicitPath ? `${symbol.slice(0, at)}@${nodePath(explicitPath)}` : symbol;
29
+ for (const form of new Set([qualified, normalizeMemorySymbolInput(qualified)])) {
30
+ for (const node of extractNodes("", { ...context, paths: only ? [only] : [], symbols: [form], checkExists: false })) nodes.add(node);
31
+ }
32
+ }
33
+ return [...nodes].sort();
34
+ }
35
+ const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
36
+ /** Symbols require name and file identity; path scopes intersect only on whole components. */
37
+ export function planNodesIntersect(left: string, right: string): boolean {
38
+ if (left === right) return true;
39
+ if (left.startsWith("symbol:") && right.startsWith("symbol:")) return normalizeMemorySymbolInput(left.slice(7)) === normalizeMemorySymbolInput(right.slice(7));
40
+ const a = nodePath(left), b = nodePath(right);
41
+ return !!a && !!b && samePath(a, b);
42
+ }
43
+ /** Callers screen complete live notes first; this pure matcher performs no I/O or writes. */
44
+ export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy"> & Partial<Pick<View, "expiresAt">>>(input: PlanGuardInput, notes: readonly T[], context: PlanGuardContext): T[] {
45
+ const nodes = planGuardNodes(input, context), now = (context.now ?? Date.now)();
46
+ return nodes.length && Number.isFinite(now) ? notes.filter(note => note.kind === "fail" && !note.supersededBy && (note.expiresAt == null || Number.isFinite(Date.parse(note.expiresAt)) && Date.parse(note.expiresAt) > now) && note.about.some(about => nodes.some(node => planNodesIntersect(node, about)))) : [];
47
+ }
@@ -6,6 +6,18 @@ import { nodePath, pathCandidates } from "./project.ts";
6
6
 
7
7
  /** True means the complete value is public. Hosts may narrow, never replace, standalone policy. */
8
8
  export interface MemoryPolicy { screen(value: unknown): Promise<boolean> }
9
+ /**
10
+ * The standalone policy. `screenStandalone` is its own part only (secrets, default PII, configured PII patterns and deny paths) as a synchronous
11
+ * answer, for callers that cannot await (the store's promotion screen); it never asks the host, so a caller with a host uses `screen`.
12
+ */
13
+ export interface StandalonePolicy extends MemoryPolicy {
14
+ screenStandalone(value: unknown): boolean;
15
+ /**
16
+ * Only the personal-data and configured-pattern and deny-path part of `screenStandalone`, without the secret screen, for a caller that counts secrets
17
+ * separately (the claude-mem import reports `pii` and `secret` skips as two numbers). True means the value names personal data or something configured.
18
+ */
19
+ declinesPersonal(text: string): boolean;
20
+ }
9
21
  export interface StandalonePolicyOptions {
10
22
  denyPaths?: readonly string[];
11
23
  /** Case-insensitive literal substrings, not executable regular expressions. */
@@ -14,7 +26,7 @@ export interface StandalonePolicyOptions {
14
26
  }
15
27
 
16
28
  const MAX_DEPTH = 16, MAX_NODES = 4096, MAX_BYTES = 256 * 1024, MAX_STRING_BYTES = 16 * 1024;
17
- const fold = (text: string) => text.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().replace(/\\/g, "/");
29
+ export const fold = (text: string) => text.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().replace(/\\/g, "/");
18
30
  const flags = new Set(["private", "pii", "privateturn", "isprivate", "ispii"]);
19
31
 
20
32
  /** Inspect descriptors, not getters/toJSON, and build the only value a host is allowed to see. */
@@ -67,6 +79,9 @@ function snapshot(value: unknown): { value: unknown; texts: string[] } | undefin
67
79
  catch { return undefined; }
68
80
  }
69
81
 
82
+ /** Safe structural data copy for optional readers, before any getters/toJSON can run. It is not a public verdict. */
83
+ export function memoryPolicySnapshot<T>(value: T): T | undefined { return snapshot(value)?.value as T | undefined; }
84
+
70
85
  function literals(value: unknown, label: string): string[] {
71
86
  if (value === undefined) return [];
72
87
  if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > 64 || Object.getOwnPropertySymbols(value).length) throw new Error(`invalid ${label}`);
@@ -92,7 +107,7 @@ function defaultPii(text: string): boolean {
92
107
  }
93
108
 
94
109
  /** No filesystem, model, network or storage work occurs while constructing or screening this policy. */
95
- export function createStandalonePolicy(options: StandalonePolicyOptions = {}): MemoryPolicy {
110
+ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): StandalonePolicy {
96
111
  if (!options || typeof options !== "object" || types.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype || Object.getOwnPropertySymbols(options).length) throw new Error("invalid standalone policy options");
97
112
  const descriptors = Object.getOwnPropertyDescriptors(options);
98
113
  for (const [key, descriptor] of Object.entries(descriptors)) if (!["denyPaths", "piiPatterns", "host"].includes(key) || !("value" in descriptor)) throw new Error("invalid standalone policy options");
@@ -100,20 +115,36 @@ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): M
100
115
  const pii = literals(descriptors.piiPatterns?.value, "PII patterns");
101
116
  const host = descriptors.host?.value as MemoryPolicy | undefined;
102
117
  if (host !== undefined && (!host || typeof host.screen !== "function")) throw new Error("invalid host policy");
118
+ const standalone = (value: unknown): { ok: boolean; value?: unknown } => {
119
+ try {
120
+ const complete = snapshot(value);
121
+ if (!complete) return { ok: false };
122
+ for (const text of complete.texts) {
123
+ if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return { ok: false };
124
+ const normalized = fold(text);
125
+ if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return { ok: false };
126
+ const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
127
+ const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
128
+ for (const path of paths) if (path && isDenied(posix.normalize(path), deny)) return { ok: false };
129
+ }
130
+ return { ok: true, value: complete.value };
131
+ } catch { return { ok: false }; }
132
+ };
133
+ const personal = (text: string): boolean => {
134
+ const normalized = fold(text);
135
+ if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return true;
136
+ const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
137
+ const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
138
+ return paths.some(path => path && isDenied(posix.normalize(path), deny));
139
+ };
103
140
  return {
141
+ screenStandalone: value => standalone(value).ok,
142
+ declinesPersonal: text => personal(text),
104
143
  async screen(value: unknown): Promise<boolean> {
105
144
  try {
106
- const complete = snapshot(value);
107
- if (!complete) return false;
108
- for (const text of complete.texts) {
109
- if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return false;
110
- const normalized = fold(text);
111
- if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return false;
112
- const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
113
- const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
114
- for (const path of paths) if (path && isDenied(posix.normalize(path), deny)) return false;
115
- }
116
- return host === undefined || await host.screen(complete.value) === true;
145
+ const own = standalone(value);
146
+ if (!own.ok) return false;
147
+ return host === undefined || await host.screen(own.value) === true;
117
148
  } catch { return false; }
118
149
  },
119
150
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `<private>...</private>` spans (the claude-mem convention) are dropped before anything else reads the text, so they are never screened, counted or
3
+ * stored. A leaf module with no imports: capture shaping, the candidate and work-list boundaries, notes, goals and imports all use it.
4
+ *
5
+ * Tags match the way an HTML reader would, so a variant cannot slip a span through: the name is case-insensitive, an opening tag may carry attributes
6
+ * (`<private class="x">`), whitespace before the name (`< private>`) and a self-closing slash (`<private/>`, which opens a span like any other opening
7
+ * tag, since a person who typed it meant what follows to stay private). A closing tag is `</private>` with the same latitude. Nested spans close from the
8
+ * inside out. An unclosed span drops everything to the end of the text. A stray closing tag is kept as plain text. `<privateer>` is not a tag.
9
+ */
10
+ const TAG = /<\s*(\/?)\s*private(?=[\s/>])[^<>]*>/gi;
11
+
12
+ export function stripPrivateSpans(text: string): string {
13
+ if (!/<\s*\/?\s*private(?=[\s/>])/i.test(text)) return text;
14
+ let out = "", depth = 0, last = 0, match: RegExpExecArray | null;
15
+ TAG.lastIndex = 0;
16
+ while ((match = TAG.exec(text))) {
17
+ const closing = match[1] === "/", stray = closing && depth === 0;
18
+ if (depth === 0) out += text.slice(last, stray ? match.index + match[0].length : match.index); // a stray closer is plain text: keep it
19
+ if (closing) { if (depth > 0) depth--; } else depth++;
20
+ last = match.index + match[0].length;
21
+ }
22
+ if (depth === 0) out += text.slice(last);
23
+ return out;
24
+ }
25
+
26
+ /** The same strip over every string inside a plain value (one level of arrays and objects, as the callers' inputs are), leaving other values as they are. */
27
+ export function stripPrivateDeep<T>(value: T, depth = 0): T {
28
+ if (typeof value === "string") return stripPrivateSpans(value) as T;
29
+ if (depth > 4 || value === null || typeof value !== "object") return value;
30
+ if (Array.isArray(value)) return value.map(item => stripPrivateDeep(item, depth + 1)) as T;
31
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, stripPrivateDeep(item, depth + 1)])) as T;
32
+ }
@@ -0,0 +1,122 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { closeSync, constants, existsSync, lstatSync, openSync, rmSync, statSync, writeSync } from "node:fs";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import { walkShards } from "./catalog.ts";
5
+ import { openCuratedForRead, requireShard } from "./curated-read.ts";
6
+ import { projectState } from "./lifecycle.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
9
+ import { looksSecret } from "./screen.ts";
10
+ import { aliasedKeys, shardDir } from "./shard.ts";
11
+ import { WorkListStore } from "./work-lists.ts";
12
+
13
+ /**
14
+ * #15 T5 / D3: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as one portable JSON bundle,
15
+ * written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
16
+ * read, and only derived files (the curated `index.db`, `search.db`, SQLite sidecars) may refresh, as they do for any read. A destination inside the home is refused.
17
+ *
18
+ * The bundle leaves the home, so it is screened like sync and promotion are: a secret-looking value is withheld, and so is anything the host policy (when
19
+ * one is passed) declines. `withheld` counts the capture rows, notes and goals left out; a work list the policy declines is left out silently by its own store.
20
+ * A forgotten project cannot be exported (the person's decision stands); an archived one can. The write streams row by row, so a shard at its cap never has to
21
+ * fit in memory, and a failure removes the partial file it created.
22
+ */
23
+
24
+ export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
25
+ /**
26
+ * ponytail: a seam, not a lock. The destination is checked (for the message) and then claimed with O_EXCL (for the guarantee); a test sets `afterCheck` to create
27
+ * the file in the gap between them, which is the only way to show that O_EXCL, not the check, keeps an export from replacing someone else's file.
28
+ */
29
+ export const exportHooks: { afterCheck?: (file: string) => void } = {};
30
+ export interface ExportCounts { capture: number; curated: number; goals: number; workLists: number }
31
+ export interface ExportResult { file: string; project: string; counts: ExportCounts; withheld: number }
32
+ const PAGE = 500;
33
+
34
+ interface CaptureRowRaw { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
35
+
36
+ function destination(base: string, to: string): string {
37
+ if (typeof to !== "string" || !to.trim()) throw new Error("export needs --to <file>");
38
+ const file = resolve(to), parent = dirname(file), name = basename(file);
39
+ if (!name || file === parent) throw new Error("--to names a file, not a directory root");
40
+ let parentReal: string;
41
+ try { if (!statSync(parent).isDirectory()) throw new Error(); parentReal = realPath(parent); }
42
+ catch { throw new Error("the destination directory does not exist"); }
43
+ let homeReal = base;
44
+ try { homeReal = realPath(base); } catch { /* a home that is not there yet holds nothing to collide with */ }
45
+ if (parentReal === homeReal || parentReal.startsWith(homeReal + sep)) throw new Error("the destination is inside the memory home; choose a folder outside it");
46
+ if (lstatSync(file, { throwIfNoEntry: false })) throw new Error("the destination exists; export never overwrites");
47
+ return join(parentReal, name);
48
+ }
49
+
50
+ export async function exportProject(base: string, key: string, opts: { to: string; policy?: MemoryPolicy }): Promise<ExportResult> {
51
+ const project = requireShard(base, key);
52
+ if (projectState(base, project) === "forgotten") throw new Error(`project ${project} is forgotten: it cannot be exported`);
53
+ const file = destination(base, opts.to), policy = opts.policy ?? createStandalonePolicy();
54
+ exportHooks.afterCheck?.(file);
55
+ const allowed = async (value: unknown): Promise<boolean> => !looksSecret(JSON.stringify(value)) && await policy.screen(value);
56
+ const counts: ExportCounts = { capture: 0, curated: 0, goals: 0, workLists: 0 };
57
+ let withheld = 0;
58
+ let fd: number;
59
+ try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
60
+ catch { throw new Error("the destination exists; export never overwrites"); }
61
+ const out = (text: string) => { writeSync(fd, text); };
62
+ const stores: { close(): void }[] = [];
63
+ try {
64
+ out(`{"schema":${JSON.stringify(EXPORT_SCHEMA)},"project":${JSON.stringify(project)},"exportedAt":${JSON.stringify(new Date().toISOString())},"capture":[`);
65
+ let first = true;
66
+ const shard = walkShards(base).find(candidate => candidate.key === project);
67
+ for (const dir of shard?.machineDirs ?? []) {
68
+ const dbFile = join(dir, "capture.db");
69
+ if (!existsSync(dbFile)) continue;
70
+ const db = new Database(dbFile, { readonly: true });
71
+ try {
72
+ db.run("PRAGMA busy_timeout = 3000");
73
+ for (let after = 0; ;) {
74
+ const rows = db.query("SELECT id, at, project, agent, event, tool, paths, status, excerpt FROM rows WHERE id > ? ORDER BY id LIMIT ?").all(after, PAGE) as CaptureRowRaw[];
75
+ for (const r of rows) {
76
+ after = r.id;
77
+ const row = { at: r.at, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt };
78
+ if (!await allowed(row)) { withheld++; continue; }
79
+ out(`${first ? "" : ","}${JSON.stringify(row)}`); first = false; counts.capture++;
80
+ }
81
+ if (rows.length < PAGE) break;
82
+ }
83
+ } finally { db.close(); }
84
+ }
85
+ out(`],"curated":[`);
86
+ const store = openCuratedForRead(base, project);
87
+ if (store) stores.push(store);
88
+ // The project's own rows and those of a key aliased to it; another project's shared note is not this project's and stays out of its bundle.
89
+ const keys = new Set([project, ...aliasedKeys(base, project)]);
90
+ first = true;
91
+ for (const view of store?.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }) ?? []) {
92
+ if (!keys.has(view.project)) continue;
93
+ if (!await allowed(view)) { withheld++; continue; }
94
+ out(`${first ? "" : ","}${JSON.stringify(view)}`); first = false; counts.curated++;
95
+ }
96
+ out(`],"goals":[`);
97
+ first = true;
98
+ for (const goal of store?.goals(project) ?? []) {
99
+ if (!keys.has(goal.project)) continue;
100
+ if (!await allowed(goal)) { withheld++; continue; }
101
+ out(`${first ? "" : ","}${JSON.stringify(goal)}`); first = false; counts.goals++;
102
+ }
103
+ out(`],"workLists":[`);
104
+ first = true;
105
+ // The bundle holds what the project's own reads show: the canonical work log and that of every key aliased to it (the same reconciliation the CLI, MCP and brief use).
106
+ const workKeys = [project, ...aliasedKeys(base, project)];
107
+ if (workKeys.some(key => existsSync(join(shardDir(base, key), "work")))) {
108
+ const work = new WorkListStore({ base, project, policy: allowed, aliases: workKeys.slice(1) });
109
+ stores.push({ close: () => work.close() });
110
+ for (const list of await work.read(undefined, true)) { out(`${first ? "" : ","}${JSON.stringify(list)}`); first = false; counts.workLists++; }
111
+ }
112
+ out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
113
+ } catch (error) {
114
+ try { closeSync(fd); } catch { /* already closed */ }
115
+ rmSync(file, { force: true }); // the file is ours: O_EXCL created it, so a partial bundle is removed, never left behind
116
+ throw error;
117
+ } finally {
118
+ for (const store of stores) { try { store.close(); } catch { /* best effort */ } }
119
+ }
120
+ closeSync(fd);
121
+ return { file, project, counts, withheld };
122
+ }
@@ -0,0 +1,56 @@
1
+ import { lstatSync, type Stats } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { isDenied } from "./platform.ts";
4
+ import { fold, type StandalonePolicy } from "./policy.ts";
5
+ import { nodePath } from "./project.ts";
6
+ import type { View } from "./store.ts";
7
+
8
+ export interface RecallStalenessOptions {
9
+ cwd: string; rootConfirmed?: boolean; deny?: readonly string[]; policy?: () => StandalonePolicy;
10
+ /** Host-only lexical glob/private-path checks run before any filesystem probe. */
11
+ allowedPath?: (path: string) => boolean;
12
+ maxProbes?: number;
13
+ probe?: (path: string) => Pick<Stats, "isSymbolicLink" | "isDirectory" | "mtimeMs">;
14
+ }
15
+ type PathState = { state: "present"; mtime: number } | { state: "missing" | "unknown" };
16
+ /** Call-scoped metadata hint only. Unknown/denied paths never become a marker or a filesystem query. */
17
+ export function createRecallStalenessProbe(options: RecallStalenessOptions): (view: Pick<View, "about" | "at">) => boolean | undefined {
18
+ const root = resolve(options.cwd), cache = new Map<string, PathState>(), requested = options.maxProbes ?? 32;
19
+ let remaining = Number.isSafeInteger(requested) && requested >= 0 ? Math.min(requested, 32) : 0;
20
+ const allowed = (path: string): boolean => {
21
+ try { return !isDenied(path, [...options.deny ?? []]) && !isDenied(fold(path), [...options.deny ?? []].map(fold)) && options.allowedPath?.(path) !== false && (!options.policy || options.policy().screenStandalone({ path })); }
22
+ catch { return false; }
23
+ };
24
+ const inspect = (path: string): PathState => {
25
+ if (!path || path.length > 4096 || isAbsolute(path) || /[\p{Cc}\p{Cf}\\]/u.test(path) || path.split("/").includes("..") || !allowed(path)) return { state: "unknown" };
26
+ const target = resolve(root, path), rel = relative(root, target); let current = root;
27
+ if (!rel || rel.startsWith("../") || isAbsolute(rel)) return { state: "unknown" };
28
+ const chainAllowed = () => { let at = root; for (const part of rel.split("/")) { at = join(at, part); if (!allowed(relative(root, at))) return false; } return true; };
29
+ if (!chainAllowed()) return { state: "unknown" };
30
+ if (cache.has(path)) return cache.get(path)!;
31
+ for (const component of rel.split("/")) {
32
+ current = join(current, component); const name = relative(root, current);
33
+ if (remaining < 1 || !allowed(name)) return { state: "unknown" };
34
+ remaining--;
35
+ try {
36
+ const stat = (options.probe ?? lstatSync)(current);
37
+ if (stat.isSymbolicLink() || current !== target && !stat.isDirectory()) return { state: "unknown" };
38
+ if (current === target) { const value: PathState = Number.isFinite(stat.mtimeMs) ? { state: "present", mtime: stat.mtimeMs } : { state: "unknown" }; if (!chainAllowed()) return { state: "unknown" }; cache.set(path, value); return value; }
39
+ } catch (error) { const value: PathState = { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown" }; if (!chainAllowed()) return { state: "unknown" }; cache.set(path, value); return value; }
40
+ }
41
+ return { state: "unknown" };
42
+ };
43
+ // ponytail: same-user ancestor swaps are outside the current metadata-only fence. Descriptor-relative traversal
44
+ // is required before treating concurrent hostile filesystem mutation as supported. No source/symbol index is stored.
45
+ return view => {
46
+ const at = Date.parse(view.at); if (!Number.isFinite(at)) return undefined;
47
+ let known = false;
48
+ for (const node of view.about) {
49
+ const path = nodePath(node); if (path === undefined) continue;
50
+ const state = inspect(path);
51
+ if (state.state === "missing" && options.rootConfirmed === true || state.state === "present" && state.mtime > at) return true;
52
+ known ||= state.state === "present";
53
+ }
54
+ return known ? false : undefined;
55
+ };
56
+ }
@@ -0,0 +1,74 @@
1
+ import type { MemoryStore, RecallStateOptions, RetainedRecallState, Graph, View } from "./store.ts";
2
+
3
+ export interface RecallOptions extends RecallStateOptions { query?: string; limit?: number }
4
+ /** Only public read semantics; mutation and exact-revision callers continue to use Store.get directly. */
5
+ export function recallViewIsLive(view: View, now: number): boolean {
6
+ return !view.supersededBy && Number.isFinite(Date.parse(view.at))
7
+ && (view.expiresAt === null || Number.isFinite(Date.parse(view.expiresAt)) && Date.parse(view.expiresAt) > now);
8
+ }
9
+ const PROMOTION_NOTICE = "promotion time unknown: tier before promotion cannot be recovered";
10
+ /** Narrow promotion uncertainty to emitted rows while retaining independent source diagnostics. */
11
+ export function recallForViews(state: RetainedRecallState, views: View[]): RetainedRecallState {
12
+ const notices = state.notices.filter(notice => notice !== PROMOTION_NOTICE || views.some(view => view.promotedFrom));
13
+ return { ...state, views, notices, incomplete: state.sourceIncomplete === true || state.malformed > 0 || notices.length > 0 };
14
+ }
15
+ function personRecall(options: RecallStateOptions): void {
16
+ if ((options.history || options.asOf !== undefined) && options.person !== true) throw Error("historical recall requires authenticated person intent from the host");
17
+ }
18
+ export function recallRows(store: MemoryStore, project: string, options: RecallOptions = {}): RetainedRecallState {
19
+ personRecall(options);
20
+ const limit = options.limit ?? 5000;
21
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > Number.MAX_SAFE_INTEGER) throw Error("recall limit must be a positive safe integer");
22
+ const state: RetainedRecallState = options.history || options.asOf !== undefined ? store.recallState(project, options)
23
+ : store.withDeferredPromotions(() => ({ at: new Date(store.readNow()).toISOString(), views: store.list({ project, limit: Number.MAX_SAFE_INTEGER }).filter(view => recallViewIsLive(view, store.readNow())), forgotten: [], relations: [], malformed: 0, incomplete: false, notices: [] }));
24
+ const words = options.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
25
+ const rows = state.views.filter(view => words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word)))
26
+ .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id));
27
+ const selected = recallForViews(state, rows.slice(0, limit));
28
+ return { ...selected, incomplete: selected.incomplete || rows.length > limit };
29
+ }
30
+ export interface RecallShown { entry?: View; state?: RetainedRecallState; ids: string[] }
31
+ export function recallShow(store: MemoryStore, project: string, rawId: string, options: RecallStateOptions = {}): RecallShown {
32
+ personRecall(options);
33
+ const id = rawId.replace(/^mem:/, "");
34
+ if (!/^[a-f0-9]{6,32}$/.test(id)) return { ids: [] };
35
+ if (!options.history && options.asOf === undefined) {
36
+ const view = store.withDeferredPromotions(() => store.get(id, project));
37
+ return view && recallViewIsLive(view, store.readNow()) ? { entry: view, ids: [view.id] } : { ids: [] };
38
+ }
39
+ const state = store.recallState(project, options), known = [...new Set([...state.views.map(view => view.id), ...state.forgotten.map(row => row.id)])];
40
+ const matches = known.includes(id) ? [id] : known.filter(value => value.startsWith(id));
41
+ if (matches.length !== 1) return { ids: [] };
42
+ const selected = new Set(matches), queue = [...matches], knownIds = new Set(known);
43
+ if (options.history) for (let index = 0; index < queue.length && queue.length <= 64; index++) {
44
+ for (const edge of state.relations) for (const next of edge.from === queue[index] ? [edge.to] : edge.to === queue[index] ? [edge.from] : []) {
45
+ if (knownIds.has(next) && !selected.has(next)) { selected.add(next); queue.push(next); }
46
+ }
47
+ }
48
+ if (queue.length > 64) throw Error("recall history chain exceeds 64 retained entries");
49
+ const views = state.views.filter(view => selected.has(view.id));
50
+ return { entry: views.find(view => view.id === matches[0]), ids: [...selected], state: { ...recallForViews(state, views), forgotten: state.forgotten.filter(row => selected.has(row.id)), relations: state.relations.filter(edge => selected.has(edge.from) && selected.has(edge.to)) } };
51
+ }
52
+
53
+ /** Public graph over live recall rows; retained history requires host-authenticated person intent. */
54
+ export function recallGraph(store: MemoryStore, project: string, start: string, hops = 1, options: RecallOptions = {}, readable: (rows: View[]) => View[] = rows => rows): Graph {
55
+ personRecall(options);
56
+ const rows = readable(recallRows(store, project, { ...options, limit: Number.MAX_SAFE_INTEGER }).views);
57
+ let node = start;
58
+ if (!start.includes(":") || start.startsWith("mem:")) {
59
+ const id = start.replace(/^mem:/, ""), exact = rows.find(view => view.id === id), matches = exact ? [exact] : id.length >= 6 ? rows.filter(view => view.id.startsWith(id)) : [];
60
+ if (matches.length !== 1) return { nodes: [], edges: [] };
61
+ node = `mem:${matches[0]!.id}`;
62
+ }
63
+ return store.withDeferredPromotions(() => store.graph(project, node, hops, 200, rows));
64
+ }
65
+
66
+ /** One prepared snapshot; its revision guard rejects changes after asynchronous host policy work. */
67
+ export function createIntentBriefSource(store: MemoryStore, project: string): { notes(): View[]; assertCurrent(): void; truncated(): boolean } {
68
+ let snapshot: ReturnType<MemoryStore["intentSnapshot"]> | undefined;
69
+ return {
70
+ notes: () => { snapshot ??= store.intentSnapshot(project); return snapshot.rows; },
71
+ assertCurrent: () => { if (!snapshot) throw Error("intent snapshot has not been prepared"); snapshot.assertCurrent(); },
72
+ truncated: () => snapshot?.truncated ?? false,
73
+ };
74
+ }