@staix/agent-mem 0.6.0 → 0.8.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 (71) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +143 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +537 -39
  6. package/src/commands-table.ts +60 -16
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +20 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +168 -33
  12. package/src/mcp.ts +43 -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 +228 -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/consolidate.ts +78 -0
  25. package/src/memory/consolidation-clusters.ts +56 -0
  26. package/src/memory/consolidation-model.ts +97 -0
  27. package/src/memory/cross-project.ts +75 -0
  28. package/src/memory/curated-read.ts +29 -0
  29. package/src/memory/decay-review.ts +136 -0
  30. package/src/memory/doctor.ts +415 -0
  31. package/src/memory/editor.ts +58 -0
  32. package/src/memory/eval-fixture.ts +145 -0
  33. package/src/memory/eval-set-ko.json +90 -0
  34. package/src/memory/eval-set.json +443 -0
  35. package/src/memory/eval.ts +152 -0
  36. package/src/memory/evidence.ts +76 -0
  37. package/src/memory/external-import.ts +267 -0
  38. package/src/memory/gc.ts +97 -0
  39. package/src/memory/generation-index.ts +22 -17
  40. package/src/memory/generation-sync.ts +3 -0
  41. package/src/memory/goal-promotion.ts +71 -0
  42. package/src/memory/home.ts +15 -4
  43. package/src/memory/intent-brief.ts +89 -0
  44. package/src/memory/lifecycle-log.ts +54 -0
  45. package/src/memory/lifecycle.ts +61 -0
  46. package/src/memory/metrics.ts +2 -2
  47. package/src/memory/note-add.ts +100 -0
  48. package/src/memory/path-glob.ts +94 -0
  49. package/src/memory/plan-guard.ts +47 -0
  50. package/src/memory/platform.ts +12 -1
  51. package/src/memory/policy.ts +44 -13
  52. package/src/memory/private-spans.ts +32 -0
  53. package/src/memory/project-export.ts +157 -0
  54. package/src/memory/project-import.ts +147 -0
  55. package/src/memory/recall-staleness.ts +56 -0
  56. package/src/memory/recall.ts +74 -0
  57. package/src/memory/repo-scope.ts +404 -0
  58. package/src/memory/shard-sync-format.ts +116 -0
  59. package/src/memory/shard-sync.ts +421 -0
  60. package/src/memory/store.ts +344 -35
  61. package/src/memory/sync-cadence.ts +149 -0
  62. package/src/memory/sync-screen.ts +86 -0
  63. package/src/memory/sync.ts +91 -11
  64. package/src/memory/usage.ts +35 -0
  65. package/src/memory/use-telemetry.ts +51 -0
  66. package/src/memory/work-lists.ts +10 -4
  67. package/src/session-brief-worker.ts +3 -2
  68. package/src/session-brief.ts +74 -15
  69. package/src/setup-owned.ts +120 -0
  70. package/src/setup-toml.ts +296 -0
  71. package/src/setup.ts +327 -0
@@ -8,46 +8,85 @@ export interface CommandDefinition {
8
8
  options?: Record<string, OptionDefinition>;
9
9
  args?: readonly ArgumentDefinition[];
10
10
  commands?: Record<string, CommandDefinition>;
11
- mutates?: boolean | "apply" | "label";
11
+ /** `confirm`: previews without `--confirm` (a read), and mutates with it (#15 T5: gc, project forget, capacity apply). */
12
+ mutates?: boolean | "apply" | "label" | "confirm";
12
13
  policyFree?: boolean;
13
14
  defaultCommand?: string;
14
15
  }
15
16
  const flag = (description: string): OptionDefinition => ({ description });
16
17
  const text = (description: string): OptionDefinition => ({ description, value: "text" });
17
18
  const json = { "--json": flag("Print JSON where supported") };
18
- const policy = { "--policy-free": flag("Explicitly bypass host privacy policy") };
19
+ const history = { "--history": flag("Read retained supersession/expiry history; forgotten text stays hidden"), "--as-of": text("Past canonical UTC date or timestamp; current forget/prune masks still apply") };
20
+ const policy = { "--policy-free": flag("Accepted and ignored: the configured privacy policy always applies") };
19
21
  const filters = { ...json, "--tier": { description: "Memory tier", value: ["short", "long"] } as OptionDefinition,
20
22
  "--kind": { description: "Note kind", value: KINDS }, "--peer": text("Author peer"), "--machine": text("Machine ID"),
21
23
  "--since": text("ISO date or time"), "--limit": text("Maximum results") };
