@staix/agent-mem 0.6.0 → 0.7.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -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 +144 -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/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
@@ -0,0 +1,167 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createStandalonePolicy, type StandalonePolicy, type StandalonePolicyOptions } from "./policy.ts";
4
+
5
+ /**
6
+ * F4 (agent-mem#40): standalone policy configuration, with no host. Two files, both plain JSON:
7
+ *
8
+ * - the machine-local `amem.config.json` in the memory home: the person's own policy (deny paths, PII patterns, the SessionStart brief byte
9
+ * budget, the capture excerpt and path caps, the capture size cap and retention, and where the home lives);
10
+ * - an optional `<repository>/.amem/config`: a repository may only NARROW. It adds deny paths and PII patterns and may lower a byte or count
11
+ * budget; it can never remove a machine entry, raise a budget, or move the home. A stricter host policy passed through the library wins over both.
12
+ *
13
+ * Either file that exists but is not a bounded plain JSON object of known keys is an error that names it, never a silent default: an unreadable
14
+ * policy must not read as "no policy". Reading is synchronous and does no model, network or storage work.
15
+ */
16
+ export const CONFIG_FILE = "amem.config.json";
17
+ export const REPO_CONFIG_DIR = ".amem";
18
+ export const REPO_CONFIG_FILE = "config";
19
+ const MAX_CONFIG_BYTES = 64 * 1024;
20
+ const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture"] as const;
21
+ const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths"] as const;
22
+
23
+ export interface AmemConfig {
24
+ /** The memory home, when the file moves it (a pointer file: see `configuredHome`). `AMEM_HOME` still wins. Machine file only. */
25
+ home?: string;
26
+ denyPaths: string[];
27
+ piiPatterns: string[];
28
+ /** SessionStart brief byte budget; the built-in hook cap is the ceiling. */
29
+ briefBytes?: number;
30
+ /** Capture excerpt cap in bytes-as-characters; the built-in cap is the ceiling. */
31
+ excerptBytes?: number;
32
+ /** Capture path-count cap; the built-in cap is the ceiling. */
33
+ capturePaths?: number;
34
+ /** The capture tier's own R6 bounds. Machine file only (a repository cannot widen the machine's disk use). */
35
+ capture: { capMb?: number; retentionDays?: number };
36
+ }
37
+ export interface ConfigSource { machine: string | null; repo: string | null }
38
+ export interface LoadedConfig { config: AmemConfig; source: ConfigSource }
39
+
40
+ const fail = (file: string, why: string): never => { throw new Error(`${file}: ${why}; fix or remove it (an unreadable policy is never treated as no policy)`); };
41
+
42
+ /** One bounded read through one descriptor: a link, a non-file or a growing file refuses, and a missing file is `undefined`. */
43
+ function readPlain(path: string, label: string): unknown | undefined {
44
+ let fd: number;
45
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
46
+ catch (error) {
47
+ const code = (error as NodeJS.ErrnoException).code;
48
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
49
+ return fail(label, code === "ELOOP" ? "is a symbolic link" : "cannot be read");
50
+ }
51
+ try {
52
+ const stat = fstatSync(fd);
53
+ if (!stat.isFile() || stat.size > MAX_CONFIG_BYTES) return fail(label, `is not a plain file within ${MAX_CONFIG_BYTES} bytes`);
54
+ const text = readFileSync(fd, "utf8");
55
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) return fail(label, `is not a plain file within ${MAX_CONFIG_BYTES} bytes`);
56
+ try { return JSON.parse(text) as unknown; } catch { return fail(label, "does not parse as JSON"); }
57
+ } finally { closeSync(fd); }
58
+ }
59
+
60
+ function listOf(value: unknown, key: string, label: string): string[] {
61
+ if (value === undefined) return [];
62
+ // The standalone policy takes at most 64 literals of 256 bytes without control characters: say so here, by name.
63
+ if (!Array.isArray(value) || value.length > 64 || !value.every(item => typeof item === "string" && item.trim() !== "" && Buffer.byteLength(item) <= 256 && !/[\x00-\x1f\x7f]/.test(item)))
64
+ return fail(label, `${key} is not a list of at most 64 non-empty strings of at most 256 bytes without control characters`);
65
+ return [...new Set(value as string[])];
66
+ }
67
+ function countOf(value: unknown, key: string, label: string, max: number): number | undefined {
68
+ if (value === undefined) return undefined;
69
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) return fail(label, `${key} is not a whole number from 1 to ${max}`);
70
+ return value;
71
+ }
72
+
73
+ function parseFile(raw: unknown, label: string, keys: readonly string[]): Partial<AmemConfig> & { capture?: AmemConfig["capture"] } {
74
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fail(label, "is not a JSON object");
75
+ const body = raw as Record<string, unknown>;
76
+ for (const key of Object.keys(body)) if (!keys.includes(key)) fail(label, `unknown key "${key}" (known: ${keys.join(", ")})`);
77
+ const out: Partial<AmemConfig> & { capture?: AmemConfig["capture"] } = {
78
+ denyPaths: listOf(body.denyPaths, "denyPaths", label), piiPatterns: listOf(body.piiPatterns, "piiPatterns", label),
79
+ };
80
+ const briefBytes = countOf(body.briefBytes, "briefBytes", label, 1_000_000); if (briefBytes !== undefined) out.briefBytes = briefBytes;
81
+ const excerptBytes = countOf(body.excerptBytes, "excerptBytes", label, 100_000); if (excerptBytes !== undefined) out.excerptBytes = excerptBytes;
82
+ const capturePaths = countOf(body.capturePaths, "capturePaths", label, 1000); if (capturePaths !== undefined) out.capturePaths = capturePaths;
83
+ if (body.home !== undefined) {
84
+ if (typeof body.home !== "string" || !body.home.trim() || body.home.includes("\0") || !body.home.startsWith("/")) return fail(label, "home is not an absolute path");
85
+ // A file that moves the home is a pointer: the policy is read from the file IN the home it names, so any policy key beside `home` here would be silently lost.
86
+ const beside = Object.keys(body).filter(key => key !== "home");
87
+ if (beside.length) return fail(label, `names a home, so it is only a pointer; put ${beside.join(", ")} in ${join(body.home, CONFIG_FILE)}`);
88
+ out.home = body.home;
89
+ }
90
+ if (body.capture !== undefined) {
91
+ const capture = body.capture;
92
+ if (!capture || typeof capture !== "object" || Array.isArray(capture) || Object.keys(capture).some(key => !["capMb", "retentionDays"].includes(key))) return fail(label, "capture takes only capMb and retentionDays");
93
+ const c = capture as Record<string, unknown>;
94
+ out.capture = {};
95
+ const capMb = countOf(c.capMb, "capture.capMb", label, 1_048_576), retentionDays = countOf(c.retentionDays, "capture.retentionDays", label, 36_500);
96
+ if (capMb !== undefined) out.capture.capMb = capMb;
97
+ if (retentionDays !== undefined) out.capture.retentionDays = retentionDays;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The machine file's `home` before any store exists: the one key read ahead of the home itself. `AMEM_HOME` and the config's `home` name different homes only when the person sets both, and `AMEM_HOME` wins. */
103
+ export function machineConfigPath(base: string): string { return join(base, CONFIG_FILE); }
104
+ export function repoConfigPath(root: string): string { return join(root, REPO_CONFIG_DIR, REPO_CONFIG_FILE); }
105
+
106
+ /**
107
+ * The nearest ancestor of `cwd` that holds a `.git` entry, by `lstat` alone: no `git` process, so a hook inside its 150 ms budget can call it.
108
+ * Bounded to 32 levels. Its `.amem/config` (if any) is the repository's narrowing file.
109
+ */
110
+ export function repoConfigRoot(cwd: string): string | undefined {
111
+ try {
112
+ for (let dir = cwd, depth = 0; depth < 32; dir = dirname(dir), depth++) {
113
+ if (lstatSync(join(dir, ".git"), { throwIfNoEntry: false })) return dir;
114
+ if (dir === dirname(dir)) return undefined;
115
+ }
116
+ } catch { /* unreadable: no repository file */ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * The home the machine configuration redirects to, read from the DEFAULT home's `amem.config.json` only (one level: a redirected home's own file
122
+ * cannot redirect again, so there is no chain and no loop). Undefined when there is no file or it names no `home`. A malformed file throws.
123
+ * That file is a pointer: it carries `home` and nothing else, and the policy lives in the file inside the home it names.
124
+ */
125
+ export function configuredHome(defaultHome: string): string | undefined {
126
+ const file = machineConfigPath(defaultHome), raw = readPlain(file, file);
127
+ return raw === undefined ? undefined : parseFile(raw, file, MACHINE_KEYS).home;
128
+ }
129
+
130
+ const lower = (machine: number | undefined, repo: number | undefined) => machine === undefined ? repo : repo === undefined ? machine : Math.min(machine, repo);
131
+
132
+ /**
133
+ * The effective configuration. The repository file can only narrow: its lists are added to the machine's, and a number counts only when it is
134
+ * lower than the machine's (or the machine has none). It never carries `home` or `capture` (unknown key: an error), so it cannot move the
135
+ * store or widen the disk bounds.
136
+ */
137
+ export function loadAmemConfig(base: string, repoRoot?: string): LoadedConfig {
138
+ const machinePath = machineConfigPath(base);
139
+ const machineRaw = readPlain(machinePath, machinePath);
140
+ const machine = machineRaw === undefined ? undefined : parseFile(machineRaw, machinePath, MACHINE_KEYS);
141
+ let repo: ReturnType<typeof parseFile> | undefined, repoPath: string | null = null;
142
+ if (repoRoot !== undefined) {
143
+ const file = repoConfigPath(repoRoot), raw = readPlain(file, file);
144
+ if (raw !== undefined) { repo = parseFile(raw, file, REPO_KEYS); repoPath = file; }
145
+ }
146
+ const union = (a: string[] | undefined, b: string[] | undefined) => [...new Set([...(a ?? []), ...(b ?? [])])];
147
+ const denyPaths = union(machine?.denyPaths, repo?.denyPaths), piiPatterns = union(machine?.piiPatterns, repo?.piiPatterns);
148
+ if (denyPaths.length > 64) fail(machinePath, "denyPaths and the repository's denyPaths name more than 64 entries together");
149
+ if (piiPatterns.length > 64) fail(machinePath, "piiPatterns and the repository's piiPatterns name more than 64 entries together");
150
+ const config: AmemConfig = { denyPaths, piiPatterns, capture: { ...(machine?.capture ?? {}) } };
151
+ if (machine?.home !== undefined) config.home = machine.home;
152
+ for (const key of ["briefBytes", "excerptBytes", "capturePaths"] as const) { const value = lower(machine?.[key], repo?.[key]); if (value !== undefined) config[key] = value; }
153
+ return { config, source: { machine: machineRaw === undefined ? null : machinePath, repo: repoPath } };
154
+ }
155
+
156
+ /**
157
+ * The standalone policy as configured. A host policy passed through the library still wins: it is composed in, never replaced, so the result
158
+ * declines whatever either declines (`createStandalonePolicy` screens the config, then asks the host).
159
+ */
160
+ export function configuredPolicyOptions(base: string, cwd?: string, options: StandalonePolicyOptions = {}): StandalonePolicyOptions {
161
+ const { config } = loadAmemConfig(base, cwd === undefined ? undefined : repoConfigRoot(cwd));
162
+ const merge = (a: readonly string[] | undefined, b: readonly string[]) => [...new Set([...(a ?? []), ...b])];
163
+ return { ...options, denyPaths: merge(options.denyPaths, config.denyPaths), piiPatterns: merge(options.piiPatterns, config.piiPatterns) };
164
+ }
165
+ export function configuredPolicy(base: string, cwd?: string, options: StandalonePolicyOptions = {}): StandalonePolicy {
166
+ return createStandalonePolicy(configuredPolicyOptions(base, cwd, options));
167
+ }
@@ -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
+ }