agent.libx.js 0.92.9 → 0.93.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
@@ -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();
@@ -4337,7 +4434,15 @@ var CartesiaTTS = class {
4337
4434
  constructor(options) {
4338
4435
  this.options = { ...new CartesiaTTSOptions(), ...options };
4339
4436
  }
4437
+ closed = false;
4438
+ connecting = null;
4340
4439
  async connect() {
4440
+ this.closed = false;
4441
+ this.connecting = this.doConnect();
4442
+ await this.connecting;
4443
+ this.connecting = null;
4444
+ }
4445
+ async doConnect() {
4341
4446
  const key = await resolveAuth(this.options.auth);
4342
4447
  const param = this.options.authMode === "token" ? "access_token" : "api_key";
4343
4448
  this.ws = new WebSocket(`wss://api.cartesia.ai/tts/websocket?cartesia_version=2026-03-01&${param}=${key}`);
@@ -4345,7 +4450,12 @@ var CartesiaTTS = class {
4345
4450
  this.ws.onopen = () => res();
4346
4451
  this.ws.onerror = (e) => rej(new Error(`cartesia ws: ${e.message || "connect failed"}`));
4347
4452
  });
4348
- this.ws.onclose = (ev) => log9.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
4453
+ this.ws.onclose = (ev) => {
4454
+ log9.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
4455
+ if (!this.closed) {
4456
+ this.connecting = this.doConnect().catch((e) => log9.error(`cartesia reconnect failed: ${e.message}`));
4457
+ }
4458
+ };
4349
4459
  this.ws.onmessage = (ev) => {
4350
4460
  const m = JSON.parse(String(ev.data));
4351
4461
  if (m.context_id && m.context_id !== this.ctxId) return;
@@ -4356,6 +4466,11 @@ var CartesiaTTS = class {
4356
4466
  else if (m.type === "error" && !/already been cancelled|does not exist/.test(m.message || "")) log9.warn(`cartesia: ${JSON.stringify(m)}`);
4357
4467
  };
4358
4468
  }
4469
+ /** Ensure the WS is open before sending — reconnects if idle-closed. */
4470
+ async ensureConnected() {
4471
+ if (this.connecting) await this.connecting;
4472
+ if (this.ws?.readyState !== WebSocket.OPEN) await this.connect();
4473
+ }
4359
4474
  newContext() {
4360
4475
  this.ctxId = `ctx-${++this.ctxSeq}`;
4361
4476
  this.firstAudioAt = 0;
@@ -4373,6 +4488,7 @@ var CartesiaTTS = class {
4373
4488
  }
4374
4489
  speak(text, cont) {
4375
4490
  if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(this.frame(text, cont));
4491
+ else void this.ensureConnected().then(() => this.ws?.readyState === WebSocket.OPEN && this.ws.send(this.frame(text, cont)));
4376
4492
  }
4377
4493
  end() {
4378
4494
  if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(this.frame("", false));
@@ -4381,6 +4497,7 @@ var CartesiaTTS = class {
4381
4497
  if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ context_id: this.ctxId, cancel: true }));
4382
4498
  }
4383
4499
  close() {
4500
+ this.closed = true;
4384
4501
  if (this.ws) this.ws.onclose = null;
4385
4502
  this.ws?.close();
4386
4503
  }
