@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/cli.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { agentCliRead } from "./cli-agent-reads.ts";
|
|
2
|
+
import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
|
|
1
3
|
import { randomUUID } from "node:crypto";
|
|
2
4
|
import type { Writable } from "node:stream";
|
|
3
5
|
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
6
|
import { proposeConsolidation } from "./memory/consolidate.ts";
|
|
5
|
-
import { createIntentBriefSource } from "./memory/recall.ts";
|
|
7
|
+
import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
|
|
6
8
|
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
7
9
|
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
8
10
|
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
@@ -10,16 +12,17 @@ import { createMemoryMcp } from "./mcp-memory.ts";
|
|
|
10
12
|
import { serveMcp, writeConfirmed } from "./mcp.ts";
|
|
11
13
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
12
14
|
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
13
|
-
import { vaultCandidates } from "./memory/vault.ts";
|
|
15
|
+
import { vaultCandidates, vaultCandidatesText, vaultCandidatesAllText } from "./memory/vault.ts";
|
|
16
|
+
import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
14
17
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
15
18
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
16
19
|
import { join } from "node:path";
|
|
17
20
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
18
|
-
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
21
|
+
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
19
22
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
20
23
|
import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
|
|
21
24
|
import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
22
|
-
import { capacityReport, capacityText } from "./memory/capacity.ts";
|
|
25
|
+
import { capacityReport, capacityText, capacityApplyText, capacitySplitText } from "./memory/capacity.ts";
|
|
23
26
|
import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
|
|
24
27
|
|
|
25
28
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
@@ -27,9 +30,9 @@ import { completionScript } from "./completion.ts";
|
|
|
27
30
|
import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
|
|
28
31
|
import { realPath } from "./memory/platform.ts";
|
|
29
32
|
import { hashJSON, readSelector, resolveProfile } from "./memory/generation.ts";
|
|
30
|
-
import { migrate } from "./memory/migrate.ts";
|
|
31
|
-
import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
|
|
32
|
-
import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
33
|
+
import { migrate, migrationText } from "./memory/migrate.ts";
|
|
34
|
+
import { migrateShards, shardMigrationStatus, shardMigrationText } from "./memory/shard-migration.ts";
|
|
35
|
+
import { applyPrune, planPrune, pruneText, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
33
36
|
import { readCatalogFresh } from "./memory/catalog.ts";
|
|
34
37
|
import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
|
|
35
38
|
import { gcText, runGc } from "./memory/gc.ts";
|
|
@@ -49,9 +52,10 @@ import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
|
49
52
|
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
50
53
|
import { runEditor } from "./memory/editor.ts";
|
|
51
54
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
55
|
+
import { statusText, type StatusView } from "./memory/status-text.ts";
|
|
56
|
+
import { colorEnabled, style, facts, label, PLAIN, type Style } from "./memory/style.ts";
|
|
52
57
|
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
53
58
|
|
|
54
|
-
export const PERSON_ONLY = "amem is for the person: an agent reads memory through its `amem mcp` server (see `amem --help`); the person reviews what agents propose with `amem review`";
|
|
55
59
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
56
60
|
export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
|
|
57
61
|
export interface CliContext {
|
|
@@ -60,7 +64,7 @@ export interface CliContext {
|
|
|
60
64
|
cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
|
|
61
65
|
/** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
|
|
62
66
|
visibility?: (url: string) => Promise<Visibility>;
|
|
63
|
-
/**
|
|
67
|
+
/** Legacy setup fixture seam; explicit apply/undo flags no longer prompt. */
|
|
64
68
|
setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
|
|
65
69
|
/** Narrow seams for `amem note add` and `amem review walk`; each defaults to the real terminal. Fixtures stand in for the person at the keyboard. */
|
|
66
70
|
prompt?: (question: string) => string | null | Promise<string | null>;
|
|
@@ -128,6 +132,13 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
|
|
|
128
132
|
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/** Administrative metadata is checked as a complete value, never by stripping individual fields. */
|
|
136
|
+
function assertAgentAdministrative(value: unknown, policy: ReturnType<typeof configuredPolicy>): void {
|
|
137
|
+
if (cliAttribution()?.actor !== "agent") return;
|
|
138
|
+
let plain: unknown;
|
|
139
|
+
try { plain = JSON.parse(JSON.stringify(value)); } catch { throw Error("Memory operation unavailable or refused; no partial result is shown."); }
|
|
140
|
+
if (!policy.screenStandalone(plain)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
141
|
+
}
|
|
131
142
|
function shardStatePresent(): boolean {
|
|
132
143
|
try { lstatSync(join(memoryHome(), "shards.json")); return true; }
|
|
133
144
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
@@ -154,7 +165,58 @@ function curatedStoreAt(base: string, project: string, opts: StoreOptions): Memo
|
|
|
154
165
|
return new MemoryStore({ ...withAliases, memoryDir: base });
|
|
155
166
|
}
|
|
156
167
|
|
|
168
|
+
/** Fixed metadata only: never include the withheld result or its error cause. */
|
|
169
|
+
class CliPartialApplyError extends Error {
|
|
170
|
+
constructor(readonly appliedCount: number, readonly jsonOutput: boolean) {
|
|
171
|
+
super(appliedCount > 0 ? "Operation partially applied; receipt withheld by policy. Inspect state before retrying."
|
|
172
|
+
: "Operation stopped; the failed target may have changed. Receipt withheld by policy. Inspect state before retrying.");
|
|
173
|
+
}
|
|
174
|
+
render(): string {
|
|
175
|
+
return this.jsonOutput ? JSON.stringify({ ok: false, partial: this.appliedCount > 0, appliedCount: this.appliedCount, failedMayHaveChanged: true, receiptWithheld: true }) : this.message;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
157
179
|
export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
|
|
180
|
+
// Native capture and the MCP server retain their existing surfaces and attribution.
|
|
181
|
+
if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
|
|
182
|
+
const env = context.env ?? process.env, marked = agentMarked(env);
|
|
183
|
+
const peer = !marked ? "person" : env.AGENTHUB_PEER_ID !== undefined ? env.AGENTHUB_PEER_ID || "agent"
|
|
184
|
+
: env.CLAUDECODE !== undefined || env.CLAUDE_CODE_SESSION_ID !== undefined ? "claude" : env.CODEX_THREAD_ID !== undefined ? "codex" : "pi";
|
|
185
|
+
return withCliAttribution({ actor: marked ? "agent" : "person", peer, policy: () => configuredPolicy(memoryHome(), context.cwd ?? process.cwd()) }, async () => {
|
|
186
|
+
const output = await runCliCommand(argv, context);
|
|
187
|
+
if (marked && !argv.includes("--help") && !["--version", "version", "--help", "help", "completion"].includes(argv[0] ?? "")) {
|
|
188
|
+
const parsed = parseCommand(argv);
|
|
189
|
+
if (parsed.definition.mutates !== undefined || parsed.path[0] === "setup") {
|
|
190
|
+
let receipt: unknown = output;
|
|
191
|
+
// Import receipts contain fixed counters named `pii`; they are not row privacy flags.
|
|
192
|
+
// Screen the complete rendered receipt, including every key and value, as text.
|
|
193
|
+
if (!(parsed.path[0] === "import" && parsed.path[1] === "claude-mem")) {
|
|
194
|
+
try { receipt = JSON.parse(output); } catch { /* human receipts remain strings */ }
|
|
195
|
+
}
|
|
196
|
+
try { assertAgentAdministrative(receipt, configuredPolicy(parsed.path[0] === "setup" ? memoryHome({ ...process.env, ...env }) : memoryHome(), context.cwd ?? process.cwd())); }
|
|
197
|
+
catch {
|
|
198
|
+
// The command returned successfully: withholding its receipt cannot undo its effects.
|
|
199
|
+
const failed = receipt !== null && typeof receipt === "object" && "ok" in receipt && receipt.ok === false;
|
|
200
|
+
return parsed.options["--json"] === true ? JSON.stringify({ ok: !failed, completed: !failed, receiptWithheld: true })
|
|
201
|
+
: failed ? "Operation did not complete; receipt withheld by policy. Inspect state before retrying." : "Operation completed; receipt withheld by policy.";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return output;
|
|
206
|
+
}).catch((error: unknown) => {
|
|
207
|
+
// Preserve the authoritative partial outcome, without exposing a cause or a withheld target.
|
|
208
|
+
if (marked && error instanceof DecayApplyError) {
|
|
209
|
+
let readable = false;
|
|
210
|
+
try { readable = configuredPolicy(memoryHome(), context.cwd ?? process.cwd()).screenStandalone(error.report); } catch { /* Withhold if policy is unavailable. */ }
|
|
211
|
+
if (!readable) throw new CliPartialApplyError(error.applied.length, error.jsonOutput);
|
|
212
|
+
const safe = new DecayApplyError(error.report, undefined); safe.jsonOutput = error.jsonOutput; throw safe;
|
|
213
|
+
}
|
|
214
|
+
// Marked administrative failures must not reveal a target's private state or native errors.
|
|
215
|
+
if (marked) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
216
|
+
throw error;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async function runCliCommand(argv: readonly string[], context: CliContext): Promise<string> {
|
|
158
220
|
if (argv.length === 1 && argv[0] === "mcp") {
|
|
159
221
|
const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
|
|
160
222
|
await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
|
|
@@ -171,15 +233,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
171
233
|
const kind = parsed.positionals[0];
|
|
172
234
|
if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi", "qwen"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi|qwen [--since ISO] [--json]");
|
|
173
235
|
const { ingestTranscript } = await import("./ingest.ts");
|
|
174
|
-
|
|
236
|
+
const result = await ingestTranscript(kind as IngestKind, {
|
|
175
237
|
cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
|
|
176
238
|
...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
|
|
177
|
-
})
|
|
239
|
+
});
|
|
240
|
+
return parsed.options["--json"] === true || !(context.stdoutTTY ?? process.stdout.isTTY) ? JSON.stringify(result) : ingestText(kind as IngestKind, result, outputStyle(context));
|
|
178
241
|
}
|
|
179
|
-
//
|
|
242
|
+
// Help and version carry no memory data.
|
|
180
243
|
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
|
|
181
244
|
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
|
|
182
|
-
if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
|
|
183
245
|
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
184
246
|
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
185
247
|
let parsed;
|
|
@@ -195,23 +257,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
195
257
|
throw error;
|
|
196
258
|
}
|
|
197
259
|
const { definition, options, rawArgs: args } = parsed;
|
|
260
|
+
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
198
261
|
if (parsed.path[0] === "setup") {
|
|
199
262
|
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|agy|all [--apply|--undo]");
|
|
200
263
|
const { runSetup } = await import("./setup.ts");
|
|
201
264
|
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
202
265
|
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
|
|
203
|
-
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
|
|
266
|
+
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
|
|
204
267
|
});
|
|
205
268
|
}
|
|
269
|
+
if (identity.actor === "agent") {
|
|
270
|
+
const read = await agentCliRead(parsed, argv, context);
|
|
271
|
+
if (read !== undefined) return read;
|
|
272
|
+
}
|
|
206
273
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
207
274
|
const policyFree = options["--policy-free"] === true;
|
|
208
275
|
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
209
276
|
|| definition.mutates === "confirm" && options["--confirm"] === true;
|
|
210
|
-
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
211
277
|
// F4: the configured policy (the machine's `amem.config.json`, narrowed by the repository's `.amem/config`) applies to every command that used to need
|
|
212
278
|
// `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
|
|
213
279
|
const policyCwd = context.cwd ?? process.cwd();
|
|
214
280
|
const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
|
|
281
|
+
const administrative = <T>(value: T): T => { assertAgentAdministrative(value, policyOf()); return value; };
|
|
215
282
|
let canonicalProject: string | undefined;
|
|
216
283
|
const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
|
|
217
284
|
const commandBinding = (home: string, project: string): (() => void) => {
|
|
@@ -233,6 +300,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
233
300
|
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
234
301
|
} });
|
|
235
302
|
};
|
|
303
|
+
if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
|
|
236
304
|
if (sub === "consolidate") {
|
|
237
305
|
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
238
306
|
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
@@ -246,7 +314,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
246
314
|
const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
|
|
247
315
|
const assertCurrent = () => { binding(); assertProjectActive(base, project); if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n")) throw Error("consolidation project binding changed"); };
|
|
248
316
|
candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
|
|
249
|
-
const report = await proposeConsolidation(store, candidates, { project, actor
|
|
317
|
+
const report = await proposeConsolidation(store, candidates, { project, actor, policy: policyOf, policyVersion: policyKey, assertCurrent });
|
|
250
318
|
return options["--json"] ? JSON.stringify(report, null, 2) : `${report.candidates.length} consolidation candidates queued for amem review; no curated notes changed${report.alreadyReviewed ? `; ${report.alreadyReviewed} already reviewed` : ""}${report.truncated ? "; scan truncated" : ""}${report.oversized ? `; ${report.oversized} proposals too large` : ""}${report.localExcluded ? `; ${report.localExcluded} local sources excluded` : ""}`;
|
|
251
319
|
} finally { candidates?.close(); store.close(); }
|
|
252
320
|
}
|
|
@@ -272,22 +340,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
272
340
|
}
|
|
273
341
|
if ((options["--history"] === true || options["--as-of"] !== undefined) && (options["--all-projects"] === true || options["--tier"] === "repo")) throw Error("historical recall selects this active project's curated store; cross-project/repo history is unavailable");
|
|
274
342
|
if (sub === "migrate") {
|
|
275
|
-
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
343
|
+
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
276
344
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
277
|
-
const mode = options["--
|
|
345
|
+
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
278
346
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
279
347
|
const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
|
|
280
348
|
if (shardRequested) {
|
|
281
349
|
if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
|
|
282
|
-
|
|
350
|
+
if (actor === "agent" && mode !== "dry-run") administrative(await migrateShards({ ...common, mode: "dry-run" }));
|
|
351
|
+
const result = await migrateShards({ ...common, mode });
|
|
352
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : shardMigrationText(result, outputStyle(context));
|
|
283
353
|
}
|
|
284
|
-
|
|
354
|
+
if (actor === "agent" && mode === "deactivate") administrative({ target: common.memoryDir, selector: readSelector(common.memoryDir), mode });
|
|
355
|
+
else if (actor === "agent" && mode !== "dry-run") administrative(await migrate({ ...common, mode: "dry-run" }));
|
|
356
|
+
const result = await migrate({ ...common, mode });
|
|
357
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : migrationText(result, outputStyle(context));
|
|
285
358
|
}
|
|
286
359
|
if (sub === "status") {
|
|
287
360
|
// F1: say where the memory lives and which configuration is in effect. The migration fields keep their names and shape; `home` and `config` are added.
|
|
288
361
|
const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
|
|
289
362
|
const { config, source } = loaded;
|
|
290
|
-
|
|
363
|
+
const status = { home: { path: where.home, source: where.source },
|
|
291
364
|
config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
|
|
292
365
|
...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
|
|
293
366
|
...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
|
|
@@ -295,14 +368,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
295
368
|
candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
|
|
296
369
|
if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
|
|
297
370
|
return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
|
|
298
|
-
...shardMigrationStatus(where.home) }
|
|
371
|
+
...shardMigrationStatus(where.home) };
|
|
372
|
+
return options["--json"] ? JSON.stringify(status, null, 2) : statusText(status as unknown as StatusView, outputStyle(context));
|
|
299
373
|
}
|
|
300
374
|
if (sub === "doctor") {
|
|
301
375
|
// F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
|
|
302
376
|
const where = resolveMemoryHome(), project = boundProject();
|
|
303
377
|
const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
|
|
304
378
|
openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
|
|
305
|
-
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
|
|
379
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report, outputStyle(context));
|
|
306
380
|
}
|
|
307
381
|
if (sub === "prune") {
|
|
308
382
|
const applying = options["--apply"] === true;
|
|
@@ -320,13 +394,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
320
394
|
? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
|
|
321
395
|
: curatedStore(project, { deferPromotions: true }, false);
|
|
322
396
|
try {
|
|
323
|
-
const auth = { project, actor
|
|
324
|
-
|
|
397
|
+
const auth = { project, actor, authorize: () => true };
|
|
398
|
+
const port = store.prunePort();
|
|
399
|
+
let plan: ReturnType<typeof planPrune>;
|
|
400
|
+
if (applying) { try { plan = JSON.parse(input!) as ReturnType<typeof planPrune>; } catch { throw Error("prune plan unavailable or oversized"); } }
|
|
401
|
+
else plan = planPrune(port, { cutoff: String(options["--cutoff"]) }, auth);
|
|
402
|
+
administrative(plan);
|
|
403
|
+
if (actor === "agent") {
|
|
404
|
+
const ids = new Set(plan.rows?.flatMap(row => row.payload.targets.map(target => target.id)) ?? []);
|
|
405
|
+
administrative(port.snapshot().rows.filter(source => ids.has(source.row.id)).map(source => source.row));
|
|
406
|
+
}
|
|
407
|
+
const result = applying ? applyPrune(port, input, auth) : plan;
|
|
408
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : pruneText(result, outputStyle(context));
|
|
325
409
|
} finally { store.close(); }
|
|
326
410
|
}
|
|
327
411
|
if (sub === "decay") {
|
|
328
|
-
const applying = options["--apply"] === true;
|
|
329
|
-
if (
|
|
412
|
+
const applying = options["--apply"] === true && options["--confirm"] === true;
|
|
413
|
+
if (options["--confirm"] === true && options["--apply"] !== true) throw new Error("decay --confirm requires --apply");
|
|
330
414
|
const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
|
|
331
415
|
const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
|
|
332
416
|
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
@@ -337,25 +421,24 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
337
421
|
const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
|
|
338
422
|
const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
|
|
339
423
|
const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
|
|
340
|
-
const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
424
|
+
const preview = administrative(decayPreview(store, project, current)), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
341
425
|
const display = [`${action} preview: ${preview.entries.length} notes older than ${days} days; citation history unknown (${preview.citedExemptions ?? "unknown"} observed retained-positive exemptions)${preview.truncated ? "; truncated" : ""}`,
|
|
342
426
|
...preview.entries.map(entry => `${entry.target.id} revision ${entry.target.revision} ${entry.view.at} ${entry.view.kind}: ${terminalSafe(entry.view.title ?? entry.view.text)}`)].join("\n");
|
|
343
427
|
if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
|
|
344
428
|
if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
|
|
345
429
|
if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
|
|
346
|
-
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
|
|
347
|
-
const ask = context.prompt ?? ((question: string) => prompt(question));
|
|
348
|
-
const answer = await ask(`Type yes to ${action} these ${preview.entries.length} exact entries${options["--forget"] ? " permanently (hidden claude-mem-imported copies require separate review; expanded groups are refused)" : ` into the short tier, expires ${preview.demoteExpiresAt}`}: `);
|
|
349
|
-
if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
|
|
350
430
|
assertCurrent();
|
|
351
|
-
const result = applyDecay(store, preview, { ...current, actor
|
|
431
|
+
const result = applyDecay(store, preview, { ...current, actor, confirmed: true, forget: options["--forget"] === true });
|
|
352
432
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
|
|
353
433
|
} catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
|
|
354
434
|
finally { store.close(); }
|
|
355
435
|
}
|
|
356
436
|
if (sub === "note") {
|
|
357
|
-
// F6: the person writes a curated note.
|
|
358
|
-
const env = context.env ?? process.env, edit =
|
|
437
|
+
// F6: the person writes a curated note. Current attribution and configured policy apply to the complete note.
|
|
438
|
+
const env = context.env ?? process.env, edit = (initial: string) => {
|
|
439
|
+
if (!(context.stdinTTY ?? process.stdin.isTTY)) throw Error("note editor requires a terminal on stdin; provide text or - for non-interactive input");
|
|
440
|
+
return (context.edit ?? ((value: string) => runEditor(env, value)))(initial);
|
|
441
|
+
};
|
|
359
442
|
const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
|
|
360
443
|
const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
|
|
361
444
|
...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
|
|
@@ -379,8 +462,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
379
462
|
if (review) {
|
|
380
463
|
const id = parsed.positionals[0];
|
|
381
464
|
if (sub !== "list" && sub !== "walk" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
|
|
465
|
+
if (sub === "walk" && !(context.stdinTTY ?? process.stdin.isTTY)) throw Error("review walk requires a terminal on stdin; stdout must be a terminal too");
|
|
466
|
+
if (sub === "walk" && context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("review walk prints each candidate and asks about it, so stdout must be a terminal (not redirected to a file or a pipe)");
|
|
467
|
+
if (sub === "walk" && actor === "agent") throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
382
468
|
const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
|
|
383
469
|
try {
|
|
470
|
+
const candidatePermitted = (candidate: import("./memory/candidates.ts").Candidate): boolean => {
|
|
471
|
+
if (actor !== "agent") return true;
|
|
472
|
+
const policy = policyOf();
|
|
473
|
+
if (!policy.screenStandalone(candidate)) return false;
|
|
474
|
+
const input = candidate.input, targets = "targets" in input ? input.targets : "target" in input ? [input.target] : [];
|
|
475
|
+
if (!targets.length && input.kind !== "promotion") return true;
|
|
476
|
+
const targetStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
477
|
+
try {
|
|
478
|
+
if (targets.some(target => { if (!target) return true; const view = targetStore.get(target.id, project); return !view || view.project !== project || !policy.screenStandalone(view); })) return false;
|
|
479
|
+
if (input.kind === "promotion") { const goal = targetStore.goalOf(input.goalId, project); if (!goal || !policy.screenStandalone(goal)) return false; }
|
|
480
|
+
return true;
|
|
481
|
+
} finally { targetStore.close(); }
|
|
482
|
+
};
|
|
483
|
+
if (actor === "agent" && sub !== "list" && sub !== "walk") {
|
|
484
|
+
const selected = candidates.get(id!, project);
|
|
485
|
+
if (!selected || !candidatePermitted(selected)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
486
|
+
}
|
|
384
487
|
if (sub === "walk") {
|
|
385
488
|
// F7 / AC4: each candidate is stamped shown as it is put in front of the person, then accepted, edited (written as the person's own note and the candidate closed), rejected or skipped.
|
|
386
489
|
// The candidate is WRITTEN to the terminal before the question, and stamped shown only after that write succeeded: a person who answers a/e/r must have seen what it is, and a Ctrl-C
|
|
@@ -391,7 +494,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
391
494
|
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
|
|
392
495
|
const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
|
|
393
496
|
// Oldest first: those are the ones nearest their expiry (`list` is newest first).
|
|
394
|
-
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
|
|
497
|
+
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse().filter(candidatePermitted);
|
|
395
498
|
if (!rows.length) return "no candidates pending";
|
|
396
499
|
const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
|
|
397
500
|
const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
|
|
@@ -400,6 +503,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
400
503
|
const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
401
504
|
try {
|
|
402
505
|
const target = viewStore.get(input.target.id, project);
|
|
506
|
+
administrative({ candidate, target });
|
|
403
507
|
return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
|
|
404
508
|
? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
|
|
405
509
|
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
@@ -412,6 +516,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
412
516
|
const step = async (args: string[]) => { try { await runCli(args, context); return true; } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; return false; } };
|
|
413
517
|
for (const [index, candidate] of rows.entries()) {
|
|
414
518
|
assertWalkCurrent();
|
|
519
|
+
if (!candidatePermitted(candidate)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
415
520
|
lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
|
|
416
521
|
assertWalkCurrent();
|
|
417
522
|
markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
|
|
@@ -433,7 +538,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
433
538
|
const asFail = input.kind === "fail" || input.noteKind === "fail";
|
|
434
539
|
try { noteAdd(store, project, asFail ? { kind: "fail", tier: "long", scope: "project", fail: parseFailTemplate(edited), ...(input.kind === "memory" && input.title ? { title: input.title } : {}) } : { kind: input.kind === "memory" ? input.noteKind ?? "finding" : "finding", tier: "long", scope: "project", text: edited, ...(input.kind === "memory" && input.title ? { title: input.title } : {}) }, standalone); }
|
|
435
540
|
finally { store.close(); }
|
|
436
|
-
candidates.reject(candidate.id, { project, actor
|
|
541
|
+
candidates.reject(candidate.id, { project, actor, verdict: "public" }); done.edited++;
|
|
437
542
|
} catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
|
|
438
543
|
} else done.skipped++;
|
|
439
544
|
}
|
|
@@ -447,7 +552,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
447
552
|
return options["--json"] ? JSON.stringify(rows, null, 2) : rows.length ? rows.map(candidate => `${candidate.id} ${candidate.input.kind} ${candidate.status} ${candidate.createdAt}`).join("\n") : "no candidates waiting for review";
|
|
448
553
|
}
|
|
449
554
|
if (sub === "reject") {
|
|
450
|
-
candidates.reject(id!, { project, actor
|
|
555
|
+
candidates.reject(id!, { project, actor, verdict: "public" });
|
|
451
556
|
return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
|
|
452
557
|
}
|
|
453
558
|
const candidate = candidates.get(id!, project);
|
|
@@ -459,7 +564,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
459
564
|
const assertCurrent = commandBinding(store.profile.home, project);
|
|
460
565
|
if (!await policyOf().screen(candidate.input)) throw new Error("candidate refused by the current standalone privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json)");
|
|
461
566
|
assertCurrent();
|
|
462
|
-
const result = candidates.accept(id!, { project, actor
|
|
567
|
+
const result = candidates.accept(id!, { project, actor, verdict: "public", store,
|
|
463
568
|
policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
|
|
464
569
|
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
465
570
|
});
|
|
@@ -470,13 +575,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
470
575
|
if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
|
|
471
576
|
if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
|
|
472
577
|
const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
473
|
-
return JSON.stringify(result, null, 2);
|
|
578
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesAllText(result, outputStyle(context));
|
|
474
579
|
}
|
|
475
580
|
if (sub === "vault-candidates") {
|
|
476
581
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
477
582
|
try {
|
|
478
583
|
const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
479
|
-
return JSON.stringify(result, null, 2);
|
|
584
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesText(result, outputStyle(context));
|
|
480
585
|
} finally { store.close(); }
|
|
481
586
|
}
|
|
482
587
|
if (sub === "eval") {
|
|
@@ -496,26 +601,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
496
601
|
if (sub === "capacity") {
|
|
497
602
|
const share = options["--disk-share"];
|
|
498
603
|
if (share !== undefined && (!Number.isFinite(Number(share)) || Number(share) <= 0 || Number(share) > 1)) throw new Error("--disk-share must be a number in (0, 1]");
|
|
499
|
-
const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
|
|
604
|
+
const report = administrative(capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) }));
|
|
500
605
|
if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
|
|
501
606
|
if (options["--split"] === true) {
|
|
502
607
|
// #15 D7: each shard's own share of the global budget, written to its own config. The same two steps as the home default below, bound to the shards it was made for.
|
|
503
608
|
if (options["--cap-mb"] !== undefined || options["--retention-days"] !== undefined) throw new Error("--split computes each shard's values; do not combine it with --cap-mb or --retention-days");
|
|
504
609
|
const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
|
|
505
|
-
|
|
610
|
+
const result = options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare);
|
|
611
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacitySplitText(result, outputStyle(context));
|
|
506
612
|
}
|
|
507
|
-
//
|
|
613
|
+
// Preview first; --confirm writes the bound home default config.
|
|
508
614
|
const file = join(memoryHome(), "capture-config.json");
|
|
509
615
|
const target: CaptureConfig = {
|
|
510
616
|
capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
|
|
511
617
|
retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
|
|
512
618
|
};
|
|
513
619
|
if (!Number.isFinite(target.capMb) || target.capMb <= 0 || !Number.isFinite(target.retentionDays) || target.retentionDays <= 0) throw new Error("capacity apply needs positive --cap-mb and --retention-days");
|
|
514
|
-
const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
|
|
620
|
+
const preview = administrative({ file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason });
|
|
515
621
|
const previewFile = join(memoryHome(), "capture-config.preview.json");
|
|
516
622
|
if (options["--confirm"] !== true) {
|
|
517
623
|
writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
|
|
518
|
-
|
|
624
|
+
const result = { preview, confirm: "re-run with --confirm to write" };
|
|
625
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
|
|
519
626
|
}
|
|
520
627
|
let approved: { file?: string; current?: unknown; target?: CaptureConfig };
|
|
521
628
|
try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
|
|
@@ -526,7 +633,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
526
633
|
}
|
|
527
634
|
const written = writeCaptureConfigFile(file, target);
|
|
528
635
|
rmSync(previewFile, { force: true });
|
|
529
|
-
|
|
636
|
+
const result = { preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } };
|
|
637
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
|
|
530
638
|
}
|
|
531
639
|
if (sub === "projects") {
|
|
532
640
|
// Archived projects stay listed (the person must see what is cold); a forgotten one is out of the default view, and --all brings it back with its state.
|
|
@@ -544,8 +652,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
544
652
|
return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
|
|
545
653
|
}
|
|
546
654
|
if (sub === "gc") {
|
|
547
|
-
// A preview reads; only --confirm evicts
|
|
655
|
+
// A preview reads; only explicit --confirm evicts, after the administrative preflight.
|
|
548
656
|
const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
|
|
657
|
+
if (actor === "agent") administrative(runGc(memoryHome(), { dryRun: true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) }));
|
|
549
658
|
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
550
659
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
551
660
|
}
|
|
@@ -581,12 +690,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
581
690
|
: `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
|
|
582
691
|
} finally { store.close(); }
|
|
583
692
|
}
|
|
584
|
-
const key = requireShard(memoryHome(), requested);
|
|
693
|
+
const key = administrative(requireShard(memoryHome(), requested));
|
|
585
694
|
if (op === "forget" && options["--confirm"] !== true) {
|
|
586
695
|
const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
|
|
587
696
|
return `preview: forget ${key} (now ${state}; capture ${shard?.captureRows ?? 0} rows, curated ${shardStatePresent() ? shard?.curatedEntries ?? "unavailable" : "unavailable (home not split)"}). Forget is an append-only record that takes the project out of recall and every listing for good; nothing is erased here and it cannot be undone. Re-run with --confirm to record it.`;
|
|
588
697
|
}
|
|
589
|
-
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor
|
|
698
|
+
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor, machine: machineId(memoryHome()).id });
|
|
590
699
|
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
591
700
|
}
|
|
592
701
|
if (sub === "search" && options["--all-projects"] === true) {
|
|
@@ -609,14 +718,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
609
718
|
if (parsed.path[1] === "add") {
|
|
610
719
|
const [from, to] = parsed.positionals;
|
|
611
720
|
if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
|
|
612
|
-
const op = appendAlias(memoryHome(), { alias: from, key: to, actor
|
|
721
|
+
const op = appendAlias(memoryHome(), { alias: from, key: to, actor, machine: machineId(memoryHome()).id });
|
|
613
722
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
|
|
614
723
|
}
|
|
615
724
|
if (parsed.path[1] === "remove") {
|
|
616
|
-
// #27 option 2:
|
|
725
|
+
// #27 option 2: append only, the latest op wins; attribution is not authentication.
|
|
617
726
|
const [from] = parsed.positionals;
|
|
618
727
|
if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
|
|
619
|
-
const op = appendUnalias(memoryHome(), { alias: from, actor
|
|
728
|
+
const op = appendUnalias(memoryHome(), { alias: from, actor, machine: machineId(memoryHome()).id });
|
|
620
729
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
|
|
621
730
|
}
|
|
622
731
|
const ops = listAliases(memoryHome());
|
|
@@ -624,7 +733,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
624
733
|
}
|
|
625
734
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
626
735
|
if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
|
|
627
|
-
// #15 T4 / D6:
|
|
736
|
+
// #15 T4 / D6: repository writes keep their explicit confirmation and visibility fences; amem never runs git.
|
|
628
737
|
const repoCwd = context.cwd ?? process.cwd();
|
|
629
738
|
if (sub === "repo") {
|
|
630
739
|
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
@@ -655,9 +764,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
655
764
|
if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
|
|
656
765
|
// `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
|
|
657
766
|
if (resolveAlias(memoryHome(), view.project) !== project) throw new Error(`${view.id.slice(0, 8)} belongs to another project; only this project's notes can be promoted into its repository`);
|
|
767
|
+
const sourceView = view, sourceRevision = candidateTargetRevision(view);
|
|
768
|
+
if (actor === "agent" && !recallViewIsLive(sourceView, Date.now())) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
769
|
+
administrative(sourceView); // Screen fields the repository representation deliberately drops, too.
|
|
770
|
+
const assertSourceReadable = () => {
|
|
771
|
+
if (actor !== "agent") return;
|
|
772
|
+
const fresh = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
773
|
+
try {
|
|
774
|
+
const current = fresh.get(sourceView.id, project);
|
|
775
|
+
if (!current || candidateTargetRevision(current) !== sourceRevision || !recallViewIsLive(current, fresh.readNow()) || resolveAlias(memoryHome(), current.project) !== project) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
776
|
+
administrative(current);
|
|
777
|
+
} finally { fresh.close(); }
|
|
778
|
+
};
|
|
658
779
|
const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
|
|
659
780
|
if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
|
|
660
781
|
const seen = await repoVisibility(root, context.visibility);
|
|
782
|
+
assertSourceReadable(); // Visibility can await: recheck before either preview or publication.
|
|
783
|
+
administrative({ path: plan.path, line: plan.line });
|
|
661
784
|
const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
|
|
662
785
|
`repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
|
|
663
786
|
if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
|
|
@@ -667,6 +790,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
667
790
|
const given = options["--confirm-public"];
|
|
668
791
|
if (typeof given !== "string" || given.length < 8 || !/^[a-f0-9]+$/.test(given) || !plan.line.id.startsWith(given)) throw new Error(`the repository is ${seen.state === "public" ? "public" : "not verified private"}: repo scope needs your confirmation of this entry: --confirm-public ${entryPrefix} (the first 8 characters of the id in the line above)`);
|
|
669
792
|
}
|
|
793
|
+
assertSourceReadable();
|
|
670
794
|
const written = appendRepoLine(root, plan.line);
|
|
671
795
|
return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
|
|
672
796
|
...(repoLogIgnored(root) ? ["WARNING: git ignores .amem/log.jsonl here, so `git status` will not show it and it cannot be committed until you un-ignore it (a .gitignore or .git/info/exclude rule)."] : [])].join("\n");
|
|
@@ -705,8 +829,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
705
829
|
const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
|
|
706
830
|
const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
|
|
707
831
|
// Reads can trigger held-note reconciliation. Defer it unless this command is one that moves notes across the boundary (promote, sync, import, review accept), and then the configured policy decides each note.
|
|
708
|
-
const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
832
|
+
const open = () => curatedStore(boundProject(), { ...(identity.actor === "agent" ? { deferPromotions: true } : {}), screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
709
833
|
// A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
|
|
834
|
+
if (goal && actor === "agent") {
|
|
835
|
+
const ids = sub === "new" ? repeated(argv, "--depends") : parsed.positionals.slice(0, 1);
|
|
836
|
+
if (ids.length) {
|
|
837
|
+
const targetStore = open();
|
|
838
|
+
try { for (const id of ids) { const target = targetStore.goalOf(id.replace(/^goal:/, ""), boundProject()); if (!target) throw Error("Memory operation unavailable or refused; no partial result is shown."); administrative(target); } }
|
|
839
|
+
finally { targetStore.close(); }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
710
842
|
if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
|
|
711
843
|
onDone: async (done, store) => {
|
|
712
844
|
const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
|
|
@@ -745,10 +877,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
745
877
|
const legacySearch = options["--filter"] === true;
|
|
746
878
|
const explainSearch = sub === "search" && options["--explain"] === true;
|
|
747
879
|
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
880
|
+
const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
|
|
748
881
|
let output: string;
|
|
749
882
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
750
883
|
try {
|
|
751
|
-
output = await withRepoRows(await rawMemoryCommand(
|
|
884
|
+
output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
|
|
752
885
|
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
753
886
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
754
887
|
} catch (error) {
|
|
@@ -771,13 +904,42 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
771
904
|
const policy = configured, db = options["--db"] ? { db: String(options["--db"]) } : {}, limit = options["--limit"] ? { limit: Number(options["--limit"]) } : {}, restart = options["--restart"] === true;
|
|
772
905
|
const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...db, ...limit, restart });
|
|
773
906
|
const capture = captureDays === undefined ? undefined : await importClaudeMemCapture({ base: memoryHome(), project: boundProject(), cwd, days: captureDays, ...db, ...limit, restart, policy: { host: policy } });
|
|
774
|
-
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2)
|
|
907
|
+
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2)
|
|
908
|
+
: claudeMemImportText(JSON.parse(output), work, capture, { captureDays, restart }, outputStyle(context));
|
|
775
909
|
}
|
|
776
910
|
return !options["--json"] && receipt.bridgeStatus
|
|
777
911
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
778
912
|
}
|
|
779
913
|
|
|
780
|
-
|
|
914
|
+
/** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
|
|
915
|
+
function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
|
|
916
|
+
|
|
917
|
+
/** Native ingest remains capture-only and agent-reachable; this formats only its returned receipt. */
|
|
918
|
+
export function ingestText(kind: IngestKind, result: IngestReceipt, s: Style = PLAIN): string {
|
|
919
|
+
const state = result.errors || result.remaining || result.spooled ? "attention" : result.files || result.read ? "complete" : "skipped";
|
|
920
|
+
const reason: string | undefined = result.reason;
|
|
921
|
+
const actions: Record<string, string> = {
|
|
922
|
+
source_unavailable: "check that this client's native transcript root is available, then ingest again",
|
|
923
|
+
source_bound: "review the native source size and supported discovery bounds before retrying",
|
|
924
|
+
cursor_busy: "wait for the active ingest writer, then ingest again",
|
|
925
|
+
scope_changed: "run ingest again from the same repository after its scope is stable",
|
|
926
|
+
schema_or_time: "check the native transcript format and timestamps; unreadable records were skipped",
|
|
927
|
+
unsupported_version: "check the reported writer version against the supported ingest contract before retrying",
|
|
928
|
+
};
|
|
929
|
+
const version = "version" in result && typeof result.version === "string" ? result.version : undefined;
|
|
930
|
+
const action = reason ? actions[reason] ?? "review the receipt before retrying"
|
|
931
|
+
: result.errors ? "review the ingest errors and supported native source contract before retrying"
|
|
932
|
+
: result.remaining ? "run the same ingest command again to continue"
|
|
933
|
+
: result.spooled ? "the next capture write replays rows spooled while the store was busy"
|
|
934
|
+
: state === "skipped" ? "open a native session in this repository before ingesting its transcript" : "none";
|
|
935
|
+
return [`amem ingest ${kind}: ${state}`,
|
|
936
|
+
`${label(s, state === "attention" ? "attention" : state === "skipped" ? "skipped" : "ok")} receipt: ${facts({ files: String(result.files), read: String(result.read), imported: String(result.imported), skipped: String(result.skipped), spooled: String(result.spooled), errors: String(result.errors) })}`,
|
|
937
|
+
`${label(s, result.remaining ? "attention" : "ok")} remaining: ${result.remaining ? "yes" : "no"}`,
|
|
938
|
+
...(reason ? [`${label(s, "attention")} reason: ${reason}${version ? ` (version ${version})` : ""}`] : []),
|
|
939
|
+
`Next action: ${action}`].join("\n");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError || error instanceof CliPartialApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
781
943
|
|
|
782
944
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
783
945
|
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|