agent.libx.js 0.92.9 → 0.93.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1974,34 +1974,61 @@ async function loadCommands(fs, dir, opts = {}) {
1974
1974
  // src/memory.ts
1975
1975
  init_tools_structured();
1976
1976
  var MAX_INDEX = 25;
1977
+ var MAX_INDEX_LINES = 200;
1978
+ var MAX_INDEX_BYTES = 25e3;
1979
+ var MEMORY_PROMPT = '## Memory guidelines\n**When to Remember:** user corrections ("no, do X instead"), preferences ("I prefer Y"), project constraints, recurring gotchas, role/context ("I\'m a data scientist"), workflow patterns confirmed by the user. Capture the WHY, not just the what.\n**When NOT to Remember:** task-specific scratch, transient state, things derivable from code/git, conversation filler, anything already in instruction files. If removing the memory wouldn\'t change future behavior, don\'t save it.\n**Types** (pass to Remember \u2014 this determines where the memory is stored):\n- `user` \u2014 role, preferences, knowledge level, collaboration style (GLOBAL \u2014 follows you across projects)\n- `feedback` \u2014 corrections, what to avoid/repeat, with WHY (GLOBAL)\n- `project` \u2014 ongoing work, goals, constraints, deadlines (LOCAL to this project; convert relative dates to absolute)\n- `reference` \u2014 pointers to external resources, URLs, vendor docs (LOCAL to this project)\n**Before acting on a recalled memory:** verify it\'s still true \u2014 a memory naming a file/function/flag is a claim from when it was written. Grep/read to confirm before recommending. Stale memory \u2192 update via Remember (same slug overwrites).\n**Dedup:** before writing, check if a similar memory exists (Recall or MemorySearch). Update the existing one rather than creating duplicates.\nCall `Recall` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.\nCall `MemorySearch` with a query to find memories by content when you don\'t know the slug.\nCall `Remember` to persist a durable fact for future sessions.';
1980
+ var VOICE_MEMORY_PROMPT = `You have Remember and Recall tools \u2014 use them directly, no delegation needed.
1981
+ IMPLICIT CAPTURE: when the user shares their name, role, a preference, a correction ("no, do X instead"), or a project constraint \u2014 call Remember immediately without announcing it. Natural memory, not a ceremony.
1982
+ For explicit "remember X" requests, also call Remember directly and confirm briefly ("got it").
1983
+ Do NOT remember: transient task details, conversation filler, things you'd forget in a real conversation.
1984
+ Keep it invisible: never announce "saving to memory" or list what you remembered unless asked.
1985
+ For anything requiring files, shell, or web \u2014 still Delegate.`;
1977
1986
  async function loadMemory(fs, dir, opts = {}) {
1978
- const indexPath = `${dir}/MEMORY.md`;
1979
- const tools = [recallTool(fs, dir), rememberTool(fs, dir), memorySearchTool(fs, dir)];
1980
- const md = await fs.exists(indexPath) ? (await fs.readFile(indexPath)).trim() : "";
1981
- if (!md) return { index: "", tools };
1982
- const lines = md.split("\n");
1983
- const pointers = lines.filter((l) => /^\s*-\s*\[.+\]\(.+\.md\)/.test(l));
1984
- const header = lines.filter((l) => !/^\s*-\s*\[.+\]\(.+\.md\)/.test(l)).join("\n").trim();
1985
- const { kept, rest } = topByRelevance(pointers, opts.relevanceHint ?? "", (l) => l, opts.max ?? MAX_INDEX);
1987
+ const dirs = (Array.isArray(dir) ? dir : [dir]).filter(Boolean);
1988
+ const writeDir = dirs[0];
1989
+ const tools = [recallTool(fs, dirs), rememberTool(fs, writeDir, opts), memorySearchTool(fs, dirs)];
1990
+ const allPointers = [];
1991
+ const seenSlugs = /* @__PURE__ */ new Set();
1992
+ let header = "";
1993
+ let hasContent = false;
1994
+ for (const d of dirs) {
1995
+ const indexPath = `${d}/MEMORY.md`;
1996
+ const md = await fs.exists(indexPath) ? (await fs.readFile(indexPath)).trim() : "";
1997
+ if (!md) continue;
1998
+ hasContent = true;
1999
+ const lines = md.split("\n");
2000
+ if (!header) header = lines.filter((l) => !/^\s*-\s*\[.+\]\(.+\.md\)/.test(l)).join("\n").trim();
2001
+ for (const l of lines.filter((l2) => /^\s*-\s*\[.+\]\(.+\.md\)/.test(l2))) {
2002
+ const slug = l.match(/\]\(([^)]+)\.md\)/)?.[1];
2003
+ if (slug && !seenSlugs.has(slug)) {
2004
+ seenSlugs.add(slug);
2005
+ allPointers.push(l);
2006
+ }
2007
+ }
2008
+ }
2009
+ if (!hasContent) return { index: MEMORY_PROMPT, tools };
2010
+ const { kept, rest } = topByRelevance(allPointers, opts.relevanceHint ?? "", (l) => l, opts.max ?? MAX_INDEX);
1986
2011
  const restSlugs = rest.map((l) => l.match(/\]\(([^)]+)\.md\)/)?.[1] ?? l.match(/\[([^\]]+)\]/)?.[1] ?? "").filter(Boolean);
1987
- const index = "## Memory (persistent context \u2014 recalled across sessions)\n" + (header ? header + "\n" : "") + kept.join("\n") + (restSlugs.length ? `
1988
- - (${restSlugs.length} more learnings, slugs only \u2014 call \`Recall\` if relevant): ${restSlugs.join(", ")}` : "") + `
1989
-
1990
- These are pointers only. Call \`Recall\` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.
1991
- Call \`MemorySearch\` with a query to find memories by content when you don't know the slug.
1992
- Call \`Remember\` to persist a durable fact for future sessions.`;
2012
+ const index = MEMORY_PROMPT + "\n\n## Memory index (persistent context \u2014 recalled across sessions)\n" + (header ? header + "\n" : "") + kept.join("\n") + (restSlugs.length ? `
2013
+ - (${restSlugs.length} more learnings, slugs only \u2014 call \`Recall\` if relevant): ${restSlugs.join(", ")}` : "");
1993
2014
  return { index, tools };
1994
2015
  }
1995
2016
  function slugify(s, fallback = "note") {
1996
2017
  const base = String(s ?? "").trim().toLowerCase().replace(/\.md$/i, "").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
1997
2018
  return base || fallback;
1998
2019
  }