@@ -4926,8 +5043,26 @@ Reference files in them by their mount path (the left side).`;
4926
5043
  ].filter(Boolean);
4927
5044
  return dirs.length ? dirs : void 0;
4928
5045
  };
4929
- const memoryDir = dot("memory");
4930
- const hooks = o.learnFromMistakes && memoryDir ? composeHooks(o.hooks, lessonCapture({ fs, dir: memoryDir, minRepeats: 2 })) : o.hooks;
5046
+ const memoryDir = (() => {
5047
+ const home = homedir();
5048
+ const projectDir = `${cwd}/.agent/memory`;
5049
+ const readDirs = [
5050
+ existsSync2(projectDir) ? projectDir : void 0,
5051
+ existsSync2(`${cwd}/.claude/memory`) ? `${cwd}/.claude/memory` : void 0,
5052
+ existsSync2(`${home}/.agent/memory`) ? `${home}/.agent/memory` : void 0,
5053
+ existsSync2(`${home}/.claude/memory`) ? `${home}/.claude/memory` : void 0
5054
+ ].filter(Boolean);
5055
+ return readDirs[0] === projectDir ? readDirs : [projectDir, ...readDirs];
5056
+ })();
5057
+ const memoryUserDir = (() => {
5058
+ const home = homedir();
5059
+ return [
5060
+ existsSync2(`${home}/.agent/memory`) ? `${home}/.agent/memory` : void 0,
5061
+ existsSync2(`${home}/.claude/memory`) ? `${home}/.claude/memory` : void 0
5062
+ ].find(Boolean) ?? `${home}/.agent/memory`;
5063
+ })();
5064
+ const memoryWriteDir = memoryDir[0];
5065
+ const hooks = o.learnFromMistakes && memoryWriteDir ? composeHooks(o.hooks, lessonCapture({ fs, dir: memoryWriteDir, minRepeats: 2 })) : o.hooks;
4931
5066
  let realShell = [];
4932
5067
  const useRealShell = o.realShell ?? !virtual;
4933
5068
  if (useRealShell && !virtual) {
@@ -4984,6 +5119,7 @@ The filesystem root '/' is the real machine root \u2014 you have full filesystem
4984
5119
  skillsDir: dots("skills"),
4985
5120
  commandsDir: dots("commands"),
4986
5121
  memoryDir,
5122
+ memoryUserDir,
4987
5123
  agentsDir: dot("agents"),
4988
5124
  instructionFiles: o.instructionFiles ?? ["AGENTS.md", "CLAUDE.md"],
4989
5125
  // Only override the Agent's safety defaults when explicitly configured.
@@ -5146,13 +5282,16 @@ var AecDuplexAudio = class {
5146
5282
  startedAt = 0;
5147
5283
  // --- AudioSource ---
5148
5284
  start(onChunk) {
5149
- this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "ignore"] });
5285
+ this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "pipe"] });
5150
5286
  this.proc.stdin.on("error", () => {
5151
5287
  });
5152
5288
  this.proc.on("exit", (c) => {
5153
5289
  if (c && !this.stopped) log12.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
5154
5290
  });
5155
5291
  this.proc.stdout.on("data", (chunk) => onChunk(chunk));
5292
+ this.proc.stderr.on("data", (d) => {
5293
+ for (const ln of String(d).split("\n")) if (ln.trim()) log12.debug(`mic-aec: ${ln.trim()}`);
5294
+ });
5156
5295
  }
5157
5296
  stop() {
5158
5297
  this.stopped = true;
@@ -5229,6 +5368,17 @@ var AecDuplexAudio = class {
5229
5368
  pausedMs() {
5230
5369
  return this.pausedAccum + (this.pausedSince ? now4() - this.pausedSince : 0);
5231
5370
  }
5371
+ /** TEST HARNESS: queue simulated user speech (s16le 16k mono) — the helper mixes it into the real
5372
+ * mic stream pre-gate, so the full live pipeline runs while a script plays the human. */
5373
+ inject(pcm16k) {
5374
+ const stdin = this.proc?.stdin;
5375
+ if (!stdin || stdin.destroyed) return;
5376
+ const hdr = Buffer.alloc(8);
5377
+ hdr.writeUInt32LE(4294967293, 0);
5378
+ hdr.writeUInt32LE(pcm16k.length, 4);
5379
+ stdin.write(hdr);
5380
+ stdin.write(pcm16k);
5381
+ }
5232
5382
  };
5233
5383
  var VoiceIOOptions = class extends VoiceEngineOptions {
5234
5384
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
@@ -6689,14 +6839,15 @@ function createLineEditor(out) {
6689
6839
  process.stdin.setRawMode(true);
6690
6840
  process.stdin.resume();
6691
6841
  out.write("\x1B[?2004h");
6692
- render(s, opts.prompt, maxVisible, opts.status);
6842
+ const promptOf = () => typeof opts.prompt === "function" ? opts.prompt() : opts.prompt;
6843
+ render(s, promptOf(), maxVisible, opts.status);
6693
6844
  const onResize = () => {
6694
6845
  curRow = 0;
6695
- render(s, opts.prompt, maxVisible, opts.status);
6846
+ render(s, promptOf(), maxVisible, opts.status);
6696
6847
  };
6697
6848
  activeRedraw = () => {
6698
6849
  curRow = 0;
6699
- render(s, opts.prompt, maxVisible, opts.status);
6850
+ render(s, promptOf(), maxVisible, opts.status);
6700
6851
  };
6701
6852
  process.on("SIGWINCH", onResize);
6702
6853
  return new Promise((resolve4) => {
@@ -6704,7 +6855,7 @@ function createLineEditor(out) {
6704
6855
  finish();
6705
6856
  resolve4(null);
6706
6857
  };
6707
- const redraw = () => render(s, opts.prompt, maxVisible, opts.status);
6858
+ const redraw = () => render(s, promptOf(), maxVisible, opts.status);
6708
6859
  let lastStatus = opts.status?.() ?? "";
6709
6860
  const ticker = opts.statusTickMs && opts.status ? setInterval(() => {
6710
6861
  if (s.pasting) return;
@@ -6779,11 +6930,11 @@ function createLineEditor(out) {
6779
6930
  }
6780
6931
  s.reset();
6781
6932
  s.refresh();
6782
- render(s, opts.prompt, maxVisible, opts.status);
6933
+ render(s, promptOf(), maxVisible, opts.status);
6783
6934
  return;
6784
6935
  }
6785
6936
  if (s.pasting) return;
6786
- render(s, opts.prompt, maxVisible, opts.status);
6937
+ render(s, promptOf(), maxVisible, opts.status);
6787
6938
  };
6788
6939
  const finish = () => {
6789
6940
  activeRedraw = void 0;
@@ -6794,7 +6945,7 @@ function createLineEditor(out) {
6794
6945
  out.write("\x1B[?2004l");
6795
6946
  s.closeMenu();
6796
6947
  s.end();
6797
- render(s, opts.prompt, maxVisible);
6948
+ render(s, promptOf(), maxVisible);
6798
6949
  out.write("\r\n");
6799
6950
  };
6800
6951
  process.stdin.on("keypress", onKey);
@@ -7284,7 +7435,7 @@ Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with
7284
7435
  Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
7285
7436
 
7286
7437
  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
7438
+ 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
7439
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
7289
7440
  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
7441
  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 +7681,31 @@ function costOf(pricing, promptTokens = 0, completionTokens = 0) {
7530
7681
  function turnCost(model, usage) {
7531
7682
  return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0);
7532
7683
  }
7684
+ async function evaluateGoal(ai, condition, transcript, log17) {
7685
+ const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
7686
+ const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
7687
+ return text.slice(0, 600);
7688
+ }).join("\n---\n");
7689
+ try {
7690
+ const r = await ai.chat({
7691
+ model: "anthropic/claude-haiku-4-5",
7692
+ stream: false,
7693
+ messages: [
7694
+ { 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.' },
7695
+ { role: "user", content: `Goal condition: ${condition}
7696
+
7697
+ Recent assistant messages:
7698
+ ${recent}` }
7699
+ ]
7700
+ });
7701
+ const match = r.content.match(/\{[\s\S]*\}/);
7702
+ if (match) return JSON.parse(match[0]);
7703
+ } catch (e) {
7704
+ log17(dim(` (goal evaluator error: ${e?.message ?? e})
7705
+ `));
7706
+ }
7707
+ return { met: false, reason: "evaluation unclear" };
7708
+ }
7533
7709
  function errInfo(e) {
7534
7710
  return { message: String(e?.message ?? e), statusCode: e?.statusCode, code: e?.code };
7535
7711
  }
@@ -7555,6 +7731,9 @@ async function runShellLine(fs, cmd) {
7555
7731
  if (r.exitCode !== 0) return `[exit ${r.exitCode}]${r.error ? " " + r.error.trim() : ""}${out ? "\n" + out : ""}`;
7556
7732
  return out || "(no output)";
7557
7733
  }
7734
+ function primaryMemDir(dir, fallback) {
7735
+ return (Array.isArray(dir) ? dir[0] : dir) || fallback;
7736
+ }
7558
7737
  async function appendMemoryNote(fs, dir, text) {
7559
7738
  await mkdirp(fs, dir);
7560
7739
  const idx = `${dir}/MEMORY.md`;
@@ -8058,6 +8237,8 @@ async function repl(args, ai, cfg, cwd) {
8058
8237
  dx = new DuplexAgent({
8059
8238
  ai,
8060
8239
  fs: agent.options.fs,
8240
+ memoryDir: agent.options.memoryDir,
8241
+ memoryUserDir: agent.options.memoryUserDir,
8061
8242
  ...args.voiceModel ?? cfg.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel ?? cfg.voiceModel) } : {},
8062
8243
  workerModel: agent.options.model,
8063
8244
  workerOptions,
@@ -8080,16 +8261,18 @@ async function repl(args, ai, cfg, cwd) {
8080
8261
  return "not a git repository";
8081
8262
  }
8082
8263
  },
8083
- // Memory READS are QuickLook material (instant, capped); memory WRITES stay delegated —
8084
- // a worker creates/updates the files under .agent/memory/.
8085
8264
  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/)";
8265
+ const _adot = (s) => `${agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd()}/.agent/${s}`;
8266
+ const dirs = Array.isArray(agent.options.memoryDir) ? agent.options.memoryDir : [agent.options.memoryDir || _adot("memory")];
8267
+ const parts = [];
8268
+ for (const d of dirs) {
8269
+ try {
8270
+ const idx = await fs.readFile(`${d}/MEMORY.md`);
8271
+ if (idx.trim()) parts.push(idx.trim());
8272
+ } catch {
8273
+ }
8092
8274
  }
8275
+ return parts.length ? parts.join("\n").slice(0, 2e3) : "(no memory yet)";
8093
8276
  }
8094
8277
  },
8095
8278
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
@@ -8221,7 +8404,14 @@ async function repl(args, ai, cfg, cwd) {
8221
8404
  face.transcript = data.messages;
8222
8405
  session = data;
8223
8406
  checkpoints.use?.(data.meta.id);
8407
+ const m = data.meta;
8408
+ goalCondition = m.goalCondition;
8409
+ goalTurns = m.goalTurns ?? 0;
8410
+ goalTokens = m.goalTokens ?? 0;
8411
+ goalLastReason = m.goalLastReason;
8224
8412
  err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? " \u2014 " + data.meta.title : ""}
8413
+ `));
8414
+ if (goalCondition) err(dim(` \u25CE goal active: ${goalCondition} (${goalTurns} turns)
8225
8415
  `));
