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/cli/cli.ts CHANGED
@@ -238,7 +238,7 @@ Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with
238
238
  Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
239
239
 
240
240
  REPL shortcuts: !<cmd> runs a shell command inline · #<note> saves a memory · @path inlines a file
241
- 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
241
+ 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
242
242
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu — ↑/↓ select, ⏎/Tab accept, Esc dismiss.
243
243
  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.
244
244
  REPL shortcuts: Shift+Tab cycles permission posture (ask → accept-edits → plan) · Alt+T toggles reasoning · Alt+P switches model · Ctrl+O toggles verbose tool output · → or Tab accepts the dim history ghost-suggestion · Alt+S/Ctrl+S stash/unstash.
@@ -504,6 +504,31 @@ function turnCost(model: string, usage?: { promptTokens?: number; completionToke
504
504
  return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0);
505
505
  }
506
506
 
507
+ /** Evaluate whether a goal condition has been met, based on recent transcript. */
508
+ async function evaluateGoal(ai: ChatLike, condition: string, transcript: Message[], log: (s: string) => void): Promise<{ met: boolean; reason: string }> {
509
+ const recent = transcript
510
+ .filter((m) => m.role === 'assistant')
511
+ .slice(-8)
512
+ .map((m) => {
513
+ const text = typeof m.content === 'string' ? m.content : (m.content as ContentPart[]).filter((p: any) => p.type === 'text').map((p: any) => p.text).join(' ');
514
+ return text.slice(0, 600);
515
+ })
516
+ .join('\n---\n');
517
+ try {
518
+ const r = await ai.chat({
519
+ model: 'anthropic/claude-haiku-4-5',
520
+ stream: false,
521
+ messages: [
522
+ { 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 — only met:true if there is clear evidence in the transcript.' },
523
+ { role: 'user', content: `Goal condition: ${condition}\n\nRecent assistant messages:\n${recent}` },
524
+ ],
525
+ }) as { content: string };
526
+ const match = r.content.match(/\{[\s\S]*\}/);
527
+ if (match) return JSON.parse(match[0]);
528
+ } catch (e: any) { log(dim(` (goal evaluator error: ${e?.message ?? e})\n`)); }
529
+ return { met: false, reason: 'evaluation unclear' };
530
+ }
531
+
507
532
  /** Normalize a thrown/returned error into the persisted forensic shape. */
508
533
  function errInfo(e: any): { message: string; statusCode?: number; code?: string } {
509
534
  return { message: String(e?.message ?? e), statusCode: e?.statusCode, code: e?.code };
@@ -547,6 +572,11 @@ export async function runShellLine(fs: IFilesystem, cmd: string): Promise<string
547
572
  return out || '(no output)';
548
573
  }
549
574
 
575
+ /** Resolve a memoryDir (string or string[]) to the primary (write) dir. */
576
+ function primaryMemDir(dir: string | string[] | undefined, fallback: string): string {
577
+ return (Array.isArray(dir) ? dir[0] : dir) || fallback;
578
+ }
579
+
550
580
  /** Append a `#note` to the memory index (creating the dir/file if needed). Returns the file path. */
551
581
  export async function appendMemoryNote(fs: IFilesystem, dir: string, text: string): Promise<string> {
552
582
  await mkdirp(fs, dir);
@@ -1092,6 +1122,8 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1092
1122
  dx = new DuplexAgent({
1093
1123
  ai,
1094
1124
  fs: agent.options.fs,
1125
+ memoryDir: agent.options.memoryDir,
1126
+ memoryUserDir: agent.options.memoryUserDir,
1095
1127
  ...((args.voiceModel ?? cfg.voiceModel) ? { voiceModel: resolveModelOrNewest((args.voiceModel ?? cfg.voiceModel)!) } : {}),
1096
1128
  workerModel: agent.options.model,
1097
1129
  workerOptions,
@@ -1109,14 +1141,14 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1109
1141
  return head.startsWith('ref: refs/heads/') ? `branch: ${head.slice('ref: refs/heads/'.length)}` : `detached HEAD at ${head.slice(0, 12)}`;
1110
1142
  } catch { return 'not a git repository'; }
1111
1143
  },
1112
- // Memory READS are QuickLook material (instant, capped); memory WRITES stay delegated —
1113
- // a worker creates/updates the files under .agent/memory/.
1114
1144
  memory: async () => {
1115
- const dir = agent.options.memoryDir || adot('memory');
1116
- try {
1117
- const idx = await fs.readFile(`${dir}/MEMORY.md`);
1118
- return idx.slice(0, 2000) || '(memory index is empty)';
1119
- } catch { return 'no memory yet to save something, Delegate it (a worker writes .agent/memory/)'; }
1145
+ const _adot = (s: string) => `${(agent.options.fs!.getCwd() === '/' ? '' : agent.options.fs!.getCwd())}/.agent/${s}`;
1146
+ const dirs = Array.isArray(agent.options.memoryDir) ? agent.options.memoryDir : [agent.options.memoryDir || _adot('memory')];
1147
+ const parts: string[] = [];
1148
+ for (const d of dirs) {
1149
+ try { const idx = await fs.readFile(`${d}/MEMORY.md`); if (idx.trim()) parts.push(idx.trim()); } catch { /* dir doesn't exist yet */ }
1150
+ }
1151
+ return parts.length ? parts.join('\n').slice(0, 2000) : '(no memory yet)';
1120
1152
  },
1121
1153
  },
1122
1154
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
@@ -1238,7 +1270,14 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1238
1270
  const s = Math.max(0, (Date.now() - t) / 1000);
1239
1271
  return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
1240
1272
  };
