agent.libx.js 0.92.8 → 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() {
@@ -3589,6 +3681,7 @@ ${recent}` : brief;
3589
3681
  let steps = 0;
3590
3682
  let inflight = null;
3591
3683
  const due = () => {
3684
+ if (this.pendingAsks.size) return void 0;
3592
3685
  const rec = this.tasks.get(id);
3593
3686
  return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
3594
3687
  };
@@ -3866,18 +3959,15 @@ var VoiceEngineOptions = class {
3866
3959
  /** heuristic (non-AEC) energy barge-in tuning */
3867
3960
  bargeRmsMult = 2;
3868
3961
  bargeRmsFloor = 500;
3869
- /** Overlap turn-taking (AEC tier, needs player.pause/resume) — human phone-call model:
3870
- * onset PAUSE (exact-sample hold, nothing lost); sustained overlap cede (interrupt; the LLM
3871
- * re-enters). Brief overlaps that die out (backchannels"mm-hm", decided by DURATION, not
3872
- * 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. */
3873
3968
  overlapPause = true;
3874
- /** sustained overlap this → cede the turn */
3875
- overlapSustainMs = 350;
3876
- /** quiet for this long while paused → resume, drop the interjection */
3969
+ /** no new partial activity for this long while paused resume, drop the interjection */
3877
3970
  overlapResumeMs = 700;
3878
- /** energy floor for "overlap candidate" — must sit ABOVE typical room ambient (~110 rms measured;
3879
- * ungated ambient re-arming the resume timer forever was a live wedge). User speech ≫ 300. */
3880
- overlapRms = 300;
3881
3971
  };
3882
3972
  var VoiceEngine = class {
3883
3973
  options;
@@ -3910,10 +4000,8 @@ var VoiceEngine = class {
3910
4000
  lastInterrupted = null;
3911
4001
  // overlap (pause) tier state — AEC + pause-capable sinks only
3912
4002
  pausedAt = 0;
3913
- overlapLoud = 0;
3914
- // loud chunks since pause (sustain must be real sound, not two clicks)
3915
- overlapLastLoudAt = 0;
3916
- // continuity guard: a gap re-arms the onset (sparse noise ≠ sustained speech)
4003
+ lastOverlapPartial = "";
4004
+ // change-detection: only NEW partial text counts as activity
3917
4005
  resumeTimer = null;
3918
4006
  constructor(options) {
3919
4007
  this.options = { ...new VoiceEngineOptions(), ...options };
@@ -4077,7 +4165,24 @@ var VoiceEngine = class {
4077
4165
  }
4078
4166
  handlePartial(text) {
4079
4167
  if (this.speaking) {
4080
- 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);
4081
4186
  if (barge) {
4082
4187
  const phase = this.ctxOpen ? "speaking" : "drain";
4083
4188
  this.interrupt();
@@ -4086,15 +4191,13 @@ var VoiceEngine = class {
4086
4191
  return;
4087
4192
  }
4088
4193
  if (this.pendingUtt && text.trim()) {
4089
- if (this.pendingTimer) {
4090
- clearTimeout(this.pendingTimer);
4091
- this.pendingTimer = null;
4092
- }
4194
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
4195
+ this.pendingTimer = setTimeout(() => this.flushUtterance(), Math.max(800, this.options.utteranceMergeMs));
4093
4196
  }
4094
4197
  if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
4095
4198
  }
4096
4199
  handleUtterance(text) {
4097
- if (this.speaking && this.ctxOpen && this.overlapCapable) {
4200
+ if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable) {
4098
4201
  this.stt.reset();
4099
4202
  return;
4100
4203
  }
@@ -4109,7 +4212,7 @@ var VoiceEngine = class {
4109
4212
  }
4110
4213
  this.pendingUtt = this.pendingUtt ? `${this.pendingUtt} ${text}` : text;
4111
4214
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
4112
- if (!this.options.utteranceMergeMs) return this.flushUtterance();
4215
+ if (!this.options.utteranceMergeMs || this.words(this.pendingUtt).length >= 4) return this.flushUtterance();
4113
4216
  this.pendingTimer = setTimeout(() => this.flushUtterance(), this.options.utteranceMergeMs);
4114
4217
  }
4115
4218
  flushUtterance() {
@@ -4124,45 +4227,12 @@ var VoiceEngine = class {
4124
4227
  get overlapCapable() {
4125
4228
  return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
4126
4229
  }
4127
- /** Overlap turn-taking (AEC tier): onset → pause (exact-sample hold); sustained → cede; died out
4128
- * → resume. No vocabulary anywhere — duration and persistence decide (backchannels are short
4129
- * and stop). Nothing is lost across a pause, so a false positive costs only a brief hold. */
4130
- handleOverlap(rms) {
4131
- const o = this.options;
4132
- if (!this.speaking || !this.overlapCapable) return;
4133
- if (rms < o.overlapRms) return;
4134
- const t = now();
4135
- if (!this.pausedAt) {
4136
- this.overlapLoud = t - this.overlapLastLoudAt <= 60 ? this.overlapLoud + 1 : 1;
4137
- this.overlapLastLoudAt = t;
4138
- if (this.overlapLoud < 3) return;
4139
- this.pausedAt = t;
4140
- this.player.pause();
4141
- this.armResume();
4142
- return;
4143
- }
4144
- if (t - this.overlapLastLoudAt > 300) {
4145
- this.pausedAt = t;
4146
- this.overlapLoud = 1;
4147
- this.overlapLastLoudAt = t;
4148
- this.armResume();
4149
- return;
4150
- }
4151
- this.overlapLastLoudAt = t;
4152
- this.overlapLoud++;
4153
- if (t - this.pausedAt >= o.overlapSustainMs && this.overlapLoud >= 4) {
4154
- const phase = this.ctxOpen ? "speaking" : "drain";
4155
- this.interrupt();
4156
- this.options.onBargeIn(phase);
4157
- return;
4158
- }
4159
- this.armResume();
4160
- }
4161
4230
  armResume() {
4162
4231
  if (this.resumeTimer) clearTimeout(this.resumeTimer);
4163
4232
  this.resumeTimer = setTimeout(() => {
4164
4233
  this.resumeTimer = null;
4165
4234
  if (!this.pausedAt) return;
4235
+ this.stt.reset();
4166
4236
  this.resetOverlap(true);
4167
4237
  }, this.options.overlapResumeMs);
4168
4238
  }
@@ -4173,11 +4243,25 @@ var VoiceEngine = class {
4173
4243
  }
4174
4244
  if (this.pausedAt && resume) this.player.resume?.();
4175
4245
  this.pausedAt = 0;
4176
- this.overlapLoud = 0;
4246
+ this.lastOverlapPartial = "";
4247
+ this.gatePassTimes = [];
4177
4248
  }
4178
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)
4179
4252
  handleLevel(rms) {
4180
- 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
+ }
4181
4265
  if (!this.speaking) {
4182
4266
  this.baseline = 0;
4183
4267
  this.hot = 0;
@@ -4217,6 +4301,9 @@ var SonioxSTTOptions = class {
4217
4301
  source;
4218
4302
  model = "stt-rt-preview";
4219
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;
4220
4307
  };
4221
4308
  var SonioxSTT = class {
4222
4309
  options;
@@ -4232,6 +4319,9 @@ var SonioxSTT = class {
4232
4319
  };
4233
4320
  finalText = "";
4234
4321
  partialText = "";
4322
+ lastChangeAt = 0;
4323
+ lastCombined = "";
4324
+ endpointTimer = null;
4235
4325
  constructor(options) {
4236
4326
  this.options = { ...new SonioxSTTOptions(), ...options };
4237
4327
  }
@@ -4268,6 +4358,13 @@ var SonioxSTT = class {
4268
4358
  await this.connectWs();
4269
4359
  if (this.sourceStarted) return;
4270
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?.();
4271
4368
  await this.options.source.start((chunk) => {
4272
4369
  let sum = 0;
4273
4370
  const view = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength);
@@ -4287,7 +4384,12 @@ var SonioxSTT = class {
4287
4384
  else if (t.is_final) this.finalText += t.text;
4288
4385
  }
4289
4386
  this.partialText = (m.tokens ?? []).filter((t) => !t.is_final && t.text !== "<end>").map((t) => t.text).join("");
4290
- 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);
4291
4393
  if (endpoint && this.finalText.trim()) {
4292
4394
  const utterance = this.finalText.trim();
4293
4395
  this.reset();
@@ -4297,9 +4399,11 @@ var SonioxSTT = class {
4297
4399
  reset() {
4298
4400
  this.finalText = "";
4299
4401
  this.partialText = "";
4402
+ this.lastCombined = "";
4300
4403
  }
4301
4404
  stop() {
4302
4405
  this.stopped = true;
4406
+ if (this.endpointTimer) clearInterval(this.endpointTimer);
4303
4407
  this.options.source?.stop();
4304
4408
  if (this.ws) this.ws.onclose = null;
4305
4409
  this.ws?.close();
@@ -4919,8 +5023,26 @@ Reference files in them by their mount path (the left side).`;
4919
5023
  ].filter(Boolean);
