myagentmemory 0.4.17 → 0.5.2

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