@ouro.bot/cli 0.1.0-alpha.514 → 0.1.0-alpha.516
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/changelog.json +22 -0
- package/dist/heart/core.js +65 -0
- package/dist/heart/daemon/cli-render.js +65 -1
- package/dist/heart/daemon/daemon-entry.js +30 -1
- package/dist/heart/daemon/daemon-health.js +35 -1
- package/dist/heart/daemon/daemon-rollup.js +57 -0
- package/dist/heart/outlook/readers/runtime-readers.js +7 -1
- package/dist/heart/tool-description.js +4 -0
- package/dist/mind/prompt.js +16 -0
- package/dist/nerves/coverage/file-completeness.js +6 -0
- package/dist/repertoire/tools-base.js +2 -1
- package/dist/repertoire/tools-flow.js +15 -1
- package/dist/repertoire/tools.js +2 -1
- package/dist/senses/bluebubbles/index.js +32 -2
- package/dist/senses/cli/ouro-tui.js +4 -2
- package/dist/senses/cli/tool-display.js +4 -2
- package/dist/senses/cli.js +9 -0
- package/dist/senses/teams.js +83 -0
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.516",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Layer 1 of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Replaces the daemon-wide rollup at `daemon-entry.ts` (the binary `degraded.length > 0 ? \"degraded\" : \"ok\"` literal) with a five-state vocabulary: `healthy / partial / degraded / safe-mode / down`. A single sick agent no longer tips the whole daemon to `degraded`.",
|
|
8
|
+
"Type structure: `RollupStatus` (4-state, returned by the new pure `computeDaemonRollup` decision function in `daemon-rollup.ts`) and `DaemonStatus = RollupStatus | \"down\"` (full daemon-status; `down` is caller-owned because it represents pre-inventory failure, before the rollup is reachable). Both unions project from a single source-of-truth literal tuple so future widening touches one site.",
|
|
9
|
+
"`renderRollupStatusLine` in `cli-render.ts` uses a compiler-forced `never`-typed exhaustive switch — adding a future state compile-errors at every consumer using the pattern. The `degraded` literal carries three copy variants picked by inspecting cached agent statuses: empty map (fresh install, prompts `ouro hatch`), non-empty + any running agent (legacy stale cache from pre-Layer-1 daemons, prompts `ouro up` refresh), non-empty + zero running (all-failed live-check, prompts `ouro doctor`).",
|
|
10
|
+
"`runtime-readers.ts:readDaemonHealthDeep` parse tightened to use `isDaemonStatus`. `OutlookDaemonHealthDeep.status` widened to `DaemonStatus | \"unknown\"` so legacy serialized strings (`\"running\"`, `\"ok\"`) coerce defensively rather than failing the parse during rollout.",
|
|
11
|
+
"9759 tests pass (508 test files); coverage gate clean. The per-agent live-check loop in `cli-exec.ts` is intentionally untouched — it was already try/catch-isolated; the bug was in how its output rolled up. Subsequent PRs (layers 4, 2, 3) build on this PR's vocabulary."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.515",
|
|
16
|
+
"changes": [
|
|
17
|
+
"New `speak` tool — agent can deliver words to the current friend mid-turn without ending the turn. Pairs with `settle` (ends turn) and `ponder` (private inner thought). For acknowledgment of heavy work, phase-boundary updates, or progress narration on chat-style channels (cli, teams, bluebubbles).",
|
|
18
|
+
"Schema is intentionally minimal: `speak({ message: string })`. Not sole-call, doesn't terminate the turn, NOT exempt from the 24-call circuit breaker (the breaker is healthy backpressure against narration-spam — silence is a natural fallback for speak, unlike settle/rest). Added `flushNow?(): void | Promise<void>` to `ChannelCallbacks`; per-sense impls deliver the buffered message immediately (CLI noop, BlueBubbles `client.sendText` keeping typing on, Teams stream emit with `sendMessage` fallback).",
|
|
19
|
+
"Engine integration follows the `ponder` interception template at `core.ts:~1303`: `speak` runs inline (emit + flushNow + push `(spoken)` tool result + nerves event `engine.speak`), then the loop continues. Empty/missing message rejected with a tool-result error and `engine.speak_invalid`. New event keys: `engine.speak`, `engine.speak_invalid`, `engine.speak_delivery_failed`, `bluebubbles.speak_flush`, `teams.speak_flush`.",
|
|
20
|
+
"System prompt nudge in Group #4 (`how i work`), gated to chat-style channels: dependency boundary (settle if next step needs a reply, otherwise speak), phase boundaries (after acking heavy ask / hitting major constraint / switching strategy / before externally-visible step — not per-tool narration), one-way framing (speak is progress, not invitation).",
|
|
21
|
+
"Hardened `speak` delivery semantics (slugger PR review fix). `flushNow` contract is now explicit: throws if the message could not be delivered through any available path. Teams `flushNow` THROWS when both stream emit AND `sendMessage` fallback fail (was silently logging delivered=false and returning normally — engine then recorded `(spoken)` even though nothing reached the friend). BlueBubbles `flushNow` already let `client.sendText` rejections propagate; contract documented. Engine wraps `await flushNow()` in try/catch: on hard failure it calls `onToolEnd('speak', ..., false)`, pushes a `'speak delivery failed: ... did not reach your friend; do not assume they saw it'` tool result, emits `engine.speak_delivery_failed` (level=error), and the turn continues — preventing the agent from assuming silent success.",
|
|
22
|
+
"`speak` is now treated as flow-control across all senses (slugger PR review fix). Like settle/observe/ponder/rest, its only visible output is the message itself — no spinner, no phrase rotation, no `⏳` placeholder, no tool-activity status line. Added 'speak' to `FLOW_CONTROL_TOOLS` in `cli/tool-display.ts` and `cli/ouro-tui.tsx`; CLI/BlueBubbles/Teams `onToolStart` early-return for speak; `tool-description.ts` returns null for speak as defense-in-depth for any future sense using `createToolActivityCallbacks`. Teams `flushNow` also stops phrase rotation when it delivers, so the actual message replaces the cycling 'thinking...' phrase.",
|
|
23
|
+
"Teams `flushNow` no longer aborts the turn on a successful sendMessage fallback (slugger PR review fix). Prior code path: stream emit fails → `tryEmit` calls `markStopped()` which calls `controller.abort()` → falls through to `sendMessage` → succeeds → `flushNow` returns normally → core records `(spoken)` with success=true — but the turn controller is already aborted, so the next model/tool step aborts. Successful fallback delivery should not poison the rest of the turn. Fix adds a non-aborting `tryEmitNoAbort` variant adjacent to `tryEmit`; `flushNow` uses it so a primary-stream failure followed by a successful sendMessage no longer triggers `controller.abort()`. Only when ALL delivery paths fail does `flushNow` call `markStopped()` and throw, letting the engine's existing `engine.speak_delivery_failed` catch path end the turn cleanly. `tryEmit` and other non-flushNow callers (end-of-turn `flush()`, `safeEmit`) are unchanged — their abort-on-failure behavior remains correct because they have no fallback path forward."
|
|
24
|
+
]
|
|
25
|
+
},
|
|
4
26
|
{
|
|
5
27
|
"version": "0.1.0-alpha.514",
|
|
6
28
|
"changes": [
|
package/dist/heart/core.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.getModel = getModel;
|
|
|
6
6
|
exports.getProvider = getProvider;
|
|
7
7
|
exports.createSummarize = createSummarize;
|
|
8
8
|
exports.getProviderDisplayLabel = getProviderDisplayLabel;
|
|
9
|
+
exports.isChatStyleChannel = isChatStyleChannel;
|
|
9
10
|
exports.isExternalStateQuery = isExternalStateQuery;
|
|
10
11
|
exports.getSettleRetryError = getSettleRetryError;
|
|
11
12
|
exports.stripLastToolCalls = stripLastToolCalls;
|
|
@@ -211,6 +212,12 @@ function hasFreshPendingWork(options) {
|
|
|
211
212
|
return pendingMessages.some((message) => typeof message?.content === "string"
|
|
212
213
|
&& message.content.trim().length > 0);
|
|
213
214
|
}
|
|
215
|
+
/** Chat-style channels expose the `speak` tool — outer human-conversation channels
|
|
216
|
+
* where mid-turn delivery is meaningful. Inner dialog has `ponder`. MCP returns
|
|
217
|
+
* synchronously. Mail is batch. Anything else (unknown channel) treats as non-chat. */
|
|
218
|
+
function isChatStyleChannel(channel) {
|
|
219
|
+
return channel === "cli" || channel === "teams" || channel === "bluebubbles";
|
|
220
|
+
}
|
|
214
221
|
// Sole-call tools must be the only tool call in a turn. When they appear
|
|
215
222
|
// alongside other tools, the sole-call tool is rejected with this message.
|
|
216
223
|
const SOLE_CALL_REJECTION = {
|
|
@@ -672,6 +679,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
672
679
|
...(isInnerDialog ? [tools_2.surfaceToolDef, tools_1.restTool] : []),
|
|
673
680
|
...(!isInnerDialog ? [tools_1.observeTool] : []),
|
|
674
681
|
...(!isInnerDialog ? [tools_1.settleTool] : []),
|
|
682
|
+
...(isChatStyleChannel(channel ?? "") ? [tools_1.speakTool] : []),
|
|
675
683
|
];
|
|
676
684
|
const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
|
|
677
685
|
if (steeringFollowUps.length > 0) {
|
|
@@ -1074,6 +1082,63 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1074
1082
|
if (tc.name === "send_message" && args.friendId === "self") {
|
|
1075
1083
|
sawSendMessageSelf = true;
|
|
1076
1084
|
}
|
|
1085
|
+
if (tc.name === "speak") {
|
|
1086
|
+
let speakArgs = {};
|
|
1087
|
+
try {
|
|
1088
|
+
speakArgs = JSON.parse(tc.arguments);
|
|
1089
|
+
}
|
|
1090
|
+
catch { /* malformed */ }
|
|
1091
|
+
const speakMessage = typeof speakArgs.message === "string" ? speakArgs.message : "";
|
|
1092
|
+
const argSummary = (0, tools_1.summarizeArgs)("speak", { message: speakMessage });
|
|
1093
|
+
callbacks.onToolStart("speak", { message: speakMessage });
|
|
1094
|
+
if (speakMessage.trim().length === 0) {
|
|
1095
|
+
const err = "speak requires a non-empty `message` string.";
|
|
1096
|
+
callbacks.onToolEnd("speak", argSummary, false);
|
|
1097
|
+
messages.push({ role: "tool", tool_call_id: tc.id, content: err });
|
|
1098
|
+
providerRuntime.appendToolOutput(tc.id, err);
|
|
1099
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1100
|
+
level: "warn",
|
|
1101
|
+
component: "engine",
|
|
1102
|
+
event: "engine.speak_invalid",
|
|
1103
|
+
message: "speak rejected: missing or empty message",
|
|
1104
|
+
meta: {},
|
|
1105
|
+
});
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
callbacks.onTextChunk(speakMessage);
|
|
1109
|
+
let speakDeliveryError = null;
|
|
1110
|
+
try {
|
|
1111
|
+
await callbacks.flushNow?.();
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
speakDeliveryError = err instanceof Error ? err : new Error(String(err));
|
|
1115
|
+
}
|
|
1116
|
+
if (speakDeliveryError) {
|
|
1117
|
+
callbacks.onToolEnd("speak", argSummary, false);
|
|
1118
|
+
const failMsg = `speak delivery failed: ${speakDeliveryError.message}. the message did not reach your friend; do not assume they saw it.`;
|
|
1119
|
+
messages.push({ role: "tool", tool_call_id: tc.id, content: failMsg });
|
|
1120
|
+
providerRuntime.appendToolOutput(tc.id, failMsg);
|
|
1121
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1122
|
+
level: "error",
|
|
1123
|
+
component: "engine",
|
|
1124
|
+
event: "engine.speak_delivery_failed",
|
|
1125
|
+
message: "speak delivery failed",
|
|
1126
|
+
meta: { error: speakDeliveryError.message, messageLength: speakMessage.length },
|
|
1127
|
+
});
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
callbacks.onToolEnd("speak", argSummary, true);
|
|
1131
|
+
const ack = "(spoken)";
|
|
1132
|
+
messages.push({ role: "tool", tool_call_id: tc.id, content: ack });
|
|
1133
|
+
providerRuntime.appendToolOutput(tc.id, ack);
|
|
1134
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1135
|
+
component: "engine",
|
|
1136
|
+
event: "engine.speak",
|
|
1137
|
+
message: "agent spoke mid-turn",
|
|
1138
|
+
meta: { messageLength: speakMessage.length },
|
|
1139
|
+
});
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1077
1142
|
if (tc.name === "ponder") {
|
|
1078
1143
|
const parsedArgs = normalizeLegacyPonderArgs(parsePonderPayload(tc.arguments));
|
|
1079
1144
|
const argSummary = (0, tools_1.summarizeArgs)(tc.name, parsedArgs);
|
|
@@ -45,6 +45,7 @@ exports.formatTable = formatTable;
|
|
|
45
45
|
exports.formatDaemonStatusOutput = formatDaemonStatusOutput;
|
|
46
46
|
exports.formatVersionOutput = formatVersionOutput;
|
|
47
47
|
exports.buildStoppedStatusPayload = buildStoppedStatusPayload;
|
|
48
|
+
exports.renderRollupStatusLine = renderRollupStatusLine;
|
|
48
49
|
exports.daemonUnavailableStatusOutput = daemonUnavailableStatusOutput;
|
|
49
50
|
exports.isDaemonUnavailableError = isDaemonUnavailableError;
|
|
50
51
|
exports.formatMcpResponse = formatMcpResponse;
|
|
@@ -472,6 +473,69 @@ function buildStoppedStatusPayload(socketPath, syncRows = [], agentRows = []) {
|
|
|
472
473
|
providers: [],
|
|
473
474
|
};
|
|
474
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Render the cached daemon-rollup status as a one-line string for the
|
|
478
|
+
* "daemon not running" view. Each `DaemonStatus` literal maps to a
|
|
479
|
+
* label + a brief explanatory copy fragment. The default branch is
|
|
480
|
+
* `never`-typed so future widening of `DaemonStatus` compile-errors
|
|
481
|
+
* here — Layer 1's compiler-forced exhaustiveness contract.
|
|
482
|
+
*
|
|
483
|
+
* The `degraded` literal splits into two copy variants based on the
|
|
484
|
+
* cached health file's `agents` map:
|
|
485
|
+
* - empty map → "no agents configured" (fresh-install copy).
|
|
486
|
+
* - non-empty map → "none ready" (all-agents-failed-live-check copy).
|
|
487
|
+
*
|
|
488
|
+
* The split lives at the render layer (not in the rollup status itself)
|
|
489
|
+
* so the same status can carry distinct UX copy without inflating the
|
|
490
|
+
* type union.
|
|
491
|
+
*/
|
|
492
|
+
function renderRollupStatusLine(health) {
|
|
493
|
+
const status = health.status;
|
|
494
|
+
const tail = `(pid ${health.pid}, uptime ${health.uptimeSeconds}s)`;
|
|
495
|
+
/* v8 ignore next -- v8 instruments the switch statement itself as a branch; the never-typed default below is unreachable by construction so v8 cannot observe its branch firing @preserve */
|
|
496
|
+
switch (status) {
|
|
497
|
+
case "healthy":
|
|
498
|
+
return `Last known status: healthy ${tail}`;
|
|
499
|
+
case "partial":
|
|
500
|
+
return `Last known status: partial — some agents unhealthy ${tail}`;
|
|
501
|
+
case "degraded": {
|
|
502
|
+
// Three-way copy split based on the cached agents map:
|
|
503
|
+
// - empty map → fresh install / no agents configured.
|
|
504
|
+
// - non-empty + any agent reports "running" → legacy stale cache:
|
|
505
|
+
// pre-Layer-1, status="degraded" meant "any sick component," so a
|
|
506
|
+
// running agent could coexist with a degraded daemon. Post-Layer-1,
|
|
507
|
+
// degraded means "zero serving" — mutually exclusive with a running
|
|
508
|
+
// agent. A live disagreement therefore implies the cache pre-dates
|
|
509
|
+
// the rollup-semantics fix; prompt for `ouro up` to refresh rather
|
|
510
|
+
// than falsely claim "none ready."
|
|
511
|
+
// - non-empty + zero agents reporting "running" → all-failed copy.
|
|
512
|
+
const agentEntries = Object.values(health.agents);
|
|
513
|
+
if (agentEntries.length === 0) {
|
|
514
|
+
return `Last known status: degraded — no agents configured (run \`ouro hatch\` to add one) ${tail}`;
|
|
515
|
+
}
|
|
516
|
+
const anyRunning = agentEntries.some((agent) => agent.status === "running");
|
|
517
|
+
if (anyRunning) {
|
|
518
|
+
return `Last known status: degraded — stale cache, run \`ouro up\` to refresh ${tail}`;
|
|
519
|
+
}
|
|
520
|
+
return `Last known status: degraded — agents configured but none ready (run \`ouro doctor\`) ${tail}`;
|
|
521
|
+
}
|
|
522
|
+
case "safe-mode":
|
|
523
|
+
return `Last known status: safe-mode — crash loop tripped ${tail}`;
|
|
524
|
+
case "down":
|
|
525
|
+
return `Last known status: down ${tail}`;
|
|
526
|
+
/* v8 ignore start -- compiler-forced exhaustiveness: the never-typed default branch is unreachable by construction; if DaemonStatus widens, tsc errors at the assignment before the throw can run @preserve */
|
|
527
|
+
default: {
|
|
528
|
+
// Compiler-forced exhaustiveness. If DaemonStatus grows a new
|
|
529
|
+
// literal, this `never` cast errors at tsc, forcing every
|
|
530
|
+
// consumer to handle it explicitly. NEVER replace this with a
|
|
531
|
+
// permissive `default:` returning a fallback string — that's
|
|
532
|
+
// exactly how the old "ok | degraded" semantics leaked through.
|
|
533
|
+
const _exhaustive = status;
|
|
534
|
+
throw new Error(`unhandled daemon status: ${_exhaustive}`);
|
|
535
|
+
}
|
|
536
|
+
/* v8 ignore stop */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
475
539
|
function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
|
|
476
540
|
// Read per-agent sync config and bundle list from disk so the user still
|
|
477
541
|
// sees them when the daemon is down. Best-effort: any fs error returns []
|
|
@@ -515,7 +579,7 @@ function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
|
|
|
515
579
|
const resolvedHealthPath = healthFilePath ?? (0, daemon_health_1.getDefaultHealthPath)();
|
|
516
580
|
const health = (0, daemon_health_1.readHealth)(resolvedHealthPath);
|
|
517
581
|
if (health) {
|
|
518
|
-
lines.push(
|
|
582
|
+
lines.push(renderRollupStatusLine(health));
|
|
519
583
|
if (health.safeMode?.active) {
|
|
520
584
|
lines.push(`SAFE MODE: ${health.safeMode.reason}`);
|
|
521
585
|
}
|
|
@@ -43,6 +43,7 @@ const index_1 = require("../../nerves/index");
|
|
|
43
43
|
const message_router_1 = require("./message-router");
|
|
44
44
|
const health_monitor_1 = require("./health-monitor");
|
|
45
45
|
const daemon_health_1 = require("./daemon-health");
|
|
46
|
+
const daemon_rollup_1 = require("./daemon-rollup");
|
|
46
47
|
const task_scheduler_1 = require("./task-scheduler");
|
|
47
48
|
const runtime_logging_1 = require("./runtime-logging");
|
|
48
49
|
const sense_manager_1 = require("./sense-manager");
|
|
@@ -173,12 +174,40 @@ function buildDaemonHealthState() {
|
|
|
173
174
|
since: snapshot.lastCrashAt ?? daemonStartedAt,
|
|
174
175
|
};
|
|
175
176
|
});
|
|
177
|
+
// Preserved for backwards-compatible inspection: callers (status
|
|
178
|
+
// command, outlook surface, etc.) may still read this combined list
|
|
179
|
+
// for per-component reasons. The rollup status field above is what
|
|
180
|
+
// changed meaning — the array is still the union of bootstrap +
|
|
181
|
+
// agent-derived degradation entries.
|
|
176
182
|
const degraded = [
|
|
177
183
|
...degradedComponents.map((entry) => ({ ...entry })),
|
|
178
184
|
...agentDegradedComponents,
|
|
179
185
|
];
|
|
186
|
+
// Layer 1 rollup: project per-agent snapshots into the minimal
|
|
187
|
+
// AgentRollupInput shape and let computeDaemonRollup decide. The
|
|
188
|
+
// input is "every enabled agent" — managedAgents was filtered via
|
|
189
|
+
// listEnabledBundleAgents at module init, and snapshots only covers
|
|
190
|
+
// agents the process manager was told to manage, so by construction
|
|
191
|
+
// these entries are all enabled. The rollup function is a pure
|
|
192
|
+
// declarative function on the data we hand it.
|
|
193
|
+
//
|
|
194
|
+
// Note: safe-mode is wired as `false` here. Existing crash-loop
|
|
195
|
+
// detection (safe-mode.ts) already runs at the daemon-up boot path
|
|
196
|
+
// (cli-exec.ts), not from inside the daemon process itself. Once
|
|
197
|
+
// the daemon is up and reaching this rollup, safe mode no longer
|
|
198
|
+
// applies — the daemon is by definition past the crash-loop gate.
|
|
199
|
+
// If a future PR moves safe-mode signal into the running daemon,
|
|
200
|
+
// wire it through this third argument.
|
|
201
|
+
const rollupStatus = (0, daemon_rollup_1.computeDaemonRollup)({
|
|
202
|
+
enabledAgents: snapshots.map((snapshot) => ({
|
|
203
|
+
name: snapshot.name,
|
|
204
|
+
status: snapshot.status,
|
|
205
|
+
})),
|
|
206
|
+
bootstrapDegraded: degradedComponents,
|
|
207
|
+
safeMode: false,
|
|
208
|
+
});
|
|
180
209
|
return {
|
|
181
|
-
status:
|
|
210
|
+
status: rollupStatus,
|
|
182
211
|
mode,
|
|
183
212
|
pid: process.pid,
|
|
184
213
|
startedAt: daemonStartedAt,
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.HEALTH_TRACKED_EVENTS = exports.DaemonHealthWriter = void 0;
|
|
37
|
+
exports.isRollupStatus = isRollupStatus;
|
|
38
|
+
exports.isDaemonStatus = isDaemonStatus;
|
|
37
39
|
exports.getDefaultHealthPath = getDefaultHealthPath;
|
|
38
40
|
exports.createHealthNervesSink = createHealthNervesSink;
|
|
39
41
|
exports.readHealth = readHealth;
|
|
@@ -41,6 +43,38 @@ const fs = __importStar(require("fs"));
|
|
|
41
43
|
const os = __importStar(require("os"));
|
|
42
44
|
const path = __importStar(require("path"));
|
|
43
45
|
const runtime_1 = require("../../nerves/runtime");
|
|
46
|
+
/**
|
|
47
|
+
* Daemon-wide rollup vocabulary — locked layer-1 contract.
|
|
48
|
+
*
|
|
49
|
+
* - `RollupStatus` is what `computeDaemonRollup` returns (post-inventory,
|
|
50
|
+
* four-state). The function never returns `"down"` because by the time
|
|
51
|
+
* the rollup is reachable the daemon has already started, opened its
|
|
52
|
+
* socket, and read its agent inventory — pre-inventory failure is the
|
|
53
|
+
* caller's domain.
|
|
54
|
+
* - `DaemonStatus` is what `DaemonHealthState.status` accepts. The caller
|
|
55
|
+
* widens the rollup result with `"down"` along the daemon-entry failure
|
|
56
|
+
* path (e.g. when the daemon process can't read inventory at all).
|
|
57
|
+
*
|
|
58
|
+
* `isRollupStatus` and `isDaemonStatus` are runtime guards used both by
|
|
59
|
+
* `readHealth` (validating cached health files on disk) and by render-side
|
|
60
|
+
* consumers that want to narrow `unknown` JSON into the typed union before
|
|
61
|
+
* branching on it.
|
|
62
|
+
*/
|
|
63
|
+
// Single source of truth — the literal lists below are the runtime
|
|
64
|
+
// projection of the type unions. A future literal added to RollupStatus
|
|
65
|
+
// MUST also be added to ROLLUP_STATUS_LITERALS or `satisfies` blows up
|
|
66
|
+
// at tsc. That tightens the Layer 1 contract: producer + consumer +
|
|
67
|
+
// guard all stay in lockstep.
|
|
68
|
+
const ROLLUP_STATUS_LITERALS = ["healthy", "partial", "degraded", "safe-mode"];
|
|
69
|
+
const DAEMON_STATUS_LITERALS = [...ROLLUP_STATUS_LITERALS, "down"];
|
|
70
|
+
const ROLLUP_STATUS_VALUES = new Set(ROLLUP_STATUS_LITERALS);
|
|
71
|
+
const DAEMON_STATUS_VALUES = new Set(DAEMON_STATUS_LITERALS);
|
|
72
|
+
function isRollupStatus(value) {
|
|
73
|
+
return typeof value === "string" && ROLLUP_STATUS_VALUES.has(value);
|
|
74
|
+
}
|
|
75
|
+
function isDaemonStatus(value) {
|
|
76
|
+
return typeof value === "string" && DAEMON_STATUS_VALUES.has(value);
|
|
77
|
+
}
|
|
44
78
|
class DaemonHealthWriter {
|
|
45
79
|
healthPath;
|
|
46
80
|
constructor(healthPath) {
|
|
@@ -111,7 +145,7 @@ function readHealth(healthPath) {
|
|
|
111
145
|
try {
|
|
112
146
|
const raw = fs.readFileSync(healthPath, "utf-8");
|
|
113
147
|
const parsed = JSON.parse(raw);
|
|
114
|
-
if (
|
|
148
|
+
if (!isDaemonStatus(parsed.status) ||
|
|
115
149
|
typeof parsed.mode !== "string" ||
|
|
116
150
|
typeof parsed.pid !== "number" ||
|
|
117
151
|
typeof parsed.startedAt !== "string" ||
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeDaemonRollup = computeDaemonRollup;
|
|
4
|
+
/**
|
|
5
|
+
* Pure rollup decision function — given the post-inventory daemon
|
|
6
|
+
* surface, returns the daemon-wide rollup state per the locked Layer 1
|
|
7
|
+
* vocabulary table:
|
|
8
|
+
*
|
|
9
|
+
* | rollup | when |
|
|
10
|
+
* | ---------- | --------------------------------------------------------- |
|
|
11
|
+
* | healthy | every enabled agent serving + no bootstrap-degraded + no safe-mode |
|
|
12
|
+
* | partial | (≥1 serving + ≥1 not serving) OR (all serving + ≥1 bootstrap-degraded) |
|
|
13
|
+
* | degraded | zero enabled agents serving (fresh install OR all unhealthy) |
|
|
14
|
+
* | safe-mode | `safeMode === true` overrides everything else |
|
|
15
|
+
*
|
|
16
|
+
* The function NEVER returns `"down"`. By the time `computeDaemonRollup`
|
|
17
|
+
* is reachable, the daemon process has started, opened its socket, and
|
|
18
|
+
* read its agent inventory — pre-inventory failure is the caller's
|
|
19
|
+
* domain. `daemon-entry.ts`'s startup-failure path assigns `"down"` to
|
|
20
|
+
* `DaemonHealthState.status` directly without consulting this function.
|
|
21
|
+
*/
|
|
22
|
+
function computeDaemonRollup(input) {
|
|
23
|
+
// Safe mode wins, period. Crash-loop detection trumps everything —
|
|
24
|
+
// we want the human to see SAFE MODE, not a noisy partial/degraded.
|
|
25
|
+
if (input.safeMode) {
|
|
26
|
+
return "safe-mode";
|
|
27
|
+
}
|
|
28
|
+
// Count serving agents. "Serving" = "running" worker status.
|
|
29
|
+
// Anything else (crashed/stopped/starting/etc) is not serving.
|
|
30
|
+
let serving = 0;
|
|
31
|
+
let notServing = 0;
|
|
32
|
+
for (const agent of input.enabledAgents) {
|
|
33
|
+
if (agent.status === "running") {
|
|
34
|
+
serving++;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
notServing++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Zero-serving wins over bootstrap-degraded — we have no working
|
|
41
|
+
// agents to surface a "partially working" story about. This covers
|
|
42
|
+
// both fresh-install (`enabledAgents.length === 0`) and
|
|
43
|
+
// all-failed-live-check (`serving === 0` with `notServing > 0`).
|
|
44
|
+
// Render layer (cli-render.ts) splits the UX copy by inspecting the
|
|
45
|
+
// agents map; the rollup itself doesn't carry the distinction.
|
|
46
|
+
if (serving === 0) {
|
|
47
|
+
return "degraded";
|
|
48
|
+
}
|
|
49
|
+
// From here we have ≥1 serving agent. The remaining choice is
|
|
50
|
+
// healthy vs partial.
|
|
51
|
+
const hasUnhealthyAgent = notServing > 0;
|
|
52
|
+
const hasBootstrapDegraded = input.bootstrapDegraded.length > 0;
|
|
53
|
+
if (hasUnhealthyAgent || hasBootstrapDegraded) {
|
|
54
|
+
return "partial";
|
|
55
|
+
}
|
|
56
|
+
return "healthy";
|
|
57
|
+
}
|
|
@@ -49,6 +49,7 @@ const runtime_1 = require("../../../nerves/runtime");
|
|
|
49
49
|
const habit_parser_1 = require("../../habits/habit-parser");
|
|
50
50
|
const habit_runtime_state_1 = require("../../habits/habit-runtime-state");
|
|
51
51
|
const identity_1 = require("../../identity");
|
|
52
|
+
const daemon_health_1 = require("../../daemon/daemon-health");
|
|
52
53
|
const shared_1 = require("./shared");
|
|
53
54
|
const agent_machine_1 = require("./agent-machine");
|
|
54
55
|
const sessions_1 = require("./sessions");
|
|
@@ -270,7 +271,12 @@ function readDaemonHealthDeep(healthPath) {
|
|
|
270
271
|
const raw = fs.readFileSync(resolvedPath, "utf-8");
|
|
271
272
|
const health = JSON.parse(raw);
|
|
272
273
|
return {
|
|
273
|
-
|
|
274
|
+
// Layer 1: tighten the parse so only post-Layer-1 vocabulary
|
|
275
|
+
// carries through. Stale cached files that still hold legacy
|
|
276
|
+
// string values like "ok" or "running" — written by an older
|
|
277
|
+
// daemon binary — fall back to "unknown" so downstream Outlook
|
|
278
|
+
// consumers can detect the unparseable case explicitly.
|
|
279
|
+
status: (0, daemon_health_1.isDaemonStatus)(health.status) ? health.status : "unknown",
|
|
274
280
|
mode: typeof health.mode === "string" ? health.mode : "unknown",
|
|
275
281
|
pid: typeof health.pid === "number" ? health.pid : 0,
|
|
276
282
|
startedAt: typeof health.startedAt === "string" ? health.startedAt : "",
|
|
@@ -114,6 +114,10 @@ const TOOL_DESCRIPTIONS = {
|
|
|
114
114
|
settle: () => null,
|
|
115
115
|
rest: () => null,
|
|
116
116
|
descend: () => null,
|
|
117
|
+
// speak's visible output is the message itself, delivered via onTextChunk +
|
|
118
|
+
// flushNow. The shared tool-activity callbacks must skip it so no per-sense
|
|
119
|
+
// tool-status text precedes the actual message.
|
|
120
|
+
speak: () => null,
|
|
117
121
|
};
|
|
118
122
|
function humanReadableToolDescription(name, args) {
|
|
119
123
|
(0, runtime_1.emitNervesEvent)({
|
package/dist/mind/prompt.js
CHANGED
|
@@ -46,6 +46,7 @@ exports.commitmentsSection = commitmentsSection;
|
|
|
46
46
|
exports.delegationHintSection = delegationHintSection;
|
|
47
47
|
exports.workspaceDisciplineSection = workspaceDisciplineSection;
|
|
48
48
|
exports.ponderPacketSopsSection = ponderPacketSopsSection;
|
|
49
|
+
exports.speakSopsSection = speakSopsSection;
|
|
49
50
|
exports.contextSection = contextSection;
|
|
50
51
|
exports.metacognitiveFramingSection = metacognitiveFramingSection;
|
|
51
52
|
exports.readJournalFiles = readJournalFiles;
|
|
@@ -991,6 +992,20 @@ function ponderPacketSopsSection() {
|
|
|
991
992
|
- research: investigate the bounded question, gather evidence, and surface the answer or concrete artifact.
|
|
992
993
|
- reflection: ordinary private thinking with no engineering workflow implied.`;
|
|
993
994
|
}
|
|
995
|
+
function speakSopsSection(channel) {
|
|
996
|
+
const isChatStyle = channel === "cli" || channel === "teams" || channel === "bluebubbles";
|
|
997
|
+
if (!isChatStyle)
|
|
998
|
+
return "";
|
|
999
|
+
return [
|
|
1000
|
+
"## speaking mid-turn",
|
|
1001
|
+
"",
|
|
1002
|
+
"i have a `speak` tool that sends words to my friend without ending my turn. i use it to keep my friend in the loop while i'm doing real work.",
|
|
1003
|
+
"",
|
|
1004
|
+
"- if my next step depends on a reply, i settle. otherwise, i speak.",
|
|
1005
|
+
"- i speak at phase boundaries during heavy work — after acking a heavy ask, after hitting a major constraint, before switching strategy, before a long externally-visible step. i don't narrate individual tool calls.",
|
|
1006
|
+
"- speak is progress, not invitation. my friend won't steer me mid-turn after i speak — if i need steering, i settle.",
|
|
1007
|
+
].join("\n");
|
|
1008
|
+
}
|
|
994
1009
|
function contextSection(context, options) {
|
|
995
1010
|
if (!context)
|
|
996
1011
|
return "";
|
|
@@ -1311,6 +1326,7 @@ async function buildSystem(channel = "cli", options, context) {
|
|
|
1311
1326
|
"# how i work",
|
|
1312
1327
|
workspaceDisciplineSection(),
|
|
1313
1328
|
ponderPacketSopsSection(),
|
|
1329
|
+
speakSopsSection(channel),
|
|
1314
1330
|
(0, scrutiny_1.preImplementationScrutinySection)(channelHasCodingTools(channel, options?.providerCapabilities)),
|
|
1315
1331
|
toolRestrictionSection(context),
|
|
1316
1332
|
loopOrientationSection(channel),
|
|
@@ -87,6 +87,12 @@ const DISPATCH_EXEMPT_PATTERNS = [
|
|
|
87
87
|
// HTTP health probe: pure HTTP utility factory. The HealthMonitor caller
|
|
88
88
|
// owns observability via daemon.health_result events.
|
|
89
89
|
"daemon/http-health-probe",
|
|
90
|
+
// Rollup decision function: pure decision tree mapping per-agent
|
|
91
|
+
// snapshots + bootstrap-degraded entries + safe-mode flag to a
|
|
92
|
+
// RollupStatus. No side effects. The caller (daemon-entry.ts
|
|
93
|
+
// buildDaemonHealthState → DaemonHealthWriter) owns observability via
|
|
94
|
+
// daemon.health_written when the rolled-up state is persisted.
|
|
95
|
+
"daemon/daemon-rollup",
|
|
90
96
|
// Attachment helper modules: generic file-path/extension utilities and the
|
|
91
97
|
// source registry are pure support seams. The orchestrator/adapters that
|
|
92
98
|
// call them own the observability.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.tools = exports.baseToolDefinitions = exports.editFileReadTracker = exports.renderInnerProgressStatus = exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
|
|
3
|
+
exports.tools = exports.baseToolDefinitions = exports.editFileReadTracker = exports.renderInnerProgressStatus = exports.speakTool = exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
|
|
4
4
|
const tools_files_1 = require("./tools-files");
|
|
5
5
|
const tools_shell_1 = require("./tools-shell");
|
|
6
6
|
const tools_notes_1 = require("./tools-notes");
|
|
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function (
|
|
|
24
24
|
Object.defineProperty(exports, "observeTool", { enumerable: true, get: function () { return tools_flow_1.observeTool; } });
|
|
25
25
|
Object.defineProperty(exports, "settleTool", { enumerable: true, get: function () { return tools_flow_1.settleTool; } });
|
|
26
26
|
Object.defineProperty(exports, "restTool", { enumerable: true, get: function () { return tools_flow_1.restTool; } });
|
|
27
|
+
Object.defineProperty(exports, "speakTool", { enumerable: true, get: function () { return tools_flow_1.speakTool; } });
|
|
27
28
|
// Re-export renderInnerProgressStatus for consumers
|
|
28
29
|
var tools_session_2 = require("./tools-session");
|
|
29
30
|
Object.defineProperty(exports, "renderInnerProgressStatus", { enumerable: true, get: function () { return tools_session_2.renderInnerProgressStatus; } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
|
|
3
|
+
exports.restTool = exports.speakTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
|
|
4
4
|
exports.ponderTool = {
|
|
5
5
|
type: "function",
|
|
6
6
|
function: {
|
|
@@ -83,6 +83,20 @@ exports.settleTool = {
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
|
+
exports.speakTool = {
|
|
87
|
+
type: "function",
|
|
88
|
+
function: {
|
|
89
|
+
name: "speak",
|
|
90
|
+
description: "i speak to send words to my friend mid-turn without ending it. for progress, acknowledgment, or phase-boundary updates during heavy work. i settle when my work is done or i need a reply. speak is one-way: my friend cannot steer me mid-turn after i speak.",
|
|
91
|
+
parameters: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
message: { type: "string", description: "the words i'm sending to my friend right now" },
|
|
95
|
+
},
|
|
96
|
+
required: ["message"],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
86
100
|
exports.restTool = {
|
|
87
101
|
type: "function",
|
|
88
102
|
function: {
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.surfaceToolDef = exports.restTool = exports.ponderTool = exports.observeTool = exports.settleTool = exports.tools = void 0;
|
|
3
|
+
exports.surfaceToolDef = exports.speakTool = exports.restTool = exports.ponderTool = exports.observeTool = exports.settleTool = exports.tools = void 0;
|
|
4
4
|
exports.resetMcpDefinitions = resetMcpDefinitions;
|
|
5
5
|
exports.getToolsForChannel = getToolsForChannel;
|
|
6
6
|
exports.execTool = execTool;
|
|
@@ -32,6 +32,7 @@ Object.defineProperty(exports, "settleTool", { enumerable: true, get: function (
|
|
|
32
32
|
Object.defineProperty(exports, "observeTool", { enumerable: true, get: function () { return tools_base_2.observeTool; } });
|
|
33
33
|
Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function () { return tools_base_2.ponderTool; } });
|
|
34
34
|
Object.defineProperty(exports, "restTool", { enumerable: true, get: function () { return tools_base_2.restTool; } });
|
|
35
|
+
Object.defineProperty(exports, "speakTool", { enumerable: true, get: function () { return tools_base_2.speakTool; } });
|
|
35
36
|
// Re-export surface tool schema for consumers (e.g. heart/core.ts)
|
|
36
37
|
var tools_surface_2 = require("./tools-surface");
|
|
37
38
|
Object.defineProperty(exports, "surfaceToolDef", { enumerable: true, get: function () { return tools_surface_2.surfaceToolDef; } });
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.enrichReactionText = enrichReactionText;
|
|
37
37
|
exports.createStatusBatcher = createStatusBatcher;
|
|
38
|
+
exports.createBlueBubblesCallbacks = createBlueBubblesCallbacks;
|
|
38
39
|
exports.isAgentSelfHandle = isAgentSelfHandle;
|
|
39
40
|
exports.getDiscoveredOwnHandles = getDiscoveredOwnHandles;
|
|
40
41
|
exports.clearDiscoveredOwnHandles = clearDiscoveredOwnHandles;
|
|
@@ -514,7 +515,12 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
514
515
|
},
|
|
515
516
|
onReasoningChunk(_text) { },
|
|
516
517
|
onToolStart(name, _args) {
|
|
517
|
-
|
|
518
|
+
// observe + speak are flow-control: their visible output (or lack of it) is
|
|
519
|
+
// handled outside the tool-activity callbacks. speak in particular delivers
|
|
520
|
+
// its message via onTextChunk/flushNow — we MUST NOT enqueue a "speaking..."
|
|
521
|
+
// status sendText here, which would arrive as a separate iMessage right
|
|
522
|
+
// before the actual speak content.
|
|
523
|
+
if (name === "observe" || name === "speak") {
|
|
518
524
|
(0, runtime_1.emitNervesEvent)({
|
|
519
525
|
component: "senses",
|
|
520
526
|
event: "senses.bluebubbles_tool_start",
|
|
@@ -535,7 +541,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
535
541
|
});
|
|
536
542
|
},
|
|
537
543
|
onToolEnd(name, summary, success) {
|
|
538
|
-
|
|
544
|
+
// observe + speak skip the tool-activity end callback (no ✓/✗ status sent).
|
|
545
|
+
if (name !== "observe" && name !== "speak") {
|
|
539
546
|
toolCallbacks.onToolEnd(name, summary, success);
|
|
540
547
|
}
|
|
541
548
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -558,6 +565,29 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
558
565
|
onClearText() {
|
|
559
566
|
textBuffer = "";
|
|
560
567
|
},
|
|
568
|
+
async flushNow() {
|
|
569
|
+
// Contract: throws if delivery fails. We deliberately let `client.sendText`
|
|
570
|
+
// rejections propagate so the engine's speak interception can mark the
|
|
571
|
+
// tool call as failed and tell the agent the message did not reach the
|
|
572
|
+
// friend (rather than silently logging and pretending success).
|
|
573
|
+
const trimmed = textBuffer.trim();
|
|
574
|
+
if (!trimmed)
|
|
575
|
+
return;
|
|
576
|
+
textBuffer = "";
|
|
577
|
+
await client.sendText({
|
|
578
|
+
chat,
|
|
579
|
+
text: trimmed,
|
|
580
|
+
replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
|
|
581
|
+
});
|
|
582
|
+
// Note: do NOT call client.setTyping(chat, false) here — the agent is
|
|
583
|
+
// still mid-turn, so the typing indicator stays ACTIVE.
|
|
584
|
+
(0, runtime_1.emitNervesEvent)({
|
|
585
|
+
component: "senses",
|
|
586
|
+
event: "bluebubbles.speak_flush",
|
|
587
|
+
message: "bluebubbles flushed mid-turn speak",
|
|
588
|
+
meta: { messageLength: trimmed.length },
|
|
589
|
+
});
|
|
590
|
+
},
|
|
561
591
|
async flush() {
|
|
562
592
|
statusBatcher.flush();
|
|
563
593
|
await queue;
|
|
@@ -73,8 +73,10 @@ function Header({ agentName, model, contextPercent, cwd, resumeInfo }) {
|
|
|
73
73
|
return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] }), resumeInfo ? ((0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.teal, children: [" resuming \u00b7 ", resumeInfo.messageCount, " messages \u00b7 last active ", resumeInfo.timeAgo] })) : null] }));
|
|
74
74
|
}
|
|
75
75
|
// ─── Message Rendering ──────────────────────────────────────────────
|
|
76
|
-
// Flow control tools are invisible to the user — they are internal agent mechanics
|
|
77
|
-
|
|
76
|
+
// Flow control tools are invisible to the user — they are internal agent mechanics.
|
|
77
|
+
// `speak` is included because its visible output is the message itself (rendered as
|
|
78
|
+
// regular assistant text), not a tool-result line or in-progress activity indicator.
|
|
79
|
+
const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest", "speak"]);
|
|
78
80
|
function ToolResultLine({ tc }) {
|
|
79
81
|
const icon = tc.success !== false ? "✓" : "✗";
|
|
80
82
|
const iconColor = tc.success !== false ? OURO.scale : OURO.fang;
|
|
@@ -13,8 +13,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.writeToolStart = writeToolStart;
|
|
14
14
|
exports.writeToolEnd = writeToolEnd;
|
|
15
15
|
const runtime_1 = require("../../nerves/runtime");
|
|
16
|
-
// Flow control tools are invisible to the user — internal agent mechanics
|
|
17
|
-
|
|
16
|
+
// Flow control tools are invisible to the user — internal agent mechanics.
|
|
17
|
+
// `speak` is included because its visible output is the message itself (delivered
|
|
18
|
+
// via onTextChunk/flushNow), not a "running speak..." spinner or tool-end status line.
|
|
19
|
+
const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest", "speak"]);
|
|
18
20
|
// Ouroboros teal: #4ec9b0 -> RGB escape
|
|
19
21
|
const OURO_TEAL = "\x1b[38;2;78;201;176m";
|
|
20
22
|
const GREEN = "\x1b[32m";
|
package/dist/senses/cli.js
CHANGED
|
@@ -353,11 +353,20 @@ function createCliCallbacks() {
|
|
|
353
353
|
/* v8 ignore stop */
|
|
354
354
|
textDirty = text.length > 0 && !text.endsWith("\n");
|
|
355
355
|
},
|
|
356
|
+
flushNow: () => {
|
|
357
|
+
// CLI flushes immediately on each onTextChunk; nothing buffered to push.
|
|
358
|
+
},
|
|
356
359
|
onReasoningChunk: (_text) => {
|
|
357
360
|
// Keep reasoning private in the CLI surface. The spinner continues to
|
|
358
361
|
// represent active thinking until actual tool or answer output arrives.
|
|
359
362
|
},
|
|
360
363
|
onToolStart: (_name, _args) => {
|
|
364
|
+
// speak is flow-control: its visible output is the message itself (delivered
|
|
365
|
+
// via onTextChunk/flushNow). Do NOT start a tool spinner — that would write
|
|
366
|
+
// a "running speak..." phrase to stderr right before the actual message
|
|
367
|
+
// arrives, which is the visual churn the user explicitly does not want.
|
|
368
|
+
if (_name === "speak")
|
|
369
|
+
return;
|
|
361
370
|
// Stop the model-start spinner: when the model returns only tool calls
|
|
362
371
|
// (no content/reasoning), onModelStreamStart never fires, so the old
|
|
363
372
|
// spinner's intervals would leak.
|
package/dist/senses/teams.js
CHANGED
|
@@ -290,6 +290,26 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
290
290
|
return false;
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
|
+
// Non-aborting awaitable emit — returns true on success, false on failure WITHOUT
|
|
294
|
+
// calling markStopped() / aborting the controller. Used by flushNow (speak) so a
|
|
295
|
+
// primary-stream failure followed by a successful sendMessage fallback does NOT
|
|
296
|
+
// poison the rest of the turn. tryEmit's abort-on-failure behavior is correct for
|
|
297
|
+
// end-of-turn flush() (no fallback path forward) but wrong for mid-turn speak,
|
|
298
|
+
// which has a sendMessage fallback that may still succeed. Caller (flushNow) is
|
|
299
|
+
// responsible for the `!stopped` precondition; no defensive guard here.
|
|
300
|
+
async function tryEmitNoAbort(text) {
|
|
301
|
+
try {
|
|
302
|
+
const result = stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
|
|
303
|
+
streamHasContent = true;
|
|
304
|
+
if (result && typeof result.then === "function") {
|
|
305
|
+
await result;
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
293
313
|
// Safely send a status update to the stream.
|
|
294
314
|
// On error (e.g. 403 from Teams stop button), abort the controller.
|
|
295
315
|
function safeUpdate(text) {
|
|
@@ -427,6 +447,56 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
427
447
|
onClearText: () => {
|
|
428
448
|
textBuffer = "";
|
|
429
449
|
},
|
|
450
|
+
flushNow: async () => {
|
|
451
|
+
const trimmed = textBuffer.trim();
|
|
452
|
+
if (!trimmed)
|
|
453
|
+
return;
|
|
454
|
+
// Cancel pending periodic flush — we're delivering now.
|
|
455
|
+
stopFlushTimer();
|
|
456
|
+
// The actual speak message replaces any "thinking..." phrase cycling.
|
|
457
|
+
stopPhraseRotation();
|
|
458
|
+
// Bypass MIN_INITIAL_CHARS threshold — speak delivers immediately.
|
|
459
|
+
firstContentEmitted = true;
|
|
460
|
+
textBuffer = "";
|
|
461
|
+
// Try the stream first via the NON-ABORTING variant; on failure, fall back
|
|
462
|
+
// to sendMessage. Critical: do NOT call markStopped() / abort the controller
|
|
463
|
+
// when only the primary stream fails — the sendMessage fallback may still
|
|
464
|
+
// deliver the speak, and a successful fallback must not poison the rest of
|
|
465
|
+
// the turn. Only abort when ALL delivery paths fail (handled below).
|
|
466
|
+
// Contract: throws if the message could not be delivered through any available path.
|
|
467
|
+
let delivered = false;
|
|
468
|
+
let lastError = null;
|
|
469
|
+
if (!stopped) {
|
|
470
|
+
const ok = await tryEmitNoAbort(trimmed);
|
|
471
|
+
if (ok)
|
|
472
|
+
delivered = true;
|
|
473
|
+
else
|
|
474
|
+
lastError = new Error("stream emit failed");
|
|
475
|
+
}
|
|
476
|
+
if (!delivered && sendMessage) {
|
|
477
|
+
try {
|
|
478
|
+
await sendMessage(trimmed);
|
|
479
|
+
delivered = true;
|
|
480
|
+
lastError = null;
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
(0, runtime_1.emitNervesEvent)({
|
|
487
|
+
component: "senses",
|
|
488
|
+
event: "teams.speak_flush",
|
|
489
|
+
message: "teams flushed mid-turn speak",
|
|
490
|
+
meta: { messageLength: trimmed.length, delivered },
|
|
491
|
+
});
|
|
492
|
+
if (!delivered) {
|
|
493
|
+
// All delivery paths exhausted — now it is correct to abort the turn.
|
|
494
|
+
// markStopped() halts further stream activity and aborts the controller
|
|
495
|
+
// so the engine catches up and ends the turn cleanly.
|
|
496
|
+
markStopped();
|
|
497
|
+
throw new Error(`teams speak delivery failed: ${lastError?.message ?? "no fallback available"}`);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
430
500
|
...(() => {
|
|
431
501
|
const toolCbs = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
|
|
432
502
|
onDescription: (text) => safeUpdate(text),
|
|
@@ -438,6 +508,13 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
438
508
|
});
|
|
439
509
|
return {
|
|
440
510
|
onToolStart: (name, args) => {
|
|
511
|
+
// speak is flow-control: its visible output is the message itself
|
|
512
|
+
// (delivered via onTextChunk + flushNow). Do NOT stop phrase rotation
|
|
513
|
+
// here, do NOT emit the \u23f3 placeholder, do NOT post a tool-activity
|
|
514
|
+
// status update \u2014 all of those would create UI churn right before the
|
|
515
|
+
// actual speak content arrives.
|
|
516
|
+
if (name === "speak")
|
|
517
|
+
return;
|
|
441
518
|
stopPhraseRotation();
|
|
442
519
|
// Force-flush any accumulated text, bypassing MIN_INITIAL_CHARS threshold
|
|
443
520
|
firstContentEmitted = true;
|
|
@@ -453,6 +530,12 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
453
530
|
hadToolRun = true;
|
|
454
531
|
},
|
|
455
532
|
onToolEnd: (name, summary, success) => {
|
|
533
|
+
// speak is flow-control: skip phrase-rotation stop and tool-activity end
|
|
534
|
+
// callback (no safeUpdate for \u2713/\u2717). The flushNow call inside the engine
|
|
535
|
+
// already emitted the actual message and stopped any rotation as part of
|
|
536
|
+
// tryEmit's first-content-emitted flag.
|
|
537
|
+
if (name === "speak")
|
|
538
|
+
return;
|
|
456
539
|
stopPhraseRotation();
|
|
457
540
|
toolCbs.onToolEnd(name, summary, success);
|
|
458
541
|
},
|