@staix/agent-mem 0.12.0 → 0.14.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 +70 -44
- package/package.json +5 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +219 -56
- package/src/commands-table.ts +10 -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/catalog.ts +4 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +63 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +36 -7
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +31 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/prune.ts +28 -1
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +42 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +51 -12
- package/src/memory/style.ts +3 -0
- package/src/memory/sync.ts +141 -5
- package/src/memory/vault.ts +31 -0
- package/src/setup.ts +59 -31
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
|
|
2
|
+
import { agentCliRead } from "./cli-agent-reads.ts";
|
|
3
|
+
import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
|
|
1
4
|
import { randomUUID } from "node:crypto";
|
|
2
5
|
import type { Writable } from "node:stream";
|
|
3
6
|
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
7
|
import { proposeConsolidation } from "./memory/consolidate.ts";
|
|
5
|
-
import { createIntentBriefSource } from "./memory/recall.ts";
|
|
8
|
+
import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
|
|
6
9
|
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
7
10
|
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
8
11
|
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
@@ -10,16 +13,17 @@ import { createMemoryMcp } from "./mcp-memory.ts";
|
|
|
10
13
|
import { serveMcp, writeConfirmed } from "./mcp.ts";
|
|
11
14
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
12
15
|
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
13
|
-
import { vaultCandidates } from "./memory/vault.ts";
|
|
16
|
+
import { vaultCandidates, vaultCandidatesText, vaultCandidatesAllText } from "./memory/vault.ts";
|
|
17
|
+
import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
14
18
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
15
19
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
16
20
|
import { join } from "node:path";
|
|
17
21
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
18
|
-
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
22
|
+
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
19
23
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
20
24
|
import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
|
|
21
25
|
import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
22
|
-
import { capacityReport, capacityText } from "./memory/capacity.ts";
|
|
26
|
+
import { capacityReport, capacityText, capacityApplyText, capacitySplitText } from "./memory/capacity.ts";
|
|
23
27
|
import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
|
|
24
28
|
|
|
25
29
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
@@ -27,9 +31,9 @@ import { completionScript } from "./completion.ts";
|
|
|
27
31
|
import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
|
|
28
32
|
import { realPath } from "./memory/platform.ts";
|
|
29
33
|
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";
|
|
34
|
+
import { migrate, migrationText } from "./memory/migrate.ts";
|
|
35
|
+
import { migrateShards, shardMigrationStatus, shardMigrationText } from "./memory/shard-migration.ts";
|
|
36
|
+
import { applyPrune, planPrune, pruneText, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
33
37
|
import { readCatalogFresh } from "./memory/catalog.ts";
|
|
34
38
|
import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
|
|
35
39
|
import { gcText, runGc } from "./memory/gc.ts";
|
|
@@ -50,10 +54,9 @@ import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, typ
|
|
|
50
54
|
import { runEditor } from "./memory/editor.ts";
|
|
51
55
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
52
56
|
import { statusText, type StatusView } from "./memory/status-text.ts";
|
|
53
|
-
import { colorEnabled, style, type Style } from "./memory/style.ts";
|
|
57
|
+
import { colorEnabled, style, facts, label, PLAIN, type Style } from "./memory/style.ts";
|
|
54
58
|
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
55
59
|
|
|
56
|
-
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`";
|
|
57
60
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
58
61
|
export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
|
|
59
62
|
export interface CliContext {
|
|
@@ -62,7 +65,7 @@ export interface CliContext {
|
|
|
62
65
|
cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
|
|
63
66
|
/** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
|
|
64
67
|
visibility?: (url: string) => Promise<Visibility>;
|
|
65
|
-
/**
|
|
68
|
+
/** Legacy setup fixture seam; explicit apply/undo flags no longer prompt. */
|
|
66
69
|
setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
|
|
67
70
|
/** 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. */
|
|
68
71
|
prompt?: (question: string) => string | null | Promise<string | null>;
|
|
@@ -130,6 +133,13 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
|
|
|
130
133
|
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
/** Administrative metadata is checked as a complete value, never by stripping individual fields. */
|
|
137
|
+
function assertAgentAdministrative(value: unknown, policy: ReturnType<typeof configuredPolicy>): void {
|
|
138
|
+
if (cliAttribution()?.actor !== "agent") return;
|
|
139
|
+
let plain: unknown;
|
|
140
|
+
try { plain = JSON.parse(JSON.stringify(value)); } catch { throw Error("Memory operation unavailable or refused; no partial result is shown."); }
|
|
141
|
+
if (!policy.screenStandalone(plain)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
142
|
+
}
|
|
133
143
|
function shardStatePresent(): boolean {
|
|
134
144
|
try { lstatSync(join(memoryHome(), "shards.json")); return true; }
|
|
135
145
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
@@ -156,7 +166,58 @@ function curatedStoreAt(base: string, project: string, opts: StoreOptions): Memo
|
|
|
156
166
|
return new MemoryStore({ ...withAliases, memoryDir: base });
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
/** Fixed metadata only: never include the withheld result or its error cause. */
|
|
170
|
+
class CliPartialApplyError extends Error {
|
|
171
|
+
constructor(readonly appliedCount: number, readonly jsonOutput: boolean) {
|
|
172
|
+
super(appliedCount > 0 ? "Operation partially applied; receipt withheld by policy. Inspect state before retrying."
|
|
173
|
+
: "Operation stopped; the failed target may have changed. Receipt withheld by policy. Inspect state before retrying.");
|
|
174
|
+
}
|
|
175
|
+
render(): string {
|
|
176
|
+
return this.jsonOutput ? JSON.stringify({ ok: false, partial: this.appliedCount > 0, appliedCount: this.appliedCount, failedMayHaveChanged: true, receiptWithheld: true }) : this.message;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
|
|
181
|
+
// Native capture and the MCP server retain their existing surfaces and attribution.
|
|
182
|
+
if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
|
|
183
|
+
const env = context.env ?? process.env, marked = agentMarked(env);
|
|
184
|
+
const peer = !marked ? "person" : env.AGENTHUB_PEER_ID !== undefined ? env.AGENTHUB_PEER_ID || "agent"
|
|
185
|
+
: env.CLAUDECODE !== undefined || env.CLAUDE_CODE_SESSION_ID !== undefined ? "claude" : env.CODEX_THREAD_ID !== undefined ? "codex" : "pi";
|
|
186
|
+
return withCliAttribution({ actor: marked ? "agent" : "person", peer, policy: () => configuredPolicy(memoryHome(), context.cwd ?? process.cwd()) }, async () => {
|
|
187
|
+
const output = await runCliCommand(argv, context);
|
|
188
|
+
if (marked && !argv.includes("--help") && !["--version", "version", "--help", "help", "completion"].includes(argv[0] ?? "")) {
|
|
189
|
+
const parsed = parseCommand(argv);
|
|
190
|
+
if (parsed.definition.mutates !== undefined || parsed.path[0] === "setup") {
|
|
191
|
+
let receipt: unknown = output;
|
|
192
|
+
// Import receipts contain fixed counters named `pii`; they are not row privacy flags.
|
|
193
|
+
// Screen the complete rendered receipt, including every key and value, as text.
|
|
194
|
+
if (!(parsed.path[0] === "import" && parsed.path[1] === "claude-mem")) {
|
|
195
|
+
try { receipt = JSON.parse(output); } catch { /* human receipts remain strings */ }
|
|
196
|
+
}
|
|
197
|
+
try { assertAgentAdministrative(receipt, configuredPolicy(parsed.path[0] === "setup" ? memoryHome({ ...process.env, ...env }) : memoryHome(), context.cwd ?? process.cwd())); }
|
|
198
|
+
catch {
|
|
199
|
+
// The command returned successfully: withholding its receipt cannot undo its effects.
|
|
200
|
+
const failed = receipt !== null && typeof receipt === "object" && "ok" in receipt && receipt.ok === false;
|
|
201
|
+
return parsed.options["--json"] === true ? JSON.stringify({ ok: !failed, completed: !failed, receiptWithheld: true })
|
|
202
|
+
: failed ? "Operation did not complete; receipt withheld by policy. Inspect state before retrying." : "Operation completed; receipt withheld by policy.";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return output;
|
|
207
|
+
}).catch((error: unknown) => {
|
|
208
|
+
// Preserve the authoritative partial outcome, without exposing a cause or a withheld target.
|
|
209
|
+
if (marked && error instanceof DecayApplyError) {
|
|
210
|
+
let readable = false;
|
|
211
|
+
try { readable = configuredPolicy(memoryHome(), context.cwd ?? process.cwd()).screenStandalone(error.report); } catch { /* Withhold if policy is unavailable. */ }
|
|
212
|
+
if (!readable) throw new CliPartialApplyError(error.applied.length, error.jsonOutput);
|
|
213
|
+
const safe = new DecayApplyError(error.report, undefined); safe.jsonOutput = error.jsonOutput; throw safe;
|
|
214
|
+
}
|
|
215
|
+
// Marked administrative failures must not reveal a target's private state or native errors.
|
|
216
|
+
if (marked) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
217
|
+
throw error;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function runCliCommand(argv: readonly string[], context: CliContext): Promise<string> {
|
|
160
221
|
if (argv.length === 1 && argv[0] === "mcp") {
|
|
161
222
|
const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
|
|
162
223
|
await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
|
|
@@ -173,15 +234,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
173
234
|
const kind = parsed.positionals[0];
|
|
174
235
|
if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi", "qwen"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi|qwen [--since ISO] [--json]");
|
|
175
236
|
const { ingestTranscript } = await import("./ingest.ts");
|
|
176
|
-
|
|
237
|
+
const result = await ingestTranscript(kind as IngestKind, {
|
|
177
238
|
cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
|
|
178
239
|
...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
|
|
179
|
-
})
|
|
240
|
+
});
|
|
241
|
+
return parsed.options["--json"] === true || !(context.stdoutTTY ?? process.stdout.isTTY) ? JSON.stringify(result) : ingestText(kind as IngestKind, result, outputStyle(context));
|
|
180
242
|
}
|
|
181
|
-
//
|
|
243
|
+
// Help and version carry no memory data.
|
|
182
244
|
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
|
|
183
245
|
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
|
|
184
|
-
if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
|
|
185
246
|
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
186
247
|
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
187
248
|
let parsed;
|
|
@@ -197,6 +258,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
197
258
|
throw error;
|
|
198
259
|
}
|
|
199
260
|
const { definition, options, rawArgs: args } = parsed;
|
|
261
|
+
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
200
262
|
if (parsed.path[0] === "setup") {
|
|
201
263
|
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]");
|
|
202
264
|
const { runSetup } = await import("./setup.ts");
|
|
@@ -205,15 +267,19 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
205
267
|
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
|
|
206
268
|
});
|
|
207
269
|
}
|
|
270
|
+
if (identity.actor === "agent") {
|
|
271
|
+
const read = await agentCliRead(parsed, argv, context);
|
|
272
|
+
if (read !== undefined) return read;
|
|
273
|
+
}
|
|
208
274
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
209
275
|
const policyFree = options["--policy-free"] === true;
|
|
210
276
|
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
211
277
|
|| definition.mutates === "confirm" && options["--confirm"] === true;
|
|
212
|
-
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
213
278
|
// 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
|
|
214
279
|
// `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
|
|
215
280
|
const policyCwd = context.cwd ?? process.cwd();
|
|
216
281
|
const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
|
|
282
|
+
const administrative = <T>(value: T): T => { assertAgentAdministrative(value, policyOf()); return value; };
|
|
217
283
|
let canonicalProject: string | undefined;
|
|
218
284
|
const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
|
|
219
285
|
const commandBinding = (home: string, project: string): (() => void) => {
|
|
@@ -235,6 +301,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
235
301
|
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
236
302
|
} });
|
|
237
303
|
};
|
|
304
|
+
if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
|
|
238
305
|
if (sub === "consolidate") {
|
|
239
306
|
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
240
307
|
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
@@ -248,7 +315,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
248
315
|
const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
|
|
249
316
|
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"); };
|
|
250
317
|
candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
|
|
251
|
-
const report = await proposeConsolidation(store, candidates, { project, actor
|
|
318
|
+
const report = await proposeConsolidation(store, candidates, { project, actor, policy: policyOf, policyVersion: policyKey, assertCurrent });
|
|
252
319
|
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` : ""}`;
|
|
253
320
|
} finally { candidates?.close(); store.close(); }
|
|
254
321
|
}
|
|
@@ -274,16 +341,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
274
341
|
}
|
|
275
342
|
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");
|
|
276
343
|
if (sub === "migrate") {
|
|
277
|
-
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
344
|
+
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
278
345
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
279
|
-
const mode = options["--
|
|
346
|
+
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
280
347
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
348
|
+
if (options["--public-prune-view"] === true) {
|
|
349
|
+
if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
|
|
350
|
+
if (actor === "agent" && mode !== "dry-run") administrative({ command: "migrate --public-prune-view", mode });
|
|
351
|
+
const result = await activatePublicPruneView({ memoryDir: memoryHome(), mode: mode as "dry-run" | "apply" | "resume", authorizedPerson: true, tty: true });
|
|
352
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `public prune view: ${result.mode}; generation ${result.generation}; mask build ${result.maskBuildCheck}; forward-only, no deactivation`;
|
|
353
|
+
}
|
|
281
354
|
const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
|
|
282
355
|
if (shardRequested) {
|
|
283
356
|
if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
|
|
284
|
-
|
|
357
|
+
if (actor === "agent" && mode !== "dry-run") administrative(await migrateShards({ ...common, mode: "dry-run" }));
|
|
358
|
+
const result = await migrateShards({ ...common, mode });
|
|
359
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : shardMigrationText(result, outputStyle(context));
|
|
285
360
|
}
|
|
286
|
-
|
|
361
|
+
if (actor === "agent" && mode === "deactivate") administrative({ target: common.memoryDir, selector: readSelector(common.memoryDir), mode });
|
|
362
|
+
else if (actor === "agent" && mode !== "dry-run") administrative(await migrate({ ...common, mode: "dry-run" }));
|
|
363
|
+
const result = await migrate({ ...common, mode });
|
|
364
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : migrationText(result, outputStyle(context));
|
|
287
365
|
}
|
|
288
366
|
if (sub === "status") {
|
|
289
367
|
// 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.
|
|
@@ -323,13 +401,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
323
401
|
? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
|
|
324
402
|
: curatedStore(project, { deferPromotions: true }, false);
|
|
325
403
|
try {
|
|
326
|
-
const auth = { project, actor
|
|
327
|
-
|
|
404
|
+
const auth = { project, actor, authorize: () => true };
|
|
405
|
+
const port = store.prunePort();
|
|
406
|
+
let plan: ReturnType<typeof planPrune>;
|
|
407
|
+
if (applying) { try { plan = JSON.parse(input!) as ReturnType<typeof planPrune>; } catch { throw Error("prune plan unavailable or oversized"); } }
|
|
408
|
+
else plan = planPrune(port, { cutoff: String(options["--cutoff"]) }, auth);
|
|
409
|
+
administrative(plan);
|
|
410
|
+
if (actor === "agent") {
|
|
411
|
+
const ids = new Set(plan.rows?.flatMap(row => row.payload.targets.map(target => target.id)) ?? []);
|
|
412
|
+
administrative(port.snapshot().rows.filter(source => ids.has(source.row.id)).map(source => source.row));
|
|
413
|
+
}
|
|
414
|
+
const result = applying ? applyPrune(port, input, auth) : plan;
|
|
415
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : pruneText(result, outputStyle(context));
|
|
328
416
|
} finally { store.close(); }
|
|
329
417
|
}
|
|
330
418
|
if (sub === "decay") {
|
|
331
|
-
const applying = options["--apply"] === true;
|
|
332
|
-
if (
|
|
419
|
+
const applying = options["--apply"] === true && options["--confirm"] === true;
|
|
420
|
+
if (options["--confirm"] === true && options["--apply"] !== true) throw new Error("decay --confirm requires --apply");
|
|
333
421
|
const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
|
|
334
422
|
const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
|
|
335
423
|
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
@@ -340,25 +428,24 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
340
428
|
const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
|
|
341
429
|
const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
|
|
342
430
|
const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
|
|
343
|
-
const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
431
|
+
const preview = administrative(decayPreview(store, project, current)), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
344
432
|
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" : ""}`,
|
|
345
433
|
...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");
|
|
346
434
|
if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
|
|
347
435
|
if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
|
|
348
436
|
if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
|
|
349
|
-
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
|
|
350
|
-
const ask = context.prompt ?? ((question: string) => prompt(question));
|
|
351
|
-
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}`}: `);
|
|
352
|
-
if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
|
|
353
437
|
assertCurrent();
|
|
354
|
-
const result = applyDecay(store, preview, { ...current, actor
|
|
438
|
+
const result = applyDecay(store, preview, { ...current, actor, confirmed: true, forget: options["--forget"] === true });
|
|
355
439
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
|
|
356
440
|
} catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
|
|
357
441
|
finally { store.close(); }
|
|
358
442
|
}
|
|
359
443
|
if (sub === "note") {
|
|
360
|
-
// F6: the person writes a curated note.
|
|
361
|
-
const env = context.env ?? process.env, edit =
|
|
444
|
+
// F6: the person writes a curated note. Current attribution and configured policy apply to the complete note.
|
|
445
|
+
const env = context.env ?? process.env, edit = (initial: string) => {
|
|
446
|
+
if (!(context.stdinTTY ?? process.stdin.isTTY)) throw Error("note editor requires a terminal on stdin; provide text or - for non-interactive input");
|
|
447
|
+
return (context.edit ?? ((value: string) => runEditor(env, value)))(initial);
|
|
448
|
+
};
|
|
362
449
|
const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
|
|
363
450
|
const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
|
|
364
451
|
...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
|
|
@@ -382,8 +469,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
382
469
|
if (review) {
|
|
383
470
|
const id = parsed.positionals[0];
|
|
384
471
|
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");
|
|
472
|
+
if (sub === "walk" && !(context.stdinTTY ?? process.stdin.isTTY)) throw Error("review walk requires a terminal on stdin; stdout must be a terminal too");
|
|
473
|
+
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)");
|
|
474
|
+
if (sub === "walk" && actor === "agent") throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
385
475
|
const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
|
|
386
476
|
try {
|
|
477
|
+
const candidatePermitted = (candidate: import("./memory/candidates.ts").Candidate): boolean => {
|
|
478
|
+
if (actor !== "agent") return true;
|
|
479
|
+
const policy = policyOf();
|
|
480
|
+
if (!policy.screenStandalone(candidate)) return false;
|
|
481
|
+
const input = candidate.input, targets = "targets" in input ? input.targets : "target" in input ? [input.target] : [];
|
|
482
|
+
if (!targets.length && input.kind !== "promotion") return true;
|
|
483
|
+
const targetStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
484
|
+
try {
|
|
485
|
+
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;
|
|
486
|
+
if (input.kind === "promotion") { const goal = targetStore.goalOf(input.goalId, project); if (!goal || !policy.screenStandalone(goal)) return false; }
|
|
487
|
+
return true;
|
|
488
|
+
} finally { targetStore.close(); }
|
|
489
|
+
};
|
|
490
|
+
if (actor === "agent" && sub !== "list" && sub !== "walk") {
|
|
491
|
+
const selected = candidates.get(id!, project);
|
|
492
|
+
if (!selected || !candidatePermitted(selected)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
493
|
+
}
|
|
387
494
|
if (sub === "walk") {
|
|
388
495
|
// 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.
|
|
389
496
|
// 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
|
|
@@ -394,7 +501,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
394
501
|
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
|
|
395
502
|
const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
|
|
396
503
|
// Oldest first: those are the ones nearest their expiry (`list` is newest first).
|
|
397
|
-
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
|
|
504
|
+
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse().filter(candidatePermitted);
|
|
398
505
|
if (!rows.length) return "no candidates pending";
|
|
399
506
|
const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
|
|
400
507
|
const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
|
|
@@ -403,6 +510,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
403
510
|
const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
404
511
|
try {
|
|
405
512
|
const target = viewStore.get(input.target.id, project);
|
|
513
|
+
administrative({ candidate, target });
|
|
406
514
|
return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
|
|
407
515
|
? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
|
|
408
516
|
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
@@ -415,6 +523,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
415
523
|
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; } };
|
|
416
524
|
for (const [index, candidate] of rows.entries()) {
|
|
417
525
|
assertWalkCurrent();
|
|
526
|
+
if (!candidatePermitted(candidate)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
418
527
|
lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
|
|
419
528
|
assertWalkCurrent();
|
|
420
529
|
markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
|
|
@@ -436,7 +545,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
436
545
|
const asFail = input.kind === "fail" || input.noteKind === "fail";
|
|
437
546
|
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); }
|
|
438
547
|
finally { store.close(); }
|
|
439
|
-
candidates.reject(candidate.id, { project, actor
|
|
548
|
+
candidates.reject(candidate.id, { project, actor, verdict: "public" }); done.edited++;
|
|
440
549
|
} catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
|
|
441
550
|
} else done.skipped++;
|
|
442
551
|
}
|
|
@@ -450,7 +559,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
450
559
|
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";
|
|
451
560
|
}
|
|
452
561
|
if (sub === "reject") {
|
|
453
|
-
candidates.reject(id!, { project, actor
|
|
562
|
+
candidates.reject(id!, { project, actor, verdict: "public" });
|
|
454
563
|
return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
|
|
455
564
|
}
|
|
456
565
|
const candidate = candidates.get(id!, project);
|
|
@@ -462,7 +571,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
462
571
|
const assertCurrent = commandBinding(store.profile.home, project);
|
|
463
572
|
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)");
|
|
464
573
|
assertCurrent();
|
|
465
|
-
const result = candidates.accept(id!, { project, actor
|
|
574
|
+
const result = candidates.accept(id!, { project, actor, verdict: "public", store,
|
|
466
575
|
policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
|
|
467
576
|
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
468
577
|
});
|
|
@@ -473,13 +582,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
473
582
|
if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
|
|
474
583
|
if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
|
|
475
584
|
const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
476
|
-
return JSON.stringify(result, null, 2);
|
|
585
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesAllText(result, outputStyle(context));
|
|
477
586
|
}
|
|
478
587
|
if (sub === "vault-candidates") {
|
|
479
588
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
480
589
|
try {
|
|
481
590
|
const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
482
|
-
return JSON.stringify(result, null, 2);
|
|
591
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesText(result, outputStyle(context));
|
|
483
592
|
} finally { store.close(); }
|
|
484
593
|
}
|
|
485
594
|
if (sub === "eval") {
|
|
@@ -499,26 +608,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
499
608
|
if (sub === "capacity") {
|
|
500
609
|
const share = options["--disk-share"];
|
|
501
610
|
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]");
|
|
502
|
-
const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
|
|
611
|
+
const report = administrative(capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) }));
|
|
503
612
|
if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
|
|
504
613
|
if (options["--split"] === true) {
|
|
505
614
|
// #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.
|
|
506
615
|
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");
|
|
507
616
|
const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
|
|
508
|
-
|
|
617
|
+
const result = options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare);
|
|
618
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacitySplitText(result, outputStyle(context));
|
|
509
619
|
}
|
|
510
|
-
//
|
|
620
|
+
// Preview first; --confirm writes the bound home default config.
|
|
511
621
|
const file = join(memoryHome(), "capture-config.json");
|
|
512
622
|
const target: CaptureConfig = {
|
|
513
623
|
capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
|
|
514
624
|
retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
|
|
515
625
|
};
|
|
516
626
|
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");
|
|
517
|
-
const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
|
|
627
|
+
const preview = administrative({ file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason });
|
|
518
628
|
const previewFile = join(memoryHome(), "capture-config.preview.json");
|
|
519
629
|
if (options["--confirm"] !== true) {
|
|
520
630
|
writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
|
|
521
|
-
|
|
631
|
+
const result = { preview, confirm: "re-run with --confirm to write" };
|
|
632
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
|
|
522
633
|
}
|
|
523
634
|
let approved: { file?: string; current?: unknown; target?: CaptureConfig };
|
|
524
635
|
try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
|
|
@@ -529,7 +640,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
529
640
|
}
|
|
530
641
|
const written = writeCaptureConfigFile(file, target);
|
|
531
642
|
rmSync(previewFile, { force: true });
|
|
532
|
-
|
|
643
|
+
const result = { preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } };
|
|
644
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
|
|
533
645
|
}
|
|
534
646
|
if (sub === "projects") {
|
|
535
647
|
// 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.
|
|
@@ -547,8 +659,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
547
659
|
return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
|
|
548
660
|
}
|
|
549
661
|
if (sub === "gc") {
|
|
550
|
-
// A preview reads; only --confirm evicts
|
|
662
|
+
// A preview reads; only explicit --confirm evicts, after the administrative preflight.
|
|
551
663
|
const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
|
|
664
|
+
if (actor === "agent") administrative(runGc(memoryHome(), { dryRun: true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) }));
|
|
552
665
|
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
553
666
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
554
667
|
}
|
|
@@ -584,12 +697,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
584
697
|
: `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
|
|
585
698
|
} finally { store.close(); }
|
|
586
699
|
}
|
|
587
|
-
const key = requireShard(memoryHome(), requested);
|
|
700
|
+
const key = administrative(requireShard(memoryHome(), requested));
|
|
588
701
|
if (op === "forget" && options["--confirm"] !== true) {
|
|
589
702
|
const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
|
|
590
703
|
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.`;
|
|
591
704
|
}
|
|
592
|
-
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor
|
|
705
|
+
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor, machine: machineId(memoryHome()).id });
|
|
593
706
|
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
594
707
|
}
|
|
595
708
|
if (sub === "search" && options["--all-projects"] === true) {
|
|
@@ -612,14 +725,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
612
725
|
if (parsed.path[1] === "add") {
|
|
613
726
|
const [from, to] = parsed.positionals;
|
|
614
727
|
if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
|
|
615
|
-
const op = appendAlias(memoryHome(), { alias: from, key: to, actor
|
|
728
|
+
const op = appendAlias(memoryHome(), { alias: from, key: to, actor, machine: machineId(memoryHome()).id });
|
|
616
729
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
|
|
617
730
|
}
|
|
618
731
|
if (parsed.path[1] === "remove") {
|
|
619
|
-
// #27 option 2:
|
|
732
|
+
// #27 option 2: append only, the latest op wins; attribution is not authentication.
|
|
620
733
|
const [from] = parsed.positionals;
|
|
621
734
|
if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
|
|
622
|
-
const op = appendUnalias(memoryHome(), { alias: from, actor
|
|
735
|
+
const op = appendUnalias(memoryHome(), { alias: from, actor, machine: machineId(memoryHome()).id });
|
|
623
736
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
|
|
624
737
|
}
|
|
625
738
|
const ops = listAliases(memoryHome());
|
|
@@ -627,7 +740,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
627
740
|
}
|
|
628
741
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
629
742
|
if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
|
|
630
|
-
// #15 T4 / D6:
|
|
743
|
+
// #15 T4 / D6: repository writes keep their explicit confirmation and visibility fences; amem never runs git.
|
|
631
744
|
const repoCwd = context.cwd ?? process.cwd();
|
|
632
745
|
if (sub === "repo") {
|
|
633
746
|
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
@@ -658,9 +771,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
658
771
|
if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
|
|
659
772
|
// `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
|
|
660
773
|
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`);
|
|
774
|
+
const sourceView = view, sourceRevision = candidateTargetRevision(view);
|
|
775
|
+
if (actor === "agent" && !recallViewIsLive(sourceView, Date.now())) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
776
|
+
administrative(sourceView); // Screen fields the repository representation deliberately drops, too.
|
|
777
|
+
const assertSourceReadable = () => {
|
|
778
|
+
if (actor !== "agent") return;
|
|
779
|
+
const fresh = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
780
|
+
try {
|
|
781
|
+
const current = fresh.get(sourceView.id, project);
|
|
782
|
+
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.");
|
|
783
|
+
administrative(current);
|
|
784
|
+
} finally { fresh.close(); }
|
|
785
|
+
};
|
|
661
786
|
const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
|
|
662
787
|
if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
|
|
663
788
|
const seen = await repoVisibility(root, context.visibility);
|
|
789
|
+
assertSourceReadable(); // Visibility can await: recheck before either preview or publication.
|
|
790
|
+
administrative({ path: plan.path, line: plan.line });
|
|
664
791
|
const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
|
|
665
792
|
`repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
|
|
666
793
|
if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
|
|
@@ -670,6 +797,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
670
797
|
const given = options["--confirm-public"];
|
|
671
798
|
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)`);
|
|
672
799
|
}
|
|
800
|
+
assertSourceReadable();
|
|
673
801
|
const written = appendRepoLine(root, plan.line);
|
|
674
802
|
return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
|
|
675
803
|
...(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");
|
|
@@ -708,8 +836,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
708
836
|
const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
|
|
709
837
|
const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
|
|
710
838
|
// 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.
|
|
711
|
-
const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
839
|
+
const open = () => curatedStore(boundProject(), { ...(identity.actor === "agent" ? { deferPromotions: true } : {}), screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
712
840
|
// A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
|
|
841
|
+
if (goal && actor === "agent") {
|
|
842
|
+
const ids = sub === "new" ? repeated(argv, "--depends") : parsed.positionals.slice(0, 1);
|
|
843
|
+
if (ids.length) {
|
|
844
|
+
const targetStore = open();
|
|
845
|
+
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); } }
|
|
846
|
+
finally { targetStore.close(); }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
713
849
|
if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
|
|
714
850
|
onDone: async (done, store) => {
|
|
715
851
|
const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
|
|
@@ -748,11 +884,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
748
884
|
const legacySearch = options["--filter"] === true;
|
|
749
885
|
const explainSearch = sub === "search" && options["--explain"] === true;
|
|
750
886
|
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
887
|
+
const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
|
|
751
888
|
let output: string;
|
|
752
889
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
753
890
|
try {
|
|
754
|
-
output = await withRepoRows(await rawMemoryCommand(
|
|
755
|
-
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
891
|
+
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),
|
|
892
|
+
sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
756
893
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
757
894
|
} catch (error) {
|
|
758
895
|
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
@@ -774,7 +911,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
774
911
|
const policy = configured, db = options["--db"] ? { db: String(options["--db"]) } : {}, limit = options["--limit"] ? { limit: Number(options["--limit"]) } : {}, restart = options["--restart"] === true;
|
|
775
912
|
const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...db, ...limit, restart });
|
|
776
913
|
const capture = captureDays === undefined ? undefined : await importClaudeMemCapture({ base: memoryHome(), project: boundProject(), cwd, days: captureDays, ...db, ...limit, restart, policy: { host: policy } });
|
|
777
|
-
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2)
|
|
914
|
+
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work, ...(capture ? { capture } : {}) }, null, 2)
|
|
915
|
+
: claudeMemImportText(JSON.parse(output), work, capture, { captureDays, restart }, outputStyle(context));
|
|
778
916
|
}
|
|
779
917
|
return !options["--json"] && receipt.bridgeStatus
|
|
780
918
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
@@ -783,7 +921,32 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
783
921
|
/** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
|
|
784
922
|
function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
|
|
785
923
|
|
|
786
|
-
|
|
924
|
+
/** Native ingest remains capture-only and agent-reachable; this formats only its returned receipt. */
|
|
925
|
+
export function ingestText(kind: IngestKind, result: IngestReceipt, s: Style = PLAIN): string {
|
|
926
|
+
const state = result.errors || result.remaining || result.spooled ? "attention" : result.files || result.read ? "complete" : "skipped";
|
|
927
|
+
const reason: string | undefined = result.reason;
|
|
928
|
+
const actions: Record<string, string> = {
|
|
929
|
+
source_unavailable: "check that this client's native transcript root is available, then ingest again",
|
|
930
|
+
source_bound: "review the native source size and supported discovery bounds before retrying",
|
|
931
|
+
cursor_busy: "wait for the active ingest writer, then ingest again",
|
|
932
|
+
scope_changed: "run ingest again from the same repository after its scope is stable",
|
|
933
|
+
schema_or_time: "check the native transcript format and timestamps; unreadable records were skipped",
|
|
934
|
+
unsupported_version: "check the reported writer version against the supported ingest contract before retrying",
|
|
935
|
+
};
|
|
936
|
+
const version = "version" in result && typeof result.version === "string" ? result.version : undefined;
|
|
937
|
+
const action = reason ? actions[reason] ?? "review the receipt before retrying"
|
|
938
|
+
: result.errors ? "review the ingest errors and supported native source contract before retrying"
|
|
939
|
+
: result.remaining ? "run the same ingest command again to continue"
|
|
940
|
+
: result.spooled ? "the next capture write replays rows spooled while the store was busy"
|
|
941
|
+
: state === "skipped" ? "open a native session in this repository before ingesting its transcript" : "none";
|
|
942
|
+
return [`amem ingest ${kind}: ${state}`,
|
|
943
|
+
`${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) })}`,
|
|
944
|
+
`${label(s, result.remaining ? "attention" : "ok")} remaining: ${result.remaining ? "yes" : "no"}`,
|
|
945
|
+
...(reason ? [`${label(s, "attention")} reason: ${reason}${version ? ` (version ${version})` : ""}`] : []),
|
|
946
|
+
`Next action: ${action}`].join("\n");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError || error instanceof CliPartialApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
787
950
|
|
|
788
951
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
789
952
|
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|