@ouro.bot/cli 0.1.0-alpha.152 → 0.1.0-alpha.154

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.
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.humanReadableToolDescription = humanReadableToolDescription;
4
+ const runtime_1 = require("../nerves/runtime");
5
+ function basename(filePath) {
6
+ const idx = filePath.lastIndexOf("/");
7
+ return idx >= 0 ? filePath.slice(idx + 1) : filePath;
8
+ }
9
+ function truncate(text, max) {
10
+ if (text.length <= max)
11
+ return text;
12
+ return text.slice(0, max - 1) + "\u2026";
13
+ }
14
+ const TOOL_DESCRIPTIONS = {
15
+ shell: (args) => {
16
+ const cmd = args.command;
17
+ if (!cmd)
18
+ return "running a command...";
19
+ return `running ${truncate(cmd, 50)}...`;
20
+ },
21
+ read_file: (args) => {
22
+ const fp = args.path || args.file_path;
23
+ if (!fp)
24
+ return "reading a file...";
25
+ return `reading ${basename(fp)}...`;
26
+ },
27
+ write_file: (args) => {
28
+ const fp = args.path || args.file_path;
29
+ if (!fp)
30
+ return "writing a file...";
31
+ return `writing ${basename(fp)}...`;
32
+ },
33
+ edit_file: (args) => {
34
+ const fp = args.path || args.file_path;
35
+ if (!fp)
36
+ return "editing a file...";
37
+ return `editing ${basename(fp)}...`;
38
+ },
39
+ recall: (args) => {
40
+ const q = args.query;
41
+ if (!q)
42
+ return "searching memory...";
43
+ return `searching memory for '${truncate(q, 40)}'...`;
44
+ },
45
+ grep: (args) => {
46
+ const p = args.pattern;
47
+ if (!p)
48
+ return "searching code...";
49
+ return `searching code for '${truncate(p, 40)}'...`;
50
+ },
51
+ glob: (args) => {
52
+ const p = args.pattern;
53
+ if (!p)
54
+ return "searching for files...";
55
+ return `searching for ${truncate(p, 40)}...`;
56
+ },
57
+ query_session: () => "checking session history...",
58
+ web_search: () => "searching the web...",
59
+ coding_spawn: () => "starting coding session...",
60
+ ponder: () => "thinking deeper...",
61
+ observe: () => "listening...",
62
+ diary_write: () => "noting something down...",
63
+ save_friend_note: () => "making a note...",
64
+ settle: () => null,
65
+ rest: () => null,
66
+ };
67
+ function humanReadableToolDescription(name, args) {
68
+ (0, runtime_1.emitNervesEvent)({
69
+ component: "engine",
70
+ event: "engine.tool_description",
71
+ message: "generated human-readable tool description",
72
+ meta: { tool: name },
73
+ });
74
+ const builder = TOOL_DESCRIPTIONS[name];
75
+ if (builder)
76
+ return builder(args);
77
+ // MCP tools: mcp__server__toolname — extract last segment
78
+ if (name.startsWith("mcp__")) {
79
+ const parts = name.split("__");
80
+ const toolName = parts[parts.length - 1];
81
+ return `using ${toolName}...`;
82
+ }
83
+ return `using ${name}...`;
84
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBufferedSink = createBufferedSink;
4
+ const runtime_1 = require("./runtime");
5
+ const DEFAULT_MAX_SIZE = 1000;
6
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
7
+ function createBufferedSink(inner, options = {}) {
8
+ const maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
9
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
10
+ const nowMs = options.nowMs ?? (() => Date.now());
11
+ const buffer = [];
12
+ let dropped = 0;
13
+ let sinkHealthy = true;
14
+ let unhealthySince = null;
15
+ function tryInner(event) {
16
+ try {
17
+ inner(event);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function markUnhealthy() {
25
+ sinkHealthy = false;
26
+ unhealthySince = unhealthySince ?? nowMs();
27
+ }
28
+ function markHealthy() {
29
+ sinkHealthy = true;
30
+ unhealthySince = null;
31
+ }
32
+ function discardBuffer() {
33
+ const count = buffer.length;
34
+ dropped += count;
35
+ buffer.length = 0;
36
+ (0, runtime_1.emitNervesEvent)({
37
+ component: "nerves",
38
+ event: "nerves.buffer_ttl_discard",
39
+ message: `discarded ${count} buffered events after TTL`,
40
+ meta: { discarded: count, ttlMs },
41
+ });
42
+ }
43
+ function checkTtl() {
44
+ if (unhealthySince !== null && nowMs() - unhealthySince > ttlMs) {
45
+ discardBuffer();
46
+ // Reset unhealthySince to now so TTL starts fresh for newly buffered events
47
+ unhealthySince = nowMs();
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ function addToBuffer(event) {
53
+ if (buffer.length >= maxSize) {
54
+ buffer.shift();
55
+ dropped++;
56
+ }
57
+ buffer.push(event);
58
+ }
59
+ function flushBuffer() {
60
+ while (buffer.length > 0) {
61
+ const event = buffer[0];
62
+ if (tryInner(event)) {
63
+ buffer.shift();
64
+ }
65
+ else {
66
+ markUnhealthy();
67
+ return;
68
+ }
69
+ }
70
+ }
71
+ function sink(event) {
72
+ if (!sinkHealthy) {
73
+ checkTtl();
74
+ // Try sending the new event to see if inner has recovered
75
+ if (tryInner(event)) {
76
+ markHealthy();
77
+ flushBuffer();
78
+ }
79
+ else {
80
+ addToBuffer(event);
81
+ }
82
+ return;
83
+ }
84
+ if (tryInner(event)) {
85
+ return;
86
+ }
87
+ // Inner failed
88
+ markUnhealthy();
89
+ addToBuffer(event);
90
+ }
91
+ function flush() {
92
+ if (buffer.length === 0)
93
+ return;
94
+ // Test if inner is recovered by trying the first buffered event
95
+ const first = buffer[0];
96
+ if (tryInner(first)) {
97
+ buffer.shift();
98
+ markHealthy();
99
+ flushBuffer();
100
+ }
101
+ // If still broken, leave buffer intact
102
+ }
103
+ function state() {
104
+ return {
105
+ buffered: buffer.length,
106
+ dropped,
107
+ sinkHealthy,
108
+ };
109
+ }
110
+ return { sink, flush, state };
111
+ }
@@ -57,7 +57,7 @@ const channel_1 = require("../mind/friends/channel");
57
57
  const pending_1 = require("../mind/pending");
58
58
  const prompt_1 = require("../mind/prompt");
59
59
  const mcp_manager_1 = require("../repertoire/mcp-manager");
60
- const phrases_1 = require("../mind/phrases");
60
+ // getPhrases removed — no longer needed after debug-activity cleanup
61
61
  const runtime_1 = require("../nerves/runtime");
62
62
  const bluebubbles_model_1 = require("./bluebubbles-model");
63
63
  const bluebubbles_client_1 = require("./bluebubbles-client");
@@ -65,7 +65,8 @@ const bluebubbles_inbound_log_1 = require("./bluebubbles-inbound-log");
65
65
  const bluebubbles_mutation_log_1 = require("./bluebubbles-mutation-log");
66
66
  const bluebubbles_runtime_state_1 = require("./bluebubbles-runtime-state");
67
67
  const bluebubbles_session_cleanup_1 = require("./bluebubbles-session-cleanup");
68
- const debug_activity_1 = require("./debug-activity");
68
+ const tool_activity_callbacks_1 = require("../heart/tool-activity-callbacks");
69
+ const commands_1 = require("./commands");
69
70
  const trust_gate_1 = require("./trust-gate");
70
71
  const pipeline_1 = require("./pipeline");
71
72
  const bbFailoverStates = new Map();
@@ -362,63 +363,58 @@ function emitBlueBubblesMarkReadWarning(chat, error) {
362
363
  }
363
364
  function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
364
365
  let textBuffer = "";
365
- const phrases = (0, phrases_1.getPhrases)();
366
- const activity = (0, debug_activity_1.createDebugActivityController)({
367
- thinkingPhrases: phrases.thinking,
368
- followupPhrases: phrases.followup,
369
- startTypingOnModelStart: !isGroupChat,
370
- startTypingOnFirstTextChunk: isGroupChat,
371
- suppressInitialModelStatus: true,
372
- suppressFollowupPhraseStatus: true,
373
- transport: {
374
- sendStatus: async (text) => {
375
- const sent = await client.sendText({
376
- chat,
377
- text,
378
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
379
- });
380
- return sent.messageGuid;
381
- },
382
- editStatus: async (_messageGuid, text) => {
383
- await client.sendText({
384
- chat,
385
- text,
386
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
387
- });
388
- },
389
- setTyping: async (active) => {
390
- if (!active) {
391
- await client.setTyping(chat, false);
392
- return;
393
- }
394
- const [markReadResult, typingResult] = await Promise.allSettled([
395
- client.markChatRead(chat),
396
- client.setTyping(chat, true),
397
- ]);
398
- if (markReadResult.status === "rejected") {
399
- emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
400
- }
401
- if (typingResult.status === "rejected") {
402
- throw typingResult.reason;
403
- }
404
- },
405
- },
406
- onTransportError: (operation, error) => {
366
+ let typingActive = false;
367
+ let queue = Promise.resolve();
368
+ function enqueue(operation, task) {
369
+ queue = queue.then(task).catch((error) => {
407
370
  (0, runtime_1.emitNervesEvent)({
408
371
  level: "warn",
409
372
  component: "senses",
410
373
  event: "senses.bluebubbles_activity_error",
411
374
  message: "bluebubbles activity transport failed",
412
- meta: {
413
- operation,
414
- reason: error instanceof Error ? error.message : String(error),
415
- },
375
+ meta: { operation, reason: error instanceof Error ? error.message : String(error) },
416
376
  });
417
- },
377
+ });
378
+ }
379
+ function startTypingNow() {
380
+ /* v8 ignore next -- defensive guard: callers already check typingActive @preserve */
381
+ if (typingActive)
382
+ return;
383
+ typingActive = true;
384
+ enqueue("typing_start", async () => {
385
+ const [markReadResult, typingResult] = await Promise.allSettled([
386
+ client.markChatRead(chat),
387
+ client.setTyping(chat, true),
388
+ ]);
389
+ if (markReadResult.status === "rejected") {
390
+ emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
391
+ }
392
+ if (typingResult.status === "rejected") {
393
+ throw typingResult.reason;
394
+ }
395
+ });
396
+ }
397
+ function sendStatus(text) {
398
+ enqueue("send_status", async () => {
399
+ await client.sendText({
400
+ chat,
401
+ text,
402
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
403
+ });
404
+ });
405
+ }
406
+ const toolCallbacks = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
407
+ onDescription: (text) => sendStatus(text),
408
+ /* v8 ignore next -- onResult only called in debug mode; tested via tool-activity-callbacks.test.ts @preserve */
409
+ onResult: (text) => sendStatus(text),
410
+ /* v8 ignore next -- onFailure only called on tool failure; tested via tool-activity-callbacks.test.ts @preserve */
411
+ onFailure: (text) => sendStatus(`\u2717 ${text}`),
412
+ isDebug: (0, commands_1.getDebugMode)(),
418
413
  });
419
414
  return {
420
415
  onModelStart() {
421
- activity.onModelStart();
416
+ if (!isGroupChat)
417
+ startTypingNow();
422
418
  (0, runtime_1.emitNervesEvent)({
423
419
  component: "senses",
424
420
  event: "senses.bluebubbles_turn_start",
@@ -435,12 +431,16 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
435
431
  });
436
432
  },
437
433
  onTextChunk(text) {
438
- activity.onTextChunk(text);
434
+ if (isGroupChat && !typingActive)
435
+ startTypingNow();
439
436
  textBuffer += text;
440
437
  },
441
438
  onReasoningChunk(_text) { },
442
439
  onToolStart(name, _args) {
443
- activity.onToolStart(name, _args);
440
+ // Tool activity is a reply commitment — start typing if not already
441
+ if (!typingActive)
442
+ startTypingNow();
443
+ toolCallbacks.onToolStart(name, _args);
444
444
  (0, runtime_1.emitNervesEvent)({
445
445
  component: "senses",
446
446
  event: "senses.bluebubbles_tool_start",
@@ -449,7 +449,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
449
449
  });
450
450
  },
451
451
  onToolEnd(name, summary, success) {
452
- activity.onToolEnd(name, summary, success);
452
+ toolCallbacks.onToolEnd(name, summary, success);
453
453
  (0, runtime_1.emitNervesEvent)({
454
454
  component: "senses",
455
455
  event: "senses.bluebubbles_tool_end",
@@ -458,7 +458,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
458
458
  });
459
459
  },
460
460
  onError(error, severity) {
461
- activity.onError(error);
461
+ sendStatus(`\u2717 ${error.message}`);
462
462
  (0, runtime_1.emitNervesEvent)({
463
463
  level: severity === "terminal" ? "error" : "warn",
464
464
  component: "senses",
@@ -471,14 +471,23 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
471
471
  textBuffer = "";
472
472
  },
473
473
  async flush() {
474
- await activity.drain();
474
+ await queue;
475
475
  const trimmed = textBuffer.trim();
476
476
  if (!trimmed) {
477
- await activity.finish();
477
+ if (typingActive) {
478
+ typingActive = false;
479
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
480
+ await queue;
481
+ }
478
482
  return;
479
483
  }
480
484
  textBuffer = "";
481
- await activity.finish();
485
+ /* v8 ignore next 4 -- branch: typing may already be stopped before flush @preserve */
486
+ if (typingActive) {
487
+ typingActive = false;
488
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
489
+ await queue;
490
+ }
482
491
  await client.sendText({
483
492
  chat,
484
493
  text: trimmed,
@@ -486,7 +495,13 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
486
495
  });
487
496
  },
488
497
  async finish() {
489
- await activity.finish();
498
+ if (!typingActive) {
499
+ await queue;
500
+ return;
501
+ }
502
+ typingActive = false;
503
+ enqueue("typing_stop", async () => { await client.setTyping(chat, false); });
504
+ await queue;
490
505
  },
491
506
  };
492
507
  }
@@ -660,28 +660,30 @@ async function runCliSession(options) {
660
660
  continue;
661
661
  }
662
662
  }
663
- // Check for slash commands
664
- const parsed = (0, commands_1.parseSlashCommand)(input);
665
- if (parsed) {
666
- const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
667
- if (dispatchResult.handled && dispatchResult.result) {
668
- if (dispatchResult.result.action === "exit") {
669
- break;
670
- }
671
- else if (dispatchResult.result.action === "new") {
672
- messages.length = 0;
673
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
674
- await options.onNewSession?.();
675
- // eslint-disable-next-line no-console -- terminal UX: session cleared
676
- console.log("session cleared");
677
- process.stdout.write(CLI_PROMPT);
678
- continue;
679
- }
680
- else if (dispatchResult.result.action === "response") {
681
- // eslint-disable-next-line no-console -- terminal UX: command dispatch result
682
- console.log(dispatchResult.result.message || "");
683
- process.stdout.write(CLI_PROMPT);
684
- continue;
663
+ // Check for slash commands (legacy path only — pipeline handles commands for runTurn path)
664
+ if (!options.runTurn) {
665
+ const parsed = (0, commands_1.parseSlashCommand)(input);
666
+ if (parsed) {
667
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
668
+ if (dispatchResult.handled && dispatchResult.result) {
669
+ if (dispatchResult.result.action === "exit") {
670
+ break;
671
+ }
672
+ else if (dispatchResult.result.action === "new") {
673
+ messages.length = 0;
674
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
675
+ await options.onNewSession?.();
676
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
677
+ console.log("session cleared");
678
+ process.stdout.write(CLI_PROMPT);
679
+ continue;
680
+ }
681
+ else if (dispatchResult.result.action === "response") {
682
+ // eslint-disable-next-line no-console -- terminal UX: command dispatch result
683
+ console.log(dispatchResult.result.message || "");
684
+ process.stdout.write(CLI_PROMPT);
685
+ continue;
686
+ }
685
687
  }
686
688
  }
687
689
  }
@@ -696,7 +698,27 @@ async function runCliSession(options) {
696
698
  if (options.runTurn) {
697
699
  // Pipeline-based turn: the runTurn callback handles user message assembly,
698
700
  // pending drain, trust gate, runAgent, postTurn, and token accumulation.
701
+ // Commands (e.g. /debug, /exit, /new) are intercepted in the pipeline.
699
702
  result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
703
+ // Handle pipeline-intercepted commands with loop-control side effects
704
+ if (result?.turnOutcome === "command") {
705
+ if (result.commandAction === "exit") {
706
+ break;
707
+ }
708
+ else if (result.commandAction === "new") {
709
+ messages.length = 0;
710
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
711
+ await options.onNewSession?.();
712
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
713
+ console.log("session cleared");
714
+ process.stdout.write(CLI_PROMPT);
715
+ continue;
716
+ }
717
+ // For "response" commands: the pipeline already emitted the response via onTextChunk
718
+ cliCallbacks.flushMarkdown();
719
+ process.stdout.write(CLI_PROMPT);
720
+ continue;
721
+ }
700
722
  }
701
723
  else {
702
724
  // Legacy path: inline runAgent (used by adoption specialist and tests)
@@ -898,7 +920,7 @@ async function main(agentName, options) {
898
920
  process.stdout.write(`${result.gateResult.autoReply}\n`);
899
921
  }
900
922
  }
901
- return { usage: result.usage };
923
+ return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
902
924
  },
903
925
  onNewSession: () => {
904
926
  (0, context_1.deleteSession)(sessPath);
@@ -3,7 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createCommandRegistry = createCommandRegistry;
4
4
  exports.getToolChoiceRequired = getToolChoiceRequired;
5
5
  exports.resetToolChoiceRequired = resetToolChoiceRequired;
6
+ exports.getDebugMode = getDebugMode;
7
+ exports.resetDebugMode = resetDebugMode;
6
8
  exports.registerDefaultCommands = registerDefaultCommands;
9
+ exports.getSharedCommandRegistry = getSharedCommandRegistry;
10
+ exports.resetSharedCommandRegistry = resetSharedCommandRegistry;
7
11
  exports.parseSlashCommand = parseSlashCommand;
8
12
  const identity_1 = require("../heart/identity");
9
13
  const runtime_1 = require("../nerves/runtime");
@@ -35,6 +39,14 @@ function getToolChoiceRequired() {
35
39
  function resetToolChoiceRequired() {
36
40
  _toolChoiceRequired = false;
37
41
  }
42
+ // Module-level toggle for debug mode
43
+ let _debugMode = false;
44
+ function getDebugMode() {
45
+ return _debugMode;
46
+ }
47
+ function resetDebugMode() {
48
+ _debugMode = false;
49
+ }
38
50
  function registerDefaultCommands(registry) {
39
51
  (0, runtime_1.emitNervesEvent)({
40
52
  event: "repertoire.load_start",
@@ -73,6 +85,15 @@ function registerDefaultCommands(registry) {
73
85
  return { action: "response", message: `tool-required mode: ${_toolChoiceRequired ? "ON" : "OFF"}` };
74
86
  },
75
87
  });
88
+ registry.register({
89
+ name: "debug",
90
+ description: "toggle debug mode (verbose tool output)",
91
+ channels: ["cli", "teams", "bluebubbles"],
92
+ handler: () => {
93
+ _debugMode = !_debugMode;
94
+ return { action: "response", message: `debug mode: ${_debugMode ? "ON" : "OFF"}` };
95
+ },
96
+ });
76
97
  (0, runtime_1.emitNervesEvent)({
77
98
  event: "repertoire.load_end",
78
99
  component: "repertoire",
@@ -80,6 +101,17 @@ function registerDefaultCommands(registry) {
80
101
  meta: {},
81
102
  });
82
103
  }
104
+ let _sharedRegistry = null;
105
+ function getSharedCommandRegistry() {
106
+ if (!_sharedRegistry) {
107
+ _sharedRegistry = createCommandRegistry();
108
+ registerDefaultCommands(_sharedRegistry);
109
+ }
110
+ return _sharedRegistry;
111
+ }
112
+ function resetSharedCommandRegistry() {
113
+ _sharedRegistry = null;
114
+ }
83
115
  function parseSlashCommand(input) {
84
116
  const trimmed = input.trim();
85
117
  if (!trimmed.startsWith("/"))
@@ -71,6 +71,7 @@ const contextual_heartbeat_1 = require("./contextual-heartbeat");
71
71
  const journal_index_1 = require("../mind/journal-index");
72
72
  const habit_parser_1 = require("../heart/daemon/habit-parser");
73
73
  const cadence_1 = require("../heart/daemon/cadence");
74
+ const daemon_health_1 = require("../heart/daemon/daemon-health");
74
75
  const DEFAULT_INNER_DIALOG_INSTINCTS = [
75
76
  {
76
77
  id: "heartbeat_checkin",
@@ -647,6 +648,17 @@ async function runInnerDialogTurn(options) {
647
648
  // Habit file not found
648
649
  userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
649
650
  }
651
+ // Degraded state nudge (best-effort: never crash on health file read failure)
652
+ try {
653
+ const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
654
+ if (health && health.degraded.length > 0) {
655
+ const reasons = health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ");
656
+ userContent = `${userContent}\n\n[note: my scheduling is degraded: ${reasons}]`;
657
+ }
658
+ }
659
+ catch {
660
+ // Best-effort: missing file or parse error -> no nudge, no crash
661
+ }
650
662
  }
651
663
  else {
652
664
  userContent = buildInstinctUserMessage(instincts, reason, state);
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.handleInboundTurn = handleInboundTurn;
9
9
  const runtime_1 = require("../nerves/runtime");
10
+ const commands_1 = require("./commands");
10
11
  const continuity_1 = require("./continuity");
11
12
  const manager_1 = require("../heart/bridges/manager");
12
13
  const identity_1 = require("../heart/identity");
@@ -167,6 +168,41 @@ async function handleInboundTurn(input) {
167
168
  // Switch failed OR succeeded — either way, fall through to normal processing.
168
169
  }
169
170
  }
171
+ // Step 0b: Slash command interception (before friend resolution / agent turn)
172
+ {
173
+ const firstUserMsg = input.messages.find((m) => m.role === "user");
174
+ const userText = firstUserMsg
175
+ ? (typeof firstUserMsg.content === "string"
176
+ ? firstUserMsg.content
177
+ : Array.isArray(firstUserMsg.content)
178
+ ? firstUserMsg.content.find((p) => p.type === "text")?.text ?? ""
179
+ : /* v8 ignore next -- defensive: content is always string or array @preserve */ "")
180
+ : "";
181
+ const parsed = (0, commands_1.parseSlashCommand)(userText);
182
+ if (parsed) {
183
+ const registry = (0, commands_1.getSharedCommandRegistry)();
184
+ const dispatchResult = registry.dispatch(parsed.command, { channel: input.channel });
185
+ if (dispatchResult.handled && dispatchResult.result) {
186
+ (0, runtime_1.emitNervesEvent)({
187
+ component: "senses",
188
+ event: "senses.pipeline_command",
189
+ message: `slash command intercepted: /${parsed.command}`,
190
+ meta: { command: parsed.command, channel: input.channel },
191
+ });
192
+ if (dispatchResult.result.message) {
193
+ input.callbacks.onTextChunk(dispatchResult.result.message);
194
+ }
195
+ // Return a minimal result — no agent turn, no session write
196
+ const resolvedContext = await input.friendResolver.resolve();
197
+ return {
198
+ resolvedContext,
199
+ gateResult: { allowed: true },
200
+ turnOutcome: "command",
201
+ commandAction: dispatchResult.result.action,
202
+ };
203
+ }
204
+ }
205
+ }
170
206
  // Step 1: Resolve friend
171
207
  const resolvedContext = await input.friendResolver.resolve();
172
208
  (0, runtime_1.emitNervesEvent)({