8226
8416
  printHistory(data.messages);
8227
8417
  };
@@ -8365,6 +8555,45 @@ ${extra}` : body);
8365
8555
  const picked = await selectMenu(process.stderr, { title: `Select a model \xB7 current: ${current}`, items: providerItems, current, filterable: true });
8366
8556
  return picked && !picked.startsWith("__") ? picked : null;
8367
8557
  };
8558
+ let goalCondition = session.meta.goalCondition;
8559
+ let goalTurns = session.meta.goalTurns ?? 0;
8560
+ let goalTokens = session.meta.goalTokens ?? 0;
8561
+ let goalLastReason = session.meta.goalLastReason;
8562
+ const GOAL_MAX_TURNS = 50;
8563
+ const persistGoal = () => {
8564
+ const m = session.meta;
8565
+ m.goalCondition = goalCondition;
8566
+ m.goalTurns = goalTurns;
8567
+ m.goalTokens = goalTokens;
8568
+ m.goalLastReason = goalLastReason;
8569
+ };
8570
+ const goalLoop = async () => {
8571
+ while (goalCondition && !aborting && !exitRequested && goalTurns < GOAL_MAX_TURNS) {
8572
+ const result = await evaluateGoal(ai, goalCondition, face.transcript, err);
8573
+ goalLastReason = result.reason;
8574
+ if (result.met) {
8575
+ err(green(` \u2713 goal met: ${result.reason}
8576
+ `));
8577
+ goalCondition = void 0;
8578
+ goalLastReason = void 0;
8579
+ persistGoal();
8580
+ return;
8581
+ }
8582
+ err(dim(` \u25CE not yet (${result.reason}) \u2014 turn ${goalTurns + 1}
8583
+ `));
8584
+ aborting = false;
8585
+ const tokensBefore = session.meta.tokens ?? 0;
8586
+ await turn(`Continue working toward the goal: ${goalCondition}`);
8587
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
8588
+ goalTurns++;
8589
+ persistGoal();
8590
+ if (exitRequested) return;
8591
+ }
8592
+ if (goalTurns >= GOAL_MAX_TURNS) {
8593
+ err(yellow(` \u26A0 goal reached ${GOAL_MAX_TURNS} turns \u2014 pausing. /goal to check status, /goal clear to cancel
8594
+ `));
8595
+ }
8596
+ };
8368
8597
  const builtins = {
8369
8598
  help: { desc: "show this help", run: () => {
8370
8599
  err(HELP + "\n");
@@ -8857,6 +9086,49 @@ ${extra}` : body);
8857
9086
  }