22
24
  export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
23
25
  export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
24
26
  export const COMMANDS: Record<string, CommandDefinition> = {
27
+ consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
28
+ setup: { description: "Preview reversible MCP and hook setup for native coding CLIs; apply and undo are person-only", options: { "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "all"] }] },
25
29
  mcp: { description: "Run the project-bound MCP stdio server for this session" },
26
30
  hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
27
31
  ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi"] }] },
28
- "vault-candidates": { description: "List screened durable curated vault candidates", options: { ...json, "--limit": text("Maximum candidates (1..100)") } },
29
- list: { description: "List memory", options: filters },
30
- search: { description: "Search memory", options: filters, args: [{ name: "words", variadic: true }] },
31
- show: { description: "Show a note", options: json, args: [{ name: "id" }] },
32
- graph: { description: "Show a graph", options: { ...json, "--hops": { description: "Graph depth", value: ["1", "2"] } }, args: [{ name: "id-or-node" }] },
32
+ "vault-candidates": { description: "List screened durable curated vault candidates (per project, or across projects)", options: { ...json, "--limit": text("Maximum candidates per project (1..100)"),
33
+ "--all-projects": flag("List every active project's candidates; the reply says it crossed projects"), "--project": text("List one named project's candidates") } },
34
+ list: { description: "List memory", options: { ...filters, ...history } },
35
+ search: { description: "Search memory", options: { ...filters, ...history, "--all-projects": flag("Search every active project; the reply says it crossed projects"),
36
+ "--include-archived": flag("With --all-projects, also read archived projects") }, args: [{ name: "words", variadic: true }] },
37
+ brief: { description: "Read intent/path memory: fails first, then decisions and contracts; untrusted data within a UTF-8 byte cap", options: { ...json,
38
+ "--for": text("Explicit intent"), "--paths": text("Repeatable named path"), "--symbols": text("Repeatable named symbol"), "--bytes": text("Complete brief-body byte cap (1..8192, default 2048)") } },
39
+ projects: { description: "List the project shards of this memory home (#15 D3)", options: { ...json, "--all": flag("Include forgotten projects (archived ones are always listed)") } },
40
+ usage: { description: "Totals and the largest project shards", options: { ...json, "--limit": text("Largest shards to list (1..100)"), "--all": flag("Include forgotten projects") } },
41
+ eval: { description: "Replay a local question set against the ranked store.search (the ranking MCP and hub recall use, not the `search` command's substring filter) and report recall@k and MRR (no model, no network; the built-in synthetic set without --set, `--set ko` for the built-in Korean one)", options: { ...json,
42
+ "--set": { description: "`ko` (the built-in Korean set) or a question set file (amem.eval/v1); with entries it is self-contained, without them its ids name entries of this project", value: "path" }, "--k": text("Cutoffs, comma separated (default 1,5,10; each 1..100)") } },
43
+ gc: { description: "Apply each shard's retention and cap across shards in bounded batches (preview without --confirm)", mutates: "confirm", options: {
44
+ ...json, "--confirm": flag("Evict now; without it, preview only"), "--batches": text("evict() windows per shard in this run (1..1000, default 1)") } },
45
+ project: { description: "Per-project lifecycle and portable import/export (#15 D3, F17): one project only", options: json, commands: {
46
+ export: { description: "Write one project's capture, curated notes, goals and work lists to a new JSON, JSONL or Markdown file (never overwrites)", options: { "--to": { description: "Destination file, outside the memory home", value: "path" }, "--format": { description: "Portable format (default json)", value: ["json", "jsonl", "markdown"] }, ...policy },
47
+ args: [{ name: "key" }] },
48
+ import: { description: "Preview or re-import curated notes from a JSONL project export with their original entry ids", options: { "--from": { description: "JSONL project export", value: "path" }, "--confirm": flag("Write after the preview; without it, preview only"), "--allow-shared": flag("Explicitly permit shared-scope notes from this file"), ...policy }, args: [{ name: "key" }], mutates: "confirm", policyFree: true },
49
+ archive: { description: "Take a cold project out of recall (append-only op; unarchive brings it back)", args: [{ name: "key" }], mutates: true },
50
+ unarchive: { description: "Bring an archived project back into recall (append-only op)", args: [{ name: "key" }], mutates: true },
51
+ forget: { description: "Forget one project for good (append-only op; preview without --confirm)", options: { "--confirm": flag("Record the forget after the preview") }, args: [{ name: "key" }], mutates: "confirm" },
52
+ } },
53
+ show: { description: "Show a note", options: { ...json, ...history }, args: [{ name: "id" }] },
54
+ graph: { description: "Show a graph", options: { ...json, ...history, "--hops": { description: "Graph depth", value: ["1", "2"] } }, args: [{ name: "id-or-node" }] },
33
55
  suggestions: { description: "List pending suggestions", options: json },
34
56
  confirm: { description: "Confirm a suggestion", options: json, args: [{ name: "id" }], mutates: true },
35
57
  reject: { description: "Reject a suggestion", options: json, args: [{ name: "id" }], mutates: true },
36
- promote: { description: "Promote a note", options: { ...json, ...policy }, args: [{ name: "id" }], mutates: true, policyFree: true },
58
+ promote: { description: "Promote a note (--to repo: append its screened line to this repository's .amem/log.jsonl)", options: { ...json, ...policy, "--to": { description: "Promote into the repository scope", value: ["repo"] },
59
+ "--confirm": flag("With --to repo: append the previewed line"), "--confirm-public": text("With --to repo, unless the repository is verified private: the first 8 characters of the entry id") }, args: [{ name: "id" }], mutates: true, policyFree: true },
60
+ repo: { description: "The repository scope (#15 D6): this repository's .amem/log.jsonl", options: json, defaultCommand: "status", commands: {
61
+ status: { description: "Show the repository log, its entries and what the screen withheld" },
62
+ attributes: { description: "Add the merge=union entry for .amem/log.jsonl to .gitattributes (preview without --confirm; never committed)", mutates: "confirm", options: { "--confirm": flag("Write after the preview") } },
63
+ } },
37
64
  demote: { description: "Demote a note", options: json, args: [{ name: "id" }], mutates: true },
38
65
  forget: { description: "Forget a note", options: json, args: [{ name: "id" }], mutates: true },
66
+ note: { description: "Write notes as the person (F6)", options: json, commands: {
67
+ add: { description: "Add one curated note: text as arguments, `-` to type it on the terminal until end of input, or no text to open $EDITOR. A fail note takes --approach, --symptom, --cause, --evidence (repeatable) and --retry-when", mutates: true, options: {
68
+ "--kind": { description: "Note kind (default finding)", value: KINDS }, "--tier": { description: "Memory tier (default long)", value: ["short", "long"] },
69
+ "--scope": { description: "Scope (default project)", value: ["project", "shared"] }, "--title": text("One-line title"),
70
+ "--approach": text("fail: what was tried"), "--symptom": text("fail: what went wrong"), "--cause": text("fail: why"), "--evidence": text("fail: a reference (repeatable)"), "--retry-when": text("fail: what would make the approach worth retrying") },
71
+ args: [{ name: "text", variadic: true }] },
72
+ } },
39
73
  supersede: { description: "Replace a note", options: json, args: [{ name: "old-id" }, { name: "new-id" }], mutates: true },
40
74
  rebuild: { description: "Rebuild the derived index", options: json, mutates: true },
41
75
  dedupe: { description: "Preview duplicate reconciliation", options: { ...json, "--apply": flag("Append reconciliation operations") }, mutates: "apply" },
42
- sync: { description: "Sync a private remote", options: { ...json, ...policy, "--remote": text("Private git URL") }, mutates: true, policyFree: true },
76
+ sync: { description: "Sync a private remote (--if-due: only when the schedule and the failure backoff allow it; --force ignores both)", options: { ...json, ...policy, "--remote": text("Private git URL"),
77
+ "--if-due": flag("Sync only when due (hourly after a success, 15 minutes doubling to a day after failures); stops after 30 seconds"), "--force": flag("With --if-due: ignore the schedule and the backoff") }, mutates: true, policyFree: true },
43
78
  machine: { description: "Show or label this machine", options: { ...json, "--label": text("Machine label") }, mutates: "label" },
44
79
  import: { description: "Import external memory", options: { ...json, ...policy, "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") }, mutates: true, policyFree: true,
45
- commands: { "claude-mem": { description: "Import claude-mem observations" } } },
80
+ commands: {
81
+ "claude-mem": { description: "Import claude-mem observations", options: { "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") } },
82
+ "claude-auto": { description: "Import Claude Code auto memory as review candidates; CLAUDE.md and AGENTS.md stay excluded", options: { "--from": { description: "Claude projects directory (default ~/.claude/projects)", value: "path" } } },
83
+ codex: { description: "Import Codex memories as review candidates; originals stay unchanged", options: { "--from": { description: "Codex memories directory (default ~/.codex/memories)", value: "path" } } },
84
+ } },
46
85
  goal: { description: "Long-term goals", options: json, defaultCommand: "list", commands: {
47
86
  list: { description: "List goals", options: { "--all": flag("Include completed goals") } },
48
87
  show: { description: "Show a goal", args: [{ name: "id" }] },
49
88
  new: { description: "Create a goal", options: { "--milestone": text("Milestone"), "--depends": text("Dependency goal ID"), "--shared": flag("Share across projects") }, args: [{ name: "title", variadic: true }], mutates: true },
50
- link: { description: "Link through ahub goal link", args: [{ name: "id" }, { name: "task-id" }], mutates: true },
89
+ link: { description: "Link a goal to a task of a host that has a task board (not available in amem alone)", args: [{ name: "id" }, { name: "task-id" }], mutates: true },
51
90
  state: { description: "Set goal state", args: [{ name: "id" }, { name: "state", values: GOAL_STATES }], mutates: true },
52
91
  done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
53
92
  take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
@@ -55,25 +94,30 @@ export const COMMANDS: Record<string, CommandDefinition> = {
55
94
  review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
56
95
  list: { description: "List review candidates", options: {
57
96
  "--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
58
- "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory"] }, "--limit": text("Maximum candidates (1..500)"),
97
+ "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] }, "--limit": text("Maximum candidates (1..500)"),
59
98
  } },
60
- accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Explicit standalone candidate kind for selected profiles", value: ["memory"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
99
+ accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Require the selected candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
61
100
  reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
101
+ walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } } },
62
102
  } },
