@staix/agent-mem 0.16.0 → 0.18.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/plugin.json +1 -1
- package/README.md +23 -1
- package/package.json +6 -3
- package/src/cli.ts +6 -1
- package/src/commands-table.ts +1 -1
- package/src/hook-spool.ts +41 -14
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +43 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +64 -12
- package/src/memory/gc.ts +22 -5
- package/src/memory/metrics.ts +2 -2
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +13 -0
- package/src/memory/store.ts +23 -2
- package/src/session-brief.ts +7 -5
- package/src/setup-console.ts +50 -0
- package/src/ui/panel.css +15 -0
- package/src/ui/panel.js +154 -0
- package/src/ui/plugin.ts +507 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.18.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -151,6 +151,24 @@ Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelat
|
|
|
151
151
|
|
|
152
152
|
Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
|
|
153
153
|
|
|
154
|
+
## Console (acon)
|
|
155
|
+
|
|
156
|
+
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (STAIxBWLB/agent-console, contract api 1). It adds a Memory area with the panels Review, Recap, Search, Timeline, Work lists, Goals and Capacity, puts pending candidates (group `candidate`) and a capture capacity proposal (group `alert`) in the console inbox, and answers the console palette with memory search hits.
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
amem setup console # preview
|
|
160
|
+
amem setup console --apply # write $AGENTHUB_HOME/console/plugins/amem.json
|
|
161
|
+
amem setup console --undo # remove it (only when it points at this install)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The manifest holds the absolute path and sha256 of `src/ui/plugin.ts`, in a 0700 directory and a 0600 file, as acon requires. acon refuses a module whose hash changed, so run `amem setup console --apply` again after upgrading amem. `setup all` does not include it.
|
|
165
|
+
|
|
166
|
+
- Reads are screened. Search, Timeline and Work lists call the in-process `amem_search`, `amem_timeline` and `amem_work_state_read` handlers for the chosen project and forward every option the tool declares, so console search returns exactly the `amem_search` set for the same query. Goals and the review queue pass the configured policy; a candidate that fails it, or whose target fails it, is not shown (the panel counts it and points to `amem review walk`). The console never shows unscreened content, also not to the person. It runs in no repository, so a repository's `.amem/config` narrowing does not apply; the machine's `amem.config.json` does.
|
|
167
|
+
- Recap computes the current SessionStart brief only on its own panel snapshots, using machine policy. Curated and work inputs use bounded private scratch copies; capture is read-only, and no live reader lease is taken. It shows the full context, coverage, partial status, token upper bound and generation time; it is not a stored copy of a prior session's injected brief. The console has no repository binding, so the repository section is absent. No recap or new capture field is stored in the memory home.
|
|
168
|
+
- Review actions are the CLI's. Accept runs the same acceptance as `amem review accept` and writes the same note, edit-then-accept is `review walk`'s edit (the person's own note, then the candidate closed), reject is `review reject`. The console stamps the person as the actor (peer `person`). A privacy-screen hit refuses with a named reason and writes nothing. agent-mem keeps no reject reason, so a reason typed in the console is not recorded, and the reply says so.
|
|
169
|
+
- Undo is offered for 10 seconds and stays bound to what that console just did: undoing an accept forgets the accepted note (an append-only forget op; a note a correction replaced shows again), undoing a reject makes the candidate pending again. A promotion has no undo. `merge_into` is not offered: the library has no merge operation.
|
|
170
|
+
- Destructive forms (`gc`, `capacity_apply`, `project_forget`, `decay_apply`, `prune`) answer `needsConfirm` with the preview and `changes[]` and never write on the first call. The console's single-use confirm token carries the fingerprint back; the plugin recomputes the preview and writes only when it still matches. `prune` needs a generation home, as `amem prune` does.
|
|
171
|
+
|
|
154
172
|
## Shell completion
|
|
155
173
|
|
|
156
174
|
Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands configure shell completion.
|
|
@@ -217,6 +235,10 @@ The JSONL segments are authoritative. `index.db` is derived and can be rebuilt.
|
|
|
217
235
|
|
|
218
236
|
From issue #13 (phase P1), the package carries a second, machine-local tier beside the curated store, born sharded (#15 T1): one capture shard per project key at `projects/<key-hash>/machine/<machine-id>/` under the memory home, with its own locks so two projects never contend. The capture tier records deterministic tool-event rows (project, agent, event, tool, paths, exit status and a bounded, secret-screened excerpt) in the shard's `capture.db`. It is never synced, never read as curated evidence, and never writes a curated log, so its maintenance can never evict notes, goals or work lists. Capture is bounded by a soft size cap and a retention window (defaults 512 MB per shard, 90 days), resolved at every invocation from the library options, then the shard's `config.json`, then the home default `capture-config.json`; eviction removes the oldest capture rows first in bounded batches. The 2026-10-17 owner decision keeps the P1 cap per shard; the machine-wide total is #15 T5. On lock contention a writer publishes a complete immutable per-writer spool segment and exits fast; the next uncontended writer replays the spool idempotently. The capture path makes no network or model call. The pre-T1 machine-wide `capture/` (an unreleased layout of this branch) is migrated per project on open and removed once empty.
|
|
219
237
|
|
|
238
|
+
Claude's `PreToolUse`, `PostToolUse` and `PostToolUseFailure` excerpts start with a deterministic tool-input line before the existing content. String values use this key order: `command`, `pattern`, `query`, `url`, `description`, `prompt`, `skill`, `subject`, then other string fields in sorted key order. Bulk `content`, `old_string`, `new_string` and `edits` are omitted. Private spans are removed before clipping; the line is at most 512 UTF-8 bytes and the combined tool excerpt is at most 2,000 bytes or the lower configured `excerptBytes` limit. Existing envelope, secret/PII and path screens remain in force; a refused tool excerpt drops the row. Legacy rows and non-tool/other-client shaping are unchanged.
|
|
239
|
+
|
|
240
|
+
Selected-project `store.search` and `amem_search` first reserve the existing unweighted result's non-capture cohort and capture slots. Only the capture slots are refilled: `Stop` and `UserPromptSubmit` receive a fixed 1.25x ordering weight, and only the best matching row per existing valid session hash is kept. A row without a hash is its own group. If distinct sessions run out, unused capture slots are omitted, yielding a shorter dense result while retaining the curated cohort and its relative order. Returned scores and explanations retain their unweighted priority values; the capture-slot ordering is separate. Collapsed rows remain accessible through timeline/show. The standalone `captureSearch` uses weighted session representatives too; `--all-projects` retains its existing separate merge. The SessionStart brief continues to use chronological capture pages, not this ranking or session collapse.
|
|
241
|
+
|
|
220
242
|
Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and the main checkout of one repository share its key; two unrelated same-named repositories without a remote get distinct path-hash keys, flagged `local` in the shard's `project.json` and the catalog. A no-remote key hashes the main working root (using the actual top-level for a separate git dir), so keys written from the repository root keep their pre-#15 value. Known limit: a linked worktree of a no-remote separate-git-dir repository gets its own key, because git does not record that repository's main working root (probe: `alpha` and `alpha/sub` share `alpha@<hash>`, `alpha-wt` gets `alpha-wt@<hash>`); the key stays as is for root-key compatibility, and a reviewed alias reconciles its capture. Keys written by older builds from a subdirectory or worktree still used that invocation's cwd hash; an explicitly written alias maps such a key to its canonical one for capture, work lists and the CLI and MCP project binding. Curated rows written under the old key keep their key in the log (nothing is rewritten), and the CLI and MCP read them under the canonical project: the project filter of notes, goals, candidates, vault candidates and work lists covers the canonical key and every key aliased to it, and presents those rows under the canonical key (#27). The library `MemoryStore` still reads by the raw key unless a host passes `reconcile`, and shared rows stay visible as before. Writes still go to the key's own log and shard, and a settle or forget of an aliased row lands in the shard that holds it. `amem alias add <from-key> <to-key>` appends an op to `aliases.jsonl` (latest wins, never chained, never automatic), `amem alias remove <from-key>` appends a removal (the old key reads under itself again, the history stays), `amem alias list` shows the ops including removals. Both append an alias operation without a general TTY gate. An amem or hub older than this change ignores a removal and keeps the old binding until it is upgraded, so upgrade every host that shares the home first. A correction accepted on an aliased row lives under the canonical key, so after a removal the old key shows the superseded note again (see `docs/contracts.md`). `catalog.db` in the memory home (#15 D3's storage part) is derived only from the shards and the alias log and rebuilds to the same rows; the D3 management commands are #15 T5 and not built.
|
|
221
243
|
|
|
222
244
|
`store.search(query, filter)` searches the curated tier and the current project's capture shard with SQLite FTS5 and BM25 plus tier/recency weighting: natural multi-word queries match partial term sets (the substring filter required every word), and a curated row outranks the same text in the capture tier. Prefix terms match inflections and Korean particles in either direction by retaining the original query term and adding a common-particle-stripped alternative. The FTS copy of the curated rows lives in its own derived `search.db` (`index.db`'s table set is a frozen contract and gains nothing); it is written from the same apply path as the index, and a prune, crash or rebuild is detected and resynced, so the incremental FTS copy equals a full resync. Where FTS5 is unavailable the engine falls back to the substring filter and reports `engine: "substring"`; capture recall needs FTS5 and is skipped in the fallback. The library entry exports `CaptureStore`, `captureSearch`, `readCaptureConfig`, `writeCaptureConfig`, `SearchIndex`, `fts5Available`, the shard helpers (`resolveAlias`, `appendAlias`, `listAliases`, `shardKeyHash`), the catalog (`rebuildCatalog`, `readCatalog`, `scanCatalog`) and the capacity advisor (`capacityReport`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"bun": ">=1.4.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
+
"@staix/agent-console": "github:STAIxBWLB/agent-console#952e962",
|
|
15
16
|
"@types/bun": "^1.3.0",
|
|
16
17
|
"typescript": "^5.8.0",
|
|
17
18
|
"@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
|
|
@@ -20,11 +21,13 @@
|
|
|
20
21
|
"@staix/agent-mem-v011": "npm:@staix/agent-mem@0.11.0",
|
|
21
22
|
"@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0",
|
|
22
23
|
"@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
|
|
23
|
-
"@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0"
|
|
24
|
+
"@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0",
|
|
25
|
+
"@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0"
|
|
24
26
|
},
|
|
25
27
|
"exports": {
|
|
26
28
|
".": "./src/index.ts",
|
|
27
|
-
"./platform": "./src/memory/platform.ts"
|
|
29
|
+
"./platform": "./src/memory/platform.ts",
|
|
30
|
+
"./ui-plugin": "./src/ui/plugin.ts"
|
|
28
31
|
},
|
|
29
32
|
"bin": {
|
|
30
33
|
"amem": "bin/amem.js"
|
package/src/cli.ts
CHANGED
|
@@ -271,7 +271,12 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
271
271
|
const { definition, options, rawArgs: args } = parsed;
|
|
272
272
|
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
273
273
|
if (parsed.path[0] === "setup") {
|
|
274
|
-
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all [--apply|--undo]");
|
|
274
|
+
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--apply|--undo]");
|
|
275
|
+
if (parsed.positionals[0] === "console") {
|
|
276
|
+
const { consoleSetup, consoleSetupText } = await import("./setup-console.ts");
|
|
277
|
+
const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", result = consoleSetup(mode, context.env ?? process.env);
|
|
278
|
+
return options["--json"] === true ? JSON.stringify(result, null, 2) : consoleSetupText(result, mode);
|
|
279
|
+
}
|
|
275
280
|
const { runSetup } = await import("./setup.ts");
|
|
276
281
|
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
277
282
|
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
|
package/src/commands-table.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
|
25
25
|
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
26
26
|
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
27
27
|
consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after explicit acceptance", 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 setup and supported capture hooks for native coding CLIs; apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all"] }] },
|
|
28
|
+
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs, or the acon console plugin manifest (console); apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
|
|
29
29
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
30
30
|
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
31
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", "qwen"] }] },
|
package/src/hook-spool.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openS
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
|
|
6
|
-
import { normalizeCaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
6
|
+
import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, 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
9
|
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
@@ -13,15 +13,18 @@ import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
|
|
|
13
13
|
import { projectState } from "./memory/lifecycle.ts";
|
|
14
14
|
import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// #199: v1 records (closed key set, no format field) are read, never written. Every 0.14-0.17 decoder deletes a record it cannot verify,
|
|
17
|
+
// so a record that adds a field publishes under a name the v1 pattern does not match (`hook-v2-`); old versions then ignore it
|
|
18
|
+
// (they still count it toward their file and byte caps). A later format that adds fields takes a new prefix again.
|
|
19
|
+
const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-";
|
|
17
20
|
const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
|
|
18
21
|
const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
|
|
19
22
|
// ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
|
|
20
23
|
// Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
|
|
21
24
|
const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
|
|
22
|
-
const namePattern = /^(hook-([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
25
|
+
const namePattern = /^(hook-(?:v2-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
23
26
|
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
24
|
-
interface Record { schema:
|
|
27
|
+
interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
|
|
25
28
|
export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
|
|
26
29
|
export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
|
|
27
30
|
interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true }
|
|
@@ -264,17 +267,21 @@ function claimOwned(base: string, name: string, file: OwnedFile): OwnedFile | un
|
|
|
264
267
|
const owned = { ...file, file: claim };
|
|
265
268
|
return owns(owned) ? owned : undefined;
|
|
266
269
|
}
|
|
267
|
-
|
|
270
|
+
/** `unknown`: a valid envelope (cwd, nonce, scope) under an unknown or newer schema. It stays pending until the TTL, never read as corrupt. */
|
|
271
|
+
function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: true } | undefined {
|
|
268
272
|
if (file.initial.size > RECORD_BYTES) return undefined; // Positively oversized: do not materialize its body.
|
|
269
273
|
const bytes = Buffer.alloc(file.initial.size);
|
|
270
274
|
if (readSync(file.fd, bytes, 0, bytes.length, 0) !== bytes.length || !owns(file)) throw new Error("hook record changed");
|
|
271
275
|
try {
|
|
272
276
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
|
|
273
|
-
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
274
|
-
|| Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture"].includes(key))
|
|
275
|
-
|| value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))
|
|
276
|
-
|| value.schema !== SCHEMA || typeof value.cwd !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
|
|
277
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.cwd !== "string" || typeof value.nonce !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
|
|
277
278
|
|| value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
|
|
279
|
+
const v1 = value.schema === SCHEMA && !("format" in value), current = value.schema === FAMILY && value.format === FORMAT;
|
|
280
|
+
if (!v1 && !current) return typeof value.schema === "string" ? { value, bytes, unknown: true } : undefined;
|
|
281
|
+
const keys = current ? 1 : 0;
|
|
282
|
+
if (![5 + keys, 6 + keys].includes(Object.keys(value).length)
|
|
283
|
+
|| Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture", ...current ? ["format"] : []].includes(key))
|
|
284
|
+
|| value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))) return undefined;
|
|
278
285
|
const c = value.capture;
|
|
279
286
|
if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
|
|
280
287
|
|| Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
|
|
@@ -394,7 +401,11 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
|
|
|
394
401
|
const record = decoded(file);
|
|
395
402
|
const valid = record && hash(record.value.cwd) === match[2] && record.value.nonce === match[4] && (record.value.scope ?? "0".repeat(64)) === match[3];
|
|
396
403
|
let reason: CaptureDropReason | undefined;
|
|
397
|
-
if (valid) {
|
|
404
|
+
if (valid && record.unknown) {
|
|
405
|
+
// #199: a newer writer's record waits for a reader that knows it, then leaves with a counted reason, never at once.
|
|
406
|
+
if (Date.now() - row.modified < RETIRE_AFTER_MS) continue;
|
|
407
|
+
reason = "unknown_schema_expired";
|
|
408
|
+
} else if (valid) {
|
|
398
409
|
if (Date.now() - Date.parse(record.value.capture.at) < RETIRE_AFTER_MS) continue;
|
|
399
410
|
try { const cwd = lstatSync(record.value.cwd); if (!cwd.isDirectory() || cwd.isSymbolicLink()) reason = "cwd_removed"; }
|
|
400
411
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
|
|
@@ -465,19 +476,27 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
465
476
|
// 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
|
|
466
477
|
// staged under a tighter cap still verifies after the cap is raised or removed.
|
|
467
478
|
const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config; // the repository's .amem/config narrows here too
|
|
479
|
+
const toolEvent = event === "PreToolUse" || event === "PostToolUse" || event === "PostToolUseFailure";
|
|
480
|
+
const contentText = typeof content === "string" ? content : JSON.stringify(content) ?? "";
|
|
481
|
+
const excerpt = toolEvent
|
|
482
|
+
? clipCaptureUtf8(toolCaptureExcerpt(toolInputLine(tool), contentText), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT))
|
|
483
|
+
: contentText;
|
|
468
484
|
// #173: attribution. Only a fixed-width hash of the native session id is kept, never the id or transcript path;
|
|
469
485
|
// a non-empty agent_id marks a subagent (or in-process teammate) event. Both are read from the already-screened input.
|
|
470
486
|
const session = typeof value.session_id === "string" && value.session_id.length > 0 && value.session_id.length <= 256 ? hash(`amem-session\0${value.session_id}`).slice(0, 16) : undefined;
|
|
471
487
|
const subagent = typeof value.agent_id === "string" && value.agent_id.length > 0;
|
|
472
488
|
const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
|
|
473
489
|
...(session ? { session } : {}), ...(subagent ? { subagent: true } : {}),
|
|
474
|
-
...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt
|
|
490
|
+
...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt },
|
|
475
491
|
undefined,
|
|
476
|
-
(text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // the
|
|
492
|
+
(text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // keep the normalizer's screen as well as the full envelope and bounded tool text screens
|
|
477
493
|
{ excerptBytes: configured.excerptBytes, paths: configured.capturePaths, ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) });
|
|
494
|
+
// The unchanged normalizer screen and final whole-capture policy also cover the joined input/content.
|
|
495
|
+
// Enriched tool rows are refused rather than persisting the normalizer's redaction marker.
|
|
496
|
+
if (toolEvent && capture.excerpt === "[secret-screened]") return undefined;
|
|
478
497
|
if (!await policy.screen({ cwd, capture })) return undefined;
|
|
479
498
|
const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
|
|
480
|
-
const record: Record = { schema:
|
|
499
|
+
const record: Record = { schema: FAMILY, format: FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
|
|
481
500
|
if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
|
|
482
501
|
const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
|
|
483
502
|
let destination = dir, deferred = false, backlog = false;
|
|
@@ -506,7 +525,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
506
525
|
deferred = true;
|
|
507
526
|
}
|
|
508
527
|
}
|
|
509
|
-
const name =
|
|
528
|
+
const name = `${PREFIX}${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
|
|
510
529
|
try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
|
|
511
530
|
finally { rmSync(temporary, { force: true }); }
|
|
512
531
|
return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
|
|
@@ -548,6 +567,14 @@ export async function drainHookCaptures(options: Options & { project: string; ex
|
|
|
548
567
|
if (!record || record.value.cwd !== cwd || record.value.scope !== null && record.value.scope !== stamp || record.value.nonce !== match[4]) {
|
|
549
568
|
retireClaim(base, claim, false); blocked++; continue;
|
|
550
569
|
}
|
|
570
|
+
// #199: an unknown or newer schema stays claimed and pending (later passes reclaim it) until the TTL, then retires with a counted reason.
|
|
571
|
+
// ponytail: pending unknown records still take drain slots oldest first; a writer that adds fields uses a new prefix, so this is the fallback only.
|
|
572
|
+
if (record.unknown) {
|
|
573
|
+
if (Date.now() - row.modified < RETIRE_AFTER_MS) { blocked++; continue; }
|
|
574
|
+
appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "unknown_schema_expired" }]);
|
|
575
|
+
if (!retireClaim(base, claim, false)) blocked++;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
551
578
|
const value = record.value, c = value.capture;
|
|
552
579
|
if (value.scope === null) { blocked++; continue; }
|
|
553
580
|
// #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:
|
package/src/mcp-memory.ts
CHANGED
|
@@ -201,11 +201,12 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
201
201
|
try {
|
|
202
202
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
203
203
|
const captureState: { engine?: "fts5" | "substring" } = {};
|
|
204
|
-
let batch: { body: string; ref: string; tier: string; at: string; value: SearchValue; expiresAt?: string | null }[] = [];
|
|
204
|
+
let batch: { body: string; ref: string; tier: string; at: string; value: SearchValue; expiresAt?: string | null; event?: string; session?: string }[] = [];
|
|
205
205
|
const append = (value: SearchValue) => {
|
|
206
206
|
const row = value.source === "capture" ? value.capture : value.view;
|
|
207
207
|
batch.push(value.source === "capture"
|
|
208
|
-
? { value, ref: `capture:${value.capture.id}`, at: row.at, tier: "capture", body: `${value.capture.excerpt}\n${value.capture.paths.join(" ")}\n${value.capture.event}
|
|
208
|
+
? { value, ref: `capture:${value.capture.id}`, at: row.at, tier: "capture", body: `${value.capture.excerpt}\n${value.capture.paths.join(" ")}\n${value.capture.event}`,
|
|
209
|
+
event: value.capture.event, ...(typeof value.capture.session === "string" && /^[a-f0-9]{16}$/.test(value.capture.session) ? { session: value.capture.session } : {}) }
|
|
209
210
|
: { value, ref: value.source === "repo" ? `repo:${value.view.id}` : value.view.id, at: row.at, tier: value.source === "repo" ? "long" : value.view.tier, body: `${value.view.title ?? ""}\n${value.view.text}\n${value.view.about.join(" ")}`, expiresAt: value.source === "curated" ? value.view.expiresAt : null });
|
|
210
211
|
if (batch.length === READ_PAGE) { index.append(batch); batch = []; }
|
|
211
212
|
};
|
|
@@ -223,7 +224,16 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
223
224
|
const repoNotes = (await repoRows()).filter(view => (!since || Date.parse(view.at) >= Date.parse(since)) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
224
225
|
for (const view of repoNotes) append({ source: "repo", evidence: false, view });
|
|
225
226
|
if (batch.length) index.append(batch);
|
|
226
|
-
const
|
|
227
|
+
const now = store.readNow(), baseline = index.best(query, limit, now, useFts, args.explain === true);
|
|
228
|
+
const captureSlots = baseline.filter(hit => hit.value.source === "capture").length;
|
|
229
|
+
const weightedCapture = captureSlots ? index.bestCaptureGroups(query, captureSlots, now, useFts, args.explain === true) : [];
|
|
230
|
+
let captureIndex = 0;
|
|
231
|
+
// Keep every baseline noncapture slot and position. Only its capture slots receive weighted,
|
|
232
|
+
// one-per-session rows from the separately screened TEMP corpus; exhausted slots are omitted.
|
|
233
|
+
const selected = baseline.flatMap(hit => {
|
|
234
|
+
if (hit.value.source !== "capture") return [hit];
|
|
235
|
+
const replacement = weightedCapture[captureIndex++]; return replacement ? [replacement] : [];
|
|
236
|
+
}).map(({ value, score, scoreDetails }) => {
|
|
227
237
|
const hit = { ...value, score };
|
|
228
238
|
return scoreDetails ? { ...hit, scoreDetails: { source: value.source, ...scoreDetails } } : hit;
|
|
229
239
|
});
|
package/src/memory/candidates.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type CandidateInput = { project: string } & (
|
|
|
38
38
|
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
39
39
|
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
40
40
|
export interface ConsolidationReview { status: Exclude<CandidateStatus, "pending">; acceptedNoteId: string | null }
|
|
41
|
+
const KINDS: readonly string[] = ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] satisfies CandidateInput["kind"][];
|
|
41
42
|
export interface CandidateFilter { project: string; status?: CandidateStatus | "all"; kind?: CandidateInput["kind"]; limit?: number }
|
|
42
43
|
export interface CounterKey { entryId: string; project: string; task?: string | null; turn?: string | null; session?: string | null; deliveryId: string }
|
|
43
44
|
export interface CounterValues { rendered?: number | null; delivered?: number | null; cited?: number | null }
|
|
@@ -187,8 +188,12 @@ export class CandidateStore {
|
|
|
187
188
|
this.db.query("DELETE FROM counters WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM counters) WHERE rank > ?)").run(COUNTER_RETENTION.count);
|
|
188
189
|
}).immediate();
|
|
189
190
|
}
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
/** #199: rows of a kind this version does not know (a newer writer's) are skipped and counted here, never thrown. */
|
|
192
|
+
skippedUnknownKinds = 0;
|
|
193
|
+
private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate | undefined {
|
|
194
|
+
const input = JSON.parse(row.payload) as CandidateInput;
|
|
195
|
+
if (input && typeof input === "object" && !KINDS.includes(input.kind)) { this.skippedUnknownKinds++; return undefined; }
|
|
196
|
+
validate(input);
|
|
192
197
|
if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
|
|
193
198
|
return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
|
|
194
199
|
}
|
|
@@ -230,14 +235,16 @@ export class CandidateStore {
|
|
|
230
235
|
return row ? { status: row.status, acceptedNoteId: row.note_id } : undefined;
|
|
231
236
|
}
|
|
232
237
|
list(filter: CandidateFilter): Candidate[] {
|
|
233
|
-
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !
|
|
238
|
+
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !KINDS.includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
234
239
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
235
240
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
236
241
|
this.prune();
|
|
237
242
|
const where = ["project IN (SELECT value FROM json_each(?))"], bind: (string | number)[] = [this.scope(filter.project)];
|
|
238
243
|
if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
|
|
239
244
|
if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
|
|
240
|
-
|
|
245
|
+
// #199: unknown kinds are skipped before LIMIT, so a newer writer's rows never displace a supported candidate; they are still counted.
|
|
246
|
+
this.skippedUnknownKinds += (this.db.query(`SELECT count(*) AS n FROM candidates WHERE ${where.join(" AND ")} AND kind NOT IN (SELECT value FROM json_each(?))`).get(...bind, JSON.stringify(KINDS)) as { n: number }).n;
|
|
247
|
+
return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} AND kind IN (SELECT value FROM json_each(?)) ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, JSON.stringify(KINDS), limit) as Parameters<CandidateStore["decode"]>[0][]).flatMap(row => this.decode(row) ?? []);
|
|
241
248
|
}
|
|
242
249
|
recordCounter(key: CounterKey, values: CounterValues, verdict: CandidateVerdict): string {
|
|
243
250
|
publicOnly(verdict); const normalized = counterKey(key);
|
|
@@ -264,6 +271,38 @@ export class CandidateStore {
|
|
|
264
271
|
if (candidate.input.kind === "consolidation") this.retainConsolidationReviews();
|
|
265
272
|
}).immediate();
|
|
266
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Undo of a reject: the rejected candidate is pending again. Only a rejected one; an accepted one is undone by forgetting its note.
|
|
276
|
+
* Retention deletes a decided candidate older than 30 days on the next open, so a caller offering undo passes the candidate it rejected as
|
|
277
|
+
* `restore`: when the row is already gone it is written back as pending with its original time, after the same identity and hash checks as a read.
|
|
278
|
+
*/
|
|
279
|
+
reopen(id: string, context: { project: string; actor: "person" | "agent"; restore?: Candidate }): void {
|
|
280
|
+
validActor(context.actor);
|
|
281
|
+
if (!candidateId(id) || !text(context.project, 400)) throw new Error("invalid candidate identity");
|
|
282
|
+
this.db.transaction(() => {
|
|
283
|
+
// No prune here: it would delete the very row this undoes.
|
|
284
|
+
const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(context.project)) as Parameters<CandidateStore["decode"]>[0] | null;
|
|
285
|
+
const candidate = row ? this.decode(row) : undefined, restore = context.restore;
|
|
286
|
+
if (candidate) {
|
|
287
|
+
if (candidate.status !== "rejected") throw new Error("only a rejected candidate can be reopened");
|
|
288
|
+
this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND status = 'rejected'").run(id);
|
|
289
|
+
} else {
|
|
290
|
+
if (!restore || restore.id !== id || !(JSON.parse(this.scope(context.project)) as string[]).includes(restore.input.project)) throw new Error("candidate unavailable");
|
|
291
|
+
validate(restore.input);
|
|
292
|
+
if (hash({ schema: "agent-mem.candidate/v1", input: restore.input }) !== id) throw new Error("candidate content hash mismatch");
|
|
293
|
+
const at = Date.parse(restore.createdAt);
|
|
294
|
+
if (!Number.isFinite(at)) throw new Error("invalid candidate time");
|
|
295
|
+
this.db.query("INSERT INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, restore.input.project, restore.input.kind, canonical(restore.input), at);
|
|
296
|
+
}
|
|
297
|
+
const input = candidate?.input ?? restore!.input;
|
|
298
|
+
// The lasting review identity would make a later proposal of the same consolidation a no-op; the decision is withdrawn.
|
|
299
|
+
if (input.kind === "consolidation") this.db.query("DELETE FROM consolidation_reviews WHERE id = ?").run(id);
|
|
300
|
+
}).immediate();
|
|
301
|
+
}
|
|
302
|
+
/** Projects with candidates waiting for review, as stored (an alias key is not resolved here). */
|
|
303
|
+
projects(): string[] {
|
|
304
|
+
return (this.db.query("SELECT DISTINCT project FROM candidates WHERE status IN ('pending', 'accepting') ORDER BY project").all() as { project: string }[]).map(row => row.project);
|
|
305
|
+
}
|
|
267
306
|
accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
268
307
|
validActor(context.actor); publicOnly(context.verdict);
|
|
269
308
|
if (realPath(context.store.profile.home) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
|
|
@@ -4,6 +4,9 @@ import { stripPrivateSpans } from "./private-spans.ts";
|
|
|
4
4
|
|
|
5
5
|
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
6
6
|
export const CAPTURE_MAX_PATHS = 32;
|
|
7
|
+
export const TOOL_INPUT_LINE_BYTES = 512;
|
|
8
|
+
const TOOL_INPUT_ORDER = ["command", "pattern", "query", "url", "description", "prompt", "skill", "subject"] as const;
|
|
9
|
+
const TOOL_INPUT_OMIT = new Set(["content", "old_string", "new_string", "edits"]);
|
|
7
10
|
const ROW_OVERHEAD_BYTES = 96;
|
|
8
11
|
/** The session key is a fixed-width hash prefix, so it adds a bounded amount covered by the row overhead. */
|
|
9
12
|
export const SESSION_KEY = /^[a-f0-9]{16}$/;
|
|
@@ -22,6 +25,37 @@ export interface NormalizedCaptureFields {
|
|
|
22
25
|
session?: string; subagent?: true;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** Clip a string at a UTF-8 boundary without splitting a code point. */
|
|
29
|
+
export function clipCaptureUtf8(text: string, maximumBytes: number): string {
|
|
30
|
+
let out = "", bytes = 0;
|
|
31
|
+
for (const character of text) {
|
|
32
|
+
const size = Buffer.byteLength(character);
|
|
33
|
+
if (bytes + size > maximumBytes) break;
|
|
34
|
+
out += character; bytes += size;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deterministic, value-only summary of Claude tool_input. Strip private spans before clipping. */
|
|
40
|
+
export function toolInputLine(input: unknown): string {
|
|
41
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return "";
|
|
42
|
+
const fields = Object.entries(input as Record<string, unknown>)
|
|
43
|
+
.filter(([key, value]) => typeof value === "string" && !TOOL_INPUT_OMIT.has(key.toLowerCase()) && value.length > 0);
|
|
44
|
+
const byKey = new Map(fields);
|
|
45
|
+
const known = TOOL_INPUT_ORDER.filter(key => byKey.has(key));
|
|
46
|
+
const other = fields.map(([key]) => key).filter(key => !(TOOL_INPUT_ORDER as readonly string[]).includes(key)).sort();
|
|
47
|
+
const values = [...known, ...other].map(key => clipCaptureUtf8(
|
|
48
|
+
stripPrivateSpans(byKey.get(key) as string).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " "), TOOL_INPUT_LINE_BYTES,
|
|
49
|
+
)).filter(Boolean);
|
|
50
|
+
return clipCaptureUtf8(values.join(" | "), TOOL_INPUT_LINE_BYTES).trimEnd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Remove private content before joining tool input and content into the persisted excerpt. */
|
|
54
|
+
export function toolCaptureExcerpt(inputLine: string, content: string): string {
|
|
55
|
+
const visibleContent = stripPrivateSpans(content);
|
|
56
|
+
return inputLine ? visibleContent ? `${inputLine}\n${visibleContent}` : inputLine : visibleContent;
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
/** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
|
|
26
60
|
/** The caps a person's configuration may lower (F4); the built-in caps are the ceiling, so a larger value is ignored. */
|
|
27
61
|
export interface CaptureCaps { excerptBytes?: number; paths?: number; denyPaths?: readonly string[] }
|
|
@@ -44,9 +44,10 @@ export function searchAllProjects(base: string, query: string, opts: { limit?: n
|
|
|
44
44
|
}
|
|
45
45
|
} finally { store?.close(); }
|
|
46
46
|
if (opts.includeCapture !== false) {
|
|
47
|
+
// #196 fixes selected-project store/MCP capture slots; this separate cross-project merge keeps its legacy cohort.
|
|
47
48
|
for (const dir of shard.machineDirs) {
|
|
48
49
|
const days = readCaptureConfig(dir, base).retentionDays, cutoff = new Date(now - days * 24 * 3600_000);
|
|
49
|
-
for (const { row, rank } of captureSearch(dir, query, Math.max(limit * 4, 32), Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined)) {
|
|
50
|
+
for (const { row, rank } of captureSearch(dir, query, Math.max(limit * 4, 32), Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined, { weightEvents: false, onePerSession: false })) {
|
|
50
51
|
hits.push({ source: "capture", score: -rank * 0.5, capture: row as CaptureRow, project: shard.key });
|
|
51
52
|
}
|
|
52
53
|
}
|
package/src/memory/doctor.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync,
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
-
import { sessionStartBrief } from "../session-brief.ts";
|
|
5
|
+
import { sessionStartBrief, type BriefOptions } from "../session-brief.ts";
|
|
6
|
+
import { openCuratedForRead } from "./curated-read.ts";
|
|
6
7
|
import { pendingSummary } from "./candidate-pending.ts";
|
|
7
8
|
import { readCaptureDrops } from "./metrics.ts";
|
|
8
9
|
import { walkShards } from "./catalog.ts";
|
|
@@ -12,7 +13,7 @@ import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
|
12
13
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
13
14
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
14
15
|
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
15
|
-
import { shardDir } from "./shard.ts";
|
|
16
|
+
import { aliasedKeys, shardDir } from "./shard.ts";
|
|
16
17
|
import type { MemoryStore, View } from "./store.ts";
|
|
17
18
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
18
19
|
|
|
@@ -93,7 +94,22 @@ function curatedPresent(home: string, project: string, layout: Layout): boolean
|
|
|
93
94
|
/** What a scratch copy leaves out: databases, locks, repositories, the machine-local capture and work trees, spools and ingest cursors. */
|
|
94
95
|
const SKIP = /\.db(-wal|-shm)?$|\.lock$|\.tmp$|^\.git$|\.git$|^spool$|^ingest$|^readers$/;
|
|
95
96
|
class TooBig extends Error {}
|
|
96
|
-
|
|
97
|
+
interface CopyBudget { bytes: number; verify?: (() => void)[] }
|
|
98
|
+
function observedCopy(from: string, to: string, budget: CopyBudget): void {
|
|
99
|
+
budget.bytes += statSync(from).size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
100
|
+
if (budget.verify) {
|
|
101
|
+
const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isFile()) throw Error("recap source is not a plain file"); return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`; };
|
|
102
|
+
const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source changed"); });
|
|
103
|
+
}
|
|
104
|
+
copyFileSync(from, to);
|
|
105
|
+
}
|
|
106
|
+
function observeDirectory(from: string, budget: CopyBudget, keep: (name: string) => boolean): void {
|
|
107
|
+
if (!budget.verify) return;
|
|
108
|
+
const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isDirectory()) throw Error("recap source directory changed"); return JSON.stringify([String(s.dev), String(s.ino), readdirSync(from).filter(keep).sort()]); };
|
|
109
|
+
const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source directory changed"); });
|
|
110
|
+
}
|
|
111
|
+
function copyTree(from: string, to: string, budget: CopyBudget, skipNames: ReadonlySet<string> = new Set()): void {
|
|
112
|
+
observeDirectory(from, budget, name => !(SKIP.test(name) && !(basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.test(name))) && !skipNames.has(name));
|
|
97
113
|
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
98
114
|
for (const name of readdirSync(from)) {
|
|
99
115
|
const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
|
|
@@ -102,25 +118,24 @@ function copyTree(from: string, to: string, budget: { bytes: number }, skipNames
|
|
|
102
118
|
if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
|
|
103
119
|
if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
|
|
104
120
|
if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
|
|
105
|
-
else if (stat.isFile())
|
|
121
|
+
else if (stat.isFile()) observedCopy(source, join(to, name), budget);
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
/** The curated side of the home, without any database: the logs of this project (and its aliases), and the small files that bind them. */
|
|
110
|
-
function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, budget:
|
|
126
|
+
function copyCurated(options: Pick<DoctorOptions, "home" | "project" | "aliases">, layout: Layout, scratch: string, budget: CopyBudget): void {
|
|
111
127
|
const home = options.home;
|
|
128
|
+
observeDirectory(home, budget, name => !SKIP.test(name) && regularFile(join(home, name)));
|
|
112
129
|
for (const name of readdirSync(home)) { // top-level plain files: machine.json, shards.json, profile.json, aliases.jsonl, lifecycle.jsonl, the configuration
|
|
113
130
|
if (SKIP.test(name) || !regularFile(join(home, name))) continue;
|
|
114
|
-
|
|
115
|
-
copyFileSync(join(home, name), join(scratch, name));
|
|
131
|
+
observedCopy(join(home, name), join(scratch, name), budget);
|
|
116
132
|
}
|
|
117
133
|
const selected = readSelector(home).selector;
|
|
118
134
|
if (selected?.schema === "agent-mem.profile/v2") {
|
|
119
135
|
const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
|
|
120
136
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
|
|
121
|
-
budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
122
137
|
const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
|
|
123
|
-
|
|
138
|
+
observedCopy(source, join(directory, name), budget); chmodSync(join(directory, name), 0o600);
|
|
124
139
|
}
|
|
125
140
|
if (layout === "split") {
|
|
126
141
|
for (const key of [options.project, ...options.aliases]) {
|
|
@@ -135,19 +150,56 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
|
|
|
135
150
|
}
|
|
136
151
|
|
|
137
152
|
/** The work logs of this project and its aliases, in a private base of their own: the brief opens a work list read-write and migrates its index. */
|
|
138
|
-
function copyWork(options: DoctorOptions, base: string, budget:
|
|
153
|
+
function copyWork(options: Pick<DoctorOptions, "home" | "project" | "aliases">, base: string, budget: CopyBudget): boolean {
|
|
139
154
|
let any = false;
|
|
140
|
-
for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
155
|
+
for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); observedCopy(join(options.home, name), join(base, name), budget); }
|
|
141
156
|
for (const key of [options.project, ...options.aliases]) {
|
|
142
157
|
const dir = shardDir(options.home, key), log = join(dir, "work", "log");
|
|
143
158
|
if (!regularDir(log)) continue;
|
|
144
159
|
copyTree(log, join(base, "projects", dir.split("/").pop()!, "work", "log"), budget);
|
|
145
|
-
if (regularFile(join(dir, "project.json")))
|
|
160
|
+
if (regularFile(join(dir, "project.json"))) observedCopy(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json"), budget);
|
|
146
161
|
any = true;
|
|
147
162
|
}
|
|
148
163
|
return any;
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
/** Reuse doctor's bounded private inputs for an on-demand brief, never a live store or reader lease. */
|
|
167
|
+
export async function withBriefSnapshot<T>(home: string, project: string, read: (inputs: NonNullable<BriefOptions["measureOnly"]>) => Promise<T>): Promise<T> {
|
|
168
|
+
const aliases = aliasedKeys(home, project), selection = readSelector(home).revision, layout = layoutOf(home), guard = guardScratch();
|
|
169
|
+
let scratch: string | undefined, curated: MemoryStore | undefined;
|
|
170
|
+
const budget: CopyBudget = { bytes: 0, verify: [] };
|
|
171
|
+
const inputDirectories = [join(home, "projects"), ...(layout === "split" ? [join(home, "user")] : [join(home, "log"), join(home, "local")]),
|
|
172
|
+
...(layout === "generation" ? [join(home, "generations")] : []),
|
|
173
|
+
...[project, ...aliases].flatMap(key => { const dir = shardDir(home, key); return [dir, join(dir, "work"), join(dir, "work", "log")]; })];
|
|
174
|
+
const checkDirectories = () => {
|
|
175
|
+
for (const dir of inputDirectories) { const stat = lstatSync(dir, { throwIfNoEntry: false }); if (stat && !stat.isDirectory()) throw Error("recap input is not a plain directory"); }
|
|
176
|
+
};
|
|
177
|
+
const check = () => {
|
|
178
|
+
checkDirectories();
|
|
179
|
+
if (readSelector(home).revision !== selection || layoutOf(home) !== layout || JSON.stringify(aliasedKeys(home, project)) !== JSON.stringify(aliases)) throw Error("recap binding changed");
|
|
180
|
+
for (const verify of budget.verify!) verify();
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
checkDirectories();
|
|
184
|
+
scratch = mkdtempSync(join(tmpdir(), `amem-doctor-${process.pid}-`)); chmodSync(scratch, 0o700); guard.adopt(scratch);
|
|
185
|
+
const options = { home, project, aliases }, workBase = join(scratch, "work-base"); mkdirSync(workBase, { mode: 0o700 });
|
|
186
|
+
copyWork(options, workBase, budget);
|
|
187
|
+
if (curatedPresent(home, project, layout)) {
|
|
188
|
+
const copy = join(scratch, "home"); mkdirSync(copy, { mode: 0o700 }); copyCurated(options, layout, copy, budget);
|
|
189
|
+
curated = openCuratedForRead(copy, project);
|
|
190
|
+
if (!curated) throw Error("recap curated snapshot unavailable");
|
|
191
|
+
}
|
|
192
|
+
check();
|
|
193
|
+
const result = await read({ ...(curated ? { curated } : {}), workBase });
|
|
194
|
+
check(); return result;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof TooBig) throw Error("recap snapshot exceeds the 64 MiB copy limit");
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { guard.release(); } }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
151
203
|
/** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
|
|
152
204
|
function snapshotIndex(live: string, to: string): boolean {
|
|
153
205
|
const file = join(live, "index.db");
|