8858
9087
  }
8859
9088
  },
9089
+ goal: {
9090
+ desc: "autonomous loop \u2014 /goal <condition> | /goal (status) | /goal clear",
9091
+ run: async (a) => {
9092
+ if (!a.length) {
9093
+ if (!goalCondition) {
9094
+ err(dim(" no active goal\n"));
9095
+ return;
9096
+ }
9097
+ const tokStr = goalTokens > 1e3 ? `${(goalTokens / 1e3).toFixed(1)}k` : String(goalTokens);
9098
+ err(` ${bold("\u25CE goal:")} ${goalCondition}
9099
+ ` + dim(` ${goalTurns} turn${goalTurns === 1 ? "" : "s"} \xB7 ${tokStr} tokens${goalLastReason ? ` \xB7 last: ${goalLastReason}` : ""}
9100
+ `));
9101
+ return;
9102
+ }
9103
+ if (a[0] === "clear") {
9104
+ if (!goalCondition) {
9105
+ err(dim(" no active goal\n"));
9106
+ return;
9107
+ }
9108
+ goalCondition = void 0;
9109
+ goalTurns = 0;
9110
+ goalTokens = 0;
9111
+ goalLastReason = void 0;
9112
+ persistGoal();
9113
+ err(green(" \u2713 goal cleared\n"));
9114
+ return;
9115
+ }
9116
+ goalCondition = a.join(" ");
9117
+ goalTurns = 0;
9118
+ goalTokens = 0;
9119
+ goalLastReason = void 0;
9120
+ persistGoal();
9121
+ err(green(` \u25CE goal set: ${goalCondition}
9122
+ `) + dim(" working\u2026 (Esc to pause)\n"));
9123
+ const tokensBefore = session.meta.tokens ?? 0;
9124
+ await turn(goalCondition);
9125
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
9126
+ goalTurns++;
9127
+ persistGoal();
9128
+ if (!exitRequested) await goalLoop();
9129
+ if (exitRequested) return true;
9130
+ }
9131
+ },
8860
9132
  exit: { desc: "quit", run: () => true },