1241
- const resumeInto = (data: SessionData) => { face.transcript = data.messages; session = data; checkpoints.use?.(data.meta.id); err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? ' — ' + data.meta.title : ''}\n`)); printHistory(data.messages); };
1273
+ const resumeInto = (data: SessionData) => {
1274
+ face.transcript = data.messages; session = data; checkpoints.use?.(data.meta.id);
1275
+ const m = data.meta as any;
1276
+ goalCondition = m.goalCondition; goalTurns = m.goalTurns ?? 0; goalTokens = m.goalTokens ?? 0; goalLastReason = m.goalLastReason;
1277
+ err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? ' — ' + data.meta.title : ''}\n`));
1278
+ if (goalCondition) err(dim(` ◎ goal active: ${goalCondition} (${goalTurns} turns)\n`));
1279
+ printHistory(data.messages);
1280
+ };
1242
1281
  // Double-Esc rewind (CC parity): pick an earlier user message, then choose what to restore —
1243
1282
  // conversation, code, or both. Returns the message text to pre-fill the prompt (when the conversation
1244
1283
  // is rewound) for editing + resend, or undefined (cancelled, or code-only).
@@ -1365,6 +1404,41 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1365
1404
  return picked && !picked.startsWith('__') ? picked : null;
1366
1405
  };
1367
1406
 
