myagentmemory 0.4.17 → 0.5.1

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/dist/cli.js CHANGED
@@ -23,12 +23,16 @@
23
23
  */
24
24
  import { spawn } from "node:child_process";
25
25
  import * as fs from "node:fs";
26
- import { COMMAND_DESCRIPTIONS, COMMANDS } from "./cli-spec.js";
26
+ import * as os from "node:os";
27
+ import * as path from "node:path";
28
+ import { COMMAND_DESCRIPTIONS, COMMAND_OPTIONS, COMMANDS, GLOBAL_OPTIONS, optionTakesValue, PLUGIN_COMMAND_OPTIONS, renderCommandHelp, SCRATCHPAD_ACTION_OPTIONS, } from "./cli-spec.js";
27
29
  import { detectCompletionShell, generateCompletion, installCompletion } from "./completions.js";
28
- import { _setBaseDir, buildMemoryContext, checkCollection, dailyPath, detectQmd, distilMemories, ensureDirs, ensureQmdAvailableForSync, ensureQmdAvailableForUpdate, getCollectionName, getDailyDir, getMemoryDir, getMemoryFile, getQmdEmbedMode, getQmdHealth, getQmdResultPath, getQmdResultText, getScratchpadFile, getTopicsDir, installSkills, memoryWrite, nowTimestamp, parseScratchpad, probeEmbeddings, readFileSafe, redactSecrets, runQmdEmbedDetached, runQmdSearch, runQmdSync, runQmdUpdateNow, scheduleQmdUpdate, searchRelevantMemories, serializeScratchpad, setupQmdCollection, slugifyTopic, todayStr, topicPath, uninstallSkills, } from "./core.js";
29
- import { detectHookAgents, installHooks, uninstallHooks } from "./hooks.js";
30
+ import { _setBaseDir, buildDynamicContext, buildMemoryContext, buildStableContext, checkCollection, dailyPath, detectQmd, distilMemories, ensureDirs, ensureQmdAvailableForSync, ensureQmdAvailableForUpdate, getCollectionName, getDailyDir, getMemoryDir, getMemoryFile, getQmdEmbedMode, getQmdHealth, getQmdResultPath, getQmdResultText, getScratchpadFile, getTopicsDir, installSkills, memoryWrite, nowTimestamp, parseScratchpad, probeEmbeddings, readFileSafe, readHookMode, redactSecrets, runQmdEmbedDetached, runQmdSearch, runQmdSync, runQmdUpdateNow, scheduleQmdUpdate, scratchpadAction, searchRelevantMemories, serializeScratchpad, setupQmdCollection, slugifyTopic, todayStr, topicPath, uninstallSkills, } from "./core.js";
31
+ import { detectHookAgents, installHooks, isHookInstalled, isStopHookInstalled, isUserPromptSubmitInstalled, uninstallHooks, } from "./hooks.js";
32
+ import { StdioMcpServer } from "./mcp-server.js";
30
33
  import { createDefaultPluginBootstrap, PluginBootstrapFailure, } from "./plugin-bootstrap.js";
31
34
  import { InstalledPluginRuntimeV1 } from "./plugin-runtime.js";