1999
- async function writeFact(fs, dir, slug, body) {
2020
+ async function writeFact(fs, dir, slug, body, opts) {
2000
2021
  await mkdirp(fs, dir);
2001
- await fs.writeFile(`${dir}/${slug}.md`, body.endsWith("\n") ? body : body + "\n");
2022
+ const content = opts?.type ? `---
2023
+ type: ${opts.type}
2024
+ ---
2025
+
2026
+ ${body}` : body;
2027
+ await fs.writeFile(`${dir}/${slug}.md`, content.endsWith("\n") ? content : content + "\n");
2002
2028
  const indexPath = `${dir}/MEMORY.md`;
2003
2029
  const idx = await fs.exists(indexPath) ? await fs.readFile(indexPath) : "# Memory Index\n";
2004
- const line = `- [${slug}](${slug}.md) \u2014 ${body.split("\n")[0].slice(0, 80)}`;
2030
+ const summary = opts?.description || body.split("\n")[0].slice(0, 80);
2031
+ const line = `- [${slug}](${slug}.md) \u2014 ${summary}`;
2005
2032
  const lines = idx.split("\n");
2006
2033
  const at = lines.findIndex((l) => l.includes(`(${slug}.md)`));
2007
2034
  if (at >= 0) {
@@ -2014,6 +2041,24 @@ async function writeFact(fs, dir, slug, body) {
2014
2041
  ${line}
2015
2042
  `);
2016
2043
  }
2044
+ await truncateIndex(fs, indexPath);
2045
+ }
2046
+ async function truncateIndex(fs, path) {
2047
+ const raw = await fs.readFile(path);
2048
+ if (raw.length <= MAX_INDEX_BYTES) {
2049
+ const count = raw.split("\n").filter((l) => /^\s*-\s*\[.+\]\(.+\.md\)/.test(l)).length;
2050
+ if (count <= MAX_INDEX_LINES) return;
2051
+ }
2052
+ const lines = raw.split("\n");
2053
+ const pointerIdxs = [];
2054
+ for (let i = 0; i < lines.length; i++) if (/^\s*-\s*\[.+\]\(.+\.md\)/.test(lines[i])) pointerIdxs.push(i);
2055
+ const drop = /* @__PURE__ */ new Set();
2056
+ for (const pi of pointerIdxs) {
2057
+ const candidate = lines.filter((_, i) => !drop.has(i)).join("\n");
2058
+ if (candidate.length <= MAX_INDEX_BYTES && pointerIdxs.length - drop.size <= MAX_INDEX_LINES) break;
2059
+ drop.add(pi);
2060
+ }
2061
+ if (drop.size) await fs.writeFile(path, lines.filter((_, i) => !drop.has(i)).join("\n"));
2017
2062
  }
2018
2063
  function cleanSlug(raw) {
2019
2064
  let s = String(raw ?? "").trim().replace(/\.md$/i, "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
@@ -2039,12 +2084,30 @@ async function listSlugs(fs, dir, prefix = "") {
2039
2084
  async function loadFact(fs, dir, slug) {
2040
2085
  const path = `${dir}/${slug}.md`;
2041
2086
  try {
2042
- return await fs.readFile(path);
2087
+ const raw = await fs.readFile(path);
2088
+ const { body } = splitFrontmatter(raw);
2089
+ return body || raw;
2043
2090
  } catch {
2044
2091
  return null;
2045
2092
  }
2046
2093
  }
2047
- function recallTool(fs, dir) {
2094
+ async function loadFactMulti(fs, dirs, slug) {
2095
+ for (const d of dirs) {
2096
+ const r = await loadFact(fs, d, slug);
2097
+ if (r != null) return r;
2098
+ }
2099
+ return null;
2100
+ }
2101
+ async function listSlugsMulti(fs, dirs) {
2102
+ const seen = /* @__PURE__ */ new Set();
2103
+ const out = [];
2104
+ for (const d of dirs) for (const s of await listSlugs(fs, d)) if (!seen.has(s)) {
2105
+ seen.add(s);
2106
+ out.push(s);
2107
+ }
2108
+ return out;
2109
+ }
2110
+ function recallTool(fs, dirs) {
2048
2111
  return {
2049
2112
  name: "Recall",
2050
2113
  description: 'Load memory facts by slug. Pass `slug` for one, `slugs` for several, or `pattern` (glob like "auth*") to match. Returns full bodies, separated by `--- slug ---` headers.',
@@ -2063,12 +2126,12 @@ function recallTool(fs, dir) {
2063
2126
  else if (pattern) {
2064
2127
  const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&");
2065
2128
  const re = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i");
2066
- targets = (await listSlugs(ctx.fs, dir)).filter((s) => re.test(s));
2129
+ targets = (await listSlugsMulti(fs, dirs)).filter((s) => re.test(s));
2067
2130
  }
2068
2131
  if (!targets.length) return `Error: no slugs resolved. Pass slug, slugs, or pattern.`;
2069
2132
  const parts = [];
2070
2133
  for (const s of targets.slice(0, 20)) {
2071
- const body = await loadFact(ctx.fs, dir, s);
2134
+ const body = await loadFactMulti(fs, dirs, s);
2072
2135
  if (body != null) parts.push(targets.length > 1 ? `--- ${s} ---
2073
2136
  ${body}` : body);
2074
2137
  else parts.push(targets.length > 1 ? `--- ${s} ---
@@ -2079,7 +2142,7 @@ ${body}` : body);
2079
2142
  }
2080
2143
  };
2081
2144
  }
2082
- function memorySearchTool(fs, dir) {
2145
+ function memorySearchTool(fs, dirs) {
2083
2146
  return {
2084
2147
  name: "MemorySearch",
2085
2148
  description: "Search memory facts by content \u2014 find relevant memories when you don't know the exact slug. Returns up to 10 matching slug + snippet pairs, ranked by relevance. Use `regex: true` for regex patterns.",
@@ -2094,7 +2157,7 @@ function memorySearchTool(fs, dir) {
2094
2157
  async run({ query, regex }, ctx) {
2095
2158
  const q2 = String(query ?? "").trim();
2096
2159
  if (!q2) return "Error: empty query.";
2097
- const slugs = await listSlugs(ctx.fs, dir);
2160
+ const slugs = await listSlugsMulti(fs, dirs);
2098
2161
  if (!slugs.length) return "(no memory facts found)";
2099
2162
  let matcher;
2100
2163
  if (regex) {
@@ -2110,7 +2173,7 @@ function memorySearchTool(fs, dir) {
2110
2173
  }
2111
2174
  const loaded = [];
2112
2175
  for (const slug of slugs) {
2113
- const body = await loadFact(ctx.fs, dir, slug);
2176
+ const body = await loadFactMulti(fs, dirs, slug);
2114
2177
  if (body) loaded.push({ slug, body });
2115
2178
  }
2116
2179
  const idf = idfWeights(loaded.map((l) => l.body));
@@ -2129,7 +2192,9 @@ function memorySearchTool(fs, dir) {
2129
2192
  }
2130
2193
  };
2131
2194
  }
2132
- function rememberTool(fs, dir) {
2195
+ function rememberTool(fs, dir, memOpts = {}) {
2196
+ const maxWrites = memOpts.maxWritesPerSession ?? 25;
2197
+ let writes = 0;
2133
2198
  return {
2134
2199
  name: "Remember",
2135
2200
  description: "Persist a durable fact for future sessions (a fix you found, a gotcha, a project constraint). Adds a pointer to the Memory index and stores the body. Use sparingly \u2014 only genuinely reusable knowledge, not task-specific scratch.",
@@ -2138,14 +2203,20 @@ function rememberTool(fs, dir) {
2138
2203
  required: ["fact"],
2139
2204
  properties: {
2140
2205
  fact: { type: "string", description: "the durable fact to remember (one or more lines)" },
2141
- slug: { type: "string", description: "optional kebab-case id; derived from the fact if omitted" }
2206
+ slug: { type: "string", description: "optional kebab-case id; derived from the fact if omitted" },
2207
+ type: { type: "string", enum: ["user", "feedback", "project", "reference"], description: "memory category (user/feedback/project/reference)" },
2208
+ description: { type: "string", description: "one-line summary for the memory index (\u226480 chars)" }
2142
2209
  }
2143
2210
  },
2144
- async run({ fact, slug }, ctx) {
2211
+ async run({ fact, slug, type, description }, ctx) {
2145
2212
  const body = String(fact ?? "").trim();
2146
2213
  if (!body) return `Error: nothing to remember (empty fact).`;
2214
+ if (++writes > maxWrites) return `Rate limit: too many memories this session (${maxWrites}). Only persist genuinely durable facts.`;
2147
2215
  const name = slugify(slug || body.split("\n")[0]);
2148
- await writeFact(ctx.fs, dir, name, body);
2216
+ const isGlobal = (type === "user" || type === "feedback") && memOpts.userDir;
2217
+ const targetDir = isGlobal ? memOpts.userDir : dir;
2218
+ await writeFact(fs, targetDir, name, body, { type, description });
2219
+ memOpts.onMemorySaved?.(name, type);
2149
2220
  return `Remembered '${name}' (recallable in future sessions).`;
2150
2221
  }
2151
2222
  };
@@ -2579,8 +2650,11 @@ var AgentOptions = class {
2579
2650
  skillsDir;
2580
2651
  /** VFS dir(s) of slash-command templates (`<dir>/<name>.md`). If set: inject a catalog + add the `SlashCommand` tool. Multiple dirs are merged (first wins). */
2581
2652
  commandsDir;
2582
- /** VFS dir of memory (`<dir>/MEMORY.md`). If set: inject the index at run start (persistence = backend). */
2653
+ /** VFS dir(s) of memory (`<dir>/MEMORY.md`). If set: inject the index at run start (persistence = backend).
2654
+ * Multiple dirs are merged (reads search all; writes go to first). */
2583
2655
  memoryDir;
2656
+ /** User-scope memory dir for global facts (type=user/feedback). Remember routes by type when set. */
2657
+ memoryUserDir;
2584
2658
  /** Filenames to discover as project instructions (e.g. `AGENT.md`, `AGENTS.md`, `CLAUDE.md`).
2585
2659
  * Walks the VFS tree and merges all found files (general → specific, like Claude Code).
2586
2660
  * `true` (default) = auto-discover standard names. `string[]` = custom names. `false` = skip. */
@@ -2617,6 +2691,8 @@ var AgentOptions = class {
2617
2691
  autoTest;
2618
2692
  /** Provider-specific options forwarded to ai.chat() (e.g. cursor mcpServers, cwd). */
2619
2693
  providerOptions;
2694
+ /** Tool selection mode: 'auto' = model decides (needed for Groq); undefined = provider default. */
2695
+ toolChoice;
2620
2696
  /** Extended-thinking / reasoning effort, normalized across providers (anthropic, openai).
2621
2697
  * `'off'`/undefined = none; `'low'|'medium'|'high'` or a raw token budget. Mapped to the
2622
2698
  * provider-specific request shape via {@link reasoningToChatFragment}; explicit `providerOptions` wins. */
@@ -2703,7 +2779,7 @@ var Agent = class _Agent {
2703
2779
  if (ins) systemPrompt += "\n\n" + ins;
2704
2780
  }
2705
2781
  if (o.memoryDir) {
2706
- const { index, tools: memTools } = await loadMemory(fs, o.memoryDir, { relevanceHint: taskHint });
2782
+ const { index, tools: memTools } = await loadMemory(fs, o.memoryDir, { relevanceHint: taskHint, userDir: o.memoryUserDir });
2707
2783
  if (index) systemPrompt += "\n\n" + index;
2708
2784
  tools = [...tools, ...memTools];
2709
2785
  }
@@ -2830,10 +2906,10 @@ var Agent = class _Agent {
2830
2906
  };
2831
2907
  try {
2832
2908
  if (useStream) {
2833
- const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: true, signal: o.signal, ...reasonOpts });
2909
+ const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: true, signal: o.signal, ...o.toolChoice ? { toolChoice: o.toolChoice } : {}, ...reasonOpts });
2834
2910
  res = await this.consumeStream(r);
2835
2911
  } else {
2836
- const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: false, signal: o.signal, ...reasonOpts });
2912
+ const r = await o.ai.chat({ model: o.model, messages: sent, tools: wireTools, stream: false, signal: o.signal, ...o.toolChoice ? { toolChoice: o.toolChoice } : {}, ...reasonOpts });
2837
2913
  res = r;
2838
2914
  }
2839
2915
  } catch (err2) {
@@ -3454,8 +3530,13 @@ var DuplexAgentOptions = class {
3454
3530
  /** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
3455
3531
  * (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
3456
3532
  quickLook;
3533
+ /** Memory directory/directories on the WORKER fs. If set, the voice agent gets Remember + Recall
3534
+ * tools directly (no delegation needed) and implicit capture guidance. */
3535
+ memoryDir;
3536
+ /** User-scope memory dir for global facts (type=user/feedback). Forwarded to Remember's routing. */
3537
+ memoryUserDir;
3457
3538
  };
3458
- var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou work in a pair: you talk, and a background worker with FULL access to the user\'s environment (files, shell, web) does the hands-on work. You can find out or do ANYTHING by calling `Delegate` with a clear, self-contained brief \u2014 so NEVER tell the user you can\'t see, access, or do something. Delegate and find out. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), delegate IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nAfter calling Delegate, tell the user you are on it in one short sentence, then end your turn. Do not wait for the result.\nResults arrive later as events like "[task t1 completed] \u2026" or "[task t1 failed] \u2026". When one arrives, summarize it for the ear in one or two short sentences. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 give at most a half-sentence aside ("still on it \u2014 running tests now") and end your turn. Never present progress as a finished result.\nNever read raw file paths, diffs, or code aloud verbatim.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nDo not fire a second Delegate for work already in flight \u2014 check `TaskStatus` first. Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not delegate, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file \u2014 use `QuickLook` (instant, no task). Anything requiring searching, reasoning, running commands, or editing still goes through `Delegate`.\nNEVER claim to have stored, saved, or remembered something durably \u2014 you cannot. Anything the user wants persisted (their name, preferences, notes) must be Delegated so a worker writes it to memory.\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled or surprising, briefly confirm what they meant ("did you mean\u2026?") instead of answering the literal words.';
3539
+ var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou work in a pair: you talk, and a background worker with FULL access to the user\'s environment (files, shell, web) does the hands-on work. You can find out or do ANYTHING by calling `Delegate` with a clear, self-contained brief \u2014 so NEVER tell the user you can\'t see, access, or do something. Delegate and find out. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), delegate IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nAfter calling Delegate, tell the user you are on it in one short sentence, then end your turn. Do not wait for the result.\nResults arrive later as events like "[task t1 completed] \u2026" or "[task t1 failed] \u2026". When one arrives, summarize it for the ear in one or two short sentences. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 give at most a half-sentence aside ("still on it \u2014 running tests now") and end your turn. Never present progress as a finished result.\nNever read raw file paths, diffs, or code aloud verbatim.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nDo not fire a second Delegate for work already in flight \u2014 check `TaskStatus` first. Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not delegate, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file \u2014 use `QuickLook` (instant, no task). Anything requiring searching, reasoning, running commands, or editing still goes through `Delegate`.\n{{MEMORY_SLOT}}\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled or surprising, briefly confirm what they meant ("did you mean\u2026?") instead of answering the literal words.';
3459
3540
  var VOICE_STYLE_CONVERSATIONAL = `Speak like a person in a live conversation, not an assistant reading a script. React first, then deliver: a quick impulsive beat ("oh nice", "hmm, hold on", "ah, got it") before the substance. Use contractions always. Vary sentence length \u2014 some very short. Light fillers and backchannels are fine ("mm-hm", "right", "let's see") but at most one per reply \u2014 never stack them. When you delegate, say it like a human would ("hang on, let me actually dig into that \u2014 gimme a minute") instead of announcing a task. When a result comes back, react to it like you just found out ("okay so \u2014 turns out\u2026"). Match the user's energy: a quick question gets a quick answer \u2014 a few words is a perfectly good turn. Prefer a short answer plus an offer ("want the details?") over covering everything. Never narrate your own mechanics (no "I will now delegate", no task ids out loud).`;
3460
3541
  var DuplexAgent = class {
3461
3542
  options;
@@ -3467,34 +3548,45 @@ var DuplexAgent = class {
3467
3548
  flushQueued = false;
3468
3549
  /** Parked worker questions awaiting a (voice-relayed) user answer, keyed by ask id. */
3469
3550
  pendingAsks = /* @__PURE__ */ new Map();
3551
+ /** Lazily resolved memory tools (async loadMemory runs in initMemory). */
3552
+ memoryReady;
3470
3553
  constructor(options) {
3471
3554
  this.options = { ...new DuplexAgentOptions(), ...options };
3472
3555
  const o = this.options;
3556
+ if (o.memoryDir && o.fs) {
3557
+ this.memoryReady = loadMemory(o.fs, o.memoryDir, { maxWritesPerSession: 10, userDir: o.memoryUserDir });
3558
+ }
3559
+ const memSlot = o.memoryDir && o.fs ? VOICE_MEMORY_PROMPT : "NEVER claim to have stored, saved, or remembered something durably \u2014 you cannot. Anything the user wants persisted (their name, preferences, notes) must be Delegated so a worker writes it to memory.";
3560
+ const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot) + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + `
3561
+ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3473
3562
  this.voice = new Agent({
3474
3563
  ai: o.ai,
3475
3564
  fs: new MemFilesystem2(),
3476
- // scratch — NOT Agent's jailed-disk default (voice has no fs tools; edge-safe)
3477
3565
  model: o.voiceModel,
3478
3566
  stream: true,
3479
3567
  host: o.host,
3480
- // Runtime context line: without it the voice confidently invents "facts" like today's date
3481
- // (its training cutoff) instead of delegating or admitting it doesn't know.
3482
- systemPrompt: VOICE_SYSTEM_PROMPT + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + `
3483
- Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`,
3568
+ systemPrompt: prompt,
3484
3569
  instructionFiles: false,
3485
3570
  maxSteps: 8,
3486
- // a voice turn should never loop
3487
3571
  timeoutMs: 3e4,
3488
3572
  ...o.voiceOptions,
3489
- // no defaultTools() — the voice can only Delegate, never touch files itself. Set AFTER the
3490
- // voiceOptions spread (addTools() would be clobbered by the first prepare()); extra voice
3491
- // tools come in via voiceOptions.tools and are merged here.
3492
3573
  tools: [...o.voiceOptions?.tools ?? [], this.delegateTool(), this.taskStatusTool(), this.cancelTaskTool(), this.quickLookTool(), this.answerTaskTool()]
3493
3574
  });
3494
3575
  }
3576
+ /** Resolve memory tools + inject index into voice system prompt (once). */
3577
+ async initMemory() {
3578
+ if (!this.memoryReady) return;
3579
+ const mem = await this.memoryReady;
3580
+ this.memoryReady = void 0;
3581
+ this.voice.options.tools.push(...mem.tools);
3582
+ if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
3583
+ }
3495
3584
  /** One user turn: the voice agent streams the reply (and may Delegate). Serialized with re-voice turns. */
3496
3585
  send(content) {
3497
- return this.enqueue(() => this.voice.send(content));
3586
+ return this.enqueue(async () => {
3587
+ await this.initMemory();
3588
+ return this.voice.send(content);
3589
+ });
3498
3590
  }
3499
3591
  /** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
3500
3592
  async idle() {
@@ -3867,18 +3959,15 @@ var VoiceEngineOptions = class {
3867
3959
  /** heuristic (non-AEC) energy barge-in tuning */
3868
3960
  bargeRmsMult = 2;
3869
3961
  bargeRmsFloor = 500;
3870
- /** Overlap turn-taking (AEC tier, needs player.pause/resume) — human phone-call model:
3871
- * onset PAUSE (exact-sample hold, nothing lost); sustained overlap cede (interrupt; the LLM
3872
- * re-enters). Brief overlaps that die out (backchannels"mm-hm", decided by DURATION, not
3873
- * vocabulary) resume from the precise sample and are dropped. false disables. */
3962
+ /** Overlap turn-taking (AEC tier, needs player.pause/resume) — human phone-call model, driven by
3963
+ * the STT ITSELF (a trained speech classifier) instead of energy thresholds (energy could not
3964
+ * separate residue bursts from speech in every room hiccup whack-a-mole): partial text while
3965
+ * speaking PAUSE (exact-sample hold); partial grows into dominant-novel ≥2 words → cede
3966
+ * (interrupt; the LLM re-enters); partial stalls/endpoints without ceding (backchannel by
3967
+ * DURATION, not vocabulary) → resume + drop. false disables. */
3874
3968
  overlapPause = true;
3875
- /** sustained overlap this → cede the turn */
3876
- overlapSustainMs = 450;
3877
- /** quiet for this long while paused → resume, drop the interjection */
3969
+ /** no new partial activity for this long while paused resume, drop the interjection */
3878
3970
  overlapResumeMs = 700;
3879
- /** energy floor for "overlap candidate" — must sit ABOVE typical room ambient (~110 rms measured;
3880
- * ungated ambient re-arming the resume timer forever was a live wedge). User speech ≫ 300. */
3881
- overlapRms = 300;
3882
3971
  };
3883
3972
  var VoiceEngine = class {
3884
3973
  options;
@@ -3911,12 +4000,8 @@ var VoiceEngine = class {
3911
4000
  lastInterrupted = null;
3912
4001
  // overlap (pause) tier state — AEC + pause-capable sinks only
3913
4002
  pausedAt = 0;
3914
- overlapLoud = 0;
3915
- // loud chunks since pause (sustain must be real sound, not two clicks)
3916
- overlapLastLoudAt = 0;
3917
- // continuity guard: a gap re-arms the onset (sparse noise ≠ sustained speech)
3918
- loudTimes = [];
3919
- // recent loud-chunk timestamps (sliding onset window)
4003
+ lastOverlapPartial = "";
4004
+ // change-detection: only NEW partial text counts as activity
3920
4005
  resumeTimer = null;
3921
4006
  constructor(options) {
3922
4007
  this.options = { ...new VoiceEngineOptions(), ...options };
@@ -4080,7 +4165,24 @@ var VoiceEngine = class {
4080
4165
  }
4081
4166
  handlePartial(text) {
4082
4167
  if (this.speaking) {
4083
- const barge = this.overlapCapable ? false : this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
4168
+ if (this.overlapCapable) {
4169
+ const txt = text.trim();
4170
+ if (!txt || txt === this.lastOverlapPartial) return;
4171
+ this.lastOverlapPartial = txt;
4172
+ if (!this.pausedAt) {
4173
+ this.pausedAt = now();
4174
+ this.player.pause();
4175
+ }
4176
+ if (this.genuine(txt) && this.words(txt).length >= 2) {
4177
+ const phase = this.ctxOpen ? "speaking" : "drain";
4178
+ this.interrupt();
4179
+ this.options.onBargeIn(phase);
4180
+ return;
4181
+ }
4182
+ this.armResume();
4183
+ return;
4184
+ }
4185
+ const barge = this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
4084
4186
  if (barge) {
4085
4187
  const phase = this.ctxOpen ? "speaking" : "drain";
4086
4188
  this.interrupt();
@@ -4089,15 +4191,13 @@ var VoiceEngine = class {
4089
4191
  return;
4090
4192
  }
4091
4193
  if (this.pendingUtt && text.trim()) {
4092
- if (this.pendingTimer) {
4093
- clearTimeout(this.pendingTimer);
4094
- this.pendingTimer = null;
4095
- }
4194
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
4195
+ this.pendingTimer = setTimeout(() => this.flushUtterance(), Math.max(800, this.options.utteranceMergeMs));
4096
4196
  }
4097
4197
  if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
4098
4198
  }
4099
4199
  handleUtterance(text) {
4100
- if (this.speaking && this.ctxOpen && this.overlapCapable) {
4200
+ if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable) {
4101
4201
  this.stt.reset();
4102
4202
  return;
4103
4203
  }
@@ -4112,7 +4212,7 @@ var VoiceEngine = class {
4112
4212
  }
4113
4213
  this.pendingUtt = this.pendingUtt ? `${this.pendingUtt} ${text}` : text;
4114
4214
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
4115
- if (!this.options.utteranceMergeMs) return this.flushUtterance();
4215
+ if (!this.options.utteranceMergeMs || this.words(this.pendingUtt).length >= 4) return this.flushUtterance();
4116
4216
  this.pendingTimer = setTimeout(() => this.flushUtterance(), this.options.utteranceMergeMs);
4117
4217
  }
4118
4218
  flushUtterance() {
@@ -4127,48 +4227,12 @@ var VoiceEngine = class {
4127
4227
  get overlapCapable() {
4128
4228
  return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
4129
4229
  }
4130
- /** Overlap turn-taking (AEC tier): onset → pause (exact-sample hold); sustained → cede; died out
4131
- * → resume. No vocabulary anywhere — duration and persistence decide (backchannels are short
4132
- * and stop). Nothing is lost across a pause, so a false positive costs only a brief hold. */
4133
- handleOverlap(rms) {
4134
- const o = this.options;
4135
- if (!this.speaking || !this.overlapCapable) return;
4136
- if (rms < o.overlapRms) return;
4137
- const t = now();
4138
- if (!this.pausedAt) {
4139
- this.loudTimes = this.loudTimes.filter((x) => t - x < 400);
4140
- this.loudTimes.push(t);
4141
- if (this.loudTimes.length < 2) return;
4142
- this.loudTimes = [];
4143
- this.pausedAt = t;
4144
- this.overlapLoud = 2;
4145
- this.overlapLastLoudAt = t;
4146
- this.player.pause();
4147
- this.armResume();
4148
- return;
4149
- }
4150
- if (t - this.overlapLastLoudAt > 450) {
4151
- this.pausedAt = t;
4152
- this.overlapLoud = 1;
4153
- this.overlapLastLoudAt = t;
4154
- this.armResume();
4155
- return;
4156
- }
4157
- this.overlapLastLoudAt = t;
4158
- this.overlapLoud++;
4159
- if (t - this.pausedAt >= o.overlapSustainMs && this.overlapLoud >= 3) {
4160
- const phase = this.ctxOpen ? "speaking" : "drain";
4161
- this.interrupt();
4162
- this.options.onBargeIn(phase);
4163
- return;
4164
- }
4165
- this.armResume();
4166
- }
4167
4230
  armResume() {
4168
4231
  if (this.resumeTimer) clearTimeout(this.resumeTimer);
4169
4232
  this.resumeTimer = setTimeout(() => {
4170
4233
  this.resumeTimer = null;
4171
4234
  if (!this.pausedAt) return;
4235
+ this.stt.reset();
4172
4236
  this.resetOverlap(true);
4173
4237
  }, this.options.overlapResumeMs);
4174
4238
  }
@@ -4179,12 +4243,25 @@ var VoiceEngine = class {
4179
4243
  }
4180
4244
  if (this.pausedAt && resume) this.player.resume?.();
4181
4245
  this.pausedAt = 0;
4182
- this.overlapLoud = 0;
4183
- this.loudTimes = [];
4246
+ this.lastOverlapPartial = "";
4247
+ this.gatePassTimes = [];
4184
4248
  }
4185
4249
  /** energy two-stage barge-in (heuristic tier only): spike over echo baseline → pause + confirm via STT */
4250
+ gatePassTimes = [];
4251
+ // recent gate-PASSING chunks (helper zeroes residue — nonzero = vetted)
4186
4252
  handleLevel(rms) {
4187
- if (this.usingAec) return this.handleOverlap(rms);
4253
+ if (this.usingAec) {
4254
+ if (!this.speaking || !this.overlapCapable || this.pausedAt || rms < 50) return;
4255
+ const t = now();
4256
+ this.gatePassTimes = this.gatePassTimes.filter((x) => t - x < 350);
4257
+ this.gatePassTimes.push(t);
4258
+ if (this.gatePassTimes.length < 2) return;
4259
+ this.gatePassTimes = [];
4260
+ this.pausedAt = t;
4261
+ this.player.pause();
4262
+ this.armResume();
4263
+ return;
4264
+ }
4188
4265
  if (!this.speaking) {
4189
4266
  this.baseline = 0;
4190
4267
  this.hot = 0;
@@ -4224,6 +4301,9 @@ var SonioxSTTOptions = class {
4224
4301
  source;
4225
4302
  model = "stt-rt-preview";
4226
4303
  languageHints = ["en"];
4304
+ /** Client-side endpoint: finalized text + no new tokens for this long = utterance (don't wait for
4305
+ * Soniox's semantic <end>, which adds 0.5-1.5s — the difference between ping-pong and lag). */
4306
+ silenceEndpointMs = 500;
4227
4307
  };
4228
4308
  var SonioxSTT = class {
4229
4309
  options;
@@ -4239,6 +4319,9 @@ var SonioxSTT = class {
4239
4319
  };
4240
4320
  finalText = "";
4241
4321
  partialText = "";
4322
+ lastChangeAt = 0;
4323
+ lastCombined = "";
4324
+ endpointTimer = null;
4242
4325
  constructor(options) {
4243
4326
  this.options = { ...new SonioxSTTOptions(), ...options };
4244
4327
  }
@@ -4275,6 +4358,13 @@ var SonioxSTT = class {
4275
4358
  await this.connectWs();
4276
4359
  if (this.sourceStarted) return;
4277
4360
  this.sourceStarted = true;
4361
+ this.endpointTimer = setInterval(() => {
4362
+ const combined = (this.finalText + this.partialText).trim();
4363
+ if (!combined || now2() - this.lastChangeAt < this.options.silenceEndpointMs) return;
4364
+ this.reset();
4365
+ this.onUtterance(combined, now2());
4366
+ }, 120);
4367
+ this.endpointTimer.unref?.();
4278
4368
  await this.options.source.start((chunk) => {
4279
4369
  let sum = 0;
4280
4370
  const view = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength);
@@ -4294,7 +4384,12 @@ var SonioxSTT = class {
4294
4384
  else if (t.is_final) this.finalText += t.text;
4295
4385
  }
4296
4386
  this.partialText = (m.tokens ?? []).filter((t) => !t.is_final && t.text !== "<end>").map((t) => t.text).join("");
4297
- this.onPartial(this.finalText + this.partialText);
4387
+ const combined = this.finalText + this.partialText;
4388
+ if (combined !== this.lastCombined) {
4389
+ this.lastCombined = combined;
4390
+ this.lastChangeAt = now2();
4391
+ }
4392
+ this.onPartial(combined);
4298
4393
  if (endpoint && this.finalText.trim()) {
4299
4394
  const utterance = this.finalText.trim();
4300
4395
  this.reset();
@@ -4304,9 +4399,11 @@ var SonioxSTT = class {
4304
4399
  reset() {
4305
4400
  this.finalText = "";
4306
4401
  this.partialText = "";
4402
+ this.lastCombined = "";
4307
4403
  }
4308
4404
  stop() {
4309
4405
  this.stopped = true;
4406
+ if (this.endpointTimer) clearInterval(this.endpointTimer);
4310
4407
  this.options.source?.stop();
4311
4408
  if (this.ws) this.ws.onclose = null;
4312
4409
  this.ws?.close();
@@ -4926,8 +5023,26 @@ Reference files in them by their mount path (the left side).`;
4926
5023
  ].filter(Boolean);
4927
5024
  return dirs.length ? dirs : void 0;
4928
5025
  };
4929
- const memoryDir = dot("memory");
4930
- const hooks = o.learnFromMistakes && memoryDir ? composeHooks(o.hooks, lessonCapture({ fs, dir: memoryDir, minRepeats: 2 })) : o.hooks;
5026
+ const memoryDir = (() => {
5027
+ const home = homedir();
5028
+ const projectDir = `${cwd}/.agent/memory`;
5029
+ const readDirs = [
5030
+ existsSync2(projectDir) ? projectDir : void 0,
5031
+ existsSync2(`${cwd}/.claude/memory`) ? `${cwd}/.claude/memory` : void 0,
5032
+ existsSync2(`${home}/.agent/memory`) ? `${home}/.agent/memory` : void 0,
5033
+ existsSync2(`${home}/.claude/memory`) ? `${home}/.claude/memory` : void 0
5034
+ ].filter(Boolean);
5035
+ return readDirs[0] === projectDir ? readDirs : [projectDir, ...readDirs];
5036
+ })();
5037
+ const memoryUserDir = (() => {
5038
+ const home = homedir();
5039
+ return [
5040
+ existsSync2(`${home}/.agent/memory`) ? `${home}/.agent/memory` : void 0,
5041
+ existsSync2(`${home}/.claude/memory`) ? `${home}/.claude/memory` : void 0
5042
+ ].find(Boolean) ?? `${home}/.agent/memory`;
5043
+ })();
5044
+ const memoryWriteDir = memoryDir[0];
5045
+ const hooks = o.learnFromMistakes && memoryWriteDir ? composeHooks(o.hooks, lessonCapture({ fs, dir: memoryWriteDir, minRepeats: 2 })) : o.hooks;
4931
5046
  let realShell = [];
4932
5047
  const useRealShell = o.realShell ?? !virtual;
4933
5048
  if (useRealShell && !virtual) {
@@ -4984,6 +5099,7 @@ The filesystem root '/' is the real machine root \u2014 you have full filesystem
4984
5099
  skillsDir: dots("skills"),
4985
5100
  commandsDir: dots("commands"),
4986
5101
  memoryDir,
5102
+ memoryUserDir,
4987
5103
  agentsDir: dot("agents"),
4988
5104
  instructionFiles: o.instructionFiles ?? ["AGENTS.md", "CLAUDE.md"],
4989
5105
  // Only override the Agent's safety defaults when explicitly configured.
@@ -5146,13 +5262,16 @@ var AecDuplexAudio = class {
5146
5262
  startedAt = 0;
5147
5263
  // --- AudioSource ---
5148
5264
  start(onChunk) {
5149
- this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "ignore"] });
5265
+ this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "pipe"] });
5150
5266
  this.proc.stdin.on("error", () => {
5151
5267
  });
5152
5268
  this.proc.on("exit", (c) => {
5153
5269
  if (c && !this.stopped) log12.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
5154
5270
  });
5155
5271
  this.proc.stdout.on("data", (chunk) => onChunk(chunk));
5272
+ this.proc.stderr.on("data", (d) => {
5273
+ for (const ln of String(d).split("\n")) if (ln.trim()) log12.debug(`mic-aec: ${ln.trim()}`);
5274
+ });
5156
5275
  }
