@staix/agent-mem 0.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
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.17.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.18.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -153,7 +153,7 @@ Acceptance normalizes its note once, including timestamp, content ID and short-t
153
153
 
154
154
  ## Console (acon)
155
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, 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.
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
157
 
158
158
  ```sh
159
159
  amem setup console # preview
@@ -164,6 +164,7 @@ amem setup console --undo # remove it (only when it points at this install)
164
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
165
 
166
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.
167
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.
168
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.
169
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.
@@ -234,6 +235,10 @@ The JSONL segments are authoritative. `index.db` is derived and can be rebuilt.
234
235
 
235
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.
236
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
+
237
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.
238
243
 
239
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.17.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",
@@ -21,7 +21,8 @@
21
21
  "@staix/agent-mem-v011": "npm:@staix/agent-mem@0.11.0",
22
22
  "@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0",
23
23
  "@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
24
- "@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"
25
26
  },
26
27
  "exports": {
27
28
  ".": "./src/index.ts",
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
- const SCHEMA = "agent-mem.hook-capture/v1";
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: typeof SCHEMA; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
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
- function decoded(file: OwnedFile): { value: Record; bytes: Buffer } | undefined {
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) || ![5, 6].includes(Object.keys(value).length)
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: typeof content === "string" ? content : JSON.stringify(content) },
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 configured patterns redact the excerpt, as the store does
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: SCHEMA, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
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 = `hook-${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, 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 selected = index.best(query, limit, store.readNow(), useFts, args.explain === true).map(({ value, score, scoreDetails }) => {
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
  });
@@ -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
- private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
191
- const input = JSON.parse(row.payload) as CandidateInput; validate(input);
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 && !["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"].includes(filter.kind)) throw new Error("invalid candidate filter");
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
- return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, limit) as Parameters<CandidateStore["decode"]>[0][]).map(row => this.decode(row));
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);
@@ -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
  }
@@ -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
- function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
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()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
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: { bytes: number }): void {
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
- const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
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
- copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
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: { bytes: number }): boolean {
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 }); copyFileSync(join(options.home, name), join(base, name)); }
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"))) copyFileSync(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "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");
@@ -36,8 +36,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
36
36
  }
37
37
 
38
38
 
39
- export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
40
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
39
+ export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired";
40
+ const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
41
41
  /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
42
42
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
43
  const read = readCaptureDrops(base);
@@ -1,4 +1,4 @@
1
- import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
1
+ import { captureEventWeightSql, rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { chmodSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -204,8 +204,8 @@ export class ScreenedSearchIndex<T> {
204
204
  if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
205
205
  db.run("PRAGMA temp_store = FILE");
206
206
  db.run(this.available
207
- ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
208
- : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
207
+ ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED, event UNINDEXED, session UNINDEXED)"
208
+ : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL, event TEXT, session TEXT)");
209
209
  db.run("PRAGMA temp.cache_size = -2048");
210
210
  // A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
211
211
  // Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
@@ -214,13 +214,13 @@ export class ScreenedSearchIndex<T> {
214
214
  } catch (error) { db?.close(); throw error; }
215
215
  }
216
216
 
217
- append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
218
- const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
217
+ append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null; event?: string; session?: string }[]): void {
218
+ const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires, event, session) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
219
219
  this.db.transaction(() => {
220
220
  for (const row of rows) {
221
221
  const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
222
222
  // Invalid deadlines fail closed; this sentinel predates every representable Date.
223
- insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
223
+ insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline, row.event ?? null, row.session ?? null);
224
224
  }
225
225
  })();
226
226
  }
@@ -242,6 +242,31 @@ export class ScreenedSearchIndex<T> {
242
242
  });
243
243
  }
244
244
 