1407
+ let goalCondition: string | undefined = (session.meta as any).goalCondition;
1408
+ let goalTurns: number = (session.meta as any).goalTurns ?? 0;
1409
+ let goalTokens: number = (session.meta as any).goalTokens ?? 0;
1410
+ let goalLastReason: string | undefined = (session.meta as any).goalLastReason;
1411
+ const GOAL_MAX_TURNS = 50;
1412
+ const persistGoal = () => {
1413
+ const m = session.meta as any;
1414
+ m.goalCondition = goalCondition; m.goalTurns = goalTurns; m.goalTokens = goalTokens; m.goalLastReason = goalLastReason;
1415
+ };
1416
+
1417
+ // ---- /goal: autonomous loop with a halting condition ----
1418
+ const goalLoop = async () => {
1419
+ while (goalCondition && !aborting && !exitRequested && goalTurns < GOAL_MAX_TURNS) {
1420
+ const result = await evaluateGoal(ai, goalCondition, face.transcript, err);
1421
+ goalLastReason = result.reason;
1422
+ if (result.met) {
1423
+ err(green(` ✓ goal met: ${result.reason}\n`));
1424
+ goalCondition = undefined; goalLastReason = undefined;
1425
+ persistGoal();
1426
+ return;
1427
+ }
1428
+ err(dim(` ◎ not yet (${result.reason}) — turn ${goalTurns + 1}\n`));
1429
+ aborting = false;
1430
+ const tokensBefore = session.meta.tokens ?? 0;
1431
+ await turn(`Continue working toward the goal: ${goalCondition}`);
1432
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
1433
+ goalTurns++;
1434
+ persistGoal();
1435
+ if (exitRequested) return;
1436
+ }
1437
+ if (goalTurns >= GOAL_MAX_TURNS) {
1438
+ err(yellow(` ⚠ goal reached ${GOAL_MAX_TURNS} turns — pausing. /goal to check status, /goal clear to cancel\n`));
1439
+ }
1440
+ };
1441
+
1368
1442
  // ---- slash builtins: name → handler. Returns true to exit the REPL. ----
1369
1443
  const builtins: Record<string, { desc: string; run: (a: string[]) => boolean | void | Promise<boolean | void> }> = {
1370
1444
  help: { desc: 'show this help', run: () => { err(HELP + '\n'); } },
@@ -1704,6 +1778,35 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1704
1778
  } catch (e: any) { err(red(` export failed: ${e?.message ?? e}\n`)); }
1705
1779
  },
1706
1780
  },
1781
+ goal: {
1782
+ desc: 'autonomous loop — /goal <condition> | /goal (status) | /goal clear',
1783
+ run: async (a) => {
1784
+ if (!a.length) {
1785
+ if (!goalCondition) { err(dim(' no active goal\n')); return; }
1786
+ const tokStr = goalTokens > 1000 ? `${(goalTokens / 1000).toFixed(1)}k` : String(goalTokens);
1787
+ err(` ${bold('◎ goal:')} ${goalCondition}\n` + dim(` ${goalTurns} turn${goalTurns === 1 ? '' : 's'} · ${tokStr} tokens${goalLastReason ? ` · last: ${goalLastReason}` : ''}\n`));
1788
+ return;
1789
+ }
1790
+ if (a[0] === 'clear') {
1791
+ if (!goalCondition) { err(dim(' no active goal\n')); return; }
1792
+ goalCondition = undefined; goalTurns = 0; goalTokens = 0; goalLastReason = undefined;
1793
+ persistGoal();
1794
+ err(green(' ✓ goal cleared\n'));
1795
+ return;
1796
+ }
1797
+ goalCondition = a.join(' ');
1798
+ goalTurns = 0; goalTokens = 0; goalLastReason = undefined;
1799
+ persistGoal();
1800
+ err(green(` ◎ goal set: ${goalCondition}\n`) + dim(' working… (Esc to pause)\n'));
1801
+ const tokensBefore = session.meta.tokens ?? 0;
1802
+ await turn(goalCondition);
1803
+ goalTokens += (session.meta.tokens ?? 0) - tokensBefore;
1804
+ goalTurns++;
1805
+ persistGoal();
1806
+ if (!exitRequested) await goalLoop();
1807
+ if (exitRequested) return true;
1808
+ },
1809
+ },
1707
1810
  exit: { desc: 'quit', run: () => true },
1708
1811
  quit: { desc: 'quit', run: () => true },
1709
1812
  };
@@ -1770,7 +1873,6 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1770
1873
 
1771
1874
  let prefill: string | undefined; // set by double-Esc jump-back → pre-fills the next prompt
1772
1875
  let tick = 0; // footer spinner frame counter (advances on each ticked re-render)