103
+ decay: { description: "Preview old live long-term notes unprotected by open goals; apply only after person review", mutates: "apply", options: {
104
+ ...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Show the exact revision preview and ask before applying"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
63
105
  migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
64
106
  ...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
65
107
  "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
66
108
  "--shards": flag("Migrate the curated home into project and user shards"),
67
109
  } },
68
110
  status: { description: "Report shard migration and pending old-layout appends", options: json },
111
+ doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
69
112
  prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: {
70
113
  ...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"),
71
114
  "--user": flag("Select the user shard explicitly after a curated split (default: current project)"),
72
115
  "--apply": flag("Apply the exact local plan"), "--plan": { description: "Sealed local plan JSON file", value: "path" },
73
116
  } },
74
117
  capacity: { description: "Report capture use and propose R6 bounds (R7)", options: { ...json, "--disk-share": text("Disk-share bound for the proposal (default 0.02)") }, commands: {
75
- apply: { description: "Write the proposed (or given) bounds to the home capture config", mutates: true, options: {
76
- ...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview") } },
118
+ apply: { description: "Write the proposed (or given) bounds to the home capture config, or with --split each shard's share of the global budget", mutates: true, options: {
119
+ ...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview"),
120
+ "--split": flag("Write the per-shard split of the global budget to each shard's own config") } },
77
121
  } },
78
122
  alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
79
123
  list: { description: "List alias ops" },
@@ -152,5 +196,5 @@ export function commandHelp(): string {
152
196
  return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
153
197
  const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
154
198
  return ` ${names}${Object.entries(row.options).map(([name, option]) => ` [${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}]`).join("")}`;
155
- }), "--policy-free: use ahub memory for the host's privacy policy.", "Mutating commands require a terminal on stdin. Agents use the hub's hub_memory_* tools."].join("\n");
199
+ }), "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "Mutating commands require a terminal on stdin. Agents use the `amem mcp` server; the person reviews what agents propose with `amem review`."].join("\n");
156
200
  }