245
+ /** Weighted capture-only fill after the caller fixes the legacy noncapture slots. The caller has already screened every row before append. */
246
+ bestCaptureGroups(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
247
+ if (!Number.isSafeInteger(limit) || limit < 1 || useFts && !ftsTerms(query)) return [];
248
+ const instant = new Date(now).toISOString(), score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
249
+ const weight = captureEventWeightSql("event");
250
+ const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
251
+ const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
252
+ const where = "WHERE tier = 'capture' AND (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
253
+ const selected: { value: T; score: number; scoreDetails?: ScoreFactors }[] = [], groups = new Set<string>();
254
+ // Sort once in SQLite-owned TEMP storage, then stream past duplicate sessions without growing JS pages or re-sorting OFFSET windows.
255
+ const statement = this.db.query(`SELECT payload, ref, event, session, ${score} AS score${columns} FROM docs ${where} ORDER BY score * ${weight} DESC, ref`);
256
+ const orderedArgs = [instant, ...(explain ? [instant] : []), now, ...(useFts ? [ftsTerms(query)] : [])];
257
+ for (const row of statement.iterate(...orderedArgs) as Iterable<{ payload: string; ref: string; event: string | null; session: string | null; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }>) {
258
+ const session = typeof row.session === "string" && /^[a-f0-9]{16}$/.test(row.session) ? row.session : undefined;
259
+ const group = session ? `session:${session}` : `row:${row.ref}`;
260
+ if (groups.has(group)) continue;
261
+ groups.add(group);
262
+ const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
263
+ if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
264
+ selected.push(hit);
265
+ if (selected.length === limit) break;
266
+ }
267
+ return selected;
268
+ }
269
+
245
270
  close(): void {
246
271
  if (this.closed) return;
247
272
  this.closed = true;
@@ -250,6 +275,7 @@ export class ScreenedSearchIndex<T> {
250
275
  }
251
276
 
252
277
  interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; session?: string | null; subagent?: number | null }
278
+ export interface CaptureSearchOptions { weightEvents?: boolean; onePerSession?: boolean; now?: number }
253
279
 
254
280
  /**
255
281
  * The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
@@ -257,21 +283,43 @@ interface RawCaptureRow { id: number; at: string; project: string; agent: string
257
283
  * Rows older than `notBefore` (an ISO time) are left out; the store passes the shard's retention cutoff, so an expired
258
284
  * row is not recalled before a later append evicts it.
259
285
  */
260
- export function captureSearch(dir: string, q: string, limit: number, notBefore?: string): { row: CaptureRow; rank: number }[] {
286
+ export function captureSearch(dir: string, q: string, limit: number, notBefore?: string, options: CaptureSearchOptions = {}): { row: CaptureRow; rank: number }[] {
261
287
  const match = ftsTerms(q);
262
- if (!match || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
288
+ if (!match || !Number.isSafeInteger(limit) || limit < 1 || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
289
+ const weightEvents = options.weightEvents !== false, onePerSession = options.onePerSession ?? weightEvents;
290
+ const eventWeight = captureEventWeightSql("r.event");
291
+ const weightedOrder = `bm25(fts) * ${eventWeight}, r.id`;
292
+ const selected: { row: CaptureRow; rank: number }[] = [], groups = new Set<string>();
263
293
  let db: Database | undefined;
264
294
  try {
265
295
  db = new Database(join(dir, "capture.db"), { readonly: true });
266
296
  db.run("PRAGMA busy_timeout = 3000");
267
297
  if (!db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get()) return [];
268
- // `notBefore` (retention cutoff, `since`) runs inside the query: rows past it never take a LIMIT slot.
269
- const rows = (notBefore === undefined
270
- ? db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? ORDER BY rank LIMIT ?`).all(match, limit)
271
- : db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? AND r.at >= ? ORDER BY rank LIMIT ?`).all(match, notBefore, limit)) as (RawCaptureRow & { rank: number })[];
272
- return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
273
- // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
274
- ...(typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? { session: r.session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) }, rank: r.rank }));
298
+ // Filter first, then sort once. In the grouped path the first row of each session is
299
+ // its best complete-score match, so stopping at limit cannot hide a better unread row.
300
+ const matchWhere = notBefore === undefined ? "fts MATCH ?" : "fts MATCH ? AND r.at >= ?";
301
+ const args: (string | number)[] = notBefore === undefined ? [match] : [match, notBefore];
302
+ const order = onePerSession
303
+ ? `${rankedScoreSql("bm25(fts)")} * ${weightEvents ? eventWeight : "1.0"} DESC, r.id`
304
+ : weightEvents ? weightedOrder : "rank";
305
+ // The capture tier is an existing score input, projected only for the shared SQL recipe.
306
+ const sql = `SELECT r.*, ${onePerSession ? "'capture' AS tier, " : ""}bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE ${matchWhere} ORDER BY ${order}${onePerSession ? "" : " LIMIT ?"}`;
307
+ args.push(onePerSession ? new Date(options.now ?? Date.now()).toISOString() : limit);
308
+ const statement = db.query(sql);
309
+ const rows = (onePerSession ? statement.iterate(...args) : statement.all(...args)) as Iterable<RawCaptureRow & { rank: number }>;
310
+ for (const r of rows) {
311
+ const session = typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? r.session : undefined;
312
+ if (onePerSession && session) {
313
+ if (groups.has(session)) continue;
314
+ groups.add(session);
315
+ }
316
+ const row: CaptureRow = { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
317
+ // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
318
+ ...(session ? { session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) };
319
+ selected.push({ row, rank: r.rank });
320
+ if (selected.length === limit) break;
321
+ }
322
+ return selected;
275
323
  } catch { return []; }
276
324
  finally { db?.close(); }
277
325
  }