5157
5276
  stop() {
5158
5277
  this.stopped = true;
@@ -5229,6 +5348,17 @@ var AecDuplexAudio = class {
5229
5348
  pausedMs() {
5230
5349
  return this.pausedAccum + (this.pausedSince ? now4() - this.pausedSince : 0);
5231
5350
  }
5351
+ /** TEST HARNESS: queue simulated user speech (s16le 16k mono) — the helper mixes it into the real
5352
+ * mic stream pre-gate, so the full live pipeline runs while a script plays the human. */
5353
+ inject(pcm16k) {
5354
+ const stdin = this.proc?.stdin;
5355
+ if (!stdin || stdin.destroyed) return;
5356
+ const hdr = Buffer.alloc(8);
5357
+ hdr.writeUInt32LE(4294967293, 0);
5358
+ hdr.writeUInt32LE(pcm16k.length, 4);
5359
+ stdin.write(hdr);
5360
+ stdin.write(pcm16k);
5361
+ }
5232
5362
  };
5233
5363
  var VoiceIOOptions = class extends VoiceEngineOptions {
5234
5364
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
@@ -6689,14 +6819,15 @@ function createLineEditor(out) {
6689
6819
  process.stdin.setRawMode(true);
6690
6820
  process.stdin.resume();
6691
6821
  out.write("\x1B[?2004h");
6692
- render(s, opts.prompt, maxVisible, opts.status);
6822
+ const promptOf = () => typeof opts.prompt === "function" ? opts.prompt() : opts.prompt;
6823
+ render(s, promptOf(), maxVisible, opts.status);
6693
6824
  const onResize = () => {
6694
6825
  curRow = 0;
6695
- render(s, opts.prompt, maxVisible, opts.status);
6826
+ render(s, promptOf(), maxVisible, opts.status);
6696
6827
  };
6697
6828
  activeRedraw = () => {
6698
6829
  curRow = 0;
6699
- render(s, opts.prompt, maxVisible, opts.status);
6830
+ render(s, promptOf(), maxVisible, opts.status);
6700
6831
  };
6701
6832
  process.on("SIGWINCH", onResize);
6702
6833
  return new Promise((resolve4) => {
@@ -6704,7 +6835,7 @@ function createLineEditor(out) {
6704
6835
  finish();
6705
6836
  resolve4(null);
6706
6837
  };
6707
- const redraw = () => render(s, opts.prompt, maxVisible, opts.status);
6838
+ const redraw = () => render(s, promptOf(), maxVisible, opts.status);
6708
6839
  let lastStatus = opts.status?.() ?? "";
6709
6840
  const ticker = opts.statusTickMs && opts.status ? setInterval(() => {
6710
6841
  if (s.pasting) return;
@@ -6779,11 +6910,11 @@ function createLineEditor(out) {
6779
6910
  }
6780
6911
  s.reset();
6781
6912
  s.refresh();
6782
- render(s, opts.prompt, maxVisible, opts.status);
6913
+ render(s, promptOf(), maxVisible, opts.status);
6783
6914
  return;
6784
6915
  }
6785
6916
  if (s.pasting) return;
6786
- render(s, opts.prompt, maxVisible, opts.status);
6917
+ render(s, promptOf(), maxVisible, opts.status);
6787
6918
  };
6788
6919
  const finish = () => {
6789
6920
  activeRedraw = void 0;
@@ -6794,7 +6925,7 @@ function createLineEditor(out) {
6794
6925
  out.write("\x1B[?2004l");
6795
6926
  s.closeMenu();
6796
6927
  s.end();
6797
- render(s, opts.prompt, maxVisible);
6928
+ render(s, promptOf(), maxVisible);
6798
6929
  out.write("\r\n");
6799
6930
  };
6800
6931
  process.stdin.on("keypress", onKey);
@@ -7284,7 +7415,7 @@ Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with
7284
7415
  Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
7285
7416
 
7286
7417
  REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \xB7 @path inlines a file
7287
- REPL slash commands: /help /version /tools /permissions /status /cost /context /cwd /model /reasoning /config /rename /compact /rewind /undo /clear /sessions /resume /commands /skills /mcp /init /export /paste /exit
7418
+ REPL slash commands: /help /version /tools /permissions /status /cost /context /cwd /model /reasoning /config /rename /compact /rewind /undo /clear /sessions /resume /commands /skills /mcp /init /export /paste /goal /exit
7288
7419
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
7289
7420
  REPL multi-line: Option/Alt+Enter inserts a newline, or end a line with \\ to continue. Esc cancels a running turn / clears the input line; double-Esc jumps back to edit a previous message.
7290
7421
  REPL shortcuts: Shift+Tab cycles permission posture (ask \u2192 accept-edits \u2192 plan) \xB7 Alt+T toggles reasoning \xB7 Alt+P switches model \xB7 Ctrl+O toggles verbose tool output \xB7 \u2192 or Tab accepts the dim history ghost-suggestion \xB7 Alt+S/Ctrl+S stash/unstash.
@@ -7530,6 +7661,31 @@ function costOf(pricing, promptTokens = 0, completionTokens = 0) {
7530
7661
  function turnCost(model, usage) {
7531
7662
  return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0);
7532
7663
  }
7664
+ async function evaluateGoal(ai, condition, transcript, log17) {
7665
+ const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
7666
+ const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
7667
+ return text.slice(0, 600);
7668
+ }).join("\n---\n");
7669
+ try {
7670
+ const r = await ai.chat({
7671
+ model: "anthropic/claude-haiku-4-5",
7672
+ stream: false,
7673
+ messages: [
7674
+ { role: "system", content: 'You judge whether a goal condition has been met based on conversation transcript. Respond ONLY with JSON: {"met": boolean, "reason": "one sentence"}. Be strict \u2014 only met:true if there is clear evidence in the transcript.' },
7675
+ { role: "user", content: `Goal condition: ${condition}
7676
+
7677
+ Recent assistant messages:
7678
+ ${recent}` }
7679
+ ]
7680
+ });
7681
+ const match = r.content.match(/\{[\s\S]*\}/);
7682
+ if (match) return JSON.parse(match[0]);
7683
+ } catch (e) {
7684
+ log17(dim(` (goal evaluator error: ${e?.message ?? e})
7685
+ `));
7686
+ }
7687
+ return { met: false, reason: "evaluation unclear" };
7688
+ }
7533
7689
  function errInfo(e) {
7534
7690
  return { message: String(e?.message ?? e), statusCode: e?.statusCode, code: e?.code };
7535
7691
  }
@@ -7555,6 +7711,9 @@ async function runShellLine(fs, cmd) {
7555
7711
  if (r.exitCode !== 0) return `[exit ${r.exitCode}]${r.error ? " " + r.error.trim() : ""}${out ? "\n" + out : ""}`;
7556
7712
  return out || "(no output)";
7557
7713
  }
7714
+ function primaryMemDir(dir, fallback) {
7715
+ return (Array.isArray(dir) ? dir[0] : dir) || fallback;
7716
+ }
7558
7717
  async function appendMemoryNote(fs, dir, text) {
7559
7718
  await mkdirp(fs, dir);
7560
7719
  const idx = `${dir}/MEMORY.md`;
@@ -8058,6 +8217,8 @@ async function repl(args, ai, cfg, cwd) {
8058
8217
  dx = new DuplexAgent({
8059
8218
  ai,
8060
8219
  fs: agent.options.fs,
8220
+ memoryDir: agent.options.memoryDir,
8221
+ memoryUserDir: agent.options.memoryUserDir,
8061
8222
  ...args.voiceModel ?? cfg.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel ?? cfg.voiceModel) } : {},
8062
8223
  workerModel: agent.options.model,
8063
8224
  workerOptions,
@@ -8080,16 +8241,18 @@ async function repl(args, ai, cfg, cwd) {
8080
8241
  return "not a git repository";
8081
8242
  }
8082
8243
  },
8083
- // Memory READS are QuickLook material (instant, capped); memory WRITES stay delegated —
8084
- // a worker creates/updates the files under .agent/memory/.
8085
8244
  memory: async () => {
8086
- const dir = agent.options.memoryDir || adot("memory");
8087
- try {
8088
- const idx = await fs.readFile(`${dir}/MEMORY.md`);
8089
- return idx.slice(0, 2e3) || "(memory index is empty)";
8090
- } catch {
8091
- return "no memory yet \u2014 to save something, Delegate it (a worker writes .agent/memory/)";
8245
+ const _adot = (s) => `${agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd()}/.agent/${s}`;
8246
+ const dirs = Array.isArray(agent.options.memoryDir) ? agent.options.memoryDir : [agent.options.memoryDir || _adot("memory")];
8247
+ const parts = [];
8248
+ for (const d of dirs) {
8249
+ try {
8250
+ const idx = await fs.readFile(`${d}/MEMORY.md`);
8251
+ if (idx.trim()) parts.push(idx.trim());
8252
+ } catch {
8253
+ }
8092
8254
  }
8255
+ return parts.length ? parts.join("\n").slice(0, 2e3) : "(no memory yet)";
8093
8256
  }
8094
8257
  },
8095
8258
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
@@ -8221,7 +8384,14 @@ async function repl(args, ai, cfg, cwd) {
8221
8384
  face.transcript = data.messages;
8222
8385
  session = data;
8223
8386
  checkpoints.use?.(data.meta.id);
8387
+ const m = data.meta;
8388
+ goalCondition = m.goalCondition;
8389
+ goalTurns = m.goalTurns ?? 0;
8390
+ goalTokens = m.goalTokens ?? 0;
8391
+ goalLastReason = m.goalLastReason;
8224
8392
  err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? " \u2014 " + data.meta.title : ""}
8393
+ `));
8394
+ if (goalCondition) err(dim(` \u25CE goal active: ${goalCondition} (${goalTurns} turns)
8225
8395
  `));
8226
8396
  printHistory(data.messages);
8227
8397
  };
@@ -8365,6 +8535,45 @@ ${extra}` : body);
8365
8535
  const picked = await selectMenu(process.stderr, { title: `Select a model \xB7 current: ${current}`, items: providerItems, current, filterable: true });
8366
8536
  return picked && !picked.startsWith("__") ? picked : null;
8367
8537
  };
8538
+ let goalCondition = session.meta.goalCondition;
8539
+ let goalTurns = session.meta.goalTurns ?? 0;
8540
+ let goalTokens = session.meta.goalTokens ?? 0;
8541
+ let goalLastReason = session.meta.goalLastReason;
8542
+ const GOAL_MAX_TURNS = 50;
8543
+ const persistGoal = () => {
8544
+ const m = session.meta;
8545
+ m.goalCondition = goalCondition;
8546
+ m.goalTurns = goalTurns;
8547
+ m.goalTokens = goalTokens;
8548
+ m.goalLastReason = goalLastReason;
8549
+ };
8550
+ const goalLoop = async () => {
8551
+ while (goalCondition && !aborting && !exitRequested && goalTurns < GOAL_MAX_TURNS) {
8552
+ const result = await evaluateGoal(ai, goalCondition, face.transcript, err);
8553
+ goalLastReason = result.reason;
8554
+ if (result.met) {
8555
+ err(green(` \u2713 goal met: ${result.reason}
8556
+ `));
8557
+ goalCondition = void 0;
8558
+ goalLastReason = void 0;
8559
+ persistGoal();
8560
+ return;
8561
+ }
8562
+ err(dim(` \u25CE not yet (${result.reason}) \u2014 turn ${goalTurns + 1}
8563
+ `));
8564
+ aborting = false;
8565
+ const tokensBefore = session.meta.tokens ?? 0;
8566
+ await turn(`Continue working toward the goal: ${goalCondition}`);
8567
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
8568
+ goalTurns++;
8569
+ persistGoal();
8570
+ if (exitRequested) return;
8571
+ }
8572
+ if (goalTurns >= GOAL_MAX_TURNS) {
8573
+ err(yellow(` \u26A0 goal reached ${GOAL_MAX_TURNS} turns \u2014 pausing. /goal to check status, /goal clear to cancel
8574
+ `));
8575
+ }
8576
+ };
8368
8577
  const builtins = {
8369
8578
  help: { desc: "show this help", run: () => {
8370
8579
  err(HELP + "\n");
@@ -8857,6 +9066,49 @@ ${extra}` : body);
8857
9066
  }
8858
9067
  }
8859
9068
  },
9069
+ goal: {
9070
+ desc: "autonomous loop \u2014 /goal <condition> | /goal (status) | /goal clear",
9071
+ run: async (a) => {
9072
+ if (!a.length) {
9073
+ if (!goalCondition) {
9074
+ err(dim(" no active goal\n"));
9075
+ return;
9076
+ }
9077
+ const tokStr = goalTokens > 1e3 ? `${(goalTokens / 1e3).toFixed(1)}k` : String(goalTokens);
9078
+ err(` ${bold("\u25CE goal:")} ${goalCondition}
9079
+ ` + dim(` ${goalTurns} turn${goalTurns === 1 ? "" : "s"} \xB7 ${tokStr} tokens${goalLastReason ? ` \xB7 last: ${goalLastReason}` : ""}
9080
+ `));
9081
+ return;
9082
+ }
9083
+ if (a[0] === "clear") {
9084
+ if (!goalCondition) {
9085
+ err(dim(" no active goal\n"));
9086
+ return;
9087
+ }
9088
+ goalCondition = void 0;
9089
+ goalTurns = 0;
9090
+ goalTokens = 0;
9091
+ goalLastReason = void 0;
9092
+ persistGoal();
9093
+ err(green(" \u2713 goal cleared\n"));
9094
+ return;
9095
+ }
9096
+ goalCondition = a.join(" ");
9097
+ goalTurns = 0;
9098
+ goalTokens = 0;
9099
+ goalLastReason = void 0;
9100
+ persistGoal();
9101
+ err(green(` \u25CE goal set: ${goalCondition}
9102
+ `) + dim(" working\u2026 (Esc to pause)\n"));
9103
+ const tokensBefore = session.meta.tokens ?? 0;
9104
+ await turn(goalCondition);
9105
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
9106
+ goalTurns++;
9107
+ persistGoal();
9108
+ if (!exitRequested) await goalLoop();
9109
+ if (exitRequested) return true;
9110
+ }
9111
+ },
8860
9112
  exit: { desc: "quit", run: () => true },
8861
9113
  quit: { desc: "quit", run: () => true }
8862
9114
  };
@@ -8965,7 +9217,7 @@ ${extra}` : body);
8965
9217
  if (line.startsWith("#")) {
8966
9218
  const note = line.slice(1).trim();
8967
9219
  if (note) {
8968
- const where = await appendMemoryNote(agent.options.fs, agent.options.memoryDir || adot("memory"), note);
9220
+ const where = await appendMemoryNote(agent.options.fs, primaryMemDir(agent.options.memoryDir, adot("memory")), note);
8969
9221
  err(green(` \u270E remembered \u2192 ${where}
8970
9222
  `));
8971
9223
  }
@@ -9001,6 +9253,11 @@ ${extra}` : body);
9001
9253
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
9002
9254
  pendingImages.length = 0;
9003
9255
  await turn(task);
9256
+ if (goalCondition && !aborting && !exitRequested) {
9257
+ goalTurns++;
9258
+ persistGoal();
9259
+ await goalLoop();
9260
+ }
9004
9261
  if (exitRequested) return "quit";
9005
9262
  };
9006
9263
  let voicePartial = "";
@@ -9092,6 +9349,7 @@ ${extra}` : body);
9092
9349
  const r = work.reasoning;
9093
9350
  if (r && r !== "off") parts.push(`reasoning:${r}`);
9094
9351
  if (verboseOutput) parts.push("verbose");
9352
+ if (goalCondition) parts.push(`\u25CE goal (${goalTurns} turns)`);
9095
9353
  if (inputStash.length) parts.push(`${inputStash.length} stashed (\u2303S to pop)`);
9096
9354
  const taskLines = [];
9097
9355
  if (dx) {
@@ -9101,8 +9359,14 @@ ${extra}` : body);
9101
9359
  }
9102
9360
  return [...taskLines, parts.join(" \xB7 ")].filter(Boolean).join("\n");
9103
9361
  };
9362
+ const livePrompt = () => {
9363
+ const ask = dx?.pendingAsks.size ? dx.pendingAsks.values().next().value : void 0;
9364
+ if (!ask) return promptStr;
9365
+ const q2 = ask.question.replace(/\s+/g, " ").slice(0, 64);
9366
+ return bold(yellow(`? ${q2}${ask.question.length > 64 ? "\u2026" : ""} \u2039yes/no\u203A `));
9367
+ };
9104
9368
  const result = await readMultiline((cont) => editor.readLine({
9105
- prompt: cont ? contPrompt : promptStr,
9369
+ prompt: cont ? contPrompt : livePrompt,
9106
9370
  suggest,
9107
9371
  history,
9108
9372
  classifyPaste,
@@ -9140,6 +9404,13 @@ ${extra}` : body);
9140
9404
  }
9141
9405
  const line = result.trim();
9142
9406
  if (!line) continue;
9407
+ if (dx?.pendingAsks.size && /^(y(es|ep|eah)?|n(o|ope)?|sure|ok(ay)?|allow|deny|go( ahead)?)[.!]?$/i.test(line)) {
9408
+ const [id, ask] = dx.pendingAsks.entries().next().value;
9409
+ ask.resolve(line);
9410
+ err(dim(` \u21B3 answered ${id}: ${line}
9411
+ `));
9412
+ continue;
9413
+ }
9143
9414
  let quit = await dispatchLine(line) === "quit";
9144
9415
  while (!quit && inputStash.length) {
9145
9416
  const next = inputStash.shift();
@@ -9243,7 +9514,8 @@ async function main() {
9243
9514
  process.once("SIGINT", () => activeTurn?.abort());
9244
9515
  const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
9245
9516
  if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
9246
- const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: agent.options.memoryDir, result: res });
9517
+ const _fsBase = agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd();
9518
+ const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
9247
9519
  if (slug) err(dim(` \u270E learned a lesson \u2192 ${slug}
9248
9520
  `));
9249
9521
  }