package/src/hook-spool.ts CHANGED
@@ -6,7 +6,11 @@ import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
6
6
  import { normalizeCaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
7
7
  import { canonicalPath, realPath } from "./memory/platform.ts";
8
8
  import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
9
+ import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
10
+ import { fold } from "./memory/policy.ts";
11
+ import { looksSecret } from "./memory/screen.ts";
9
12
  import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
13
+ import { projectState } from "./memory/lifecycle.ts";
10
14
  import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
11
15
 
12
16
  const SCHEMA = "agent-mem.hook-capture/v1";
@@ -280,6 +284,7 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer } | undefined
280
284
  const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
281
285
  ...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }) });
282
286
  const actual = c as unknown as {[key: string]: unknown};
287
+ // A record staged under a configured cap is already inside the built-in one, so normalizing it again must leave every field unchanged.
283
288
  if (Object.entries(normalized).some(([key, item]) => JSON.stringify(actual[key]) !== JSON.stringify(item))) return undefined;
284
289
  return { value, bytes };
285
290
  } catch { return undefined; }
@@ -445,7 +450,7 @@ export function quarantineHookScope(options: { cwd: string; base: string; scope:
445
450
  /** Only screened normalized capture fields are retained, never the native input envelope. */
446
451
  export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
447
452
  if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host) return undefined;
448
- const policy = createStandalonePolicy(options.policy);
453
+ const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
449
454
  if (!await policy.screen(input)) return undefined;
450
455
  const value = structuredClone(input) as {[key: string]: unknown};
451
456
  if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.byteLength(JSON.stringify(value)) > HOOK_INPUT_BYTES || !await policy.screen(value)) return undefined;
