@ouro.bot/cli 0.1.0-alpha.242 → 0.1.0-alpha.244

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.
@@ -106,454 +106,447 @@ function prependTurnSections(message, sections) {
106
106
  // ── Pipeline ──────────────────────────────────────────────────────
107
107
  let _lastSessionKey = null;
108
108
  async function handleInboundTurn(input) {
109
- return (0, sync_1.runWithSyncContext)(async () => {
110
- // Reset session-scoped state when the session changes
111
- const sessionKey = `${input.channel}/${input.sessionKey ?? "session"}`;
112
- if (sessionKey !== _lastSessionKey) {
113
- _lastSessionKey = sessionKey;
114
- // Reset file-state cache and scrutiny tracking for the new session
115
- const { fileStateCache } = await Promise.resolve().then(() => __importStar(require("../mind/file-state")));
116
- const { resetSessionModifiedFiles } = await Promise.resolve().then(() => __importStar(require("../mind/scrutiny")));
117
- fileStateCache.clear();
118
- resetSessionModifiedFiles();
119
- }
120
- // Step 0: Check for pending failover reply
121
- if (input.failoverState?.pending) {
122
- const userText = input.messages
123
- .filter((m) => m.role === "user")
124
- .map((m) => typeof m.content === "string" ? m.content : /* v8 ignore next -- defensive: multipart content fallback @preserve */ "")
125
- .join(" ")
126
- .trim();
127
- const pendingContext = input.failoverState.pending;
128
- const failoverAction = (0, provider_failover_1.handleFailoverReply)(userText, pendingContext);
129
- const failoverAgentName = pendingContext.agentName;
130
- input.failoverState.pending = null; // always clear before acting
131
- if (failoverAction.action === "switch") {
132
- let switchSucceeded = false;
133
- try {
134
- (0, auth_flow_1.writeAgentProviderSelection)(failoverAgentName, "human", failoverAction.provider);
135
- (0, auth_flow_1.writeAgentProviderSelection)(failoverAgentName, "agent", failoverAction.provider);
136
- switchSucceeded = true;
137
- /* v8 ignore start -- defensive: write failure during provider switch @preserve */
138
- }
139
- catch (switchError) {
140
- (0, runtime_1.emitNervesEvent)({
141
- level: "error",
142
- component: "senses",
143
- event: "senses.failover_switch_error",
144
- message: `failed to switch provider to ${failoverAction.provider}`,
145
- meta: { agentName: failoverAgentName, provider: failoverAction.provider, error: switchError instanceof Error ? switchError.message : String(switchError) },
146
- });
147
- }
148
- /* v8 ignore stop */
149
- /* v8 ignore next -- false branch: write-failure fallthrough @preserve */
150
- if (switchSucceeded) {
151
- (0, runtime_1.emitNervesEvent)({
152
- component: "senses",
153
- event: "senses.failover_switch",
154
- message: `switched provider to ${failoverAction.provider} via failover`,
155
- meta: { agentName: failoverAgentName, provider: failoverAction.provider },
156
- });
157
- // Replace "switch to <provider>" with a context message for the agent.
158
- // The session already has the user's original question from the failed turn.
159
- // The agent needs to know what happened so it can respond appropriately.
160
- const newProviderSecrets = (() => {
161
- try {
162
- const { secrets } = (0, auth_flow_1.loadAgentSecrets)(failoverAgentName);
163
- const cfg = secrets.providers[failoverAction.provider];
164
- return cfg?.model ?? cfg?.modelName ?? "";
165
- /* v8 ignore next 2 -- defensive: secrets read failure @preserve */
166
- }
167
- catch {
168
- return "";
169
- }
170
- })();
171
- const newProviderLabel = newProviderSecrets ? `${failoverAction.provider} (${newProviderSecrets})` : failoverAction.provider;
172
- input.messages = [{
173
- role: "user",
174
- content: `[provider switch: ${pendingContext.errorSummary}. switched to ${newProviderLabel}. your conversation history is intact — respond to the user's last message.]`,
175
- }];
176
- input.switchedProvider = failoverAction.provider;
177
- }
178
- // Switch failed OR succeeded — either way, fall through to normal processing.
109
+ // Reset session-scoped state when the session changes
110
+ const sessionKey = `${input.channel}/${input.sessionKey ?? "session"}`;
111
+ if (sessionKey !== _lastSessionKey) {
112
+ _lastSessionKey = sessionKey;
113
+ // Reset file-state cache and scrutiny tracking for the new session
114
+ const { fileStateCache } = await Promise.resolve().then(() => __importStar(require("../mind/file-state")));
115
+ const { resetSessionModifiedFiles } = await Promise.resolve().then(() => __importStar(require("../mind/scrutiny")));
116
+ fileStateCache.clear();
117
+ resetSessionModifiedFiles();
118
+ }
119
+ // Step 0: Check for pending failover reply
120
+ if (input.failoverState?.pending) {
121
+ const userText = input.messages
122
+ .filter((m) => m.role === "user")
123
+ .map((m) => typeof m.content === "string" ? m.content : /* v8 ignore next -- defensive: multipart content fallback @preserve */ "")
124
+ .join(" ")
125
+ .trim();
126
+ const pendingContext = input.failoverState.pending;
127
+ const failoverAction = (0, provider_failover_1.handleFailoverReply)(userText, pendingContext);
128
+ const failoverAgentName = pendingContext.agentName;
129
+ input.failoverState.pending = null; // always clear before acting
130
+ if (failoverAction.action === "switch") {
131
+ let switchSucceeded = false;
132
+ try {
133
+ (0, auth_flow_1.writeAgentProviderSelection)(failoverAgentName, "human", failoverAction.provider);
134
+ (0, auth_flow_1.writeAgentProviderSelection)(failoverAgentName, "agent", failoverAction.provider);
135
+ switchSucceeded = true;
136
+ /* v8 ignore start -- defensive: write failure during provider switch @preserve */
179
137
  }
180
- }
181
- // Step 0b: Slash command interception (before friend resolution / agent turn)
182
- {
183
- const firstUserMsg = input.messages.find((m) => m.role === "user");
184
- const userText = firstUserMsg
185
- ? (typeof firstUserMsg.content === "string"
186
- ? firstUserMsg.content
187
- : Array.isArray(firstUserMsg.content)
188
- ? firstUserMsg.content.find((p) => p.type === "text")?.text ?? ""
189
- : /* v8 ignore next -- defensive: content is always string or array @preserve */ "")
190
- : "";
191
- const parsed = (0, commands_1.parseSlashCommand)(userText);
192
- if (parsed) {
193
- const registry = (0, commands_1.getSharedCommandRegistry)();
194
- const dispatchResult = registry.dispatch(parsed.command, { channel: input.channel });
195
- if (dispatchResult.handled && dispatchResult.result) {
196
- (0, runtime_1.emitNervesEvent)({
197
- component: "senses",
198
- event: "senses.pipeline_command",
199
- message: `slash command intercepted: /${parsed.command}`,
200
- meta: { command: parsed.command, channel: input.channel },
201
- });
202
- if (dispatchResult.result.message) {
203
- input.callbacks.onTextChunk(dispatchResult.result.message);
138
+ catch (switchError) {
139
+ (0, runtime_1.emitNervesEvent)({
140
+ level: "error",
141
+ component: "senses",
142
+ event: "senses.failover_switch_error",
143
+ message: `failed to switch provider to ${failoverAction.provider}`,
144
+ meta: { agentName: failoverAgentName, provider: failoverAction.provider, error: switchError instanceof Error ? switchError.message : String(switchError) },
145
+ });
146
+ }
147
+ /* v8 ignore stop */
148
+ /* v8 ignore next -- false branch: write-failure fallthrough @preserve */
149
+ if (switchSucceeded) {
150
+ (0, runtime_1.emitNervesEvent)({
151
+ component: "senses",
152
+ event: "senses.failover_switch",
153
+ message: `switched provider to ${failoverAction.provider} via failover`,
154
+ meta: { agentName: failoverAgentName, provider: failoverAction.provider },
155
+ });
156
+ // Replace "switch to <provider>" with a context message for the agent.
157
+ // The session already has the user's original question from the failed turn.
158
+ // The agent needs to know what happened so it can respond appropriately.
159
+ const newProviderSecrets = (() => {
160
+ try {
161
+ const { secrets } = (0, auth_flow_1.loadAgentSecrets)(failoverAgentName);
162
+ const cfg = secrets.providers[failoverAction.provider];
163
+ return cfg?.model ?? cfg?.modelName ?? "";
164
+ /* v8 ignore next 2 -- defensive: secrets read failure @preserve */
165
+ }
166
+ catch {
167
+ return "";
204
168
  }
205
- // Return a minimal result — no agent turn, no session write
206
- const resolvedContext = await input.friendResolver.resolve();
207
- return {
208
- resolvedContext,
209
- gateResult: { allowed: true },
210
- turnOutcome: "command",
211
- commandAction: dispatchResult.result.action,
212
- };
169
+ })();
170
+ const newProviderLabel = newProviderSecrets ? `${failoverAction.provider} (${newProviderSecrets})` : failoverAction.provider;
171
+ input.messages = [{
172
+ role: "user",
173
+ content: `[provider switch: ${pendingContext.errorSummary}. switched to ${newProviderLabel}. your conversation history is intact — respond to the user's last message.]`,
174
+ }];
175
+ input.switchedProvider = failoverAction.provider;
176
+ }
177
+ // Switch failed OR succeeded — either way, fall through to normal processing.
178
+ }
179
+ }
180
+ // Step 0b: Slash command interception (before friend resolution / agent turn)
181
+ {
182
+ const firstUserMsg = input.messages.find((m) => m.role === "user");
183
+ const userText = firstUserMsg
184
+ ? (typeof firstUserMsg.content === "string"
185
+ ? firstUserMsg.content
186
+ : Array.isArray(firstUserMsg.content)
187
+ ? firstUserMsg.content.find((p) => p.type === "text")?.text ?? ""
188
+ : /* v8 ignore next -- defensive: content is always string or array @preserve */ "")
189
+ : "";
190
+ const parsed = (0, commands_1.parseSlashCommand)(userText);
191
+ if (parsed) {
192
+ const registry = (0, commands_1.getSharedCommandRegistry)();
193
+ const dispatchResult = registry.dispatch(parsed.command, { channel: input.channel });
194
+ if (dispatchResult.handled && dispatchResult.result) {
195
+ (0, runtime_1.emitNervesEvent)({
196
+ component: "senses",
197
+ event: "senses.pipeline_command",
198
+ message: `slash command intercepted: /${parsed.command}`,
199
+ meta: { command: parsed.command, channel: input.channel },
200
+ });
201
+ if (dispatchResult.result.message) {
202
+ input.callbacks.onTextChunk(dispatchResult.result.message);
213
203
  }
204
+ // Return a minimal result — no agent turn, no session write
205
+ const resolvedContext = await input.friendResolver.resolve();
206
+ return {
207
+ resolvedContext,
208
+ gateResult: { allowed: true },
209
+ turnOutcome: "command",
210
+ commandAction: dispatchResult.result.action,
211
+ };
214
212
  }
215
213
  }
216
- // Step 1: Resolve friend
217
- const resolvedContext = await input.friendResolver.resolve();
214
+ }
215
+ // Step 1: Resolve friend
216
+ const resolvedContext = await input.friendResolver.resolve();
217
+ (0, runtime_1.emitNervesEvent)({
218
+ component: "senses",
219
+ event: "senses.pipeline_start",
220
+ message: "inbound turn pipeline started",
221
+ meta: {
222
+ channel: input.channel,
223
+ friendId: resolvedContext.friend.id,
224
+ senseType: input.capabilities.senseType,
225
+ },
226
+ });
227
+ // Step 2: Trust gate
228
+ const gateInput = {
229
+ friend: resolvedContext.friend,
230
+ provider: input.provider ?? "local",
231
+ externalId: input.externalId ?? "",
232
+ tenantId: input.tenantId,
233
+ channel: input.channel,
234
+ senseType: input.capabilities.senseType,
235
+ isGroupChat: input.isGroupChat ?? false,
236
+ groupHasFamilyMember: input.groupHasFamilyMember ?? false,
237
+ hasExistingGroupWithFamily: input.hasExistingGroupWithFamily ?? false,
238
+ };
239
+ const gateResult = input.enforceTrustGate(gateInput);
240
+ // Gate rejection: return early, no agent turn
241
+ if (!gateResult.allowed) {
218
242
  (0, runtime_1.emitNervesEvent)({
219
243
  component: "senses",
220
- event: "senses.pipeline_start",
221
- message: "inbound turn pipeline started",
244
+ event: "senses.pipeline_gate_reject",
245
+ message: "trust gate rejected inbound turn",
222
246
  meta: {
223
247
  channel: input.channel,
224
248
  friendId: resolvedContext.friend.id,
225
- senseType: input.capabilities.senseType,
249
+ reason: gateResult.reason,
226
250
  },
227
251
  });
228
- // Step 2: Trust gate
229
- const gateInput = {
230
- friend: resolvedContext.friend,
231
- provider: input.provider ?? "local",
232
- externalId: input.externalId ?? "",
233
- tenantId: input.tenantId,
234
- channel: input.channel,
235
- senseType: input.capabilities.senseType,
236
- isGroupChat: input.isGroupChat ?? false,
237
- groupHasFamilyMember: input.groupHasFamilyMember ?? false,
238
- hasExistingGroupWithFamily: input.hasExistingGroupWithFamily ?? false,
252
+ return {
253
+ resolvedContext,
254
+ gateResult,
239
255
  };
240
- const gateResult = input.enforceTrustGate(gateInput);
241
- // Gate rejection: return early, no agent turn
242
- if (!gateResult.allowed) {
243
- (0, runtime_1.emitNervesEvent)({
244
- component: "senses",
245
- event: "senses.pipeline_gate_reject",
246
- message: "trust gate rejected inbound turn",
247
- meta: {
248
- channel: input.channel,
249
- friendId: resolvedContext.friend.id,
250
- reason: gateResult.reason,
251
- },
252
- });
253
- return {
254
- resolvedContext,
255
- gateResult,
256
- };
256
+ }
257
+ // Step 3: Load/create session
258
+ const session = await input.sessionLoader.loadOrCreate();
259
+ const sessionMessages = session.messages;
260
+ let mustResolveBeforeHandoff = (0, continuity_1.resolveMustResolveBeforeHandoff)(session.state?.mustResolveBeforeHandoff === true, input.continuityIngressTexts);
261
+ const lastFriendActivityAt = input.channel === "inner"
262
+ ? session.state?.lastFriendActivityAt
263
+ : new Date().toISOString();
264
+ const currentObligation = input.continuityIngressTexts
265
+ ?.map((text) => text.trim())
266
+ .filter((text) => text.length > 0)
267
+ .at(-1);
268
+ const currentSession = {
269
+ friendId: resolvedContext.friend.id,
270
+ channel: input.channel,
271
+ key: input.sessionKey ?? "session",
272
+ sessionPath: session.sessionPath,
273
+ };
274
+ // Step 3b: Pre-turn sync pull (opt-in) — MUST happen before any continuity state reads
275
+ // so that obligations, episodes, cares, etc. reflect the latest remote state.
276
+ let syncFailure;
277
+ let syncConfig = { enabled: false, remote: "origin" };
278
+ try {
279
+ syncConfig = (0, config_1.getSyncConfig)();
280
+ }
281
+ catch { /* config not available */ }
282
+ // Wrap the turn body in try/finally so postTurnPush always runs — even on
283
+ // error or early-return failover paths.
284
+ try {
285
+ /* v8 ignore start -- sync-enabled branches tested in sync.test.ts, pipeline tests mock at module boundary @preserve */
286
+ if (syncConfig.enabled) {
287
+ const pullResult = (0, sync_1.preTurnPull)((0, identity_1.getAgentRoot)(), syncConfig);
288
+ if (!pullResult.ok) {
289
+ syncFailure = pullResult.error;
290
+ }
291
+ // Check for pending-sync from a prior failed push
292
+ if (!syncFailure) {
293
+ const pendingSyncPath = path.join((0, identity_1.getAgentRoot)(), "state", "pending-sync.json");
294
+ try {
295
+ if (fs.existsSync(pendingSyncPath)) {
296
+ const pendingSync = JSON.parse(fs.readFileSync(pendingSyncPath, "utf-8"));
297
+ syncFailure = `prior sync push failed: ${pendingSync.error ?? "unknown"}`;
298
+ fs.unlinkSync(pendingSyncPath);
299
+ }
300
+ }
301
+ catch {
302
+ // Ignore read errors for pending-sync
303
+ }
304
+ }
257
305
  }
258
- // Step 3: Load/create session
259
- const session = await input.sessionLoader.loadOrCreate();
260
- const sessionMessages = session.messages;
261
- let mustResolveBeforeHandoff = (0, continuity_1.resolveMustResolveBeforeHandoff)(session.state?.mustResolveBeforeHandoff === true, input.continuityIngressTexts);
262
- const lastFriendActivityAt = input.channel === "inner"
263
- ? session.state?.lastFriendActivityAt
264
- : new Date().toISOString();
265
- const currentObligation = input.continuityIngressTexts
266
- ?.map((text) => text.trim())
267
- .filter((text) => text.length > 0)
268
- .at(-1);
269
- const currentSession = {
270
- friendId: resolvedContext.friend.id,
306
+ /* v8 ignore stop */
307
+ // Build the turn context snapshot — centralizes all state reads
308
+ const ctx = await (0, turn_context_1.buildTurnContext)({
309
+ currentSession,
271
310
  channel: input.channel,
272
- key: input.sessionKey ?? "session",
273
- sessionPath: session.sessionPath,
274
- };
275
- // Step 3b: Pre-turn sync pull (opt-in) — MUST happen before any continuity state reads
276
- // so that obligations, episodes, cares, etc. reflect the latest remote state.
277
- let syncFailure;
278
- let syncConfig = { enabled: false, remote: "origin" };
279
- try {
280
- syncConfig = (0, config_1.getSyncConfig)();
311
+ friendStore: input.friendStore,
312
+ });
313
+ // Propagate sync failure from pre-turn pull
314
+ ctx.syncFailure = syncFailure;
315
+ const { activeBridges, sessionActivity, pendingObligations, codingSessions, otherCodingSessions } = ctx;
316
+ const bridgeContext = (0, manager_1.formatBridgeContext)(activeBridges) || undefined;
317
+ const activeWorkFrame = (0, active_work_1.buildActiveWorkFrame)({
318
+ currentSession,
319
+ currentObligation,
320
+ mustResolveBeforeHandoff,
321
+ inner: ctx.innerWorkState,
322
+ bridges: activeBridges,
323
+ codingSessions,
324
+ otherCodingSessions,
325
+ pendingObligations,
326
+ taskBoard: ctx.taskBoard,
327
+ friendActivity: sessionActivity,
328
+ targetCandidates: ctx.targetCandidates,
329
+ innerReturnObligations: ctx.returnObligations,
330
+ });
331
+ const delegationDecision = (0, delegation_1.decideDelegation)({
332
+ channel: input.channel,
333
+ ingressTexts: input.continuityIngressTexts ?? [],
334
+ activeWork: activeWorkFrame,
335
+ mustResolveBeforeHandoff,
336
+ });
337
+ // Step 4: Drain deferred friend returns, then ordinary per-session pending.
338
+ const deferredReturns = input.channel === "inner"
339
+ ? []
340
+ : (input.drainDeferredReturns?.(resolvedContext.friend.id) ?? []);
341
+ const sessionPending = input.drainPending(input.pendingDir);
342
+ const pending = [...deferredReturns, ...sessionPending];
343
+ // Assemble messages: session messages + pending + inbound user messages
344
+ // NOTE: live world-state checkpoint and pending messages are rendered via buildSystem (system prompt sections)
345
+ const extraPrefixSections = input.onPendingDrained?.(pending) ?? [];
346
+ // extraPrefixSections from onPendingDrained still prepend to user message (e.g., inner dialog wakes)
347
+ if (extraPrefixSections.length > 0 && input.messages.length > 0) {
348
+ input.messages[0] = prependTurnSections(input.messages[0], extraPrefixSections);
349
+ }
350
+ // Append user messages from the inbound turn
351
+ for (const msg of input.messages) {
352
+ sessionMessages.push(msg);
281
353
  }
282
- catch { /* config not available */ }
283
- // Wrap the turn body in try/finally so drainSyncWrites always runs — even on
284
- // error or early-return failover paths. This prevents writes from leaking into
285
- // the next turn's accumulator.
354
+ // Step 4b: Continuity pipeline — derive tempo, build start-of-turn packet, snapshot obligations
355
+ let renderedStartOfTurnPacket;
356
+ const preTurnObligationIds = new Set(pendingObligations.map((ob) => `${ob.id}:${ob.status}`));
286
357
  try {
287
- /* v8 ignore start -- sync-enabled branches tested in sync.test.ts, pipeline tests mock at module boundary @preserve */
288
- if (syncConfig.enabled) {
289
- const pullResult = (0, sync_1.preTurnPull)((0, identity_1.getAgentRoot)(), syncConfig);
290
- if (!pullResult.ok) {
291
- syncFailure = pullResult.error;
292
- }
293
- // Check for pending-sync from a prior failed push
294
- if (!syncFailure) {
295
- const pendingSyncPath = path.join((0, identity_1.getAgentRoot)(), "state", "pending-sync.json");
296
- try {
297
- if (fs.existsSync(pendingSyncPath)) {
298
- const pendingSync = JSON.parse(fs.readFileSync(pendingSyncPath, "utf-8"));
299
- syncFailure = `prior sync push failed: ${pendingSync.error ?? "unknown"}`;
300
- fs.unlinkSync(pendingSyncPath);
301
- }
302
- }
303
- catch {
304
- // Ignore read errors for pending-sync
305
- }
306
- }
307
- }
308
- /* v8 ignore stop */
309
- // Build the turn context snapshot — centralizes all state reads
310
- const ctx = await (0, turn_context_1.buildTurnContext)({
311
- currentSession,
312
- channel: input.channel,
313
- friendStore: input.friendStore,
358
+ const agentRoot = (0, identity_1.getAgentRoot)();
359
+ const agentName = (0, identity_1.getAgentName)();
360
+ const { recentEpisodes, activeCares } = ctx;
361
+ const tempoState = (0, tempo_1.deriveTempo)({
362
+ activeSessions: sessionActivity.length + 1,
363
+ openObligations: pendingObligations.length,
364
+ recentEpisodeCount: recentEpisodes.length,
365
+ lastActivityAgeMs: sessionActivity.length > 0
366
+ ? Date.now() - new Date(sessionActivity[0].lastActivityAt).getTime()
367
+ : 0,
368
+ hasBlockers: false, // obligations use specific statuses, not "blocked"
369
+ highSalienceEpisodes: recentEpisodes.filter((ep) => ep.salience === "high" || ep.salience === "critical").length,
370
+ activeCareCount: activeCares.length,
371
+ atRiskCareCount: activeCares.filter((c) => c.currentRisk != null).length,
314
372
  });
315
- // Propagate sync failure from pre-turn pull
316
- ctx.syncFailure = syncFailure;
317
- const { activeBridges, sessionActivity, pendingObligations, codingSessions, otherCodingSessions } = ctx;
318
- const bridgeContext = (0, manager_1.formatBridgeContext)(activeBridges) || undefined;
319
- const activeWorkFrame = (0, active_work_1.buildActiveWorkFrame)({
320
- currentSession,
321
- currentObligation,
322
- mustResolveBeforeHandoff,
323
- inner: ctx.innerWorkState,
324
- bridges: activeBridges,
325
- codingSessions,
326
- otherCodingSessions,
327
- pendingObligations,
328
- taskBoard: ctx.taskBoard,
329
- friendActivity: sessionActivity,
330
- targetCandidates: ctx.targetCandidates,
331
- innerReturnObligations: ctx.returnObligations,
373
+ const temporalView = (0, temporal_view_1.buildTemporalView)(agentRoot, {
374
+ tempo: tempoState.mode,
375
+ preloaded: {
376
+ recentEpisodes,
377
+ activeObligations: pendingObligations,
378
+ activeCares,
379
+ },
332
380
  });
333
- const delegationDecision = (0, delegation_1.decideDelegation)({
334
- channel: input.channel,
335
- ingressTexts: input.continuityIngressTexts ?? [],
336
- activeWork: activeWorkFrame,
337
- mustResolveBeforeHandoff,
381
+ const startOfTurnPacket = (0, start_of_turn_packet_1.buildStartOfTurnPacket)(temporalView, {
382
+ canonicalObligations: {
383
+ primary: activeWorkFrame.primaryObligation,
384
+ all: activeWorkFrame.pendingObligations,
385
+ },
338
386
  });
339
- // Step 4: Drain deferred friend returns, then ordinary per-session pending.
340
- const deferredReturns = input.channel === "inner"
341
- ? []
342
- : (input.drainDeferredReturns?.(resolvedContext.friend.id) ?? []);
343
- const sessionPending = input.drainPending(input.pendingDir);
344
- const pending = [...deferredReturns, ...sessionPending];
345
- // Assemble messages: session messages + pending + inbound user messages
346
- // NOTE: live world-state checkpoint and pending messages are rendered via buildSystem (system prompt sections)
347
- const extraPrefixSections = input.onPendingDrained?.(pending) ?? [];
348
- // extraPrefixSections from onPendingDrained still prepend to user message (e.g., inner dialog wakes)
349
- if (extraPrefixSections.length > 0 && input.messages.length > 0) {
350
- input.messages[0] = prependTurnSections(input.messages[0], extraPrefixSections);
387
+ /* v8 ignore next 3 -- syncFailure propagation tested in sync.test.ts @preserve */
388
+ if (syncFailure) {
389
+ startOfTurnPacket.syncFailure = syncFailure;
351
390
  }
352
- // Append user messages from the inbound turn
353
- for (const msg of input.messages) {
354
- sessionMessages.push(msg);
391
+ const capabilities = (0, start_of_turn_packet_1.buildCapabilitiesSection)(agentRoot);
392
+ if (capabilities) {
393
+ startOfTurnPacket.capabilities = capabilities;
355
394
  }
356
- // Step 4b: Continuity pipeline — derive tempo, build start-of-turn packet, snapshot obligations
357
- let renderedStartOfTurnPacket;
358
- const preTurnObligationIds = new Set(pendingObligations.map((ob) => `${ob.id}:${ob.status}`));
395
+ renderedStartOfTurnPacket = (0, start_of_turn_packet_1.renderStartOfTurnPacket)(startOfTurnPacket);
396
+ if (!renderedStartOfTurnPacket)
397
+ renderedStartOfTurnPacket = undefined;
398
+ // Update self-presence
399
+ const presence = (0, presence_1.derivePresence)(agentRoot, agentName, {
400
+ activeSessions: sessionActivity.length + 1,
401
+ openObligations: pendingObligations.length,
402
+ activeBridges: activeBridges.length,
403
+ codingLanes: codingSessions.length,
404
+ currentTempo: tempoState.mode,
405
+ });
406
+ (0, presence_1.writePresence)(agentRoot, agentName, presence);
407
+ }
408
+ catch (continuityError) {
409
+ (0, runtime_1.emitNervesEvent)({
410
+ level: "warn",
411
+ component: "senses",
412
+ event: "senses.continuity_error",
413
+ message: "continuity pipeline failed, continuing without start-of-turn packet",
414
+ meta: { error: continuityError instanceof Error ? continuityError.message : String(continuityError) },
415
+ });
416
+ }
417
+ // Step 5: runAgent
418
+ const existingToolContext = input.runAgentOptions?.toolContext;
419
+ const runAgentOptions = {
420
+ ...input.runAgentOptions,
421
+ bridgeContext,
422
+ activeWorkFrame,
423
+ delegationDecision,
424
+ startOfTurnPacket: renderedStartOfTurnPacket,
425
+ pendingMessages: pending.length > 0 ? pending.map((msg) => ({ from: msg.from, content: msg.content })) : undefined,
426
+ currentSessionKey: currentSession.key,
427
+ currentObligation,
428
+ mustResolveBeforeHandoff,
429
+ setMustResolveBeforeHandoff: (value) => {
430
+ mustResolveBeforeHandoff = value;
431
+ },
432
+ // Pre-read state from TurnContext for prompt assembly
433
+ daemonRunning: ctx.daemonRunning,
434
+ senseStatusLines: ctx.senseStatusLines,
435
+ bundleMeta: ctx.bundleMeta,
436
+ daemonHealth: ctx.daemonHealth,
437
+ journalFiles: ctx.journalFiles,
438
+ toolContext: {
439
+ /* v8 ignore next -- default no-op signin satisfies interface; real signin injected by sense adapter @preserve */
440
+ signin: async () => undefined,
441
+ ...existingToolContext,
442
+ context: resolvedContext,
443
+ friendStore: input.friendStore,
444
+ currentSession,
445
+ activeBridges,
446
+ },
447
+ };
448
+ const result = await input.runAgent(sessionMessages, input.callbacks, input.channel, input.signal, runAgentOptions);
449
+ // Step 5b: Failover on terminal error
450
+ if (result.outcome === "errored" && input.failoverState) {
359
451
  try {
360
- const agentRoot = (0, identity_1.getAgentRoot)();
361
452
  const agentName = (0, identity_1.getAgentName)();
362
- const { recentEpisodes, activeCares } = ctx;
363
- const tempoState = (0, tempo_1.deriveTempo)({
364
- activeSessions: sessionActivity.length + 1,
365
- openObligations: pendingObligations.length,
366
- recentEpisodeCount: recentEpisodes.length,
367
- lastActivityAgeMs: sessionActivity.length > 0
368
- ? Date.now() - new Date(sessionActivity[0].lastActivityAt).getTime()
369
- : 0,
370
- hasBlockers: false, // obligations use specific statuses, not "blocked"
371
- highSalienceEpisodes: recentEpisodes.filter((ep) => ep.salience === "high" || ep.salience === "critical").length,
372
- activeCareCount: activeCares.length,
373
- atRiskCareCount: activeCares.filter((c) => c.currentRisk != null).length,
374
- });
375
- const temporalView = (0, temporal_view_1.buildTemporalView)(agentRoot, {
376
- tempo: tempoState.mode,
377
- preloaded: {
378
- recentEpisodes,
379
- activeObligations: pendingObligations,
380
- activeCares,
381
- },
382
- });
383
- const startOfTurnPacket = (0, start_of_turn_packet_1.buildStartOfTurnPacket)(temporalView, {
384
- canonicalObligations: {
385
- primary: activeWorkFrame.primaryObligation,
386
- all: activeWorkFrame.pendingObligations,
387
- },
388
- });
389
- /* v8 ignore next 3 -- syncFailure propagation tested in sync.test.ts @preserve */
390
- if (syncFailure) {
391
- startOfTurnPacket.syncFailure = syncFailure;
392
- }
393
- const capabilities = (0, start_of_turn_packet_1.buildCapabilitiesSection)(agentRoot);
394
- if (capabilities) {
395
- startOfTurnPacket.capabilities = capabilities;
453
+ const agentConfig = (0, identity_1.loadAgentConfig)();
454
+ const currentProvider = agentConfig.humanFacing.provider;
455
+ /* v8 ignore next -- defensive: errorClassification always set when errored @preserve */
456
+ const classification = result.errorClassification ?? "unknown";
457
+ const inventory = await (0, provider_ping_1.runHealthInventory)(agentName, currentProvider);
458
+ const { secrets } = (0, auth_flow_1.loadAgentSecrets)(agentName);
459
+ const providerModels = {};
460
+ for (const [p, cfg] of Object.entries(secrets.providers)) {
461
+ const model = cfg.model ?? cfg.modelName;
462
+ if (typeof model === "string" && model)
463
+ providerModels[p] = model;
396
464
  }
397
- renderedStartOfTurnPacket = (0, start_of_turn_packet_1.renderStartOfTurnPacket)(startOfTurnPacket);
398
- if (!renderedStartOfTurnPacket)
399
- renderedStartOfTurnPacket = undefined;
400
- // Update self-presence
401
- const presence = (0, presence_1.derivePresence)(agentRoot, agentName, {
402
- activeSessions: sessionActivity.length + 1,
403
- openObligations: pendingObligations.length,
404
- activeBridges: activeBridges.length,
405
- codingLanes: codingSessions.length,
406
- currentTempo: tempoState.mode,
407
- });
408
- (0, presence_1.writePresence)(agentRoot, agentName, presence);
465
+ // Use agent.json model (source of truth), not secrets model (may be stale)
466
+ const currentModel = agentConfig.humanFacing.model;
467
+ const failoverContext = (0, provider_failover_1.buildFailoverContext)(
468
+ /* v8 ignore next -- defensive: error always set when errored @preserve */
469
+ result.error?.message ?? "unknown error", classification, currentProvider, currentModel, agentName, inventory, providerModels);
470
+ input.failoverState.pending = failoverContext;
471
+ input.postTurn(sessionMessages, session.sessionPath, result.usage);
472
+ return {
473
+ resolvedContext,
474
+ gateResult,
475
+ usage: result.usage,
476
+ turnOutcome: result.outcome,
477
+ sessionPath: session.sessionPath,
478
+ messages: sessionMessages,
479
+ drainedPending: pending,
480
+ failoverMessage: failoverContext.userMessage,
481
+ };
482
+ /* v8 ignore start -- failover catch: tested via pipeline failover sequence throws test but v8 under-reports catch coverage @preserve */
409
483
  }
410
- catch (continuityError) {
484
+ catch (failoverError) {
411
485
  (0, runtime_1.emitNervesEvent)({
412
486
  level: "warn",
413
487
  component: "senses",
414
- event: "senses.continuity_error",
415
- message: "continuity pipeline failed, continuing without start-of-turn packet",
416
- meta: { error: continuityError instanceof Error ? continuityError.message : String(continuityError) },
488
+ event: "senses.failover_error",
489
+ message: "failover sequence failed, falling through",
490
+ meta: { error: failoverError instanceof Error ? failoverError.message : String(failoverError) },
417
491
  });
418
492
  }
419
- // Step 5: runAgent
420
- const existingToolContext = input.runAgentOptions?.toolContext;
421
- const runAgentOptions = {
422
- ...input.runAgentOptions,
423
- bridgeContext,
424
- activeWorkFrame,
425
- delegationDecision,
426
- startOfTurnPacket: renderedStartOfTurnPacket,
427
- pendingMessages: pending.length > 0 ? pending.map((msg) => ({ from: msg.from, content: msg.content })) : undefined,
428
- currentSessionKey: currentSession.key,
429
- currentObligation,
430
- mustResolveBeforeHandoff,
431
- setMustResolveBeforeHandoff: (value) => {
432
- mustResolveBeforeHandoff = value;
433
- },
434
- // Pre-read state from TurnContext for prompt assembly
435
- daemonRunning: ctx.daemonRunning,
436
- senseStatusLines: ctx.senseStatusLines,
437
- bundleMeta: ctx.bundleMeta,
438
- daemonHealth: ctx.daemonHealth,
439
- journalFiles: ctx.journalFiles,
440
- toolContext: {
441
- /* v8 ignore next -- default no-op signin satisfies interface; real signin injected by sense adapter @preserve */
442
- signin: async () => undefined,
443
- ...existingToolContext,
444
- context: resolvedContext,
445
- friendStore: input.friendStore,
446
- currentSession,
447
- activeBridges,
448
- },
449
- };
450
- const result = await input.runAgent(sessionMessages, input.callbacks, input.channel, input.signal, runAgentOptions);
451
- // Step 5b: Failover on terminal error
452
- if (result.outcome === "errored" && input.failoverState) {
453
- try {
454
- const agentName = (0, identity_1.getAgentName)();
455
- const agentConfig = (0, identity_1.loadAgentConfig)();
456
- const currentProvider = agentConfig.humanFacing.provider;
457
- /* v8 ignore next -- defensive: errorClassification always set when errored @preserve */
458
- const classification = result.errorClassification ?? "unknown";
459
- const inventory = await (0, provider_ping_1.runHealthInventory)(agentName, currentProvider);
460
- const { secrets } = (0, auth_flow_1.loadAgentSecrets)(agentName);
461
- const providerModels = {};
462
- for (const [p, cfg] of Object.entries(secrets.providers)) {
463
- const model = cfg.model ?? cfg.modelName;
464
- if (typeof model === "string" && model)
465
- providerModels[p] = model;
466
- }
467
- // Use agent.json model (source of truth), not secrets model (may be stale)
468
- const currentModel = agentConfig.humanFacing.model;
469
- const failoverContext = (0, provider_failover_1.buildFailoverContext)(
470
- /* v8 ignore next -- defensive: error always set when errored @preserve */
471
- result.error?.message ?? "unknown error", classification, currentProvider, currentModel, agentName, inventory, providerModels);
472
- input.failoverState.pending = failoverContext;
473
- input.postTurn(sessionMessages, session.sessionPath, result.usage);
474
- return {
475
- resolvedContext,
476
- gateResult,
477
- usage: result.usage,
478
- turnOutcome: result.outcome,
479
- sessionPath: session.sessionPath,
480
- messages: sessionMessages,
481
- drainedPending: pending,
482
- failoverMessage: failoverContext.userMessage,
483
- };
484
- /* v8 ignore start -- failover catch: tested via pipeline failover sequence throws test but v8 under-reports catch coverage @preserve */
485
- }
486
- catch (failoverError) {
487
- (0, runtime_1.emitNervesEvent)({
488
- level: "warn",
489
- component: "senses",
490
- event: "senses.failover_error",
491
- message: "failover sequence failed, falling through",
492
- meta: { error: failoverError instanceof Error ? failoverError.message : String(failoverError) },
493
- });
494
- }
495
- /* v8 ignore stop */
496
- }
497
- // Step 5c: Emit episodes for obligation state transitions
493
+ /* v8 ignore stop */
494
+ }
495
+ // Step 5c: Emit episodes for obligation state transitions
496
+ try {
497
+ const agentRoot = (0, identity_1.getAgentRoot)();
498
+ const postTurnObligations = (0, obligations_1.readPendingObligations)(agentRoot);
499
+ emitObligationTransitionEpisodes(agentRoot, preTurnObligationIds, postTurnObligations, pendingObligations);
500
+ }
501
+ catch {
502
+ // Episode emission is non-fatal
503
+ }
504
+ // Step 6: postTurn
505
+ const continuingState = {
506
+ ...(mustResolveBeforeHandoff ? { mustResolveBeforeHandoff: true } : {}),
507
+ ...(typeof lastFriendActivityAt === "string" ? { lastFriendActivityAt } : {}),
508
+ };
509
+ const nextState = result.outcome === "settled" || result.outcome === "blocked" || result.outcome === "superseded" || result.outcome === "observed"
510
+ ? (typeof lastFriendActivityAt === "string"
511
+ ? { lastFriendActivityAt }
512
+ : undefined)
513
+ : (Object.keys(continuingState).length > 0 ? continuingState : undefined);
514
+ input.postTurn(sessionMessages, session.sessionPath, result.usage, undefined, nextState);
515
+ // Step 7: Token accumulation
516
+ await input.accumulateFriendTokens(input.friendStore, resolvedContext.friend.id, result.usage);
517
+ (0, runtime_1.emitNervesEvent)({
518
+ component: "senses",
519
+ event: "senses.pipeline_end",
520
+ message: "inbound turn pipeline completed",
521
+ meta: {
522
+ channel: input.channel,
523
+ friendId: resolvedContext.friend.id,
524
+ },
525
+ });
526
+ // DRY cross-session awareness: notify inner dialog that activity happened on another channel
527
+ // Inner dialog's next checkpoint will include this session's state
528
+ if (input.channel !== "inner") {
498
529
  try {
499
- const agentRoot = (0, identity_1.getAgentRoot)();
500
- const postTurnObligations = (0, obligations_1.readPendingObligations)(agentRoot);
501
- emitObligationTransitionEpisodes(agentRoot, preTurnObligationIds, postTurnObligations, pendingObligations);
502
- }
503
- catch {
504
- // Episode emission is non-fatal
530
+ (0, socket_client_1.requestInnerWake)((0, identity_1.getAgentName)()).catch(/* v8 ignore next */ () => { });
505
531
  }
506
- // Step 6: postTurn
507
- const continuingState = {
508
- ...(mustResolveBeforeHandoff ? { mustResolveBeforeHandoff: true } : {}),
509
- ...(typeof lastFriendActivityAt === "string" ? { lastFriendActivityAt } : {}),
510
- };
511
- const nextState = result.outcome === "settled" || result.outcome === "blocked" || result.outcome === "superseded" || result.outcome === "observed"
512
- ? (typeof lastFriendActivityAt === "string"
513
- ? { lastFriendActivityAt }
514
- : undefined)
515
- : (Object.keys(continuingState).length > 0 ? continuingState : undefined);
516
- input.postTurn(sessionMessages, session.sessionPath, result.usage, undefined, nextState);
517
- // Step 7: Token accumulation
518
- await input.accumulateFriendTokens(input.friendStore, resolvedContext.friend.id, result.usage);
519
- (0, runtime_1.emitNervesEvent)({
520
- component: "senses",
521
- event: "senses.pipeline_end",
522
- message: "inbound turn pipeline completed",
523
- meta: {
524
- channel: input.channel,
525
- friendId: resolvedContext.friend.id,
526
- },
527
- });
528
- // DRY cross-session awareness: notify inner dialog that activity happened on another channel
529
- // Inner dialog's next checkpoint will include this session's state
530
- if (input.channel !== "inner") {
531
- try {
532
- (0, socket_client_1.requestInnerWake)((0, identity_1.getAgentName)()).catch(/* v8 ignore next */ () => { });
533
- }
534
- catch { /* getAgentName may fail in test environments */ }
535
- }
536
- return {
537
- resolvedContext,
538
- gateResult,
539
- usage: result.usage,
540
- turnOutcome: result.outcome,
541
- completion: result.completion,
542
- sessionPath: session.sessionPath,
543
- messages: sessionMessages,
544
- drainedPending: pending,
545
- ...(input.switchedProvider ? { switchedProvider: input.switchedProvider } : {}),
546
- };
532
+ catch { /* getAgentName may fail in test environments */ }
547
533
  }
548
- finally {
549
- // Step 6b: Post-turn sync push (opt-in, turn-scoped writes only).
550
- // In a finally block so writes are always drained — even on error or early-return
551
- // failover paths. This prevents writes from leaking into the next turn.
552
- const writtenPaths = (0, sync_1.drainSyncWrites)();
553
- /* v8 ignore next 3 -- postTurnPush integration tested in sync.test.ts @preserve */
554
- if (syncConfig.enabled && writtenPaths.length > 0) {
555
- (0, sync_1.postTurnPush)((0, identity_1.getAgentRoot)(), syncConfig, writtenPaths);
556
- }
534
+ return {
535
+ resolvedContext,
536
+ gateResult,
537
+ usage: result.usage,
538
+ turnOutcome: result.outcome,
539
+ completion: result.completion,
540
+ sessionPath: session.sessionPath,
541
+ messages: sessionMessages,
542
+ drainedPending: pending,
543
+ ...(input.switchedProvider ? { switchedProvider: input.switchedProvider } : {}),
544
+ };
545
+ }
546
+ finally {
547
+ // Step 6b: Post-turn sync push (opt-in, git-status-based discovery).
548
+ if (syncConfig.enabled) {
549
+ (0, sync_1.postTurnPush)((0, identity_1.getAgentRoot)(), syncConfig);
557
550
  }
558
- }); // end runWithSyncContext
551
+ }
559
552
  }