4920
5024
  return dirs.length ? dirs : void 0;
4921
5025
  };
4922
- const memoryDir = dot("memory");
4923
- 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;
4924
5046
  let realShell = [];
4925
5047
  const useRealShell = o.realShell ?? !virtual;
4926
5048
  if (useRealShell && !virtual) {
@@ -4977,6 +5099,7 @@ The filesystem root '/' is the real machine root \u2014 you have full filesystem
4977
5099
  skillsDir: dots("skills"),
4978
5100
  commandsDir: dots("commands"),
4979
5101
  memoryDir,
5102
+ memoryUserDir,
4980
5103
  agentsDir: dot("agents"),
4981
5104
  instructionFiles: o.instructionFiles ?? ["AGENTS.md", "CLAUDE.md"],
4982
5105
  // Only override the Agent's safety defaults when explicitly configured.
@@ -5139,13 +5262,16 @@ var AecDuplexAudio = class {
5139
5262
  startedAt = 0;
5140
5263
  // --- AudioSource ---
5141
5264
  start(onChunk) {
5142
- this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "ignore"] });
5265
+ this.proc = spawn2(this.bin, [], { stdio: ["pipe", "pipe", "pipe"] });
5143
5266
  this.proc.stdin.on("error", () => {
5144
5267
  });
