@staix/agent-mem 0.12.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 +50 -43
- package/package.json +4 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +211 -55
- 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 +23 -4
- 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/store.ts +5 -4
- package/src/memory/style.ts +3 -0
- package/src/memory/vault.ts +31 -0
- package/src/setup.ts +59 -31
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";
|
|
@@ -50,10 +53,9 @@ import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, typ
|
|
|
50
53
|
import { runEditor } from "./memory/editor.ts";
|
|
51
54
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
52
55
|
import { statusText, type StatusView } from "./memory/status-text.ts";
|
|
53
|
-
import { colorEnabled, style, type Style } from "./memory/style.ts";
|
|
56
|
+
import { colorEnabled, style, facts, label, PLAIN, type Style } from "./memory/style.ts";
|
|
54
57
|
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
55
58
|
|
|
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
59
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
58
60
|
export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
|
|
59
61
|
export interface CliContext {
|
|
@@ -62,7 +64,7 @@ export interface CliContext {
|
|
|
62
64
|
cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
|
|
63
65
|
/** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
|
|
64
66
|
visibility?: (url: string) => Promise<Visibility>;
|
|
65
|
-
/**
|
|
67
|
+
/** Legacy setup fixture seam; explicit apply/undo flags no longer prompt. */
|
|
66
68
|
setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
|
|
67
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. */
|
|
68
70
|
prompt?: (question: string) => string | null | Promise<string | null>;
|
|
@@ -130,6 +132,13 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
|
|
|
130
132
|
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
131
133
|
}
|
|
132
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
|
+
}
|
|
133
142
|
function shardStatePresent(): boolean {
|
|
134
143
|
try { lstatSync(join(memoryHome(), "shards.json")); return true; }
|
|
135
144
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
@@ -156,7 +165,58 @@ function curatedStoreAt(base: string, project: string, opts: StoreOptions): Memo
|
|
|
156
165
|
return new MemoryStore({ ...withAliases, memoryDir: base });
|
|
157
166
|
}
|
|
158
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
|
+
|
|
159
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> {
|
|
160
220
|
if (argv.length === 1 && argv[0] === "mcp") {
|
|
161
221
|
const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
|
|
162
222
|
await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
|
|
@@ -173,15 +233,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
173
233
|
const kind = parsed.positionals[0];
|
|
174
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]");
|
|
175
235
|
const { ingestTranscript } = await import("./ingest.ts");
|
|
176
|
-
|
|
236
|
+
const result = await ingestTranscript(kind as IngestKind, {
|
|
177
237
|
cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
|
|
178
238
|
...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
|
|
179
|
-
})
|
|
239
|
+
});
|
|
240
|
+
return parsed.options["--json"] === true || !(context.stdoutTTY ?? process.stdout.isTTY) ? JSON.stringify(result) : ingestText(kind as IngestKind, result, outputStyle(context));
|
|
180
241
|
}
|
|
181
|
-
//
|
|
242
|
+
// Help and version carry no memory data.
|
|
182
243
|
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
|
|
183
244
|
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
245
|
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
186
246
|
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
187
247
|
let parsed;
|
|
@@ -197,6 +257,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
197
257
|
throw error;
|
|
198
258
|
}
|
|
199
259
|
const { definition, options, rawArgs: args } = parsed;
|
|
260
|
+
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
200
261
|
if (parsed.path[0] === "setup") {
|
|
201
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]");
|
|
202
263
|
const { runSetup } = await import("./setup.ts");
|
|
@@ -205,15 +266,19 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
205
266
|
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
|
|
206
267
|
});
|
|
207
268
|
}
|
|
269
|
+
if (identity.actor === "agent") {
|
|
270
|
+
const read = await agentCliRead(parsed, argv, context);
|
|
271
|
+
if (read !== undefined) return read;
|
|
272
|
+
}
|
|
208
273
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
209
274
|
const policyFree = options["--policy-free"] === true;
|
|
210
275
|
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
211
276
|
|| 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
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
|
|
214
278
|
// `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
|
|
215
279
|
const policyCwd = context.cwd ?? process.cwd();
|
|
216
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; };
|
|
217
282
|
let canonicalProject: string | undefined;
|
|
218
283
|
const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
|
|
219
284
|
const commandBinding = (home: string, project: string): (() => void) => {
|
|
@@ -235,6 +300,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
235
300
|
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
236
301
|
} });
|
|
237
302
|
};
|
|
303
|
+
if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
|
|
238
304
|
if (sub === "consolidate") {
|
|
239
305
|
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
240
306
|
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
@@ -248,7 +314,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
248
314
|
const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
|
|
249
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"); };
|
|
250
316
|
candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
|
|
251
|
-
const report = await proposeConsolidation(store, candidates, { project, actor
|
|
317
|
+
const report = await proposeConsolidation(store, candidates, { project, actor, policy: policyOf, policyVersion: policyKey, assertCurrent });
|
|
252
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` : ""}`;
|
|
253
319
|
} finally { candidates?.close(); store.close(); }
|
|
254
320
|
}
|
|
@@ -274,16 +340,21 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
274
340
|
}
|
|
275
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");
|
|
276
342
|
if (sub === "migrate") {
|
|
277
|
-
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
343
|
+
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
278
344
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
279
|
-
const mode = options["--
|
|
345
|
+
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
280
346
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
281
347
|
const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
|
|
282
348
|
if (shardRequested) {
|
|
283
349
|
if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
|
|
284
|
-
|
|
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));
|
|
285
353
|
}
|
|
286
|
-
|
|
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));
|
|
287
358
|
}
|
|
288
359
|
if (sub === "status") {
|
|
289
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.
|
|
@@ -323,13 +394,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
323
394
|
? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
|
|
324
395
|
: curatedStore(project, { deferPromotions: true }, false);
|
|
325
396
|
try {
|
|
326
|
-
const auth = { project, actor
|
|
327
|
-
|
|
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));
|
|
328
409
|
} finally { store.close(); }
|
|
329
410
|
}
|
|
330
411
|
if (sub === "decay") {
|
|
331
|
-
const applying = options["--apply"] === true;
|
|
332
|
-
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");
|
|
333
414
|
const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
|
|
334
415
|
const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
|
|
335
416
|
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
@@ -340,25 +421,24 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
340
421
|
const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
|
|
341
422
|
const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
|
|
342
423
|
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";
|
|
424
|
+
const preview = administrative(decayPreview(store, project, current)), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
344
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" : ""}`,
|
|
345
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");
|
|
346
427
|
if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
|
|
347
428
|
if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
|
|
348
429
|
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
430
|
assertCurrent();
|
|
354
|
-
const result = applyDecay(store, preview, { ...current, actor
|
|
431
|
+
const result = applyDecay(store, preview, { ...current, actor, confirmed: true, forget: options["--forget"] === true });
|
|
355
432
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
|
|
356
433
|
} catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
|
|
357
434
|
finally { store.close(); }
|
|
358
435
|
}
|
|
359
436
|
if (sub === "note") {
|
|
360
|
-
// F6: the person writes a curated note.
|
|
361
|
-
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
|
+
};
|
|
362
442
|
const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
|
|
363
443
|
const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
|
|
364
444
|
...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
|
|
@@ -382,8 +462,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
382
462
|
if (review) {
|
|
383
463
|
const id = parsed.positionals[0];
|
|
384
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.");
|
|
385
468
|
const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
|
|
386
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
|
+
}
|
|
387
487
|
if (sub === "walk") {
|
|
388
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.
|
|
389
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
|
|
@@ -394,7 +494,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
394
494
|
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
|
|
395
495
|
const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
|
|
396
496
|
// 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();
|
|
497
|
+
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse().filter(candidatePermitted);
|
|
398
498
|
if (!rows.length) return "no candidates pending";
|
|
399
499
|
const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
|
|
400
500
|
const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
|
|
@@ -403,6 +503,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
403
503
|
const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
404
504
|
try {
|
|
405
505
|
const target = viewStore.get(input.target.id, project);
|
|
506
|
+
administrative({ candidate, target });
|
|
406
507
|
return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
|
|
407
508
|
? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
|
|
408
509
|
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
@@ -415,6 +516,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
415
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; } };
|
|
416
517
|
for (const [index, candidate] of rows.entries()) {
|
|
417
518
|
assertWalkCurrent();
|
|
519
|
+
if (!candidatePermitted(candidate)) throw Error("Memory operation unavailable or refused; no partial result is shown.");
|
|
418
520
|
lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
|
|
419
521
|
assertWalkCurrent();
|
|
420
522
|
markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
|
|
@@ -436,7 +538,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
436
538
|
const asFail = input.kind === "fail" || input.noteKind === "fail";
|
|
437
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); }
|
|
438
540
|
finally { store.close(); }
|
|
439
|
-
candidates.reject(candidate.id, { project, actor
|
|
541
|
+
candidates.reject(candidate.id, { project, actor, verdict: "public" }); done.edited++;
|
|
440
542
|
} catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
|
|
441
543
|
} else done.skipped++;
|
|
442
544
|
}
|
|
@@ -450,7 +552,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
450
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";
|
|
451
553
|
}
|
|
452
554
|
if (sub === "reject") {
|
|
453
|
-
candidates.reject(id!, { project, actor
|
|
555
|
+
candidates.reject(id!, { project, actor, verdict: "public" });
|
|
454
556
|
return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
|
|
455
557
|
}
|
|
456
558
|
const candidate = candidates.get(id!, project);
|
|
@@ -462,7 +564,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
462
564
|
const assertCurrent = commandBinding(store.profile.home, project);
|
|
463
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)");
|
|
464
566
|
assertCurrent();
|
|
465
|
-
const result = candidates.accept(id!, { project, actor
|
|
567
|
+
const result = candidates.accept(id!, { project, actor, verdict: "public", store,
|
|
466
568
|
policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
|
|
467
569
|
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
468
570
|
});
|
|
@@ -473,13 +575,13 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
473
575
|
if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
|
|
474
576
|
if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
|
|
475
577
|
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);
|
|
578
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesAllText(result, outputStyle(context));
|
|
477
579
|
}
|
|
478
580
|
if (sub === "vault-candidates") {
|
|
479
581
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
480
582
|
try {
|
|
481
583
|
const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
482
|
-
return JSON.stringify(result, null, 2);
|
|
584
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : vaultCandidatesText(result, outputStyle(context));
|
|
483
585
|
} finally { store.close(); }
|
|
484
586
|
}
|
|
485
587
|
if (sub === "eval") {
|
|
@@ -499,26 +601,28 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
499
601
|
if (sub === "capacity") {
|
|
500
602
|
const share = options["--disk-share"];
|
|
501
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]");
|
|
502
|
-
const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
|
|
604
|
+
const report = administrative(capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) }));
|
|
503
605
|
if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
|
|
504
606
|
if (options["--split"] === true) {
|
|
505
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.
|
|
506
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");
|
|
507
609
|
const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
|
|
508
|
-
|
|
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));
|
|
509
612
|
}
|
|
510
|
-
//
|
|
613
|
+
// Preview first; --confirm writes the bound home default config.
|
|
511
614
|
const file = join(memoryHome(), "capture-config.json");
|
|
512
615
|
const target: CaptureConfig = {
|
|
513
616
|
capMb: options["--cap-mb"] ? Number(options["--cap-mb"]) : report.proposal.capMb,
|
|
514
617
|
retentionDays: options["--retention-days"] ? Number(options["--retention-days"]) : report.proposal.retentionDays,
|
|
515
618
|
};
|
|
516
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");
|
|
517
|
-
const preview = { file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason };
|
|
620
|
+
const preview = administrative({ file, current: readCaptureConfigFile(file), target, reason: report.proposal.reason });
|
|
518
621
|
const previewFile = join(memoryHome(), "capture-config.preview.json");
|
|
519
622
|
if (options["--confirm"] !== true) {
|
|
520
623
|
writeFileSync(previewFile, JSON.stringify(preview) + "\n", { mode: 0o600 });
|
|
521
|
-
|
|
624
|
+
const result = { preview, confirm: "re-run with --confirm to write" };
|
|
625
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : capacityApplyText(result, outputStyle(context));
|
|
522
626
|
}
|
|
523
627
|
let approved: { file?: string; current?: unknown; target?: CaptureConfig };
|
|
524
628
|
try { approved = JSON.parse(readFileSync(previewFile, "utf8")) as typeof approved; }
|
|
@@ -529,7 +633,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
529
633
|
}
|
|
530
634
|
const written = writeCaptureConfigFile(file, target);
|
|
531
635
|
rmSync(previewFile, { force: true });
|
|
532
|
-
|
|
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));
|
|
533
638
|
}
|
|
534
639
|
if (sub === "projects") {
|
|
535
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.
|
|
@@ -547,8 +652,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
547
652
|
return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
|
|
548
653
|
}
|
|
549
654
|
if (sub === "gc") {
|
|
550
|
-
// A preview reads; only --confirm evicts
|
|
655
|
+
// A preview reads; only explicit --confirm evicts, after the administrative preflight.
|
|
551
656
|
const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
|
|
657
|
+
if (actor === "agent") administrative(runGc(memoryHome(), { dryRun: true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) }));
|
|
552
658
|
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
553
659
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
554
660
|
}
|
|
@@ -584,12 +690,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
584
690
|
: `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
|
|
585
691
|
} finally { store.close(); }
|
|
586
692
|
}
|
|
587
|
-
const key = requireShard(memoryHome(), requested);
|
|
693
|
+
const key = administrative(requireShard(memoryHome(), requested));
|
|
588
694
|
if (op === "forget" && options["--confirm"] !== true) {
|
|
589
695
|
const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
|
|
590
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.`;
|
|
591
697
|
}
|
|
592
|
-
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 });
|
|
593
699
|
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
594
700
|
}
|
|
595
701
|
if (sub === "search" && options["--all-projects"] === true) {
|
|
@@ -612,14 +718,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
612
718
|
if (parsed.path[1] === "add") {
|
|
613
719
|
const [from, to] = parsed.positionals;
|
|
614
720
|
if (!from || !to) throw new Error("usage: amem alias add <from-key> <to-key>");
|
|
615
|
-
const op = appendAlias(memoryHome(), { alias: from, key: to, actor
|
|
721
|
+
const op = appendAlias(memoryHome(), { alias: from, key: to, actor, machine: machineId(memoryHome()).id });
|
|
616
722
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
|
|
617
723
|
}
|
|
618
724
|
if (parsed.path[1] === "remove") {
|
|
619
|
-
// #27 option 2:
|
|
725
|
+
// #27 option 2: append only, the latest op wins; attribution is not authentication.
|
|
620
726
|
const [from] = parsed.positionals;
|
|
621
727
|
if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
|
|
622
|
-
const op = appendUnalias(memoryHome(), { alias: from, actor
|
|
728
|
+
const op = appendUnalias(memoryHome(), { alias: from, actor, machine: machineId(memoryHome()).id });
|
|
623
729
|
return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
|
|
624
730
|
}
|
|
625
731
|
const ops = listAliases(memoryHome());
|
|
@@ -627,7 +733,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
627
733
|
}
|
|
628
734
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
629
735
|
if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
|
|
630
|
-
// #15 T4 / D6:
|
|
736
|
+
// #15 T4 / D6: repository writes keep their explicit confirmation and visibility fences; amem never runs git.
|
|
631
737
|
const repoCwd = context.cwd ?? process.cwd();
|
|
632
738
|
if (sub === "repo") {
|
|
633
739
|
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
@@ -658,9 +764,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
658
764
|
if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
|
|
659
765
|
// `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
|
|
660
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
|
+
};
|
|
661
779
|
const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
|
|
662
780
|
if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
|
|
663
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 });
|
|
664
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(", ")})`] : []),
|
|
665
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)"}`];
|
|
666
786
|
if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
|
|
@@ -670,6 +790,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
670
790
|
const given = options["--confirm-public"];
|
|
671
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)`);
|
|
672
792
|
}
|
|
793
|
+
assertSourceReadable();
|
|
673
794
|
const written = appendRepoLine(root, plan.line);
|
|
674
795
|
return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
|
|
675
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");
|
|
@@ -708,8 +829,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
708
829
|
const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
|
|
709
830
|
const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
|
|
710
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.
|
|
711
|
-
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" });
|
|
712
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
|
+
}
|
|
713
842
|
if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
|
|
714
843
|
onDone: async (done, store) => {
|
|
715
844
|
const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
|
|
@@ -748,10 +877,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
748
877
|
const legacySearch = options["--filter"] === true;
|
|
749
878
|
const explainSearch = sub === "search" && options["--explain"] === true;
|
|
750
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;
|
|
751
881
|
let output: string;
|
|
752
882
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
753
883
|
try {
|
|
754
|
-
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),
|
|
755
885
|
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
756
886
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
757
887
|
} catch (error) {
|
|
@@ -774,7 +904,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
774
904
|
const policy = configured, db = options["--db"] ? { db: String(options["--db"]) } : {}, limit = options["--limit"] ? { limit: Number(options["--limit"]) } : {}, restart = options["--restart"] === true;
|
|
775
905
|
const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...db, ...limit, restart });
|
|
776
906
|
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)
|
|
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));
|
|
778
909
|
}
|
|
779
910
|
return !options["--json"] && receipt.bridgeStatus
|
|
780
911
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
@@ -783,7 +914,32 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
783
914
|
/** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
|
|
784
915
|
function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
|
|
785
916
|
|
|
786
|
-
|
|
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}`; }
|
|
787
943
|
|
|
788
944
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
789
945
|
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|