@staix/agent-mem 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/cli.ts
CHANGED
|
@@ -1,30 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Writable } from "node:stream";
|
|
3
|
+
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
|
+
import { createIntentBriefSource } from "./memory/recall.ts";
|
|
5
|
+
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
6
|
+
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
7
|
+
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
1
8
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
2
|
-
import { serveMcp } from "./mcp.ts";
|
|
9
|
+
import { serveMcp, writeConfirmed } from "./mcp.ts";
|
|
3
10
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
11
|
+
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
4
12
|
import { vaultCandidates } from "./memory/vault.ts";
|
|
5
13
|
import { importClaudeMemWorkState } from "./memory/import.ts";
|
|
6
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
7
15
|
import { join } from "node:path";
|
|
8
16
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
9
17
|
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
10
18
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
11
|
-
import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
|
|
19
|
+
import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
|
|
12
20
|
import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
13
21
|
import { capacityReport, capacityText } from "./memory/capacity.ts";
|
|
14
22
|
import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
|
|
15
23
|
|
|
16
24
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
17
25
|
import { completionScript } from "./completion.ts";
|
|
18
|
-
import { memoryHome } from "./memory/home.ts";
|
|
19
|
-
import {
|
|
26
|
+
import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
|
|
27
|
+
import { realPath } from "./memory/platform.ts";
|
|
28
|
+
import { hashJSON, readSelector, resolveProfile } from "./memory/generation.ts";
|
|
20
29
|
import { migrate } from "./memory/migrate.ts";
|
|
21
30
|
import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
|
|
22
31
|
import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
32
|
+
import { readCatalogFresh } from "./memory/catalog.ts";
|
|
33
|
+
import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
|
|
34
|
+
import { gcText, runGc } from "./memory/gc.ts";
|
|
35
|
+
import { usageReport, usageText } from "./memory/usage.ts";
|
|
36
|
+
import { exportProject } from "./memory/project-export.ts";
|
|
37
|
+
import { searchAllProjects, vaultCandidatesAll } from "./memory/cross-project.ts";
|
|
38
|
+
import { requireShard } from "./memory/curated-read.ts";
|
|
39
|
+
import { appendGitattributes, appendRepoLine, boundRepoRoot, planGitattributes, planRepoPromotion, readRepoScope, repoLogIgnored, repoLogPath, repoReadPolicy, repoVisibility, type RepoView } from "./memory/repo-scope.ts";
|
|
40
|
+
import type { Visibility } from "./memory/sync.ts";
|
|
41
|
+
import { applySplit, previewSplit } from "./memory/capacity-split.ts";
|
|
42
|
+
import { evalText, parseKs } from "./memory/eval.ts";
|
|
43
|
+
import { evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
|
|
44
|
+
import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
45
|
+
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
46
|
+
import { runEditor } from "./memory/editor.ts";
|
|
47
|
+
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
48
|
+
import { nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
23
49
|
|
|
24
|
-
export const PERSON_ONLY = "amem is for the person
|
|
50
|
+
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`";
|
|
25
51
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
26
|
-
export interface
|
|
52
|
+
export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
|
|
53
|
+
export interface CliContext {
|
|
54
|
+
/** Prepared output metadata only. Invoke rendered only after the output writer succeeds. */
|
|
55
|
+
onOutputReady?: (receipt: CliOutputReceipt) => void;
|
|
56
|
+
cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
|
|
57
|
+
/** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
|
|
58
|
+
visibility?: (url: string) => Promise<Visibility>;
|
|
59
|
+
/** Trusted isolated-HOME setup fixture; ordinary CLI use asks through its terminal. */
|
|
60
|
+
setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
|
|
61
|
+
/** 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. */
|
|
62
|
+
prompt?: (question: string) => string | null | Promise<string | null>;
|
|
63
|
+
/** Where `review walk` prints each candidate BEFORE it asks about it; defaults to stdout, which must then be a terminal. A fixture that supplies this stands in for the terminal. */
|
|
64
|
+
write?: (text: string) => void;
|
|
65
|
+
/** Whether stdout is a terminal, for the real stdout only (default `process.stdout.isTTY`). */
|
|
66
|
+
stdoutTTY?: boolean;
|
|
67
|
+
edit?: (initial: string) => string | Promise<string>;
|
|
68
|
+
/** Test seam: the `sync --if-due` deadline in milliseconds (the CLI always uses 30 seconds). */
|
|
69
|
+
syncDeadlineMs?: number;
|
|
70
|
+
stdin?: () => string;
|
|
71
|
+
}
|
|
27
72
|
|
|
73
|
+
/** Candidate and note text came from an agent or the person, and goes to a terminal: flatten control characters (an ANSI escape included) and bound it. */
|
|
74
|
+
const terminalSafe = (value: string, max = 600): string => { const flat = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").replace(/ {2,}/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max)}...` : flat; };
|
|
75
|
+
/** Every value given for a repeatable flag, in order (`parseCommand` keeps only the last). */
|
|
76
|
+
function repeated(argv: readonly string[], flag: string): string[] {
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const token = argv[i]!;
|
|
80
|
+
if (token === flag) { const value = argv[++i]; if (value !== undefined && !value.startsWith("--")) out.push(value); }
|
|
81
|
+
else if (token.startsWith(`${flag}=`)) out.push(token.slice(flag.length + 1));
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
28
85
|
/** Narrow descriptor seam for deterministic race fixtures; the CLI always uses the native implementation. */
|
|
29
86
|
export interface PrunePlanFileIO {
|
|
30
87
|
open(path: string, flags: number): number;
|
|
@@ -62,7 +119,7 @@ export function readPrunePlanFile(path: string, io: PrunePlanFileIO = planFileIO
|
|
|
62
119
|
|
|
63
120
|
/** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
|
|
64
121
|
export function agentMarked(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
65
|
-
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
|
|
122
|
+
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
66
123
|
}
|
|
67
124
|
|
|
68
125
|
function shardStatePresent(): boolean {
|
|
@@ -72,6 +129,9 @@ function shardStatePresent(): boolean {
|
|
|
72
129
|
|
|
73
130
|
/** `reconciled` (#27): the project filter also covers every key aliased to `project`; prune and other planners keep the exact key. */
|
|
74
131
|
function curatedStore(project: string, opts: StoreOptions, reconciled = true): MemoryStore {
|
|
132
|
+
// #15 T5: the person's own project-local recall (search, list, show, graph, goals, prune) leaves an archived or forgotten project too, on a split home and on
|
|
133
|
+
// an unsplit one alike. `search --all-projects --include-archived` and `project export` are the explicit ways to reach an archived project.
|
|
134
|
+
assertProjectActive(memoryHome(), project);
|
|
75
135
|
const aliases = reconciled ? aliasedKeys(memoryHome(), project) : [];
|
|
76
136
|
const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
|
|
77
137
|
if (shardStatePresent()) {
|
|
@@ -81,12 +141,38 @@ function curatedStore(project: string, opts: StoreOptions, reconciled = true): M
|
|
|
81
141
|
return new MemoryStore(withAliases);
|
|
82
142
|
}
|
|
83
143
|
|
|
144
|
+
/** The same store `curatedStore` opens, on another base: doctor's private scratch copy. No lifecycle gate here, the copy carries the home's lifecycle log and doctor read it already. */
|
|
145
|
+
function curatedStoreAt(base: string, project: string, opts: StoreOptions): MemoryStore {
|
|
146
|
+
const aliases = aliasedKeys(base, project), withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
|
|
147
|
+
if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); }
|
|
148
|
+
return new MemoryStore({ ...withAliases, memoryDir: base });
|
|
149
|
+
}
|
|
150
|
+
|
|
84
151
|
export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
|
|
85
152
|
if (argv.length === 1 && argv[0] === "mcp") {
|
|
86
153
|
const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
|
|
87
|
-
await serveMcp({ tools: memory.tools, close: memory.close, version: VERSION });
|
|
154
|
+
await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
|
|
88
155
|
return "";
|
|
89
156
|
}
|
|
157
|
+
if (argv[0] === "hook") {
|
|
158
|
+
const { hookMain } = await import("./hooks.ts");
|
|
159
|
+
return hookMain(argv.slice(1));
|
|
160
|
+
}
|
|
161
|
+
if (argv[0] === "ingest") {
|
|
162
|
+
// Capture-only native intake: no project/home override, policy bypass or curated write capability.
|
|
163
|
+
const parsed = parseCommand(argv);
|
|
164
|
+
if (parsed.options["--help"] === true) return commandHelp();
|
|
165
|
+
const kind = parsed.positionals[0];
|
|
166
|
+
if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi [--since ISO] [--json]");
|
|
167
|
+
const { ingestTranscript } = await import("./ingest.ts");
|
|
168
|
+
return JSON.stringify(await ingestTranscript(kind as "codex" | "kimi" | "pi", {
|
|
169
|
+
cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
|
|
170
|
+
...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
// F1: `--help` and `--version` only print text, so they work in an agent shell too. Every other command keeps the honest-session refusal below.
|
|
174
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
|
|
175
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
|
|
90
176
|
if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
|
|
91
177
|
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
92
178
|
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
@@ -103,15 +189,65 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
103
189
|
throw error;
|
|
104
190
|
}
|
|
105
191
|
const { definition, options, rawArgs: args } = parsed;
|
|
192
|
+
if (parsed.path[0] === "setup") {
|
|
193
|
+
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
|
|
194
|
+
const { runSetup } = await import("./setup.ts");
|
|
195
|
+
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
196
|
+
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY,
|
|
197
|
+
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
106
200
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
107
201
|
const policyFree = options["--policy-free"] === true;
|
|
108
|
-
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
202
|
+
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
203
|
+
|| definition.mutates === "confirm" && options["--confirm"] === true;
|
|
109
204
|
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
205
|
+
// 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
|
|
206
|
+
// `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
|
|
207
|
+
const policyCwd = context.cwd ?? process.cwd();
|
|
208
|
+
const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
|
|
113
209
|
let canonicalProject: string | undefined;
|
|
114
210
|
const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
|
|
211
|
+
const commandBinding = (home: string, project: string): (() => void) => {
|
|
212
|
+
const base = realPath(home), selector = readSelector(base).revision, aliases = aliasedKeys(base, project).join("\n");
|
|
213
|
+
const split = shardStatePresent(), shards = split ? JSON.stringify(readShardedSelection(base)) : undefined;
|
|
214
|
+
return () => {
|
|
215
|
+
const currentSplit = shardStatePresent();
|
|
216
|
+
if (realPath(memoryHome()) !== base || resolveAlias(base, memoryProjectKey(policyCwd)) !== project || readSelector(base).revision !== selector || aliasedKeys(base, project).join("\n") !== aliases || projectState(base, project) !== "active" || split !== currentSplit || split && JSON.stringify(readShardedSelection(base)) !== shards) throw new Error("memory command binding changed since preview");
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
const outputReady = (ids: readonly string[], project = boundProject()) => {
|
|
220
|
+
if (!context.onOutputReady || !ids.length) return;
|
|
221
|
+
const base = realPath(memoryHome()), assertCurrent = commandBinding(base, project), deliveryId = `cli:${randomUUID()}`, selected = [...new Set(ids)].slice(0, 256);
|
|
222
|
+
context.onOutputReady({ project, ids: selected, rendered: () => {
|
|
223
|
+
let candidates: CandidateStore | undefined;
|
|
224
|
+
try { assertCurrent(); candidates = new CandidateStore({ memoryDir: base }); let known = true;
|
|
225
|
+
for (const entryId of selected) known = recordMemoryUse(candidates, { project, entryId, deliveryId }, { rendered: 1 }, { assertCurrent }) && known;
|
|
226
|
+
return known;
|
|
227
|
+
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
228
|
+
} });
|
|
229
|
+
};
|
|
230
|
+
if (sub === "brief") {
|
|
231
|
+
const intent = options["--for"]; if (typeof intent !== "string" || !intent.trim() || intent.length > 1000 || parsed.positionals.length) throw Error("usage: amem brief --for <intent> [--paths <path>] [--symbols <symbol>] [--bytes N] [--json]");
|
|
232
|
+
const rawBytes = options["--bytes"];
|
|
233
|
+
if (rawBytes !== undefined && !/^[1-9][0-9]*$/.test(String(rawBytes))) throw Error("brief bytes must be a positive decimal integer");
|
|
234
|
+
const maxBytes = rawBytes === undefined ? 2048 : Number(rawBytes); if (!Number.isSafeInteger(maxBytes) || maxBytes > 8192) throw Error("brief bytes must be 1..8192");
|
|
235
|
+
const project = boundProject(), base = realPath(memoryHome()), paths = repeated(argv, "--paths"), symbols = repeated(argv, "--symbols");
|
|
236
|
+
if (paths.length > 150 || symbols.length > 150 || [...paths, ...symbols].some(value => value.length > 512)) throw Error("brief paths and symbols exceed the input bound");
|
|
237
|
+
const store = curatedStore(project, { deferPromotions: true });
|
|
238
|
+
try {
|
|
239
|
+
const assertCurrent = commandBinding(base, project), repository = boundRepoRoot(policyCwd, base, project), root = repository ?? policyCwd;
|
|
240
|
+
const rootConfirmed = repository !== undefined || project.startsWith("local:") && resolveAlias(base, memoryProjectKey(policyCwd)) === project;
|
|
241
|
+
const settings = () => configuredPolicyOptions(base, policyCwd), policyKey = () => hashJSON(settings()), revision = policyKey(), snapshot = createStandalonePolicy(settings());
|
|
242
|
+
const policy = () => snapshot, policyVersion = () => policyKey();
|
|
243
|
+
if (policyVersion() !== revision) throw Error("brief policy changed before read");
|
|
244
|
+
const result = await buildIntentBrief(createIntentBriefSource(store, project),
|
|
245
|
+
{ project, cwd: root, repo: project, intent, paths, symbols, maxBytes, policy, policyVersion, assertCurrent, now: () => store.readNow(), stale: createRecallStalenessProbe({ cwd: root, rootConfirmed, policy }) });
|
|
246
|
+
assertCurrent(); outputReady(result.items.map(view => view.id), project);
|
|
247
|
+
return options["--json"] ? JSON.stringify({ brief: result.text, bytes: result.bytes, truncated: result.truncated, state: result.state }, null, 2) : result.text;
|
|
248
|
+
} finally { store.close(); }
|
|
249
|
+
}
|
|
250
|
+
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");
|
|
115
251
|
if (sub === "migrate") {
|
|
116
252
|
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
117
253
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
@@ -124,7 +260,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
124
260
|
}
|
|
125
261
|
return JSON.stringify(await migrate({ ...common, mode }), null, 2);
|
|
126
262
|
}
|
|
127
|
-
if (sub === "status")
|
|
263
|
+
if (sub === "status") {
|
|
264
|
+
// 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.
|
|
265
|
+
const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
|
|
266
|
+
const { config, source } = loaded;
|
|
267
|
+
return JSON.stringify({ home: { path: where.home, source: where.source },
|
|
268
|
+
config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
|
|
269
|
+
...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
|
|
270
|
+
...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
|
|
271
|
+
// F7: pending candidates, and a warning when any is within 7 days of its age expiry (a shown one is then deleted after its grace period; an unshown one is kept until it is shown).
|
|
272
|
+
candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
|
|
273
|
+
if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
|
|
274
|
+
return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
|
|
275
|
+
...shardMigrationStatus(where.home) }, null, 2);
|
|
276
|
+
}
|
|
277
|
+
if (sub === "doctor") {
|
|
278
|
+
// F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
|
|
279
|
+
const where = resolveMemoryHome(), project = boundProject();
|
|
280
|
+
const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
|
|
281
|
+
openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
|
|
282
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
|
|
283
|
+
}
|
|
128
284
|
if (sub === "prune") {
|
|
129
285
|
const applying = options["--apply"] === true;
|
|
130
286
|
if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune [--user] --cutoff <UTC> --dry-run | [--user] --apply --plan <file>");
|
|
@@ -145,17 +301,124 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
145
301
|
return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
|
|
146
302
|
} finally { store.close(); }
|
|
147
303
|
}
|
|
304
|
+
if (sub === "decay") {
|
|
305
|
+
const applying = options["--apply"] === true;
|
|
306
|
+
if (applying && context.write === undefined && !(context.stdoutTTY ?? process.stdout.isTTY)) throw new Error("decay apply shows a revision preview before confirmation, so stdout must be a terminal");
|
|
307
|
+
const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
|
|
308
|
+
const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
|
|
309
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
310
|
+
try {
|
|
311
|
+
const assertCurrent = commandBinding(store.profile.home, project);
|
|
312
|
+
const selected = parsed.positionals.length ? parsed.positionals : undefined;
|
|
313
|
+
if (options["--days"] !== undefined && !/^[1-9][0-9]*$/.test(String(options["--days"]))) throw new Error("decay days must be a positive decimal integer");
|
|
314
|
+
const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
|
|
315
|
+
const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
|
|
316
|
+
const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
|
|
317
|
+
const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
318
|
+
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" : ""}`,
|
|
319
|
+
...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");
|
|
320
|
+
if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
|
|
321
|
+
if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
|
|
322
|
+
if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
|
|
323
|
+
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
|
|
324
|
+
const ask = context.prompt ?? ((question: string) => prompt(question));
|
|
325
|
+
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}`}: `);
|
|
326
|
+
if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
|
|
327
|
+
assertCurrent();
|
|
328
|
+
const result = applyDecay(store, preview, { ...current, actor: "person", confirmed: true, forget: options["--forget"] === true });
|
|
329
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
|
|
330
|
+
} catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
|
|
331
|
+
finally { store.close(); }
|
|
332
|
+
}
|
|
333
|
+
if (sub === "note") {
|
|
334
|
+
// F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates).
|
|
335
|
+
const env = context.env ?? process.env, edit = context.edit ?? ((initial: string) => runEditor(env, initial));
|
|
336
|
+
const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
|
|
337
|
+
const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
|
|
338
|
+
...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
|
|
339
|
+
const evidence = repeated(argv, "--evidence"); if (evidence.length) fail.evidence = evidence;
|
|
340
|
+
let text: string | undefined = parsed.positionals.join(" ");
|
|
341
|
+
if (text === "-") text = (context.stdin ?? (() => readFileSync(0, "utf8")))();
|
|
342
|
+
else if (!text.trim()) {
|
|
343
|
+
text = undefined;
|
|
344
|
+
// No text: a fail note with no field given opens the labelled template, any other note opens an empty file.
|
|
345
|
+
if (kind === "fail" && !Object.keys(fail).length) Object.assign(fail, parseFailTemplate(await edit(FAIL_TEMPLATE)));
|
|
346
|
+
else if (kind !== "fail") text = await edit("");
|
|
347
|
+
}
|
|
348
|
+
const project = boundProject(), policy = policyOf();
|
|
349
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policy.screenStandalone(note) ? "promote" : "keep" });
|
|
350
|
+
try {
|
|
351
|
+
const { id } = noteAdd(store, project, { kind, tier: (options["--tier"] ?? "long") as "short" | "long", scope: (options["--scope"] ?? "project") as "project" | "shared",
|
|
352
|
+
...(text !== undefined ? { text } : {}), ...(typeof options["--title"] === "string" ? { title: options["--title"] } : {}), ...(Object.keys(fail).length ? { fail } : {}) }, policy);
|
|
353
|
+
return options["--json"] ? JSON.stringify({ id, kind }, null, 2) : `added ${kind} note ${id}`;
|
|
354
|
+
} finally { store.close(); }
|
|
355
|
+
}
|
|
148
356
|
if (review) {
|
|
149
|
-
if (sub === "accept" && options["--kind"] !== "memory" && shardStatePresent()) throw new Error("candidate acceptance requires shard-aware review integration; use --kind memory only for a general memory candidate");
|
|
150
|
-
if (sub === "accept" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use --kind memory only for a general memory candidate");
|
|
151
|
-
if (sub === "list" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; select --kind memory for the standalone memory candidate surface");
|
|
152
357
|
const id = parsed.positionals[0];
|
|
153
|
-
if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
|
|
358
|
+
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");
|
|
154
359
|
const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
|
|
155
360
|
try {
|
|
361
|
+
if (sub === "walk") {
|
|
362
|
+
// 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.
|
|
363
|
+
// 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
|
|
364
|
+
// or a closed pipe before the print leaves nothing stamped. Each outcome is written as it happens, so an interrupted walk still shows what it did.
|
|
365
|
+
// The walk asks accept, edit or reject questions, so what it prints must reach a person: with stdout redirected to a file or a pipe nobody sees the candidate, and stamping it shown
|
|
366
|
+
// would start the 7-day clock for something unread. Refused before anything is read or stamped. (A caller-supplied `write` is a fixture standing in for the terminal.)
|
|
367
|
+
if (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)");
|
|
368
|
+
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
|
|
369
|
+
const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
|
|
370
|
+
// Oldest first: those are the ones nearest their expiry (`list` is newest first).
|
|
371
|
+
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
|
|
372
|
+
if (!rows.length) return "no candidates pending";
|
|
373
|
+
const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
|
|
374
|
+
const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
|
|
375
|
+
const body = (candidate: (typeof rows)[number]): string => { const input = candidate.input;
|
|
376
|
+
if (input.kind === "promotion") {
|
|
377
|
+
const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
378
|
+
try {
|
|
379
|
+
const target = viewStore.get(input.target.id, project);
|
|
380
|
+
return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
|
|
381
|
+
? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
|
|
382
|
+
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
383
|
+
} finally { viewStore.close(); }
|
|
384
|
+
}
|
|
385
|
+
return input.kind === "memory" ? `${input.title ? `${input.title}: ` : ""}${input.text}` : input.kind === "fail" ? `fail. Approach: ${input.approach} Symptom: ${input.symptom} Cause: ${input.cause} Retry when: ${input.retryWhen}`
|
|
386
|
+
: input.kind === "correction" ? `correction. ${input.proposedText} Reason: ${input.reason}` : `decay. ${input.reason}`; };
|
|
387
|
+
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; } };
|
|
388
|
+
for (const [index, candidate] of rows.entries()) {
|
|
389
|
+
assertWalkCurrent();
|
|
390
|
+
lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
|
|
391
|
+
assertWalkCurrent();
|
|
392
|
+
markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
|
|
393
|
+
const answer = ((await ask("[a]ccept [e]dit [r]eject [s]kip [q]uit: ")) ?? "q").trim().toLowerCase()[0] ?? "s";
|
|
394
|
+
assertWalkCurrent();
|
|
395
|
+
if (answer === "q") break;
|
|
396
|
+
if (answer === "a") { if (await step(["review", "accept", candidate.id, ...(candidate.input.kind === "memory" ? ["--kind", "memory"] : []), "--json"])) done.accepted++; }
|
|
397
|
+
else if (answer === "r") { if (await step(["review", "reject", candidate.id])) done.rejected++; }
|
|
398
|
+
else if (answer === "e") {
|
|
399
|
+
const input = candidate.input;
|
|
400
|
+
if (input.kind !== "memory" && input.kind !== "fail") { lines.push(" edit is for memory and fail candidates; accept, reject or skip this one"); done.failed++; continue; }
|
|
401
|
+
const edit = context.edit ?? ((initial: string) => runEditor(context.env ?? process.env, initial));
|
|
402
|
+
// A memory candidate proposed as a fail note opens the template (its own text pre-filled under Approach when it is not already labelled), so the person can structure it.
|
|
403
|
+
const first = input.kind === "fail" ? `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`
|
|
404
|
+
: input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
|
|
405
|
+
try {
|
|
406
|
+
const edited = await edit(first); assertWalkCurrent(); const standalone = policyOf(), store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" });
|
|
407
|
+
// A fail candidate, and a general memory candidate proposed as a fail note, are both written as a structured fail note from the labelled body the person edited.
|
|
408
|
+
const asFail = input.kind === "fail" || input.noteKind === "fail";
|
|
409
|
+
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); }
|
|
410
|
+
finally { store.close(); }
|
|
411
|
+
candidates.reject(candidate.id, { project, actor: "person", verdict: "public" }); done.edited++;
|
|
412
|
+
} catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
|
|
413
|
+
} else done.skipped++;
|
|
414
|
+
}
|
|
415
|
+
return `accepted ${done.accepted}, edited ${done.edited}, rejected ${done.rejected}, skipped ${done.skipped}, failed ${done.failed}`;
|
|
416
|
+
}
|
|
156
417
|
if (sub === "list") {
|
|
157
418
|
const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
|
|
158
419
|
...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
420
|
+
// AC4, "shown" means the person read the candidate's content, and the list cannot know that: it prints an id, a kind and a date (no content), it is run by scripts and cron, and
|
|
421
|
+
// `--json` may be piped to a program. So `review list` stamps nothing, exactly like the hook; only `review walk`, which prints each candidate before it asks, does.
|
|
159
422
|
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";
|
|
160
423
|
}
|
|
161
424
|
if (sub === "reject") {
|
|
@@ -164,33 +427,56 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
164
427
|
}
|
|
165
428
|
const candidate = candidates.get(id!, project);
|
|
166
429
|
if (!candidate) throw new Error("candidate unavailable");
|
|
167
|
-
if (options["--kind"]
|
|
168
|
-
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use
|
|
169
|
-
|
|
170
|
-
const standalone = createStandalonePolicy();
|
|
171
|
-
if (!await standalone.screen(candidate.input)) throw new Error("candidate refused by current host policy; current standalone privacy policy declined it");
|
|
172
|
-
// Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
|
|
173
|
-
const store = curatedStore(project, { deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
|
|
430
|
+
if (options["--kind"] !== undefined && candidate.input.kind !== options["--kind"]) throw new Error("candidate kind does not match --kind");
|
|
431
|
+
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use `amem demote` or `amem forget` after review");
|
|
432
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
174
433
|
try {
|
|
434
|
+
const assertCurrent = commandBinding(store.profile.home, project);
|
|
435
|
+
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)");
|
|
436
|
+
assertCurrent();
|
|
175
437
|
const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
|
|
176
|
-
policy: (proposal,
|
|
438
|
+
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
177
439
|
});
|
|
178
|
-
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary note ${result.noteId}`;
|
|
440
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ${candidate.input.kind === "promotion" ? "promoted" : "ordinary"} note ${result.noteId}`;
|
|
179
441
|
} finally { store.close(); }
|
|
180
442
|
} finally { candidates.close(); }
|
|
181
443
|
}
|
|
444
|
+
if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
|
|
445
|
+
if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
|
|
446
|
+
const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
447
|
+
return JSON.stringify(result, null, 2);
|
|
448
|
+
}
|
|
182
449
|
if (sub === "vault-candidates") {
|
|
183
450
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
184
451
|
try {
|
|
185
|
-
const result = await vaultCandidates(store, { project, policy:
|
|
452
|
+
const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
186
453
|
return JSON.stringify(result, null, 2);
|
|
187
454
|
} finally { store.close(); }
|
|
188
455
|
}
|
|
456
|
+
if (sub === "eval") {
|
|
457
|
+
// F13: read-only. A set with entries is scored against a throwaway store seeded with them (touches no memory home); one without scores the bound project's real entries.
|
|
458
|
+
const ks = parseKs(typeof options["--k"] === "string" ? options["--k"] : undefined);
|
|
459
|
+
const file = typeof options["--set"] === "string" ? options["--set"] : undefined;
|
|
460
|
+
const set = readEvalSet(file ?? FIXTURE_PATH);
|
|
461
|
+
let report;
|
|
462
|
+
if (set.entries?.length) report = evalSeeded(set, ks);
|
|
463
|
+
else {
|
|
464
|
+
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
465
|
+
try { report = evalStore(store, project, set, ks); } finally { store.close(); }
|
|
466
|
+
}
|
|
467
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, file ?? "built-in fixture");
|
|
468
|
+
}
|
|
189
469
|
if (sub === "capacity") {
|
|
190
470
|
const share = options["--disk-share"];
|
|
191
471
|
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]");
|
|
192
472
|
const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
|
|
193
473
|
if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
|
|
474
|
+
if (options["--split"] === true) {
|
|
475
|
+
// #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.
|
|
476
|
+
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");
|
|
477
|
+
const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
|
|
478
|
+
return JSON.stringify(options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare), null, 2);
|
|
479
|
+
}
|
|
194
480
|
// Person-only (agent marker and TTY are refused above): preview first, --confirm writes the home default config.
|
|
195
481
|
const file = join(memoryHome(), "capture-config.json");
|
|
196
482
|
const target: CaptureConfig = {
|
|
@@ -215,6 +501,53 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
215
501
|
rmSync(previewFile, { force: true });
|
|
216
502
|
return JSON.stringify({ preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } }, null, 2);
|
|
217
503
|
}
|
|
504
|
+
if (sub === "projects") {
|
|
505
|
+
// 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.
|
|
506
|
+
const rows = readCatalogFresh(memoryHome()).filter(project => options["--all"] === true || project.state !== "forgotten");
|
|
507
|
+
if (options["--json"]) return JSON.stringify(rows, null, 2);
|
|
508
|
+
if (!rows.length) return "no project shards yet";
|
|
509
|
+
const mb = (n: number) => `${(n / 1048576).toFixed(1)} MB`;
|
|
510
|
+
// Per-project curated counts exist only after the split; before it every note is in one shared store, so the honest figure is unavailable, never 0.
|
|
511
|
+
const split = shardStatePresent();
|
|
512
|
+
return rows.map(p => `${p.key} ${p.state}${p.local ? " local-only key" : ""}${p.aliases.length ? ` aliases: ${p.aliases.join(", ")}` : ""} capture ${p.captureRows} rows ${mb(p.captureBytes)} curated ${split ? p.curatedEntries ?? "unavailable" : "unavailable (home not split)"} disk ${mb(p.diskBytes)} last ${p.captureLastAt ?? "never"}`).join("\n");
|
|
513
|
+
}
|
|
514
|
+
if (sub === "usage") {
|
|
515
|
+
const limit = options["--limit"] === undefined ? undefined : Number(options["--limit"]);
|
|
516
|
+
const report = usageReport(memoryHome(), { ...(limit !== undefined ? { limit } : {}), includeForgotten: options["--all"] === true });
|
|
517
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
|
|
518
|
+
}
|
|
519
|
+
if (sub === "gc") {
|
|
520
|
+
// A preview reads; only --confirm evicts (and then the terminal and agent-marker refusals above already ran, because `mutates: "confirm"`).
|
|
521
|
+
const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
|
|
522
|
+
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
523
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
524
|
+
}
|
|
525
|
+
if (sub === "project") {
|
|
526
|
+
const op = parsed.path[1]!, [requested] = parsed.positionals;
|
|
527
|
+
if (!requested) throw new Error(`usage: amem project ${op} <key>`);
|
|
528
|
+
if (op === "export") {
|
|
529
|
+
if (typeof options["--to"] !== "string") throw new Error("usage: amem project export <key> --to <file>");
|
|
530
|
+
// The bundle leaves the home, so it is screened by the configured policy (secrets, personal data, the configured patterns and deny paths).
|
|
531
|
+
const result = await exportProject(memoryHome(), requested, { to: options["--to"], policy: policyOf() });
|
|
532
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `exported ${result.project} to ${result.file}: ${result.counts.capture} capture rows, ${result.counts.curated} notes, ${result.counts.goals} goals, ${result.counts.workLists} work lists${result.withheld ? `; ${result.withheld} withheld by the screen` : ""}`;
|
|
533
|
+
}
|
|
534
|
+
const key = requireShard(memoryHome(), requested);
|
|
535
|
+
if (op === "forget" && options["--confirm"] !== true) {
|
|
536
|
+
const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
|
|
537
|
+
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.`;
|
|
538
|
+
}
|
|
539
|
+
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor: "person", machine: machineId(memoryHome()).id });
|
|
540
|
+
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
541
|
+
}
|
|
542
|
+
if (sub === "search" && options["--all-projects"] === true) {
|
|
543
|
+
const words = parsed.positionals.join(" ");
|
|
544
|
+
if (!words.trim()) throw new Error("usage: amem search --all-projects <words> [--limit N] [--include-archived] [--json]");
|
|
545
|
+
const result = searchAllProjects(memoryHome(), words, { ...(options["--limit"] !== undefined ? { limit: Number(options["--limit"]) } : {}), includeArchived: options["--include-archived"] === true });
|
|
546
|
+
if (options["--json"]) return JSON.stringify(result, null, 2);
|
|
547
|
+
const one = (hit: (typeof result.hits)[number]) => hit.view ? `${hit.project} ${hit.view.id.slice(0, 8)} ${hit.view.kind} ${(hit.view.title ?? hit.view.text).split("\n")[0]!.slice(0, 100)}` : `${hit.project} capture ${hit.capture!.id} ${hit.capture!.event} ${hit.capture!.excerpt.split("\n")[0]!.slice(0, 100)}`;
|
|
548
|
+
return [`crossed projects: searched ${result.projects.length} (${result.projects.join(", ")})`, ...(result.hits.length ? result.hits.map(one) : ["(nothing)"])].join("\n");
|
|
549
|
+
}
|
|
550
|
+
if (sub === "search" && options["--include-archived"] === true) throw new Error("--include-archived only applies with --all-projects");
|
|
218
551
|
if (sub === "alias") {
|
|
219
552
|
if (parsed.path[1] === "add") {
|
|
220
553
|
const [from, to] = parsed.positionals;
|
|
@@ -233,18 +566,132 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
233
566
|
return options["--json"] ? JSON.stringify(ops, null, 2) : ops.length ? ops.map((op) => op.op === "alias" ? `${op.at} ${op.alias} -> ${op.key} (${op.actor})` : `${op.at} ${op.alias} removed (${op.actor})`).join("\n") : "no aliases recorded";
|
|
234
567
|
}
|
|
235
568
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
236
|
-
if (goal && sub === "link") throw new Error("goal link needs host
|
|
237
|
-
|
|
569
|
+
if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
|
|
570
|
+
// #15 T4 / D6: the repo scope. Person-only and a terminal were enforced above (agent markers, `mutates`); amem never runs git.
|
|
571
|
+
const repoCwd = context.cwd ?? process.cwd();
|
|
572
|
+
if (sub === "repo") {
|
|
573
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
574
|
+
if (!root) throw new Error("this directory is not inside the working tree of the repository that is this project (amem repo works on the current repository only)");
|
|
575
|
+
if (parsed.path[1] === "attributes") {
|
|
576
|
+
const plan = planGitattributes(root);
|
|
577
|
+
if (plan.unsafe) throw new Error(plan.unsafe);
|
|
578
|
+
if (plan.present) return `${plan.path} already has the merge=union entry for .amem/log.jsonl`;
|
|
579
|
+
if (options["--confirm"] !== true) return `preview: append ".amem/log.jsonl merge=union" to ${plan.path} (nothing written; add --confirm). amem never commits it: you do.`;
|
|
580
|
+
const written = appendGitattributes(root);
|
|
581
|
+
return `${written.added ? "added" : "already had"} the merge=union entry in ${written.path}; commit it through normal review`;
|
|
582
|
+
}
|
|
583
|
+
const scoped = repoReadPolicy(root);
|
|
584
|
+
const read = "reason" in scoped ? undefined : await readRepoScope(root, scoped.policy);
|
|
585
|
+
const status = { root, file: repoLogPath(root), state: read?.state ?? "unavailable", entries: read?.views.length ?? 0, skipped: read?.skipped ?? null, ...(read?.reason ? { reason: read.reason } : {}), ...("reason" in scoped ? { reason: scoped.reason } : {}) };
|
|
586
|
+
return options["--json"] ? JSON.stringify(status, null, 2) : [`repo scope of ${project}: ${status.state}`, `file: ${status.file}`, `entries: ${status.entries}${read ? ` (skipped: ${read.skipped.malformed} malformed, ${read.skipped.mismatched} id mismatch, ${read.skipped.duplicate} duplicate, ${read.skipped.withheld} withheld by the screen)` : ""}`,
|
|
587
|
+
...(status.reason ? [`unavailable: ${status.reason}`] : [])].join("\n");
|
|
588
|
+
}
|
|
589
|
+
if (sub === "promote" && options["--to"] === undefined && (options["--confirm"] !== undefined || options["--confirm-public"] !== undefined)) throw new Error("--confirm and --confirm-public only apply with --to repo");
|
|
590
|
+
if (sub === "promote" && options["--to"] !== undefined) {
|
|
591
|
+
const idText = parsed.positionals[0];
|
|
592
|
+
if (!idText || parsed.positionals.length !== 1) throw new Error("usage: amem promote <id> --to repo --policy-free [--confirm [--confirm-public <entry id prefix>]]");
|
|
593
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
594
|
+
if (!root) throw new Error("repo scope needs the working tree of the repository that is this project: run it from inside that repository");
|
|
595
|
+
const store = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
596
|
+
let view;
|
|
597
|
+
try { view = store.get(idText, project); } finally { store.close(); }
|
|
598
|
+
if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
|
|
599
|
+
// `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
|
|
600
|
+
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`);
|
|
601
|
+
const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
|
|
602
|
+
if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
|
|
603
|
+
const seen = await repoVisibility(root, context.visibility);
|
|
604
|
+
const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
|
|
605
|
+
`repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
|
|
606
|
+
if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
|
|
607
|
+
const entryPrefix = plan.line.id.slice(0, 8);
|
|
608
|
+
if (options["--confirm"] !== true) return [...preview, seen.state === "private" ? "nothing written; add --confirm to append it" : `NOT a verified private repository: add --confirm --confirm-public ${entryPrefix} to append this entry (per entry, never blanket)`].join("\n");
|
|
609
|
+
if (seen.state !== "private") {
|
|
610
|
+
const given = options["--confirm-public"];
|
|
611
|
+
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)`);
|
|
612
|
+
}
|
|
613
|
+
const written = appendRepoLine(root, plan.line);
|
|
614
|
+
return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
|
|
615
|
+
...(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");
|
|
616
|
+
}
|
|
617
|
+
if (sub === "show" && /^repo:/.test(parsed.positionals[0] ?? "")) {
|
|
618
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project), scoped = root ? repoReadPolicy(root) : undefined;
|
|
619
|
+
const rows = root && scoped && !("reason" in scoped) ? (await readRepoScope(root, scoped.policy)).views : [];
|
|
620
|
+
const wanted = parsed.positionals[0]!.slice(5), hit = rows.filter(row => row.id === wanted || wanted.length >= 6 && row.id.startsWith(wanted));
|
|
621
|
+
if (hit.length !== 1) throw new Error(`no repo entry ${parsed.positionals[0]} in this repository's .amem/log.jsonl`);
|
|
622
|
+
const row = hit[0]!;
|
|
623
|
+
return options["--json"] ? JSON.stringify(row, null, 2) : [`repo:${row.id} ${row.kind}, repo scope, ${row.at}`, ...(row.about.length ? [`about: ${row.about.join(", ")}`] : []), "", ...(row.title ? [row.title] : []), row.text].join("\n");
|
|
624
|
+
}
|
|
625
|
+
// Recall (D4): the current project plus, when the repository IS this project, that repository's `.amem/`. Never another project's.
|
|
626
|
+
const withRepoRows = async (output: string): Promise<string> => {
|
|
627
|
+
if (options["--history"] === true || options["--as-of"] !== undefined) return output;
|
|
628
|
+
if (sub !== "list" && sub !== "search") return output;
|
|
629
|
+
const root = boundRepoRoot(repoCwd, memoryHome(), boundProject());
|
|
630
|
+
if (!root) return output;
|
|
631
|
+
const scoped = repoReadPolicy(root);
|
|
632
|
+
if ("reason" in scoped) return options["--json"] ? output : `${output}\n(repo scope unavailable: ${scoped.reason})`;
|
|
633
|
+
const read = await readRepoScope(root, scoped.policy);
|
|
634
|
+
if (read.state === "absent" && !read.skipped.malformed) return output;
|
|
635
|
+
const words = sub === "search" ? parsed.positionals.join(" ").toLowerCase().split(/\s+/).filter(Boolean) : [];
|
|
636
|
+
const kind = options["--kind"], since = options["--since"], limit = options["--limit"] !== undefined ? Number(options["--limit"]) : 50;
|
|
637
|
+
const rows = read.views.filter(row => options["--tier"] !== "short" && (kind === undefined || row.kind === kind) && (typeof since !== "string" || Date.parse(row.at) >= Date.parse(since))
|
|
638
|
+
&& words.every(word => `${row.title ?? ""}\n${row.text}\n${row.about.join(" ")}`.toLowerCase().includes(word))).slice(0, limit);
|
|
639
|
+
if (options["--json"]) { try { const base = JSON.parse(output) as unknown[]; return JSON.stringify([...base, ...rows], null, 2); } catch { return output; } }
|
|
640
|
+
const line = (row: RepoView) => `repo:${row.id.slice(0, 8)} ${row.at.slice(0, 16).replace("T", " ")} repo ${row.kind.padEnd(8)} ${`${row.title ? `${row.title}: ` : ""}${row.text}`.replace(/\s+/g, " ").slice(0, 100)}`;
|
|
641
|
+
const note = read.state === "unsafe" ? [`(repo scope unavailable: ${read.reason})`] : read.skipped.malformed + read.skipped.mismatched + read.skipped.withheld ? [`(${read.skipped.malformed + read.skipped.mismatched} repo lines skipped as invalid, ${read.skipped.withheld} withheld by the screen)`] : [];
|
|
642
|
+
return [output, ...(rows.length ? ["repo scope (.amem/log.jsonl):", ...rows.map(line)] : []), ...note].join("\n");
|
|
643
|
+
};
|
|
644
|
+
const crosses = definition.policyFree === true;
|
|
238
645
|
const cwd = context.cwd ?? process.cwd();
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
//
|
|
242
|
-
|
|
646
|
+
const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
|
|
647
|
+
const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
|
|
648
|
+
// 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.
|
|
649
|
+
const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
650
|
+
// A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
|
|
651
|
+
if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
|
|
652
|
+
onDone: async (done, store) => {
|
|
653
|
+
const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
|
|
654
|
+
if (readSelector(base).revision !== store.profile.selectorRevision) throw new Error("goal promotion store selector changed");
|
|
655
|
+
assertCurrent();
|
|
656
|
+
const { offerGoalPromotions } = await import("./memory/goal-promotion.ts");
|
|
657
|
+
assertCurrent();
|
|
658
|
+
const candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases: aliasedKeys(base, project) } });
|
|
659
|
+
try { return await offerGoalPromotions(store, candidates, project, done.id, { policy: () => configuredPolicy(base, cwd), assertCurrent }); }
|
|
660
|
+
finally { candidates.close(); }
|
|
661
|
+
} });
|
|
243
662
|
const receipt: { bridgeStatus?: { skippedRows: number } } = {};
|
|
244
|
-
|
|
245
|
-
|
|
663
|
+
// F16: `sync --if-due` is the entry for the person's own shell (an EXIT trap; it needs a terminal like every mutating command). amem registers no sync hook and runs nothing between sessions. It does nothing without a
|
|
664
|
+
// configured remote, nothing when the schedule or the failure backoff says it is not due (`--force` skips that), and it stops a running git step after 30 seconds.
|
|
665
|
+
const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome();
|
|
666
|
+
if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due");
|
|
667
|
+
if (ifDue) {
|
|
668
|
+
if (options["--remote"] === undefined && !remoteConfigured(syncHome)) return options["--json"] ? JSON.stringify({ ok: true, skipped: "no sync remote configured" }) : "no sync remote configured: nothing to do";
|
|
669
|
+
const state = usableState(readSyncState(syncHome), Date.now());
|
|
670
|
+
if (!syncDue(state, Date.now(), options["--force"] === true)) {
|
|
671
|
+
const next = nextSyncAt(state);
|
|
672
|
+
return options["--json"] ? JSON.stringify({ ok: true, skipped: "not due", nextAttemptAt: next === null ? null : new Date(next).toISOString(), failures: state.failures })
|
|
673
|
+
: `sync not due until ${next === null ? "now" : new Date(next).toISOString()}${state.failures ? ` (${state.failures} failure${state.failures === 1 ? "" : "s"} in a row: the wait doubles up to a day; --force skips it)` : ""}`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Only a real attempt is stamped: a local refusal (lock held, no remote, policy, usage) is not, and a future stamp is no history (src/memory/sync-cadence.ts).
|
|
677
|
+
const stampSync = (error: string | undefined, cause?: StopCause) => { if (syncOutcome(error, cause) !== "local") recordSyncAttempt(syncHome, error === undefined, Date.now()); };
|
|
678
|
+
const syncArgs = args.filter(arg => arg !== "--if-due" && arg !== "--force");
|
|
679
|
+
let output: string;
|
|
680
|
+
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
681
|
+
try {
|
|
682
|
+
output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, project: boundProject(), open, readable: notes => notes, stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
|
|
683
|
+
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
684
|
+
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
685
|
+
} catch (error) { if (sub === "sync") stampSync((error as Error).message, stop?.cause()); throw error; }
|
|
686
|
+
finally { stop?.dispose(); }
|
|
687
|
+
if (sub === "sync") {
|
|
688
|
+
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
689
|
+
let failure: string | undefined;
|
|
690
|
+
if (options["--json"]) { try { const result = JSON.parse(output) as { ok?: unknown; error?: unknown }; if (result.ok === false) failure = typeof result.error === "string" ? result.error : "sync failed"; } catch { /* not a sync result */ } }
|
|
691
|
+
stampSync(failure, stop?.cause());
|
|
692
|
+
}
|
|
246
693
|
if (sub === "import" && parsed.path[1] === "claude-mem") {
|
|
247
|
-
const policy =
|
|
694
|
+
const policy = configured;
|
|
248
695
|
const work = await importClaudeMemWorkState({ base: memoryHome(), project: boundProject(), policy: row => policy.screen(row), ...(options["--db"] ? { db: String(options["--db"]) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}), restart: options["--restart"] === true });
|
|
249
696
|
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work }, null, 2) : `${output}\nwork lists: ${JSON.stringify(work)}`;
|
|
250
697
|
}
|
|
@@ -252,9 +699,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
252
699
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
253
700
|
}
|
|
254
701
|
|
|
702
|
+
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
703
|
+
|
|
704
|
+
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
705
|
+
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|
|
255
706
|
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
|
256
|
-
try {
|
|
257
|
-
|
|
707
|
+
try {
|
|
708
|
+
const receipts: CliOutputReceipt[] = [], output = await runCli(argv, { onOutputReady: receipt => receipts.push(receipt) });
|
|
709
|
+
if (output) { await writeCliOutput(output + "\n"); for (const receipt of receipts) { try { receipt.rendered(); } catch { /* telemetry stays optional */ } } }
|
|
710
|
+
} catch (error) { console.error(formatCliError(error)); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; }
|
|
258
711
|
}
|
|
259
712
|
|
|
260
713
|
if (import.meta.main) await main();
|