@staix/agent-mem 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +51 -42
- package/package.json +4 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +220 -58
- package/src/commands-table.ts +9 -9
- package/src/index.ts +1 -0
- package/src/ingest.ts +21 -4
- package/src/mcp.ts +8 -8
- package/src/memory/candidates.ts +15 -13
- package/src/memory/capacity-split.ts +4 -1
- package/src/memory/capacity.ts +48 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +59 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +63 -10
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +30 -0
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune.ts +28 -1
- package/src/memory/shard-migration.ts +38 -0
- package/src/memory/status-text.ts +47 -0
- package/src/memory/store.ts +5 -4
- package/src/memory/style.ts +40 -0
- package/src/memory/vault.ts +31 -0
- package/src/setup-toml.ts +5 -5
- package/src/setup.ts +130 -40
package/src/commands-table.ts
CHANGED
|
@@ -24,8 +24,8 @@ const filters = { ...json, "--tier": { description: "Memory tier", value: ["shor
|
|
|
24
24
|
export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
25
25
|
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
26
26
|
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
27
|
-
consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after
|
|
28
|
-
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo
|
|
27
|
+
consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after explicit acceptance", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
|
|
28
|
+
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
|
|
29
29
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
30
30
|
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
31
31
|
ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi", "qwen"] }] },
|
|
@@ -64,7 +64,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
64
64
|
} },
|
|
65
65
|
demote: { description: "Demote a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
66
66
|
forget: { description: "Forget a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
67
|
-
note: { description: "Write notes
|
|
67
|
+
note: { description: "Write curated notes through the configured policy (F6)", options: json, commands: {
|
|
68
68
|
add: { description: "Add one curated note: text as arguments, `-` to type it on the terminal until end of input, or no text to open $EDITOR. A fail note takes --approach, --symptom, --cause, --evidence (repeatable) and --retry-when", mutates: true, options: {
|
|
69
69
|
"--kind": { description: "Note kind (default finding)", value: KINDS }, "--tier": { description: "Memory tier (default long)", value: ["short", "long"] },
|
|
70
70
|
"--scope": { description: "Scope (default project)", value: ["project", "shared"] }, "--title": text("One-line title"),
|
|
@@ -101,10 +101,10 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
101
101
|
reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
|
|
102
102
|
walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } } },
|
|
103
103
|
} },
|
|
104
|
-
decay: { description: "Preview old live long-term notes unprotected by open goals; apply only
|
|
105
|
-
...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("
|
|
106
|
-
migrate: { description: "
|
|
107
|
-
...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
|
|
104
|
+
decay: { description: "Preview old live long-term notes unprotected by open goals; apply only with --apply --confirm", mutates: "apply", options: {
|
|
105
|
+
...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Preview exact revisions; combine with --confirm to apply"), "--confirm": flag("Apply the exact revision preview with --apply"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
|
|
106
|
+
migrate: { description: "Preview a generation migration; apply only with --apply, or deactivate it explicitly", mutates: "apply", options: {
|
|
107
|
+
...json, "--apply": flag("Apply the previewed migration"), "--dry-run": flag("Read-only migration receipt (default; kept as an alias)"), "--generation": text("Explicit 32-hex generation ID"),
|
|
108
108
|
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
109
109
|
"--shards": flag("Migrate the curated home into project and user shards"),
|
|
110
110
|
} },
|
|
@@ -120,7 +120,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
120
120
|
...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview"),
|
|
121
121
|
"--split": flag("Write the per-shard split of the global budget to each shard's own config") } },
|
|
122
122
|
} },
|
|
123
|
-
alias: { description: "
|
|
123
|
+
alias: { description: "Explicit project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
|
|
124
124
|
list: { description: "List alias ops" },
|
|
125
125
|
add: { description: "Map one key onto its canonical key (append-only op)", args: [{ name: "from-key" }, { name: "to-key" }], mutates: true },
|
|
126
126
|
remove: { description: "Remove an alias so the key resolves to itself again (append-only op, the latest wins)", args: [{ name: "alias-key" }], mutates: true },
|
|
@@ -197,5 +197,5 @@ export function commandHelp(): string {
|
|
|
197
197
|
return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
|
|
198
198
|
const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
|
|
199
199
|
return ` ${names}${Object.entries(row.options).map(([name, option]) => ` [${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}]`).join("")}`;
|
|
200
|
-
}), "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "
|
|
200
|
+
}), "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "Agent-marked reads use the MCP privacy ceiling. Explicit apply/confirm flags do not require a terminal; review walk and the note editor remain interactive."].join("\n");
|
|
201
201
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./memory/shard-migration.ts";
|
|
|
29
29
|
export * from "./mcp.ts";
|
|
30
30
|
export * from "./mcp-memory.ts";
|
|
31
31
|
export * from "./memory/policy.ts";
|
|
32
|
+
export { configuredPolicy } from "./memory/config.ts";
|
|
32
33
|
export * from "./memory/work-lists.ts";
|
|
33
34
|
export * from "./memory/vault.ts";
|
|
34
35
|
|
package/src/ingest.ts
CHANGED
|
@@ -20,7 +20,9 @@ export interface IngestOptions {
|
|
|
20
20
|
}
|
|
21
21
|
export interface IngestReceipt {
|
|
22
22
|
files: number; read: number; imported: number; skipped: number; spooled: number; remaining: boolean; errors: number;
|
|
23
|
-
reason?: "source_bound" | "source_unavailable" | "schema_or_time" | "cursor_busy" | "scope_changed";
|
|
23
|
+
reason?: "source_bound" | "source_unavailable" | "schema_or_time" | "cursor_busy" | "scope_changed" | "unsupported_version";
|
|
24
|
+
/** With `unsupported_version` (#136): the first native writer version seen that this decoder does not support. A validated version token, never transcript text. */
|
|
25
|
+
version?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
type Scope = "own" | "foreign" | "unknown";
|
|
@@ -30,6 +32,10 @@ const MAX_BYTES = 16 * 1024 * 1024, DEFAULT_BYTES = 4 * 1024 * 1024, MAX_FILES =
|
|
|
30
32
|
const MAX_CURSOR_BYTES = 128 * 1024;
|
|
31
33
|
// Bump after extending the verified Qwen decoder: replay skipped versions with existing capture receipts deduplicating accepted rows.
|
|
32
34
|
const QWEN_CONTRACT = "qwen-0.26.1-v1";
|
|
35
|
+
/** A native writer version as reported in a receipt: a short token only, so no transcript content can pass through it (#136). */
|
|
36
|
+
const VERSION_TOKEN = /^[0-9]{1,6}(\.[0-9]{1,6}){1,3}([-+][0-9A-Za-z.]{1,16})?$/; // a dotted version shape: a bare dashed number (000-00-0000) never qualifies
|
|
37
|
+
const versionToken = (value: unknown): string | undefined => typeof value === "string" && value.length <= 32 && VERSION_TOKEN.test(value) ? value : undefined;
|
|
38
|
+
const KIMI_PROTOCOLS = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"];
|
|
33
39
|
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
34
40
|
const MAX_RECORD = 256 * 1024, MAX_RECORDS = 4096, MAX_DISCOVERY = 4096, MAX_DEPTH = 8, ANCHOR_BYTES = 1024;
|
|
35
41
|
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
@@ -270,6 +276,12 @@ function extract(kind: IngestKind, raw: Record<string, any>, project: string): C
|
|
|
270
276
|
export async function ingestTranscript(kind: IngestKind, options: IngestOptions): Promise<IngestReceipt> {
|
|
271
277
|
const result: IngestReceipt = { files: 0, read: 0, imported: 0, skipped: 0, spooled: 0, remaining: false, errors: 0 };
|
|
272
278
|
const fail = (reason: NonNullable<IngestReceipt["reason"]>, remaining = false) => { result.errors++; result.reason ??= reason; result.remaining ||= remaining; };
|
|
279
|
+
// An unsupported writer version explains its records better than the generic schema reason, so it replaces only that one; cursors and replay are untouched.
|
|
280
|
+
// The version is screened by the current policy every time it would be reported, including one restored from a cursor written under an older policy.
|
|
281
|
+
const unsupported = async (version: string, screen: (value: unknown) => Promise<boolean>) => {
|
|
282
|
+
if (!await screen({ version })) { fail("schema_or_time"); return; }
|
|
283
|
+
result.errors++; if (result.reason === undefined || result.reason === "schema_or_time") { result.reason = "unsupported_version"; result.version = version; }
|
|
284
|
+
};
|
|
273
285
|
const bytes = options.maxBytes ?? DEFAULT_BYTES, count = options.maxFiles ?? DEFAULT_FILES;
|
|
274
286
|
if (!["codex", "kimi", "pi", "qwen"].includes(kind) || !Number.isSafeInteger(bytes) || bytes < 1 || bytes > MAX_BYTES || !Number.isSafeInteger(count) || count < 1 || count > MAX_FILES
|
|
275
287
|
|| options.files !== undefined && (!Array.isArray(options.files) || options.files.length > MAX_FILES || options.files.some(file => typeof file !== "string" || !isAbsolute(file) || file.includes("\0")))
|
|
@@ -392,13 +404,18 @@ export async function ingestTranscript(kind: IngestKind, options: IngestOptions)
|
|
|
392
404
|
else if (context !== undefined) position.scope = permitted && object(context) ? scopeOf(context.cwd) : "unknown";
|
|
393
405
|
else if (raw.cwd !== undefined) position.scope = permitted ? scopeOf(raw.cwd) : "unknown";
|
|
394
406
|
if (kind === "kimi" && raw.type === "metadata") {
|
|
395
|
-
|
|
407
|
+
// A validated unsupported version is kept (not "unknown") so later records can name it; the gate below still refuses it.
|
|
408
|
+
// Supported literals carry no content and are kept as before; only an unsupported token is gated on the policy, so a declined record never lends it to the cursor or receipt.
|
|
409
|
+
position.protocol = typeof raw.protocol_version === "string" && KIMI_PROTOCOLS.includes(raw.protocol_version) ? raw.protocol_version
|
|
410
|
+
: permitted ? versionToken(raw.protocol_version) ?? "unknown" : "unknown";
|
|
396
411
|
}
|
|
397
|
-
|
|
412
|
+
const qwenVersion = kind === "qwen" && !known && permitted ? versionToken(raw.version) : undefined;
|
|
413
|
+
if (!known) { if (qwenVersion !== undefined && qwenVersion !== "0.26.1") await unsupported(qwenVersion, value => policy.screen(value)); else fail("schema_or_time"); }
|
|
398
414
|
const input = known ? extract(kind, raw, project) : undefined;
|
|
399
415
|
if (input) {
|
|
400
416
|
const at = timeOf(raw, kind);
|
|
401
|
-
if (
|
|
417
|
+
if (kind === "kimi" && permitted && at && position.scope !== "unknown" && position.protocol !== "unknown" && versionToken(position.protocol) !== undefined && !KIMI_PROTOCOLS.includes(position.protocol)) { await unsupported(position.protocol, value => policy.screen(value)); result.skipped++; }
|
|
418
|
+
else if (!at || position.scope === "unknown" || kind === "kimi" && !KIMI_PROTOCOLS.includes(position.protocol)) { fail("schema_or_time"); result.skipped++; }
|
|
402
419
|
else if (!permitted || position.scope !== "own" || since !== undefined && at < since) result.skipped++;
|
|
403
420
|
else {
|
|
404
421
|
input.at = at;
|
package/src/mcp.ts
CHANGED
|
@@ -7,7 +7,7 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
|
|
|
7
7
|
const tool = <Name extends string>(name: Name, description: string, inputSchema: ReturnType<typeof object>, readOnlyHint = true) => ({ name, description, inputSchema,
|
|
8
8
|
annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } });
|
|
9
9
|
|
|
10
|
-
/** Storage scope comes from startup, never tool arguments;
|
|
10
|
+
/** Storage scope comes from startup, never tool arguments. MCP proposes candidates only; the CLI accepts or rejects them under the current policy and records actor/peer. */
|
|
11
11
|
export const MCP_TOOLS = [
|
|
12
12
|
tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" }, explain: { type: "boolean" } }, ["query"])),
|
|
13
13
|
tool("amem_evidence", "Return a bounded bundle of cited, live curated entries for a question. Untrusted memory data only: no answer or model call. Capture, repo-only notes, pending candidates and model answers are not evidence.", object({ question: string(1000), limit: { type: "integer", minimum: 1, maximum: 20 }, maxBytes: { type: "integer", minimum: 256, maximum: 8192 } }, ["question"])),
|
|
@@ -18,11 +18,11 @@ export const MCP_TOOLS = [
|
|
|
18
18
|
tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })),
|
|
19
19
|
tool("amem_work_state_write", "Append scalar project work-list fields; null clears, task selects an item, list status done closes.", object({ list: string(200), fields: { type: "object", minProperties: 1, maxProperties: 64,
|
|
20
20
|
additionalProperties: { type: ["string", "number", "boolean", "null"], maxLength: 4000 } } }, ["list", "fields"]), false),
|
|
21
|
-
tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen.
|
|
21
|
+
tool("amem_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Review and accept or reject through the CLI under current policy; this tool never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] },
|
|
22
22
|
approach: string(4000), symptom: string(4000), cause: string(4000), evidenceRefs: { type: "array", minItems: 1, maxItems: 16, items: string(400) }, retryWhen: string(4000) }), false),
|
|
23
|
-
tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters.
|
|
24
|
-
tool("amem_vault_candidates", "List screened durable curated notes
|
|
25
|
-
tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it
|
|
23
|
+
tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. Review and accept or reject through the CLI under current policy before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false),
|
|
24
|
+
tool("amem_vault_candidates", "List screened durable curated notes for review before moving them to a vault. Read only.", object({ limit })),
|
|
25
|
+
tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it requires the matching CLI preview and `--confirm`. This tool has no apply operation.", object({})),
|
|
26
26
|
] as const;
|
|
27
27
|
export type McpToolName = typeof MCP_TOOLS[number]["name"];
|
|
28
28
|
export type McpHandlers = Record<McpToolName, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
|
|
@@ -123,10 +123,10 @@ export async function serveMcp(options: McpOptions): Promise<void> {
|
|
|
123
123
|
if (text === undefined || Buffer.byteLength(text) > RESULT_BYTES) throw new Error("result unavailable");
|
|
124
124
|
result = { content: [{ type: "text", text }] }; observedValue = value; succeeded = true;
|
|
125
125
|
} catch (failure) {
|
|
126
|
-
// Every failure is opaque to the model except one named, fixed, text-free condition: the
|
|
127
|
-
// tells the
|
|
126
|
+
// Every failure is opaque to the model except one named, fixed, text-free condition: the candidate queue is full. It carries no stored content, so it is safe to say, and it
|
|
127
|
+
// tells the caller to stop proposing and use `amem review` instead of retrying blindly.
|
|
128
128
|
const full = typeof failure === "object" && failure !== null && (failure as { code?: unknown }).code === "candidate_queue_full";
|
|
129
|
-
result = { isError: true, content: [{ type: "text", text: full ? "The
|
|
129
|
+
result = { isError: true, content: [{ type: "text", text: full ? "The review queue is full: no more candidates are accepted. Do not retry; review pending candidates through the screened CLI." : "Memory operation unavailable or refused; no partial result is shown." }] };
|
|
130
130
|
}
|
|
131
131
|
if (succeeded && options.onEmitted) {
|
|
132
132
|
await writeConfirmed(output, JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
package/src/memory/candidates.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { chmodSync, mkdirSync } from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { CANDIDATE_SHOWN_GRACE_MS, hasShownColumn } from "./candidate-pending.ts";
|
|
6
6
|
import { oneLine } from "./note-add.ts";
|
|
7
|
+
import { cliAttribution } from "./cli-attribution.ts";
|
|
7
8
|
import { memoryHome } from "./home.ts";
|
|
8
9
|
import { realPath } from "./platform.ts";
|
|
9
10
|
import { stripPrivateDeep } from "./private-spans.ts";
|
|
@@ -43,7 +44,7 @@ export interface CounterValues { rendered?: number | null; delivered?: number |
|
|
|
43
44
|
export interface Counter extends CounterValues { id: string; key: CounterKey; createdAt: string; rendered: number | null; delivered: number | null; cited: number | null }
|
|
44
45
|
export interface CandidateAcceptance {
|
|
45
46
|
project: string;
|
|
46
|
-
/** The host binds this to
|
|
47
|
+
/** The host binds this to CLI attribution; it is not an authentication token. */
|
|
47
48
|
actor: "person" | "agent";
|
|
48
49
|
verdict: CandidateVerdict;
|
|
49
50
|
store: MemoryStore;
|
|
@@ -63,7 +64,8 @@ const text = (value: unknown, max = 4000): value is string => typeof value === "
|
|
|
63
64
|
const entryId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
64
65
|
const candidateId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
65
66
|
const publicOnly = (verdict: CandidateVerdict) => { if (verdict !== "public") throw new Error("candidate data refused by host privacy verdict"); };
|
|
66
|
-
const
|
|
67
|
+
const validActor = (actor: string) => { if (actor !== "person" && actor !== "agent") throw new Error("candidate actor must be person or agent"); };
|
|
68
|
+
const attributionPeer = (actor: "person" | "agent") => cliAttribution()?.peer ?? actor;
|
|
67
69
|
const validTarget = (value: CandidateTarget | undefined) => value !== undefined && entryId(value.id) && candidateId(value.revision);
|
|
68
70
|
function validate(input: CandidateInput): void {
|
|
69
71
|
if (!input || !text(input.project, 400)) throw new Error("invalid candidate project");
|
|
@@ -126,10 +128,10 @@ function retryLocked(work: () => void): void {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
/** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it
|
|
131
|
+
/** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it. */
|
|
130
132
|
export class CandidateQueueFull extends Error {
|
|
131
133
|
readonly code = "candidate_queue_full";
|
|
132
|
-
constructor(readonly waiting: number) { super(`${waiting} unshown candidates await
|
|
134
|
+
constructor(readonly waiting: number) { super(`${waiting} unshown candidates await review; no more are accepted until the queue is reviewed. Run \`amem review\``); this.name = "CandidateQueueFull"; }
|
|
133
135
|
}
|
|
134
136
|
export class CandidateStore {
|
|
135
137
|
readonly dir: string;
|
|
@@ -253,7 +255,7 @@ export class CandidateStore {
|
|
|
253
255
|
return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload) as CounterKey, createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })).filter(row => !entry || row.key.entryId === entry);
|
|
254
256
|
}
|
|
255
257
|
reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
|
|
256
|
-
|
|
258
|
+
validActor(context.actor); publicOnly(context.verdict);
|
|
257
259
|
this.db.transaction(() => {
|
|
258
260
|
const candidate = this.get(id, context.project);
|
|
259
261
|
if (!candidate) throw new Error("candidate unavailable");
|
|
@@ -263,7 +265,7 @@ export class CandidateStore {
|
|
|
263
265
|
}).immediate();
|
|
264
266
|
}
|
|
265
267
|
accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
266
|
-
|
|
268
|
+
validActor(context.actor); publicOnly(context.verdict);
|
|
267
269
|
if (realPath(context.store.profile.home) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
|
|
268
270
|
return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
|
|
269
271
|
}
|
|
@@ -292,7 +294,7 @@ export class CandidateStore {
|
|
|
292
294
|
scope: data.kind === "correction" ? target!.scope : "project",
|
|
293
295
|
// A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
|
|
294
296
|
tier: data.kind === "correction" && target!.local ? "short" : "long",
|
|
295
|
-
kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer:
|
|
297
|
+
kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: attributionPeer(context.actor), source: "person", about: [...target?.about ?? []],
|
|
296
298
|
text: data.kind === "correction" ? acceptedCorrectionText(data, id) : `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
|
|
297
299
|
if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
|
|
298
300
|
if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
|
|
@@ -303,7 +305,7 @@ export class CandidateStore {
|
|
|
303
305
|
// ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
|
|
304
306
|
// automatic replay; add explicit person-audited recovery before offering background acceptance retries.
|
|
305
307
|
const noteId = append();
|
|
306
|
-
if (data.kind === "correction" && !context.store.supersede(target!.id, noteId,
|
|
308
|
+
if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, context.actor, context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
|
|
307
309
|
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
|
|
308
310
|
return { candidateId: id, noteId, alreadyAccepted: false };
|
|
309
311
|
});
|
|
@@ -316,13 +318,13 @@ export class CandidateStore {
|
|
|
316
318
|
}
|
|
317
319
|
private acceptConsolidation(candidate: Candidate, data: Extract<CandidateInput, { kind: "consolidation" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
318
320
|
if (typeof context.policyVersion !== "function") throw Error("consolidation requires a policy revision guard");
|
|
319
|
-
const boundStore = context.store, boundProject = context.project, boundPolicy = context.policy;
|
|
321
|
+
const boundStore = context.store, boundProject = context.project, boundActor = context.actor, boundPolicy = context.policy;
|
|
320
322
|
const policyVersion = context.policyVersion(); if (typeof policyVersion !== "string") throw Error("consolidation policy revision unavailable");
|
|
321
323
|
return context.store.withCurrentEntries(data.targets.map(target => target.id), context.project, () => {
|
|
322
324
|
const sourcePlacements = context.store.entryPlacements(data.targets.map(target => target.id), context.project);
|
|
323
325
|
if (data.targets.some(target => sourcePlacements.get(target.id) !== "synced")) throw Error("consolidation requires verified synced source placement");
|
|
324
326
|
const policyCurrent = () => {
|
|
325
|
-
if (context.actor !==
|
|
327
|
+
if (context.actor !== boundActor || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed");
|
|
326
328
|
if (context.policyVersion!() !== policyVersion) throw Error("consolidation policy changed");
|
|
327
329
|
};
|
|
328
330
|
// One placement proof per locked section: `sourcePlacements` (read once inside withCurrentEntries, whose fences stay held) serves every check before the append section;
|
|
@@ -341,7 +343,7 @@ export class CandidateStore {
|
|
|
341
343
|
return current;
|
|
342
344
|
};
|
|
343
345
|
const sources = check(), first = sources[0]!;
|
|
344
|
-
const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer:
|
|
346
|
+
const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: attributionPeer(context.actor), source: "person", about: [...data.about],
|
|
345
347
|
text: `${ACCEPTED_CONSOLIDATION_PREFIX}${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` };
|
|
346
348
|
if (note.text.length > 4000) throw Error("accepted consolidation exceeds the v1 note limit");
|
|
347
349
|
for (const source of sources) if (context.policy(structuredClone(candidate), structuredClone(source), structuredClone(note)) !== true) throw Error("consolidation refused by current host policy");
|
|
@@ -352,7 +354,7 @@ export class CandidateStore {
|
|
|
352
354
|
const noteId = append(), applied: string[] = [];
|
|
353
355
|
try {
|
|
354
356
|
if (data.action !== "recurring-fix") for (const target of data.targets) {
|
|
355
|
-
if (!context.store.supersede(target.id, noteId,
|
|
357
|
+
if (!context.store.supersede(target.id, noteId, context.actor, context.project)) throw Error("supersession refused");
|
|
356
358
|
applied.push(target.id);
|
|
357
359
|
}
|
|
358
360
|
} catch {
|
|
@@ -385,7 +387,7 @@ export class CandidateStore {
|
|
|
385
387
|
resolve();
|
|
386
388
|
const replay = this.beginAcceptance(candidate.id, context.project); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
|
|
387
389
|
// A thrown append remains accepting: its authored-row outcome needs person-audited recovery.
|
|
388
|
-
const noteId = context.store.promote(target.id,
|
|
390
|
+
const noteId = context.store.promote(target.id, context.actor, context.project);
|
|
389
391
|
if (noteId === undefined) {
|
|
390
392
|
this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'accepting'").run(candidate.id, this.scope(context.project));
|
|
391
393
|
throw Error("promotion unavailable or declined; candidate remains pending");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertCliPolicy } from "./cli-attribution.ts";
|
|
1
2
|
import { lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { basename, join } from "node:path";
|
|
3
4
|
import { capacityReport, type ShardSplit } from "./capacity.ts";
|
|
@@ -8,7 +9,7 @@ import { machineId } from "./store.ts";
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* #15 T5 / D7: `amem capacity apply --split`. Writes each shard's own capture config (`machine/<id>/config.json`, which overrides the home default) with its
|
|
11
|
-
* share of the global budget.
|
|
12
|
+
* share of the global budget. Like the other apply, it uses two steps:
|
|
12
13
|
* a preview that records which shards, which current configs and which target values the person is shown, and a `--confirm` that writes exactly those
|
|
13
14
|
* target values and nothing recomputed. It refuses unless the set of shards and every shard's current config are unchanged, and unless the preview is younger
|
|
14
15
|
* than `PREVIEW_MAX_AGE_MS`: a week-old preview cannot write a cap the person never saw, and free disk drifting a little between the two steps changes nothing
|
|
@@ -46,6 +47,7 @@ export function previewSplit(base: string, opts: { diskShare?: number; now?: ()
|
|
|
46
47
|
if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
|
|
47
48
|
const found = targets(base, report.split);
|
|
48
49
|
const preview: SplitPreview = { at: (opts.now ?? Date.now)(), split: found.targets, skipped: found.skipped };
|
|
50
|
+
assertCliPolicy(preview);
|
|
49
51
|
writeFileSync(previewPath(base), JSON.stringify(preview) + "\n", { mode: 0o600 });
|
|
50
52
|
return { ...preview, confirm: "re-run with --confirm to write each shard's share" };
|
|
51
53
|
}
|
|
@@ -66,6 +68,7 @@ export function applySplit(base: string, opts: { diskShare?: number; now?: () =>
|
|
|
66
68
|
// What was previewed is what is written: the same shards, each with the config the person saw replaced by the target the person saw.
|
|
67
69
|
const identity = (rows: SplitTarget[]) => JSON.stringify(rows.map(({ key, dir, current }) => ({ key, dir, current })));
|
|
68
70
|
if (!Array.isArray(approved.split) || identity(approved.split) !== identity(live)) throw new Error("capacity preview changed (a shard appeared or went, or a shard's config moved); re-run without --confirm");
|
|
71
|
+
assertCliPolicy({ approved, live });
|
|
69
72
|
const written = approved.split.map(row => {
|
|
70
73
|
const next = writeCaptureConfigFile(join(row.dir, "config.json"), row.target);
|
|
71
74
|
return { key: row.key, cap_mb: next.capMb ?? row.target.capMb, retention_days: next.retentionDays ?? row.target.retentionDays };
|
package/src/memory/capacity.ts
CHANGED
|
@@ -8,17 +8,18 @@ export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./me
|
|
|
8
8
|
import { walkShards } from "./catalog.ts";
|
|
9
9
|
import { lifecycleResolver } from "./lifecycle.ts";
|
|
10
10
|
import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
|
|
11
|
+
import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* #13 R7: the capacity advisor. `capacityReport()` reads this machine's load and proposes capture bounds; the
|
|
14
15
|
* `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
|
|
15
|
-
*
|
|
16
|
+
* marked CLI calls may read the proposal; the CLI applies only after a matching preview and `--confirm`.
|
|
16
17
|
*
|
|
17
18
|
* Proposal rule (owner decision, 2026-10-17, item 2): the proposed cap and retention are the largest values whose projection stays within the
|
|
18
19
|
* disk-share bound (default 2% of free space, configurable) and whose measured search and hook readings stay within R2/R3, and they may be above
|
|
19
20
|
* the current ones. The cap is the disk-share bound itself; retention is the share over the daily growth, up to a 365-day ceiling. R2 halves the cap
|
|
20
21
|
* and R3 halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`,
|
|
21
|
-
* `R3 search RSS`, `R2 hook cost`. The advisor never writes:
|
|
22
|
+
* `R3 search RSS`, `R2 hook cost`. The advisor never writes: the CLI applies only after a preview and matching `--confirm`.
|
|
22
23
|
*
|
|
23
24
|
* Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
|
|
24
25
|
* `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
|
|
@@ -60,7 +61,7 @@ export interface CapacityReport {
|
|
|
60
61
|
current: CaptureConfig;
|
|
61
62
|
diskShare: number;
|
|
62
63
|
proposal: { capMb: number; retentionDays: number; reason: string };
|
|
63
|
-
/** #15 D7: the per-shard split of the global capture budget (the disk share, less what forgotten shards still hold), one row per non-forgotten shard. A proposal
|
|
64
|
+
/** #15 D7: the per-shard split of the global capture budget (the disk share, less what forgotten shards still hold), one row per non-forgotten shard. A proposal; applying it requires a matching preview and `--confirm`. */
|
|
64
65
|
split: ShardSplit[];
|
|
65
66
|
/** Why `split` is empty although shards exist: the budget cannot give each shard its 1 MB minimum. Absent when the split was made. */
|
|
66
67
|
splitRefused?: string;
|
|
@@ -222,7 +223,7 @@ export function capacityReport(opts: {
|
|
|
222
223
|
};
|
|
223
224
|
const diskShare = opts.diskShare ?? 0.02;
|
|
224
225
|
// The single proposal below is one shard's bounds (the home default). #15 T5 (D7): `split` shares the machine-wide budget across the shards, so a
|
|
225
|
-
// home with N shards no longer holds N x cap once
|
|
226
|
+
// home with N shards no longer holds N x cap once `amem capacity apply --split --confirm` writes it.
|
|
226
227
|
const budgetBytes = diskShare * disk.freeBytes;
|
|
227
228
|
// Growth (owner decision 2026-10-17, item 2): propose the largest cap and retention that fit the disk share, above the current values when there is room.
|
|
228
229
|
// The cap's ceiling is the disk-share bound itself. A person's own setting above the retention ceiling is never proposed down for the ceiling alone.
|
|
@@ -280,3 +281,46 @@ export function capacityText(report: CapacityReport): string {
|
|
|
280
281
|
...(report.split.length ? ["split of the global budget per shard (a proposal: amem capacity apply --split):", ...report.split.map(row => ` ${row.key}: cap ${row.capMb} MB, retention ${row.retentionDays} days (${row.reason})`)] : []),
|
|
281
282
|
].join("\n");
|
|
282
283
|
}
|
|
284
|
+
|
|
285
|
+
/** Human-readable `capacity apply` result. JSON output remains the caller's responsibility. */
|
|
286
|
+
export function capacityApplyText(result: {
|
|
287
|
+
preview: { file: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string };
|
|
288
|
+
confirm?: string;
|
|
289
|
+
written?: { cap_mb?: number; retention_days?: number };
|
|
290
|
+
}, s: Style = PLAIN): string {
|
|
291
|
+
const applied = result.written !== undefined;
|
|
292
|
+
return [
|
|
293
|
+
`amem capacity apply: ${applied ? s.ok("complete") : s.attention("preview")}`,
|
|
294
|
+
`${label(s, applied ? "ok" : "attention")} target: ${singleLine(result.preview.file)}`,
|
|
295
|
+
`${label(s, "ok")} current: ${facts({ cap_mb: result.preview.current.capMb, retention_days: result.preview.current.retentionDays }) || "defaults"}`,
|
|
296
|
+
`${label(s, "attention")} proposed: ${facts({ cap_mb: result.preview.target.capMb, retention_days: result.preview.target.retentionDays })}`,
|
|
297
|
+
`${label(s, "ok")} reason: ${singleLine(result.preview.reason)}`,
|
|
298
|
+
...(applied ? [`${label(s, "ok")} written: ${facts(result.written!) || "no settings reported"}`] : []),
|
|
299
|
+
`Next action: ${applied ? "none" : "re-run the same capacity apply command with --confirm, keeping all other arguments"}`,
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Human-readable `capacity apply --split` result, retaining skipped shard identities. */
|
|
304
|
+
export function capacitySplitText(result:
|
|
305
|
+
| { at: number; split: { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }[]; skipped: string[]; confirm: string }
|
|
306
|
+
| { split: { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }[]; written: { key: string; cap_mb: number; retention_days: number }[] },
|
|
307
|
+
s: Style = PLAIN,
|
|
308
|
+
): string {
|
|
309
|
+
const applied = "written" in result;
|
|
310
|
+
const rows = result.split.map(row => [
|
|
311
|
+
`${label(s, applied ? "ok" : "attention")} shard: ${singleLine(row.key)}`,
|
|
312
|
+
`${label(s, applied ? "ok" : "attention")} target: ${singleLine(row.dir)}`,
|
|
313
|
+
`${label(s, "ok")} current: ${facts({ cap_mb: row.current.capMb, retention_days: row.current.retentionDays }) || "defaults"}`,
|
|
314
|
+
`${label(s, applied ? "ok" : "attention")} proposed: ${facts({ cap_mb: row.target.capMb, retention_days: row.target.retentionDays })}`,
|
|
315
|
+
`${label(s, "ok")} reason: ${singleLine(row.reason)}`,
|
|
316
|
+
].join("\n"));
|
|
317
|
+
const skipped = "skipped" in result ? result.skipped : [];
|
|
318
|
+
return [
|
|
319
|
+
`amem capacity apply --split: ${applied ? s.ok("complete") : s.attention("preview")}`,
|
|
320
|
+
...(result.split.length ? [] : [`${label(s, "skipped")} targets: none`]),
|
|
321
|
+
...rows,
|
|
322
|
+
...skipped.map(key => `${label(s, "skipped")} skipped shard: ${singleLine(key)} (no local shard config to write)`),
|
|
323
|
+
...(applied ? result.written.map(row => `${label(s, "ok")} written ${singleLine(row.key)}: ${facts({ cap_mb: row.cap_mb, retention_days: row.retention_days })}`) : []),
|
|
324
|
+
`Next action: ${applied || !result.split.length ? "none" : "re-run the same capacity apply --split command with --confirm, keeping all other arguments"}`,
|
|
325
|
+
].join("\n");
|
|
326
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/** CLI labels are attribution, never authentication or permission to bypass policy. */
|
|
4
|
+
export interface CliAttribution { actor: "person" | "agent"; peer: string; policy?: () => { screenStandalone(value: unknown): boolean } }
|
|
5
|
+
const scope = new AsyncLocalStorage<Readonly<CliAttribution>>();
|
|
6
|
+
|
|
7
|
+
/** Preserve library defaults outside an ordinary CLI invocation, including concurrent calls. */
|
|
8
|
+
export function cliAttribution(): Readonly<CliAttribution> | undefined { return scope.getStore(); }
|
|
9
|
+
export function withCliAttribution<T>(identity: CliAttribution, work: () => T): T { return scope.run(Object.freeze({ ...identity, peer: identity.actor === "agent" && identity.peer === "person" ? "agent" : identity.peer }), work); }
|
|
10
|
+
|
|
11
|
+
/** Display metadata derived from the existing peer field; never a claim of authentication. */
|
|
12
|
+
export const noteDisplayActor = (peer: string): "person" | "agent" => peer === "person" ? "person" : "agent";
|
|
13
|
+
|
|
14
|
+
/** Internal CLI-only guard: library callers outside a CLI scope retain their existing contract. */
|
|
15
|
+
export function assertCliPolicy(value: unknown): void {
|
|
16
|
+
const identity = scope.getStore();
|
|
17
|
+
if (identity?.actor !== "agent") return;
|
|
18
|
+
try {
|
|
19
|
+
const plain: unknown = JSON.parse(JSON.stringify(value));
|
|
20
|
+
if (identity.policy?.().screenStandalone(plain) === true) return;
|
|
21
|
+
} catch { /* Withhold the complete value, including configuration errors. */ }
|
|
22
|
+
throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
23
|
+
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { cliAttribution, noteDisplayActor } from "./cli-attribution.ts";
|
|
1
2
|
import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "./store.ts";
|
|
2
3
|
import { syncMemory, type SyncOptions } from "./sync.ts";
|
|
3
|
-
import { importClaudeMem } from "./import.ts";
|
|
4
|
+
import { importClaudeMem, type ImportResult, type WorkStateImportResult, type CaptureImportResult } from "./import.ts";
|
|
5
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
4
6
|
import { memoryProjectKey, projectChain } from "./project.ts";
|
|
5
7
|
import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
|
|
6
8
|
import type { GoalPromotionReport } from "./goal-promotion.ts";
|
|
@@ -47,8 +49,8 @@ export interface GoalCommandOptions {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
|
-
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`).
|
|
51
|
-
*
|
|
52
|
+
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Callers provide read visibility, retained-history intent and write policy;
|
|
53
|
+
* ordinary CLI calls carry scoped attribution while library calls keep their existing defaults.
|
|
52
54
|
*/
|
|
53
55
|
/** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
|
|
54
56
|
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] [--capture-days N] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
|
|
@@ -58,10 +60,39 @@ export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${n
|
|
|
58
60
|
export const GOAL_USAGE = goalUsage();
|
|
59
61
|
|
|
60
62
|
const fail = (message: string): never => { throw new Error(message); };
|
|
63
|
+
|
|
64
|
+
/** A person-facing aggregate receipt; import results and their JSON representation stay untouched. */
|
|
65
|
+
export function claudeMemImportText(notes: ImportResult, work: WorkStateImportResult, capture?: CaptureImportResult,
|
|
66
|
+
options: { captureDays?: number; restart?: boolean } = {}, s: Style = PLAIN): string {
|
|
67
|
+
const more = notes.more || work.more || capture?.more === true;
|
|
68
|
+
const row = (name: string, result: { read: number; added: number; more: boolean }, skipped: number, spooled?: number) =>
|
|
69
|
+
`${label(s, result.more || (spooled ?? 0) > 0 ? "attention" : "ok")} ${name}: ${facts({ read: String(result.read), added: String(result.added), skipped: String(skipped), spooled: spooled === undefined ? "n/a" : String(spooled) })}${result.more ? "; more remain" : ""}`;
|
|
70
|
+
const noteSkipped = notes.read - notes.added, workSkipped = work.read - work.added;
|
|
71
|
+
const otherNotes = Math.max(0, noteSkipped - notes.skipped.model - notes.skipped.pii - notes.skipped.secret);
|
|
72
|
+
const duplicateWork = Math.max(0, workSkipped - work.skipped.invalid - work.skipped.policy);
|
|
73
|
+
const lines = [`amem import claude-mem: ${more ? s.attention("run again") : s.ok("complete")}`,
|
|
74
|
+
row("notes", notes, noteSkipped),
|
|
75
|
+
` skipped reasons: ${facts({ model: notes.skipped.model, PII: notes.skipped.pii, secret: notes.skipped.secret, "empty/malformed/already imported": otherNotes }) || "none"}`,
|
|
76
|
+
row("work lists", work, workSkipped),
|
|
77
|
+
` skipped reasons: ${facts({ invalid: work.skipped.invalid, policy: work.skipped.policy, "already imported": duplicateWork }) || "none"}`];
|
|
78
|
+
if (capture) {
|
|
79
|
+
lines.push(row("capture", capture, capture.skipped, capture.spooled));
|
|
80
|
+
if (capture.skipped) lines.push(` skipped reasons: combined ${capture.skipped} (empty/invalid, policy or already imported; no breakdown reported)`);
|
|
81
|
+
} else lines.push(`${label(s, "skipped")} capture: not requested (use --capture-days N for recent capture)`);
|
|
82
|
+
lines.push(more ? options.restart
|
|
83
|
+
? "Next action: run the same import command again without --restart, keeping all other arguments, until all receipts are complete."
|
|
84
|
+
: "Next action: run the same import command again until all receipts are complete."
|
|
85
|
+
: capture?.spooled ? `Next action: ${capture.spooled} capture ${capture.spooled === 1 ? "row was" : "rows were"} spooled while busy; the next capture write (hook event or import adding capture rows) replays them.`
|
|
86
|
+
: "Next action: none; all requested import receipts are complete.");
|
|
87
|
+
if (capture && options.captureDays !== undefined) lines.push(`Capture window: last ${options.captureDays} days. Widening --capture-days later requires --restart.`);
|
|
88
|
+
if (options.restart) lines.push("Restart: selected cursors replayed from the start; existing import receipts prevent duplicates.");
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
61
92
|
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)})` : ""}`;
|
|
62
93
|
function detail(v: View): string[] {
|
|
63
94
|
return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
|
|
64
|
-
...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
|
|
95
|
+
...(cliAttribution() ? [`actor: ${noteDisplayActor(v.peer)}, peer: ${v.peer}`] : []), ...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
export async function rawMemoryCommand(args: string[], cwd: string, opts: MemoryCommandOptions): Promise<string> {
|
|
@@ -77,7 +108,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
77
108
|
if (args.includes("--explain") && (opts.person !== true || opts.rankedSearch !== true || opts.explainSearch !== true)) fail("score explanation requires ranked search and person intent");
|
|
78
109
|
for (let index = rest.indexOf("--explain"); index >= 0; index = rest.indexOf("--explain")) rest.splice(index, 1);
|
|
79
110
|
const store = opts.store ?? opts.open();
|
|
80
|
-
const actor = "user";
|
|
111
|
+
const actor = cliAttribution()?.actor ?? "user";
|
|
112
|
+
const visibleTarget = (view: View | undefined, id: string): View => {
|
|
113
|
+
if (cliAttribution()?.actor === "agent" && (!view || !opts.readable([view]).some(row => row.id === view.id))) fail("Memory operation unavailable or refused; no partial result is shown.");
|
|
114
|
+
return need(view, id);
|
|
115
|
+
};
|
|
81
116
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
82
117
|
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
83
118
|
// #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.
|
|
@@ -146,11 +181,14 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
146
181
|
const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
|
|
147
182
|
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");
|
|
148
183
|
}
|
|
149
|
-
case "promote": { const v =
|
|
150
|
-
case "demote": { const v =
|
|
151
|
-
case "forget": { const
|
|
184
|
+
case "promote": { const v = visibleTarget(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)}`; }
|
|
185
|
+
case "demote": { const v = visibleTarget(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"}`; }
|
|
186
|
+
case "forget": { const target = visibleTarget(store.get(rest[0] ?? fail(usage), project), rest[0]!);
|
|
187
|
+
if (cliAttribution()?.actor === "agent") for (const member of store.forgetTargets(target.id, project)) visibleTarget(member, member.id);
|
|
188
|
+
const v = need(store.forget(target.id, 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)"}`; }
|
|
152
189
|
case "supersede": {
|
|
153
190
|
if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
|
|
191
|
+
if (cliAttribution()?.actor === "agent") for (const id of rest) visibleTarget(store.get(id, project), id);
|
|
154
192
|
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)`);
|
|
155
193
|
}
|
|
156
194
|
case "sync": {
|
|
@@ -164,6 +202,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
164
202
|
case "dedupe": {
|
|
165
203
|
const apply = rest.includes("--apply");
|
|
166
204
|
if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
|
|
205
|
+
if (cliAttribution()?.actor === "agent") {
|
|
206
|
+
const preview = store.dedupe(project, false);
|
|
207
|
+
for (const group of preview.groups) { for (const view of group.members) visibleTarget(view, view.id); visibleTarget(store.get(group.target, project), group.target); }
|
|
208
|
+
}
|
|
167
209
|
const result = store.dedupe(project, apply);
|
|
168
210
|
if (json) return JSON.stringify(result, null, 2);
|
|
169
211
|
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",
|
|
@@ -188,6 +230,11 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
188
230
|
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";
|
|
189
231
|
}
|
|
190
232
|
case "confirm": case "reject": {
|
|
233
|
+
if (cliAttribution()?.actor === "agent") {
|
|
234
|
+
const matches = store.suggestions(project).filter(row => row.id === rest[0] || row.id.startsWith(rest[0] ?? ""));
|
|
235
|
+
if (matches.length !== 1) fail("Memory operation unavailable or refused; no partial result is shown.");
|
|
236
|
+
for (const id of [matches[0]!.from, matches[0]!.to]) visibleTarget(store.get(id, project), id);
|
|
237
|
+
}
|
|
191
238
|
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)`);
|
|
192
239
|
return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
|
|
193
240
|
}
|
|
@@ -217,6 +264,7 @@ function leaseText(g: GoalView, me: string, goal: string): string {
|
|
|
217
264
|
* person's. A peer links its own board task with hub_task_propose's `goal`.
|
|
218
265
|
*/
|
|
219
266
|
export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
|
|
267
|
+
const actor = cliAttribution()?.actor ?? "user";
|
|
220
268
|
const json = args.includes("--json");
|
|
221
269
|
const rest = args.filter((a) => a !== "--json");
|
|
222
270
|
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; };
|
|
@@ -251,7 +299,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
251
299
|
const shared = rest.includes("--shared");
|
|
252
300
|
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
|
|
253
301
|
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");
|
|
254
|
-
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor
|
|
302
|
+
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor });
|
|
255
303
|
return `long-term task ${id.slice(0, 8)} created: ${title}`;
|
|
256
304
|
}
|
|
257
305
|
case "link": {
|
|
@@ -264,7 +312,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
264
312
|
const g = need(rest[0]);
|
|
265
313
|
const state = (sub === "done" ? "done" : rest[1]) as GoalState;
|
|
266
314
|
if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
|
|
267
|
-
store.goalState(g.id, state,
|
|
315
|
+
store.goalState(g.id, state, actor);
|
|
268
316
|
if (state === "done" && opts.onDone) {
|
|
269
317
|
const current = store.goalOf(g.id, project);
|
|
270
318
|
if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
|
|
@@ -276,7 +324,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
276
324
|
}
|
|
277
325
|
case "take": {
|
|
278
326
|
const g = need(rest[0]);
|
|
279
|
-
const r = store.take(g.id,
|
|
327
|
+
const r = store.take(g.id, actor, { force: rest.includes("--force") });
|
|
280
328
|
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`);
|
|
281
329
|
return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
|
|
282
330
|
}
|