5145
5268
  this.proc.on("exit", (c) => {
5146
5269
  if (c && !this.stopped) log12.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
5147
5270
  });
5148
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
+ });
5149
5275
  }
5150
5276
  stop() {
5151
5277
  this.stopped = true;
@@ -5222,6 +5348,17 @@ var AecDuplexAudio = class {
5222
5348
  pausedMs() {
5223
5349
  return this.pausedAccum + (this.pausedSince ? now4() - this.pausedSince : 0);
5224
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
+ }
5225
5362
  };
5226
5363
  var VoiceIOOptions = class extends VoiceEngineOptions {
5227
5364
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
@@ -6682,14 +6819,15 @@ function createLineEditor(out) {
6682
6819
  process.stdin.setRawMode(true);
6683
6820
  process.stdin.resume();
6684
6821
  out.write("\x1B[?2004h");
6685
- 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);
6686
6824
  const onResize = () => {
6687
6825
  curRow = 0;
6688
- render(s, opts.prompt, maxVisible, opts.status);
6826
+ render(s, promptOf(), maxVisible, opts.status);
6689
6827
  };
6690
6828
  activeRedraw = () => {
6691
6829
  curRow = 0;
6692
- render(s, opts.prompt, maxVisible, opts.status);
6830
+ render(s, promptOf(), maxVisible, opts.status);
6693
6831
  };
6694
6832
  process.on("SIGWINCH", onResize);
6695
6833
  return new Promise((resolve4) => {
@@ -6697,7 +6835,7 @@ function createLineEditor(out) {
6697
6835
  finish();
6698
6836
  resolve4(null);
6699
6837
  };
6700
- const redraw = () => render(s, opts.prompt, maxVisible, opts.status);
6838
+ const redraw = () => render(s, promptOf(), maxVisible, opts.status);
6701
6839
  let lastStatus = opts.status?.() ?? "";
6702
6840
  const ticker = opts.statusTickMs && opts.status ? setInterval(() => {
6703
6841
  if (s.pasting) return;
@@ -6772,11 +6910,11 @@ function createLineEditor(out) {
6772
6910
  }
6773
6911
  s.reset();
6774
6912
  s.refresh();
6775
- render(s, opts.prompt, maxVisible, opts.status);
6913
+ render(s, promptOf(), maxVisible, opts.status);
6776
6914
  return;
6777
6915
  }
6778
6916
  if (s.pasting) return;
6779
- render(s, opts.prompt, maxVisible, opts.status);
6917
+ render(s, promptOf(), maxVisible, opts.status);
6780
6918
  };
6781
6919
  const finish = () => {
6782
6920
  activeRedraw = void 0;
@@ -6787,7 +6925,7 @@ function createLineEditor(out) {
6787
6925
  out.write("\x1B[?2004l");
6788
6926
  s.closeMenu();
6789
6927
  s.end();
6790
- render(s, opts.prompt, maxVisible);
6928
+ render(s, promptOf(), maxVisible);
6791
6929
  out.write("\r\n");
6792
6930
  };
6793
6931
  process.stdin.on("keypress", onKey);
@@ -7277,7 +7415,7 @@ Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with
7277
7415
  Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
7278
7416
 
7279
7417
  REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \xB7 @path inlines a file
7280
- 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
7281
7419
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
7282
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.
7283
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.
@@ -7523,6 +7661,31 @@ function costOf(pricing, promptTokens = 0, completionTokens = 0) {
7523
7661
  function turnCost(model, usage) {
7524
7662
  return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0);
7525
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
+ }
7526
7689
  function errInfo(e) {
7527
7690
  return { message: String(e?.message ?? e), statusCode: e?.statusCode, code: e?.code };
7528
7691
  }
@@ -7548,6 +7711,9 @@ async function runShellLine(fs, cmd) {
7548
7711
  if (r.exitCode !== 0) return `[exit ${r.exitCode}]${r.error ? " " + r.error.trim() : ""}${out ? "\n" + out : ""}`;
7549
7712
  return out || "(no output)";
7550
7713
  }
7714
+ function primaryMemDir(dir, fallback) {
7715
+ return (Array.isArray(dir) ? dir[0] : dir) || fallback;
7716
+ }
7551
7717
  async function appendMemoryNote(fs, dir, text) {
7552
7718
  await mkdirp(fs, dir);
7553
7719
  const idx = `${dir}/MEMORY.md`;
@@ -7966,13 +8132,17 @@ async function repl(args, ai, cfg, cwd) {
7966
8132
  const duplexAsk = async (call) => {
7967
8133
  if (args.voice && dx) {
7968
8134
  const hint = summarizeCall(call.name, call.args).slice(0, 80);
8135
+ if (cfg.voiceAskUi === "menu") {
8136
+ editorRef?.suspend();
8137
+ const v = await selectMenu(process.stderr, { title: `? background worker asks to run ${call.name} ${hint}`, items: [{ label: "Allow", value: "y" }, { label: "Deny", value: "n" }], current: "n" });
8138
+ editorRef?.resume();
8139
+ editorRef?.redrawNow();
8140
+ return { decision: v === "y" ? "allow" : "deny" };
8141
+ }
7969
8142
  const id = `perm-${++permSeq}`;
7970
- err("\r\x1B[0J" + yellow(` ? worker asks to run ${call.name}`) + dim(` ${hint} \u2014 say yes/no (or it auto-denies)
7971
- `));
7972
- editorRef?.redrawNow();
7973
- const a = await dx.parkQuestion(id, `Permission: may the background worker run ${call.name}${hint ? ` (${hint})` : ""}? Yes or no.`);
8143
+ const a = await dx.parkQuestion(id, `Permission: may the background worker run ${call.name}${hint ? ` (${hint})` : ""}? Answer yes or no (you can also type it).`);
7974
8144
  const allow = /^\s*(y(es|ep|eah)?|sure|ok(ay)?|allow|go|approved?|do it)\b/i.test(a);
7975
- err("\r\x1B[0J" + dim(` ${allow ? "\u2713 allowed" : "\u2298 denied"} ${call.name} (${a.trim() || "no answer"})
8145
+ err("\r\x1B[0J" + (allow ? green(` \u2713 allowed ${call.name}`) : yellow(` \u2298 denied ${call.name}`)) + dim(` (${a.trim() || "no answer"})
7976
8146
  `));
7977
8147
  editorRef?.redrawNow();
7978
8148
  return { decision: allow ? "allow" : "deny" };
@@ -8022,8 +8192,9 @@ async function repl(args, ai, cfg, cwd) {
8022
8192
  }
8023
8193
  if (typeof e.kind === "string" && e.kind.startsWith("task_")) {
8024
8194
  spinner.stop();
8025
- err("\r\x1B[0J" + dim(` \xB7 ${e.message}
8026
- `));
8195
+ err("\r\x1B[0J" + (e.kind === "task_ask" ? yellow(` ? ${e.message} \u2014 answer by voice or type yes/no
8196
+ `) : dim(` \xB7 ${e.message}
8197
+ `)));
8027
8198
  editorRef?.redrawNow();
8028
8199
  return;
8029
8200
  }
@@ -8046,6 +8217,8 @@ async function repl(args, ai, cfg, cwd) {
8046
8217
  dx = new DuplexAgent({
8047
8218
  ai,
8048
8219
  fs: agent.options.fs,
8220
+ memoryDir: agent.options.memoryDir,
8221
+ memoryUserDir: agent.options.memoryUserDir,
8049
8222
  ...args.voiceModel ?? cfg.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel ?? cfg.voiceModel) } : {},
8050
8223
  workerModel: agent.options.model,
8051
8224
  workerOptions,
@@ -8068,16 +8241,18 @@ async function repl(args, ai, cfg, cwd) {
8068
8241
  return "not a git repository";
8069
8242
  }
8070
8243
  },
8071
- // Memory READS are QuickLook material (instant, capped); memory WRITES stay delegated —
8072
- // a worker creates/updates the files under .agent/memory/.
8073
8244
  memory: async () => {
8074
- const dir = agent.options.memoryDir || adot("memory");
8075
- try {
8076
- const idx = await fs.readFile(`${dir}/MEMORY.md`);
8077
- return idx.slice(0, 2e3) || "(memory index is empty)";
8078
- } catch {
8079
- 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
+ }
8080
8254
  }
8255
+ return parts.length ? parts.join("\n").slice(0, 2e3) : "(no memory yet)";
8081
8256
  }
8082
8257
  },
8083
8258
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
@@ -8209,7 +8384,14 @@ async function repl(args, ai, cfg, cwd) {
8209
8384
  face.transcript = data.messages;
8210
8385
  session = data;
8211
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;
8212
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)
8213
8395
  `));
8214
8396
  printHistory(data.messages);
8215
8397
  };
@@ -8353,6 +8535,45 @@ ${extra}` : body);
8353
8535
  const picked = await selectMenu(process.stderr, { title: `Select a model \xB7 current: ${current}`, items: providerItems, current, filterable: true });
8354
8536
  return picked && !picked.startsWith("__") ? picked : null;
8355
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
+ };
8356
8577
  const builtins = {
8357
8578
  help: { desc: "show this help", run: () => {
8358
8579
  err(HELP + "\n");
@@ -8845,6 +9066,49 @@ ${extra}` : body);
8845
9066
  }
8846
9067
  }
8847
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
+ },
8848
9112
  exit: { desc: "quit", run: () => true },
8849
9113
  quit: { desc: "quit", run: () => true }
8850
9114
  };
@@ -8953,7 +9217,7 @@ ${extra}` : body);
8953
9217
  if (line.startsWith("#")) {
8954
9218
  const note = line.slice(1).trim();
8955
9219
  if (note) {
8956
- 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);
8957
9221
  err(green(` \u270E remembered \u2192 ${where}
8958
9222
  `));
8959
9223
  }
@@ -8989,6 +9253,11 @@ ${extra}` : body);
8989
9253
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
8990
9254
  pendingImages.length = 0;
8991
9255
  await turn(task);
9256
+ if (goalCondition && !aborting && !exitRequested) {
9257
+ goalTurns++;
9258
+ persistGoal();
9259
+ await goalLoop();
9260
+ }
8992
9261
  if (exitRequested) return "quit";
8993
9262
  };
8994
9263
  let voicePartial = "";
@@ -9080,6 +9349,7 @@ ${extra}` : body);
9080
9349
  const r = work.reasoning;
9081
9350
  if (r && r !== "off") parts.push(`reasoning:${r}`);
9082
9351
  if (verboseOutput) parts.push("verbose");
9352
+ if (goalCondition) parts.push(`\u25CE goal (${goalTurns} turns)`);
9083
9353
  if (inputStash.length) parts.push(`${inputStash.length} stashed (\u2303S to pop)`);
9084
9354
  const taskLines = [];
9085
9355
  if (dx) {
@@ -9089,8 +9359,14 @@ ${extra}` : body);
9089
9359
  }
9090
9360
  return [...taskLines, parts.join(" \xB7 ")].filter(Boolean).join("\n");
9091
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
+ };
9092
9368
  const result = await readMultiline((cont) => editor.readLine({
9093
- prompt: cont ? contPrompt : promptStr,
9369
+ prompt: cont ? contPrompt : livePrompt,
9094
9370
  suggest,
9095
9371
  history,
9096
9372
  classifyPaste,
@@ -9128,6 +9404,13 @@ ${extra}` : body);
9128
9404
  }
9129
9405
  const line = result.trim();
9130
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
+ }
9131
9414
  let quit = await dispatchLine(line) === "quit";
9132
9415
  while (!quit && inputStash.length) {
9133
9416
  const next = inputStash.shift();
@@ -9231,7 +9514,8 @@ async function main() {
9231
9514
  process.once("SIGINT", () => activeTurn?.abort());
9232
9515
  const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
9233
9516
  if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
9234
- 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 });
9235
9519
  if (slug) err(dim(` \u270E learned a lesson \u2192 ${slug}
9236
9520
  `));
9237
9521
  }