@@ -14,6 +14,16 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
14
14
  const MONTH_DAYS = 30;
15
15
  const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
16
16
  const DEFAULT_TIER_WEIGHT = 0.9;
17
+ // ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
18
+ // Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
19
+ export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
20
+ export function captureEventWeight(event: string): number {
21
+ return event === "Stop" || event === "UserPromptSubmit" ? CAPTURE_CONVERSATION_WEIGHT : 1;
22
+ }
23
+ /** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
24
+ export function captureEventWeightSql(eventColumn: string): string {
25
+ return `(CASE WHEN ${eventColumn} IN ('Stop', 'UserPromptSubmit') THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
26
+ }
17
27
  const TIER_FACTOR: ScoreExpr = { op: "tier" };
18
28
  const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
19
29
  const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
@@ -53,6 +63,9 @@ function sql(expr: ScoreExpr, rank: string): string {
53
63
  export function rankedScore(rank: number, tier: string, at: string, now: number): number {
54
64
  return numeric(SCORE, rank, tier, at, now);
55
65
  }
66
+ export function captureWeightedScore(rank: number, event: string, at: string, now: number): number {
67
+ return rankedScore(rank, "capture", at, now) * captureEventWeight(event);
68
+ }
56
69
  /** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
57
70
  export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
58
71
  export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
@@ -1094,6 +1094,7 @@ export class MemoryStore {
1094
1094
  for (const store of this.readStores()) store.syncSearchIndex();
1095
1095
  const now = this.now();
1096
1096
  const hits: SearchHit[] = [];
1097
+ let captureShard: string | undefined, captureNotBefore: string | undefined;
1097
1098
  // The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
1098
1099
  // starve this project's LIMIT.
1099
1100
  // Superseded, forgotten, expired and `since` rows are filtered after FTS, so a fixed candidate cap could be filled
@@ -1122,13 +1123,17 @@ export class MemoryStore {
1122
1123
  // #15 D4/T1: the capture tier is project-scoped; without a project key there is no shard to recall from.
1123
1124
  if (f.includeCapture !== false && f.project) {
1124
1125
  const shard = shardMachineDir(this.memoryBaseDir, resolveAlias(this.memoryBaseDir, f.project), this.machine.id);
1126
+ captureShard = shard;
1125
1127
  // The retention cutoff and `since` run in the capture query, so neither expired nor old rows take a candidate slot.
1126
1128
  // An out-of-range retention_days gives no cutoff rather than failing the whole search (eviction keeps its own check).
1127
1129
  const override = f.captureRetentionDays, days = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : readCaptureConfig(shard, this.memoryBaseDir).retentionDays;
1128
1130
  const cutoff = new Date(now - days * 24 * 3600_000);
1129
1131
  const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
1130
1132
  const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
1131
- for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
1133
+ captureNotBefore = since && (!retained || since > retained) ? since : retained;
1134
+ // First reproduce the old unweighted BM25 candidate window. Only these baseline capture slots may change;
1135
+ // curated membership and positions are not affected by conversational weighting or session collapse.
1136
+ for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), captureNotBefore, { weightEvents: false, onePerSession: false })) {
1132
1137
  const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
1133
1138
  if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
1134
1139
  hits.push(selected);
@@ -1144,7 +1149,23 @@ export class MemoryStore {
1144
1149
  }
1145
1150
  const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
1146
1151
  hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
1147
- return { engine: "fts5", hits: hits.slice(0, limit) };
1152
+ const baseline = hits.slice(0, limit), captureSlots = baseline.filter(hit => hit.source === "capture").length;
1153
+ if (!captureSlots || !captureShard) return { engine: "fts5", hits: baseline };
1154
+ // Stream session representatives in complete weighted-score order; the legacy
1155
+ // baseline above still owns noncapture slots and exposed scores remain unweighted.
1156
+ const weighted = captureSearch(captureShard, q, captureSlots, captureNotBefore,
1157
+ { weightEvents: true, onePerSession: true, now });
1158
+ const replacements = weighted.map(({ row, rank }) => {
1159
+ const score = rankedScore(rank, "capture", row.at, now), hit: SearchHit = { source: "capture", score, capture: row };
1160
+ if (f.explain) hit.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, score);
1161
+ return hit;
1162
+ });
1163
+ let nextCapture = 0;
1164
+ const selected = baseline.flatMap(hit => {
1165
+ if (hit.source !== "capture") return [hit];
1166
+ const replacement = replacements[nextCapture++]; return replacement ? [replacement] : [];
1167
+ });
1168
+ return { engine: "fts5", hits: selected };
1148
1169
  }
1149
1170
 
1150
1171
  /** Bring the derived FTS copy level with the curated rows when a prune, a crash, a lock or a rebuild drifted them apart. */