@@ -455,8 +460,14 @@ export async function stageHookCapture(event: string, input: unknown, options: O
455
460
  const tool = value.tool_input && typeof value.tool_input === "object" && !Array.isArray(value.tool_input) ? value.tool_input as {[key: string]: unknown} : {};
456
461
  const paths = [tool.file_path, tool.path, ...(Array.isArray(tool.paths) ? tool.paths : [])].filter((p): p is string => typeof p === "string");
457
462
  const content = event === "PostToolUseFailure" ? value.error ?? value.tool_response ?? "" : value.prompt ?? value.last_assistant_message ?? value.tool_response ?? "";
463
+ // F4: the person's configured caps apply at staging. Replay (`decoded`) re-normalizes with the built-in caps, which are the ceiling, so a record
464
+ // staged under a tighter cap still verifies after the cap is raised or removed.
465
+ const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config; // the repository's .amem/config narrows here too
458
466
  const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
459
- ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt: typeof content === "string" ? content : JSON.stringify(content) });
467
+ ...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt: typeof content === "string" ? content : JSON.stringify(content) },
468
+ undefined,
469
+ (text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // the configured patterns redact the excerpt, as the store does
470
+ { excerptBytes: configured.excerptBytes, paths: configured.capturePaths, ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) });
460
471
  if (!await policy.screen({ cwd, capture })) return undefined;
461
472
  const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
