@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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
package/src/commands-table.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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")
|
|
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: {
|
|
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
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
153
|
-
const
|
|
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");
|