@ouro.bot/cli 0.1.0-alpha.12 → 0.1.0-alpha.14

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.
@@ -38,6 +38,7 @@ exports.handleSigint = handleSigint;
38
38
  exports.addHistory = addHistory;
39
39
  exports.renderMarkdown = renderMarkdown;
40
40
  exports.createCliCallbacks = createCliCallbacks;
41
+ exports.runCliSession = runCliSession;
41
42
  exports.main = main;
42
43
  const readline = __importStar(require("readline"));
43
44
  const os = __importStar(require("os"));
@@ -368,76 +369,12 @@ function createCliCallbacks() {
368
369
  },
369
370
  };
370
371
  }
371
- async function main(agentName, options) {
372
- if (agentName)
373
- (0, identity_1.setAgentName)(agentName);
374
- const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
375
- // Fail fast if provider is misconfigured (triggers human-readable error + exit)
376
- (0, core_1.getProvider)();
372
+ async function runCliSession(options) {
373
+ const pasteDebounceMs = options.pasteDebounceMs ?? 50;
377
374
  const registry = (0, commands_1.createCommandRegistry)();
378
375
  (0, commands_1.registerDefaultCommands)(registry);
379
- // Resolve context kernel (identity + channel) for CLI
380
- const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
381
- const friendStore = new store_file_1.FileFriendStore(friendsPath);
382
- const username = os.userInfo().username;
383
- const hostname = os.hostname();
384
- const localExternalId = `${username}@${hostname}`;
385
- const resolver = new resolver_1.FriendResolver(friendStore, {
386
- provider: "local",
387
- externalId: localExternalId,
388
- displayName: username,
389
- channel: "cli",
390
- });
391
- const resolvedContext = await resolver.resolve();
392
- const cliToolContext = {
393
- /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
394
- signin: async () => undefined,
395
- context: resolvedContext,
396
- friendStore,
397
- summarize: (0, core_1.createSummarize)(),
398
- };
399
- const friendId = resolvedContext.friend.id;
400
- const agentConfig = (0, identity_1.loadAgentConfig)();
401
- (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
402
- level: agentConfig.logging?.level,
403
- sinks: agentConfig.logging?.sinks,
404
- });
405
- const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
406
- let sessionLock = null;
407
- try {
408
- sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
409
- }
410
- catch (error) {
411
- /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
412
- if (error instanceof session_lock_1.SessionLockError) {
413
- process.stderr.write(`${error.message}\n`);
414
- return;
415
- }
416
- throw error;
417
- /* v8 ignore stop */
418
- }
419
- // Load existing session or start fresh
420
- const existing = (0, context_1.loadSession)(sessPath);
421
- const messages = existing?.messages && existing.messages.length > 0
422
- ? existing.messages
423
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
424
- // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
425
- const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
426
- const drainToMessages = () => {
427
- const pending = (0, pending_1.drainPending)(pendingDir);
428
- if (pending.length === 0)
429
- return 0;
430
- for (const msg of pending) {
431
- messages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
432
- messages.push({ role: "assistant", content: msg.content });
433
- }
434
- return pending.length;
435
- };
436
- // Startup drain: deliver offline messages
437
- const startupCount = drainToMessages();
438
- if (startupCount > 0) {
439
- (0, context_1.saveSession)(sessPath, messages);
440
- }
376
+ const messages = options.messages
377
+ ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
441
378
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
442
379
  const ctrl = new InputController(rl);
443
380
  let currentAbort = null;
@@ -445,11 +382,26 @@ async function main(agentName, options) {
445
382
  let closed = false;
446
383
  rl.on("close", () => { closed = true; });
447
384
  // eslint-disable-next-line no-console -- terminal UX: startup banner
448
- console.log(`\n${(0, identity_1.getAgentName)()} (type /commands for help)\n`);
385
+ console.log(`\n${options.agentName} (type /commands for help)\n`);
449
386
  const cliCallbacks = createCliCallbacks();
387
+ // exitOnToolCall machinery: wrap execTool to detect target tool
388
+ let exitToolResult;
389
+ let exitToolFired = false;
390
+ const resolvedExecTool = options.execTool;
391
+ const wrappedExecTool = options.exitOnToolCall && resolvedExecTool
392
+ ? async (name, args, ctx) => {
393
+ const result = await resolvedExecTool(name, args, ctx);
394
+ if (name === options.exitOnToolCall) {
395
+ exitToolResult = result;
396
+ exitToolFired = true;
397
+ }
398
+ return result;
399
+ }
400
+ : resolvedExecTool;
401
+ // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
402
+ const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
450
403
  process.stdout.write("\x1b[36m> \x1b[0m");
451
404
  // Ctrl-C at the input prompt: clear line or warn/exit
452
- // readline with terminal:true catches Ctrl-C in raw mode (no ^C echo)
453
405
  rl.on("SIGINT", () => {
454
406
  const rlInt = rl;
455
407
  const currentLine = rlInt.line || "";
@@ -481,7 +433,6 @@ async function main(agentName, options) {
481
433
  const first = await iter.next();
482
434
  if (first.done)
483
435
  break;
484
- // Collect any lines that arrive within the debounce window (paste detection)
485
436
  const lines = [first.value];
486
437
  let more = true;
487
438
  while (more) {
@@ -502,6 +453,13 @@ async function main(agentName, options) {
502
453
  yield lines.join("\n");
503
454
  }
504
455
  }
456
+ (0, runtime_1.emitNervesEvent)({
457
+ component: "senses",
458
+ event: "senses.cli_session_start",
459
+ message: "runCliSession started",
460
+ meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
461
+ });
462
+ let exitReason = "user_quit";
505
463
  try {
506
464
  for await (const input of debouncedLines(rl)) {
507
465
  if (closed)
@@ -510,20 +468,18 @@ async function main(agentName, options) {
510
468
  process.stdout.write("\x1b[36m> \x1b[0m");
511
469
  continue;
512
470
  }
513
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
514
- friend: resolvedContext.friend,
515
- provider: "local",
516
- externalId: localExternalId,
517
- channel: "cli",
518
- });
519
- if (!trustGate.allowed) {
520
- if (trustGate.reason === "stranger_first_reply") {
521
- process.stdout.write(`${trustGate.autoReply}\n`);
471
+ // Optional input gate (e.g. trust gate in main)
472
+ if (options.onInput) {
473
+ const gate = options.onInput(input);
474
+ if (!gate.allowed) {
475
+ if (gate.reply) {
476
+ process.stdout.write(`${gate.reply}\n`);
477
+ }
478
+ if (closed)
479
+ break;
480
+ process.stdout.write("\x1b[36m> \x1b[0m");
481
+ continue;
522
482
  }
523
- if (closed)
524
- break;
525
- process.stdout.write("\x1b[36m> \x1b[0m");
526
- continue;
527
483
  }
528
484
  // Check for slash commands
529
485
  const parsed = (0, commands_1.parseSlashCommand)(input);
@@ -536,7 +492,7 @@ async function main(agentName, options) {
536
492
  else if (dispatchResult.result.action === "new") {
537
493
  messages.length = 0;
538
494
  messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
539
- (0, context_1.deleteSession)(sessPath);
495
+ await options.onNewSession?.();
540
496
  // eslint-disable-next-line no-console -- terminal UX: session cleared
541
497
  console.log("session cleared");
542
498
  process.stdout.write("\x1b[36m> \x1b[0m");
@@ -550,13 +506,12 @@ async function main(agentName, options) {
550
506
  }
551
507
  }
552
508
  }
553
- // Re-style the echoed input lines (readline terminal:true echoes each line)
554
- // For multiline paste, each line was echoed separately — erase them all
509
+ // Re-style the echoed input lines
555
510
  const cols = process.stdout.columns || 80;
556
511
  const inputLines = input.split("\n");
557
512
  let echoRows = 0;
558
513
  for (const line of inputLines) {
559
- echoRows += Math.ceil((2 + line.length) / cols); // "> " prefix + line content
514
+ echoRows += Math.ceil((2 + line.length) / cols);
560
515
  }
561
516
  process.stdout.write(`\x1b[${echoRows}A\x1b[K` + `\x1b[1m> ${inputLines[0]}${inputLines.length > 1 ? ` (+${inputLines.length - 1} lines)` : ""}\x1b[0m\n\n`);
562
517
  messages.push({ role: "user", content: input });
@@ -567,38 +522,147 @@ async function main(agentName, options) {
567
522
  let result;
568
523
  try {
569
524
  result = await (0, core_1.runAgent)(messages, cliCallbacks, "cli", currentAbort.signal, {
570
- toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
571
- toolContext: cliToolContext,
525
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
572
526
  traceId,
527
+ tools: options.tools,
528
+ execTool: wrappedExecTool,
529
+ toolContext: options.toolContext,
573
530
  });
574
531
  }
575
532
  catch {
576
- // AbortError silently return to prompt
533
+ // AbortError -- silently return to prompt
577
534
  }
578
535
  cliCallbacks.flushMarkdown();
579
536
  ctrl.restore();
580
537
  currentAbort = null;
538
+ // Check if exit tool was fired during this turn
539
+ if (exitToolFired) {
540
+ exitReason = "tool_exit";
541
+ break;
542
+ }
581
543
  // Safety net: never silently swallow an empty response
582
544
  const lastMsg = messages[messages.length - 1];
583
545
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
584
546
  process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
585
547
  }
586
548
  process.stdout.write("\n\n");
587
- (0, context_1.postTurn)(messages, sessPath, result?.usage);
588
- await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result?.usage);
589
- // Post-turn: drain any pending messages that arrived during runAgent
590
- drainToMessages();
591
- // Post-turn: refresh system prompt so active sessions metadata is current
592
- await (0, prompt_refresh_1.refreshSystemPrompt)(messages, "cli", undefined, resolvedContext);
549
+ // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
550
+ if (options.onTurnEnd) {
551
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
552
+ }
593
553
  if (closed)
594
554
  break;
595
555
  process.stdout.write("\x1b[36m> \x1b[0m");
596
556
  }
597
557
  }
598
558
  finally {
599
- sessionLock?.release();
600
559
  rl.close();
601
560
  // eslint-disable-next-line no-console -- terminal UX: goodbye
602
561
  console.log("bye");
603
562
  }
563
+ return { exitReason, toolResult: exitToolResult };
564
+ }
565
+ async function main(agentName, options) {
566
+ if (agentName)
567
+ (0, identity_1.setAgentName)(agentName);
568
+ const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
569
+ // Fail fast if provider is misconfigured (triggers human-readable error + exit)
570
+ (0, core_1.getProvider)();
571
+ // Resolve context kernel (identity + channel) for CLI
572
+ const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
573
+ const friendStore = new store_file_1.FileFriendStore(friendsPath);
574
+ const username = os.userInfo().username;
575
+ const hostname = os.hostname();
576
+ const localExternalId = `${username}@${hostname}`;
577
+ const resolver = new resolver_1.FriendResolver(friendStore, {
578
+ provider: "local",
579
+ externalId: localExternalId,
580
+ displayName: username,
581
+ channel: "cli",
582
+ });
583
+ const resolvedContext = await resolver.resolve();
584
+ const cliToolContext = {
585
+ /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
586
+ signin: async () => undefined,
587
+ context: resolvedContext,
588
+ friendStore,
589
+ summarize: (0, core_1.createSummarize)(),
590
+ };
591
+ const friendId = resolvedContext.friend.id;
592
+ const agentConfig = (0, identity_1.loadAgentConfig)();
593
+ (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
594
+ level: agentConfig.logging?.level,
595
+ sinks: agentConfig.logging?.sinks,
596
+ });
597
+ const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
598
+ let sessionLock = null;
599
+ try {
600
+ sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
601
+ }
602
+ catch (error) {
603
+ /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
604
+ if (error instanceof session_lock_1.SessionLockError) {
605
+ process.stderr.write(`${error.message}\n`);
606
+ return;
607
+ }
608
+ throw error;
609
+ /* v8 ignore stop */
610
+ }
611
+ // Load existing session or start fresh
612
+ const existing = (0, context_1.loadSession)(sessPath);
613
+ const sessionMessages = existing?.messages && existing.messages.length > 0
614
+ ? existing.messages
615
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
616
+ // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
617
+ const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
618
+ const drainToMessages = () => {
619
+ const pending = (0, pending_1.drainPending)(pendingDir);
620
+ if (pending.length === 0)
621
+ return 0;
622
+ for (const msg of pending) {
623
+ sessionMessages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
624
+ sessionMessages.push({ role: "assistant", content: msg.content });
625
+ }
626
+ return pending.length;
627
+ };
628
+ // Startup drain: deliver offline messages
629
+ const startupCount = drainToMessages();
630
+ if (startupCount > 0) {
631
+ (0, context_1.saveSession)(sessPath, sessionMessages);
632
+ }
633
+ try {
634
+ await runCliSession({
635
+ agentName: (0, identity_1.getAgentName)(),
636
+ pasteDebounceMs,
637
+ messages: sessionMessages,
638
+ toolContext: cliToolContext,
639
+ onInput: () => {
640
+ const trustGate = (0, trust_gate_1.enforceTrustGate)({
641
+ friend: resolvedContext.friend,
642
+ provider: "local",
643
+ externalId: localExternalId,
644
+ channel: "cli",
645
+ });
646
+ if (!trustGate.allowed) {
647
+ return {
648
+ allowed: false,
649
+ reply: trustGate.reason === "stranger_first_reply" ? trustGate.autoReply : undefined,
650
+ };
651
+ }
652
+ return { allowed: true };
653
+ },
654
+ onTurnEnd: async (msgs, result) => {
655
+ (0, context_1.postTurn)(msgs, sessPath, result.usage);
656
+ await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result.usage);
657
+ drainToMessages();
658
+ await (0, prompt_refresh_1.refreshSystemPrompt)(msgs, "cli", undefined, resolvedContext);
659
+ },
660
+ onNewSession: () => {
661
+ (0, context_1.deleteSession)(sessPath);
662
+ },
663
+ });
664
+ }
665
+ finally {
666
+ sessionLock?.release();
667
+ }
604
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "ouro": "dist/heart/daemon/ouro-entry.js",
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "dist/",
11
11
  "AdoptionSpecialist.ouro/",
12
- "subagents/"
12
+ "subagents/",
13
+ "assets/"
13
14
  ],
14
15
  "exports": {
15
16
  ".": "./dist/heart/daemon/daemon-cli.js",
@@ -34,6 +35,10 @@
34
35
  "@microsoft/teams.dev": "^2.0.5",
35
36
  "openai": "^6.27.0"
36
37
  },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/ouroborosbot/ouroboros"
41
+ },
37
42
  "devDependencies": {
38
43
  "@vitest/coverage-v8": "^4.0.18",
39
44
  "eslint": "^10.0.2",
@@ -1,177 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runSpecialistSession = runSpecialistSession;
4
- const runtime_1 = require("../../nerves/runtime");
5
- /**
6
- * Run the specialist conversation session loop.
7
- *
8
- * The loop:
9
- * 1. Initialize messages with system prompt
10
- * 2. Prompt user -> add to messages -> call streamTurn -> process result
11
- * 3. If result has no tool calls: push assistant message, re-prompt
12
- * 4. If result has final_answer sole call: extract answer, emit via callbacks, done
13
- * 5. If result has other tool calls: execute each, push tool results, continue loop
14
- * 6. On abort signal: clean exit
15
- * 7. Return { hatchedAgentName } -- name from hatch_agent if called
16
- */
17
- async function runSpecialistSession(deps) {
18
- const { providerRuntime, systemPrompt, tools, execTool, readline, callbacks, signal, kickoffMessage, suppressInput, restoreInput, flushMarkdown, writePrompt, } = deps;
19
- (0, runtime_1.emitNervesEvent)({
20
- component: "daemon",
21
- event: "daemon.specialist_session_start",
22
- message: "starting specialist session loop",
23
- meta: {},
24
- });
25
- const messages = [
26
- { role: "system", content: systemPrompt },
27
- ];
28
- let hatchedAgentName = null;
29
- let done = false;
30
- let isFirstTurn = true;
31
- let currentAbort = null;
32
- try {
33
- while (!done) {
34
- if (signal?.aborted)
35
- break;
36
- // On the first turn with a kickoff message, inject it so the specialist speaks first
37
- if (isFirstTurn && kickoffMessage) {
38
- isFirstTurn = false;
39
- messages.push({ role: "user", content: kickoffMessage });
40
- }
41
- else {
42
- // Get user input
43
- const userInput = await readline.question(writePrompt ? "" : "> ");
44
- if (!userInput.trim()) {
45
- if (writePrompt)
46
- writePrompt();
47
- continue;
48
- }
49
- messages.push({ role: "user", content: userInput });
50
- }
51
- providerRuntime.resetTurnState(messages);
52
- // Suppress input during model execution
53
- currentAbort = new AbortController();
54
- const mergedSignal = signal;
55
- if (suppressInput) {
56
- suppressInput(() => currentAbort.abort());
57
- }
58
- // Inner loop: process tool calls until we get a final_answer or plain text
59
- let turnDone = false;
60
- while (!turnDone) {
61
- if (mergedSignal?.aborted || currentAbort.signal.aborted) {
62
- done = true;
63
- break;
64
- }
65
- callbacks.onModelStart();
66
- const result = await providerRuntime.streamTurn({
67
- messages,
68
- activeTools: tools,
69
- callbacks,
70
- signal: mergedSignal,
71
- });
72
- // Build assistant message
73
- const assistantMsg = {
74
- role: "assistant",
75
- };
76
- if (result.content)
77
- assistantMsg.content = result.content;
78
- if (result.toolCalls.length) {
79
- assistantMsg.tool_calls = result.toolCalls.map((tc) => ({
80
- id: tc.id,
81
- type: "function",
82
- function: { name: tc.name, arguments: tc.arguments },
83
- }));
84
- }
85
- if (!result.toolCalls.length) {
86
- // Plain text response -- flush markdown, push and re-prompt
87
- if (flushMarkdown)
88
- flushMarkdown();
89
- messages.push(assistantMsg);
90
- turnDone = true;
91
- continue;
92
- }
93
- // Check for final_answer
94
- const isSoleFinalAnswer = result.toolCalls.length === 1 && result.toolCalls[0].name === "final_answer";
95
- if (isSoleFinalAnswer) {
96
- let answer;
97
- try {
98
- const parsed = JSON.parse(result.toolCalls[0].arguments);
99
- if (typeof parsed === "string") {
100
- answer = parsed;
101
- }
102
- else if (parsed.answer != null) {
103
- answer = parsed.answer;
104
- }
105
- }
106
- catch {
107
- // malformed
108
- }
109
- if (answer != null) {
110
- callbacks.onTextChunk(answer);
111
- if (flushMarkdown)
112
- flushMarkdown();
113
- messages.push(assistantMsg);
114
- done = true;
115
- turnDone = true;
116
- continue;
117
- }
118
- // Malformed final_answer -- ask model to retry
119
- messages.push(assistantMsg);
120
- messages.push({
121
- role: "tool",
122
- tool_call_id: result.toolCalls[0].id,
123
- content: "your final_answer was incomplete or malformed. call final_answer again with your complete response.",
124
- });
125
- providerRuntime.appendToolOutput(result.toolCalls[0].id, "retry");
126
- continue;
127
- }
128
- // Execute tool calls
129
- messages.push(assistantMsg);
130
- for (const tc of result.toolCalls) {
131
- if (mergedSignal?.aborted)
132
- break;
133
- let args = {};
134
- try {
135
- args = JSON.parse(tc.arguments);
136
- }
137
- catch {
138
- // ignore parse error
139
- }
140
- callbacks.onToolStart(tc.name, args);
141
- let toolResult;
142
- try {
143
- toolResult = await execTool(tc.name, args);
144
- }
145
- catch (e) {
146
- toolResult = `error: ${e}`;
147
- }
148
- callbacks.onToolEnd(tc.name, tc.name, true);
149
- // Track hatchling name
150
- if (tc.name === "hatch_agent" && args.name) {
151
- hatchedAgentName = args.name;
152
- }
153
- messages.push({ role: "tool", tool_call_id: tc.id, content: toolResult });
154
- providerRuntime.appendToolOutput(tc.id, toolResult);
155
- }
156
- // After processing tool calls, continue inner loop for tool result processing
157
- }
158
- // Restore input and show prompt for next turn
159
- if (flushMarkdown)
160
- flushMarkdown();
161
- if (restoreInput)
162
- restoreInput();
163
- currentAbort = null;
164
- if (!done) {
165
- process.stdout.write("\n\n");
166
- if (writePrompt)
167
- writePrompt();
168
- }
169
- }
170
- }
171
- finally {
172
- if (restoreInput)
173
- restoreInput();
174
- readline.close();
175
- }
176
- return { hatchedAgentName };
177
- }
@@ -1,4 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- // Backward-compatible wrapper; unified runtime now lives at heart/agent-entry.
4
- require("./heart/agent-entry");