@staix/agent-mem 0.6.0 → 0.8.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 +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -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 +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -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/metrics.ts +2 -2
- 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 +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- 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/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
package/src/cli.ts
CHANGED
|
@@ -1,30 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Writable } from "node:stream";
|
|
3
|
+
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
|
+
import { proposeConsolidation } from "./memory/consolidate.ts";
|
|
5
|
+
import { createIntentBriefSource } from "./memory/recall.ts";
|
|
6
|
+
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
7
|
+
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
8
|
+
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
1
9
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
2
|
-
import { serveMcp } from "./mcp.ts";
|
|
10
|
+
import { serveMcp, writeConfirmed } from "./mcp.ts";
|
|
3
11
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
12
|
+
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
4
13
|
import { vaultCandidates } from "./memory/vault.ts";
|
|
5
14
|
import { importClaudeMemWorkState } from "./memory/import.ts";
|
|
6
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
7
16
|
import { join } from "node:path";
|
|
8
17
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
9
18
|
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
10
19
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
11
|
-
import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
|
|
20
|
+
import { CandidateStore, candidateTargetRevision, type CandidateFilter } from "./memory/candidates.ts";
|
|
12
21
|
import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
13
22
|
import { capacityReport, capacityText } from "./memory/capacity.ts";
|
|
14
23
|
import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
|
|
15
24
|
|
|
16
25
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
17
26
|
import { completionScript } from "./completion.ts";
|
|
18
|
-
import { memoryHome } from "./memory/home.ts";
|
|
19
|
-
import {
|
|
27
|
+
import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
|
|
28
|
+
import { realPath } from "./memory/platform.ts";
|
|
29
|
+
import { hashJSON, readSelector, resolveProfile } from "./memory/generation.ts";
|
|
20
30
|
import { migrate } from "./memory/migrate.ts";
|
|
21
31
|
import { migrateShards, shardMigrationStatus } from "./memory/shard-migration.ts";
|
|
22
32
|
import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
33
|
+
import { readCatalogFresh } from "./memory/catalog.ts";
|
|
34
|
+
import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
|
|
35
|
+
import { gcText, runGc } from "./memory/gc.ts";
|
|
36
|
+
import { usageReport, usageText } from "./memory/usage.ts";
|
|
37
|
+
import { exportProject, type ProjectExportFormat } from "./memory/project-export.ts";
|
|
38
|
+
import { importProject } from "./memory/project-import.ts";
|
|
39
|
+
import { importExternalMemory, type ExternalMemorySource } from "./memory/external-import.ts";
|
|
40
|
+
import { searchAllProjects, vaultCandidatesAll } from "./memory/cross-project.ts";
|
|
41
|
+
import { requireShard } from "./memory/curated-read.ts";
|
|
42
|
+
import { appendGitattributes, appendRepoLine, boundRepoRoot, planGitattributes, planRepoPromotion, readRepoScope, repoLogIgnored, repoLogPath, repoReadPolicy, repoVisibility, type RepoView } from "./memory/repo-scope.ts";
|
|
43
|
+
import type { Visibility } from "./memory/sync.ts";
|
|
44
|
+
import { applySplit, previewSplit } from "./memory/capacity-split.ts";
|
|
45
|
+
import { evalText, parseKs } from "./memory/eval.ts";
|
|
46
|
+
import { builtinSet, evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
|
|
47
|
+
import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
48
|
+
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
49
|
+
import { runEditor } from "./memory/editor.ts";
|
|
50
|
+
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
51
|
+
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
23
52
|
|
|
24
|
-
export const PERSON_ONLY = "amem is for the person
|
|
53
|
+
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
54
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
26
|
-
export interface
|
|
55
|
+
export interface CliOutputReceipt { project: string; ids: readonly string[]; rendered(): boolean }
|
|
56
|
+
export interface CliContext {
|
|
57
|
+
/** Prepared output metadata only. Invoke rendered only after the output writer succeeds. */
|
|
58
|
+
onOutputReady?: (receipt: CliOutputReceipt) => void;
|
|
59
|
+
cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean;
|
|
60
|
+
/** Narrow seam for deterministic fixtures standing in for `gh` (#15 T4): the CLI always uses the native remote visibility check. */
|
|
61
|
+
visibility?: (url: string) => Promise<Visibility>;
|
|
62
|
+
/** Trusted isolated-HOME setup fixture; ordinary CLI use asks through its terminal. */
|
|
63
|
+
setupConfirm?: (plan: import("./setup.ts").SetupPlan) => Promise<boolean>;
|
|
64
|
+
/** 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. */
|
|
65
|
+
prompt?: (question: string) => string | null | Promise<string | null>;
|
|
66
|
+
/** 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. */
|
|
67
|
+
write?: (text: string) => void;
|
|
68
|
+
/** Whether stdout is a terminal, for the real stdout only (default `process.stdout.isTTY`). */
|
|
69
|
+
stdoutTTY?: boolean;
|
|
70
|
+
edit?: (initial: string) => string | Promise<string>;
|
|
71
|
+
/** Test seam: the `sync --if-due` deadline in milliseconds (the CLI always uses 30 seconds). */
|
|
72
|
+
syncDeadlineMs?: number;
|
|
73
|
+
/** Called with the signal name when a person's signal ended `sync --if-due`, after its handlers are removed. `main` re-raises it once the output is written; absent, nothing happens. */
|
|
74
|
+
onPersonStop?: (signal: string) => void;
|
|
75
|
+
stdin?: () => string;
|
|
76
|
+
}
|
|
27
77
|
|
|
78
|
+
/** 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. */
|
|
79
|
+
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; };
|
|
80
|
+
/** Every value given for a repeatable flag, in order (`parseCommand` keeps only the last). */
|
|
81
|
+
function repeated(argv: readonly string[], flag: string): string[] {
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
for (let i = 0; i < argv.length; i++) {
|
|
84
|
+
const token = argv[i]!;
|
|
85
|
+
if (token === flag) { const value = argv[++i]; if (value !== undefined && !value.startsWith("--")) out.push(value); }
|
|
86
|
+
else if (token.startsWith(`${flag}=`)) out.push(token.slice(flag.length + 1));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
28
90
|
/** Narrow descriptor seam for deterministic race fixtures; the CLI always uses the native implementation. */
|
|
29
91
|
export interface PrunePlanFileIO {
|
|
30
92
|
open(path: string, flags: number): number;
|
|
@@ -62,7 +124,7 @@ export function readPrunePlanFile(path: string, io: PrunePlanFileIO = planFileIO
|
|
|
62
124
|
|
|
63
125
|
/** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
|
|
64
126
|
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);
|
|
127
|
+
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
|
|
66
128
|
}
|
|
67
129
|
|
|
68
130
|
function shardStatePresent(): boolean {
|
|
@@ -72,6 +134,9 @@ function shardStatePresent(): boolean {
|
|
|
72
134
|
|
|
73
135
|
/** `reconciled` (#27): the project filter also covers every key aliased to `project`; prune and other planners keep the exact key. */
|
|
74
136
|
function curatedStore(project: string, opts: StoreOptions, reconciled = true): MemoryStore {
|
|
137
|
+
// #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
|
|
138
|
+
// an unsplit one alike. `search --all-projects --include-archived` and `project export` are the explicit ways to reach an archived project.
|
|
139
|
+
assertProjectActive(memoryHome(), project);
|
|
75
140
|
const aliases = reconciled ? aliasedKeys(memoryHome(), project) : [];
|
|
76
141
|
const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
|
|
77
142
|
if (shardStatePresent()) {
|
|
@@ -81,10 +146,17 @@ function curatedStore(project: string, opts: StoreOptions, reconciled = true): M
|
|
|
81
146
|
return new MemoryStore(withAliases);
|
|
82
147
|
}
|
|
83
148
|
|
|
149
|
+
/** 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. */
|
|
150
|
+
function curatedStoreAt(base: string, project: string, opts: StoreOptions): MemoryStore {
|
|
151
|
+
const aliases = aliasedKeys(base, project), withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
|
|
152
|
+
if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); }
|
|
153
|
+
return new MemoryStore({ ...withAliases, memoryDir: base });
|
|
154
|
+
}
|
|
155
|
+
|
|
84
156
|
export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
|
|
85
157
|
if (argv.length === 1 && argv[0] === "mcp") {
|
|
86
158
|
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 });
|
|
159
|
+
await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
|
|
88
160
|
return "";
|
|
89
161
|
}
|
|
90
162
|
if (argv[0] === "hook") {
|
|
@@ -103,6 +175,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
103
175
|
...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
|
|
104
176
|
}));
|
|
105
177
|
}
|
|
178
|
+
// F1: `--help` and `--version` only print text, so they work in an agent shell too. Every other command keeps the honest-session refusal below.
|
|
179
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "version")) return `amem ${VERSION}`;
|
|
180
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "help")) return commandHelp();
|
|
106
181
|
if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
|
|
107
182
|
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
108
183
|
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
@@ -119,15 +194,82 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
119
194
|
throw error;
|
|
120
195
|
}
|
|
121
196
|
const { definition, options, rawArgs: args } = parsed;
|
|
197
|
+
if (parsed.path[0] === "setup") {
|
|
198
|
+
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
|
|
199
|
+
const { runSetup } = await import("./setup.ts");
|
|
200
|
+
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
201
|
+
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY,
|
|
202
|
+
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
122
205
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
123
206
|
const policyFree = options["--policy-free"] === true;
|
|
124
|
-
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
207
|
+
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined
|
|
208
|
+
|| definition.mutates === "confirm" && options["--confirm"] === true;
|
|
125
209
|
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
210
|
+
// 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
|
|
211
|
+
// `--policy-free`. The flag is still accepted and means nothing more, so existing scripts keep working.
|
|
212
|
+
const policyCwd = context.cwd ?? process.cwd();
|
|
213
|
+
const policyOf = () => configuredPolicy(memoryHome(), policyCwd); // the same home every other command here resolves
|
|
129
214
|
let canonicalProject: string | undefined;
|
|
130
215
|
const boundProject = () => canonicalProject ??= resolveAlias(memoryHome(), memoryProjectKey(context.cwd ?? process.cwd()));
|
|
216
|
+
const commandBinding = (home: string, project: string): (() => void) => {
|
|
217
|
+
const base = realPath(home), selector = readSelector(base).revision, aliases = aliasedKeys(base, project).join("\n");
|
|
218
|
+
const split = shardStatePresent(), shards = split ? JSON.stringify(readShardedSelection(base)) : undefined;
|
|
219
|
+
return () => {
|
|
220
|
+
const currentSplit = shardStatePresent();
|
|
221
|
+
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");
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
const outputReady = (ids: readonly string[], project = boundProject()) => {
|
|
225
|
+
if (!context.onOutputReady || !ids.length) return;
|
|
226
|
+
const base = realPath(memoryHome()), assertCurrent = commandBinding(base, project), deliveryId = `cli:${randomUUID()}`, selected = [...new Set(ids)].slice(0, 256);
|
|
227
|
+
context.onOutputReady({ project, ids: selected, rendered: () => {
|
|
228
|
+
let candidates: CandidateStore | undefined;
|
|
229
|
+
try { assertCurrent(); candidates = new CandidateStore({ memoryDir: base }); let known = true;
|
|
230
|
+
for (const entryId of selected) known = recordMemoryUse(candidates, { project, entryId, deliveryId }, { rendered: 1 }, { assertCurrent }) && known;
|
|
231
|
+
return known;
|
|
232
|
+
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
233
|
+
} });
|
|
234
|
+
};
|
|
235
|
+
if (sub === "consolidate") {
|
|
236
|
+
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
237
|
+
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
238
|
+
const base = memoryHome(), project = resolveAlias(base, requested), aliases = aliasedKeys(base, project);
|
|
239
|
+
// The policy below is narrowed by the repository around the working directory, which is the bound project's. Another project's own repository policy cannot be resolved from here, so screening it with this one would apply the wrong narrowing.
|
|
240
|
+
if (project !== boundProject()) throw Error("consolidate --project must name the current project; run it from the other project's directory to consolidate that one");
|
|
241
|
+
assertProjectActive(base, project);
|
|
242
|
+
const store = curatedStore(project, { deferPromotions: true, screen: () => "keep" });
|
|
243
|
+
let candidates: CandidateStore | undefined;
|
|
244
|
+
try {
|
|
245
|
+
const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
|
|
246
|
+
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"); };
|
|
247
|
+
candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
|
|
248
|
+
const report = await proposeConsolidation(store, candidates, { project, actor: "person", policy: policyOf, policyVersion: policyKey, assertCurrent });
|
|
249
|
+
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` : ""}`;
|
|
250
|
+
} finally { candidates?.close(); store.close(); }
|
|
251
|
+
}
|
|
252
|
+
if (sub === "brief") {
|
|
253
|
+
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]");
|
|
254
|
+
const rawBytes = options["--bytes"];
|
|
255
|
+
if (rawBytes !== undefined && !/^[1-9][0-9]*$/.test(String(rawBytes))) throw Error("brief bytes must be a positive decimal integer");
|
|
256
|
+
const maxBytes = rawBytes === undefined ? 2048 : Number(rawBytes); if (!Number.isSafeInteger(maxBytes) || maxBytes > 8192) throw Error("brief bytes must be 1..8192");
|
|
257
|
+
const project = boundProject(), base = realPath(memoryHome()), paths = repeated(argv, "--paths"), symbols = repeated(argv, "--symbols");
|
|
258
|
+
if (paths.length > 150 || symbols.length > 150 || [...paths, ...symbols].some(value => value.length > 512)) throw Error("brief paths and symbols exceed the input bound");
|
|
259
|
+
const store = curatedStore(project, { deferPromotions: true });
|
|
260
|
+
try {
|
|
261
|
+
const assertCurrent = commandBinding(base, project), repository = boundRepoRoot(policyCwd, base, project), root = repository ?? policyCwd;
|
|
262
|
+
const rootConfirmed = repository !== undefined || project.startsWith("local:") && resolveAlias(base, memoryProjectKey(policyCwd)) === project;
|
|
263
|
+
const settings = () => configuredPolicyOptions(base, policyCwd), policyKey = () => hashJSON(settings()), revision = policyKey(), snapshot = createStandalonePolicy(settings());
|
|
264
|
+
const policy = () => snapshot, policyVersion = () => policyKey();
|
|
265
|
+
if (policyVersion() !== revision) throw Error("brief policy changed before read");
|
|
266
|
+
const result = await buildIntentBrief(createIntentBriefSource(store, project),
|
|
267
|
+
{ project, cwd: root, repo: project, intent, paths, symbols, maxBytes, policy, policyVersion, assertCurrent, now: () => store.readNow(), stale: createRecallStalenessProbe({ cwd: root, rootConfirmed, policy }) });
|
|
268
|
+
assertCurrent(); outputReady(result.items.map(view => view.id), project);
|
|
269
|
+
return options["--json"] ? JSON.stringify({ brief: result.text, bytes: result.bytes, truncated: result.truncated, state: result.state }, null, 2) : result.text;
|
|
270
|
+
} finally { store.close(); }
|
|
271
|
+
}
|
|
272
|
+
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");
|
|
131
273
|
if (sub === "migrate") {
|
|
132
274
|
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
133
275
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
@@ -140,7 +282,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
140
282
|
}
|
|
141
283
|
return JSON.stringify(await migrate({ ...common, mode }), null, 2);
|
|
142
284
|
}
|
|
143
|
-
if (sub === "status")
|
|
285
|
+
if (sub === "status") {
|
|
286
|
+
// 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.
|
|
287
|
+
const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
|
|
288
|
+
const { config, source } = loaded;
|
|
289
|
+
return JSON.stringify({ home: { path: where.home, source: where.source },
|
|
290
|
+
config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
|
|
291
|
+
...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
|
|
292
|
+
...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
|
|
293
|
+
// 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).
|
|
294
|
+
candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
|
|
295
|
+
if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
|
|
296
|
+
return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
|
|
297
|
+
...shardMigrationStatus(where.home) }, null, 2);
|
|
298
|
+
}
|
|
299
|
+
if (sub === "doctor") {
|
|
300
|
+
// F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
|
|
301
|
+
const where = resolveMemoryHome(), project = boundProject();
|
|
302
|
+
const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
|
|
303
|
+
openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
|
|
304
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
|
|
305
|
+
}
|
|
144
306
|
if (sub === "prune") {
|
|
145
307
|
const applying = options["--apply"] === true;
|
|
146
308
|
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>");
|
|
@@ -161,17 +323,126 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
161
323
|
return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
|
|
162
324
|
} finally { store.close(); }
|
|
163
325
|
}
|
|
326
|
+
if (sub === "decay") {
|
|
327
|
+
const applying = options["--apply"] === true;
|
|
328
|
+
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");
|
|
329
|
+
const { decayPreview, applyDecay, readDecayCounters, DECAY_DEFAULT_DAYS, assertExactDecayForget } = await import("./memory/decay-review.ts");
|
|
330
|
+
const project = boundProject(), base = memoryHome(), aliases = aliasedKeys(base, project);
|
|
331
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
332
|
+
try {
|
|
333
|
+
const assertCurrent = commandBinding(store.profile.home, project);
|
|
334
|
+
const selected = parsed.positionals.length ? parsed.positionals : undefined;
|
|
335
|
+
if (options["--days"] !== undefined && !/^[1-9][0-9]*$/.test(String(options["--days"]))) throw new Error("decay days must be a positive decimal integer");
|
|
336
|
+
const days = options["--days"] === undefined ? DECAY_DEFAULT_DAYS : Number(options["--days"]);
|
|
337
|
+
const settings = { days, ...(selected ? { ids: selected } : {}), aliases, counters: () => readDecayCounters(base, [project, ...aliases]) };
|
|
338
|
+
const current = { ...settings, screen: (note: import("./memory/store.ts").View) => policyOf().screenStandalone(note), assertCurrent };
|
|
339
|
+
const preview = decayPreview(store, project, current), action = options["--forget"] === true ? "FORGET" : "demote";
|
|
340
|
+
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" : ""}`,
|
|
341
|
+
...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");
|
|
342
|
+
if (!applying) return options["--json"] ? JSON.stringify(preview, null, 2) : display;
|
|
343
|
+
if (!preview.entries.length) return options["--json"] ? JSON.stringify({ action, ids: [] }) : "no eligible notes to apply";
|
|
344
|
+
if (options["--forget"] === true) assertExactDecayForget(store, preview.entries.map(entry => entry.target.id), project);
|
|
345
|
+
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); }); write(display);
|
|
346
|
+
const ask = context.prompt ?? ((question: string) => prompt(question));
|
|
347
|
+
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}`}: `);
|
|
348
|
+
if (answer?.trim() !== "yes") return options["--json"] ? JSON.stringify({ cancelled: true }) : "decay apply cancelled";
|
|
349
|
+
assertCurrent();
|
|
350
|
+
const result = applyDecay(store, preview, { ...current, actor: "person", confirmed: true, forget: options["--forget"] === true });
|
|
351
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.action}: ${result.ids.length} entries${result.expiresAt ? `; expires ${result.expiresAt}` : ""}`;
|
|
352
|
+
} catch (error) { if (error instanceof DecayApplyError) error.jsonOutput = options["--json"] === true; throw error; }
|
|
353
|
+
finally { store.close(); }
|
|
354
|
+
}
|
|
355
|
+
if (sub === "note") {
|
|
356
|
+
// F6: the person writes a curated note. Person-only (the agent-marker refusal and the terminal requirement above already ran, because `note add` mutates).
|
|
357
|
+
const env = context.env ?? process.env, edit = context.edit ?? ((initial: string) => runEditor(env, initial));
|
|
358
|
+
const kind = (options["--kind"] ?? "finding") as Parameters<typeof buildNote>[0]["kind"];
|
|
359
|
+
const fail: Partial<FailFields> = { ...(typeof options["--approach"] === "string" ? { approach: options["--approach"] } : {}), ...(typeof options["--symptom"] === "string" ? { symptom: options["--symptom"] } : {}),
|
|
360
|
+
...(typeof options["--cause"] === "string" ? { cause: options["--cause"] } : {}), ...(typeof options["--retry-when"] === "string" ? { retryWhen: options["--retry-when"] } : {}) };
|
|
361
|
+
const evidence = repeated(argv, "--evidence"); if (evidence.length) fail.evidence = evidence;
|
|
362
|
+
let text: string | undefined = parsed.positionals.join(" ");
|
|
363
|
+
if (text === "-") text = (context.stdin ?? (() => readFileSync(0, "utf8")))();
|
|
364
|
+
else if (!text.trim()) {
|
|
365
|
+
text = undefined;
|
|
366
|
+
// No text: a fail note with no field given opens the labelled template, any other note opens an empty file.
|
|
367
|
+
if (kind === "fail" && !Object.keys(fail).length) Object.assign(fail, parseFailTemplate(await edit(FAIL_TEMPLATE)));
|
|
368
|
+
else if (kind !== "fail") text = await edit("");
|
|
369
|
+
}
|
|
370
|
+
const project = boundProject(), policy = policyOf();
|
|
371
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policy.screenStandalone(note) ? "promote" : "keep" });
|
|
372
|
+
try {
|
|
373
|
+
const { id } = noteAdd(store, project, { kind, tier: (options["--tier"] ?? "long") as "short" | "long", scope: (options["--scope"] ?? "project") as "project" | "shared",
|
|
374
|
+
...(text !== undefined ? { text } : {}), ...(typeof options["--title"] === "string" ? { title: options["--title"] } : {}), ...(Object.keys(fail).length ? { fail } : {}) }, policy);
|
|
375
|
+
return options["--json"] ? JSON.stringify({ id, kind }, null, 2) : `added ${kind} note ${id}`;
|
|
376
|
+
} finally { store.close(); }
|
|
377
|
+
}
|
|
164
378
|
if (review) {
|
|
165
|
-
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");
|
|
166
|
-
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");
|
|
167
|
-
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");
|
|
168
379
|
const id = parsed.positionals[0];
|
|
169
|
-
if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
|
|
380
|
+
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");
|
|
170
381
|
const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
|
|
171
382
|
try {
|
|
383
|
+
if (sub === "walk") {
|
|
384
|
+
// 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.
|
|
385
|
+
// 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
|
|
386
|
+
// 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.
|
|
387
|
+
// 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
|
|
388
|
+
// 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.)
|
|
389
|
+
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)");
|
|
390
|
+
const write = context.write ?? ((text: string) => { process.stdout.write(`${text}\n`); });
|
|
391
|
+
const ask = context.prompt ?? ((question: string) => prompt(question)), scope = [project, ...aliases], base = realPath(candidates.dir), assertWalkCurrent = commandBinding(candidates.dir, project);
|
|
392
|
+
// Oldest first: those are the ones nearest their expiry (`list` is newest first).
|
|
393
|
+
const rows = candidates.list({ project, status: "pending", ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}) }).reverse();
|
|
394
|
+
if (!rows.length) return "no candidates pending";
|
|
395
|
+
const done = { accepted: 0, edited: 0, rejected: 0, skipped: 0, failed: 0 };
|
|
396
|
+
const lines = { push: (...texts: string[]) => { for (const text of texts) write(text); return 0; } };
|
|
397
|
+
const body = (candidate: (typeof rows)[number]): string => { const input = candidate.input;
|
|
398
|
+
if (input.kind === "promotion") {
|
|
399
|
+
const viewStore = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
400
|
+
try {
|
|
401
|
+
const target = viewStore.get(input.target.id, project);
|
|
402
|
+
return target && target.id === input.target.id && candidateTargetRevision(target) === input.target.revision
|
|
403
|
+
? `promotion. Goal goal:${input.goalId}, mem:${target.id}. ${target.title ? `${target.title}: ` : ""}${target.text}`
|
|
404
|
+
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
405
|
+
} finally { viewStore.close(); }
|
|
406
|
+
}
|
|
407
|
+
if (input.kind === "model-answer") return `Model answer (not evidence; cannot accept, author an independent note with amem note add). ${input.text}`;
|
|
408
|
+
if (input.kind === "consolidation") return `${input.action}. Sources: ${input.targets.map(target => `mem:${target.id}`).join(", ")}. ${input.text}`;
|
|
409
|
+
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}`
|
|
410
|
+
: input.kind === "correction" ? `correction. ${input.proposedText} Reason: ${input.reason}` : `decay. ${input.reason}`; };
|
|
411
|
+
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; } };
|
|
412
|
+
for (const [index, candidate] of rows.entries()) {
|
|
413
|
+
assertWalkCurrent();
|
|
414
|
+
lines.push(`${index + 1}/${rows.length} ${candidate.id.slice(0, 12)} ${candidate.input.kind} ${candidate.createdAt.slice(0, 10)}`, ` ${terminalSafe(body(candidate))}`);
|
|
415
|
+
assertWalkCurrent();
|
|
416
|
+
markShown(base, scope, Date.now(), [candidate.id]); // after the write: shown means the person could read it
|
|
417
|
+
const answer = ((await ask("[a]ccept [e]dit [r]eject [s]kip [q]uit: ")) ?? "q").trim().toLowerCase()[0] ?? "s";
|
|
418
|
+
assertWalkCurrent();
|
|
419
|
+
if (answer === "q") break;
|
|
420
|
+
if (answer === "a") { if (await step(["review", "accept", candidate.id, ...(candidate.input.kind === "memory" ? ["--kind", "memory"] : []), "--json"])) done.accepted++; }
|
|
421
|
+
else if (answer === "r") { if (await step(["review", "reject", candidate.id])) done.rejected++; }
|
|
422
|
+
else if (answer === "e") {
|
|
423
|
+
const input = candidate.input;
|
|
424
|
+
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; }
|
|
425
|
+
const edit = context.edit ?? ((initial: string) => runEditor(context.env ?? process.env, initial));
|
|
426
|
+
// 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.
|
|
427
|
+
const first = input.kind === "fail" ? `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`
|
|
428
|
+
: input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
|
|
429
|
+
try {
|
|
430
|
+
const edited = await edit(first); assertWalkCurrent(); const standalone = policyOf(), store = curatedStore(project, { deferPromotions: true, screen: note => standalone.screenStandalone(note) ? "promote" : "keep" });
|
|
431
|
+
// 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.
|
|
432
|
+
const asFail = input.kind === "fail" || input.noteKind === "fail";
|
|
433
|
+
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); }
|
|
434
|
+
finally { store.close(); }
|
|
435
|
+
candidates.reject(candidate.id, { project, actor: "person", verdict: "public" }); done.edited++;
|
|
436
|
+
} catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; }
|
|
437
|
+
} else done.skipped++;
|
|
438
|
+
}
|
|
439
|
+
return `accepted ${done.accepted}, edited ${done.edited}, rejected ${done.rejected}, skipped ${done.skipped}, failed ${done.failed}`;
|
|
440
|
+
}
|
|
172
441
|
if (sub === "list") {
|
|
173
442
|
const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
|
|
174
443
|
...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
444
|
+
// 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
|
|
445
|
+
// `--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.
|
|
175
446
|
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";
|
|
176
447
|
}
|
|
177
448
|
if (sub === "reject") {
|
|
@@ -180,33 +451,58 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
180
451
|
}
|
|
181
452
|
const candidate = candidates.get(id!, project);
|
|
182
453
|
if (!candidate) throw new Error("candidate unavailable");
|
|
183
|
-
if (options["--kind"]
|
|
184
|
-
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use
|
|
185
|
-
|
|
186
|
-
const standalone = createStandalonePolicy();
|
|
187
|
-
if (!await standalone.screen(candidate.input)) throw new Error("candidate refused by current host policy; current standalone privacy policy declined it");
|
|
188
|
-
// Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
|
|
189
|
-
const store = curatedStore(project, { deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
|
|
454
|
+
if (options["--kind"] !== undefined && candidate.input.kind !== options["--kind"]) throw new Error("candidate kind does not match --kind");
|
|
455
|
+
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use `amem demote` or `amem forget` after review");
|
|
456
|
+
const store = curatedStore(project, { deferPromotions: true, screen: note => policyOf().screenStandalone(note) ? "promote" : "keep" });
|
|
190
457
|
try {
|
|
458
|
+
const assertCurrent = commandBinding(store.profile.home, project);
|
|
459
|
+
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)");
|
|
460
|
+
assertCurrent();
|
|
191
461
|
const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
|
|
192
|
-
|
|
462
|
+
policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
|
|
463
|
+
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
193
464
|
});
|
|
194
|
-
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary note ${result.noteId}`;
|
|
465
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ${candidate.input.kind === "promotion" ? "promoted" : "ordinary"} note ${result.noteId}`;
|
|
195
466
|
} finally { store.close(); }
|
|
196
467
|
} finally { candidates.close(); }
|
|
197
468
|
}
|
|
469
|
+
if (sub === "vault-candidates" && (options["--all-projects"] === true || options["--project"] !== undefined)) {
|
|
470
|
+
if (options["--all-projects"] === true && options["--project"] !== undefined) throw new Error("choose either --all-projects or --project <key>, not both");
|
|
471
|
+
const result = await vaultCandidatesAll(memoryHome(), { policy: policyOf(), ...(options["--project"] !== undefined ? { project: resolveAlias(memoryHome(), String(options["--project"])) } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
472
|
+
return JSON.stringify(result, null, 2);
|
|
473
|
+
}
|
|
198
474
|
if (sub === "vault-candidates") {
|
|
199
475
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
200
476
|
try {
|
|
201
|
-
const result = await vaultCandidates(store, { project, policy:
|
|
477
|
+
const result = await vaultCandidates(store, { project, policy: policyOf(), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
202
478
|
return JSON.stringify(result, null, 2);
|
|
203
479
|
} finally { store.close(); }
|
|
204
480
|
}
|
|
481
|
+
if (sub === "eval") {
|
|
482
|
+
// 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.
|
|
483
|
+
const ks = parseKs(typeof options["--k"] === "string" ? options["--k"] : undefined);
|
|
484
|
+
const file = typeof options["--set"] === "string" ? options["--set"] : undefined;
|
|
485
|
+
const builtin = file === undefined ? undefined : builtinSet(file);
|
|
486
|
+
const set = readEvalSet(builtin ?? file ?? FIXTURE_PATH);
|
|
487
|
+
let report;
|
|
488
|
+
if (set.entries?.length) report = evalSeeded(set, ks);
|
|
489
|
+
else {
|
|
490
|
+
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
491
|
+
try { report = evalStore(store, project, set, ks); } finally { store.close(); }
|
|
492
|
+
}
|
|
493
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, builtin ? `built-in fixture ${file}` : file ?? "built-in fixture");
|
|
494
|
+
}
|
|
205
495
|
if (sub === "capacity") {
|
|
206
496
|
const share = options["--disk-share"];
|
|
207
497
|
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]");
|
|
208
498
|
const report = capacityReport({ ...(share !== undefined ? { diskShare: Number(share) } : {}) });
|
|
209
499
|
if (parsed.path[1] !== "apply") return options["--json"] ? JSON.stringify(report, null, 2) : capacityText(report);
|
|
500
|
+
if (options["--split"] === true) {
|
|
501
|
+
// #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.
|
|
502
|
+
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");
|
|
503
|
+
const diskShare = share !== undefined ? { diskShare: Number(share) } : {};
|
|
504
|
+
return JSON.stringify(options["--confirm"] === true ? applySplit(memoryHome(), diskShare) : previewSplit(memoryHome(), diskShare), null, 2);
|
|
505
|
+
}
|
|
210
506
|
// Person-only (agent marker and TTY are refused above): preview first, --confirm writes the home default config.
|
|
211
507
|
const file = join(memoryHome(), "capture-config.json");
|
|
212
508
|
const target: CaptureConfig = {
|
|
@@ -231,6 +527,76 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
231
527
|
rmSync(previewFile, { force: true });
|
|
232
528
|
return JSON.stringify({ preview, written: { cap_mb: written.capMb, retention_days: written.retentionDays } }, null, 2);
|
|
233
529
|
}
|
|
530
|
+
if (sub === "projects") {
|
|
531
|
+
// 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.
|
|
532
|
+
const rows = readCatalogFresh(memoryHome()).filter(project => options["--all"] === true || project.state !== "forgotten");
|
|
533
|
+
if (options["--json"]) return JSON.stringify(rows, null, 2);
|
|
534
|
+
if (!rows.length) return "no project shards yet";
|
|
535
|
+
const mb = (n: number) => `${(n / 1048576).toFixed(1)} MB`;
|
|
536
|
+
// 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.
|
|
537
|
+
const split = shardStatePresent();
|
|
538
|
+
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");
|
|
539
|
+
}
|
|
540
|
+
if (sub === "usage") {
|
|
541
|
+
const limit = options["--limit"] === undefined ? undefined : Number(options["--limit"]);
|
|
542
|
+
const report = usageReport(memoryHome(), { ...(limit !== undefined ? { limit } : {}), includeForgotten: options["--all"] === true });
|
|
543
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : usageText(report);
|
|
544
|
+
}
|
|
545
|
+
if (sub === "gc") {
|
|
546
|
+
// A preview reads; only --confirm evicts (and then the terminal and agent-marker refusals above already ran, because `mutates: "confirm"`).
|
|
547
|
+
const batches = options["--batches"] === undefined ? undefined : Number(options["--batches"]);
|
|
548
|
+
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
549
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
550
|
+
}
|
|
551
|
+
if (sub === "import" && ["claude-auto", "codex"].includes(parsed.path[1] ?? "")) {
|
|
552
|
+
for (const flag of ["--db", "--limit", "--restart"]) if (options[flag] !== undefined) throw new Error(`${flag} only applies to import claude-mem`);
|
|
553
|
+
const source = parsed.path[1] as ExternalMemorySource, policy = policyOf();
|
|
554
|
+
const result = await importExternalMemory({ base: memoryHome(), project: boundProject(), source, cwd: policyCwd, env: context.env ?? process.env,
|
|
555
|
+
...(typeof options["--from"] === "string" ? { from: options["--from"] } : {}), policy: value => policy.screenStandalone(value) });
|
|
556
|
+
return options["--json"] ? JSON.stringify(result, null, 2)
|
|
557
|
+
: `imported ${result.added} ${source} candidates from ${result.files} files (${result.read} records, ${result.pending.count} pending); skipped ${result.skipped.rules} rule files, ${result.skipped.policy} policy/secret records, ${result.skipped.invalid} invalid records and ${result.skipped.truncated} oversized inputs${result.queueFull ? "; candidate queue is full" : result.limitReached ? "; per-run candidate cap reached" : ""}`;
|
|
558
|
+
}
|
|
559
|
+
if (sub === "project") {
|
|
560
|
+
const op = parsed.path[1]!, [requested] = parsed.positionals;
|
|
561
|
+
if (!requested) throw new Error(`usage: amem project ${op} <key>`);
|
|
562
|
+
if (op === "export") {
|
|
563
|
+
if (typeof options["--to"] !== "string") throw new Error("usage: amem project export <key> --to <file>");
|
|
564
|
+
const format = (options["--format"] ?? "json") as ProjectExportFormat;
|
|
565
|
+
// The bundle leaves the home, so it is screened by the configured policy (secrets, personal data, the configured patterns and deny paths).
|
|
566
|
+
const result = await exportProject(memoryHome(), requested, { to: options["--to"], policy: policyOf(), format });
|
|
567
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `exported ${result.project} to ${result.file} as ${result.format}: ${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` : ""}`;
|
|
568
|
+
}
|
|
569
|
+
if (op === "import") {
|
|
570
|
+
if (typeof options["--from"] !== "string") throw new Error("usage: amem project import <key> --from <file.jsonl> [--confirm] [--allow-shared]");
|
|
571
|
+
const key = resolveAlias(memoryHome(), requested), policy = policyOf();
|
|
572
|
+
const store = curatedStore(key, { screen: note => policy.screenStandalone(note) ? "promote" : "keep", createAliases: true });
|
|
573
|
+
try {
|
|
574
|
+
const result = await importProject(store, { from: options["--from"], project: key, confirm: options["--confirm"] === true,
|
|
575
|
+
allowShared: options["--allow-shared"] === true, policy: note => policy.screenStandalone(note) });
|
|
576
|
+
const counts = `sources ${JSON.stringify(result.provenance.source)}, peers ${JSON.stringify(result.provenance.peer)}, scopes ${JSON.stringify(result.provenance.scope)}`;
|
|
577
|
+
const summary = result.applied ? `imported ${result.added} of ${result.read} curated notes into ${result.project} with their original ids`
|
|
578
|
+
: `preview: ${result.wouldAdd} of ${result.read} curated notes can be imported into ${result.project} with their original ids (re-run with --confirm to write)`;
|
|
579
|
+
return options["--json"] ? JSON.stringify(result, null, 2)
|
|
580
|
+
: `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
|
|
581
|
+
} finally { store.close(); }
|
|
582
|
+
}
|
|
583
|
+
const key = requireShard(memoryHome(), requested);
|
|
584
|
+
if (op === "forget" && options["--confirm"] !== true) {
|
|
585
|
+
const state = projectState(memoryHome(), key), shard = readCatalogFresh(memoryHome()).find(p => p.key === key);
|
|
586
|
+
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.`;
|
|
587
|
+
}
|
|
588
|
+
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor: "person", machine: machineId(memoryHome()).id });
|
|
589
|
+
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
590
|
+
}
|
|
591
|
+
if (sub === "search" && options["--all-projects"] === true) {
|
|
592
|
+
const words = parsed.positionals.join(" ");
|
|
593
|
+
if (!words.trim()) throw new Error("usage: amem search --all-projects <words> [--limit N] [--include-archived] [--json]");
|
|
594
|
+
const result = searchAllProjects(memoryHome(), words, { ...(options["--limit"] !== undefined ? { limit: Number(options["--limit"]) } : {}), includeArchived: options["--include-archived"] === true });
|
|
595
|
+
if (options["--json"]) return JSON.stringify(result, null, 2);
|
|
596
|
+
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)}`;
|
|
597
|
+
return [`crossed projects: searched ${result.projects.length} (${result.projects.join(", ")})`, ...(result.hits.length ? result.hits.map(one) : ["(nothing)"])].join("\n");
|
|
598
|
+
}
|
|
599
|
+
if (sub === "search" && options["--include-archived"] === true) throw new Error("--include-archived only applies with --all-projects");
|
|
234
600
|
if (sub === "alias") {
|
|
235
601
|
if (parsed.path[1] === "add") {
|
|
236
602
|
const [from, to] = parsed.positionals;
|
|
@@ -249,18 +615,140 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
249
615
|
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";
|
|
250
616
|
}
|
|
251
617
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
252
|
-
if (goal && sub === "link") throw new Error("goal link needs host
|
|
253
|
-
|
|
618
|
+
if (goal && sub === "link") throw new Error("goal link needs a host with a task board; amem alone has none");
|
|
619
|
+
// #15 T4 / D6: the repo scope. Person-only and a terminal were enforced above (agent markers, `mutates`); amem never runs git.
|
|
620
|
+
const repoCwd = context.cwd ?? process.cwd();
|
|
621
|
+
if (sub === "repo") {
|
|
622
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
623
|
+
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)");
|
|
624
|
+
if (parsed.path[1] === "attributes") {
|
|
625
|
+
const plan = planGitattributes(root);
|
|
626
|
+
if (plan.unsafe) throw new Error(plan.unsafe);
|
|
627
|
+
if (plan.present) return `${plan.path} already has the merge=union entry for .amem/log.jsonl`;
|
|
628
|
+
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.`;
|
|
629
|
+
const written = appendGitattributes(root);
|
|
630
|
+
return `${written.added ? "added" : "already had"} the merge=union entry in ${written.path}; commit it through normal review`;
|
|
631
|
+
}
|
|
632
|
+
const scoped = repoReadPolicy(root);
|
|
633
|
+
const read = "reason" in scoped ? undefined : await readRepoScope(root, scoped.policy);
|
|
634
|
+
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 } : {}) };
|
|
635
|
+
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)` : ""}`,
|
|
636
|
+
...(status.reason ? [`unavailable: ${status.reason}`] : [])].join("\n");
|
|
637
|
+
}
|
|
638
|
+
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");
|
|
639
|
+
if (sub === "promote" && options["--to"] !== undefined) {
|
|
640
|
+
const idText = parsed.positionals[0];
|
|
641
|
+
if (!idText || parsed.positionals.length !== 1) throw new Error("usage: amem promote <id> --to repo --policy-free [--confirm [--confirm-public <entry id prefix>]]");
|
|
642
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project);
|
|
643
|
+
if (!root) throw new Error("repo scope needs the working tree of the repository that is this project: run it from inside that repository");
|
|
644
|
+
const store = curatedStore(project, { deferPromotions: true, screen: () => "skip" });
|
|
645
|
+
let view;
|
|
646
|
+
try { view = store.get(idText, project); } finally { store.close(); }
|
|
647
|
+
if (!view) throw new Error(`no memory entry ${idText} in this project (amem list shows the ids)`);
|
|
648
|
+
// `get` also returns shared-scope notes of other projects: only this project's own private notes go into this repository.
|
|
649
|
+
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`);
|
|
650
|
+
const plan = await planRepoPromotion(root, view, configuredPolicyOptions(memoryHome(), root));
|
|
651
|
+
if (plan.refusals.length) throw new Error(`not promoted to repo scope:\n- ${plan.refusals.join("\n- ")}`);
|
|
652
|
+
const seen = await repoVisibility(root, context.visibility);
|
|
653
|
+
const preview = [`the exact line that would be appended to ${plan.path}:`, plan.text, ...(plan.droppedNodes.length ? [`(left out, private references: ${plan.droppedNodes.join(", ")})`] : []),
|
|
654
|
+
`repository visibility: ${seen.state}${seen.remotes.length ? ` (${[...new Set(seen.remotes.map(r => `${r.name}${r.kind === "push" ? " (push)" : ""} ${r.visibility}`))].join(", ")})` : " (no remote)"}`];
|
|
655
|
+
if (plan.alreadyPresent) return [...preview, "already in the file: nothing to write"].join("\n");
|
|
656
|
+
const entryPrefix = plan.line.id.slice(0, 8);
|
|
657
|
+
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");
|
|
658
|
+
if (seen.state !== "private") {
|
|
659
|
+
const given = options["--confirm-public"];
|
|
660
|
+
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)`);
|
|
661
|
+
}
|
|
662
|
+
const written = appendRepoLine(root, plan.line);
|
|
663
|
+
return [...preview, `appended to ${written.path}. amem does not commit or push: review and commit it yourself.`,
|
|
664
|
+
...(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");
|
|
665
|
+
}
|
|
666
|
+
if (sub === "show" && /^repo:/.test(parsed.positionals[0] ?? "")) {
|
|
667
|
+
const project = boundProject(), root = boundRepoRoot(repoCwd, memoryHome(), project), scoped = root ? repoReadPolicy(root) : undefined;
|
|
668
|
+
const rows = root && scoped && !("reason" in scoped) ? (await readRepoScope(root, scoped.policy)).views : [];
|
|
669
|
+
const wanted = parsed.positionals[0]!.slice(5), hit = rows.filter(row => row.id === wanted || wanted.length >= 6 && row.id.startsWith(wanted));
|
|
670
|
+
if (hit.length !== 1) throw new Error(`no repo entry ${parsed.positionals[0]} in this repository's .amem/log.jsonl`);
|
|
671
|
+
const row = hit[0]!;
|
|
672
|
+
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");
|
|
673
|
+
}
|
|
674
|
+
// Recall (D4): the current project plus, when the repository IS this project, that repository's `.amem/`. Never another project's.
|
|
675
|
+
const withRepoRows = async (output: string): Promise<string> => {
|
|
676
|
+
if (options["--history"] === true || options["--as-of"] !== undefined) return output;
|
|
677
|
+
if (sub !== "list" && sub !== "search") return output;
|
|
678
|
+
const root = boundRepoRoot(repoCwd, memoryHome(), boundProject());
|
|
679
|
+
if (!root) return output;
|
|
680
|
+
const scoped = repoReadPolicy(root);
|
|
681
|
+
if ("reason" in scoped) return options["--json"] ? output : `${output}\n(repo scope unavailable: ${scoped.reason})`;
|
|
682
|
+
const read = await readRepoScope(root, scoped.policy);
|
|
683
|
+
if (read.state === "absent" && !read.skipped.malformed) return output;
|
|
684
|
+
const words = sub === "search" ? parsed.positionals.join(" ").toLowerCase().split(/\s+/).filter(Boolean) : [];
|
|
685
|
+
const kind = options["--kind"], since = options["--since"], limit = options["--limit"] !== undefined ? Number(options["--limit"]) : 50;
|
|
686
|
+
const rows = read.views.filter(row => options["--tier"] !== "short" && (kind === undefined || row.kind === kind) && (typeof since !== "string" || Date.parse(row.at) >= Date.parse(since))
|
|
687
|
+
&& words.every(word => `${row.title ?? ""}\n${row.text}\n${row.about.join(" ")}`.toLowerCase().includes(word))).slice(0, limit);
|
|
688
|
+
if (options["--json"]) { try { const base = JSON.parse(output) as unknown[]; return JSON.stringify([...base, ...rows], null, 2); } catch { return output; } }
|
|
689
|
+
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)}`;
|
|
690
|
+
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)`] : [];
|
|
691
|
+
return [output, ...(rows.length ? ["repo scope (.amem/log.jsonl):", ...rows.map(line)] : []), ...note].join("\n");
|
|
692
|
+
};
|
|
693
|
+
const crosses = definition.policyFree === true;
|
|
254
694
|
const cwd = context.cwd ?? process.cwd();
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
//
|
|
258
|
-
|
|
695
|
+
const configured = policyOf(), recallRoot = boundRepoRoot(cwd, memoryHome(), boundProject());
|
|
696
|
+
const rootConfirmed = recallRoot !== undefined || boundProject().startsWith("local:") && resolveAlias(memoryHome(), memoryProjectKey(cwd)) === boundProject();
|
|
697
|
+
// 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.
|
|
698
|
+
const open = () => curatedStore(boundProject(), { screen: note => !crosses ? "skip" : configured.screenStandalone(note) ? "promote" : "keep" });
|
|
699
|
+
// A person's reads are unscreened. A write is screened by the configured policy, which includes the built-in secret screen.
|
|
700
|
+
if (goal) return rawGoalCommand(args, cwd, { project: boundProject(), open, clean: (title, milestones) => !mutating || configured.screenStandalone({ title, milestones }),
|
|
701
|
+
onDone: async (done, store) => {
|
|
702
|
+
const project = boundProject(), base = realPath(store.profile.home), assertCurrent = commandBinding(store.profile.home, project);
|
|
703
|
+
if (readSelector(base).revision !== store.profile.selectorRevision) throw new Error("goal promotion store selector changed");
|
|
704
|
+
assertCurrent();
|
|
705
|
+
const { offerGoalPromotions } = await import("./memory/goal-promotion.ts");
|
|
706
|
+
assertCurrent();
|
|
707
|
+
const candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases: aliasedKeys(base, project) } });
|
|
708
|
+
try { return await offerGoalPromotions(store, candidates, project, done.id, { policy: () => configuredPolicy(base, cwd), assertCurrent }); }
|
|
709
|
+
finally { candidates.close(); }
|
|
710
|
+
} });
|
|
259
711
|
const receipt: { bridgeStatus?: { skippedRows: number } } = {};
|
|
260
|
-
|
|
261
|
-
|
|
712
|
+
// 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
|
|
713
|
+
// 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.
|
|
714
|
+
const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome();
|
|
715
|
+
if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due");
|
|
716
|
+
if (ifDue) {
|
|
717
|
+
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";
|
|
718
|
+
const state = usableState(readSyncState(syncHome), Date.now());
|
|
719
|
+
if (!syncDue(state, Date.now(), options["--force"] === true)) {
|
|
720
|
+
const next = nextSyncAt(state);
|
|
721
|
+
return options["--json"] ? JSON.stringify({ ok: true, skipped: "not due", nextAttemptAt: next === null ? null : new Date(next).toISOString(), failures: state.failures })
|
|
722
|
+
: `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)` : ""}`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// 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).
|
|
726
|
+
const stampSync = (error: string | undefined, cause?: StopCause) => { if (syncOutcome(error, cause) !== "local") recordSyncAttempt(syncHome, error === undefined, Date.now()); };
|
|
727
|
+
const syncArgs = args.filter(arg => arg !== "--if-due" && arg !== "--force");
|
|
728
|
+
let output: string;
|
|
729
|
+
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
730
|
+
try {
|
|
731
|
+
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),
|
|
732
|
+
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
733
|
+
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
stop?.dispose();
|
|
740
|
+
// A run its person ended ends like that signal (130, 143, 129, 131), as `amem doctor` does, also when `--json` reports it as a result instead of an error: the caller decides when.
|
|
741
|
+
const ended = stop?.cause() === "person" ? stop.signalName() : undefined;
|
|
742
|
+
if (ended !== undefined) context.onPersonStop?.(ended);
|
|
743
|
+
}
|
|
744
|
+
if (sub === "sync") {
|
|
745
|
+
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
746
|
+
let failure: string | undefined;
|
|
747
|
+
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 */ } }
|
|
748
|
+
stampSync(failure, stop?.cause());
|
|
749
|
+
}
|
|
262
750
|
if (sub === "import" && parsed.path[1] === "claude-mem") {
|
|
263
|
-
const policy =
|
|
751
|
+
const policy = configured;
|
|
264
752
|
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 });
|
|
265
753
|
return options["--json"] ? JSON.stringify({ ...JSON.parse(output), work }, null, 2) : `${output}\nwork lists: ${JSON.stringify(work)}`;
|
|
266
754
|
}
|
|
@@ -268,9 +756,19 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
268
756
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
269
757
|
}
|
|
270
758
|
|
|
759
|
+
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
760
|
+
|
|
761
|
+
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
762
|
+
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|
|
271
763
|
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
|
272
|
-
|
|
273
|
-
|
|
764
|
+
let personStop: string | undefined;
|
|
765
|
+
try {
|
|
766
|
+
const receipts: CliOutputReceipt[] = [], output = await runCli(argv, { onOutputReady: receipt => receipts.push(receipt), onPersonStop: name => { personStop ??= name; } });
|
|
767
|
+
if (output) { await writeCliOutput(output + "\n"); for (const receipt of receipts) { try { receipt.rendered(); } catch { /* telemetry stays optional */ } } }
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.error(formatCliError(error)); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1;
|
|
770
|
+
}
|
|
771
|
+
if (personStop !== undefined) endLikeSignal(personStop); // after the report, with amem's own handlers already removed
|
|
274
772
|
}
|
|
275
773
|
|
|
276
774
|
if (import.meta.main) await main();
|