@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -2
- package/package.json +3 -2
- package/src/hook-spool.ts +41 -14
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +11 -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/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/ui/panel.js +25 -0
- package/src/ui/plugin.ts +17 -5
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
|
|
|
@@ -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.
|
|
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
|
-
|
|
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);
|
|
@@ -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");
|
package/src/memory/metrics.ts
CHANGED
|
@@ -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);
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -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
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 } {
|
package/src/memory/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/session-brief.ts
CHANGED
|
@@ -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
|
-
*
|
|
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!,
|
|
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: "
|
|
103
|
-
{ id: "
|
|
104
|
-
{ id: "
|
|
105
|
-
{ id: "
|
|
106
|
-
{ id: "
|
|
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: [] };
|