8861
9133
  quit: { desc: "quit", run: () => true }
8862
9134
  };
@@ -8965,7 +9237,7 @@ ${extra}` : body);
8965
9237
  if (line.startsWith("#")) {
8966
9238
  const note = line.slice(1).trim();
8967
9239
  if (note) {
8968
- const where = await appendMemoryNote(agent.options.fs, agent.options.memoryDir || adot("memory"), note);
9240
+ const where = await appendMemoryNote(agent.options.fs, primaryMemDir(agent.options.memoryDir, adot("memory")), note);
8969
9241
  err(green(` \u270E remembered \u2192 ${where}
8970
9242
  `));
8971
9243
  }
@@ -9001,6 +9273,11 @@ ${extra}` : body);
9001
9273
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
9002
9274
  pendingImages.length = 0;
9003
9275
  await turn(task);
9276
+ if (goalCondition && !aborting && !exitRequested) {
9277
+ goalTurns++;
9278
+ persistGoal();
9279
+ await goalLoop();
9280
+ }
9004
9281
  if (exitRequested) return "quit";
9005
9282
  };
9006
9283
  let voicePartial = "";
@@ -9092,6 +9369,7 @@ ${extra}` : body);
9092
9369
  const r = work.reasoning;
9093
9370
  if (r && r !== "off") parts.push(`reasoning:${r}`);
9094
9371
  if (verboseOutput) parts.push("verbose");
9372
+ if (goalCondition) parts.push(`\u25CE goal (${goalTurns} turns)`);
9095
9373
  if (inputStash.length) parts.push(`${inputStash.length} stashed (\u2303S to pop)`);
9096
9374
  const taskLines = [];
9097
9375
  if (dx) {
@@ -9101,8 +9379,14 @@ ${extra}` : body);
9101
9379
  }