@@ -17,6 +17,8 @@ import type { MemoryStore, View } from "./memory/store.ts";
17
17
  import { WorkListStore, type WorkListFields, type WorkListView } from "./memory/work-lists.ts";
18
18
 
19
19
  export interface BriefOptions { cwd: string; base?: string; project?: string; maxTokens?: number; policy?: StandalonePolicyOptions;
20
+ /** Repository-less hosts use only machine policy and omit the repo section, even if their home lies inside a repository. */
21
+ repository?: false;
20
22
  /** Host-only monotonic budget probe. The native hook still owns the hard child deadline. */
21
23
  timeRemaining?: () => number;
22
24
  /** Host-only parent CLI path: precomputed owned process identity and zero-wait stores. */
@@ -26,7 +28,7 @@ export interface BriefOptions { cwd: string; base?: string; project?: string; ma
26
28
  /** Test seam for the pending-candidate line's clock. */
27
29
  now?: () => number;
28
30
  /**
29
- * `amem doctor` only (F15): size the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
31
+ * Read-only diagnostic/console projection: build the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
30
32
  * open a store read-write: `curated` is a store opened on a private copy of the logs (absent: the project has no curated notes, an empty complete section) and
31
33
  * `workBase` is a private base holding a copy of the project's and its aliases' work logs (absent: the work section is `unavailable`, never claimed complete).
32
34
  * The capture section opens this machine's capture database read-only, and only when it exists.
@@ -104,7 +106,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
104
106
  try {
105
107
  if (options.nativeSession && typeof options.timeRemaining !== "function") return result;
106
108
  const cwdForConfig = options.cwd, baseForConfig = canonicalPath(options.base ?? memoryHome());
107
- const configuredBrief = loadAmemConfig(baseForConfig, repoConfigRoot(cwdForConfig)).config.briefBytes;
109
+ const configuredBrief = loadAmemConfig(baseForConfig, options.repository === false ? undefined : repoConfigRoot(cwdForConfig)).config.briefBytes;
108
110
  // A caller's explicit budget wins over the default; the person's configured budget can only lower either.
109
111
  const maximum = Math.min(options.maxTokens ?? DEFAULT_TOKENS, configuredBrief ?? Number.MAX_SAFE_INTEGER);
110
112
  if (!Number.isSafeInteger(maximum) || maximum < 1) return result;
@@ -112,7 +114,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
112
114
  const requested = options.project ?? memoryProjectKey(cwd, { repositoryIsolation: true }), project = resolveAlias(base, requested);
113
115
  // #15 T5: a project the person archived or forgot yields no brief at all, before any lease, store or screen is opened.
114
116
  if (projectState(base, project) !== "active") return result;
115
- const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
117
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, options.repository === false ? undefined : cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
116
118
  closers.push(release);
117
119
  const selection = readSelector(base).revision;
118
120
  const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
@@ -138,7 +140,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
138
140
  const coverage: BriefResult["coverage"] = { work: "complete", curated: "complete", capture: "complete" };
139
141
  // #15 T4: the bound repository's `.amem/log.jsonl`, only when the repository IS this project (AC3). The `lstat` probe comes FIRST and on every path (the SessionStart
140
142
  // worker builds this brief without `nativeSession`): a repository with no `.amem/log.jsonl` at or above the cwd costs a few syscalls and no git process.
141
- const repoRoot = !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
143
+ const repoRoot = options.repository === false || !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
142
144
  if (repoLogPresent(repoRoot)) coverage.repo = "complete";
143
145
  const lines: string[] = [];
144
146
  const reasons: Partial<Record<Section, string>> = {};
@@ -179,7 +181,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
179
181
  await run("work", async () => {
180
182
  const measured = options.measureOnly;
181
183
  if (measured && !measured.workBase) { coverage.work = "unavailable"; return; }
182
- if (measured && !lstatSync(join(shardDir(measured.workBase!, project), "work", "log"), { throwIfNoEntry: false })) return; // no work log: the real brief shows an empty list
184
+ if (measured && ![project, ...aliases].some(key => lstatSync(join(shardDir(measured.workBase!, key), "work", "log"), { throwIfNoEntry: false }))) return; // no work log: the real brief shows an empty list
183
185
  const work = new WorkListStore({ base: measured?.workBase ?? base, project, aliases, policy: screen, ...(options.nativeSession ? { busyTimeoutMs: 0, timeRemaining: options.timeRemaining } : {}) }); closers.push(() => work.close());
184
186
  // Complete operation rows AND the composed view are screened before closed-item filtering or display caps.
185
187
  const views = await work.read(undefined, true); guard();
package/src/ui/panel.js CHANGED
@@ -109,6 +109,31 @@ export default (host) => {
109
109
  project ? btn("Forget this project...", () => act({ action: "project_forget" }), "btn danger") : null)));
110
110
  return out;
111
111
  },
112
+ recap(s) {
113
+ const brief = s.brief;
114
+ if (!brief || typeof brief.context !== "string") return [ui.empty("Recap unavailable", "The current SessionStart brief could not be computed.")];
115
+ const coverage = brief.coverage ?? {};
116
+ const sections = ["work", "curated", "capture", "repo"].filter((key) => Object.hasOwn(coverage, key));
117
+ const rows = sections.map((key) => {
118
+ const value = coverage[key];
119
+ const detail = value && typeof value === "object" ? JSON.stringify(value) : String(value);
120
+ return { title: key, sub: line(detail, 180) };
121
+ });
122
+ const tokens = Number.isSafeInteger(brief.tokens) && brief.tokens >= 0 ? `At most ${brief.tokens} tokens` : "Token upper bound unavailable";
123
+ const generated = typeof s.generatedAt === "string" ? s.generatedAt : "unavailable";
124
+ return [
125
+ ui.notice("Computed now for this snapshot; this is not a copy of a prior session's injected brief.", "neutral"),
126
+ h("section", { class: "card amem-card" },
127
+ h("h3", { text: "Coverage" }),
128
+ ui.rows(rows, (row) => ui.row(row)) ?? ui.empty("No coverage details"),
129
+ h("dl", { class: "ui-provenance" },
130
+ h("dt", { text: "Status" }), h("dd", { text: brief.partial === true ? "Partial" : "Complete" }),
131
+ h("dt", { text: "Token bound" }), h("dd", { text: tokens }),
132
+ h("dt", { text: "Generated" }), h("dd", { text: generated }))),
133
+ s.repoAbsent === true ? h("p", { class: "amem-sub", text: "Repository section absent: the console has no repository binding." }) : null,
134
+ h("section", { class: "card amem-card" }, h("h3", { text: "SessionStart context" }), h("pre", { class: "amem-pre", text: brief.context })),
135
+ ].filter(Boolean);
136
+ },
112
137
  };
113
138
 
114
139
  host.onSnapshot((s) => {
package/src/ui/plugin.ts CHANGED
@@ -7,6 +7,8 @@ import { createHash } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { createMemoryMcp } from "../mcp-memory.ts";
10
+ import { sessionStartBrief } from "../session-brief.ts";
11
+ import { withBriefSnapshot } from "../memory/doctor.ts";
10
12
  import { MCP_TOOLS } from "../mcp.ts";
11
13
  import { CandidateStore, type Candidate, type CandidateInput } from "../memory/candidates.ts";
12
14
  import { candidateOverlapsBatch } from "../memory/candidate-overlaps.ts";
@@ -99,11 +101,12 @@ export const plugin = {
99
101
  api: 1 as const, tool: "amem", version: VERSION, title: "Memory", icon: "memory", pollMs: 15_000,
100
102
  panels: [
101
103
  { id: "review", title: "Review", order: 1 },
102
- { id: "find", title: "Search", order: 2 },
103
- { id: "timeline", title: "Timeline", order: 3 },
104
- { id: "work", title: "Work lists", order: 4 },
105
- { id: "goals", title: "Goals", order: 5 },
106
- { id: "capacity", title: "Capacity", order: 6 },
104
+ { id: "recap", title: "Recap", order: 2 },
105
+ { id: "find", title: "Search", order: 3 },
106
+ { id: "timeline", title: "Timeline", order: 4 },
107
+ { id: "work", title: "Work lists", order: 5 },
108
+ { id: "goals", title: "Goals", order: 6 },
109
+ { id: "capacity", title: "Capacity", order: 7 },
107
110
  ],
108
111
  commands: [
109
112
  { id: "gc", title: "Evict expired capture rows", hint: "amem gc", action: "gc" },
@@ -247,6 +250,15 @@ export const plugin = {
247
250
  async function panel(name: string, args: Args, project: string): Promise<Args> {
248
251
  switch (name) {
249
252
  case "review": return { ...(await pending(project, true)), selected: str(args.id) ?? null };
253
+ case "recap": {
254
+ const revision = policyVersion(), generatedAt = new Date().toISOString();
255
+ assertProjectActive(base, project);
256
+ const brief = await withBriefSnapshot(base, project, measureOnly => sessionStartBrief({ cwd: base, base, project,
257
+ repository: false, policy: configuredPolicyOptions(base), measureOnly }));
258
+ assertProjectActive(base, project);
259
+ if (policyVersion() !== revision) throw Error("recap policy changed");
260
+ return { brief, generatedAt, repoAbsent: true };
261
+ }
250
262
  case "find": {
251
263
  const q = str(args.q);
252
264
  if (!q) return { q: "", hits: [] };