@staix/agent-mem 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +143 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +537 -39
  6. package/src/commands-table.ts +60 -16
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +20 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +168 -33
  12. package/src/mcp.ts +43 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +228 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/consolidate.ts +78 -0
  25. package/src/memory/consolidation-clusters.ts +56 -0
  26. package/src/memory/consolidation-model.ts +97 -0
  27. package/src/memory/cross-project.ts +75 -0
  28. package/src/memory/curated-read.ts +29 -0
  29. package/src/memory/decay-review.ts +136 -0
  30. package/src/memory/doctor.ts +415 -0
  31. package/src/memory/editor.ts +58 -0
  32. package/src/memory/eval-fixture.ts +145 -0
  33. package/src/memory/eval-set-ko.json +90 -0
  34. package/src/memory/eval-set.json +443 -0
  35. package/src/memory/eval.ts +152 -0
  36. package/src/memory/evidence.ts +76 -0
  37. package/src/memory/external-import.ts +267 -0
  38. package/src/memory/gc.ts +97 -0
  39. package/src/memory/generation-index.ts +22 -17
  40. package/src/memory/generation-sync.ts +3 -0
  41. package/src/memory/goal-promotion.ts +71 -0
  42. package/src/memory/home.ts +15 -4
  43. package/src/memory/intent-brief.ts +89 -0
  44. package/src/memory/lifecycle-log.ts +54 -0
  45. package/src/memory/lifecycle.ts +61 -0
  46. package/src/memory/metrics.ts +2 -2
  47. package/src/memory/note-add.ts +100 -0
  48. package/src/memory/path-glob.ts +94 -0
  49. package/src/memory/plan-guard.ts +47 -0
  50. package/src/memory/platform.ts +12 -1
  51. package/src/memory/policy.ts +44 -13
  52. package/src/memory/private-spans.ts +32 -0
  53. package/src/memory/project-export.ts +157 -0
  54. package/src/memory/project-import.ts +147 -0
  55. package/src/memory/recall-staleness.ts +56 -0
  56. package/src/memory/recall.ts +74 -0
  57. package/src/memory/repo-scope.ts +404 -0
  58. package/src/memory/shard-sync-format.ts +116 -0
  59. package/src/memory/shard-sync.ts +421 -0
  60. package/src/memory/store.ts +344 -35
  61. package/src/memory/sync-cadence.ts +149 -0
  62. package/src/memory/sync-screen.ts +86 -0
  63. package/src/memory/sync.ts +91 -11
  64. package/src/memory/usage.ts +35 -0
  65. package/src/memory/use-telemetry.ts +51 -0
  66. package/src/memory/work-lists.ts +10 -4
  67. package/src/session-brief-worker.ts +3 -2
  68. package/src/session-brief.ts +74 -15
  69. package/src/setup-owned.ts +120 -0
  70. package/src/setup-toml.ts +296 -0
  71. package/src/setup.ts +327 -0