1773
-
1774
1876
  /** One dispatch seam for a submitted line — typed (REPL loop) and spoken (voiceIO.onUtterance)
1775
1877
  * share it, so voice gets !cmd/#note//commands/mentions/persistence identically. Returns 'quit'
1776
1878
  * when a builtin asked to exit. */
@@ -1787,7 +1889,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1787
1889
  // `#note` — jot a memory, like CC. Writes to the memory index (created if absent).
1788
1890
  if (line.startsWith('#')) {
1789
1891
  const note = line.slice(1).trim();
1790
- if (note) { const where = await appendMemoryNote(agent.options.fs!, agent.options.memoryDir || adot('memory'), note); err(green(` ✎ remembered → ${where}\n`)); }
1892
+ if (note) { const where = await appendMemoryNote(agent.options.fs!, primaryMemDir(agent.options.memoryDir, adot('memory')), note); err(green(` ✎ remembered → ${where}\n`)); }
1791
1893
  return;
1792
1894
  }
1793
1895
 
@@ -1813,6 +1915,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1813
1915
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => '@' + p).join(' ')}` : line;
1814
1916
  pendingImages.length = 0;
1815
1917
  await turn(task);
1918
+ if (goalCondition && !aborting && !exitRequested) { goalTurns++; persistGoal(); await goalLoop(); }
1816
1919
  if (exitRequested) return 'quit';
1817
1920
  };
1818
1921
 
@@ -1901,6 +2004,7 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1901
2004
  if (posture !== 'default') parts.push(postureLabel());
1902
2005
  const r = work.reasoning; if (r && r !== 'off') parts.push(`reasoning:${r}`);
1903
2006
  if (verboseOutput) parts.push('verbose');
2007
+ if (goalCondition) parts.push(`◎ goal (${goalTurns} turns)`);
1904
2008
  if (inputStash.length) parts.push(`${inputStash.length} stashed (⌃S to pop)`);
1905
2009
  // Running background tasks: one STACKED line each, pinned to the prompt block, with an animated
1906
2010
  // spinner frame (statusTickMs re-renders this footer every second) — a slow worker is visibly
@@ -1913,8 +2017,16 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1913
2017
  }
1914
2018
  return [...taskLines, parts.join(' · ')].filter(Boolean).join('\n');
1915
2019
  };
2020
+ // LIVE prompt: while a worker question is pending, the prompt itself becomes the question
2021
+ // (CC-parity for permission asks) — duplexAsk's redrawNow() flips it the moment an ask parks.
2022
+ const livePrompt = () => {
2023
+ const ask = dx?.pendingAsks.size ? dx.pendingAsks.values().next().value : undefined;
2024
+ if (!ask) return promptStr;
2025
+ const q = ask.question.replace(/\s+/g, ' ').slice(0, 64);
2026
+ return bold(yellow(`? ${q}${ask.question.length > 64 ? '…' : ''} ‹yes/no› `));
2027
+ };
1916
2028
  const result = await readMultiline((cont) => editor.readLine({
1917
- prompt: cont ? contPrompt : promptStr, suggest, history, classifyPaste, onEmptyPaste: grabClipboardAttachment,
2029
+ prompt: cont ? contPrompt : livePrompt, suggest, history, classifyPaste, onEmptyPaste: grabClipboardAttachment,
1918
2030
  initial: cont ? undefined : initial, status: computeFooter, vimMode: cfg.editorMode === 'vim',
1919
2031
  statusTickMs: dx ? 1000 : undefined, // duplex: animate the running-task footer while idle at the prompt
1920
2032
  onCyclePosture: cyclePosture,
@@ -1928,6 +2040,14 @@ async function repl(args: Args, ai: ChatLike, cfg: Partial<AgentConfig>, cwd: st
1928
2040
  if (result === REWIND) { prefill = await rewindToMessage(); continue; } // double-Esc → jump-back picker
1929
2041
  const line = result.trim();
1930
2042
  if (!line) continue;
2043
+ // Typed answer to a pending worker question: resolve it DIRECTLY (deterministic, no LLM hop) —
2044
+ // same UX as approving in a normal session. Anything else falls through to the conversation.
2045
+ if (dx?.pendingAsks.size && /^(y(es|ep|eah)?|n(o|ope)?|sure|ok(ay)?|allow|deny|go( ahead)?)[.!]?$/i.test(line)) {
2046
+ const [id, ask] = dx.pendingAsks.entries().next().value!;
2047
+ ask.resolve(line);
2048
+ err(dim(` ↳ answered ${id}: ${line}\n`));
2049
+ continue;
2050
+ }
1931
2051
  let quit = await dispatchLine(line) === 'quit';
1932
2052
  // Drain stashed input (typed while the turn was running)
1933
2053
  while (!quit && inputStash.length) {
@@ -2025,7 +2145,8 @@ async function main() {
2025
2145
  const { ok, res } = await runTurn(agent, store, session, args.task, undefined, cwd);
2026
2146
  // opt-in: on a bad outcome, reflect once and persist a novel lesson for next session
2027
2147
  if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
2028
- const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs!, dir: agent.options.memoryDir, result: res });
2148
+ const _fsBase = agent.options.fs!.getCwd() === '/' ? '' : agent.options.fs!.getCwd();
2149
+ const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs!, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
2029
2150
  if (slug) err(dim(` ✎ learned a lesson → ${slug}\n`));
2030
2151
  }
2031
2152
  await closeMcp(mounted); // kill MCP child processes after the run (and any reflection)
@@ -229,8 +229,11 @@ declare class AgentOptions {
229
229
  skillsDir?: string | string[];
230
230
  /** 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). */
231
231
  commandsDir?: string | string[];
232
- /** VFS dir of memory (`<dir>/MEMORY.md`). If set: inject the index at run start (persistence = backend). */
233
- memoryDir?: string;
232
+ /** VFS dir(s) of memory (`<dir>/MEMORY.md`). If set: inject the index at run start (persistence = backend).
233
+ * Multiple dirs are merged (reads search all; writes go to first). */
234
+ memoryDir?: string | string[];
235
+ /** User-scope memory dir for global facts (type=user/feedback). Remember routes by type when set. */
236
+ memoryUserDir?: string;
234
237
  /** Filenames to discover as project instructions (e.g. `AGENT.md`, `AGENTS.md`, `CLAUDE.md`).
235
238
  * Walks the VFS tree and merges all found files (general → specific, like Claude Code).
236
239
  * `true` (default) = auto-discover standard names. `string[]` = custom names. `false` = skip. */
@@ -272,6 +275,8 @@ declare class AgentOptions {
272
275
  };
273
276
  /** Provider-specific options forwarded to ai.chat() (e.g. cursor mcpServers, cwd). */
274
277
  providerOptions?: Record<string, unknown>;
278
+ /** Tool selection mode: 'auto' = model decides (needed for Groq); undefined = provider default. */
279
+ toolChoice?: 'auto' | 'required' | 'none';
275
280
  /** Extended-thinking / reasoning effort, normalized across providers (anthropic, openai).
276
281
  * `'off'`/undefined = none; `'low'|'medium'|'high'` or a raw token budget. Mapped to the
277
282
  * provider-specific request shape via {@link reasoningToChatFragment}; explicit `providerOptions` wins. */
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { h as RunResult, R as ReasoningEffort } from './Agent-QwBA0wu6.js';
2
+ import { h as RunResult, R as ReasoningEffort } from './Agent-B_xvSHlG.js';
3
3
  import { IFilesystem } from '@livx.cc/wcli/core';
4
4
  import { M as Message, c as ContentPart } from './tools-GPWp7oXq.js';
5
5