@staix/agent-mem 0.5.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -0,0 +1,61 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { SHARD_SCHEMA, effectiveAliases, resolveAlias } from "./aliases.ts";
3
+ import { lifecyclePath, lifecycleStates, listLifecycle, type LifecycleKind, type LifecycleOp, type LifecycleState } from "./lifecycle-log.ts";
4
+ import { readShardDescriptor, shardKeyHash } from "./shard.ts";
5
+
6
+ export { lifecyclePath, lifecycleStates, listLifecycle, type LifecycleKind, type LifecycleOp, type LifecycleState };
7
+ const KINDS: readonly string[] = ["archive", "unarchive", "forget"];
8
+
9
+ /**
10
+ * #15 T5 / D3: the per-project lifecycle. `archive`, `unarchive` and `forget` are append-only ops in `lifecycle.jsonl` of the memory home, in the same
11
+ * local, never-synced form as `aliases.jsonl` (no shared-v1 segment sees a new op kind). Nothing rewrites a line and nothing deletes a shard: the op
12
+ * is the whole effect, and every reader consults it. The latest op wins, except that `forgotten` is terminal.
13
+ *
14
+ * - archived: the shard stays on disk, but a writable capture store, recall and the brief refuse it, and the catalog lists it as archived.
15
+ * - forgotten: the project is out of recall and the default listing for good. Its bytes are not erased here (that is what `amem project export`
16
+ * and a person's own file deletion are for); the op is the record that the person asked for it.
17
+ */
18
+
19
+ const SEVERITY: Record<LifecycleState, number> = { active: 0, archived: 1, forgotten: 2 };
20
+ const worse = (a: LifecycleState, b: LifecycleState): LifecycleState => SEVERITY[a] >= SEVERITY[b] ? a : b;
21
+
22
+ /**
23
+ * The state of `key`, alias-aware in both directions: the worse of the key's own state and its canonical key's. An op is recorded on the canonical key, so
24
+ * an alias to an archived or forgotten project is that state, and a key forgotten before it became an alias of an active project stays forgotten (its own
25
+ * op is not erased by a later alias line). Terminal: after one `forget`, no later line moves a key back.
26
+ */
27
+ export function projectState(base: string, key: string): LifecycleState {
28
+ return lifecycleResolver(base)(key);
29
+ }
30
+
31
+ /** One pass over both logs, then a lookup per key for a walk of many shards: `state(key)` is exactly `projectState(base, key)`. */
32
+ export function lifecycleResolver(base: string): (key: string) => LifecycleState {
33
+ const states = lifecycleStates(base), aliases = effectiveAliases(base);
34
+ return key => worse(states.get(key) ?? "active", states.get(aliases.get(key) ?? key) ?? "active");
35
+ }
36
+
37
+ /** Refuses a project the person archived or forgot; the one check every writable capture open, recall and brief shares. */
38
+ export function assertProjectActive(base: string, key: string): void {
39
+ const state = projectState(base, key);
40
+ if (state !== "active") throw new Error(`project ${resolveAlias(base, key)} is ${state}: ${state === "archived" ? "amem project unarchive brings it back" : "it is out of recall for good"}`);
41
+ }
42
+
43
+ /**
44
+ * One person-authorized op. The key resolves through the alias table to the one canonical project, and the transition is checked first so a typo or a
45
+ * repeat never records an op that means nothing: archive needs an active project, unarchive an archived one, and nothing follows a forget.
46
+ */
47
+ export function appendLifecycle(base: string, input: { op: LifecycleKind; key: string; actor: string; machine: string; at?: string }): LifecycleOp {
48
+ if (!KINDS.includes(input.op)) throw new Error("unknown lifecycle op");
49
+ const requested = input.key.trim();
50
+ if (!requested || requested.length > 1000) throw new Error("a lifecycle op needs one project key");
51
+ const key = resolveAlias(base, requested);
52
+ if (!readShardDescriptor(base, shardKeyHash(key))) throw new Error(`no project shard for ${key}`);
53
+ const state = projectState(base, key);
54
+ if (state === "forgotten") throw new Error(`project ${key} is forgotten: nothing follows a forget`);
55
+ if (input.op === "archive" && state === "archived") throw new Error(`project ${key} is already archived`);
56
+ if (input.op === "unarchive" && state !== "archived") throw new Error(`project ${key} is not archived`);
57
+ const op: LifecycleOp = { schema: SHARD_SCHEMA, op: input.op, at: input.at ?? new Date().toISOString(), machine: input.machine, key, actor: input.actor };
58
+ mkdirSync(base, { recursive: true, mode: 0o700 });
59
+ appendFileSync(lifecyclePath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
60
+ return op;
61
+ }
@@ -0,0 +1,31 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export interface Machine { id: string; label: string }
6
+ /** Random per `~/.agenthub`, with a person-given label; created on first use. */
7
+ export function machineId(dir: string): Machine {
8
+ const file = join(dir, "machine.json");
9
+ const read = (): Machine | undefined => {
10
+ try {
11
+ const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
12
+ if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
13
+ } catch { /* none, or unreadable */ }
14
+ return undefined;
15
+ };
16
+ const found = read();
17
+ if (found) return found;
18
+ // Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
19
+ // which fails if another got there first; then everyone reads the one identity that won.
20
+ const id = randomBytes(8).toString("hex");
21
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
23
+ writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
24
+ try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
25
+ // A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
26
+ // fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
27
+ const won = read();
28
+ if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
29
+ return won;
30
+ }
31
+
@@ -0,0 +1,63 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
5
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
6
+ const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
7
+
8
+ export function metricsPath(base: string): string {
9
+ return join(base, "metrics.jsonl");
10
+ }
11
+
12
+ /** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
13
+ export function appendMetrics(base: string, samples: MetricSample[]): void {
14
+ mkdirSync(base, { recursive: true, mode: 0o700 });
15
+ const file = metricsPath(base);
16
+ appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
17
+ try {
18
+ if (statSync(file).size <= METRICS_MAX_BYTES) return;
19
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
20
+ writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
21
+ } catch { /* a trim failure loses nothing: the file simply stays larger */ }
22
+ }
23
+
24
+ /** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
25
+ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
26
+ let lines: string[] = [];
27
+ try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
28
+ const out: MetricSample[] = [];
29
+ for (const line of lines.slice(-limit)) {
30
+ try {
31
+ const s = JSON.parse(line) as MetricSample;
32
+ if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
33
+ } catch { /* a malformed line is skipped */ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+
39
+ export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
40
+ const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
41
+ /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
42
+ export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
+ const counts: Partial<Record<CaptureDropReason, number>> = {};
44
+ let fd: number | undefined;
45
+ try {
46
+ fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
47
+ const stat = fstatSync(fd); if (!stat.isFile()) return counts;
48
+ const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
49
+ const length = readSync(fd, bytes, 0, bytes.length, start);
50
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
51
+ if (start) text = text.slice(text.indexOf("\n") + 1);
52
+ for (const line of text.split("\n").filter(Boolean).slice(-METRICS_KEEP)) {
53
+ try {
54
+ const sample = JSON.parse(line) as MetricSample;
55
+ if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
56
+ const reason = sample.captureDropReason!;
57
+ counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
58
+ } catch { /* A malformed metric is never a notice. */ }
59
+ }
60
+ } catch { /* Metric availability never denies the native call. */ }
61
+ finally { if (fd !== undefined) closeSync(fd); }
62
+ return counts;
63
+ }
@@ -6,10 +6,11 @@ export const MUTATION_BUSY_TIMEOUT_MS = 3000;
6
6
  // ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
