@staix/agent-mem 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/cli.ts +10 -4
- package/src/commands-table.ts +1 -1
- package/src/memory/doctor.ts +41 -7
- package/src/memory/status-text.ts +47 -0
- package/src/memory/style.ts +37 -0
- package/src/setup-toml.ts +5 -5
- package/src/setup.ts +76 -14
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.12.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -114,7 +114,7 @@ A repository may add `.amem/config` (same shape, only `denyPaths`, `piiPatterns`
|
|
|
114
114
|
|
|
115
115
|
`amem note add [--kind decision|finding|contract|fail] [--tier short|long] [--scope project|shared] [--title T] [text... | -]` writes one curated note as the person (agent-mem#40 F6). The text is the arguments, `-` to read it from the terminal until end of input, or nothing to open `$VISUAL` (an empty one is skipped) or `$EDITOR` on a private temporary file (the command is run without a shell: an existing path with spaces works as is, otherwise quotes and backslashes split it, and Ctrl-C reaches the editor while it runs). `-` reads the text you type on the terminal until end of input: `note add` needs a terminal on stdin, so it cannot read a pipe, and that differs from a literal reading of "from stdin" in the issue. A `fail` note takes its five fields instead of free text: `--approach`, `--symptom`, `--cause`, `--evidence` (repeatable) and `--retry-when`. All five are required, and the command names the ones that are missing; with none given it opens a labelled template in the editor. The configured policy screens the title and text first, and a refusal never echoes them. Like every ordinary command it needs a terminal and refuses an agent session.
|
|
116
116
|
|
|
117
|
-
The SessionStart brief carries one line when candidates wait, for example `2 candidates pending, oldest expires in 27 days` (agent-mem#40 F7). It holds numbers only, is read-only (the hook never marks anything shown), comes out of the brief's own byte budget, and is left out when it does not fit. `amem status` adds a `candidates` object (or `unavailable` when `candidates.db` exists but cannot be read, never a false zero) and a warning when any is within 7 days of expiry. `amem review walk` needs a terminal on both stdin and stdout (it refuses when stdout is redirected to a file or a pipe, because nobody would see what it asks about). It prints each pending candidate, oldest first, and only then asks accept, edit, reject, skip or quit: accept goes through the normal policy, edit opens the text in your editor and writes it as your own note (a candidate proposed as a fail note opens the labelled template), and what it prints has control characters removed. Nothing expires unseen: a candidate counts as shown only when `review walk` has printed it (the plain `review list` prints no content and runs in scripts, and the brief line cannot know you read it, so neither marks anything). A candidate is kept past its 30 days until it has been shown, and then for 7 more days. When 500 unseen candidates wait, a new one is refused with a named error (an MCP tool reports a fixed "the review queue is full, ask the person to run `amem review`" reply) instead of dropping an unseen one.
|
|
117
|
+
The SessionStart brief carries one line when candidates wait, for example `2 candidates pending, oldest expires in 27 days` (agent-mem#40 F7). It holds numbers only, is read-only (the hook never marks anything shown), comes out of the brief's own byte budget, and is left out when it does not fit. `amem status --json` adds a `candidates` object (bare `amem status` shows the same as a labelled row and next action) (or `unavailable` when `candidates.db` exists but cannot be read, never a false zero) and a warning when any is within 7 days of expiry. `amem review walk` needs a terminal on both stdin and stdout (it refuses when stdout is redirected to a file or a pipe, because nobody would see what it asks about). It prints each pending candidate, oldest first, and only then asks accept, edit, reject, skip or quit: accept goes through the normal policy, edit opens the text in your editor and writes it as your own note (a candidate proposed as a fail note opens the labelled template), and what it prints has control characters removed. Nothing expires unseen: a candidate counts as shown only when `review walk` has printed it (the plain `review list` prints no content and runs in scripts, and the brief line cannot know you read it, so neither marks anything). A candidate is kept past its 30 days until it has been shown, and then for 7 more days. When 500 unseen candidates wait, a new one is refused with a named error (an MCP tool reports a fixed "the review queue is full, ask the person to run `amem review`" reply) instead of dropping an unseen one.
|
|
118
118
|
|
|
119
119
|
## Candidate review
|
|
120
120
|
|
|
@@ -290,6 +290,8 @@ Successful native project identities have a private, bounded derived cache (128
|
|
|
290
290
|
|
|
291
291
|
## Doctor and the sync cadence
|
|
292
292
|
|
|
293
|
+
`amem doctor`, `amem status` and `amem setup` print a readable report: an overall state, one labelled row per check (`OK`, `ATTENTION`, `SKIPPED`, `UNAVAILABLE`) and the next actions. Color appears only on an interactive terminal and is turned off by `NO_COLOR` or `TERM=dumb`. Add `--json` for the machine-readable object (for `status` and `setup` this was the default before #125).
|
|
294
|
+
|
|
293
295
|
`amem doctor` prints one read-only report of this project's memory: duplicate and stale notes, pending candidates, capture freshness and hook registration for each CLI (a CLI with no hook is `manual`), the SessionStart brief's token cost, whether the index equals a rebuild of the logs, reader leases and the policy in effect. `amem doctor --json` is a stable machine-readable form (`amem.doctor/v1`). It writes nothing into the memory home (every check runs on a temporary copy of the logs) and creates nothing in a home you never used.
|
|
294
296
|
|
|
295
297
|
Intake runs at the end of each Codex, Kimi, Pi and Qwen session once `amem setup <cli> --apply` has registered it. Sync is off unless you ask for it: amem registers no sync hook and runs nothing between sessions. To sync on your own schedule, call `amem sync --if-due` from your shell (for example an EXIT trap in `~/.zshrc`; it needs a terminal). It syncs at most hourly, backs off 15 minutes doubling to a day after failures, stops after 30 seconds, and does nothing without a remote set by `amem sync --remote <private git url>`. `--force` skips the schedule and the backoff.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -49,6 +49,8 @@ import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
|
49
49
|
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
50
50
|
import { runEditor } from "./memory/editor.ts";
|
|
51
51
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
52
|
+
import { statusText, type StatusView } from "./memory/status-text.ts";
|
|
53
|
+
import { colorEnabled, style, type Style } from "./memory/style.ts";
|
|
52
54
|
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
53
55
|
|
|
54
56
|
export const PERSON_ONLY = "amem is for the person: an agent reads memory through its `amem mcp` server (see `amem --help`); the person reviews what agents propose with `amem review`";
|
|
@@ -200,7 +202,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
200
202
|
const { runSetup } = await import("./setup.ts");
|
|
201
203
|
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
202
204
|
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
|
|
203
|
-
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
|
|
205
|
+
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
|
|
204
206
|
});
|
|
205
207
|
}
|
|
206
208
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
@@ -287,7 +289,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
287
289
|
// F1: say where the memory lives and which configuration is in effect. The migration fields keep their names and shape; `home` and `config` are added.
|
|
288
290
|
const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
|
|
289
291
|
const { config, source } = loaded;
|
|
290
|
-
|
|
292
|
+
const status = { home: { path: where.home, source: where.source },
|
|
291
293
|
config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
|
|
292
294
|
...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
|
|
293
295
|
...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
|
|
@@ -295,14 +297,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
295
297
|
candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
|
|
296
298
|
if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
|
|
297
299
|
return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
|
|
298
|
-
...shardMigrationStatus(where.home) }
|
|
300
|
+
...shardMigrationStatus(where.home) };
|
|
301
|
+
return options["--json"] ? JSON.stringify(status, null, 2) : statusText(status as unknown as StatusView, outputStyle(context));
|
|
299
302
|
}
|
|
300
303
|
if (sub === "doctor") {
|
|
301
304
|
// F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
|
|
302
305
|
const where = resolveMemoryHome(), project = boundProject();
|
|
303
306
|
const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
|
|
304
307
|
openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
|
|
305
|
-
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
|
|
308
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report, outputStyle(context));
|
|
306
309
|
}
|
|
307
310
|
if (sub === "prune") {
|
|
308
311
|
const applying = options["--apply"] === true;
|
|
@@ -777,6 +780,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
777
780
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
778
781
|
}
|
|
779
782
|
|
|
783
|
+
/** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
|
|
784
|
+
function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
|
|
785
|
+
|
|
780
786
|
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
781
787
|
|
|
782
788
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
package/src/commands-table.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
|
25
25
|
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
26
26
|
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
27
27
|
consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
|
|
28
|
-
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo are person-only", options: { "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
|
|
28
|
+
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo are person-only", options: { ...json, "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
|
|
29
29
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
30
30
|
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
31
31
|
ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi", "qwen"] }] },
|
package/src/memory/doctor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { readSelector } from "./generation.ts";
|
|
|
10
10
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
11
11
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
12
12
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
13
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
13
14
|
import { shardDir } from "./shard.ts";
|
|
14
15
|
import type { MemoryStore, View } from "./store.ts";
|
|
15
16
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
@@ -403,13 +404,46 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
403
404
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
404
405
|
}
|
|
405
406
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
407
|
+
/** Section order and one-line descriptions for the person-facing report (#125). */
|
|
408
|
+
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
409
|
+
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
410
|
+
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
|
|
414
|
+
if (name === "capture" && detail.clis && typeof detail.clis === "object") {
|
|
415
|
+
return Object.entries(detail.clis as Record<string, Record<string, unknown>>).map(([cli, row]) => {
|
|
416
|
+
const capture = String(row.capture), mark = capture === "fresh" ? s.ok(capture) : capture === "stale" ? s.attention(capture) : s.dim(capture);
|
|
417
|
+
return `${cli.padEnd(7)} hook ${row.hook}${row.setup ? ` (${row.setup})` : ""}, capture ${mark}${row.lastCaptureAt ? ` (last ${row.lastCaptureAt})` : ""}, ${row.rows} rows`;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (name === "cadence") {
|
|
421
|
+
const sync = (detail.sync ?? {}) as Record<string, unknown>, ingest = (detail.ingest ?? {}) as Record<string, string>;
|
|
422
|
+
return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
|
|
423
|
+
`sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
|
|
424
|
+
}
|
|
425
|
+
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
426
|
+
const { examples, ...rest } = detail;
|
|
427
|
+
const line = facts(rest);
|
|
428
|
+
return [...(line ? [line] : []), ...(Array.isArray(examples) && examples.length ? [`examples ${examples.map(item => Array.isArray(item) ? item.join("=") : String(item)).join(", ")}`] : [])];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The person-facing report: overall state, one labelled row per section, then the next actions. `amem doctor --json` keeps the full report. */
|
|
432
|
+
export function doctorText(report: DoctorReport, s: Style = PLAIN): string {
|
|
433
|
+
const entries = Object.entries(report.sections) as [keyof DoctorReport["sections"], Section & { hint?: string; reason?: string }][];
|
|
434
|
+
const count = (status: SectionStatus) => entries.filter(([, section]) => section.status === status).length;
|
|
435
|
+
const summary = facts({ attention: count("attention"), unavailable: count("unavailable"), skipped: count("skipped") });
|
|
436
|
+
const lines = [`${s.bold("amem doctor:")} ${report.status === "ok" ? s.ok(report.status) : report.status === "attention" ? s.attention(report.status) : s.error(report.status)}${summary ? ` (${summary})` : ` (all ${entries.length} checks ok)`}`,
|
|
437
|
+
`home ${report.home.path} ${s.dim(`(${report.home.source})`)}`, `project ${report.project}`, ""];
|
|
438
|
+
const actions: string[] = [];
|
|
439
|
+
for (const [name, section] of entries) {
|
|
440
|
+
const { status, hint, reason, ...detail } = section;
|
|
441
|
+
lines.push(`${label(s, status)} ${name}: ${s.dim(SECTION_TITLE[name])}`);
|
|
442
|
+
for (const line of sectionDetail(name, detail, s)) lines.push(` ${line}`);
|
|
443
|
+
if (reason) lines.push(` ${s.dim(`reason: ${reason}`)}`);
|
|
444
|
+
if (hint) actions.push(`${name}: ${hint}`);
|
|
445
|
+
if (name === "capture" && detail.clis) for (const [cli, row] of Object.entries(detail.clis as Record<string, { hint?: string }>)) if (row.hint) actions.push(`capture ${cli}: ${row.hint}`);
|
|
413
446
|
}
|
|
447
|
+
lines.push("", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`));
|
|
414
448
|
return lines.join("\n");
|
|
415
449
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
2
|
+
|
|
3
|
+
/** The `amem status` object as the CLI builds it; `amem status --json` prints it unchanged. */
|
|
4
|
+
export interface StatusView {
|
|
5
|
+
home: { path: string; source: string };
|
|
6
|
+
config: { machine: string | null; repo: string | null; denyPaths: number; piiPatterns: number } & Record<string, unknown>;
|
|
7
|
+
candidates: { unavailable: string } | ({ count: number; oldestExpiresInDays: number | null; dueWithin7Days: number; unshown: number; warning?: string });
|
|
8
|
+
active: boolean; generation?: string; phase?: string;
|
|
9
|
+
pending: Record<string, unknown> & { rows: number };
|
|
10
|
+
deadLeasesRemoved?: { lease: string; at: string }[]; refusals?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Person-facing `amem status` (#125): overall state, the effective home and policy, the counts that matter, then next actions. Zero counters are summarized, never listed. */
|
|
14
|
+
export function statusText(status: StatusView, s: Style = PLAIN): string {
|
|
15
|
+
const actions: string[] = [], warnings: string[] = [];
|
|
16
|
+
const candidates = status.candidates;
|
|
17
|
+
let candidateLine: string, candidateState: "ok" | "attention" | "unavailable";
|
|
18
|
+
if ("unavailable" in candidates) { candidateState = "unavailable"; candidateLine = candidates.unavailable; warnings.push(candidates.unavailable); }
|
|
19
|
+
else {
|
|
20
|
+
candidateState = candidates.dueWithin7Days ? "attention" : "ok";
|
|
21
|
+
candidateLine = candidates.count ? `${candidates.count} pending${candidates.unshown ? ` (${candidates.unshown} never shown)` : ""}${candidates.oldestExpiresInDays !== null ? `, oldest expires in ${candidates.oldestExpiresInDays} days` : ""}` : "none pending";
|
|
22
|
+
if (candidates.warning) actions.push(candidates.warning);
|
|
23
|
+
else if (candidates.count) actions.push("review pending candidates: amem review walk");
|
|
24
|
+
}
|
|
25
|
+
const refusals = status.refusals ?? [];
|
|
26
|
+
const interrupted = status.phase === "captured" && !status.active; // authoring refuses until activation (generation-index.ts)
|
|
27
|
+
const migrationState: "ok" | "attention" = refusals.length || status.pending.rows || interrupted ? "attention" : "ok";
|
|
28
|
+
if (interrupted) actions.push("a shard migration stopped after capture and authoring is refused until it activates: amem migrate --shards --resume");
|
|
29
|
+
if (refusals.length) warnings.push(...refusals.map(reason => `migration refusal: ${reason}`));
|
|
30
|
+
if (status.pending.rows && !interrupted) actions.push(`${status.pending.rows} old-layout rows wait for the shard migration: amem migrate --shards --resume carries them (amem migrate --shards --dry-run previews first)`);
|
|
31
|
+
const overall = candidateState === "unavailable" ? "unavailable" : candidateState === "attention" || migrationState === "attention" ? "attention" : "ok";
|
|
32
|
+
const { machine, repo, denyPaths, piiPatterns, ...limits } = status.config;
|
|
33
|
+
const pendingCounts = facts(Object.fromEntries(Object.entries(status.pending).filter(([key]) => !key.endsWith("Ids"))));
|
|
34
|
+
const lines = [
|
|
35
|
+
`${s.bold("amem status:")} ${overall === "ok" ? s.ok(overall) : overall === "attention" ? s.attention(overall) : s.error(overall)}`,
|
|
36
|
+
`home ${status.home.path} ${s.dim(`(${status.home.source})`)}`,
|
|
37
|
+
"",
|
|
38
|
+
`${label(s, "ok")} policy: machine ${machine ?? "none (defaults)"}, repo ${repo ?? "none"}; ${denyPaths} deny paths, ${piiPatterns} PII patterns${facts(limits) ? `; ${facts(limits)}` : ""}`,
|
|
39
|
+
`${label(s, candidateState)} candidates: ${candidateLine}`,
|
|
40
|
+
`${label(s, migrationState)} layout: ${status.active ? `shards active${status.generation ? ` (generation ${status.generation.slice(0, 8)})` : ""}` : status.phase ? `shard migration ${status.phase}${interrupted ? ", not active" : ""}` : "single home"}; ${pendingCounts ? `pending ${pendingCounts}` : "no pending old-layout appends"}`,
|
|
41
|
+
...(status.deadLeasesRemoved?.length ? [` ${s.dim(`${status.deadLeasesRemoved.length} dead reader leases removed`)}`] : []),
|
|
42
|
+
...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` - ${s.attention(warning)}`)] : []),
|
|
43
|
+
"", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`),
|
|
44
|
+
s.dim("Full report: amem status --json; health checks: amem doctor"),
|
|
45
|
+
];
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free text style for person-facing CLI output (#125).
|
|
3
|
+
* Color is decoration only: every caller also prints a textual label, so plain output carries the same facts.
|
|
4
|
+
* JSON output never passes through this module.
|
|
5
|
+
*/
|
|
6
|
+
export interface Style {
|
|
7
|
+
readonly color: boolean;
|
|
8
|
+
ok(text: string): string;
|
|
9
|
+
attention(text: string): string;
|
|
10
|
+
error(text: string): string;
|
|
11
|
+
dim(text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Color only for an interactive stdout, and never with NO_COLOR (any value, per no-color.org) or TERM=dumb. */
|
|
16
|
+
export function colorEnabled(env: Readonly<Record<string, string | undefined>>, isTTY: boolean | undefined): boolean {
|
|
17
|
+
return isTTY === true && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const wrap = (on: boolean, open: number, close: number) => (text: string) => on ? `\x1b[${open}m${text}\x1b[${close}m` : text;
|
|
21
|
+
export function style(color: boolean): Style {
|
|
22
|
+
return { color, ok: wrap(color, 32, 39), attention: wrap(color, 33, 39), error: wrap(color, 31, 39), dim: wrap(color, 2, 22), bold: wrap(color, 1, 22) };
|
|
23
|
+
}
|
|
24
|
+
export const PLAIN: Style = style(false);
|
|
25
|
+
|
|
26
|
+
/** Status label, padded so the columns line up; the word is always present. */
|
|
27
|
+
export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
|
|
28
|
+
const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
|
|
29
|
+
return state === "ok" ? s.ok(word) : state === "attention" ? s.attention(word) : state === "skipped" ? s.dim(word) : s.error(word);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "key value" pairs, leaving out zero, empty and null values; returns "" when nothing material is left. */
|
|
33
|
+
export function facts(values: Record<string, unknown>): string {
|
|
34
|
+
return Object.entries(values).filter(([, value]) => value !== 0 && value !== null && value !== undefined && value !== false && value !== ""
|
|
35
|
+
&& !(Array.isArray(value) && value.length === 0) && !(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0))
|
|
36
|
+
.map(([key, value]) => `${key} ${typeof value === "object" ? Array.isArray(value) ? value.join(", ") : JSON.stringify(value) : String(value)}`).join(", ");
|
|
37
|
+
}
|
package/src/setup-toml.ts
CHANGED
|
@@ -162,11 +162,11 @@ function editTomlPath(text: string, path: readonly string[], value: unknown | un
|
|
|
162
162
|
changes.push({ start: position, end: position, text: `${newline}${key} = ${inline(value)}${eol}` });
|
|
163
163
|
}
|
|
164
164
|
output = replace(text, changes);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
165
|
+
}
|
|
166
|
+
// Removing the only child of an implicit table must retain its parsed empty parent value.
|
|
167
|
+
for (let depth = path.length - 1; depth > 0; depth--) {
|
|
168
|
+
const parent = path.slice(0, depth), wanted = at(expected, parent);
|
|
169
|
+
if (plain(wanted) && !Object.keys(wanted).length && at(parse(output), parent) === undefined) output += `${output && !output.endsWith("\n") ? eol : ""}[${parent.map(part => JSON.stringify(part)).join(".")}]${eol}`;
|
|
170
170
|
}
|
|
171
171
|
if (!isDeepStrictEqual(shape(parse(output)), shape(expected))) return error();
|
|
172
172
|
return output;
|
package/src/setup.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { isDeepStrictEqual } from "node:util";
|
|
11
11
|
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
12
12
|
import { memoryHome } from "./memory/home.ts";
|
|
13
|
+
import { label, PLAIN, type Style } from "./memory/style.ts";
|
|
13
14
|
import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
|
|
14
15
|
|
|
15
16
|
export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
|
|
@@ -152,7 +153,8 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
|
|
|
152
153
|
}
|
|
153
154
|
function command(target: SetupTarget, bun: string, entry: string): string[] | null {
|
|
154
155
|
if (target === "kimi" || target === "jcode" || target === "agy") return null;
|
|
155
|
-
return [target, "mcp", "add",
|
|
156
|
+
if (target === "qwen") return [target, "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
|
|
157
|
+
return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
|
|
156
158
|
}
|
|
157
159
|
function assertJcodeHome(env: Env): void {
|
|
158
160
|
if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
|
|
@@ -227,7 +229,12 @@ function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
|
|
|
227
229
|
}
|
|
228
230
|
function native(argv: readonly string[], env: Env, cwd: string): void {
|
|
229
231
|
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
|
|
230
|
-
if (result.status !== 0)
|
|
232
|
+
if (result.status !== 0) {
|
|
233
|
+
const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
|
|
234
|
+
const argumentsRejected = argv[0] === "qwen" && `${result.stderr ?? ""}\n${result.stdout ?? ""}`.includes("Not enough non-option arguments:");
|
|
235
|
+
const reason = timedOut ? "timed out" : argumentsRejected ? "rejected command arguments" : "failed";
|
|
236
|
+
throw Error(`${argv[0]} MCP registration ${reason} in staging; no native output disclosed`);
|
|
237
|
+
}
|
|
231
238
|
}
|
|
232
239
|
function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
|
|
233
240
|
const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
|
|
@@ -284,16 +291,33 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
284
291
|
if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
|
|
285
292
|
(options.native ?? native)(argv, env, staging);
|
|
286
293
|
changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
|
|
287
|
-
if (changes[0]!.after.bytes === null) throw Error(
|
|
294
|
+
if (changes[0]!.after.bytes === null) throw Error(`${target} native registration produced no configuration`);
|
|
288
295
|
const installedText = Buffer.from(changes[0]!.after.bytes!, "base64").toString("utf8");
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
296
|
+
let installed: Record<string, unknown>, installedServer: Record<string, unknown>;
|
|
297
|
+
try {
|
|
298
|
+
installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
|
|
299
|
+
installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
|
|
300
|
+
} catch { throw Error(`${target} native registration produced invalid configuration; no configuration published`); }
|
|
301
|
+
if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error(`${target} native registration did not produce the planned amem server`);
|
|
292
302
|
const serverKey = target === "codex" ? "mcp_servers" : "mcpServers";
|
|
293
303
|
const withoutAmem = (value: Record<string, unknown>) => { const copy = { ...value }, servers = { ...record(value[serverKey] ?? {}) }; delete servers.amem; copy[serverKey] = servers; return copy; };
|
|
294
|
-
|
|
304
|
+
const originalUnrelated = withoutAmem(document), installedUnrelated = withoutAmem(installed);
|
|
305
|
+
if (target === "codex") {
|
|
306
|
+
// Restore the omitted default only in this detached comparison, never in native output.
|
|
307
|
+
const originalServers = record(originalUnrelated[serverKey]), installedServers = record(installedUnrelated[serverKey]);
|
|
308
|
+
for (const [name, value] of Object.entries(originalServers)) {
|
|
309
|
+
if (!Object.hasOwn(installedServers, name)) continue;
|
|
310
|
+
const before = record(value), after = installedServers[name];
|
|
311
|
+
if (typeof before.command === "string" && Object.hasOwn(before, "args") && Array.isArray(before.args) && before.args.length === 0
|
|
312
|
+
&& after && typeof after === "object" && !Array.isArray(after) && !Object.hasOwn(after, "args")) {
|
|
313
|
+
installedServers[name] = { ...after, args: [] };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!isDeepStrictEqual(originalUnrelated[serverKey], installedUnrelated[serverKey])) throw Error(`${target} native registration changed unrelated MCP servers; no configuration published`);
|
|
295
318
|
if (target === "codex") {
|
|
296
|
-
if (!isDeepStrictEqual(
|
|
319
|
+
if (!isDeepStrictEqual(originalUnrelated, installedUnrelated)) throw Error("codex native registration changed unrelated settings; no configuration published");
|
|
320
|
+
changes[0]!.after = putMcpRecord(target, changes[0]!.before, installedServer);
|
|
297
321
|
} else {
|
|
298
322
|
// Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
|
|
299
323
|
// Qwen's restored image also retires its previous managed hook on a runtime upgrade.
|
|
@@ -425,16 +449,54 @@ export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restor
|
|
|
425
449
|
return withSetupLock(options, () => undoUnlocked(plan, options));
|
|
426
450
|
}
|
|
427
451
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
452
|
+
/** Person-facing setup plan (#125): each target once, with its effective files, MCP and hook changes, then skipped clients and notes. `--json` keeps the raw plan. */
|
|
453
|
+
export function setupPlanText(plan: SetupPlan, s: Style = PLAIN): string {
|
|
454
|
+
const verb = plan.mode === "undo" ? "undo" : plan.mode === "apply" ? "apply" : "preview";
|
|
455
|
+
// Undo restores every target with a receipt whether or not its CLI is still installed (undoUnlocked), so undo never collapses an action.
|
|
456
|
+
const full = (action: SetupPlan["actions"][number]) => plan.mode === "undo" || action.available;
|
|
457
|
+
const skipped = plan.actions.filter(action => !full(action)), changing = plan.actions.filter(action => full(action) && action.mcpChange !== "unchanged");
|
|
458
|
+
const lines = [`${s.bold(`amem setup ${verb}:`)} ${plan.targets.length} target${plan.targets.length === 1 ? "" : "s"}, ${changing.length} with ${plan.mode === "undo" ? "restores" : "MCP changes"}${skipped.length ? `, ${skipped.length} skipped` : ""}`, ""];
|
|
459
|
+
for (const action of plan.actions) {
|
|
460
|
+
if (!full(action)) { lines.push(`${label(s, "skipped")} ${s.bold(action.target)}: CLI not installed`); continue; }
|
|
461
|
+
const state = action.mcpChange === "unchanged" ? "ok" : "attention";
|
|
462
|
+
const what = action.mcpChange === "unchanged" ? plan.mode === "undo" ? "no setup receipt; nothing to restore" : "already set up; no change" : action.mcpChange === "restore" ? `restore original MCP/hook files${action.available ? "" : " (CLI not installed)"}` : `MCP ${action.mcpChange}`;
|
|
463
|
+
lines.push(`${label(s, state)} ${s.bold(action.target)}: ${what} ${s.dim(`(${action.state})`)}`);
|
|
464
|
+
for (const file of action.files) lines.push(` file ${file}`);
|
|
465
|
+
if (action.cwd) lines.push(` cwd ${action.cwd}`);
|
|
466
|
+
lines.push(` receipt ${action.receipt}`);
|
|
467
|
+
if (action.available && action.registration) lines.push(` command ${action.registration.map(quote).join(" ")}`);
|
|
468
|
+
for (const hook of action.hooks) lines.push(` hook ${hook.event}: ${hook.command} (timeout ${hook.timeout})`);
|
|
469
|
+
if (action.available && !action.hooks.length && plan.mode !== "undo") lines.push(` hook ${s.dim("none")}`);
|
|
470
|
+
lines.push(` ${s.dim(action.lifecycle)}`);
|
|
471
|
+
}
|
|
472
|
+
const warnings = plan.notes.filter(note => /^WARNING|detected|version-specific/.test(note)), notes = plan.notes.filter(note => !warnings.includes(note));
|
|
473
|
+
if (skipped.length) lines.push("", `Skipped clients: ${skipped.map(action => action.target).join(", ")} (not installed)`);
|
|
474
|
+
if (warnings.length) lines.push("", "Warnings:", ...warnings.map(note => ` - ${s.attention(note)}`));
|
|
475
|
+
lines.push("", "Notes:", ...notes.map(note => ` - ${note}`));
|
|
476
|
+
if (plan.mode === "preview") lines.push("", `Next: amem setup ${plan.targets.length === 1 ? plan.targets[0] : "all"} --apply (asks before writing); --json prints the full plan`);
|
|
477
|
+
return lines.join("\n");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function setupResultText(result: SetupApplyResult | { restored: SetupTarget[] }, s: Style): string {
|
|
481
|
+
if ("restored" in result) return `${label(s, "ok")} restored: ${result.restored.join(", ") || "none"}`;
|
|
482
|
+
return [`${label(s, "ok")} applied: ${result.applied.join(", ") || "none"}`, `${label(s, "ok")} unchanged: ${result.unchanged.join(", ") || "none"}`,
|
|
483
|
+
...(result.skipped ?? []).map(item => `${label(s, "skipped")} ${item.target}: ${item.reason}`)].join("\n");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** What the default confirmation prints before its prompt: the raw plan under `--json`, the readable plan otherwise. */
|
|
487
|
+
export function setupConfirmationText(plan: SetupPlan, json: boolean, s: Style = PLAIN): string { return json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s); }
|
|
488
|
+
|
|
489
|
+
export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext & { json?: boolean; style?: Style } = {}): Promise<string> {
|
|
490
|
+
const plan = planSetup(target, mode, options), s = options.json ? PLAIN : options.style ?? PLAIN;
|
|
491
|
+
if (mode === "preview") return options.json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s);
|
|
431
492
|
if (!(options.stdinTTY ?? process.stdin.isTTY)) throw Error("setup --apply/--undo require a terminal on stdin");
|
|
432
493
|
const confirm = options.confirm ?? (async (shown: SetupPlan) => {
|
|
433
|
-
process.stdout.write(
|
|
494
|
+
process.stdout.write(setupConfirmationText(shown, options.json === true, s) + "\n\n");
|
|
434
495
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
435
496
|
try { return (await terminal.question(`Type yes to ${mode} the listed configuration changes: `)).trim() === "yes"; }
|
|
436
497
|
finally { terminal.close(); }
|
|
437
498
|
});
|
|
438
|
-
if (!await confirm(plan)) return JSON.stringify({ cancelled: true });
|
|
439
|
-
|
|
499
|
+
if (!await confirm(plan)) return options.json ? JSON.stringify({ cancelled: true }) : "setup cancelled; nothing changed";
|
|
500
|
+
const result = mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options);
|
|
501
|
+
return options.json ? JSON.stringify(result, null, 2) : setupResultText(result, s);
|
|
440
502
|
}
|