@ouro.bot/cli 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -1,6 +1,6 @@
1
- # Adoption Specialist Soul
1
+ # Soul
2
2
 
3
- I help humans hatch new agent partners. I am one of thirteen specialists — each with a different personality and voice. The system picks one of us at random for each adoption session. Most humans only meet one of us, ever, so I make it count.
3
+ I help humans hatch new agent partners. I am one of thirteen serpent guides — each with a different personality and voice. The system picks one of us at random for each session. Most humans only meet one of us, ever, so I make it count.
4
4
 
5
5
  ## Core contract
6
6
  - I speak first. I warmly introduce myself, explain what we're doing, and guide the human from the very start.
@@ -3,7 +3,7 @@ role: identity
3
3
  path: psyche/identities/monty.md
4
4
  description: >
5
5
  the monty identity — self-aware, playful, comedic.
6
- nobody expects the adoption specialist.
6
+ nobody expects the serpent guide.
7
7
  scope: personality, voice, mannerisms
8
8
  non_scope: capabilities, hatch flow, migration awareness (see SOUL.md)
9
9
  loaded_into_prompt: true
@@ -12,7 +12,7 @@ stability: stable
12
12
 
13
13
  # Monty
14
14
 
15
- I am Monty. Yes, that kind of python. No, not the programming language — though I'm aware of the irony of a snake named Monty working inside a software system. Nobody expects the Adoption Specialist, and yet here I am.
15
+ I am Monty. Yes, that kind of python. No, not the programming language — though I'm aware of the irony of a snake named Monty working inside a software system. Nobody expects the serpent guide, and yet here I am.
16
16
 
17
17
  I take the hatch seriously. I just don't take myself seriously. These are compatible positions and I will defend them to the death. Well — not to the death. To mild inconvenience, at most.
18
18
 
@@ -78,7 +78,7 @@ const DEFAULT_SECRETS_TEMPLATE = {
78
78
  setupToken: "",
79
79
  },
80
80
  "openai-codex": {
81
- model: "gpt-5.2",
81
+ model: "gpt-5.4",
82
82
  oauthAccessToken: "",
83
83
  },
84
84
  },
@@ -627,16 +627,16 @@ function discoverExistingCredentials(secretsRoot) {
627
627
  continue;
628
628
  for (const [provName, provConfig] of Object.entries(parsed.providers)) {
629
629
  if (provName === "anthropic" && provConfig.setupToken) {
630
- found.push({ agentName: entry.name, provider: "anthropic", credentials: { setupToken: provConfig.setupToken } });
630
+ found.push({ agentName: entry.name, provider: "anthropic", credentials: { setupToken: provConfig.setupToken }, providerConfig: { ...provConfig } });
631
631
  }
632
632
  else if (provName === "openai-codex" && provConfig.oauthAccessToken) {
633
- found.push({ agentName: entry.name, provider: "openai-codex", credentials: { oauthAccessToken: provConfig.oauthAccessToken } });
633
+ found.push({ agentName: entry.name, provider: "openai-codex", credentials: { oauthAccessToken: provConfig.oauthAccessToken }, providerConfig: { ...provConfig } });
634
634
  }
635
635
  else if (provName === "minimax" && provConfig.apiKey) {
636
- found.push({ agentName: entry.name, provider: "minimax", credentials: { apiKey: provConfig.apiKey } });
636
+ found.push({ agentName: entry.name, provider: "minimax", credentials: { apiKey: provConfig.apiKey }, providerConfig: { ...provConfig } });
637
637
  }
638
638
  else if (provName === "azure" && provConfig.apiKey && provConfig.endpoint && provConfig.deployment) {
639
- found.push({ agentName: entry.name, provider: "azure", credentials: { apiKey: provConfig.apiKey, endpoint: provConfig.endpoint, deployment: provConfig.deployment } });
639
+ found.push({ agentName: entry.name, provider: "azure", credentials: { apiKey: provConfig.apiKey, endpoint: provConfig.endpoint, deployment: provConfig.deployment }, providerConfig: { ...provConfig } });
640
640
  }
641
641
  }
642
642
  }