7
7
  // that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
8
8
  /** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
9
- export function mutationDatabase(dir: string): Database {
9
+ export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
10
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid mutation busy timeout");
10
11
  const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
11
12
  chmodSync(file, 0o600);
12
- db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`);
13
+ db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
13
14
  return db;
14
15
  }
15
16
 
@@ -22,13 +23,14 @@ export function acquireMutation(dir: string): (() => void) | undefined {
22
23
  }
23
24
 
24
25
  /** Short local admission fence, never held across sync/network or data mutations. */
25
- export function readerAdmissionDatabase(dir: string): Database {
26
+ export function readerAdmissionDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
27
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid reader admission busy timeout");
26
28
  const file = join(dir, "reader-admission.db");
27
29
  for (const suffix of ["", "-wal", "-shm", "-journal"]) {
28
30
  const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
29
31
  if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
30
32
  }
31
33
  const db = new Database(file, { create: true });
32
- try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`); return db; }
34
+ try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`); return db; }
33
35
  catch (error) { db.close(); throw error; }
34
36
  }
@@ -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
+ }
@@ -1,7 +1,12 @@
1
1
  // Single-source platform helpers. Hub, local and Pi modules re-export these functions unchanged.
2
2
  import { realpathSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
- import { spawnSync, execFileSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
4
+ import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+ const require = createRequire(import.meta.url);
7
+ let processes: typeof import("node:child_process") | undefined;
8
+ // Path/policy-only hook parents never need a process module. Node-safe synchronous users load it on demand.
9
+ const childProcess = () => processes ??= require("node:child_process") as typeof import("node:child_process");
5
10
  import { createHash } from "node:crypto";
6
11
 
7
12
  const canonical = (path: string): string => {
@@ -33,7 +38,7 @@ export function realPath(path: string): string {
33
38
  // The path as given: resolve() would fold a `..` after a symlink by text. GNU realpath accepts a dangling last
34
39
  // component unless told -e; the BSD one on macOS refuses it by default.
35
40
  const argv = process.platform === "darwin" ? ["/bin/realpath", "--", path] : ["realpath", "-e", "--", path];
36
- const r = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
41
+ const r = childProcess().spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
37
42
  if (r.status !== 0 || !r.stdout.endsWith("\n")) throw error;
38
43
  return r.stdout.slice(0, -1);
39
44
  }
@@ -94,7 +99,7 @@ export function memoryGitEnv(env: NodeJS.ProcessEnv = process.env, options: { re
94
99
  /** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
95
100
  export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
96
101
  const argv = memoryGitArgv(args);
97
- return spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
102
+ return childProcess().spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
98
103
  }
99
104
 
100
105
  /**
@@ -109,7 +114,7 @@ export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStrin
109
114
  */
110
115
  export function processSignature(pid: number): string | undefined {
111
116
  try {
112
- const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
117
+ const text = childProcess().execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
113
118
  return text ? createHash("sha256").update(text).digest("hex") : undefined;
114
119
  } catch { return undefined; }
115
120
  }
@@ -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
+ }