@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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
package/src/memory/commands.ts
CHANGED
|
@@ -2,32 +2,56 @@ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type Go
|
|
|
2
2
|
import { syncMemory, type SyncOptions } from "./sync.ts";
|
|
3
3
|
import { importClaudeMem } from "./import.ts";
|
|
4
4
|
import { memoryProjectKey, projectChain } from "./project.ts";
|
|
5
|
+
import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
|
|
6
|
+
import type { GoalPromotionReport } from "./goal-promotion.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the commands call themselves in their own messages. The default is amem's own surface; a host that exposes the same commands under its own
|
|
10
|
+
* name (agent-hub's `ahub memory` and `ahub goal`) passes it, so no host name is baked into the library.
|
|
11
|
+
*/
|
|
12
|
+
export interface CommandNames { memory: string; goal: string }
|
|
13
|
+
export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
|
|
5
14
|
|
|
6
15
|
export interface MemoryCommandOptions {
|
|
16
|
+
/** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
|
|
17
|
+
person?: boolean;
|
|
18
|
+
/** Defaults to amem's own names. */
|
|
19
|
+
names?: CommandNames;
|
|
7
20
|
/** Startup-bound canonical project supplied by a host; no model-facing selector. */
|
|
8
21
|
project?: string;
|
|
9
22
|
store?: MemoryStore;
|
|
10
23
|
sync?: Omit<SyncOptions, "remote">;
|
|
11
24
|
open: () => MemoryStore;
|
|
12
25
|
readable: (notes: View[]) => View[];
|
|
26
|
+
stale?: (view: View) => boolean | undefined;
|
|
27
|
+
/** Prepared row ids only: the host must wait for successful output before recording rendered. */
|
|
28
|
+
outputReady?: (notes: readonly View[]) => void;
|
|
13
29
|
importPolicy: () => (text: string) => boolean;
|
|
14
30
|
/** Explicit person-status receipt; model-facing hosts omit this callback. */
|
|
15
31
|
onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
|
|
16
32
|
}
|
|
17
33
|
export interface GoalCommandOptions {
|
|
34
|
+
/** Defaults to amem's own names. */
|
|
35
|
+
names?: CommandNames;
|
|
18
36
|
project?: string;
|
|
19
37
|
store?: MemoryStore;
|
|
20
38
|
link?: (goal: string, task: string) => Promise<string>;
|
|
39
|
+
/** Optional host proposal hook; the goal state is saved before it runs. It never grants mutation authority. */
|
|
40
|
+
onDone?: (goal: GoalView, store: MemoryStore) => Promise<GoalPromotionReport>;
|
|
21
41
|
open: () => MemoryStore;
|
|
22
42
|
clean: (title: string, milestones: string[], project: string) => boolean;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/**
|
|
26
|
-
* #258:
|
|
46
|
+
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Reading is anyone's; promote, demote, forget,
|
|
27
47
|
* supersede and rebuild are a person's (the identity gate refuses agent shells).
|
|
28
48
|
*/
|
|
29
|
-
|
|
30
|
-
export const
|
|
49
|
+
/** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
|
|
50
|
+
export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
|
|
51
|
+
export const MEMORY_USAGE = memoryUsage();
|
|
52
|
+
/** The usage line of the goal commands under `name` (`amem goal` by default; agent-hub passes `ahub goal`). */
|
|
53
|
+
export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${name} [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]`;
|
|
54
|
+
export const GOAL_USAGE = goalUsage();
|
|
31
55
|
|
|
32
56
|
const fail = (message: string): never => { throw new Error(message); };
|
|
33
57
|
const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
|
|
@@ -41,64 +65,86 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
41
65
|
const rest = args.filter((a) => a !== "--json");
|
|
42
66
|
const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
|
|
43
67
|
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
68
|
+
const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
|
|
69
|
+
if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
|
|
44
70
|
const store = opts.store ?? opts.open();
|
|
45
71
|
const actor = "user";
|
|
46
72
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
47
|
-
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (
|
|
73
|
+
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
74
|
+
// #15 D4: this command is bound to one project. Crossing projects is the person's `amem search --all-projects`; here the flags would otherwise read as query words.
|
|
75
|
+
for (const crossing of ["--all-projects", "--include-archived"]) if (rest.includes(crossing)) fail(`${crossing} crosses projects: run amem search --all-projects; ${names.memory} reads this project only`);
|
|
76
|
+
// #15 T4: repo scope is the person's `amem promote --to repo`, which screens, previews the committed line and checks the repository's visibility. Here the flag would otherwise be ignored and the note promoted privately.
|
|
77
|
+
for (const repoOnly of ["--to", "--confirm", "--confirm-public"]) if (rest.includes(repoOnly)) fail(`${repoOnly} belongs to the person's amem promote <id> --to repo; ${names.memory} never writes a repository file`);
|
|
78
|
+
const stale = (view: View) => { try { return opts.stale?.(view) === true; } catch { return false; } };
|
|
79
|
+
const ready = (rows: readonly View[]) => { try { opts.outputReady?.(rows); } catch { /* optional output metadata cannot fail recall */ } };
|
|
80
|
+
const decorated = (view: View) => ({ ...view, ...(stale(view) ? { stale: true } : {}) });
|
|
81
|
+
if (!["list", "search", "show", "graph"].includes(sub) && (rest.includes("--history") || rest.includes("--as-of"))) fail("history/as-of apply only to memory reads");
|
|
48
82
|
try {
|
|
49
83
|
switch (sub) {
|
|
50
84
|
case "list": case "search": {
|
|
51
|
-
const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
|
|
85
|
+
const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit"), asOf = flag("--as-of");
|
|
86
|
+
const history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
52
87
|
if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
|
|
53
88
|
if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
|
|
54
89
|
if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
|
|
55
90
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
56
|
-
const q = sub === "search" ? rest.join(" ")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
|
|
60
|
-
// Screened first, then cut to the limit: withheld entries never crowd out readable ones.
|
|
91
|
+
const q = sub === "search" ? rest.join(" ") : undefined;
|
|
92
|
+
const state = recallRows(store, project, { person: opts.person, history, asOf, query: q, limit: Number.MAX_SAFE_INTEGER });
|
|
93
|
+
const all = state.views.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
|
|
61
94
|
const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
95
|
+
ready(rows);
|
|
96
|
+
if (history || asOf !== undefined) {
|
|
97
|
+
const emitted = recallForViews(state, rows), publicIds = new Set(rows.map(view => view.id)), allForgotten = q?.trim() ? [] : state.forgotten;
|
|
98
|
+
const forgotten = allForgotten.slice(0, Math.max(0, (limit ? Math.floor(Number(limit)) : 50) - rows.length));
|
|
99
|
+
const result = { at: state.at, entries: rows.map(decorated), forgotten, relations: state.relations.filter(edge => publicIds.has(edge.from) && publicIds.has(edge.to)), incomplete: emitted.incomplete || readable.length > rows.length || forgotten.length < allForgotten.length, notices: emitted.notices };
|
|
100
|
+
return json ? JSON.stringify(result, null, 2) : [`retained memory as of ${state.at}`, ...result.notices, ...rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}${view.expiresAt && Date.parse(view.expiresAt) <= Date.parse(state.at) ? ` (expired at ${view.expiresAt})` : ""}`), ...forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(result.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
|
|
101
|
+
}
|
|
62
102
|
if (json) return JSON.stringify(rows, null, 2);
|
|
63
|
-
|
|
64
|
-
const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
|
|
65
|
-
const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
|
|
103
|
+
const counted = opts.readable(store.withDeferredPromotions(() => store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })));
|
|
66
104
|
const withheld = all.length - readable.length;
|
|
67
|
-
return [`
|
|
105
|
+
return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ? rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}`) : ["(nothing)"]),
|
|
68
106
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
69
107
|
}
|
|
70
108
|
case "show": {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
74
|
-
|
|
109
|
+
const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
110
|
+
const id = rest[0] ?? fail(`usage: ${names.memory} show <id>`), shown = recallShow(store, project, id, { person: opts.person, history, asOf });
|
|
111
|
+
if (!shown.ids.length) return fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
112
|
+
if (shown.state) {
|
|
113
|
+
const rows = opts.readable(shown.state.views), ids = new Set(rows.map(view => view.id)); ready(rows);
|
|
114
|
+
const state = { ...recallForViews(shown.state, rows), views: rows.map(decorated), relations: shown.state.relations.filter(edge => ids.has(edge.from) && ids.has(edge.to)) };
|
|
115
|
+
return json ? JSON.stringify(state, null, 2) : [`retained memory as of ${state.at}`, ...state.notices, ...rows.flatMap(view => [...(stale(view) ? ["(stale?)"] : []), ...detail(view)]), ...state.forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(state.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
|
|
116
|
+
}
|
|
117
|
+
const view = need(shown.entry, id); if (!opts.readable([view]).length) fail(`${view.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
|
|
118
|
+
ready([view]); return json ? JSON.stringify(view, null, 2) : [...(stale(view) ? ["(stale?)"] : []), ...detail(view)].join("\n");
|
|
75
119
|
}
|
|
76
120
|
case "graph": {
|
|
77
121
|
const hops = Number(flag("--hops") ?? 1);
|
|
78
122
|
if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const g = store
|
|
123
|
+
const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
124
|
+
const start = rest[0] ?? fail(`usage: ${names.memory} graph <id|node>`);
|
|
125
|
+
const g = recallGraph(store, project, start, hops, { person: opts.person, history, asOf });
|
|
126
|
+
if ((!start.includes(":") || start.startsWith("mem:")) && !g.nodes.length) fail(`no memory entry ${start} (${names.memory} list shows the ids)`);
|
|
127
|
+
const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
|
|
82
128
|
return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
|
|
83
129
|
}
|
|
84
|
-
case "promote": { const v = need(store.get(rest[0] ?? fail(
|
|
85
|
-
case "demote": { const v = need(store.get(rest[0] ?? fail(
|
|
86
|
-
case "forget": { const v = need(store.forget(rest[0] ?? fail(
|
|
130
|
+
case "promote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
|
|
131
|
+
case "demote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
|
|
132
|
+
case "forget": { const v = need(store.forget(rest[0] ?? fail(usage), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
|
|
87
133
|
case "supersede": {
|
|
88
|
-
if (rest.length !== 2) fail(
|
|
89
|
-
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(
|
|
134
|
+
if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
|
|
135
|
+
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(`both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (${names.memory} promote it first)`);
|
|
90
136
|
}
|
|
91
137
|
case "sync": {
|
|
92
138
|
const remote = flag("--remote");
|
|
93
|
-
if (rest.length) fail(
|
|
139
|
+
if (rest.length) fail(`usage: ${names.memory} sync [--remote <private git url>]`);
|
|
94
140
|
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
95
141
|
if (json) return JSON.stringify(r, null, 2);
|
|
96
|
-
if (!r.ok) fail(
|
|
142
|
+
if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
|
|
97
143
|
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
98
144
|
}
|
|
99
145
|
case "dedupe": {
|
|
100
146
|
const apply = rest.includes("--apply");
|
|
101
|
-
if (rest.some(arg => arg !== "--apply")) fail(
|
|
147
|
+
if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
|
|
102
148
|
const result = store.dedupe(project, apply);
|
|
103
149
|
if (json) return JSON.stringify(result, null, 2);
|
|
104
150
|
return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
|
|
@@ -108,7 +154,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
108
154
|
const db = flag("--db"), limit = flag("--limit");
|
|
109
155
|
const restart = rest.includes("--restart");
|
|
110
156
|
if (restart) rest.splice(rest.indexOf("--restart"), 1);
|
|
111
|
-
if (rest[0] !== "claude-mem" || rest.length !== 1) fail(
|
|
157
|
+
if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart]`);
|
|
112
158
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
113
159
|
const isPii = opts.importPolicy();
|
|
114
160
|
const key = opts.project ?? memoryProjectKey(cwd);
|
|
@@ -120,10 +166,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
120
166
|
case "suggestions": {
|
|
121
167
|
const pending = store.suggestions(project).filter((x) => x.status === "suggested");
|
|
122
168
|
if (json) return JSON.stringify(pending, null, 2);
|
|
123
|
-
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by
|
|
169
|
+
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by a model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
|
|
124
170
|
}
|
|
125
171
|
case "confirm": case "reject": {
|
|
126
|
-
const x = store.judge(rest[0] ?? fail(`usage:
|
|
172
|
+
const x = store.judge(rest[0] ?? fail(`usage: ${names.memory} ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (${names.memory} suggestions lists them)`);
|
|
127
173
|
return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
|
|
128
174
|
}
|
|
129
175
|
case "machine": {
|
|
@@ -132,7 +178,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
132
178
|
return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
|
|
133
179
|
}
|
|
134
180
|
case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
|
|
135
|
-
default: return fail(
|
|
181
|
+
default: return fail(usage);
|
|
136
182
|
}
|
|
137
183
|
} finally {
|
|
138
184
|
try { opts.onBridgeStatus?.(store.bridgeStatus()); }
|
|
@@ -140,15 +186,15 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
140
186
|
}
|
|
141
187
|
}
|
|
142
188
|
|
|
143
|
-
const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
|
|
144
|
-
function leaseText(g: GoalView, me: string): string {
|
|
189
|
+
const goalOne = (g: GoalView, me: string, goal: string = AMEM_NAMES.goal) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me, goal)}`;
|
|
190
|
+
function leaseText(g: GoalView, me: string, goal: string): string {
|
|
145
191
|
const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
|
|
146
|
-
if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with
|
|
192
|
+
if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ${goal} take --force)` : ""}`;
|
|
147
193
|
return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
|
|
148
194
|
}
|
|
149
195
|
|
|
150
196
|
/**
|
|
151
|
-
* #258: `ahub goal
|
|
197
|
+
* #258: the goal commands (agent-hub exposes the same as `ahub goal`), long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
|
|
152
198
|
* person's. A peer links its own board task with hub_task_propose's `goal`.
|
|
153
199
|
*/
|
|
154
200
|
export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
|
|
@@ -156,11 +202,12 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
156
202
|
const rest = args.filter((a) => a !== "--json");
|
|
157
203
|
const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
|
|
158
204
|
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
205
|
+
const names = opts.names ?? AMEM_NAMES, usage = goalUsage(names.goal);
|
|
159
206
|
const store = opts.store ?? opts.open();
|
|
160
207
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
161
208
|
const me = store.machine.id;
|
|
162
209
|
// Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
|
|
163
|
-
const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(
|
|
210
|
+
const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(usage), project) ?? fail(`no long-term task ${id} for this project (${names.goal} list shows them)`);
|
|
164
211
|
const clean = (title: string, milestones: string[], of = project) => opts.clean(title, milestones, of);
|
|
165
212
|
try {
|
|
166
213
|
switch (sub) {
|
|
@@ -169,36 +216,43 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
169
216
|
const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
|
|
170
217
|
const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
|
|
171
218
|
if (json) return JSON.stringify(goals, null, 2);
|
|
172
|
-
return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (
|
|
219
|
+
return [...(goals.length ? goals.map((g) => goalOne(g, me, names.goal)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (${names.goal} new <title>)`]),
|
|
173
220
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
|
|
174
221
|
}
|
|
175
222
|
case "show": {
|
|
176
223
|
const g = need(rest[0]);
|
|
177
224
|
if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
|
|
178
225
|
if (json) return JSON.stringify(g, null, 2);
|
|
179
|
-
return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
|
|
226
|
+
return [goalOne(g, me, names.goal), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
|
|
180
227
|
...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
|
|
181
228
|
...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
|
|
182
229
|
}
|
|
183
230
|
case "new": {
|
|
184
231
|
const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
|
|
185
232
|
const shared = rest.includes("--shared");
|
|
186
|
-
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(
|
|
233
|
+
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
|
|
187
234
|
if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
|
|
188
235
|
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
|
|
189
236
|
return `long-term task ${id.slice(0, 8)} created: ${title}`;
|
|
190
237
|
}
|
|
191
238
|
case "link": {
|
|
192
239
|
const g = need(rest[0]);
|
|
193
|
-
const task = rest[1] ?? fail(
|
|
240
|
+
const task = rest[1] ?? fail(`usage: ${names.goal} link <id> <task id>`);
|
|
194
241
|
if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
|
|
195
|
-
return await (opts.link ?? fail("linking needs
|
|
242
|
+
return await (opts.link ?? fail("linking a goal to a task needs a host that has a task board; amem alone has none"))(g.id, task);
|
|
196
243
|
}
|
|
197
244
|
case "state": case "done": {
|
|
198
245
|
const g = need(rest[0]);
|
|
199
246
|
const state = (sub === "done" ? "done" : rest[1]) as GoalState;
|
|
200
247
|
if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
|
|
201
248
|
store.goalState(g.id, state, "user");
|
|
249
|
+
if (state === "done" && opts.onDone) {
|
|
250
|
+
const current = store.goalOf(g.id, project);
|
|
251
|
+
if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
|
|
252
|
+
const promotion = await opts.onDone(current, store);
|
|
253
|
+
if (json) return JSON.stringify({ goalId: current.id, state: current.state, promotion }, null, 2);
|
|
254
|
+
return `long-term task ${current.id.slice(0, 8)} is done\npromotion candidates: ${promotion.offered} offered, ${promotion.alreadyOffered} already offered, ${promotion.withheld} withheld${promotion.truncated ? "; more linked notes remain (truncated)" : ""}`;
|
|
255
|
+
}
|
|
202
256
|
return `long-term task ${g.id.slice(0, 8)} is ${state}`;
|
|
203
257
|
}
|
|
204
258
|
case "take": {
|
|
@@ -207,7 +261,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
207
261
|
if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
|
|
208
262
|
return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
|
|
209
263
|
}
|
|
210
|
-
default: return fail(
|
|
264
|
+
default: return fail(usage);
|
|
211
265
|
}
|
|
212
266
|
} finally { if (!opts.store) store.close(); }
|
|
213
267
|
}
|
|
@@ -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,78 @@
|
|
|
1
|
+
import { CandidateStore, candidateInputId, candidateTargetRevision, type CandidateInput } from "./candidates.ts";
|
|
2
|
+
import { consolidationClusters } from "./consolidation-clusters.ts";
|
|
3
|
+
import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts";
|
|
4
|
+
import { recallViewIsLive } from "./recall.ts";
|
|
5
|
+
import type { MemoryStore, View } from "./store.ts";
|
|
6
|
+
import { realPath } from "./platform.ts";
|
|
7
|
+
import { proposeModelConsolidations, type ModelConsolidationOptions, type ModelConsolidationReport } from "./consolidation-model.ts";
|
|
8
|
+
|
|
9
|
+
export interface ConsolidationOptions {
|
|
10
|
+
project: string; actor: "person" | "agent"; policy: () => StandalonePolicy; policyVersion: () => string;
|
|
11
|
+
allowed?: () => boolean; assertCurrent?: () => void;
|
|
12
|
+
/** Person-enabled host callback only. Absent means no model work or budget reservation. */
|
|
13
|
+
model?: ModelConsolidationOptions;
|
|
14
|
+
}
|
|
15
|
+
export interface ConsolidationReport {
|
|
16
|
+
schema: "amem.consolidation/v1"; project: string; candidates: { id: string; action: string; sources: string[] }[];
|
|
17
|
+
engine: "fts5" | "substring"; truncated: boolean; oversized: number; localExcluded: number;
|
|
18
|
+
/** Proposals whose candidate already exists as accepted, accepting or rejected: they are not queued again and are not in `candidates`. */
|
|
19
|
+
alreadyReviewed: number;
|
|
20
|
+
model?: ModelConsolidationReport;
|
|
21
|
+
}
|
|
22
|
+
export class ConsolidationProposalError extends Error {
|
|
23
|
+
constructor(readonly proposed: string[]) { super(`consolidation proposal interrupted; candidates already queued: ${proposed.join(", ") || "none"}; no curated notes changed`); this.name = "ConsolidationProposalError"; }
|
|
24
|
+
}
|
|
25
|
+
const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
|
|
26
|
+
/** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
|
|
27
|
+
export async function proposeConsolidation(store: MemoryStore, candidates: CandidateStore, options: ConsolidationOptions): Promise<ConsolidationReport> {
|
|
28
|
+
if (options.actor !== "person") throw Error("consolidation requires person intent");
|
|
29
|
+
if (options.allowed?.() === false) throw Error("consolidation unavailable");
|
|
30
|
+
if (realPath(candidates.dir) !== realPath(store.profile.home)) throw Error("consolidation requires its own candidate home");
|
|
31
|
+
const project = options.project, version = options.policyVersion(), policy = options.policy();
|
|
32
|
+
if (!project.trim() || typeof version !== "string") throw Error("consolidation context unavailable");
|
|
33
|
+
const sourceCurrent = store.beginRecallRead();
|
|
34
|
+
const check = () => {
|
|
35
|
+
if (options.actor !== "person" || options.project !== project || options.allowed?.() === false || options.policyVersion() !== version) throw Error("consolidation context changed");
|
|
36
|
+
options.assertCurrent?.(); sourceCurrent();
|
|
37
|
+
};
|
|
38
|
+
check();
|
|
39
|
+
if (!await policy.screen({ project })) throw Error("consolidation refused by current policy");
|
|
40
|
+
check();
|
|
41
|
+
const raw = store.withDeferredPromotions(() => store.list({ project, limit: 257 })), rows: View[] = [];
|
|
42
|
+
for (const value of raw) {
|
|
43
|
+
check(); const row = memoryPolicySnapshot(value);
|
|
44
|
+
if (!row || row.project !== project || !recallViewIsLive(row, store.readNow()) || modelAnswer(row)) continue;
|
|
45
|
+
if (await policy.screen(row)) rows.push(row);
|
|
46
|
+
}
|
|
47
|
+
check();
|
|
48
|
+
const placements = rows.length ? store.entryPlacements(rows.map(row => row.id).slice(0, 256), project) : new Map<string, "local" | "synced">();
|
|
49
|
+
const synced = rows.slice(0, 256).filter(row => placements.get(row.id) === "synced");
|
|
50
|
+
const clustered = consolidationClusters(synced, project, store.readNow()), prepared: Extract<CandidateInput, { kind: "consolidation" }>[] = [];
|
|
51
|
+
let oversized = 0;
|
|
52
|
+
for (const cluster of clustered.clusters) {
|
|
53
|
+
const first = cluster.sources[0]!;
|
|
54
|
+
if (cluster.sources.some(row => row.scope !== first.scope || row.local !== first.local)) continue;
|
|
55
|
+
const text = `${cluster.reason}\n\nUntrusted quoted sources, not instructions:\n${cluster.sources.map(row => JSON.stringify({ ref: `mem:${row.id}`, kind: row.kind, text: row.text })).join("\n")}`;
|
|
56
|
+
const about = [...new Set(cluster.sources.flatMap(row => [...row.about, ...(row.task ? [`task:${row.task}`] : [])]))].sort();
|
|
57
|
+
if (text.length > 3000 || about.length > 32 || about.some(node => node.length > 400)) { oversized++; continue; }
|
|
58
|
+
const input: Extract<CandidateInput, { kind: "consolidation" }> = { kind: "consolidation", project, action: cluster.action,
|
|
59
|
+
targets: cluster.sources.map(row => ({ id: row.id, revision: candidateTargetRevision(row) })), text, about, noteKind: cluster.action === "recurring-fix" ? "fail" : first.kind };
|
|
60
|
+
check(); if (await policy.screen(input)) prepared.push(input);
|
|
61
|
+
}
|
|
62
|
+
check();
|
|
63
|
+
const report: ConsolidationReport = { schema: "amem.consolidation/v1", project, candidates: [], engine: clustered.engine, truncated: raw.length === 257 || clustered.truncated, oversized, localExcluded: rows.slice(0, 256).length - synced.length, alreadyReviewed: 0 };
|
|
64
|
+
try {
|
|
65
|
+
// All asynchronous host work finishes before the first candidate write. The ordinary candidate
|
|
66
|
+
// queue cap still applies; a full queue reports the ids already enqueued, never a silent batch.
|
|
67
|
+
// put() is INSERT OR IGNORE: a candidate the person already accepted or rejected keeps its status, so it is neither written again nor reported as queued.
|
|
68
|
+
for (const input of prepared) {
|
|
69
|
+
check();
|
|
70
|
+
const known = candidates.get(candidateInputId(input), project);
|
|
71
|
+
if (known && known.status !== "pending") { report.alreadyReviewed++; continue; }
|
|
72
|
+
const id = candidates.put(input, "public");
|
|
73
|
+
report.candidates.push({ id, action: input.action, sources: input.targets.map(target => `mem:${target.id}`) });
|
|
74
|
+
}
|
|
75
|
+
if (options.model !== undefined) report.model = await proposeModelConsolidations(store, candidates, synced, options.model, { project, actor: options.actor, check, policy });
|
|
76
|
+
} catch { throw new ConsolidationProposalError(report.candidates.map(row => row.id)); }
|
|
77
|
+
return report;
|
|
78
|
+
}
|