@@ -0,0 +1,56 @@
1
+ import { SearchIndex } from "./search-fts.ts";
2
+ import { recallViewIsLive } from "./recall.ts";
3
+ import { ACCEPTED_CONSOLIDATION_PREFIX } from "./candidates.ts";
4
+ import type { View } from "./store.ts";
5
+
6
+ export type ConsolidationAction = "merge" | "supersession" | "recurring-fix";
7
+ export interface ConsolidationCluster { action: ConsolidationAction; sources: View[]; reason: string }
8
+ export const CONSOLIDATION_SOURCE_LIMIT = 256, CONSOLIDATION_PROPOSAL_LIMIT = 32;
9
+ const words = (row: View) => new Set((`${row.title ?? ""} ${row.text}`.toLowerCase().match(/[\p{L}\p{N}_]{3,}/gu) ?? []).filter(word => !["the", "and", "for", "with", "from", "this", "that", "approach", "symptom", "cause", "evidence", "retry", "when"].includes(word)).slice(0, 128));
10
+ /** A note written by accepting a consolidation candidate. Proposing over one would be a summary of summaries. */
11
+ export const isAcceptedConsolidation = (row: Pick<View, "text">): boolean => row.text.startsWith(ACCEPTED_CONSOLIDATION_PREFIX);
12
+ /** An accepted recurring-fix summary keeps its sources live and quotes them whole, so it overlaps each of them. */
13
+ const isAcceptedRecurringFix = (row: Pick<View, "text">): boolean => isAcceptedConsolidation(row) && /^Action: recurring-fix$/m.test(row.text.split("\n\n", 1)[0] ?? "");
14
+ const paths = (row: View) => row.about.filter(node => node.startsWith("path:"));
15
+ const sharedPath = (a: View, b: View) => paths(a).some(node => paths(b).includes(node));
16
+ const overlap = (a: Set<string>, b: Set<string>) => [...a].filter(word => b.has(word)).length / Math.max(1, Math.min(a.size, b.size));
17
+
18
+ /** Bounded deterministic proposals over already-screened rows. Never mutates a note or runs a model. */
19
+ export function consolidationClusters(input: readonly View[], project: string, now: number): { clusters: ConsolidationCluster[]; truncated: boolean; engine: "fts5" | "substring" } {
20
+ if (!project.trim() || !Number.isFinite(now)) throw Error("consolidation context unavailable");
21
+ const eligible = input.filter(row => row.project === project && recallViewIsLive(row, now))
22
+ .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id));
23
+ const rows = [...new Map(eligible.map(row => [row.id, row])).values()].slice(0, CONSOLIDATION_SOURCE_LIMIT);
24
+ const index = new SearchIndex(":memory:"), clusters: ConsolidationCluster[] = [], seen = new Set<string>();
25
+ let truncated = eligible.length > rows.length;
26
+ const add = (action: ConsolidationAction, sources: View[], reason: string) => {
27
+ const key = `${action}:${sources.map(row => row.id).sort().join(":")}`;
28
+ if (seen.has(key)) return; seen.add(key);
29
+ if (clusters.length === CONSOLIDATION_PROPOSAL_LIMIT) { truncated = true; return; }
30
+ clusters.push({ action, sources, reason });
31
+ };
32
+ try {
33
+ index.resync(() => rows.map(row => ({ id: row.id, at: row.at, project, tier: row.tier, scope: row.scope, body: `${row.title ?? ""}\n${row.text}` })));
34
+ if (index.available && index.needsResync(rows.length)) throw Error("consolidation index unavailable");
35
+ const terms = new Map(rows.map(row => [row.id, words(row)]));
36
+ for (let i = 0; i < rows.length; i++) {
37
+ const row = rows[i]!, candidates = index.available ? new Set(index.query([...terms.get(row.id)!].slice(0, 32).join(" "), rows.length, project).map(hit => hit.ref)) : undefined;
38
+ for (const peer of rows.slice(i + 1)) {
39
+ if (row.kind !== peer.kind || isAcceptedRecurringFix(row) || isAcceptedRecurringFix(peer)) continue;
40
+ const lexical = overlap(terms.get(row.id)!, terms.get(peer.id)!);
41
+ if (row.text === peer.text) add("supersession", [row, peer], "Identical text; review one replacement for both originals.");
42
+ else if ((!candidates || candidates.has(peer.id)) && lexical >= 0.5 && sharedPath(row, peer)) add("merge", [row, peer], "Shared path and overlapping indexed terms; no equivalence is assumed.");
43
+ }
44
+ // A retained supersession chain is context, never a reason to resurrect an old body.
45
+ if (row.supersedes.length >= 2 && !isAcceptedConsolidation(row)) add("merge", [row], "Current head already replaces multiple originals; review a chain summary.");
46
+ }
47
+ const failRows = rows.filter(row => row.kind === "fail" && !isAcceptedConsolidation(row)), decisions = rows.filter(row => row.kind === "decision" || row.kind === "contract");
48
+ for (let i = 0; i < failRows.length; i++) for (const peer of failRows.slice(i + 1)) {
49
+ const fail = failRows[i]!;
50
+ if (!sharedPath(fail, peer) || overlap(terms.get(fail.id)!, terms.get(peer.id)!) < 0.5) continue;
51
+ const fix = decisions.find(row => sharedPath(row, fail) && overlap(terms.get(row.id)!, terms.get(fail.id)!) >= 0.25);
52
+ if (fix) add("recurring-fix", [fail, peer, fix], "Repeated fail notes and a related decision on the same path; review whether the decision addresses the failures.");
53
+ }
54
+ return { clusters, truncated, engine: index.available ? "fts5" : "substring" };
55
+ } finally { index.close(); }
56
+ }
@@ -0,0 +1,97 @@
1
+ import { types } from "node:util";
2
+ import { candidateTargetRevision, type CandidateInput, type CandidateStore } from "./candidates.ts";
3
+ import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts";
4
+ import { realPath } from "./platform.ts";
5
+ import { recallViewIsLive } from "./recall.ts";
6
+ import type { MemoryStore, View } from "./store.ts";
7
+
8
+ export interface ModelConsolidationNote extends View { revision: string }
9
+ export interface ModelConsolidationInput { project: string; notes: ModelConsolidationNote[] }
10
+ export interface ModelConsolidationOptions {
11
+ enabled: true;
12
+ run: (input: ModelConsolidationInput, signal: AbortSignal) => Promise<{ text: string } | null>;
13
+ }
14
+ export interface ModelConsolidationGuard { project: string; actor: "person" | "agent"; check(): void; policy: StandalonePolicy }
15
+ export interface ModelConsolidationReport {
16
+ state: "disabled" | "known" | "unknown"; ids: string[]; calls: number; nonAnswers: number; rejected: number; oversized: number;
17
+ limited: boolean; timedOut: boolean; truncated: boolean;
18
+ }
19
+ const TIMEOUT_MS = 10_000, MAX_INPUT_BYTES = 8192, MAX_OUTPUT_BYTES = 4096, PREFIX = "Model answer:\n";
20
+ async function invoke(model: ModelConsolidationOptions, input: ModelConsolidationInput, before: () => void): Promise<{ state: "value"; value: unknown } | { state: "timeout" } | { state: "error" }> {
21
+ const controller = new AbortController(), deadline = performance.now() + TIMEOUT_MS; let timer: ReturnType<typeof setTimeout> | undefined;
22
+ try {
23
+ const timeout = new Promise<{ state: "timeout" }>(resolve => { timer = setTimeout(() => { controller.abort(); resolve({ state: "timeout" }); }, TIMEOUT_MS); });
24
+ const result = await Promise.race([Promise.resolve().then(() => { before(); return model.run(structuredClone(input), controller.signal); }).then(value => ({ state: "value" as const, value }), () => ({ state: "error" as const })), timeout]);
25
+ if (performance.now() >= deadline) { controller.abort(); return { state: "timeout" }; }
26
+ return result;
27
+ } finally { if (timer) clearTimeout(timer); }
28
+ // ponytail: an injected host callback must honor AbortSignal to stop its own work. The library cannot
29
+ // terminate arbitrary host work, but the expired race has no late candidate-writing continuation.
30
+ }
31
+ /** Already-screened public rows only; guard.check retains the caller's current policy/source binding.
32
+ * Optional injected callback only, never a built-in provider, automatic pass or curated writer. */
33
+ export async function proposeModelConsolidations(store: MemoryStore, candidates: CandidateStore, rows: readonly View[], model: ModelConsolidationOptions | undefined, guard: ModelConsolidationGuard): Promise<ModelConsolidationReport> {
34
+ const report: ModelConsolidationReport = { state: model === undefined ? "disabled" : "known", ids: [], calls: 0, nonAnswers: 0, rejected: 0, oversized: 0, limited: false, timedOut: false, truncated: false };
35
+ if (model === undefined) return report;
36
+ if (guard.actor !== "person") throw Error("model consolidation requires person intent");
37
+ try {
38
+ if (!model || model.enabled !== true || typeof model.run !== "function" || !guard.project.trim() || guard.project.length > 400) throw Error("model consolidation unavailable");
39
+ guard.check();
40
+ if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("model consolidation home mismatch");
41
+ if (types.isProxy(rows) || !Array.isArray(rows) || rows.length > 256) throw Error("model consolidation source bound exceeded");
42
+ const project = guard.project, sourceCurrent = store.beginRecallRead(), sources: ModelConsolidationNote[] = [], seen = new Set<string>();
43
+ const check = (batch: readonly ModelConsolidationNote[]) => {
44
+ guard.check(); sourceCurrent();
45
+ if (guard.actor !== "person" || guard.project !== project) throw Error("model consolidation context changed");
46
+ const placements = batch.length ? store.entryPlacements(batch.map(row => row.id), project) : new Map<string, "local" | "synced">();
47
+ store.withDeferredPromotions(() => {
48
+ for (const row of batch) {
49
+ const current = store.get(row.id, project);
50
+ if (!current || current.id !== row.id || current.project !== project || !recallViewIsLive(current, store.readNow()) || candidateTargetRevision(current) !== row.revision || placements.get(row.id) !== "synced") throw Error("model consolidation sources changed");
51
+ }
52
+ });
53
+ guard.check(); sourceCurrent();
54
+ };
55
+ for (let index = 0; index < rows.length; index++) {
56
+ const descriptor = Object.getOwnPropertyDescriptor(rows, String(index));
57
+ if (!descriptor || !("value" in descriptor)) throw Error("model consolidation source unavailable");
58
+ const row = memoryPolicySnapshot(descriptor.value as View);
59
+ if (!row || !/^[a-f0-9]{32}$/.test(row.id) || row.project !== project || !recallViewIsLive(row, store.readNow()) || row.peer === "amem-model" || row.text.startsWith("Model answer:")) throw Error("model consolidation source unavailable");
60
+ if (seen.has(row.id)) continue; seen.add(row.id); sources.push({ ...row, revision: candidateTargetRevision(row) });
61
+ }
62
+ check(sources);
63
+ const batches: ModelConsolidationNote[][] = []; let pending: ModelConsolidationNote[] = [];
64
+ for (const row of sources) {
65
+ if (Buffer.byteLength(JSON.stringify({ project, notes: [row] })) > MAX_INPUT_BYTES) { report.oversized++; report.truncated = true; continue; }
66
+ if (pending.length === 8 || Buffer.byteLength(JSON.stringify({ project, notes: [...pending, row] })) > MAX_INPUT_BYTES) { batches.push(pending); pending = []; }
67
+ pending.push(row);
68
+ }
69
+ if (pending.length) batches.push(pending);
70
+ report.truncated ||= batches.length > 2;
71
+ for (const batch of batches.slice(0, 2)) {
72
+ check(batch);
73
+ const input: ModelConsolidationInput = { project, notes: batch }, inputBytes = Buffer.byteLength(JSON.stringify(input));
74
+ // Exported callers must not be able to forward an unscreened row merely by claiming it was screened.
75
+ const inputPermitted = await guard.policy.screen(input); check(batch);
76
+ if (!inputPermitted) { report.rejected++; continue; }
77
+ if (!candidates.reserveConsolidationModel(inputBytes)) { report.limited = true; report.truncated = true; break; }
78
+ report.calls++;
79
+ const result = await invoke(model, input, () => check(batch)); check(batch);
80
+ if (result.state === "timeout") { report.state = "unknown"; report.timedOut = true; break; }
81
+ if (result.state === "error") { report.state = "unknown"; report.rejected++; break; }
82
+ const value = memoryPolicySnapshot(result.value);
83
+ if (value === null) { report.nonAnswers++; continue; }
84
+ if (!value || typeof value !== "object" || !("text" in value) || typeof value.text !== "string") { report.rejected++; continue; }
85
+ if (Buffer.byteLength(value.text) > MAX_OUTPUT_BYTES) { report.oversized++; continue; }
86
+ const body = value.text.trim(); if (!body) { report.nonAnswers++; continue; }
87
+ const text = PREFIX + body;
88
+ if (text.length > 3000) { report.oversized++; continue; }
89
+ const candidate: Extract<CandidateInput, { kind: "model-answer" }> = { project, kind: "model-answer", marker: "model-answer", targets: batch.map(row => ({ id: row.id, revision: row.revision })), text };
90
+ guard.check();
91
+ const permitted = await guard.policy.screen({ project, output: value, candidate }); check(batch);
92
+ if (!permitted) { report.rejected++; continue; }
93
+ report.ids.push(candidates.put(candidate, "public"));
94
+ }
95
+ return report;
96
+ } catch { report.state = "unknown"; report.rejected++; return report; }
97
+ }
@@ -0,0 +1,75 @@
1
+ import { captureSearch } from "./search-fts.ts";
2
+ import { readCaptureConfig, type CaptureRow } from "./capture.ts";
3
+ import { walkShards } from "./catalog.ts";
4
+ import { openCuratedForRead } from "./curated-read.ts";
5
+ import { lifecycleResolver } from "./lifecycle.ts";
6
+ import type { MemoryPolicy } from "./policy.ts";
7
+ import type { SearchHit, View } from "./store.ts";
8
+ import { vaultCandidates } from "./vault.ts";
9
+
10
+ /**
11
+ * #15 T5 / D4 and D8: the two reads that deliberately cross project boundaries, each one project at a time.
12
+ *
13
+ * - `searchAllProjects` (`amem search --all-projects`): one ordinary project-scoped search per active project, merged by score. Every hit names its project and
14
+ * the reply says `crossedProjects: true` with the projects it read, so a caller can never mistake it for a single-project answer. An archived project is
15
+ * read only with `includeArchived`; a forgotten one never. It changes no content file of the home (only derived indexes and sidecars may refresh, as for any read).
16
+ * - `vaultCandidatesAll` (`amem vault-candidates --all-projects | --project <key>`): the existing durable-candidate selection (`vaultCandidates`), under the
17
+ * caller's policy, run per project and grouped by project. amem lists; it never writes the vault or a vault link (D8).
18
+ *
19
+ * Both are person reads at the CLI: agents reach memory through MCP, which stays bound to one project and has no all-projects argument (D4).
20
+ */
21
+
22
+ export interface CrossHit extends SearchHit { project: string }
23
+ export interface CrossSearchResult { crossedProjects: true; projects: string[]; hits: CrossHit[]; engine: "fts5" | "substring" }
24
+
25
+ function readableProjects(base: string, opts: { includeArchived?: boolean }): { key: string; machineDirs: string[] }[] {
26
+ const stateOf = lifecycleResolver(base);
27
+ return walkShards(base).filter(shard => { const state = stateOf(shard.key); return state === "active" || state === "archived" && opts.includeArchived === true; });
28
+ }
29
+
30
+ export function searchAllProjects(base: string, query: string, opts: { limit?: number; includeArchived?: boolean; includeCapture?: boolean; now?: () => number } = {}): CrossSearchResult {
31
+ const limit = opts.limit ?? 50;
32
+ if (!query.trim()) throw new Error("usage: amem search --all-projects <words>");
33
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new Error("--limit takes a number from 1 to 500");
34
+ const now = (opts.now ?? Date.now)(), hits: CrossHit[] = [], projects: string[] = [];
35
+ let engine: "fts5" | "substring" = "fts5";
36
+ for (const shard of readableProjects(base, opts)) {
37
+ projects.push(shard.key);
38
+ const store = openCuratedForRead(base, shard.key);
39
+ try {
40
+ if (store) {
41
+ const found = store.search(query, { project: shard.key, includeCapture: false, limit });
42
+ if (found.engine === "substring") engine = "substring";
43
+ for (const hit of found.hits) if (hit.view && hit.view.project === shard.key) hits.push({ ...hit, project: shard.key });
44
+ }
45
+ } finally { store?.close(); }
46
+ if (opts.includeCapture !== false) {
47
+ for (const dir of shard.machineDirs) {
48
+ 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
+ hits.push({ source: "capture", score: -rank * 0.5, capture: row as CaptureRow, project: shard.key });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ const ref = (h: CrossHit): string => `${h.project}:${h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0")}`;
56
+ hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
57
+ return { crossedProjects: true, projects: projects.sort(), hits: hits.slice(0, limit), engine };
58
+ }
59
+
60
+ export interface VaultCandidatesAll { crossedProjects: boolean; projects: { project: string; candidates: View[] }[] }
61
+
62
+ export async function vaultCandidatesAll(base: string, opts: { policy: MemoryPolicy; project?: string; limit?: number; includeArchived?: boolean }): Promise<VaultCandidatesAll> {
63
+ const shards = readableProjects(base, opts).filter(shard => opts.project === undefined || shard.key === opts.project);
64
+ if (opts.project !== undefined && !shards.length) throw new Error(`no readable project shard for ${opts.project}`);
65
+ const projects: { project: string; candidates: View[] }[] = [];
66
+ for (const shard of shards.sort((a, b) => a.key.localeCompare(b.key))) {
67
+ const store = openCuratedForRead(base, shard.key);
68
+ if (!store) continue;
69
+ try {
70
+ const candidates = await vaultCandidates(store, { project: shard.key, policy: opts.policy, ...(opts.limit !== undefined ? { limit: opts.limit } : {}) });
71
+ if (candidates.length) projects.push({ project: shard.key, candidates });
72
+ } finally { store.close(); }
73
+ }
74
+ return { crossedProjects: opts.project === undefined, projects };
75
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { MemoryStore, machineId, openShardStore } from "./store.ts";
4
+ import { aliasedKeys, readShardedSelection, resolveAlias, shardDir, shardKeyHash, readShardDescriptor } from "./shard.ts";
5
+
6
+ /**
7
+ * Opens the curated store that holds one project's notes, without initializing anything (#15 T5 reads, never creates):
8
+ * an unsplit home has one shared store (the project filter selects), a split home has one shard per project. A split home's
9
+ * capture-only shard (no `generation.json` yet) has no curated store, and the answer is `undefined`, never an empty one made on the spot.
10
+ */
11
+ export function openCuratedForRead(base: string, key: string): MemoryStore | undefined {
12
+ const aliases = aliasedKeys(base, key), reconcile = aliases.length ? { canonical: key, aliases } : undefined;
13
+ if (existsSync(join(base, "shards.json"))) {
14
+ readShardedSelection(base);
15
+ if (!existsSync(join(shardDir(base, key), "generation.json"))) return undefined;
16
+ return openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore", ...(reconcile ? { reconcile } : {}) });
17
+ }
18
+ if (!existsSync(join(base, "log")) && !existsSync(join(base, "local")) && !existsSync(join(base, "generations"))) return undefined;
19
+ return new MemoryStore({ memoryDir: base, deferPromotions: true, ...(reconcile ? { reconcile } : {}) });
20
+ }
21
+
22
+ /** The canonical key of `key`, refused unless a shard of it exists. */
23
+ export function requireShard(base: string, key: string): string {
24
+ const canonical = resolveAlias(base, key.trim());
25
+ if (!canonical || !readShardDescriptor(base, shardKeyHash(canonical))) throw new Error(`no project shard for ${canonical || key}`);
26
+ return canonical;
27
+ }
28
+
29
+ export const thisMachine = (base: string): string => machineId(base).id;
@@ -0,0 +1,136 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hashJSON } from "./generation.ts";
5
+ import { candidateTargetRevision, type CandidateTarget, type Counter } from "./candidates.ts";
6
+ import { MemoryStore, type View } from "./store.ts";
7
+
8
+ export const DECAY_DEFAULT_DAYS = 90, DECAY_PREVIEW_LIMIT = 64, DECAY_SCAN_LIMIT = 5000;
9
+ export interface DecayEntry { target: CandidateTarget; view: View }
10
+ export interface DecayPlan {
11
+ project: string; profile: string; days: number; at: string; cutoff: string; demoteExpiresAt: string;
12
+ entries: DecayEntry[]; withheld: number; citedExemptions: number | null; truncated: boolean;
13
+ /** Missing/zero observations do not prove the note was never cited. */
14
+ citationHistory: "unknown";
15
+ }
16
+ export interface DecayOptions {
17
+ now?: () => number; days?: number; ids?: readonly string[]; aliases?: readonly string[]; counters?: () => readonly Counter[] | undefined;
18
+ screen?: (view: View) => boolean; assertCurrent?: () => void;
19
+ }
20
+ function idsOf(ids: readonly string[]): string[] {
21
+ const normalized = ids.map(id => id.replace(/^mem:/, ""));
22
+ if (normalized.length > DECAY_PREVIEW_LIMIT || new Set(normalized).size !== normalized.length || normalized.some(id => !/^[a-f0-9]{32}$/.test(id))) throw Error("name up to 64 distinct exact entry ids");
23
+ return normalized;
24
+ }
25
+ /** Advisory telemetry without opening/pruning the candidate writer. Unavailable is unknown, never zero. */
26
+ export function readDecayCounters(base: string, projects: readonly string[]): Counter[] | undefined {
27
+ const file = join(base, "candidates.db");
28
+ let db: Database | undefined;
29
+ try {
30
+ const stat = lstatSync(file, { throwIfNoEntry: false });
31
+ if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return undefined;
32
+ db = new Database(file, { readonly: true }); db.run("PRAGMA busy_timeout = 0");
33
+ const rows = db.query("SELECT id,payload,at,rendered,delivered,cited FROM counters WHERE project IN (SELECT value FROM json_each(?)) AND cited > 0 ORDER BY at DESC").all(JSON.stringify(projects)) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
34
+ return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload), createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited }));
35
+ } catch { return undefined; }
36
+ finally { try { db?.close(); } catch { /* An unavailable telemetry close cannot authorize a mutation. */ } }
37
+ }
38
+ function citedIds(project: string, aliases: readonly string[], counters: readonly Counter[] | undefined): Set<string> {
39
+ const ids = new Set<string>();
40
+ for (const row of counters ?? []) {
41
+ if (!row?.key || ![project, ...aliases].includes(row.key.project) || !Number.isSafeInteger(row.cited) || !(row.cited! > 0)) continue;
42
+ // Counter timestamps describe delivery, not a later citation update. Any retained positive exempts.
43
+ ids.add(row.key.entryId);
44
+ }
45
+ return ids;
46
+ }
47
+ /** Age/goal selection is independent of telemetry; retained positive citations may only exempt entries. */
48
+ export function decayPreview(store: MemoryStore, project: string, options: DecayOptions = {}): DecayPlan {
49
+ options.assertCurrent?.();
50
+ if (!project.trim() || store.profile.sharded && !store.profile.shardUser && store.profile.shardKey !== project) throw Error("decay project does not match the bound store");
51
+ const now = (options.now ?? Date.now)(), days = options.days ?? DECAY_DEFAULT_DAYS, cutoff = now - days * 86400_000;
52
+ if (!Number.isSafeInteger(days) || days < 1 || !Number.isFinite(now) || !Number.isFinite(cutoff) || Math.abs(cutoff) > 8_640_000_000_000_000) throw Error("decay days are outside the supported range");
53
+ const requested = options.ids === undefined ? undefined : idsOf(options.ids);
54
+ return store.withDeferredPromotions(() => {
55
+ const goals = store.goals(project).filter(goal => ["open", "active", "blocked"].includes(goal.state));
56
+ const tasks = new Set(goals.flatMap(goal => goal.links.map(link => link.task))), goalNodes = new Set(goals.map(goal => `goal:${goal.id}`));
57
+ let counters: readonly Counter[] | undefined;
58
+ try { counters = options.counters?.(); } catch { /* Telemetry is advisory; an unreadable snapshot is unknown. */ }
59
+ const cited = citedIds(project, options.aliases ?? [], counters);
60
+ // ponytail: list materializes the existing core corpus before slicing; a scoped pager is the upgrade for large homes.
61
+ const rows = requested ? requested.map(id => store.get(id, project)) : store.list({ project, tier: "long", limit: DECAY_SCAN_LIMIT + 1 });
62
+ if (rows.length > DECAY_SCAN_LIMIT) throw Error("decay preview scan bound exceeded; name exact ids to narrow it");
63
+ const entries: DecayEntry[] = []; let withheld = 0, citedExemptions = 0;
64
+ for (let index = 0; index < rows.length; index++) {
65
+ const view = rows[index];
66
+ const eligible = view && (!requested || view.id === requested[index]) && view.project === project && view.tier === "long" && !view.supersededBy && Number.isFinite(Date.parse(view.at)) && Date.parse(view.at) < cutoff
67
+ && !(view.task && tasks.has(view.task)) && !view.about.some(node => goalNodes.has(node) || node.startsWith("task:") && tasks.has(node.slice(5)));
68
+ if (!eligible) { if (requested) throw Error("requested entry is not eligible for decay"); continue; }
69
+ if (cited.has(view.id)) { citedExemptions++; if (requested) throw Error("requested entry has a retained recorded citation"); continue; }
70
+ if (options.screen && !options.screen(structuredClone(view))) { withheld++; if (requested) throw Error("requested entry is withheld by current policy"); continue; }
71
+ entries.push({ target: { id: view.id, revision: candidateTargetRevision(view) }, view: structuredClone(view) });
72
+ }
73
+ options.assertCurrent?.();
74
+ return { project, profile: hashJSON(store.profile), days, at: new Date(now).toISOString(), cutoff: new Date(cutoff).toISOString(), demoteExpiresAt: store.shortExpiry(), entries: entries.slice(0, DECAY_PREVIEW_LIMIT), withheld, citedExemptions: counters === undefined ? null : citedExemptions, truncated: entries.length > DECAY_PREVIEW_LIMIT, citationHistory: "unknown" };
75
+ });
76
+ }
77
+ /** Duplicate-group forgetting requires a separate person group review, never an implicit decay expansion. */
78
+ export function assertExactDecayForget(store: MemoryStore, ids: readonly string[], project: string): void {
79
+ store.withDeferredPromotions(() => {
80
+ for (const id of ids) {
81
+ const targets = store.forgetTargets(id, project);
82
+ if (targets.length !== 1 || targets[0]?.id !== id) throw Error("decay forget expands beyond reviewed exact entries; review the imported copy group separately");
83
+ }
84
+ });
85
+ }
86
+
87
+ export interface DecayApplyOptions extends Omit<DecayOptions, "days"> {
88
+ actor: "person" | "agent"; confirmed?: boolean; forget?: boolean;
89
+ }
90
+ export interface DecayApplyReport { action: "demote" | "forget"; applied: string[]; failed: string; remaining: string[]; expiresAt?: string }
91
+ export class DecayApplyError extends Error {
92
+ jsonOutput = false;
93
+ readonly failedMayHaveChanged = true;
94
+ constructor(readonly report: DecayApplyReport, cause: unknown) {
95
+ super("decay apply stopped; the failed target may have changed, inspect before retrying", { cause }); this.name = "DecayApplyError";
96
+ }
97
+ get applied(): readonly string[] { return this.report.applied; }
98
+ get failed(): string { return this.report.failed; }
99
+ get remaining(): readonly string[] { return this.report.remaining; }
100
+ render(): string {
101
+ if (this.jsonOutput) return JSON.stringify({ ok: false, ...this.report, failedMayHaveChanged: this.failedMayHaveChanged });
102
+ return `${this.message}; applied: ${this.report.applied.join(", ") || "none"}; failed: ${this.report.failed}; remaining: ${this.report.remaining.join(", ") || "none"}${this.report.expiresAt ? `; completed demotions expire: ${this.report.expiresAt}` : ""}`;
103
+ }
104
+ }
105
+ /** The host shows this exact plan; every selected revision/eligibility is rechecked before any append. */
106
+ export function applyDecay(store: MemoryStore, plan: DecayPlan, options: DecayApplyOptions): { action: "demote" | "forget"; ids: string[]; expiresAt?: string } {
107
+ if (options.actor !== "person") throw Error("decay apply requires person authority");
108
+ options.assertCurrent?.();
109
+ const ids = options.ids === undefined ? plan.entries.map(entry => entry.target.id) : idsOf(options.ids);
110
+ if ((options.ids === undefined || options.forget) && options.confirmed !== true) throw Error("decay bulk/forget apply requires explicit person confirmation");
111
+ if (hashJSON(store.profile) !== plan.profile || ids.some(id => !plan.entries.some(entry => entry.target.id === id))) throw Error("decay preview binding changed or target was not previewed");
112
+ const action = options.forget ? "forget" : "demote";
113
+ if (!ids.length) return { action, ids };
114
+ if (!options.forget && (!Number.isFinite(Date.parse(plan.demoteExpiresAt)) || new Date(plan.demoteExpiresAt).toISOString() !== plan.demoteExpiresAt || Date.parse(plan.demoteExpiresAt) <= (options.now ?? Date.now)())) throw Error("decay demotion expiry is spent; preview again");
115
+ if (!options.forget) store.assertDemotionExpiry(plan.demoteExpiresAt);
116
+ return store.withCurrentEntries(ids, plan.project, current => {
117
+ const fresh = decayPreview(store, plan.project, { ...options, days: plan.days, ids });
118
+ for (const view of current) {
119
+ const expected = plan.entries.find(entry => entry.target.id === view.id)?.target.revision;
120
+ if (!expected || candidateTargetRevision(view) !== expected || !fresh.entries.some(entry => entry.target.id === view.id && entry.target.revision === expected)) throw Error("decay target changed since preview");
121
+ }
122
+ if (options.forget) assertExactDecayForget(store, ids, plan.project);
123
+ options.assertCurrent?.();
124
+ // All checks precede effects. The failed target may itself have appended before an I/O error.
125
+ const applied: string[] = [], expiry = options.forget ? {} : { expiresAt: plan.demoteExpiresAt };
126
+ for (let index = 0; index < ids.length; index++) {
127
+ const id = ids[index]!;
128
+ try {
129
+ const result = options.forget ? store.forget(id, "person", plan.project, true) : store.demote(id, "person", plan.project, plan.demoteExpiresAt);
130
+ if (!result) throw Error("decay target outcome unavailable");
131
+ applied.push(id);
132
+ } catch (error) { throw new DecayApplyError({ action, applied: [...applied], failed: id, remaining: ids.slice(index + 1), ...expiry }, error); }
133
+ }
134
+ return { action, ids, ...expiry };
135
+ });
136
+ }