462
473
  const record: Record = { schema: SCHEMA, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
@@ -507,6 +518,8 @@ export async function drainHookCaptures(options: Options & { project: string; ex
507
518
  append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
508
519
  const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
509
520
  if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
521
+ // F4: the configured policy applies at drain too. `options.policy` carries it, so the anchored line below (a test diagnostic matches its exact text) stays as it was.
522
+ options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
510
523
  const policy = createStandalonePolicy(options.policy), dir = directory(base);
511
524
  const matching = (rows: ReturnType<typeof entries>, capacity: boolean) => rows.filter(row => {
512
525
  const m = namePattern.exec(row.name); return m && m[2] === hash(cwd) && m[3] === stamp;
@@ -530,6 +543,15 @@ export async function drainHookCaptures(options: Options & { project: string; ex
530
543
  }
531
544
  const value = record.value, c = value.capture;
532
545
  if (value.scope === null) { blocked++; continue; }
546
+ // #15 T5: a project the person archived or forgot takes no new capture. Retire the record here with a counted reason instead of leaving it pending:
547
+ // the home-wide spool is bounded, so a kept record would crowd out every active project. Read per record: an op can land mid-drain. A receipt that
548
+ // cannot be written keeps the body pending, as for every other retirement.
549
+ const state = projectState(base, options.project);
550
+ if (state !== "active") {
551
+ appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: state === "archived" ? "project_archived" : "project_forgotten" }]);
552
+ if (!retireClaim(base, claim, false)) blocked++;
553
+ continue;
554
+ }
533
555
  const input: CaptureInput = { project: options.project, agent: c.agent, event: c.event, at: c.at, paths: c.paths,
534
556
  ...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }), excerpt: c.excerpt };
535
557
  if (!await policy.screen(value) || !await policy.screen(input)) { retireClaim(base, claim, true); blocked++; continue; }
package/src/hooks.ts CHANGED
@@ -8,6 +8,7 @@ import { stageHookCapture, hookCaptureScope, HookSpoolFull, type PendingHookCapt
8
8
  import { memoryHome } from "./memory/home.ts";
9
9
  import { canonicalPath, realPath } from "./memory/platform.ts";
10
10
  import { createStandalonePolicy, type MemoryPolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
11
+ import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
11
12
 
12
13
  import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES } from "./hook-contract.ts";
13
14
  export { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, type HookEvent } from "./hook-contract.ts";
@@ -74,7 +75,9 @@ async function boundedOutput(stream: ReadableStream<Uint8Array>): Promise<string
74
75
  async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean }> {
75
76
  const workDeadline = deadline - WORKER_CLEANUP_MS;
76
77
  if (options.policy?.host || workDeadline - performance.now() < 5) return { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
77
- const maxTokens = Math.min(HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
78
+ const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
79
+ const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
80
+ // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
78
81
  const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
79
82
  policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
80
83
  const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
@@ -149,11 +152,12 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
149
152
  const losses = captureDropSummary(base);
150
153
  if (!pending) losses.spool_full = (losses.spool_full ?? 0) + 1;
151
154
  const dropNotice = Object.entries(losses).map(([reason, count]) => `capture dropped: ${reason.replaceAll("_", " ")} (${count})`).join("; ");
152
- const contextLimit = Math.min(HOOK_CONTEXT_BYTES, Math.max(1, Number.isFinite(options.maxTokens) ? Math.floor(options.maxTokens!) : HOOK_CONTEXT_BYTES));
153
- const brief = await sessionStartBrief({ ...options, maxTokens: Math.max(1, contextLimit - Buffer.byteLength(dropNotice) - (dropNotice ? 1 : 0)), cwd, base, project, nativeSession: { signature }, timeRemaining: remaining });
155
+ const configuredBrief = loadAmemConfig(base, repoConfigRoot(cwd)).config.briefBytes;
156
+ const contextLimit = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, Number.isFinite(options.maxTokens) ? Math.floor(options.maxTokens!) : HOOK_CONTEXT_BYTES));
157
+ const brief = await sessionStartBrief({ ...options, maxTokens: Math.max(1, contextLimit - Buffer.byteLength(dropNotice) - (dropNotice ? 1 : 0)), cwd, base, project, nativeSession: { signature }, projectFromCwd: true, timeRemaining: remaining });
154
158
  result.pending = !!pending && !result.captured;
155
159
  result.dropped = !pending;
156
- if (hookCaptureScope(cwd, base) !== scope || !await createStandalonePolicy(options.policy).screen({ context: brief.context })) return result;
160
+ if (hookCaptureScope(cwd, base) !== scope || !await createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)).screen({ context: brief.context })) return result;
157
161
  result.context = joinedContext(brief.context, dropNotice, contextLimit);
158
162
  result.valid = result.context.length > 0;
159
163
  return result;
@@ -193,7 +197,7 @@ export async function runHook(event: string, input: unknown, options: HookOption
193
197
  try {
194
198
  base = canonicalPath(options.base ?? memoryHome());
195
199
  if (!(HOOK_EVENTS as readonly string[]).includes(event)) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
196
- const policy = createStandalonePolicy(options.policy);
200
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, options.cwd, options.policy));
197
201
  // Persist screened capture before the optional worker can exhaust its time slice.