35
+ import { checkForUpgrades, detectInstallMethod, formatUpgradeNotice, isCacheFresh, readUpgradeCache, refreshUpgradeCacheBackground, runInstaller, } from "./upgrade.js";
32
36
  function readPackageVersion() {
33
37
  try {
34
38
  const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
@@ -43,16 +47,38 @@ function parseArgs(argv) {
43
47
  const flags = {};
44
48
  const positional = [];
45
49
  let command = "";
46
- for (let i = 0; i < argv.length; i++) {
47
- const arg = argv[i];
50
+ // Normalize short flags to their long form so downstream code can rely on `flags.help`
51
+ // / `flags.version` without also checking `-h` / `-V`. Reserved shorthands only.
52
+ const SHORT_TO_LONG = { "-h": "--help", "-V": "--version" };
53
+ const normalized = argv.map((arg) => SHORT_TO_LONG[arg] ?? arg);
54
+ for (let i = 0; i < normalized.length; i++) {
55
+ const arg = normalized[i];
48
56
  if (!command && !arg.startsWith("-")) {
49
57
  command = arg;
50
58
  continue;
51
59
  }
52
60
  if (arg.startsWith("--")) {
61
+ // Support `--flag=value` in addition to `--flag value`.
62
+ const eqIdx = arg.indexOf("=");
63
+ if (eqIdx > 2) {
64
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
65
+ continue;
66
+ }
53
67
  const key = arg.slice(2);
54
- const next = argv[i + 1];
55
- if (next && !next.startsWith("--")) {
68
+ const next = normalized[i + 1];
69
+ // Use the option spec to decide whether this flag takes a value; that way boolean
70
+ // flags don't greedily consume the next positional argument. Unknown flags fall
71
+ // back to the "next token isn't a flag" heuristic.
72
+ const known = optionTakesValue(`--${key}`);
73
+ const looksLikeValue = next !== undefined && !next.startsWith("-");
74
+ if (known === true && looksLikeValue) {
75
+ flags[key] = next;
76
+ i++;
77
+ }
78
+ else if (known === false) {
79
+ flags[key] = true;
80
+ }
81
+ else if (looksLikeValue) {
56
82
  flags[key] = next;
57
83
  i++;
58
84
  }
@@ -74,6 +100,206 @@ function hasFlag(flags, key) {
74
100
  return key in flags;
75
101
  }
76
102
  // ---------------------------------------------------------------------------
103
+ // Fuzzy suggestions (levenshtein distance)
104
+ // ---------------------------------------------------------------------------
105
+ function levenshtein(a, b) {
106
+ if (a === b)
107
+ return 0;
108
+ if (!a.length)
109
+ return b.length;
110
+ if (!b.length)
111
+ return a.length;
112
+ const prev = new Array(b.length + 1);
113
+ const curr = new Array(b.length + 1);
114
+ for (let j = 0; j <= b.length; j++)
115
+ prev[j] = j;
116
+ for (let i = 1; i <= a.length; i++) {
117
+ curr[0] = i;
118
+ for (let j = 1; j <= b.length; j++) {
119
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
120
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
121
+ }
122
+ for (let j = 0; j <= b.length; j++)
123
+ prev[j] = curr[j];
124
+ }
125
+ return prev[b.length];
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Pro plan / cap-exhausted UX
129
+ // ---------------------------------------------------------------------------
130
+ const UPGRADE_URL = "https://agentmemory.paperpilot.me/upgrade";
131
+ function detectCapExhausted(result) {
132
+ if (result.ok !== false)
133
+ return null;
134
+ const code = result.error?.code ?? "";
135
+ const message = result.error?.message ?? "";
136
+ // Authoritative signal: the plugin passed a decision through the data channel.
137
+ let decisionExhausted = false;
138
+ let resetAt;
139
+ let used;
140
+ let limit;
141
+ let remaining;
142
+ if (result.data && typeof result.data === "object") {
143
+ const decision = result.data.decision;
144
+ if (decision) {
145
+ decisionExhausted = decision.state === "exhausted";
146
+ if (typeof decision.resetAt === "string")
147
+ resetAt = decision.resetAt;
148
+ if (typeof decision.used === "number")
149
+ used = decision.used;
150
+ if (typeof decision.limit === "number")
151
+ limit = decision.limit;
152
+ if (typeof decision.remaining === "number")
153
+ remaining = decision.remaining;
154
+ }
155
+ }
156
+ // Only treat semantic exhaustion codes as cap-hits. Transient throttles
157
+ // (rate_limit, too_many_requests, HTTP 429) are NOT exhaustion — they must
158
+ // bubble as normal errors so we don't mislead the user with an upgrade prompt.
159
+ const hardExhaustion = /^(session_exhausted|quota_exceeded|preview_(limit|exhausted)|allowance_exhausted|session_capacity)$/i.test(code);
160
+ const messageExhaustion = /free preview (limit|allowance)|daily limit reached|no recalls remaining/i.test(message);
161
+ if (!decisionExhausted && !hardExhaustion && !messageExhaustion)
162
+ return null;
163
+ // Fallback: look for an ISO-ish timestamp in the message.
164
+ if (!resetAt) {
165
+ const match = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?)/.exec(message);
166
+ if (match)
167
+ resetAt = match[1];
168
+ }
169
+ return { code, message, resetAt, used, limit, remaining };
170
+ }
171
+ function formatResetTime(resetAt) {
172
+ if (!resetAt)
173
+ return "later today";
174
+ const parsed = Date.parse(resetAt);
175
+ if (!Number.isFinite(parsed))
176
+ return resetAt;
177
+ const now = Date.now();
178
+ const diffMs = parsed - now;
179
+ if (diffMs <= 0)
180
+ return "now";
181
+ const hours = Math.floor(diffMs / 3_600_000);
182
+ const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
183
+ if (hours > 0)
184
+ return `${hours}h ${minutes}m`;
185
+ return `${minutes}m`;
186
+ }
187
+ function printCapExhaustedBox(command, info) {
188
+ const reset = formatResetTime(info.resetAt);
189
+ const usedLine = info.used !== undefined && info.limit !== undefined
190
+ ? ` You've used all ${info.used}/${info.limit} free '${command}' calls today (resets in ${reset}).`
191
+ : ` '${command}' free preview limit hit (resets in ${reset}).`;
192
+ const lines = [
193
+ "",
194
+ "─────────────────────────────────────────────────────────────",
195
+ usedLine,
196
+ "",
197
+ " Upgrade for unlimited recall + automatic capture:",
198
+ ` ${UPGRADE_URL}`,
199
+ "─────────────────────────────────────────────────────────────",
200
+ "",
201
+ ];
202
+ console.error(lines.join("\n"));
203
+ openExternalUrl(UPGRADE_URL);
204
+ }
205
+ // Persist the last usage decision to disk so `pro status` can show counters.
206
+ function cacheProUsage(decision) {
207
+ try {
208
+ const stateDir = `${getMemoryDir()}/state`;
209
+ fs.mkdirSync(stateDir, { recursive: true });
210
+ const path = `${stateDir}/pro-usage.json`;
211
+ let existing = {};
212
+ try {
213
+ existing = JSON.parse(fs.readFileSync(path, "utf-8"));
214
+ }
215
+ catch {
216
+ // missing or corrupt — start fresh
217
+ }
218
+ const capability = decision.capability ?? "session";
219
+ existing[capability] = {
220
+ used: decision.used,
221
+ limit: decision.limit,
222
+ remaining: decision.remaining,
223
+ resetAt: decision.resetAt,
224
+ state: decision.state,
225
+ recordedAt: new Date().toISOString(),
226
+ };
227
+ fs.writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
228
+ }
229
+ catch {
230
+ // caching is best-effort
231
+ }
232
+ }
233
+ function nearest(word, candidates, maxDistance = 2) {
234
+ let best;
235
+ for (const candidate of candidates) {
236
+ const distance = levenshtein(word, candidate);
237
+ if (distance <= maxDistance && (!best || distance < best.distance)) {
238
+ best = { word: candidate, distance };
239
+ }
240
+ }
241
+ return best?.word;
242
+ }
243
+ // ---------------------------------------------------------------------------
244
+ // Argument validation (whitelist from cli-spec)
245
+ // ---------------------------------------------------------------------------
246
+ // Internal / non-user commands that opt out of strict flag validation.
247
+ // `hook` is invoked by SessionStart wrappers with dynamic keys.
248
+ const UNVALIDATED_COMMANDS = new Set(["hook"]);
249
+ function allowedFlagsFor(command, positional) {
250
+ // `distill` (double-l) is an accepted alias for `distil` — share its flag whitelist.
251
+ const canonical = command === "distill" ? "distil" : command;
252
+ const allowed = new Set();
253
+ for (const opt of GLOBAL_OPTIONS)
254
+ allowed.add(opt.replace(/^-+/, ""));
255
+ for (const opt of COMMAND_OPTIONS[canonical] ?? [])
256
+ allowed.add(opt.replace(/^-+/, ""));
257
+ const sub = positional[0];
258
+ if (sub) {
259
+ if (canonical === "scratchpad") {
260
+ for (const opt of SCRATCHPAD_ACTION_OPTIONS[sub] ?? [])
261
+ allowed.add(opt.replace(/^-+/, ""));
262
+ }
263
+ if (canonical === "plugin" || canonical === "pro") {
264
+ const key = sub === "upgrade" ? "update" : sub;
265
+ for (const opt of PLUGIN_COMMAND_OPTIONS[key] ?? [])
266
+ allowed.add(opt.replace(/^-+/, ""));
267
+ }
268
+ }
269
+ return allowed;
270
+ }
271
+ function validateCommand(command) {
272
+ if (!command || UNVALIDATED_COMMANDS.has(command))
273
+ return;
274
+ const known = new Set([...COMMANDS, "distill", "hook"]);
275
+ if (known.has(command))
276
+ return;
277
+ // Unknown commands may still be plugin-provided; only warn when clearly a typo of a core command.
278
+ const suggestion = nearest(command, COMMANDS);
279
+ if (suggestion) {
280
+ console.error(`Error: Unknown command '${command}'. Did you mean '${suggestion}'?`);
281
+ console.error("Run 'agent-memory help' for the full list.");
282
+ process.exit(1);
283
+ }
284
+ // Fall through — plugin runtime will produce a definitive error if the command is truly unknown.
285
+ }
286
+ function validateFlags(command, positional, flags) {
287
+ if (!command || UNVALIDATED_COMMANDS.has(command))
288
+ return;
289
+ if (!COMMANDS.includes(command) && command !== "distill")
290
+ return; // plugin commands validate their own flags
291
+ const allowed = allowedFlagsFor(command, positional);
292
+ for (const key of Object.keys(flags)) {
293
+ if (allowed.has(key))
294
+ continue;
295
+ const suggestion = nearest(key, allowed);
296
+ const hint = suggestion ? `. Did you mean --${suggestion}?` : "";
297
+ console.error(`Error: Unknown flag --${key} for '${command}'${hint}`);
298
+ console.error(`Run 'agent-memory ${command} --help' for valid flags.`);
299
+ process.exit(1);
300
+ }
301
+ }
302
+ // ---------------------------------------------------------------------------
77
303
  // Output helpers
78
304
  // ---------------------------------------------------------------------------
79
305
  function output(data, json) {
@@ -119,25 +345,68 @@ function openExternalUrl(url) {
119
345
  return false;
120
346
  }
121
347
  }
348
+ // ---------------------------------------------------------------------------
349
+ // Color output (respects NO_COLOR and non-TTY)
350
+ // ---------------------------------------------------------------------------
351
+ const USE_COLOR = process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
352
+ const COLORS = {
353
+ reset: USE_COLOR ? "\x1b[0m" : "",
354
+ dim: USE_COLOR ? "\x1b[2m" : "",
355
+ bold: USE_COLOR ? "\x1b[1m" : "",
356
+ red: USE_COLOR ? "\x1b[31m" : "",
357
+ green: USE_COLOR ? "\x1b[32m" : "",
358
+ yellow: USE_COLOR ? "\x1b[33m" : "",
359
+ cyan: USE_COLOR ? "\x1b[36m" : "",
360
+ };
361
+ function colorize(text, color) {
362
+ return `${COLORS[color]}${text}${COLORS.reset}`;
363
+ }
364
+ const MARK_OK = colorize("✓", "green");
365
+ const MARK_WARN = colorize("⚠", "yellow");
366
+ const MARK_FAIL = colorize("✗", "red");
367
+ function readCachedProUsage() {
368
+ try {
369
+ const path = `${getMemoryDir()}/state/pro-usage.json`;
370
+ return JSON.parse(fs.readFileSync(path, "utf-8"));
371
+ }
372
+ catch {
373
+ return {};
374
+ }
375
+ }
376
+ function printProUsageCounters() {
377
+ const cache = readCachedProUsage();
378
+ const entries = Object.entries(cache);
379
+ if (entries.length === 0)
380
+ return;
381
+ console.log("Free preview usage today (local cache):");
382
+ for (const [capability, entry] of entries) {
383
+ const used = entry.used ?? 0;
384
+ const limit = entry.limit ?? 0;
385
+ const resetIn = formatResetTime(entry.resetAt);
386
+ const label = capability === "session" ? "Session captures" : capability;
387
+ const marker = entry.state === "exhausted" ? " (exhausted)" : "";
388
+ console.log(` ${label.padEnd(20)} ${used}/${limit} · resets in ${resetIn}${marker}`);
389
+ }
390
+ console.log("");
391
+ }
122
392
  function printProOverview(installed) {
123
393
  console.log("");
124
394
  console.log("Core remembers what you save. Pro learns from what you do.");
125
395
  console.log("");
126
396
  console.log("AgentMemory Pro:");
127
- console.log(" Recall coding history Find decisions and context across Pi, Codex, and Claude Code sessions.");
128
- console.log(" Learn from corrections Turn repeated fixes into reviewable, reversible memory.");
129
- console.log(" See and control learning Inspect what AgentMemory remembers and why in the Memory Dashboard.");
130
- console.log("");
131
- console.log("No account is required for the free preview. Your coding history stays on this device.");
397
+ console.log(' Remember past sessions Ask "what did we decide about auth?" across Claude Code, Codex, and Cursor.');
398
+ console.log(" Learn from your patterns Turn repeated corrections into memory you can inspect and undo.");
399
+ console.log(" Private by default Memory and session content index locally and never leave this machine.");
132
400
  console.log("");
133
401
  if (installed) {
402
+ printProUsageCounters();
134
403
  console.log("Try it:");
135
404
  console.log(' agent-memory recall "what did we decide about authentication?"');
136
405
  console.log(" agent-memory learn");
137
406
  console.log(" agent-memory dashboard");
138
407
  }
139
408
  else {
140
- console.log("Start your free Pro preview:");
409
+ console.log("No account. No email. Free preview starts now:");
141
410
  console.log(" agent-memory pro install");
142
411
  }
143
412
  }
@@ -213,11 +482,16 @@ async function cmdContext(flags) {
213
482
  const json = hasFlag(flags, "json");
214
483
  const noSearch = hasFlag(flags, "no-search");
215
484
  const query = getFlag(flags, "query") ?? "";
485
+ const layer = getFlag(flags, "layer");
216
486
  ensureDirs();
217
487
  if (!noSearch && query)
218
488
  await ensureQmdAvailableForSync();
219
489
  const searchResults = noSearch ? "" : await searchRelevantMemories(query);
220
- const coreContext = buildMemoryContext(searchResults);
490
+ const coreContext = layer === "stable"
491
+ ? buildStableContext()
492
+ : layer === "dynamic"
493
+ ? buildDynamicContext(searchResults, query)
494
+ : buildMemoryContext(searchResults);
221
495
  let pluginSections = [];
222
496
  try {
223
497
  pluginSections = await new InstalledPluginRuntimeV1({ coreVersion: VERSION }).provideContext({
@@ -241,6 +515,193 @@ async function cmdContext(flags) {
241
515
  }
242
516
  }
243
517
  }
518
+ /**
519
+ * Read stdin (up to 1 MB) as JSON. Returns null on non-TTY stdin, oversized
520
+ * payload, or JSON parse errors — the caller emits empty stdout in those
521
+ * cases so a malformed harness hook payload never poisons the conversation.
522
+ */
523
+ async function readStdinJson() {
524
+ if (process.stdin.isTTY)
525
+ return null;
526
+ const chunks = [];
527
+ let total = 0;
528
+ try {
529
+ for await (const chunk of process.stdin) {
530
+ const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
531
+ total += buffer.length;
532
+ if (total > 1_000_000)
533
+ return null;
534
+ chunks.push(buffer);
535
+ }
536
+ const text = Buffer.concat(chunks).toString("utf-8").trim();
537
+ if (!text)
538
+ return null;
539
+ return JSON.parse(text);
540
+ }
541
+ catch {
542
+ return null;
543
+ }
544
+ }
545
+ /**
546
+ * Sanitize a user prompt into a search query. Mirrors the discipline in
547
+ * `searchRelevantMemories`: strip control chars, cap length. Never throws.
548
+ */
549
+ function sanitizePromptQuery(prompt) {
550
+ if (typeof prompt !== "string")
551
+ return "";
552
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping control chars.
553
+ const stripped = prompt.replace(/[\x00-\x1f\x7f]/g, " ");
554
+ return stripped.trim().slice(0, 200);
555
+ }
556
+ /**
557
+ * UserPromptSubmit hook handler — fires on every user prompt in per-turn mode.
558
+ * Reads the harness's JSON payload from stdin, emits the dynamic context layer
559
+ * (daily logs + qmd search + plugin context), and refreshes background workers
560
+ * un-metered. Silently degrades to empty stdout on any failure or timeout so
561
+ * a broken install never blocks the user.
562
+ */
563
+ async function cmdUserPromptSubmit(_flags) {
564
+ const TIMEOUT_MS = 3_000;
565
+ const controller = new AbortController();
566
+ let timer;
567
+ const timeout = new Promise((resolve) => {
568
+ timer = setTimeout(() => {
569
+ controller.abort();
570
+ resolve();
571
+ }, TIMEOUT_MS);
572
+ });
573
+ const work = (async () => {
574
+ const payload = await readStdinJson();
575
+ if (!payload)
576
+ return;
577
+ const query = sanitizePromptQuery(payload.user_input ?? payload.prompt);
578
+ ensureDirs();
579
+ if (query)
580
+ await ensureQmdAvailableForSync({ signal: controller.signal });
581
+ const searchResults = query ? await searchRelevantMemories(query, { signal: controller.signal }) : "";
582
+ const coreContext = buildDynamicContext(searchResults, query);
583
+ const runtime = new InstalledPluginRuntimeV1({ coreVersion: VERSION });
584
+ let pluginSections = [];
585
+ try {
586
+ pluginSections = await runtime.provideContext({
587
+ host: "agent-memory-cli",
588
+ cwd: process.cwd(),
589
+ query: query || undefined,
590
+ signal: controller.signal,
591
+ });
592
+ }
593
+ catch {
594
+ // Plugin context is optional — silent degradation.
595
+ }
596
+ try {
597
+ await runtime.refreshBackgroundWorkers({
598
+ host: "agent-memory-cli",
599
+ cwd: process.cwd(),
600
+ signal: controller.signal,
601
+ });
602
+ }
603
+ catch {
604
+ // Worker refresh must never block the user prompt.
605
+ }
606
+ const pluginContext = pluginSections.map((section) => `${section.label}\n\n${section.content}`).join("\n\n");
607
+ const context = [coreContext, pluginContext].filter(Boolean).join("\n\n");
608
+ if (context)
609
+ process.stdout.write(context);
610
+ })().catch(() => {
611
+ // Any failure in the per-turn hook must be swallowed — never emit an
612
+ // error message that would leak into the harness's context.
613
+ });
614
+ await Promise.race([work, timeout]);
615
+ if (timer)
616
+ clearTimeout(timer);
617
+ }
618
+ // How many Stop events must elapse (per session_id) before the periodic
619
+ // memory-write nudge fires again. Balances "long sessions get checked
620
+ // repeatedly" against "don't block every single turn".
621
+ const STOP_NAG_INTERVAL = 12;
622
+ // Bound state/stop-hook.json so it can't grow unboundedly across many sessions.
623
+ const STOP_HOOK_MAX_SESSIONS = 50;
624
+ function stopHookStatePath() {
625
+ return `${getMemoryDir()}/state/stop-hook.json`;
626
+ }
627
+ function readStopHookState() {
628
+ try {
629
+ const raw = fs.readFileSync(stopHookStatePath(), "utf-8");
630
+ const parsed = JSON.parse(raw);
631
+ return { sessions: parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {} };
632
+ }
633
+ catch {
634
+ return { sessions: {} };
635
+ }
636
+ }
637
+ function writeStopHookState(state) {
638
+ const entries = Object.entries(state.sessions).sort((a, b) => b[1].lastSeenAt - a[1].lastSeenAt);
639
+ const pruned = Object.fromEntries(entries.slice(0, STOP_HOOK_MAX_SESSIONS));
640
+ const stateDir = `${getMemoryDir()}/state`;
641
+ fs.mkdirSync(stateDir, { recursive: true });
642
+ fs.writeFileSync(stopHookStatePath(), `${JSON.stringify({ sessions: pruned }, null, 2)}\n`, { mode: 0o600 });
643
+ }
644
+ /**
645
+ * Bump the Stop-event counter for `sessionId` and report whether the
646
+ * periodic memory-write nudge should fire this time. Never throws — a
647
+ * corrupt or unwritable state file just means the nudge falls back to
648
+ * "never fires" rather than breaking the Stop hook.
649
+ */
650
+ function shouldNagOnStop(sessionId, now) {
651
+ try {
652
+ const state = readStopHookState();
653
+ const existing = state.sessions[sessionId] ?? { count: 0, lastNagCount: 0, lastSeenAt: now };
654
+ const count = existing.count + 1;
655
+ const shouldNag = count - existing.lastNagCount >= STOP_NAG_INTERVAL;
656
+ state.sessions[sessionId] = {
657
+ count,
658
+ lastNagCount: shouldNag ? count : existing.lastNagCount,
659
+ lastSeenAt: now,
660
+ };
661
+ writeStopHookState(state);
662
+ return shouldNag;
663
+ }
664
+ catch {
665
+ return false;
666
+ }
667
+ }
668
+ const STOP_NAG_REASON = "Before stopping: if this session produced a durable fact, bug fix, or decision worth remembering, " +
669
+ 'capture it now — `agent-memory write --content "..."` for a daily note, or `--target long_term` for a ' +
670
+ "durable fact — and update the scratchpad with any open follow-ups. If there's nothing worth recording, " +
671
+ "ignore this and stop normally.";
672
+ /**
673
+ * Stop hook handler — fires at the end of every assistant turn (not once per
674
+ * session). Blocks at most once every STOP_NAG_INTERVAL turns per session_id
675
+ * to nudge a memory-write check without being disruptive. Always allows the
676
+ * stop (empty stdout) on missing session_id, `stop_hook_active` (Claude Code's
677
+ * own re-entrancy signal — never block twice in a row), or any internal error.
678
+ */
679
+ async function cmdStop(_flags) {
680
+ const TIMEOUT_MS = 3_000;
681
+ const controller = new AbortController();
682
+ let timer;
683
+ const timeout = new Promise((resolve) => {
684
+ timer = setTimeout(() => {
685
+ controller.abort();
686
+ resolve();
687
+ }, TIMEOUT_MS);
688
+ });
689
+ const work = (async () => {
690
+ const payload = await readStdinJson();
691
+ const sessionId = typeof payload?.session_id === "string" ? payload.session_id : "";
692
+ if (!sessionId || payload?.stop_hook_active === true)
693
+ return;
694
+ if (shouldNagOnStop(sessionId, Date.now())) {
695
+ process.stdout.write(JSON.stringify({ decision: "block", reason: STOP_NAG_REASON }));
696
+ }
697
+ })().catch(() => {
698
+ // Any failure in the Stop hook must be swallowed — never trap the user
699
+ // in a stuck session over a broken memory-write nudge.
700
+ });
701
+ await Promise.race([work, timeout]);
702
+ if (timer)
703
+ clearTimeout(timer);
704
+ }
244
705
  async function cmdWrite(flags) {
245
706
  const json = hasFlag(flags, "json");
246
707
  const target = getFlag(flags, "target") ?? "daily";
@@ -459,15 +920,42 @@ async function cmdSearch(flags) {
459
920
  const collName = getCollectionName();
460
921
  const hasCollection = await checkCollection(collName);
461
922
  if (!hasCollection) {
462
- exitError(`qmd collection '${collName}' not found. Run: agent-memory init`, json);
923
+ exitError(`qmd collection '${collName}' not found. Run: agent-memory setup`, json);
463
924
  }
464
925
  try {
465
926
  const { results, stderr } = await runQmdSearch(mode, query, limit);
927
+ let recallHits = [];
928
+ if (results.length === 0) {
929
+ try {
930
+ const runtime = new InstalledPluginRuntimeV1({ coreVersion: VERSION });
931
+ const recallResult = await runtime.run("recall", {
932
+ args: [query],
933
+ flags: { limit: String(limit) },
934
+ signal: new AbortController().signal,
935
+ });
936
+ if (recallResult?.ok && Array.isArray(recallResult.data) && recallResult.data.length > 0) {
937
+ recallHits = recallResult.data;
938
+ }
939
+ }
940
+ catch {
941
+ // Pro not installed or recall unavailable — fall through to normal empty result
942
+ }
943
+ }
466
944
  if (json) {
467
- output({ mode, query, count: results.length, results }, true);
945
+ if (results.length === 0 && recallHits.length > 0) {
946
+ output({ mode, query, source: "recall", count: recallHits.length, results: recallHits }, true);
947
+ }
948
+ else {
949
+ output({ mode, query, source: "qmd", count: results.length, results }, true);
950
+ }
468
951
  return;
469
952
  }
470
953
  if (results.length === 0) {
954
+ if (recallHits.length > 0) {
955
+ console.log(`No hits in local memory files for "${query}" — falling back to prior sessions via Pro recall (${recallHits.length} hit(s)):\n`);
956
+ console.log(JSON.stringify(recallHits, null, 2));
957
+ return;
958
+ }
471
959
  const needsEmbed = /need embeddings/i.test(stderr ?? "");
472
960
  if (needsEmbed && (mode === "semantic" || mode === "deep")) {
473
961
  console.log(`No results found. qmd reports missing embeddings — run: qmd embed`);
@@ -556,6 +1044,11 @@ function cmdInstallSkills(flags) {
556
1044
  console.log(`Skipped ${item.label} (${item.reason})`);
557
1045
  }
558
1046
  }
1047
+ if (report.installed.length > 0 && process.stdout.isTTY) {
1048
+ const first = report.installed[0].label;
1049
+ console.log("");
1050
+ console.log(`Next: open ${first} and ask ${colorize('"what do you remember about me?"', "cyan")} to verify.`);
1051
+ }
559
1052
  }
560
1053
  async function promptYesNo(question, defaultYes) {
561
1054
  const readline = await import("node:readline/promises");
@@ -574,26 +1067,83 @@ async function cmdInstallHooks(flags) {
574
1067
  const json = hasFlag(flags, "json");
575
1068
  const requested = getFlag(flags, "only");
576
1069
  const requestedKeys = requested ? new Set(requested.split(",").map((value) => value.trim())) : null;
1070
+ const modeFlag = getFlag(flags, "mode");
1071
+ if (modeFlag !== undefined && modeFlag !== "stable" && modeFlag !== "per-turn") {
1072
+ exitError(`--mode must be 'stable' or 'per-turn' (got ${modeFlag})`, json);
1073
+ }
1074
+ const mode = modeFlag ?? "per-turn";
577
1075
  const { homeDir, targets } = detectHookAgents();
578
1076
  if (!homeDir)
579
1077
  exitError("Home directory not found.", json);
580
1078
  const eligible = targets.filter((target) => target.supported && target.detected && (!requestedKeys || requestedKeys.has(target.key)));
1079
+ if (!eligible.length) {
1080
+ if (json)
1081
+ return output({ ok: true, homeDir, results: [] }, true);
1082
+ return output("No eligible agents. Nothing to install.", false);
1083
+ }
1084
+ // Consider an agent "already installed" only when the wiring matches the requested mode.
1085
+ // per-turn requires BOTH SessionStart and UserPromptSubmit; stable requires SessionStart AND
1086
+ // no UserPromptSubmit (so a downgrade correctly removes the per-turn hook).
1087
+ const isFullyInstalled = (target) => {
1088
+ if (!homeDir)
1089
+ return false;
1090
+ const session = isHookInstalled(homeDir, target.key);
1091
+ if (!session)
1092
+ return false;
1093
+ if (target.key !== "claude" && target.key !== "codex")
1094
+ return true; // cursor/opencode: static only
1095
+ const prompt = isUserPromptSubmitInstalled(homeDir, target.key);
1096
+ if (mode === "per-turn" ? !prompt : prompt)
1097
+ return false;
1098
+ // Stop (write-side nudge) is Claude Code only and mode-independent.
1099
+ if (target.key === "claude") {
1100
+ if (!isStopHookInstalled(homeDir, target.key))
1101
+ return false;
1102
+ }
1103
+ return true;
1104
+ };
1105
+ const alreadyInstalled = eligible.filter(isFullyInstalled);
1106
+ const pending = eligible.filter((target) => !alreadyInstalled.includes(target));
1107
+ if (!json && alreadyInstalled.length) {
1108
+ const labels = alreadyInstalled.map((target) => target.label).join(", ");
1109
+ console.log(`Automatic context already active for: ${labels}.`);
1110
+ }
1111
+ if (!pending.length) {
1112
+ if (json) {
1113
+ return output({
1114
+ ok: true,
1115
+ homeDir,
1116
+ results: alreadyInstalled.map((target) => ({
1117
+ key: target.key,
1118
+ label: target.label,
1119
+ installed: false,
1120
+ reason: "already installed",
1121
+ mode,
1122
+ })),
1123
+ }, true);
1124
+ }
1125
+ return output("Nothing to install.", false);
1126
+ }
581
1127
  const selected = new Set();
582
1128
  const applyAll = hasFlag(flags, "yes") || hasFlag(flags, "all") || !process.stdin.isTTY;
583
- for (const target of eligible) {
584
- if (applyAll || (await promptYesNo(`Install SessionStart hook for ${target.label}?`, true)))
1129
+ const hookLabel = mode === "per-turn" ? "SessionStart + UserPromptSubmit hooks" : "SessionStart hook";
1130
+ for (const target of pending) {
1131
+ if (applyAll || (await promptYesNo(`Install ${hookLabel} for ${target.label}?`, true)))
585
1132
  selected.add(target.key);
586
1133
  }
587
- const report = installHooks(selected);
1134
+ if (!selected.size) {
1135
+ if (json)
1136
+ return output({ ok: true, homeDir, results: [] }, true);
1137
+ return output("Nothing selected. Skipped.", false);
1138
+ }
1139
+ const report = installHooks(selected, mode);
588
1140
  if (!report.ok)
589
1141
  exitError(report.error ?? "install failed", json);
590
1142
  if (json)
591
1143
  return output(report, true);
592
- if (!report.results.length)
593
- return output("No eligible agents. Nothing to install.", false);
594
1144
  for (const result of report.results) {
595
1145
  console.log(result.installed
596
- ? `Installed ${result.label} hook: ${result.path}`
1146
+ ? `Installed ${result.label} hook (${result.mode ?? mode}): ${result.path}`
597
1147
  : `Skipped ${result.label} (${result.reason ?? "unknown"})`);
598
1148
  }
599
1149
  }
@@ -645,7 +1195,7 @@ async function cmdSync(flags) {
645
1195
  const collName = getCollectionName();
646
1196
  const hasCollection = await checkCollection(collName);
647
1197
  if (!hasCollection) {
648
- exitError(`qmd collection '${collName}' not found. Run: agent-memory init`, json);
1198
+ exitError(`qmd collection '${collName}' not found. Run: agent-memory setup`, json);
649
1199
  }
650
1200
  const result = await runQmdSync();
651
1201
  if (json) {
@@ -671,6 +1221,9 @@ async function cmdSync(flags) {
671
1221
  }
672
1222
  async function cmdInit(flags) {
673
1223
  const json = hasFlag(flags, "json");
1224
+ const nonInteractive = json || hasFlag(flags, "yes") || !process.stdin.isTTY || !process.stdout.isTTY;
1225
+ const skipSkills = hasFlag(flags, "skip-skills");
1226
+ const skipHooks = hasFlag(flags, "skip-hooks");
674
1227
  ensureDirs();
675
1228
  const dir = getMemoryDir();
676
1229
  const qmdFound = await detectQmd();
@@ -683,7 +1236,6 @@ async function cmdInit(flags) {
683
1236
  if (!hasCollection) {
684
1237
  collectionCreated = await setupQmdCollection();
685
1238
  }
686
- // Run initial index update + start background embed
687
1239
  await ensureQmdAvailableForUpdate();
688
1240
  await runQmdUpdateNow();
689
1241
  indexUpdated = true;
@@ -691,117 +1243,511 @@ async function cmdInit(flags) {
691
1243
  embedStarted = child !== null;
692
1244
  }
693
1245
  if (json) {
694
- output({
695
- ok: true,
696
- directory: dir,
697
- qmd: qmdFound,
698
- collectionCreated,
699
- indexUpdated,
700
- embedStarted,
701
- }, true);
1246
+ output({ ok: true, directory: dir, qmd: qmdFound, collectionCreated, indexUpdated, embedStarted }, true);
1247
+ return;
702
1248
  }
703
- else {
1249
+ // ---------------------------------------------------------------------
1250
+ // Non-interactive path (backward compatible with existing `--yes` / CI use)
1251
+ // ---------------------------------------------------------------------
1252
+ if (nonInteractive) {
704
1253
  console.log(`Memory directory: ${dir}`);
705
- console.log(` MEMORY.md, SCRATCHPAD.md, daily/, topics/ created.`);
1254
+ console.log(` MEMORY.md, SCRATCHPAD.md, daily/, topics/ ready.`);
706
1255
  if (qmdFound) {
707
- if (collectionCreated) {
708
- console.log(` qmd collection '${getCollectionName()}' created.`);
709
- }
710
- else {
711
- console.log(` qmd collection '${getCollectionName()}' already exists.`);
712
- }
713
- if (indexUpdated) {
714
- console.log(` Index updated.`);
715
- }
716
- if (embedStarted) {
717
- console.log(` Embedding started in background.`);
718
- }
1256
+ console.log(` Search index ready.`);
1257
+ if (embedStarted)
1258
+ console.log(` Semantic search is preparing in the background.`);
719
1259
  }
720
1260
  else {
721
- console.log(` qmd not found — search features unavailable.`);
722
- console.log(` Install: bun install -g https://github.com/tobi/qmd`);
1261
+ console.log(` Search index unavailablekeyword search remains available.`);
1262
+ console.log(` Optional: install qmd with bun install -g https://github.com/tobi/qmd`);
723
1263
  }
724
- if (process.stdout.isTTY) {
725
- try {
726
- const plugin = await createDefaultPluginBootstrap(VERSION).list();
727
- if (plugin.result === "not_installed") {
728
- console.log("");
729
- console.log("Optional: Pro recalls coding history and learns from repeated corrections.");
730
- console.log("Try it without an account: agent-memory pro install");
731
- }
732
- }
733
- catch {
734
- // Commercial discovery must never make core initialization fail.
735
- }
1264
+ return;
1265
+ }
1266
+ // ---------------------------------------------------------------------
1267
+ // Interactive wizard
1268
+ // ---------------------------------------------------------------------
1269
+ console.log("");
1270
+ console.log(colorize("Welcome to AgentMemory.", "bold"));
1271
+ console.log(colorize("Persistent memory for Claude Code, Codex, Cursor, and other coding agents.", "dim"));
1272
+ console.log("");
1273
+ console.log(` ${MARK_OK} Memory directory: ${dir}`);
1274
+ if (qmdFound) {
1275
+ console.log(` ${MARK_OK} Search index: ${collectionCreated ? "created" : "ready"}`);
1276
+ }
1277
+ else {
1278
+ console.log(` ${MARK_WARN} qmd not installed — search will be limited.`);
1279
+ console.log(` ${colorize("Install: bun install -g https://github.com/tobi/qmd", "cyan")}`);
1280
+ }
1281
+ console.log("");
1282
+ // Detect which agent hosts are present
1283
+ const { targets } = detectHookAgents();
1284
+ const detected = targets.filter((target) => target.detected && target.supported);
1285
+ if (detected.length === 0) {
1286
+ console.log(colorize(" No supported agents detected on this machine.", "yellow"));
1287
+ console.log(colorize(" Install Claude Code, Codex, or Cursor, then re-run.", "dim"));
1288
+ }
1289
+ else {
1290
+ console.log(colorize("Detected agents:", "bold"));
1291
+ for (const target of detected)
1292
+ console.log(` ${MARK_OK} ${target.label}`);
1293
+ console.log("");
1294
+ }
1295
+ // Step 1: install skills
1296
+ if (!skipSkills && detected.length > 0) {
1297
+ if (await promptYesNo("Install the AgentMemory skill so your agents can use memory?", true)) {
1298
+ cmdInstallSkills({});
736
1299
  }
737
1300
  }
738
- }
739
- async function cmdStatus(flags) {
740
- const json = hasFlag(flags, "json");
741
- ensureDirs();
742
- const dir = getMemoryDir();
1301
+ console.log("");
1302
+ // Step 2: install SessionStart hooks (delegates to existing interactive path)
1303
+ if (!skipHooks && detected.length > 0) {
1304
+ if (await promptYesNo("Install SessionStart hooks so context loads automatically?", true)) {
1305
+ await cmdInstallHooks({});
1306
+ }
1307
+ }
1308
+ console.log("");
1309
+ // Step 3: seed MEMORY.md so the skill's cold start isn't empty
743
1310
  const memFile = getMemoryFile();
744
- const spFile = getScratchpadFile();
745
- const dailyDir = getDailyDir();
746
- const topicsDir = getTopicsDir();
747
- const memContent = readFileSafe(memFile);
748
- const spContent = readFileSafe(spFile);
749
- let dailyCount = 0;
1311
+ const existing = readFileSafe(memFile) ?? "";
1312
+ if (existing.trim().length === 0) {
1313
+ const platform = process.platform === "darwin" ? "macOS" : process.platform;
1314
+ const seed = `<!-- ${nowTimestamp()} [init] -->\nAgentMemory initialized on ${platform} · ${todayStr()}. First session: check the scratchpad and daily log for context.\n`;
1315
+ fs.writeFileSync(memFile, seed);
1316
+ console.log(` ${MARK_OK} Seeded MEMORY.md with a first entry so your agent has something to read.`);
1317
+ }
1318
+ // Step 4: offer Pro preview if not installed
750
1319
  try {
751
- dailyCount = fs.readdirSync(dailyDir).filter((f) => f.endsWith(".md")).length;
1320
+ const plugin = await createDefaultPluginBootstrap(VERSION).list();
1321
+ if (plugin.result === "not_installed") {
1322
+ printProPitch("first-run");
1323
+ if (await promptYesNo("Preview what Pro would find in your existing session history?", true)) {
1324
+ await cmdProPreview({});
1325
+ }
1326
+ }
752
1327
  }
753
1328
  catch {
754
- // directory may not exist
1329
+ // Commercial discovery must never make core initialization fail.
755
1330
  }
756
- let topicCount = 0;
1331
+ // Cheat sheet
1332
+ console.log("");
1333
+ console.log(colorize("You're set. Try these:", "bold"));
1334
+ console.log(' agent-memory save "made progress on <thing>" — quick note in today\'s log');
1335
+ console.log(' agent-memory note "follow up on <thing>" — persistent todo item');
1336
+ console.log(' agent-memory recall "what did we decide about X?" — search past sessions (Pro)');
1337
+ console.log(" agent-memory doctor — health check");
1338
+ console.log("");
1339
+ console.log(colorize(`Now open one of ${detected.map((target) => target.label).join(", ") || "your agents"} and ask: "what do you remember about me?"`, "cyan"));
1340
+ }
1341
+ function readProUsageCache() {
757
1342
  try {
758
- topicCount = fs.readdirSync(topicsDir).filter((f) => f.endsWith(".md")).length;
1343
+ return JSON.parse(fs.readFileSync(`${getMemoryDir()}/state/pro-usage.json`, "utf-8"));
759
1344
  }
760
1345
  catch {
761
- // directory may not exist
1346
+ return {};
762
1347
  }
763
- const qmdFound = await detectQmd();
764
- let hasCollection = false;
765
- let health = null;
766
- let embeddings = "n/a";
767
- if (qmdFound) {
768
- hasCollection = await checkCollection();
769
- if (hasCollection) {
770
- await ensureQmdAvailableForSync();
771
- health = await getQmdHealth();
772
- // A live semantic probe confirms embeddings are actually usable, but
773
- // it costs a real qmd query (and a possible model load), so it's
774
- // opt-in the cheap pending-embed count below covers the common case.
775
- if (hasFlag(flags, "probe")) {
776
- embeddings = await probeEmbeddings();
1348
+ }
1349
+ async function printSetupUsageStats(pluginInstalled) {
1350
+ if (!pluginInstalled)
1351
+ return;
1352
+ try {
1353
+ const usage = readProUsageCache();
1354
+ const recall = usage.recall;
1355
+ const learn = usage.learn;
1356
+ const recallStr = recall?.used !== undefined && recall?.limit !== undefined
1357
+ ? `${recall.used}/${recall.limit} recalls today`
1358
+ : `0/20 recalls today`;
1359
+ const learnStr = learn?.used !== undefined && learn?.limit !== undefined
1360
+ ? `${learn.used}/${learn.limit} learnings today`
1361
+ : `0/5 learnings today`;
1362
+ console.log(colorize(` Plan: Free · ${recallStr} · ${learnStr}`, "dim"));
1363
+ let sessionStats = null;
1364
+ try {
1365
+ const result = await new InstalledPluginRuntimeV1({ coreVersion: VERSION }).run("index", {
1366
+ args: [],
1367
+ flags: {},
1368
+ signal: new AbortController().signal,
1369
+ });
1370
+ if (result?.ok && result.data && typeof result.data === "object") {
1371
+ const stats = result.data.stats;
1372
+ if (stats?.discovered) {
1373
+ sessionStats = {
1374
+ claude: stats.discovered.claude ?? 0,
1375
+ codex: stats.discovered.codex ?? 0,
1376
+ pi: stats.discovered.pi ?? 0,
1377
+ };
1378
+ }
777
1379
  }
778
1380
  }
1381
+ catch {
1382
+ // best-effort
1383
+ }
1384
+ if (sessionStats !== null) {
1385
+ const total = sessionStats.claude + sessionStats.codex + sessionStats.pi;
1386
+ if (total > 0) {
1387
+ const parts = [
1388
+ sessionStats.claude > 0 ? `Claude Code ${sessionStats.claude.toLocaleString("en-US")}` : null,
1389
+ sessionStats.codex > 0 ? `Codex ${sessionStats.codex.toLocaleString("en-US")}` : null,
1390
+ sessionStats.pi > 0 ? `Pi ${sessionStats.pi.toLocaleString("en-US")}` : null,
1391
+ ]
1392
+ .filter(Boolean)
1393
+ .join(" · ");
1394
+ console.log(colorize(` Sessions indexed: ${total.toLocaleString("en-US")} (${parts})`, "dim"));
1395
+ }
1396
+ }
1397
+ console.log("");
779
1398
  }
780
- const embedMode = getQmdEmbedMode();
781
- let officialPlugin = {
782
- installed: false,
783
- result: "unavailable",
784
- entitlement: "missing",
1399
+ catch {
1400
+ // stats are best-effort; never block setup completion
1401
+ }
1402
+ }
1403
+ /**
1404
+ * One-shot idempotent installer. Runs init + install-skills + install-hooks +
1405
+ * plugin install (if a bundle is discoverable) and then prints a one-page status
1406
+ * summary. Each step is a no-op when the target is already good, so `setup` is
1407
+ * safe to re-run after upgrades.
1408
+ *
1409
+ * The point of `setup` is that a first-time user only has to remember ONE
1410
+ * command. `init`, `install-skills`, `install-hooks`, `plugin install` still
1411
+ * exist for scripts and for finer control, but no one needs them for the happy
1412
+ * path.
1413
+ *
1414
+ * Interactivity model: on an interactive TTY (and when not `--json`) setup
1415
+ * will prompt exactly once — to install AgentMemory Pro — because the benefits
1416
+ * (cross-session recall, learn-from-corrections) are invisible without it.
1417
+ * With `--yes` we auto-install Pro; with `--json`, `--skip-plugin`, or a
1418
+ * non-TTY stdin/stdout we fall back to the previous passive hint so scripted
1419
+ * setups keep working unchanged.
1420
+ */
1421
+ async function cmdSetup(flags) {
1422
+ const json = hasFlag(flags, "json");
1423
+ const skipSkills = hasFlag(flags, "skip-skills");
1424
+ const skipHooks = hasFlag(flags, "skip-hooks");
1425
+ const skipPlugin = hasFlag(flags, "skip-plugin");
1426
+ const skipMcp = hasFlag(flags, "skip-mcp");
1427
+ const interactive = !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
1428
+ // setup is non-interactive by design. Sub-steps that print progress noise get their
1429
+ // stdout captured when we're in json mode, so the whole command emits ONE envelope.
1430
+ const subFlags = { yes: true };
1431
+ const steps = [];
1432
+ const runQuiet = async (fn) => {
1433
+ if (!json) {
1434
+ await fn();
1435
+ return;
1436
+ }
1437
+ const originalLog = console.log;
1438
+ const originalInfo = console.info;
1439
+ console.log = () => { };
1440
+ console.info = () => { };
1441
+ try {
1442
+ await fn();
1443
+ }
1444
+ finally {
1445
+ console.log = originalLog;
1446
+ console.info = originalInfo;
1447
+ }
785
1448
  };
1449
+ // Step 1: memory dir + qmd
786
1450
  try {
787
- const plugin = await createDefaultPluginBootstrap(VERSION).status();
788
- officialPlugin = {
789
- installed: Boolean(plugin.bundle),
790
- result: plugin.result,
791
- entitlement: plugin.entitlement.state,
792
- };
1451
+ await runQuiet(() => cmdInit({ ...subFlags, "skip-skills": true, "skip-hooks": true }));
1452
+ steps.push({ name: "memory", ok: true, detail: getMemoryDir() });
793
1453
  }
794
- catch {
795
- // Commercial status must never make core status fail.
1454
+ catch (error) {
1455
+ steps.push({ name: "memory", ok: false, detail: error.message });
796
1456
  }
797
- if (json) {
798
- output({
799
- directory: dir,
800
- memoryFile: {
801
- exists: memContent !== null,
802
- chars: memContent?.length ?? 0,
803
- lines: memContent ? memContent.split("\n").length : 0,
804
- },
1457
+ // Step 2: skills for detected agents
1458
+ if (!skipSkills) {
1459
+ try {
1460
+ await runQuiet(() => cmdInstallSkills(subFlags));
1461
+ steps.push({ name: "skills", ok: true });
1462
+ }
1463
+ catch (error) {
1464
+ steps.push({ name: "skills", ok: false, detail: error.message });
1465
+ }
1466
+ }
1467
+ else {
1468
+ steps.push({ name: "skills", ok: true, detail: "skipped" });
1469
+ }
1470
+ // Step 3: hooks (uses the improved preflight — silent when all already installed)
1471
+ if (!skipHooks) {
1472
+ try {
1473
+ await runQuiet(() => cmdInstallHooks(subFlags));
1474
+ steps.push({ name: "hooks", ok: true });
1475
+ }
1476
+ catch (error) {
1477
+ steps.push({ name: "hooks", ok: false, detail: error.message });
1478
+ }
1479
+ }
1480
+ else {
1481
+ steps.push({ name: "hooks", ok: true, detail: "skipped" });
1482
+ }
1483
+ // Step 4: install the local session-intelligence plugin by default. The free
1484
+ // allowance is part of the product experience, not an opt-in gate: users
1485
+ // should feel recall and learning before they ever see an upgrade prompt.
1486
+ let pluginJustInstalled = false;
1487
+ if (!skipPlugin) {
1488
+ const receiptPath = path.join(process.env.HOME ?? "", ".agent-memory/system/plugins/receipts/agentmemory.pro.json");
1489
+ const readReceiptVersion = () => {
1490
+ try {
1491
+ const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf-8"));
1492
+ return receipt.version;
1493
+ }
1494
+ catch {
1495
+ return undefined;
1496
+ }
1497
+ };
1498
+ if (fs.existsSync(receiptPath)) {
1499
+ const version = readReceiptVersion();
1500
+ steps.push({
1501
+ name: "plugin",
1502
+ ok: true,
1503
+ detail: version ? `${version} installed` : "installed",
1504
+ });
1505
+ }
1506
+ else {
1507
+ // The free allowance is enabled automatically. `--skip-plugin` is the
1508
+ // explicit escape hatch for users who want core-only setup.
1509
+ const manager = createDefaultPluginBootstrap(VERSION);
1510
+ // Run install, capture the structured result (don't drop it), and
1511
+ // auto-recover from `version_conflict` — that just means a stale
1512
+ // bundle from an earlier dev iteration is sitting in
1513
+ // ~/.agent-memory/system/plugins/bundles/... with a different SHA.
1514
+ // A manual `plugin uninstall && plugin install` fixes it, so we do
1515
+ // the same automatically once during setup.
1516
+ const runInstall = async () => {
1517
+ let outcome;
1518
+ await runQuiet(async () => {
1519
+ outcome = await manager.install({ channel: "stable", allowAuthentication: interactive });
1520
+ });
1521
+ return outcome;
1522
+ };
1523
+ try {
1524
+ let installResult = await runInstall();
1525
+ if (!installResult.ok && installResult.error?.code === "version_conflict") {
1526
+ // `manager.uninstall()` only wipes bundles referenced by a
1527
+ // receipt. On version_conflict there is no receipt (that's
1528
+ // the whole point — an orphan bundle from an earlier failed
1529
+ // install is on disk with a different SHA). Try the API path
1530
+ // first, then fall back to removing the specific version
1531
+ // directory under ~/.agent-memory/system/plugins/bundles/.
1532
+ await runQuiet(async () => {
1533
+ await manager.uninstall();
1534
+ });
1535
+ try {
1536
+ const bundlesRoot = path.join(process.env.HOME ?? "", ".agent-memory/system/plugins/bundles/agentmemory.pro");
1537
+ if (fs.existsSync(bundlesRoot)) {
1538
+ for (const entry of fs.readdirSync(bundlesRoot)) {
1539
+ // Only touch semver-looking dirs; keep sibling
1540
+ // artefacts like `*.backup` untouched.
1541
+ if (!/^\d+\.\d+\.\d+/.test(entry))
1542
+ continue;
1543
+ const target = path.join(bundlesRoot, entry);
1544
+ try {
1545
+ fs.rmSync(target, { recursive: true, force: true });
1546
+ }
1547
+ catch {
1548
+ // Best-effort; the retry below will surface a real error if this mattered.
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+ catch {
1554
+ // Non-fatal — we retry the install regardless.
1555
+ }
1556
+ installResult = await runInstall();
1557
+ }
1558
+ if (installResult.ok &&
1559
+ (installResult.result === "installed" ||
1560
+ installResult.result === "upgraded" ||
1561
+ installResult.result === "current")) {
1562
+ const version = installResult.bundle?.version ?? readReceiptVersion();
1563
+ steps.push({
1564
+ name: "plugin",
1565
+ ok: true,
1566
+ detail: version ? `${version} installed` : "installed",
1567
+ });
1568
+ pluginJustInstalled = installResult.result !== "current";
1569
+ }
1570
+ else {
1571
+ const reasonCode = installResult.error?.code;
1572
+ const reasonMessage = installResult.error?.message ?? installResult.result ?? "unknown reason";
1573
+ const hint = reasonCode === "auth_required" || reasonCode === "renewal_required"
1574
+ ? "run `agent-memory plugin install` and complete the browser sign-in"
1575
+ : "run `agent-memory plugin install --json` for details";
1576
+ steps.push({
1577
+ name: "plugin",
1578
+ ok: false,
1579
+ detail: `install failed (${reasonMessage}) — ${hint}`,
1580
+ });
1581
+ }
1582
+ }
1583
+ catch (error) {
1584
+ steps.push({
1585
+ name: "plugin",
1586
+ ok: false,
1587
+ detail: `install failed — ${error.message}`,
1588
+ });
1589
+ }
1590
+ }
1591
+ }
1592
+ if (skipPlugin) {
1593
+ steps.push({ name: "plugin", ok: true, detail: "skipped — core-only setup requested" });
1594
+ }
1595
+ // Step 5: register the MCP server in every detected local harness (Claude
1596
+ // Code, Cursor, Windsurf, Codex). Idempotent — re-running setup after adding
1597
+ // a new agent will pick it up. Hooks handle passive injection; MCP is what
1598
+ // lets the model pull memory on demand as tools.
1599
+ let mcpRegisteredKeys = [];
1600
+ if (!skipMcp) {
1601
+ try {
1602
+ const results = registerMcpInAgents(null);
1603
+ const registered = results.filter((r) => r.status === "registered").map((r) => r.key);
1604
+ const already = results.filter((r) => r.status === "already").map((r) => r.key);
1605
+ mcpRegisteredKeys = registered;
1606
+ let detail;
1607
+ if (registered.length === 0 && already.length === 0) {
1608
+ detail = "no supported agents detected";
1609
+ }
1610
+ else if (registered.length === 0) {
1611
+ detail = `already registered (${already.join(", ")})`;
1612
+ }
1613
+ else if (already.length === 0) {
1614
+ detail = `registered (${registered.join(", ")})`;
1615
+ }
1616
+ else {
1617
+ detail = `registered (${registered.join(", ")}); already (${already.join(", ")})`;
1618
+ }
1619
+ steps.push({ name: "mcp", ok: true, detail });
1620
+ }
1621
+ catch (error) {
1622
+ steps.push({ name: "mcp", ok: false, detail: error.message });
1623
+ }
1624
+ }
1625
+ else {
1626
+ steps.push({ name: "mcp", ok: true, detail: "skipped" });
1627
+ }
1628
+ if (json) {
1629
+ output({ ok: steps.every((step) => step.ok), directory: getMemoryDir(), steps }, true);
1630
+ return;
1631
+ }
1632
+ console.log("");
1633
+ console.log(colorize("agent-memory setup", "bold"));
1634
+ for (const step of steps) {
1635
+ const mark = step.ok ? MARK_OK : MARK_FAIL;
1636
+ const detail = step.detail ? colorize(` ${step.detail}`, "dim") : "";
1637
+ console.log(` ${mark} ${step.name}${detail}`);
1638
+ }
1639
+ console.log("");
1640
+ if (mcpRegisteredKeys.length > 0) {
1641
+ console.log(colorize(`Restart ${mcpRegisteredKeys.join(", ")} to pick up the new MCP server.`, "dim"));
1642
+ }
1643
+ await printSetupUsageStats(steps.some((s) => s.name === "plugin" && s.ok));
1644
+ console.log(colorize("Setup complete. Your agents will discover memory automatically next session.", "green"));
1645
+ console.log(colorize("Your notes stay in plain Markdown on this device — no account, no upload.", "dim"));
1646
+ console.log(colorize('Open your agent and ask: "What do you remember about me?"', "cyan"));
1647
+ console.log("");
1648
+ console.log("Try it now:");
1649
+ console.log(` ${colorize('agent-memory save "your first note"', "cyan")} — save a note you own`);
1650
+ console.log(` ${colorize("agent-memory status", "cyan")} — verify everything is healthy`);
1651
+ if (pluginJustInstalled) {
1652
+ console.log("");
1653
+ console.log(colorize("The local plugin is live. Feel the magic now:", "green"));
1654
+ console.log(` ${colorize('agent-memory recall "what did we decide about auth?"', "cyan")} — search past sessions`);
1655
+ console.log(` ${colorize("agent-memory learn", "cyan")} — surface repeated corrections`);
1656
+ console.log(` ${colorize("agent-memory worker start", "cyan")} — capture new sessions in real time`);
1657
+ console.log(` ${colorize("agent-memory dashboard", "cyan")} — private local dashboard`);
1658
+ }
1659
+ else if (skipPlugin) {
1660
+ console.log(colorize("Core-only setup selected. Enable session intelligence anytime:", "dim"));
1661
+ console.log(` ${colorize("agent-memory plugin install", "cyan")} — recall and learn with a free daily allowance`);
1662
+ }
1663
+ console.log("");
1664
+ }
1665
+ /**
1666
+ * Explain the optional plugin without making a successful core setup feel
1667
+ * incomplete. Keep the quota line here in sync with `freeEntitlement()`.
1668
+ */
1669
+ function printProPitch(mode) {
1670
+ console.log("");
1671
+ if (mode === "reinstall") {
1672
+ console.log(`${MARK_WARN} ${colorize("The optional local plugin is not enabled.", "yellow")}`);
1673
+ console.log(colorize(" Core memory is already ready. The plugin adds:", "dim"));
1674
+ }
1675
+ else {
1676
+ console.log(`${colorize("Optional: AgentMemory Pro", "bold")} — ${colorize("memory that learns from your work", "dim")}`);
1677
+ }
1678
+ console.log(` ${colorize("Recall across sessions", "cyan")} Ask "what did we decide about auth?" across Claude, Codex, Cursor.`);
1679
+ console.log(` ${colorize("Learn from corrections", "cyan")} Turn repeated fixes into memory you can inspect and undo.`);
1680
+ console.log(` ${colorize("Real-time capture", "cyan")} Local worker indexes new sessions as they happen.`);
1681
+ console.log(` ${colorize("Private by default", "cyan")} Memory and session content stay on this device — no account required.`);
1682
+ console.log(` ${colorize("Included at no cost:", "green")} ${colorize("20 recalls + 5 learning scans per day", "bold")}. Local indexing and dashboard remain free.`);
1683
+ console.log("");
1684
+ }
1685
+ async function cmdStatus(flags) {
1686
+ const json = hasFlag(flags, "json");
1687
+ ensureDirs();
1688
+ const dir = getMemoryDir();
1689
+ const memFile = getMemoryFile();
1690
+ const spFile = getScratchpadFile();
1691
+ const dailyDir = getDailyDir();
1692
+ const topicsDir = getTopicsDir();
1693
+ const memContent = readFileSafe(memFile);
1694
+ const spContent = readFileSafe(spFile);
1695
+ let dailyCount = 0;
1696
+ try {
1697
+ dailyCount = fs.readdirSync(dailyDir).filter((f) => f.endsWith(".md")).length;
1698
+ }
1699
+ catch {
1700
+ // directory may not exist
1701
+ }
1702
+ let topicCount = 0;
1703
+ try {
1704
+ topicCount = fs.readdirSync(topicsDir).filter((f) => f.endsWith(".md")).length;
1705
+ }
1706
+ catch {
1707
+ // directory may not exist
1708
+ }
1709
+ const qmdFound = await detectQmd();
1710
+ let hasCollection = false;
1711
+ let health = null;
1712
+ let embeddings = "n/a";
1713
+ if (qmdFound) {
1714
+ hasCollection = await checkCollection();
1715
+ if (hasCollection) {
1716
+ await ensureQmdAvailableForSync();
1717
+ health = await getQmdHealth();
1718
+ // A live semantic probe confirms embeddings are actually usable, but
1719
+ // it costs a real qmd query (and a possible model load), so it's
1720
+ // opt-in — the cheap pending-embed count below covers the common case.
1721
+ if (hasFlag(flags, "probe")) {
1722
+ embeddings = await probeEmbeddings();
1723
+ }
1724
+ }
1725
+ }
1726
+ const embedMode = getQmdEmbedMode();
1727
+ let officialPlugin = {
1728
+ installed: false,
1729
+ result: "unavailable",
1730
+ entitlement: "missing",
1731
+ };
1732
+ try {
1733
+ const plugin = await createDefaultPluginBootstrap(VERSION).status();
1734
+ officialPlugin = {
1735
+ installed: Boolean(plugin.bundle),
1736
+ result: plugin.result,
1737
+ entitlement: plugin.entitlement.state,
1738
+ };
1739
+ }
1740
+ catch {
1741
+ // Commercial status must never make core status fail.
1742
+ }
1743
+ if (json) {
1744
+ output({
1745
+ directory: dir,
1746
+ memoryFile: {
1747
+ exists: memContent !== null,
1748
+ chars: memContent?.length ?? 0,
1749
+ lines: memContent ? memContent.split("\n").length : 0,
1750
+ },
805
1751
  scratchpadFile: {
806
1752
  exists: spContent !== null,
807
1753
  items: spContent ? parseScratchpad(spContent).length : 0,
@@ -842,7 +1788,7 @@ async function cmdStatus(flags) {
842
1788
  console.log("");
843
1789
  if (qmdFound) {
844
1790
  console.log(`qmd: available`);
845
- console.log(`Collection '${getCollectionName()}': ${hasCollection ? "configured" : "not configured — run: agent-memory init"}`);
1791
+ console.log(`Collection '${getCollectionName()}': ${hasCollection ? "configured" : "not configured — run: agent-memory setup"}`);
846
1792
  console.log(`Embed mode: ${embedMode}`);
847
1793
  if (hasCollection && embeddings !== "n/a") {
848
1794
  const embLabel = embeddings === "ready"
@@ -875,6 +1821,320 @@ async function cmdStatus(flags) {
875
1821
  }
876
1822
  }
877
1823
  }
1824
+ function printDoctorRow(row) {
1825
+ const mark = row.status === "ok" ? MARK_OK : row.status === "warn" ? MARK_WARN : MARK_FAIL;
1826
+ console.log(` ${mark} ${row.label.padEnd(28)} ${colorize(row.detail, "dim")}`);
1827
+ if (row.fix)
1828
+ console.log(` ${colorize(`↳ fix: ${row.fix}`, "cyan")}`);
1829
+ }
1830
+ async function cmdDoctor(flags) {
1831
+ const json = hasFlag(flags, "json");
1832
+ ensureDirs();
1833
+ const rows = [];
1834
+ // Memory dir
1835
+ const dir = getMemoryDir();
1836
+ let dirWritable = false;
1837
+ try {
1838
+ fs.accessSync(dir, fs.constants.W_OK);
1839
+ dirWritable = true;
1840
+ }
1841
+ catch {
1842
+ // noop
1843
+ }
1844
+ rows.push({
1845
+ status: dirWritable ? "ok" : "fail",
1846
+ label: "Memory directory",
1847
+ detail: dirWritable ? dir : `${dir} — not writable`,
1848
+ fix: dirWritable ? undefined : `chmod u+w ${dir}`,
1849
+ });
1850
+ // MEMORY.md seeded?
1851
+ const memContent = readFileSafe(getMemoryFile());
1852
+ rows.push({
1853
+ status: memContent && memContent.length > 0 ? "ok" : "warn",
1854
+ label: "MEMORY.md",
1855
+ detail: memContent && memContent.length > 0
1856
+ ? `${memContent.split("\n").length} lines, ${memContent.length} chars`
1857
+ : "empty — new sessions will see no long-term context",
1858
+ fix: memContent && memContent.length > 0
1859
+ ? undefined
1860
+ : 'agent-memory write --target long_term --content "…first fact…"',
1861
+ });
1862
+ // qmd
1863
+ const qmdFound = await detectQmd();
1864
+ if (!qmdFound) {
1865
+ rows.push({
1866
+ status: "warn",
1867
+ label: "qmd search index",
1868
+ detail: "not installed — keyword/semantic search unavailable",
1869
+ fix: "bun install -g https://github.com/tobi/qmd",
1870
+ });
1871
+ }
1872
+ else {
1873
+ const hasCollection = await checkCollection();
1874
+ if (!hasCollection) {
1875
+ rows.push({
1876
+ status: "warn",
1877
+ label: "qmd collection",
1878
+ detail: `'${getCollectionName()}' not configured`,
1879
+ fix: "agent-memory setup",
1880
+ });
1881
+ }
1882
+ else {
1883
+ try {
1884
+ await ensureQmdAvailableForSync();
1885
+ }
1886
+ catch {
1887
+ // noop
1888
+ }
1889
+ const health = await getQmdHealth();
1890
+ const files = health?.totalFiles ?? 0;
1891
+ const pending = health?.pendingEmbed ?? 0;
1892
+ rows.push({
1893
+ status: pending > 0 ? "warn" : "ok",
1894
+ label: "qmd search index",
1895
+ detail: `${files} files indexed${pending > 0 ? `, ${pending} pending embeds` : ""}`,
1896
+ fix: pending > 0 ? "agent-memory sync" : undefined,
1897
+ });
1898
+ }
1899
+ }
1900
+ // Skills + hooks per detected host
1901
+ const { homeDir, targets } = detectHookAgents();
1902
+ const detected = targets.filter((target) => target.detected);
1903
+ const hookMode = readHookMode();
1904
+ rows.push({
1905
+ status: "ok",
1906
+ label: "Hook mode",
1907
+ detail: hookMode === "per-turn"
1908
+ ? "per-turn (SessionStart stable snapshot + UserPromptSubmit query-scoped recall)"
1909
+ : "stable (SessionStart-only, full snapshot every session)",
1910
+ });
1911
+ if (detected.length === 0) {
1912
+ rows.push({
1913
+ status: "warn",
1914
+ label: "Agent hosts",
1915
+ detail: "no supported agents detected (Claude Code, Codex, Cursor, opencode)",
1916
+ fix: "install one of the agents first, then: agent-memory install-skills",
1917
+ });
1918
+ }
1919
+ else {
1920
+ rows.push({
1921
+ status: "ok",
1922
+ label: "Agent hosts detected",
1923
+ detail: detected.map((target) => target.label).join(", "),
1924
+ });
1925
+ for (const target of detected) {
1926
+ if (!target.supported) {
1927
+ // Skip skill/hook rows for hosts we don't yet integrate with (e.g. pi has its own extension).
1928
+ continue;
1929
+ }
1930
+ const skillPath = homeDir ? `${target.homeMarker}/skills/agent-memory/SKILL.md` : null;
1931
+ const skillInstalled = skillPath ? fs.existsSync(skillPath) : false;
1932
+ rows.push({
1933
+ status: skillInstalled ? "ok" : "warn",
1934
+ label: `Skill: ${target.label}`,
1935
+ detail: skillInstalled ? "SKILL.md installed" : "SKILL.md missing — agent cannot call memory",
1936
+ fix: skillInstalled ? undefined : "agent-memory install-skills",
1937
+ });
1938
+ const sessionInstalled = homeDir ? isHookInstalled(homeDir, target.key) : false;
1939
+ const supportsPerTurn = target.key === "claude" || target.key === "codex";
1940
+ // opencode only gets a static instructions file (no command-execution hook API in its
1941
+ // plugin surface we could verify) — never report it as a guaranteed-automatic hook.
1942
+ const guaranteedAutomatic = target.key !== "opencode";
1943
+ const promptInstalled = homeDir && supportsPerTurn ? isUserPromptSubmitInstalled(homeDir, target.key) : false;
1944
+ const wantsPerTurn = hookMode === "per-turn" && supportsPerTurn;
1945
+ // Stop backs the write side with a periodic memory-write nudge. Claude
1946
+ // Code only for now, mode-independent — always wanted when supported.
1947
+ const wantsWriteHooks = target.key === "claude";
1948
+ const stopInstalled = homeDir && wantsWriteHooks ? isStopHookInstalled(homeDir, target.key) : false;
1949
+ const writeHooksOk = !wantsWriteHooks || stopInstalled;
1950
+ const ok = sessionInstalled && guaranteedAutomatic && (wantsPerTurn ? promptInstalled : true) && writeHooksOk;
1951
+ let detail;
1952
+ if (!sessionInstalled) {
1953
+ detail = "not installed — no automatic context";
1954
+ }
1955
+ else if (!guaranteedAutomatic) {
1956
+ detail = "static instructions installed — model must run context manually, not guaranteed";
1957
+ }
1958
+ else if (!supportsPerTurn) {
1959
+ detail = "SessionStart hook active";
1960
+ }
1961
+ else if (wantsPerTurn && !promptInstalled) {
1962
+ detail = "SessionStart active, UserPromptSubmit missing — per-turn recall disabled";
1963
+ }
1964
+ else if (wantsPerTurn) {
1965
+ detail = "SessionStart + UserPromptSubmit hooks active";
1966
+ }
1967
+ else {
1968
+ detail = "SessionStart hook active";
1969
+ }
1970
+ if (wantsWriteHooks) {
1971
+ detail += stopInstalled ? "; Stop memory-write nudge active" : "; Stop memory-write nudge missing";
1972
+ }
1973
+ rows.push({
1974
+ status: ok ? "ok" : "warn",
1975
+ label: `Hook: ${target.label}`,
1976
+ detail,
1977
+ fix: ok ? undefined : `agent-memory install-hooks --mode ${hookMode}`,
1978
+ });
1979
+ }
1980
+ }
1981
+ // Pro plugin — emit exactly one row per outcome (bootstrap failure, not installed, or installed).
1982
+ let proBootstrapError;
1983
+ let proStatus;
1984
+ try {
1985
+ const plugin = await createDefaultPluginBootstrap(VERSION).status();
1986
+ proStatus = {
1987
+ installed: Boolean(plugin.bundle),
1988
+ result: plugin.result,
1989
+ entitlement: plugin.entitlement.state,
1990
+ };
1991
+ }
1992
+ catch (error) {
1993
+ proBootstrapError = error instanceof Error ? error.message : String(error);
1994
+ }
1995
+ if (proBootstrapError) {
1996
+ rows.push({
1997
+ status: "fail",
1998
+ label: "AgentMemory Pro",
1999
+ detail: `bootstrap failed: ${proBootstrapError}`,
2000
+ fix: "agent-memory pro install",
2001
+ });
2002
+ }
2003
+ else if (!proStatus?.installed) {
2004
+ rows.push({
2005
+ status: "warn",
2006
+ label: "AgentMemory Pro",
2007
+ detail: "not installed — recall + learn + dashboard unavailable",
2008
+ fix: "agent-memory pro install",
2009
+ });
2010
+ }
2011
+ else {
2012
+ rows.push({
2013
+ status: "ok",
2014
+ label: "AgentMemory Pro",
2015
+ detail: `installed (${proStatus.result}, entitlement=${proStatus.entitlement})`,
2016
+ });
2017
+ }
2018
+ if (json) {
2019
+ output({ rows }, true);
2020
+ if (rows.some((row) => row.status === "fail"))
2021
+ process.exitCode = 1;
2022
+ return;
2023
+ }
2024
+ console.log("");
2025
+ console.log(colorize("AgentMemory diagnostic", "bold"));
2026
+ console.log("");
2027
+ for (const row of rows)
2028
+ printDoctorRow(row);
2029
+ console.log("");
2030
+ const failed = rows.filter((row) => row.status === "fail").length;
2031
+ const warned = rows.filter((row) => row.status === "warn").length;
2032
+ if (failed > 0) {
2033
+ console.log(colorize(`${failed} issue(s) need attention.`, "red"));
2034
+ process.exitCode = 1;
2035
+ }
2036
+ else if (warned > 0) {
2037
+ console.log(colorize(`${warned} optional improvement(s) available.`, "yellow"));
2038
+ }
2039
+ else {
2040
+ console.log(colorize("Everything looks healthy.", "green"));
2041
+ }
2042
+ }
2043
+ async function cmdTutorial(flags) {
2044
+ const json = hasFlag(flags, "json");
2045
+ if (json) {
2046
+ exitError("tutorial is interactive — remove --json to run", true);
2047
+ }
2048
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2049
+ exitError("tutorial requires an interactive terminal", false);
2050
+ }
2051
+ const os = await import("node:os");
2052
+ const originalDir = getMemoryDir();
2053
+ const sandboxDir = fs.mkdtempSync(`${os.tmpdir()}/agent-memory-tutorial-`);
2054
+ _setBaseDir(sandboxDir);
2055
+ try {
2056
+ console.log("");
2057
+ console.log(colorize("AgentMemory Tutorial (3 minutes)", "bold"));
2058
+ console.log(colorize("Everything below runs in a throwaway sandbox — your real memory is untouched.", "dim"));
2059
+ console.log(colorize(`Sandbox: ${sandboxDir}`, "dim"));
2060
+ console.log("");
2061
+ console.log(colorize("Real memory dir stays at:", "dim"), originalDir);
2062
+ console.log("");
2063
+ await promptEnter("Press Enter to begin.");
2064
+ // Step 1: init
2065
+ console.log("");
2066
+ console.log(colorize("Step 1/4 — init", "bold"));
2067
+ console.log("This creates MEMORY.md, SCRATCHPAD.md, daily/ and topics/ in the sandbox.");
2068
+ console.log("");
2069
+ await promptEnter("Ready? Press Enter to run `agent-memory init --yes`.");
2070
+ ensureDirs();
2071
+ console.log(` ${MARK_OK} Sandbox ready at ${sandboxDir}`);
2072
+ // Step 2: save
2073
+ console.log("");
2074
+ console.log(colorize("Step 2/4 — save a memory", "bold"));
2075
+ console.log("`save` appends a line to today's log. Try it:");
2076
+ console.log("");
2077
+ await promptEnter('Press Enter to run `agent-memory save "tutorial: first save"`.');
2078
+ await memoryWrite({ target: "daily", content: "tutorial: first save" });
2079
+ console.log(` ${MARK_OK} Saved. Look inside: ${sandboxDir}/daily/`);
2080
+ // Step 3: note (scratchpad)
2081
+ console.log("");
2082
+ console.log(colorize("Step 3/4 — track a follow-up", "bold"));
2083
+ console.log("`note` adds a persistent checklist item.");
2084
+ console.log("");
2085
+ await promptEnter('Press Enter to run `agent-memory note "tutorial: try recall"`.');
2086
+ // Direct scratchpad manipulation to keep the demo dep-free
2087
+ const spFile = getScratchpadFile();
2088
+ const existing = readFileSafe(spFile) ?? "# Scratchpad\n";
2089
+ fs.writeFileSync(spFile, `${existing}\n<!-- ${nowTimestamp()} [cli] -->\n- [ ] tutorial: try recall\n`);
2090
+ console.log(` ${MARK_OK} Added a scratchpad item you can complete later.`);
2091
+ // Step 4: context
2092
+ console.log("");
2093
+ console.log(colorize("Step 4/4 — inspect what the agent sees", "bold"));
2094
+ console.log("`context` builds the injected context block your agent reads at session start.");
2095
+ console.log("");
2096
+ await promptEnter("Press Enter to preview the context.");
2097
+ const context = buildMemoryContext("");
2098
+ const preview = context.split("\n").slice(0, 20).join("\n");
2099
+ console.log(colorize("--- context preview (first 20 lines) ---", "dim"));
2100
+ console.log(preview);
2101
+ console.log(colorize("--- end preview ---", "dim"));
2102
+ console.log("");
2103
+ console.log(colorize("You're done.", "bold"));
2104
+ console.log("");
2105
+ console.log("In your real setup:");
2106
+ console.log(" agent-memory setup — one-shot install: memory dir, skills, hooks, MCP");
2107
+ console.log(' agent-memory save "your note" — quick save');
2108
+ console.log(' agent-memory note "your todo" — scratchpad item');
2109
+ console.log(" agent-memory doctor — health check");
2110
+ console.log("");
2111
+ if (await promptYesNo("Remove the sandbox directory now?", true)) {
2112
+ try {
2113
+ fs.rmSync(sandboxDir, { recursive: true, force: true });
2114
+ console.log(` ${MARK_OK} Sandbox removed.`);
2115
+ }
2116
+ catch (error) {
2117
+ console.log(colorize(` Could not remove sandbox: ${error instanceof Error ? error.message : String(error)}`, "yellow"));
2118
+ }
2119
+ }
2120
+ else {
2121
+ console.log(` Sandbox preserved: ${sandboxDir}`);
2122
+ }
2123
+ }
2124
+ finally {
2125
+ _setBaseDir(originalDir);
2126
+ }
2127
+ }
2128
+ async function promptEnter(prompt) {
2129
+ const readline = await import("node:readline/promises");
2130
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
2131
+ try {
2132
+ await rl.question(`${colorize(prompt, "cyan")} `);
2133
+ }
2134
+ finally {
2135
+ rl.close();
2136
+ }
2137
+ }
878
2138
  async function cmdDistil(flags) {
879
2139
  const json = hasFlag(flags, "json");
880
2140
  const dryRun = hasFlag(flags, "dry-run");
@@ -910,8 +2170,8 @@ Usage:
910
2170
  agent-memory plugin manage [--no-browser]
911
2171
 
912
2172
  The public core remains fully usable without AgentMemory Pro. Install uses a random
913
- installation identifier and requires no account or email. The free preview includes
914
- 10 recalls and one learning scan per local day; indexing and the Memory Dashboard
2173
+ installation identifier and requires no account or email. The free tier includes
2174
+ 20 recalls and 5 learning scans per local day; indexing and the Memory Dashboard
915
2175
  remain available. Memory and session content stay on this device.`);
916
2176
  }
917
2177
  function pluginCommandFailure(command, error) {
@@ -1026,16 +2286,155 @@ async function printFirstRunProof() {
1026
2286
  // Personalized proof is helpful but must never turn a successful install into a failure.
1027
2287
  }
1028
2288
  }
2289
+ const PRO_PREVIEW_DAILY_SESSION_CAP = 50;
2290
+ const PRO_PREVIEW_DISCOVERY_FILE_CAP = 10_000;
2291
+ function previewSessionRoots() {
2292
+ return {
2293
+ claude: process.env.AGENT_MEMORY_CLAUDE_SESSION_ROOT ?? path.join(os.homedir(), ".claude", "projects"),
2294
+ codex: process.env.AGENT_MEMORY_CODEX_SESSION_ROOT ?? path.join(os.homedir(), ".codex", "sessions"),
2295
+ pi: process.env.AGENT_MEMORY_PI_SESSION_ROOT ?? path.join(os.homedir(), ".pi", "agent", "sessions"),
2296
+ };
2297
+ }
2298
+ function localDayKey(now = new Date()) {
2299
+ const year = now.getFullYear();
2300
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2301
+ const day = String(now.getDate()).padStart(2, "0");
2302
+ return `${year}-${month}-${day}`;
2303
+ }
2304
+ function nextLocalMidnight(now = new Date()) {
2305
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString();
2306
+ }
2307
+ function previewUsagePath() {
2308
+ return path.join(process.env.AGENT_MEMORY_PLUGIN_DIR ?? path.join(os.homedir(), ".agent-memory", "system", "plugins"), "state", "pro-preview-usage.json");
2309
+ }
2310
+ function countSessionFiles(root, cap) {
2311
+ if (!fs.existsSync(root))
2312
+ return 0;
2313
+ let count = 0;
2314
+ const stack = [root];
2315
+ while (stack.length && count < cap) {
2316
+ const directory = stack.pop();
2317
+ let entries;
2318
+ try {
2319
+ entries = fs.readdirSync(directory, { withFileTypes: true });
2320
+ }
2321
+ catch {
2322
+ continue;
2323
+ }
2324
+ for (const entry of entries) {
2325
+ if (entry.name.startsWith("."))
2326
+ continue;
2327
+ const fullPath = path.join(directory, entry.name);
2328
+ if (entry.isDirectory()) {
2329
+ stack.push(fullPath);
2330
+ }
2331
+ else if (entry.isFile()) {
2332
+ count++;
2333
+ if (count >= cap)
2334
+ break;
2335
+ }
2336
+ }
2337
+ }
2338
+ return count;
2339
+ }
2340
+ function reserveProPreviewSessions(discovered) {
2341
+ const now = new Date();
2342
+ const date = localDayKey(now);
2343
+ const filePath = previewUsagePath();
2344
+ let used = 0;
2345
+ try {
2346
+ const existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
2347
+ if (existing.date === date && Number.isSafeInteger(existing.used))
2348
+ used = Math.max(0, Number(existing.used));
2349
+ }
2350
+ catch {
2351
+ // Missing or corrupt preview usage starts a fresh local day window.
2352
+ }
2353
+ const remainingBefore = Math.max(0, PRO_PREVIEW_DAILY_SESSION_CAP - used);
2354
+ const consumed = Math.min(discovered, remainingBefore);
2355
+ const nextUsed = used + consumed;
2356
+ try {
2357
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
2358
+ fs.writeFileSync(filePath, `${JSON.stringify({ schemaVersion: 1, date, used: nextUsed, updatedAt: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
2359
+ }
2360
+ catch {
2361
+ // Preview metering is best-effort and local only; never block the preview.
2362
+ }
2363
+ return {
2364
+ consumed,
2365
+ cap: {
2366
+ limit: PRO_PREVIEW_DAILY_SESSION_CAP,
2367
+ used: nextUsed,
2368
+ remaining: Math.max(0, PRO_PREVIEW_DAILY_SESSION_CAP - nextUsed),
2369
+ resetAt: nextLocalMidnight(now),
2370
+ exhausted: remainingBefore === 0 && discovered > 0,
2371
+ },
2372
+ };
2373
+ }
2374
+ function scanProPreviewSessions() {
2375
+ const roots = previewSessionRoots();
2376
+ const sessions = { claude: 0, codex: 0, pi: 0 };
2377
+ for (const host of Object.keys(roots)) {
2378
+ sessions[host] = countSessionFiles(roots[host], PRO_PREVIEW_DISCOVERY_FILE_CAP);
2379
+ }
2380
+ const discovered = sessions.claude + sessions.codex + sessions.pi;
2381
+ const { cap, consumed } = reserveProPreviewSessions(discovered);
2382
+ const previewed = Math.min(discovered, consumed);
2383
+ return { available: discovered > 0 && previewed > 0, sessions, discovered, previewed, cap };
2384
+ }
2385
+ async function cmdProPreview(flags) {
2386
+ const json = hasFlag(flags, "json");
2387
+ const preview = scanProPreviewSessions();
2388
+ if (json)
2389
+ output(preview, true);
2390
+ else
2391
+ renderProPreview(preview);
2392
+ }
2393
+ function renderProPreview(preview) {
2394
+ if (!preview.discovered) {
2395
+ console.log("No local coding history found yet. Try running a session in Claude Code or Codex first.");
2396
+ console.log("Then re-run: agent-memory pro preview");
2397
+ return;
2398
+ }
2399
+ if (preview.cap.exhausted) {
2400
+ console.log("");
2401
+ console.log(`Daily preview cap reached (${preview.cap.used}/${preview.cap.limit} sessions).`);
2402
+ console.log(`Resets at ${preview.cap.resetAt}.`);
2403
+ console.log("");
2404
+ console.log("Install to make your local history searchable:");
2405
+ console.log(" agent-memory pro install");
2406
+ return;
2407
+ }
2408
+ console.log("");
2409
+ console.log(colorize(` ${preview.previewed.toLocaleString("en-US")} of ${preview.discovered.toLocaleString("en-US")} local sessions previewed (nothing uploaded).`, "bold"));
2410
+ console.log("");
2411
+ console.log(` ${MARK_OK} Claude Code ${preview.sessions.claude.toLocaleString("en-US")}`);
2412
+ console.log(` ${MARK_OK} Codex ${preview.sessions.codex.toLocaleString("en-US")}`);
2413
+ if (preview.sessions.pi > 0) {
2414
+ console.log(` ${MARK_OK} Pi ${preview.sessions.pi.toLocaleString("en-US")}`);
2415
+ }
2416
+ console.log("");
2417
+ console.log(`Daily preview cap: ${preview.cap.used}/${preview.cap.limit} sessions used, ${preview.cap.remaining} remaining.`);
2418
+ console.log("");
2419
+ console.log("Install to make all of it searchable:");
2420
+ console.log(" agent-memory pro install");
2421
+ console.log("");
2422
+ console.log(colorize("No account. No email. Free preview starts now.", "dim"));
2423
+ }
1029
2424
  async function cmdPro(flags, positional) {
1030
2425
  const subcommand = positional[0];
1031
2426
  if (!subcommand) {
1032
2427
  await cmdPlugin(flags, ["list"]);
1033
2428
  return;
1034
2429
  }
2430
+ if (subcommand === "preview") {
2431
+ await cmdProPreview(flags);
2432
+ return;
2433
+ }
1035
2434
  const mapped = subcommand === "upgrade" ? "update" : subcommand;
1036
2435
  if (!["install", "status", "update", "manage"].includes(mapped)) {
1037
2436
  const json = hasFlag(flags, "json");
1038
- const message = `Unknown Pro command: ${subcommand}. Available commands: install, status, upgrade, manage.`;
2437
+ const message = `Unknown Pro command: ${subcommand}. Available commands: install, preview, status, upgrade, manage.`;
1039
2438
  if (json)
1040
2439
  console.log(JSON.stringify({ error: message }));
1041
2440
  else
@@ -1048,64 +2447,446 @@ async function cmdPro(flags, positional) {
1048
2447
  await printFirstRunProof();
1049
2448
  }
1050
2449
  // ---------------------------------------------------------------------------
2450
+ // Upgrade (CLI + Pro plugin bundle)
2451
+ // ---------------------------------------------------------------------------
2452
+ async function readPluginCurrentVersion() {
2453
+ try {
2454
+ const manager = createDefaultPluginBootstrap(VERSION);
2455
+ const status = await manager.status("stable");
2456
+ return status.bundle?.version ?? null;
2457
+ }
2458
+ catch {
2459
+ return null;
2460
+ }
2461
+ }
2462
+ async function resolvePluginLatestHint() {
2463
+ try {
2464
+ const manager = createDefaultPluginBootstrap(VERSION);
2465
+ const status = await manager.status("stable");
2466
+ // `status.result === "update_available"` means the release feed advertises
2467
+ // a newer signed release. `bundle.version` always reflects the *installed*
2468
+ // version, so we cannot report the new version number from this call —
2469
+ // only the fact that an upgrade exists. `checkForUpgrades` treats
2470
+ // `latest == null` + explicit `pluginUpgradeAvailable` accordingly.
2471
+ const updateAvailable = status.result === "update_available";
2472
+ return {
2473
+ latest: updateAvailable ? null : (status.bundle?.version ?? null),
2474
+ updateAvailable,
2475
+ result: status,
2476
+ };
2477
+ }
2478
+ catch {
2479
+ return { latest: null, updateAvailable: false };
2480
+ }
2481
+ }
2482
+ async function cmdUpgrade(flags) {
2483
+ const json = hasFlag(flags, "json");
2484
+ const quiet = hasFlag(flags, "quiet");
2485
+ const checkOnly = hasFlag(flags, "check");
2486
+ const refresh = hasFlag(flags, "refresh");
2487
+ const onlyCli = hasFlag(flags, "cli");
2488
+ const onlyPlugin = hasFlag(flags, "plugin");
2489
+ const targetCli = onlyCli || !onlyPlugin;
2490
+ const targetPlugin = onlyPlugin || !onlyCli;
2491
+ const applyAll = hasFlag(flags, "yes") || !process.stdin.isTTY || !process.stdout.isTTY;
2492
+ const pluginCurrent = await readPluginCurrentVersion();
2493
+ const pluginProbe = targetPlugin ? await resolvePluginLatestHint() : { latest: null, updateAvailable: false };
2494
+ const status = await checkForUpgrades({
2495
+ cliCurrent: VERSION,
2496
+ pluginCurrent,
2497
+ refresh,
2498
+ pluginLatestHint: pluginProbe.latest,
2499
+ pluginUpgradeAvailable: pluginProbe.updateAvailable,
2500
+ });
2501
+ if (checkOnly) {
2502
+ if (json)
2503
+ output(status, true);
2504
+ else if (!quiet)
2505
+ printUpgradeStatus(status, { targetCli, targetPlugin });
2506
+ return;
2507
+ }
2508
+ const cliNeedsUpgrade = targetCli && status.cli.upgradeAvailable;
2509
+ const pluginNeedsUpgrade = targetPlugin && status.plugin.upgradeAvailable;
2510
+ if (!cliNeedsUpgrade && !pluginNeedsUpgrade) {
2511
+ if (json)
2512
+ output({ ...status, action: "noop" }, true);
2513
+ else if (!quiet) {
2514
+ console.log(`agent-memory ${VERSION} is up to date${pluginCurrent ? ` (Pro ${pluginCurrent})` : ""}.`);
2515
+ }
2516
+ return;
2517
+ }
2518
+ if (!applyAll) {
2519
+ if (!quiet)
2520
+ printUpgradeStatus(status, { targetCli, targetPlugin });
2521
+ const confirmed = await promptYesNo("Install available upgrades now?", true);
2522
+ if (!confirmed) {
2523
+ if (json)
2524
+ output({ ...status, action: "aborted" }, true);
2525
+ else
2526
+ console.log("Upgrade cancelled.");
2527
+ return;
2528
+ }
2529
+ }
2530
+ const actions = [];
2531
+ if (cliNeedsUpgrade) {
2532
+ const method = detectInstallMethod();
2533
+ if (!quiet)
2534
+ console.log(`Upgrading CLI via: ${method.command.join(" ")}`);
2535
+ const result = runInstaller(method);
2536
+ actions.push({
2537
+ target: "cli",
2538
+ ok: result.ok,
2539
+ detail: result.ok
2540
+ ? `CLI upgraded to ${status.cli.latest ?? "latest"}`
2541
+ : (result.stderr || result.stdout || `exit ${result.code}`).trim(),
2542
+ });
2543
+ }
2544
+ if (pluginNeedsUpgrade) {
2545
+ if (!quiet)
2546
+ console.log("Upgrading Pro plugin bundle…");
2547
+ const pluginResult = await cmdPlugin({ json: json ? true : "" }, ["update"]);
2548
+ const ok = Boolean(pluginResult?.ok);
2549
+ actions.push({
2550
+ target: "plugin",
2551
+ ok,
2552
+ detail: ok
2553
+ ? `Pro upgraded to ${status.plugin.latest ?? "latest"}`
2554
+ : (pluginResult?.error?.message ?? "plugin update failed"),
2555
+ });
2556
+ }
2557
+ const allOk = actions.every((entry) => entry.ok);
2558
+ if (json)
2559
+ output({ ...status, actions, ok: allOk }, true);
2560
+ else if (!quiet) {
2561
+ for (const entry of actions)
2562
+ console.log(` ${entry.ok ? MARK_OK : MARK_FAIL} ${entry.detail}`);
2563
+ }
2564
+ if (!allOk)
2565
+ process.exitCode = 1;
2566
+ }
2567
+ function printUpgradeStatus(status, scope) {
2568
+ const lines = [];
2569
+ if (scope.targetCli) {
2570
+ const marker = status.cli.upgradeAvailable ? MARK_WARN : MARK_OK;
2571
+ const target = status.cli.latest ?? "unknown";
2572
+ lines.push(` ${marker} CLI ${status.cli.current} → ${target}${status.cli.upgradeAvailable ? " (upgrade available)" : ""}`);
2573
+ }
2574
+ if (scope.targetPlugin) {
2575
+ const current = status.plugin.current ?? "not installed";
2576
+ const target = status.plugin.latest ?? "unknown";
2577
+ const marker = status.plugin.upgradeAvailable ? MARK_WARN : MARK_OK;
2578
+ lines.push(` ${marker} Pro ${current} → ${target}${status.plugin.upgradeAvailable ? " (upgrade available)" : ""}`);
2579
+ }
2580
+ console.log(`Checked ${status.checkedAt}${status.fromCache ? " (cached)" : ""}`);
2581
+ for (const line of lines)
2582
+ console.log(line);
2583
+ }
2584
+ /**
2585
+ * Register the `agent-memory` MCP server across every supported local harness.
2586
+ * Idempotent: existing entries are preserved, missing agent dirs are reported
2587
+ * as `not-installed` rather than treated as an error. Returns a structured
2588
+ * result so both `serve --register` and `setup` can format it their own way.
2589
+ */
2590
+ function registerMcpInAgents(only) {
2591
+ const home = os.homedir();
2592
+ const mcpEntry = { type: "stdio", command: "agent-memory", args: ["serve", "--mcp"] };
2593
+ const want = (key) => !only || only.has(key);
2594
+ const results = [];
2595
+ const registerJson = (key, displayName, detectPaths, configFile) => {
2596
+ const p = path.join(home, configFile);
2597
+ const detected = detectPaths.some((rel) => fs.existsSync(path.join(home, rel)));
2598
+ if (!detected) {
2599
+ results.push({ key, displayName, path: p, status: "not-installed" });
2600
+ return;
2601
+ }
2602
+ let s = {};
2603
+ try {
2604
+ s = JSON.parse(fs.readFileSync(p, "utf8"));
2605
+ }
2606
+ catch { }
2607
+ const servers = (s.mcpServers ?? {});
2608
+ if (servers["agent-memory"]) {
2609
+ results.push({ key, displayName, path: p, status: "already" });
2610
+ return;
2611
+ }
2612
+ servers["agent-memory"] = mcpEntry;
2613
+ s.mcpServers = servers;
2614
+ fs.mkdirSync(path.dirname(p), { recursive: true });
2615
+ fs.writeFileSync(p, `${JSON.stringify(s, null, 2)}\n`);
2616
+ results.push({ key, displayName, path: p, status: "registered" });
2617
+ };
2618
+ // Claude Code stores user-level MCP servers in ~/.claude.json (top-level
2619
+ // `mcpServers` key), NOT in ~/.claude/settings.json. The settings file
2620
+ // holds hooks/permissions/env — Claude Code does not read MCP config from
2621
+ // there. Detect either the `.claude/` dir or `.claude.json` file so we
2622
+ // register whenever the user has Claude Code installed at all.
2623
+ if (want("claude"))
2624
+ registerJson("claude", "Claude Code", [".claude", ".claude.json"], ".claude.json");
2625
+ if (want("cursor"))
2626
+ registerJson("cursor", "Cursor", [".cursor"], ".cursor/mcp.json");
2627
+ if (want("windsurf"))
2628
+ registerJson("windsurf", "Windsurf", [".windsurf"], ".windsurf/mcp_settings.json");
2629
+ if (want("codex")) {
2630
+ const p = path.join(home, ".codex", "config.toml");
2631
+ if (!fs.existsSync(p)) {
2632
+ results.push({ key: "codex", displayName: "Codex", path: p, status: "not-installed" });
2633
+ }
2634
+ else {
2635
+ const existing = fs.readFileSync(p, "utf8");
2636
+ if (existing.includes("[mcp_servers.agent-memory]")) {
2637
+ results.push({ key: "codex", displayName: "Codex", path: p, status: "already" });
2638
+ }
2639
+ else {
2640
+ const block = '\n[mcp_servers.agent-memory]\ncommand = "agent-memory"\nargs = ["serve", "--mcp"]\n';
2641
+ fs.writeFileSync(p, existing.trimEnd() + block, "utf8");
2642
+ results.push({ key: "codex", displayName: "Codex", path: p, status: "registered" });
2643
+ }
2644
+ }
2645
+ }
2646
+ return results;
2647
+ }
2648
+ async function cmdServe(flags) {
2649
+ const isMcp = hasFlag(flags, "mcp");
2650
+ const isRegister = hasFlag(flags, "register");
2651
+ if (isRegister) {
2652
+ const only = getFlag(flags, "only");
2653
+ const onlySet = only ? new Set(only.split(",").map((s) => s.trim())) : null;
2654
+ const results = registerMcpInAgents(onlySet);
2655
+ const shown = onlySet ? results : results.filter((r) => r.status !== "not-installed");
2656
+ for (const r of shown) {
2657
+ if (r.status === "already")
2658
+ console.log(` ${r.displayName}: already registered`);
2659
+ else if (r.status === "registered")
2660
+ console.log(` ${r.displayName}: registered (${r.path})`);
2661
+ else
2662
+ console.log(` ${r.displayName}: not installed`);
2663
+ }
2664
+ const anyRegistered = results.some((r) => r.status === "registered");
2665
+ const anyDetected = results.some((r) => r.status !== "not-installed");
2666
+ if (anyRegistered) {
2667
+ console.log("");
2668
+ console.log("Restart the agent(s) above for the change to take effect.");
2669
+ }
2670
+ else if (!onlySet && !anyDetected) {
2671
+ console.log("No supported agents detected (Claude Code, Cursor, Windsurf, Codex).");
2672
+ console.log("Use --only <agent> to force: --only claude,cursor,windsurf,codex");
2673
+ }
2674
+ return;
2675
+ }
2676
+ if (!isMcp) {
2677
+ console.log("Usage: agent-memory serve --mcp");
2678
+ console.log(" agent-memory serve --register (register in all detected agents)");
2679
+ console.log(" agent-memory serve --register --only claude,cursor,windsurf,codex");
2680
+ console.log("");
2681
+ console.log("Supported agents: Claude Code, Cursor, Windsurf, Codex");
2682
+ console.log("Tip: run --register once, then restart the agent(s).");
2683
+ return;
2684
+ }
2685
+ const server = new StdioMcpServer(VERSION);
2686
+ // Core tools: free tier, available without Pro.
2687
+ server.addTool({
2688
+ name: "memory_read",
2689
+ description: "Read the current long-term memory (MEMORY.md) and scratchpad checklist.",
2690
+ inputSchema: { type: "object", properties: {} },
2691
+ }, async () => {
2692
+ const memFile = getMemoryFile();
2693
+ const scratchFile = getScratchpadFile();
2694
+ const memory = redactSecrets(readFileSafe(memFile) ?? "").content || "(empty)";
2695
+ const scratchRaw = readFileSafe(scratchFile) ?? "";
2696
+ const scratchItems = parseScratchpad(scratchRaw)
2697
+ .filter((item) => !item.done)
2698
+ .map((item) => `- [ ] ${redactSecrets(item.text).content}`);
2699
+ return [
2700
+ "## Long-term memory (MEMORY.md)",
2701
+ memory,
2702
+ "",
2703
+ "## Open scratchpad items",
2704
+ scratchItems.length ? scratchItems.join("\n") : "(none)",
2705
+ ].join("\n");
2706
+ });
2707
+ server.addTool({
2708
+ name: "memory_write",
2709
+ description: "Append a new entry to memory. Use target='daily' for session notes, 'long_term' for durable facts.",
2710
+ inputSchema: {
2711
+ type: "object",
2712
+ properties: {
2713
+ content: { type: "string", description: "Text to store" },
2714
+ target: { type: "string", description: "Where to write", enum: ["daily", "long_term", "scratchpad"] },
2715
+ },
2716
+ required: ["content"],
2717
+ },
2718
+ }, async (input) => {
2719
+ const content = typeof input.content === "string" ? input.content.trim() : "";
2720
+ const target = typeof input.target === "string" ? input.target : "daily";
2721
+ if (!content)
2722
+ return "Error: content is required";
2723
+ if (target === "scratchpad") {
2724
+ const result = await scratchpadAction({ action: "add", text: content, sessionId: "mcp-serve" });
2725
+ return result.text;
2726
+ }
2727
+ await memoryWrite({ target: target, content, sessionId: "mcp-serve" });
2728
+ return `Written to ${target} memory.`;
2729
+ });
2730
+ // Load Pro plugin and let it register additional MCP tools + startup hooks.
2731
+ const runtime = new InstalledPluginRuntimeV1({ coreVersion: VERSION });
2732
+ try {
2733
+ await runtime.load();
2734
+ for (const tool of runtime.getMcpTools()) {
2735
+ server.addTool({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, (input) => tool.run(input));
2736
+ }
2737
+ server.addStartupHook(() => runtime.runMcpStartup());
2738
+ }
2739
+ catch {
2740
+ // Pro not installed or failed to load — serve with core tools only.
2741
+ }
2742
+ await server.start();
2743
+ }
2744
+ // ---------------------------------------------------------------------------
1051
2745
  // Usage
1052
2746
  // ---------------------------------------------------------------------------
1053
2747
  function printUsage() {
1054
- const commandWidth = Math.max(...COMMANDS.map((command) => command.length));
1055
- const commandList = COMMANDS.map((command) => ` ${command.padEnd(commandWidth)} ${COMMAND_DESCRIPTIONS[command]}`).join("\n");
2748
+ const groups = [
2749
+ ["Do things", ["save", "note", "recall", "search"]],
2750
+ ["See things", ["status", "doctor", "dashboard"]],
2751
+ ["Advanced", ["write", "read", "context", "scratchpad", "distil", "sync"]],
2752
+ ["Setup", ["setup", "install-skills", "install-hooks", "completion"]],
2753
+ ["Pro", ["pro", "learn"]],
2754
+ ];
2755
+ const knownGroupCommands = new Set(groups.flatMap(([, list]) => list));
2756
+ const otherCommands = COMMANDS.filter((command) => !knownGroupCommands.has(command));
2757
+ if (otherCommands.length > 0)
2758
+ groups.push(["Other", [...otherCommands]]);
2759
+ const allCommands = groups.flatMap(([, list]) => list);
2760
+ const width = Math.max(...allCommands.map((command) => command.length));
2761
+ const sections = groups
2762
+ .map(([title, list]) => {
2763
+ const rows = list
2764
+ .filter((command) => COMMAND_DESCRIPTIONS[command])
2765
+ .map((command) => {
2766
+ const description = COMMAND_DESCRIPTIONS[command];
2767
+ return ` ${command.padEnd(width)} ${description}`;
2768
+ })
2769
+ .join("\n");
2770
+ return `${title}:\n${rows}`;
2771
+ })
2772
+ .join("\n\n");
1056
2773
  console.log(`agent-memory — persistent memory for coding agents
1057
2774
 
1058
2775
  Usage:
1059
2776
  agent-memory <command> [options]
1060
2777
 
1061
- Commands:
1062
- ${commandList}
2778
+ ${sections}
1063
2779
 
1064
2780
  Global flags:
1065
2781
  --dir <path> Override memory directory
1066
2782
  --json Machine-readable JSON output
1067
2783
 
1068
- Examples:
1069
- agent-memory init
1070
- agent-memory write --content "Fixed auth bug in login flow"
1071
- agent-memory write --target long_term --content "User prefers dark mode" --source-uri "session://agent/turn/12"
1072
- agent-memory write --target topic --topic "auth" --content "Rolled JWT refresh to edge"
1073
- agent-memory read --target long_term
1074
- agent-memory read --target daily --date 2026-02-15
1075
- agent-memory read --target list
1076
- agent-memory read --target topic --topic "auth"
1077
- agent-memory read --target topics
1078
- agent-memory scratchpad add --text "Review PR #42"
1079
- agent-memory scratchpad list
1080
- agent-memory scratchpad done --text "PR #42"
1081
- agent-memory search --query "database choice" --mode keyword
1082
- agent-memory distil --dry-run
1083
- agent-memory context --query "database choice"
1084
- agent-memory sync
1085
- agent-memory status --json
1086
- agent-memory completion zsh
1087
- agent-memory install-hooks --yes
1088
- agent-memory pro status
1089
- agent-memory pro install
1090
- agent-memory recall "what did we decide about authentication?"
1091
- agent-memory dashboard`);
2784
+ Get started:
2785
+ agent-memory setup — one-shot idempotent installer (recommended)
2786
+ agent-memory save "your note" quick save to today's log
2787
+ agent-memory recall "what did we decide about X?" search prior sessions
2788
+ agent-memory status — one-page health readout
2789
+
2790
+ New here? Run: ${colorize("agent-memory setup", "cyan")}`);
1092
2791
  }
1093
2792
  // ---------------------------------------------------------------------------
1094
2793
  // Main
1095
2794
  // ---------------------------------------------------------------------------
2795
+ // Pre-parse content-taking aliases (`save`, `note`) before the general flag parser
2796
+ // consumes tokens that were meant to be literal user text. Extracts only known
2797
+ // infrastructure flags — `--json`, `--dir <path>`, and `--target <val>` for `save` —
2798
+ // and treats the rest of argv as the content string.
2799
+ function extractRawContentAlias(argv) {
2800
+ if (argv.length === 0)
2801
+ return null;
2802
+ const command = argv[0];
2803
+ if (command !== "save" && command !== "note")
2804
+ return null;
2805
+ // Never treat reserved flags as content — they must fall through to the normal
2806
+ // parser so `save --help` renders help and `save --json` sets JSON mode without
2807
+ // accidentally saving "--help" or "--json" as a daily-log entry.
2808
+ if (argv.slice(1).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) {
2809
+ return null;
2810
+ }
2811
+ const flags = {};
2812
+ const contentParts = [];
2813
+ const validValueFlags = new Set(["--dir", "--target"]);
2814
+ for (let i = 1; i < argv.length; i++) {
2815
+ const arg = argv[i];
2816
+ if (arg === "--json") {
2817
+ flags.json = true;
2818
+ continue;
2819
+ }
2820
+ if (validValueFlags.has(arg) && i + 1 < argv.length) {
2821
+ flags[arg.slice(2)] = argv[i + 1];
2822
+ i++;
2823
+ continue;
2824
+ }
2825
+ contentParts.push(arg);
2826
+ }
2827
+ return { command, content: contentParts.join(" ").trim(), flags };
2828
+ }
1096
2829
  async function main() {
1097
- const { command, flags, positional } = parseArgs(process.argv.slice(2));
2830
+ const rawArgv = process.argv.slice(2);
2831
+ const rawAlias = extractRawContentAlias(rawArgv);
2832
+ const { command, flags, positional } = rawAlias
2833
+ ? { command: rawAlias.command, flags: rawAlias.flags, positional: [rawAlias.content].filter(Boolean) }
2834
+ : parseArgs(rawArgv);
1098
2835
  const json = hasFlag(flags, "json");
2836
+ // Reject unknown commands / flags with a helpful "did you mean" hint.
2837
+ // Plugin-provided commands fall through and are validated by the runtime.
2838
+ validateCommand(command);
2839
+ validateFlags(command, positional, flags);
1099
2840
  // Apply --dir override
1100
2841
  const dir = getFlag(flags, "dir");
1101
2842
  if (dir) {
1102
2843
  _setBaseDir(dir);
1103
2844
  }
2845
+ // First-run detection: nudge new users toward `init` before they run anything
2846
+ // destructive/creative. Gated on the memory *directory* (not MEMORY.md) so a
2847
+ // user who intentionally cleared their memory files doesn't get the wizard on
2848
+ // every subsequent command. TTY-only; skip introspection commands.
2849
+ if (process.stdin.isTTY &&
2850
+ process.stdout.isTTY &&
2851
+ !json &&
2852
+ command &&
2853
+ !["init", "help", "version", "doctor", "status", "completion", "hook", "serve"].includes(command) &&
2854
+ !fs.existsSync(getMemoryDir())) {
2855
+ console.log(colorize("It looks like this is your first run — no memory directory yet.", "yellow"));
2856
+ if (await promptYesNo("Run agent-memory init to set things up?", true)) {
2857
+ await cmdInit({});
2858
+ console.log("");
2859
+ console.log(colorize(`Now continuing with: agent-memory ${command}`, "dim"));
2860
+ console.log("");
2861
+ }
2862
+ }
1104
2863
  if (command === "version" || hasFlag(flags, "version")) {
1105
2864
  output(json ? { version: VERSION } : VERSION, json);
1106
2865
  return;
1107
2866
  }
1108
- if (!command || command === "help" || (hasFlag(flags, "help") && command !== "plugin")) {
2867
+ // Per-command help: `agent-memory <cmd> --help` shows only that command's usage,
2868
+ // flags, and examples. Falls back to the top-level overview when no command was
2869
+ // specified. Plugin subcommands render their own help via the plugin runtime.
2870
+ if (command === "help") {
2871
+ const target = positional[0];
2872
+ if (target) {
2873
+ console.log(renderCommandHelp(target));
2874
+ }
2875
+ else {
2876
+ printUsage();
2877
+ }
2878
+ return;
2879
+ }
2880
+ if (hasFlag(flags, "help") && command !== "plugin") {
2881
+ if (!command) {
2882
+ printUsage();
2883
+ }
2884
+ else {
2885
+ console.log(renderCommandHelp(command));
2886
+ }
2887
+ return;
2888
+ }
2889
+ if (!command) {
1109
2890
  printUsage();
1110
2891
  return;
1111
2892
  }
@@ -1113,9 +2894,28 @@ async function main() {
1113
2894
  case "context":
1114
2895
  await cmdContext(flags);
1115
2896
  break;
1116
- case "write":
1117
- await cmdWrite(flags);
2897
+ case "write": {
2898
+ // Accept content as the first positional so `write "text"` mirrors
2899
+ // `save "text"` / `note "text"`. `--content` still works for scripts
2900
+ // and existing callers.
2901
+ const positionalContent = positional[0];
2902
+ await cmdWrite(positionalContent && !getFlag(flags, "content") ? { ...flags, content: positionalContent } : flags);
2903
+ break;
2904
+ }
2905
+ case "save": {
2906
+ const text = positional[0] ?? "";
2907
+ if (!text)
2908
+ exitError('save requires content: agent-memory save "your note here"', json);
2909
+ await cmdWrite({ ...flags, content: text, target: getFlag(flags, "target") ?? "daily" });
2910
+ break;
2911
+ }
2912
+ case "note": {
2913
+ const text = positional[0] ?? "";
2914
+ if (!text)
2915
+ exitError('note requires text: agent-memory note "your item"', json);
2916
+ await cmdScratchpad({ ...flags, text }, ["add"]);
1118
2917
  break;
2918
+ }
1119
2919
  case "read":
1120
2920
  await cmdRead(flags);
1121
2921
  break;
@@ -1141,9 +2941,18 @@ async function main() {
1141
2941
  case "init":
1142
2942
  await cmdInit(flags);
1143
2943
  break;
2944
+ case "setup":
2945
+ await cmdSetup(flags);
2946
+ break;
1144
2947
  case "status":
1145
2948
  await cmdStatus(flags);
1146
2949
  break;
2950
+ case "doctor":
2951
+ await cmdDoctor(flags);
2952
+ break;
2953
+ case "tutorial":
2954
+ await cmdTutorial(flags);
2955
+ break;
1147
2956
  case "completion":
1148
2957
  cmdCompletion(flags, positional);
1149
2958
  break;
@@ -1154,23 +2963,70 @@ async function main() {
1154
2963
  cmdUninstallHooks(flags);
1155
2964
  break;
1156
2965
  case "hook": {
1157
- if (positional[0] !== "session-start")
1158
- exitError("hook requires 'session-start'", json);
2966
+ const sub = positional[0];
2967
+ if (sub !== "session-start" && sub !== "user-prompt-submit" && sub !== "stop") {
2968
+ exitError("hook requires 'session-start', 'user-prompt-submit', or 'stop'", json);
2969
+ }
1159
2970
  const agent = getFlag(flags, "agent");
1160
2971
  if (!agent)
1161
- exitError("hook session-start requires --agent", json);
1162
- await cmdContext({ "no-search": true });
2972
+ exitError(`hook ${sub} requires --agent`, json);
2973
+ if (sub === "user-prompt-submit") {
2974
+ await cmdUserPromptSubmit(flags);
2975
+ break;
2976
+ }
2977
+ if (sub === "stop") {
2978
+ await cmdStop(flags);
2979
+ break;
2980
+ }
2981
+ // In per-turn mode, SessionStart emits only the stable layer (MEMORY.md + scratchpad).
2982
+ // The dynamic layer (daily logs + search) is emitted per-turn by UserPromptSubmit.
2983
+ // In stable mode, SessionStart emits the full context (current behavior).
2984
+ const layer = readHookMode() === "per-turn" ? "stable" : undefined;
2985
+ await cmdContext(layer ? { "no-search": true, layer } : { "no-search": true });
2986
+ try {
2987
+ const cache = readUpgradeCache();
2988
+ if (cache) {
2989
+ const status = await checkForUpgrades({
2990
+ cliCurrent: VERSION,
2991
+ pluginCurrent: cache.pluginCurrent,
2992
+ cacheOnly: true,
2993
+ });
2994
+ const notice = formatUpgradeNotice(status);
2995
+ if (notice)
2996
+ console.error(notice);
2997
+ }
2998
+ if (!isCacheFresh(cache) && !process.env.AGENT_MEMORY_UPGRADE_BACKGROUND) {
2999
+ refreshUpgradeCacheBackground();
3000
+ }
3001
+ }
3002
+ catch {
3003
+ // Passive upgrade notice must never break session start.
3004
+ }
1163
3005
  try {
1164
3006
  const decision = await new InstalledPluginRuntimeV1({ coreVersion: VERSION }).runSessionStart({
1165
3007
  host: agent,
1166
3008
  cwd: process.cwd(),
1167
3009
  signal: new AbortController().signal,
1168
3010
  });
1169
- if (decision?.state === "exhausted")
1170
- console.error(`AgentMemory free session allowance resets at ${decision.resetAt}`);
3011
+ if (decision) {
3012
+ cacheProUsage({
3013
+ used: decision.used,
3014
+ limit: decision.limit,
3015
+ remaining: decision.remaining,
3016
+ resetAt: decision.resetAt,
3017
+ state: decision.state,
3018
+ capability: "session",
3019
+ });
3020
+ if (decision.state === "exhausted") {
3021
+ console.error(`AgentMemory free session allowance resets in ${formatResetTime(decision.resetAt)}. Upgrade: ${UPGRADE_URL}`);
3022
+ }
3023
+ }
1171
3024
  }
1172
- catch {
1173
- // Paid SessionStart work must never make public-core context unavailable.
3025
+ catch (error) {
3026
+ // Paid SessionStart work must never make public-core context unavailable,
3027
+ // but a completely silent failure hides broken installs from users and `doctor`.
3028
+ const message = error instanceof Error ? error.message : String(error);
3029
+ console.error(`AgentMemory Pro session hook failed: ${message}`);
1174
3030
  }
1175
3031
  break;
1176
3032
  }
@@ -1180,12 +3036,35 @@ async function main() {
1180
3036
  case "pro":
1181
3037
  await cmdPro(flags, positional);
1182
3038
  break;
3039
+ case "upgrade":
3040
+ await cmdUpgrade(flags);
3041
+ break;
3042
+ case "serve":
3043
+ await cmdServe(flags);
3044
+ break;
1183
3045
  default: {
1184
3046
  const controller = new AbortController();
1185
3047
  const abort = () => controller.abort();
1186
3048
  process.once("SIGINT", abort);
1187
3049
  try {
1188
3050
  const pluginCommand = command === "dashboard" ? "web" : command;
3051
+ // Dev-only: AGENT_MEMORY_FORCE_QUOTA_EXHAUSTED=recall (or "learn") injects an
3052
+ // exhaustion result so the upgrade-prompt flow can be tested locally without
3053
+ // burning through real server quota.
3054
+ const forceExhausted = process.env.AGENT_MEMORY_FORCE_QUOTA_EXHAUSTED;
3055
+ if (!json && forceExhausted && forceExhausted.split(",").includes(command)) {
3056
+ const resetAt = new Date(Date.now() + 18 * 3_600_000).toISOString();
3057
+ const forced = {
3058
+ code: "quota_exceeded",
3059
+ used: 20,
3060
+ limit: 20,
3061
+ remaining: 0,
3062
+ resetAt,
3063
+ };
3064
+ cacheProUsage({ used: 20, limit: 20, remaining: 0, resetAt, state: "exhausted", capability: command });
3065
+ printCapExhaustedBox(command, forced);
3066
+ process.exit(1);
3067
+ }
1189
3068
  const result = await new InstalledPluginRuntimeV1({ coreVersion: VERSION }).run(pluginCommand, {
1190
3069
  args: positional,
1191
3070
  flags,
@@ -1193,8 +3072,22 @@ async function main() {
1193
3072
  });
1194
3073
  if (!result)
1195
3074
  exitError(`Unknown command: ${command}. Run 'agent-memory help' for usage.`, json);
1196
- if (!result.ok)
3075
+ if (!result.ok) {
3076
+ const capInfo = detectCapExhausted(result);
3077
+ if (capInfo && !json) {
3078
+ cacheProUsage({
3079
+ used: capInfo.used,
3080
+ limit: capInfo.limit,
3081
+ remaining: capInfo.remaining ?? 0,
3082
+ resetAt: capInfo.resetAt,
3083
+ state: "exhausted",
3084
+ capability: command,
3085
+ });
3086
+ printCapExhaustedBox(command, capInfo);
3087
+ process.exit(1);
3088
+ }
1197
3089
  exitError(result.error?.message ?? `Plugin command ${pluginCommand} failed`, json);
3090
+ }
1198
3091
  output(result.data ?? { ok: true }, json);
1199
3092
  }
1200
3093
  finally {