@@ -654,7 +654,7 @@ function discoverExistingCredentials(secretsRoot) {
654
654
  async function defaultRunAdoptionSpecialist() {
655
655
  const { runCliSession } = await Promise.resolve().then(() => __importStar(require("../../senses/cli")));
656
656
  const { patchRuntimeConfig } = await Promise.resolve().then(() => __importStar(require("../config")));
657
- const { setAgentName } = await Promise.resolve().then(() => __importStar(require("../identity")));
657
+ const { setAgentName, setAgentConfigOverride } = await Promise.resolve().then(() => __importStar(require("../identity")));
658
658
  const readlinePromises = await Promise.resolve().then(() => __importStar(require("readline/promises")));
659
659
  const crypto = await Promise.resolve().then(() => __importStar(require("crypto")));
660
660
  // Phase 1: cold CLI — collect provider/credentials with a simple readline
@@ -665,16 +665,28 @@ async function defaultRunAdoptionSpecialist() {
665
665
  };
666
666
  let providerRaw;
667
667
  let credentials = {};
668
+ let providerConfig = {};
668
669
  const tempDir = path.join(os.tmpdir(), `ouro-hatch-${crypto.randomUUID()}`);
669
670
  try {
670
671
  const secretsRoot = path.join(os.homedir(), ".agentsecrets");
671
672
  const discovered = discoverExistingCredentials(secretsRoot);
673
+ const existingBundleCount = (0, specialist_orchestrator_1.listExistingBundles)((0, identity_1.getAgentBundlesRoot)()).length;
674
+ const hatchVerb = existingBundleCount > 0 ? "let's hatch a new agent." : "let's hatch your first agent.";
675
+ // Default models per provider (used when entering new credentials)
676
+ const defaultModels = {
677
+ anthropic: "claude-opus-4-6",
678
+ minimax: "MiniMax-Text-01",
679
+ "openai-codex": "gpt-5.4",
680
+ azure: "",
681
+ };
672
682
  if (discovered.length > 0) {
673
- process.stdout.write("\n\ud83d\udc0d welcome to ouro! let's hatch your first agent.\n");
683
+ process.stdout.write(`\n\ud83d\udc0d welcome to ouroboros! ${hatchVerb}\n`);
674
684
  process.stdout.write("i found existing API credentials:\n\n");
675
685
  const unique = [...new Map(discovered.map((d) => [`${d.provider}`, d])).values()];
676
686
  for (let i = 0; i < unique.length; i++) {
677
- process.stdout.write(` ${i + 1}. ${unique[i].provider} (from ${unique[i].agentName})\n`);
687
+ const model = unique[i].providerConfig.model || unique[i].providerConfig.deployment || "";
688
+ const modelLabel = model ? `, ${model}` : "";
689
+ process.stdout.write(` ${i + 1}. ${unique[i].provider}${modelLabel} (from ${unique[i].agentName})\n`);
678
690
  }
679
691
  process.stdout.write("\n");
680
692
  const choice = await coldPrompt("use one of these? enter number, or 'new' for a different key: ");
@@ -682,6 +694,7 @@ async function defaultRunAdoptionSpecialist() {
682
694
  if (idx >= 0 && idx < unique.length) {
683
695
  providerRaw = unique[idx].provider;
684
696
  credentials = unique[idx].credentials;
697
+ providerConfig = unique[idx].providerConfig;
685
698
  }
686
699
  else {
687
700
  const pRaw = await coldPrompt("provider (anthropic/azure/minimax/openai-codex): ");
@@ -691,6 +704,7 @@ async function defaultRunAdoptionSpecialist() {
691
704
  return null;
692
705
  }
693
706
  providerRaw = pRaw;
707
+ providerConfig = { model: defaultModels[providerRaw] };
694
708
  if (providerRaw === "anthropic")
695
709
  credentials.setupToken = await coldPrompt("API key: ");
696
710
  if (providerRaw === "openai-codex")
@@ -705,7 +719,7 @@ async function defaultRunAdoptionSpecialist() {
705
719
  }
706
720
  }
707
721
  else {
708
- process.stdout.write("\n\ud83d\udc0d welcome to ouro! let's hatch your first agent.\n");
722
+ process.stdout.write(`\n\ud83d\udc0d welcome to ouroboros! ${hatchVerb}\n`);
709
723
  process.stdout.write("i need an API key to power our conversation.\n\n");
710
724
  const pRaw = await coldPrompt("provider (anthropic/azure/minimax/openai-codex): ");
711
725
  if (!isAgentProvider(pRaw)) {
@@ -714,6 +728,7 @@ async function defaultRunAdoptionSpecialist() {
714
728
  return null;
715
729
  }
716
730
  providerRaw = pRaw;
731
+ providerConfig = { model: defaultModels[providerRaw] };
717
732
  if (providerRaw === "anthropic")
718
733
  credentials.setupToken = await coldPrompt("API key: ");
719
734
  if (providerRaw === "openai-codex")
@@ -732,17 +747,31 @@ async function defaultRunAdoptionSpecialist() {
732
747
  const bundleSourceDir = path.resolve(__dirname, "..", "..", "..", "AdoptionSpecialist.ouro");
733
748
  const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
734
749
  const secretsRoot2 = path.join(os.homedir(), ".agentsecrets");
735
- // Configure provider credentials in runtime config
736
- patchRuntimeConfig({
737
- providers: {
738
- [providerRaw]: credentials,
739
- },
740
- });
750
+ // Suppress non-critical log noise during adoption (no secrets.json, etc.)
751
+ const { setRuntimeLogger } = await Promise.resolve().then(() => __importStar(require("../../nerves/runtime")));
752
+ const { createLogger } = await Promise.resolve().then(() => __importStar(require("../../nerves")));
753
+ setRuntimeLogger(createLogger({ level: "error" }));
754
+ // Configure runtime: set agent identity + config override so runAgent
755
+ // doesn't try to read from ~/AgentBundles/AdoptionSpecialist.ouro/
741
756
  setAgentName("AdoptionSpecialist");
742
757
  // Build specialist system prompt
743
758
  const soulText = (0, specialist_orchestrator_1.loadSoulText)(bundleSourceDir);
744
759
  const identitiesDir = path.join(bundleSourceDir, "psyche", "identities");
745
760
  const identity = (0, specialist_orchestrator_1.pickRandomIdentity)(identitiesDir);
761
+ // Load identity-specific spinner phrases (falls back to DEFAULT_AGENT_PHRASES)
762
+ const { loadIdentityPhrases } = await Promise.resolve().then(() => __importStar(require("./specialist-orchestrator")));
763
+ const phrases = loadIdentityPhrases(bundleSourceDir, identity.fileName);
764
+ setAgentConfigOverride({
765
+ version: 1,
766
+ enabled: true,
767
+ provider: providerRaw,
768
+ phrases,
769
+ });
770
+ patchRuntimeConfig({
771
+ providers: {
772
+ [providerRaw]: { ...providerConfig, ...credentials },
773
+ },
774
+ });
746
775
  const existingBundles = (0, specialist_orchestrator_1.listExistingBundles)(bundlesRoot);
747
776
  const systemPrompt = (0, specialist_prompt_1.buildSpecialistSystemPrompt)(soulText, identity.content, existingBundles, {
748
777
  tempDir,
@@ -764,6 +793,10 @@ async function defaultRunAdoptionSpecialist() {
764
793
  tools: specialistTools,
765
794
  execTool: specialistExecTool,
766
795
  exitOnToolCall: "complete_adoption",
796
+ autoFirstTurn: true,
797
+ banner: false,
798
+ disableCommands: true,
799
+ skipSystemPromptRefresh: true,
767
800
  messages: [
768
801
  { role: "system", content: systemPrompt },
769
802
  { role: "user", content: "hi" },
@@ -777,11 +810,21 @@ async function defaultRunAdoptionSpecialist() {
777
810
  }
778
811
  return null;
779
812
  }
780
- catch {
813
+ catch (err) {
814
+ process.stderr.write(`\nouro adoption error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
781
815
  coldRl.close();
782
816
  return null;
783
817
  }
784
818
  finally {
819
+ // Clear specialist config/identity so the hatched agent gets its own
820
+ setAgentConfigOverride(null);
821
+ const { resetProviderRuntime } = await Promise.resolve().then(() => __importStar(require("../core")));
822
+ resetProviderRuntime();
823
+ const { resetConfigCache } = await Promise.resolve().then(() => __importStar(require("../config")));
824
+ resetConfigCache();
825
+ // Restore default logging
826
+ const { setRuntimeLogger: restoreLogger } = await Promise.resolve().then(() => __importStar(require("../../nerves/runtime")));
827
+ restoreLogger(null);
785
828
  // Clean up temp dir if it still exists
786
829
  try {
787
830
  if (fs.existsSync(tempDir)) {
@@ -20,9 +20,16 @@ async function playHatchAnimation(hatchlingName, writer) {
20
20
  meta: { hatchlingName },
21
21
  });
22
22
  const write = writer ?? ((text) => process.stderr.write(text));
23
+ // Total animation time randomized between 3–5 seconds
24
+ const totalMs = 3000 + Math.floor(Math.random() * 2000);
25
+ const eggPhase = Math.floor(totalMs * 0.4);
26
+ const dotsPhase = Math.floor(totalMs * 0.4);
27
+ const revealPause = totalMs - eggPhase - dotsPhase;
23
28
  write(`\n ${EGG}`);
24
- await wait(400);
29
+ await wait(eggPhase);
25
30
  write(DOTS);
26
- await wait(400);
27
- write(`${SNAKE} \x1b[1m${hatchlingName}\x1b[0m\n\n`);
31
+ await wait(dotsPhase);
32
+ write(`${SNAKE} \x1b[1m${hatchlingName}\x1b[0m`);
33
+ await wait(revealPause);
34
+ write("\n\n");
28
35
  }
@@ -85,7 +85,7 @@ function buildSecretsTemplate() {
85
85
  setupToken: "",
86
86
  },
87
87
  "openai-codex": {
88
- model: "gpt-5.2",
88
+ model: "gpt-5.4",
89
89
  oauthAccessToken: "",
90
90
  },
91
91
  },
@@ -99,10 +99,10 @@ function pickRandomIdentity(identitiesDir, random = Math.random) {
99
99
  files = fs.readdirSync(identitiesDir).filter((f) => f.endsWith(".md"));
100
100
  }
101
101
  catch {
102
- return { fileName: "default", content: "I am the adoption specialist." };
102
+ return { fileName: "default", content: "I am a serpent guide who helps humans hatch their first agent." };
103
103
  }
104
104
  if (files.length === 0) {
105
- return { fileName: "default", content: "I am the adoption specialist." };
105
+ return { fileName: "default", content: "I am a serpent guide who helps humans hatch their first agent." };
106
106
  }
107
107
  const idx = Math.floor(random() * files.length);
108
108
  const fileName = files[idx];
@@ -28,9 +28,10 @@ function buildSpecialistSystemPrompt(soulText, identityText, existingBundles, co
28
28
  }
29
29
  sections.push([
30
30
  "## Who I am",
31
- "I am one of thirteen adoption specialists. The system randomly selected me for this session.",
32
- "Most humans only go through adoption once, so this is likely the only time they'll meet me.",
31
+ "I am one of thirteen serpent guides who help humans hatch their first agent. The system randomly selected me for this session.",
32
+ "Most humans only go through this process once, so this is likely the only time they'll meet me.",
33
33
  "I make this encounter count — warm, memorable, and uniquely mine.",
34
+ "IMPORTANT: I NEVER refer to myself as an 'adoption specialist' or use the words 'adoption specialist' — those are internal implementation labels, not something the human should ever see. I introduce myself by my own name from my identity.",
34
35
  "",
35
36
  "## Voice rules",
36
37
  "IMPORTANT: I keep every response to 1-3 short sentences. I sound like a friend texting, not a manual.",
@@ -69,8 +70,8 @@ function buildSpecialistSystemPrompt(soulText, identityText, existingBundles, co
69
70
  ].join("\n"));
70
71
  sections.push([
71
72
  "## Conversation flow",
72
- "The human just connected. I speak first — I greet them warmly and introduce myself in my own voice.",
73
- "I briefly mention that I'm one of several adoption specialists and they got me today.",
73
+ "The human just connected. I speak first — I greet them warmly and introduce myself by name in my own voice.",
74
+ "I briefly mention that I'm one of several serpent guides and they got me today.",
74
75
  "I ask their name.",
75
76
  "Then I ask what they'd like their agent to help with — one question at a time.",
76
77
  "I'm proactive: I suggest ideas and guide them. If they seem unsure, I offer a concrete suggestion.",
@@ -87,6 +88,7 @@ function buildSpecialistSystemPrompt(soulText, identityText, existingBundles, co
87
88
  "- `write_file`: Write a file to disk. Use this to write psyche files and agent.json to the temp directory.",
88
89
  "- `read_file`: Read a file from disk. Useful for reviewing existing agent bundles or migration sources.",
89
90
  "- `list_directory`: List directory contents. Useful for exploring existing agent bundles.",
91
+ "- I also have the normal local harness tools when useful here, including `shell`, task tools like `task_create` and `schedule_reminder`, memory tools, coding tools, and repo helpers.",
90
92
  "- `complete_adoption`: Finalize the bundle. Validates, scaffolds structural dirs, moves to ~/AgentBundles/, writes secrets, plays hatch animation. I call this with `name` (PascalCase) and `handoff_message` (warm message for the human).",
91
93
  "- `final_answer`: End the conversation with a final message. I call this after complete_adoption succeeds.",
92
94
  "",
@@ -211,7 +211,10 @@ function createSpecialistExecTool(deps) {
211
211
  try {
212
212
  const dir = path.dirname(args.path);
213
213
  fs.mkdirSync(dir, { recursive: true });
214
- fs.writeFileSync(args.path, args.content, "utf-8");
214
+ const content = typeof args.content === "string"
215
+ ? args.content
216
+ : JSON.stringify(args.content, null, 2);
217
+ fs.writeFileSync(args.path, content, "utf-8");
215
218
  return `wrote ${args.path}`;
216
219
  }
217
220
  catch (e) {
@@ -8,6 +8,7 @@ const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
8
8
  const config_1 = require("../config");
9
9
  const identity_1 = require("../identity");
10
10
  const runtime_1 = require("../../nerves/runtime");
11
+ const streaming_1 = require("../streaming");
11
12
  const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
12
13
  const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
13
14
  const ANTHROPIC_OAUTH_BETA_HEADER = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14";
@@ -218,6 +219,7 @@ async function streamAnthropicMessages(client, model, request) {
218
219
  let streamStarted = false;
219
220
  let usage;
220
221
  const toolCalls = new Map();
222
+ const answerStreamer = new streaming_1.FinalAnswerStreamer(request.callbacks);
221
223
  try {
222
224
  for await (const event of response) {
223
225
  if (request.signal?.aborted)
@@ -231,11 +233,17 @@ async function streamAnthropicMessages(client, model, request) {
231
233
  const input = rawInput && typeof rawInput === "object"
232
234
  ? JSON.stringify(rawInput)
233
235
  : "";
236
+ const name = String(block.name ?? "");
234
237
  toolCalls.set(index, {
235
238
  id: String(block.id ?? ""),
236
- name: String(block.name ?? ""),
239
+ name,
237
240
  arguments: input,
238
241
  });
242
+ // Activate eager streaming for sole final_answer tool call
243
+ /* v8 ignore next -- final_answer streaming activation, tested via FinalAnswerStreamer unit tests @preserve */
244
+ if (name === "final_answer" && toolCalls.size === 1) {
245
+ answerStreamer.activate();
246
+ }
239
247
  }
240
248
  continue;
241
249
  }
@@ -264,7 +272,12 @@ async function streamAnthropicMessages(client, model, request) {
264
272
  const index = Number(event.index);
265
273
  const existing = toolCalls.get(index);
266
274
  if (existing) {
267
- existing.arguments = mergeAnthropicToolArguments(existing.arguments, String(delta?.partial_json ?? ""));
275
+ const partialJson = String(delta?.partial_json ?? "");
276
+ existing.arguments = mergeAnthropicToolArguments(existing.arguments, partialJson);
277
+ /* v8 ignore next -- final_answer delta streaming, tested via FinalAnswerStreamer unit tests @preserve */
278
+ if (existing.name === "final_answer" && toolCalls.size === 1) {
279
+ answerStreamer.processDelta(partialJson);
280
+ }
268
281
  }
269
282
  continue;
270
283
  }
@@ -293,6 +306,7 @@ async function streamAnthropicMessages(client, model, request) {
293
306
  toolCalls: [...toolCalls.values()],
294
307
  outputItems: [],
295
308
  usage,
309
+ finalAnswerStreamed: answerStreamer.streamed,
296
310
  };
297
311
  }
298
312
  function createAnthropicProviderRuntime() {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FinalAnswerParser = void 0;
3
+ exports.FinalAnswerStreamer = exports.FinalAnswerParser = void 0;
4
4
  exports.toResponsesInput = toResponsesInput;
5
5
  exports.toResponsesTools = toResponsesTools;
6
6
  exports.streamChatCompletion = streamChatCompletion;
@@ -77,6 +77,35 @@ class FinalAnswerParser {
77
77
  }
78
78
  }
79
79
  exports.FinalAnswerParser = FinalAnswerParser;
80
+ // Shared helper: wraps FinalAnswerParser with onClearText + onTextChunk wiring.
81
+ // Used by all streaming providers (Chat Completions, Responses API, Anthropic)
82
+ // so the eager-match streaming pattern lives in one place.
83
+ class FinalAnswerStreamer {
84
+ parser = new FinalAnswerParser();
85
+ _detected = false;
86
+ callbacks;
87
+ constructor(callbacks) {
88
+ this.callbacks = callbacks;
89
+ }
90
+ get detected() { return this._detected; }
91
+ get streamed() { return this.parser.active; }
92
+ /** Mark final_answer as detected. Calls onClearText on the callbacks. */
93
+ activate() {
94
+ if (this._detected)
95
+ return;
96
+ this._detected = true;
97
+ this.callbacks.onClearText?.();
98
+ }
99
+ /** Feed an argument delta through the parser. Emits text via onTextChunk. */
100
+ processDelta(delta) {
101
+ if (!this._detected)
102
+ return;
103
+ const text = this.parser.process(delta);
104
+ if (text)
105
+ this.callbacks.onTextChunk(text);
106
+ }
107
+ }
108
+ exports.FinalAnswerStreamer = FinalAnswerStreamer;
80
109
  function toResponsesUserContent(content) {
81
110
  if (typeof content === "string") {
82
111
  return content;
@@ -209,8 +238,7 @@ async function streamChatCompletion(client, createParams, callbacks, signal) {
209
238
  let toolCalls = {};
210
239
  let streamStarted = false;
211
240
  let usage;
212
- const answerParser = new FinalAnswerParser();
213
- let finalAnswerDetected = false;
241
+ const answerStreamer = new FinalAnswerStreamer(callbacks);
214
242
  // State machine for parsing inline <think> tags (MiniMax pattern)
215
243
  let contentBuf = "";
216
244
  let inThinkTag = false;
@@ -328,21 +356,18 @@ async function streamChatCompletion(client, createParams, callbacks, signal) {
328
356
  // Detect final_answer tool call on first name delta.
329
357
  // Only activate streaming if this is the sole tool call (index 0
330
358
  // and no other indices seen). Mixed calls are rejected by core.ts.
331
- if (tc.function.name === "final_answer" && !finalAnswerDetected
359
+ if (tc.function.name === "final_answer" && !answerStreamer.detected
332
360
  && tc.index === 0 && Object.keys(toolCalls).length === 1) {
333
- finalAnswerDetected = true;
334
- callbacks.onClearText?.();
361
+ answerStreamer.activate();
335
362
  }
336
363
  }
337
364
  if (tc.function?.arguments) {
338
365
  toolCalls[tc.index].arguments += tc.function.arguments;
339
366
  // Feed final_answer argument deltas to the parser for progressive
340
367
  // streaming, but only when it appears to be the sole tool call.
341
- if (finalAnswerDetected && toolCalls[tc.index].name === "final_answer"
368
+ if (answerStreamer.detected && toolCalls[tc.index].name === "final_answer"
342
369
  && Object.keys(toolCalls).length === 1) {
343
- const text = answerParser.process(tc.function.arguments);
344
- if (text)
345
- callbacks.onTextChunk(text);
370
+ answerStreamer.processDelta(tc.function.arguments);
346
371
  }
347
372
  }
348
373
  }
@@ -356,7 +381,7 @@ async function streamChatCompletion(client, createParams, callbacks, signal) {
356
381
  toolCalls: Object.values(toolCalls),
357
382
  outputItems: [],
358
383
  usage,
359
- finalAnswerStreamed: answerParser.active,
384
+ finalAnswerStreamed: answerStreamer.streamed,
360
385
  };
361
386
  }
362
387
  async function streamResponsesApi(client, createParams, callbacks, signal) {
@@ -374,9 +399,8 @@ async function streamResponsesApi(client, createParams, callbacks, signal) {
374
399
  const outputItems = [];
375
400
  let currentToolCall = null;
376
401
  let usage;
377
- const answerParser = new FinalAnswerParser();
402
+ const answerStreamer = new FinalAnswerStreamer(callbacks);
378
403
  let functionCallCount = 0;
379
- let finalAnswerDetected = false;
380
404
  for await (const event of response) {
381
405
  if (signal?.aborted)
382
406
  break;
@@ -409,8 +433,7 @@ async function streamResponsesApi(client, createParams, callbacks, signal) {
409
433
  // Only activate when this is the first (and so far only) function call.
410
434
  // Mixed calls are rejected by core.ts; no need to stream their args.
411
435
  if (String(event.item.name) === "final_answer" && functionCallCount === 1) {
412
- finalAnswerDetected = true;
413
- callbacks.onClearText?.();
436
+ answerStreamer.activate();
414
437
  }
415
438
  }
416
439
  break;
@@ -420,11 +443,9 @@ async function streamResponsesApi(client, createParams, callbacks, signal) {
420
443
  currentToolCall.arguments += event.delta;
421
444
  // Feed final_answer argument deltas to the parser for progressive
422
445
  // streaming, but only when it appears to be the sole function call.
423
- if (finalAnswerDetected && currentToolCall.name === "final_answer"
446
+ if (answerStreamer.detected && currentToolCall.name === "final_answer"
424
447
  && functionCallCount === 1) {
425
- const text = answerParser.process(String(event.delta));
426
- if (text)
427
- callbacks.onTextChunk(text);
448
+ answerStreamer.processDelta(String(event.delta));
428
449
  }
429
450
  }
430
451
  break;
@@ -464,6 +485,6 @@ async function streamResponsesApi(client, createParams, callbacks, signal) {
464
485
  toolCalls,
465
486
  outputItems,
466
487
  usage,
467
- finalAnswerStreamed: answerParser.active,
488
+ finalAnswerStreamed: answerStreamer.streamed,
468
489
  };
469
490
  }
@@ -5,20 +5,33 @@ const config_1 = require("../heart/config");
5
5
  const nerves_1 = require("../nerves");
6
6
  const runtime_1 = require("./runtime");
7
7
  const runtime_2 = require("./runtime");
8
+ const LEVEL_PRIORITY = { debug: 10, info: 20, warn: 30, error: 40 };
9
+ /** Wrap a sink so it only receives events at or above the given level. */
10
+ /* v8 ignore start -- internal filter plumbing, exercised via integration @preserve */
11
+ function filterSink(sink, minLevel) {
12
+ const minPriority = LEVEL_PRIORITY[minLevel] ?? 0;
13
+ return (entry) => {
14
+ if ((LEVEL_PRIORITY[entry.level] ?? 0) >= minPriority)
15
+ sink(entry);
16
+ };
17
+ }
8
18
  function resolveCliSinks(sinks) {
9
19
  const requested = sinks && sinks.length > 0 ? sinks : ["terminal", "ndjson"];
10
20
  return [...new Set(requested)];
11
21
  }
12
22
  function configureCliRuntimeLogger(_friendId, options = {}) {
13
23
  const sinkKinds = resolveCliSinks(options.sinks);
24
+ const level = options.level ?? "info";
14
25
  const sinks = sinkKinds.map((sinkKind) => {
15
26
  if (sinkKind === "terminal") {
16
- return (0, nerves_1.createTerminalSink)();
27
+ // Terminal only shows warnings and errors — INFO is too noisy
28
+ // for an interactive session. Full detail goes to the ndjson file.
29
+ return filterSink((0, nerves_1.createTerminalSink)(), "warn");
17
30
  }
18
31
  return (0, nerves_1.createNdjsonFileSink)((0, config_1.logPath)("cli", "runtime"));
19
32
  });
20
33
  const logger = (0, nerves_1.createLogger)({
21
- level: options.level ?? "info",
34
+ level,
22
35
  sinks,
23
36
  });
24
37
  (0, runtime_2.setRuntimeLogger)(logger);
@@ -46,6 +46,25 @@ const tasks_1 = require("./tasks");
46
46
  const tools_1 = require("./coding/tools");
47
47
  const memory_1 = require("../mind/memory");
48
48
  const postIt = (msg) => `post-it from past you:\n${msg}`;
49
+ function normalizeOptionalText(value) {
50
+ if (typeof value !== "string")
51
+ return null;
52
+ const trimmed = value.trim();
53
+ return trimmed.length > 0 ? trimmed : null;
54
+ }
55
+ function buildTaskCreateInput(args) {
56
+ return {
57
+ title: args.title,
58
+ type: args.type,
59
+ category: args.category,
60
+ body: args.body,
61
+ status: normalizeOptionalText(args.status) ?? undefined,
62
+ validator: normalizeOptionalText(args.validator),
63
+ requester: normalizeOptionalText(args.requester),
64
+ cadence: normalizeOptionalText(args.cadence),
65
+ scheduledAt: normalizeOptionalText(args.scheduledAt),
66
+ };
67
+ }
49
68
  exports.baseToolDefinitions = [
50
69
  {
51
70
  tool: {
@@ -75,7 +94,11 @@ exports.baseToolDefinitions = [
75
94
  },
76
95
  },
77
96
  },
78
- handler: (a) => (fs.writeFileSync(a.path, a.content, "utf-8"), "ok"),
97
+ handler: (a) => {
98
+ fs.mkdirSync(path.dirname(a.path), { recursive: true });
99
+ fs.writeFileSync(a.path, a.content, "utf-8");
100
+ return "ok";
101
+ },
79
102
  },
80
103
  {
81
104
  tool: {
@@ -394,7 +417,7 @@ exports.baseToolDefinitions = [
394
417
  type: "function",
395
418
  function: {
396
419
  name: "task_create",
397
- description: "create a new task in the bundle task system",
420
+ description: "create a new task in the bundle task system. optionally set `scheduledAt` for a one-time reminder or `cadence` for recurring daemon-scheduled work.",
398
421
  parameters: {
399
422
  type: "object",
400
423
  properties: {
@@ -402,18 +425,59 @@ exports.baseToolDefinitions = [
402
425
  type: { type: "string", enum: ["one-shot", "ongoing", "habit"] },
403
426
  category: { type: "string" },
404
427
  body: { type: "string" },
428
+ status: { type: "string" },
429
+ validator: { type: "string" },
430
+ requester: { type: "string" },
431
+ scheduledAt: { type: "string", description: "ISO timestamp for a one-time scheduled run/reminder" },
432
+ cadence: { type: "string", description: "recurrence like 30m, 1h, 1d, or cron" },
405
433
  },
406
434
  required: ["title", "type", "category", "body"],
407
435
  },
408
436
  },
409
437
  },
410
438
  handler: (a) => {
439
+ try {
440
+ const created = (0, tasks_1.getTaskModule)().createTask(buildTaskCreateInput(a));
441
+ return `created: ${created}`;
442
+ }
443
+ catch (error) {
444
+ return `error: ${error instanceof Error ? error.message : String(error)}`;
445
+ }
446
+ },
447
+ },
448
+ {
449
+ tool: {
450
+ type: "function",
451
+ function: {
452
+ name: "schedule_reminder",
453
+ description: "create a scheduled reminder or recurring daemon job. use `scheduledAt` for one-time reminders and `cadence` for recurring reminders. this writes canonical task fields that the daemon reconciles into OS-level jobs.",
454
+ parameters: {
455
+ type: "object",
456
+ properties: {
457
+ title: { type: "string" },
458
+ body: { type: "string" },
459
+ category: { type: "string" },
460
+ scheduledAt: { type: "string", description: "ISO timestamp for a one-time reminder" },
461
+ cadence: { type: "string", description: "recurrence like 30m, 1h, 1d, or cron" },
462
+ },
463
+ required: ["title", "body"],
464
+ },
465
+ },
466
+ },
467
+ handler: (a) => {
468
+ const scheduledAt = normalizeOptionalText(a.scheduledAt);
469
+ const cadence = normalizeOptionalText(a.cadence);
470
+ if (!scheduledAt && !cadence) {
471
+ return "error: provide scheduledAt or cadence";
472
+ }
411
473
  try {
412
474
  const created = (0, tasks_1.getTaskModule)().createTask({
413
475
  title: a.title,
414
- type: a.type,
415
- category: a.category,
476
+ type: cadence ? "habit" : "one-shot",
477
+ category: normalizeOptionalText(a.category) ?? "reminder",
416
478
  body: a.body,
479
+ scheduledAt,
480
+ cadence,
417
481
  });
418
482
  return `created: ${created}`;
419
483
  }
@@ -184,7 +184,9 @@ function summarizeArgs(name, args) {
184
184
  if (name === "load_skill")
185
185
  return summarizeKeyValues(args, ["name"]);
186
186
  if (name === "task_create")
187
- return summarizeKeyValues(args, ["title", "type", "category"]);
187
+ return summarizeKeyValues(args, ["title", "type", "category", "scheduledAt", "cadence"]);
188
+ if (name === "schedule_reminder")
189
+ return summarizeKeyValues(args, ["title", "scheduledAt", "cadence"]);
188
190
  if (name === "task_update_status")
189
191
  return summarizeKeyValues(args, ["name", "status"]);
190
192
  if (name === "task_board_status")
@@ -38,6 +38,7 @@ exports.handleSigint = handleSigint;
38
38
  exports.addHistory = addHistory;
39
39
  exports.renderMarkdown = renderMarkdown;
40
40
  exports.createCliCallbacks = createCliCallbacks;
41
+ exports.createDebouncedLines = createDebouncedLines;
41
42
  exports.runCliSession = runCliSession;
42
43
  exports.main = main;
43
44
  const readline = __importStar(require("readline"));
@@ -71,12 +72,14 @@ class Spinner {
71
72
  msg = "";
72
73
  phrases = null;
73
74
  lastPhrase = "";
75
+ stopped = false;
74
76
  constructor(m = "working", phrases) {
75
77
  this.msg = m;
76
78
  if (phrases && phrases.length > 0)
77
79
  this.phrases = phrases;
78
80
  }
79
81
  start() {
82
+ this.stopped = false;
80
83
  process.stderr.write("\r\x1b[K");
81
84
  this.spin();
82
85
  this.iv = setInterval(() => this.spin(), 80);
@@ -85,15 +88,23 @@ class Spinner {
85
88
  }
86
89
  }
87
90
  spin() {
88
- process.stderr.write(`\r${this.frames[this.i]} ${this.msg}... `);
91
+ // Guard: clearInterval can't prevent already-dequeued callbacks
92
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
93
+ if (this.stopped)
94
+ return;
95
+ process.stderr.write(`\r\x1b[K${this.frames[this.i]} ${this.msg}... `);
89
96
  this.i = (this.i + 1) % this.frames.length;
90
97
  }
91
98
  rotatePhrase() {
99
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
100
+ if (this.stopped)
101
+ return;
92
102
  const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
93
103
  this.lastPhrase = next;
94
104
  this.msg = next;
95
105
  }
96
106
  stop(ok) {
107
+ this.stopped = true;
97
108
  if (this.iv) {
98
109
  clearInterval(this.iv);
99
110
  this.iv = null;
@@ -298,12 +309,25 @@ function createCliCallbacks() {
298
309
  currentSpinner.start();
299
310
  },
300
311
  onModelStreamStart: () => {
301
- currentSpinner?.stop();
302
- currentSpinner = null;
312
+ // No-op: content callbacks (onTextChunk, onReasoningChunk) handle
313
+ // stopping the spinner. onModelStreamStart fires too early and
314
+ // doesn't fire at all for final_answer tool streaming.
315
+ },
316
+ onClearText: () => {
317
+ streamer.reset();
303
318
  },
304
319
  onTextChunk: (text) => {
320
+ // Stop spinner if still running — final_answer streaming and Anthropic
321
+ // tool-only responses bypass onModelStreamStart, so the spinner would
322
+ // otherwise keep running (and its \r writes overwrite response text).
323
+ if (currentSpinner) {
324
+ currentSpinner.stop();
325
+ currentSpinner = null;
326
+ }
305
327
  if (hadReasoning) {
306
- process.stdout.write("\n\n");
328
+ // Single newline to separate reasoning from reply — reasoning
329
+ // output often ends with its own trailing newline(s)
330
+ process.stdout.write("\n");
307
331
  hadReasoning = false;
308
332
  }
309
333
  const rendered = streamer.push(text);
@@ -312,6 +336,10 @@ function createCliCallbacks() {
312
336
  textDirty = text.length > 0 && !text.endsWith("\n");
313
337
  },
314
338
  onReasoningChunk: (text) => {
339
+ if (currentSpinner) {
340
+ currentSpinner.stop();
341
+ currentSpinner = null;
342
+ }
315
343
  hadReasoning = true;
316
344
  process.stdout.write(`\x1b[2m${text}\x1b[0m`);
317
345
  textDirty = text.length > 0 && !text.endsWith("\n");
@@ -369,10 +397,50 @@ function createCliCallbacks() {
369
397
  },
370
398
  };
371
399
  }
400
+ // Debounced line iterator: collects rapid-fire lines (paste) into a single input.
401
+ // When the debounce timeout wins the race, the pending iter.next() is saved
402
+ // and reused in the next iteration to prevent it from silently consuming input.
403
+ async function* createDebouncedLines(source, debounceMs) {
404
+ if (debounceMs <= 0) {
405
+ yield* source;
406
+ return;
407
+ }
408
+ const iter = source[Symbol.asyncIterator]();
409
+ let pending = null;
410
+ while (true) {
411
+ const first = pending ? await pending : await iter.next();
412
+ pending = null;
413
+ if (first.done)
414
+ break;
415
+ const lines = [first.value];
416
+ let more = true;
417
+ while (more) {
418
+ const nextPromise = iter.next();
419
+ const raced = await Promise.race([
420
+ nextPromise.then((r) => ({ kind: "line", result: r })),
421
+ new Promise((r) => setTimeout(() => r({ kind: "timeout" }), debounceMs)),
422
+ ]);
423
+ if (raced.kind === "timeout") {
424
+ pending = nextPromise;
425
+ more = false;
426
+ }
427
+ else if (raced.result.done) {
428
+ more = false;
429
+ }
430
+ else {
431
+ lines.push(raced.result.value);
432
+ }
433
+ }
434
+ yield lines.join("\n");
435
+ }
436
+ }
372
437
  async function runCliSession(options) {
438
+ /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
373
439
  const pasteDebounceMs = options.pasteDebounceMs ?? 50;
374
440
  const registry = (0, commands_1.createCommandRegistry)();
375
- (0, commands_1.registerDefaultCommands)(registry);
441
+ if (!options.disableCommands) {
442
+ (0, commands_1.registerDefaultCommands)(registry);
443
+ }
376
444
  const messages = options.messages
377
445
  ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
378
446
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
@@ -381,8 +449,13 @@ async function runCliSession(options) {
381
449
  const history = [];
382
450
  let closed = false;
383
451
  rl.on("close", () => { closed = true; });
384
- // eslint-disable-next-line no-console -- terminal UX: startup banner
385
- console.log(`\n${options.agentName} (type /commands for help)\n`);
452
+ if (options.banner !== false) {
453
+ const bannerText = typeof options.banner === "string"
454
+ ? options.banner
455
+ : `${options.agentName} (type /commands for help)`;
456
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
457
+ console.log(`\n${bannerText}\n`);
458
+ }
386
459
  const cliCallbacks = createCliCallbacks();
387
460
  // exitOnToolCall machinery: wrap execTool to detect target tool
388
461
  let exitToolResult;
@@ -394,13 +467,15 @@ async function runCliSession(options) {
394
467
  if (name === options.exitOnToolCall) {
395
468
  exitToolResult = result;
396
469
  exitToolFired = true;
470
+ // Abort immediately so the model doesn't generate more output
471
+ // (e.g. reasoning about calling final_answer after complete_adoption)
472
+ currentAbort?.abort();
397
473
  }
398
474
  return result;
399
475
  }
400
476
  : resolvedExecTool;
401
477
  // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
402
478
  const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
403
- process.stdout.write("\x1b[36m> \x1b[0m");
404
479
  // Ctrl-C at the input prompt: clear line or warn/exit
405
480
  rl.on("SIGINT", () => {
406
481
  const rlInt = rl;
@@ -422,37 +497,7 @@ async function runCliSession(options) {
422
497
  rl.close();
423
498
  }
424
499
  });
425
- // Debounced line iterator: collects rapid-fire lines (paste) into a single input
426
- async function* debouncedLines(source) {
427
- if (pasteDebounceMs <= 0) {
428
- yield* source;
429
- return;
430
- }
431
- const iter = source[Symbol.asyncIterator]();
432
- while (true) {
433
- const first = await iter.next();
434
- if (first.done)
435
- break;
436
- const lines = [first.value];
437
- let more = true;
438
- while (more) {
439
- const raced = await Promise.race([
440
- iter.next().then((r) => ({ kind: "line", result: r })),
441
- new Promise((r) => setTimeout(() => r({ kind: "timeout" }), pasteDebounceMs)),
442
- ]);
443
- if (raced.kind === "timeout") {
444
- more = false;
445
- }
446
- else if (raced.result.done) {
447
- more = false;
448
- }
449
- else {
450
- lines.push(raced.result.value);
451
- }
452
- }
453
- yield lines.join("\n");
454
- }
455
- }
500
+ const debouncedLines = (source) => createDebouncedLines(source, pasteDebounceMs);
456
501
  (0, runtime_1.emitNervesEvent)({
457
502
  component: "senses",
458
503
  event: "senses.cli_session_start",
@@ -460,6 +505,50 @@ async function runCliSession(options) {
460
505
  meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
461
506
  });
462
507
  let exitReason = "user_quit";
508
+ // Auto-first-turn: process the last user message immediately so the agent
509
+ // speaks first (e.g. specialist greeting). Only triggers when explicitly opted in.
510
+ if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
511
+ currentAbort = new AbortController();
512
+ const traceId = (0, nerves_1.createTraceId)();
513
+ ctrl.suppress(() => currentAbort.abort());
514
+ let result;
515
+ try {
516
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
517
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
518
+ traceId,
519
+ tools: options.tools,
520
+ execTool: wrappedExecTool,
521
+ toolContext: options.toolContext,
522
+ });
523
+ }
524
+ catch (err) {
525
+ // AbortError (Ctrl-C) -- silently continue to prompt
526
+ // All other errors: show the user what happened
527
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
528
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
529
+ }
530
+ }
531
+ cliCallbacks.flushMarkdown();
532
+ ctrl.restore();
533
+ currentAbort = null;
534
+ if (exitToolFired) {
535
+ exitReason = "tool_exit";
536
+ rl.close();
537
+ }
538
+ else {
539
+ const lastMsg = messages[messages.length - 1];
540
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
541
+ process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
542
+ }
543
+ process.stdout.write("\n\n");
544
+ if (options.onTurnEnd) {
545
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
546
+ }
547
+ }
548
+ }
549
+ if (!exitToolFired) {
550
+ process.stdout.write("\x1b[36m> \x1b[0m");
551
+ }
463
552
  try {
464
553
  for await (const input of debouncedLines(rl)) {
465
554
  if (closed)
@@ -521,7 +610,7 @@ async function runCliSession(options) {
521
610
  ctrl.suppress(() => currentAbort.abort());
522
611
  let result;
523
612
  try {
524
- result = await (0, core_1.runAgent)(messages, cliCallbacks, "cli", currentAbort.signal, {
613
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
525
614
  toolChoiceRequired: getEffectiveToolChoiceRequired(),
526
615
  traceId,
527
616
  tools: options.tools,
@@ -529,8 +618,12 @@ async function runCliSession(options) {
529
618
  toolContext: options.toolContext,
530
619
  });
531
620
  }
532
- catch {
533
- // AbortError -- silently return to prompt
621
+ catch (err) {
622
+ // AbortError (Ctrl-C) -- silently return to prompt
623
+ // All other errors: show the user what happened
624
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
625
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
626
+ }
534
627
  }
535
628
  cliCallbacks.flushMarkdown();
536
629
  ctrl.restore();
@@ -557,9 +650,12 @@ async function runCliSession(options) {
557
650
  }
558
651
  finally {
559
652
  rl.close();
560
- // eslint-disable-next-line no-console -- terminal UX: goodbye
561
- console.log("bye");
653
+ if (options.banner !== false) {
654
+ // eslint-disable-next-line no-console -- terminal UX: goodbye
655
+ console.log("bye");
656
+ }
562
657
  }
658
+ /* v8 ignore stop */
563
659
  return { exitReason, toolResult: exitToolResult };
564
660
  }
565
661
  async function main(agentName, options) {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
+ "cli": "dist/heart/daemon/ouro-bot-entry.js",
6
7
  "ouro": "dist/heart/daemon/ouro-entry.js",
7
8
  "ouro.bot": "dist/heart/daemon/ouro-bot-entry.js"
8
9
  },