198
202
  const result = await captureAndWork(event, input, options, base, deadline - 3);
199
203
  if (!result) return { captured, ...(pending ? { pending: true as const } : {}), ...(dropped ? { dropped: true as const, dropReason: "spool_full" as const, dropCount: 1 } : {}), skipped: true };
package/src/index.ts CHANGED
@@ -5,6 +5,13 @@ export type { CuratedDoc, RankedRef } from "./memory/search-fts.ts";
5
5
  export * from "./memory/shard.ts";
6
6
  export * from "./memory/catalog.ts";
7
7
  export * from "./memory/capacity.ts";
8
+ export * from "./memory/lifecycle.ts";
9
+ export * from "./memory/usage.ts";
10
+ export * from "./memory/gc.ts";
11
+ export * from "./memory/project-export.ts";
12
+ export * from "./memory/project-import.ts";
13
+ export * from "./memory/external-import.ts";
14
+ export * from "./memory/cross-project.ts";
8
15
  export * from "./memory/sync.ts";
9
16
  export * from "./memory/import.ts";
10
17
  export * from "./memory/project.ts";
@@ -27,3 +34,16 @@ export * from "./memory/vault.ts";
27
34
  export * from "./hooks.ts";
28
35
  export * from "./ingest.ts";
29
36
  export * from "./session-brief.ts";
37
+
38
+ export * from "./memory/decay-review.ts";
39
+ export * from "./memory/goal-promotion.ts";
40
+
41
+ export * from "./memory/recall.ts";
42
+ export * from "./memory/recall-staleness.ts";
43
+ export * from "./memory/intent-brief.ts";
44
+ export * from "./memory/plan-guard.ts";
45
+ export * from "./memory/use-telemetry.ts";
46
+ export * from "./memory/consolidate.ts";
47
+ export * from "./memory/consolidation-clusters.ts";
48
+ export * from "./memory/consolidation-model.ts";
49
+ export * from "./memory/evidence.ts";
package/src/ingest.ts CHANGED
@@ -7,6 +7,7 @@ import { CaptureStore, type CaptureInput } from "./memory/capture.ts";
7
7
  import { memoryHome } from "./memory/home.ts";
8
8
  import { canonicalPath, processLiveness, processSignature, realPath } from "./memory/platform.ts";
9
9
  import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
10
+ import { configuredPolicyOptions } from "./memory/config.ts";
10
11
  import { memoryProjectKey } from "./memory/project.ts";
11
12
  import { effectiveAliases } from "./memory/shard.ts";
12
13
 
@@ -192,7 +193,7 @@ export async function ingestTranscript(kind: IngestKind, options: IngestOptions)
192
193
  try {
193
194
  const cwd = realPath(options.cwd), base = canonicalPath(options.base ?? memoryHome()), aliases = effectiveAliases(base);
194
195
  const aliasStamp = JSON.stringify([...aliases].sort()), requested = memoryProjectKey(cwd, { repositoryIsolation: true }), project = aliases.get(requested) ?? requested;
195
- const policy = createStandalonePolicy(options.policy);
196
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
196
197
  const scopes = new Map<string, Scope>();
197
198
  const guard = () => { if (JSON.stringify([...effectiveAliases(base)].sort()) !== aliasStamp) throw new Error("scope changed"); };
198
199
  const scopeOf = (value: unknown): Scope => {
@@ -203,7 +204,7 @@ export async function ingestTranscript(kind: IngestKind, options: IngestOptions)
203
204
  if (scopes.size < 64) scopes.set(value, scope);
204
205
  return scope;
205
206
  };
206
- capture = new CaptureStore({ memoryDir: base, project });
207
+ capture = new CaptureStore({ memoryDir: base, project, cwd });
207
208
  const directory = join(capture.dir, "ingest"); privateDirectory(directory);
208
209
  const found = options.files === undefined ? discover(kind) : { files: [...new Set(options.files)], bounded: false, unavailable: false };
209
210
  if (found.unavailable) fail("source_unavailable");