9102
9380
  return [...taskLines, parts.join(" \xB7 ")].filter(Boolean).join("\n");
9103
9381
  };
9382
+ const livePrompt = () => {
9383
+ const ask = dx?.pendingAsks.size ? dx.pendingAsks.values().next().value : void 0;
9384
+ if (!ask) return promptStr;
9385
+ const q2 = ask.question.replace(/\s+/g, " ").slice(0, 64);
9386
+ return bold(yellow(`? ${q2}${ask.question.length > 64 ? "\u2026" : ""} \u2039yes/no\u203A `));
9387
+ };
9104
9388
  const result = await readMultiline((cont) => editor.readLine({
9105
- prompt: cont ? contPrompt : promptStr,
9389
+ prompt: cont ? contPrompt : livePrompt,
9106
9390
  suggest,
9107
9391
  history,
9108
9392
  classifyPaste,
@@ -9140,6 +9424,13 @@ ${extra}` : body);
9140
9424
  }
9141
9425
  const line = result.trim();
9142
9426
  if (!line) continue;
9427
+ if (dx?.pendingAsks.size && /^(y(es|ep|eah)?|n(o|ope)?|sure|ok(ay)?|allow|deny|go( ahead)?)[.!]?$/i.test(line)) {
9428
+ const [id, ask] = dx.pendingAsks.entries().next().value;
9429
+ ask.resolve(line);
9430
+ err(dim(` \u21B3 answered ${id}: ${line}
9431
+ `));
9432
+ continue;
9433
+ }
9143
9434
  let quit = await dispatchLine(line) === "quit";
9144
9435
  while (!quit && inputStash.length) {
9145
9436
  const next = inputStash.shift();
@@ -9243,7 +9534,8 @@ async function main() {
9243
9534
  process.once("SIGINT", () => activeTurn?.abort());
9244
9535
  const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
9245
9536
  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 });
9537
+ const _fsBase = agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd();
9538
+ const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
9247
9539
  if (slug) err(dim(` \u270E learned a lesson \u2192 ${slug}
9248
9540
  `));
9249
9541
  }