@vellumai/assistant 0.4.30 → 0.4.32

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.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/Dockerfile +14 -8
  3. package/README.md +2 -2
  4. package/docs/architecture/memory.md +28 -29
  5. package/docs/runbook-trusted-contacts.md +1 -4
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
  8. package/src/__tests__/anthropic-provider.test.ts +86 -1
  9. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  10. package/src/__tests__/checker.test.ts +37 -98
  11. package/src/__tests__/commit-message-enrichment-service.test.ts +15 -4
  12. package/src/__tests__/config-schema.test.ts +6 -14
  13. package/src/__tests__/conflict-policy.test.ts +76 -0
  14. package/src/__tests__/conflict-store.test.ts +14 -20
  15. package/src/__tests__/contacts-tools.test.ts +8 -61
  16. package/src/__tests__/contradiction-checker.test.ts +5 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +1 -19
  19. package/src/__tests__/followup-tools.test.ts +0 -30
  20. package/src/__tests__/gemini-provider.test.ts +79 -1
  21. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
  22. package/src/__tests__/guardian-routing-invariants.test.ts +6 -4
  23. package/src/__tests__/ipc-snapshot.test.ts +0 -4
  24. package/src/__tests__/managed-proxy-context.test.ts +163 -0
  25. package/src/__tests__/memory-lifecycle-e2e.test.ts +13 -12
  26. package/src/__tests__/memory-regressions.test.ts +6 -6
  27. package/src/__tests__/openai-provider.test.ts +82 -0
  28. package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
  29. package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
  30. package/src/__tests__/recurrence-types.test.ts +0 -15
  31. package/src/__tests__/registry.test.ts +0 -10
  32. package/src/__tests__/schedule-tools.test.ts +28 -44
  33. package/src/__tests__/script-proxy-session-runtime.test.ts +6 -1
  34. package/src/__tests__/session-agent-loop.test.ts +0 -2
  35. package/src/__tests__/session-conflict-gate.test.ts +243 -388
  36. package/src/__tests__/session-profile-injection.test.ts +0 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +2 -3
  38. package/src/__tests__/session-skill-tools.test.ts +0 -49
  39. package/src/__tests__/session-workspace-cache-state.test.ts +0 -1
  40. package/src/__tests__/session-workspace-injection.test.ts +0 -1
  41. package/src/__tests__/session-workspace-tool-tracking.test.ts +0 -1
  42. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  43. package/src/__tests__/task-management-tools.test.ts +111 -0
  44. package/src/__tests__/tool-grant-request-escalation.test.ts +2 -1
  45. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -1
  46. package/src/__tests__/twilio-config.test.ts +0 -3
  47. package/src/amazon/session.ts +30 -91
  48. package/src/approvals/guardian-decision-primitive.ts +11 -7
  49. package/src/approvals/guardian-request-resolvers.ts +5 -3
  50. package/src/calls/call-controller.ts +423 -571
  51. package/src/calls/finalize-call.ts +20 -0
  52. package/src/calls/relay-access-wait.ts +340 -0
  53. package/src/calls/relay-server.ts +269 -899
  54. package/src/calls/relay-setup-router.ts +307 -0
  55. package/src/calls/relay-verification.ts +280 -0
  56. package/src/calls/twilio-config.ts +1 -8
  57. package/src/calls/voice-control-protocol.ts +184 -0
  58. package/src/calls/voice-session-bridge.ts +1 -8
  59. package/src/config/agent-schema.ts +1 -1
  60. package/src/config/bundled-skills/contacts/SKILL.md +7 -18
  61. package/src/config/bundled-skills/contacts/TOOLS.json +4 -20
  62. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +2 -4
  63. package/src/config/bundled-skills/contacts/tools/contact-search.ts +6 -12
  64. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +3 -24
  65. package/src/config/bundled-skills/followups/TOOLS.json +0 -4
  66. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  67. package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
  68. package/src/config/bundled-tool-registry.ts +0 -5
  69. package/src/config/core-schema.ts +1 -1
  70. package/src/config/env.ts +0 -10
  71. package/src/config/feature-flag-registry.json +1 -1
  72. package/src/config/loader.ts +19 -0
  73. package/src/config/memory-schema.ts +0 -10
  74. package/src/config/schema.ts +2 -2
  75. package/src/config/system-prompt.ts +6 -0
  76. package/src/contacts/contact-store.ts +36 -62
  77. package/src/contacts/contacts-write.ts +14 -3
  78. package/src/contacts/types.ts +9 -4
  79. package/src/daemon/handlers/config-heartbeat.ts +1 -2
  80. package/src/daemon/handlers/contacts.ts +2 -2
  81. package/src/daemon/handlers/guardian-actions.ts +1 -1
  82. package/src/daemon/handlers/session-history.ts +398 -0
  83. package/src/daemon/handlers/session-user-message.ts +982 -0
  84. package/src/daemon/handlers/sessions.ts +9 -1337
  85. package/src/daemon/ipc-contract/contacts.ts +2 -2
  86. package/src/daemon/ipc-contract/sessions.ts +0 -6
  87. package/src/daemon/ipc-contract-inventory.json +0 -1
  88. package/src/daemon/lifecycle.ts +0 -29
  89. package/src/daemon/session-agent-loop.ts +1 -45
  90. package/src/daemon/session-conflict-gate.ts +21 -82
  91. package/src/daemon/session-memory.ts +7 -52
  92. package/src/daemon/session-process.ts +3 -1
  93. package/src/daemon/session-runtime-assembly.ts +18 -35
  94. package/src/heartbeat/heartbeat-service.ts +5 -1
  95. package/src/home-base/app-link-store.ts +0 -7
  96. package/src/memory/conflict-intent.ts +3 -6
  97. package/src/memory/conflict-policy.ts +34 -0
  98. package/src/memory/conflict-store.ts +10 -18
  99. package/src/memory/contradiction-checker.ts +2 -2
  100. package/src/memory/conversation-attention-store.ts +1 -1
  101. package/src/memory/conversation-store.ts +0 -51
  102. package/src/memory/db-init.ts +8 -0
  103. package/src/memory/job-handlers/conflict.ts +24 -7
  104. package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
  105. package/src/memory/migrations/134-contacts-notes-column.ts +68 -0
  106. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +31 -0
  107. package/src/memory/migrations/index.ts +2 -0
  108. package/src/memory/migrations/registry.ts +6 -0
  109. package/src/memory/recall-cache.ts +0 -5
  110. package/src/memory/schema/calls.ts +274 -0
  111. package/src/memory/schema/contacts.ts +125 -0
  112. package/src/memory/schema/conversations.ts +129 -0
  113. package/src/memory/schema/guardian.ts +172 -0
  114. package/src/memory/schema/index.ts +8 -0
  115. package/src/memory/schema/infrastructure.ts +205 -0
  116. package/src/memory/schema/memory-core.ts +196 -0
  117. package/src/memory/schema/notifications.ts +191 -0
  118. package/src/memory/schema/tasks.ts +78 -0
  119. package/src/memory/schema.ts +1 -1402
  120. package/src/memory/slack-thread-store.ts +0 -69
  121. package/src/messaging/index.ts +0 -1
  122. package/src/messaging/types.ts +0 -38
  123. package/src/notifications/decisions-store.ts +2 -105
  124. package/src/notifications/deliveries-store.ts +0 -11
  125. package/src/notifications/preferences-store.ts +1 -58
  126. package/src/permissions/checker.ts +6 -17
  127. package/src/providers/anthropic/client.ts +6 -2
  128. package/src/providers/gemini/client.ts +13 -2
  129. package/src/providers/managed-proxy/constants.ts +55 -0
  130. package/src/providers/managed-proxy/context.ts +77 -0
  131. package/src/providers/registry.ts +112 -0
  132. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
  133. package/src/runtime/guardian-action-service.ts +3 -2
  134. package/src/runtime/guardian-outbound-actions.ts +3 -3
  135. package/src/runtime/guardian-reply-router.ts +4 -4
  136. package/src/runtime/http-server.ts +83 -710
  137. package/src/runtime/http-types.ts +0 -16
  138. package/src/runtime/middleware/auth.ts +0 -12
  139. package/src/runtime/routes/app-routes.ts +33 -0
  140. package/src/runtime/routes/approval-routes.ts +32 -0
  141. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -3
  142. package/src/runtime/routes/attachment-routes.ts +32 -0
  143. package/src/runtime/routes/brain-graph-routes.ts +27 -0
  144. package/src/runtime/routes/call-routes.ts +41 -0
  145. package/src/runtime/routes/channel-readiness-routes.ts +20 -0
  146. package/src/runtime/routes/channel-routes.ts +70 -0
  147. package/src/runtime/routes/contact-routes.ts +371 -29
  148. package/src/runtime/routes/conversation-attention-routes.ts +15 -0
  149. package/src/runtime/routes/conversation-routes.ts +192 -194
  150. package/src/runtime/routes/debug-routes.ts +15 -0
  151. package/src/runtime/routes/events-routes.ts +16 -0
  152. package/src/runtime/routes/global-search-routes.ts +17 -2
  153. package/src/runtime/routes/guardian-action-routes.ts +23 -1
  154. package/src/runtime/routes/guardian-approval-interception.ts +2 -1
  155. package/src/runtime/routes/guardian-bootstrap-routes.ts +26 -1
  156. package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
  157. package/src/runtime/routes/identity-routes.ts +20 -0
  158. package/src/runtime/routes/inbound-message-handler.ts +8 -0
  159. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +5 -1
  160. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +2 -1
  161. package/src/runtime/routes/integration-routes.ts +83 -0
  162. package/src/runtime/routes/invite-routes.ts +31 -0
  163. package/src/runtime/routes/migration-routes.ts +47 -17
  164. package/src/runtime/routes/pairing-routes.ts +18 -0
  165. package/src/runtime/routes/secret-routes.ts +20 -0
  166. package/src/runtime/routes/surface-action-routes.ts +26 -0
  167. package/src/runtime/routes/trust-rules-routes.ts +31 -0
  168. package/src/runtime/routes/twilio-routes.ts +79 -0
  169. package/src/schedule/recurrence-types.ts +1 -11
  170. package/src/tools/followups/followup_create.ts +9 -3
  171. package/src/tools/mcp/mcp-tool-factory.ts +0 -17
  172. package/src/tools/memory/definitions.ts +0 -6
  173. package/src/tools/network/script-proxy/session-manager.ts +38 -3
  174. package/src/tools/schedule/create.ts +1 -3
  175. package/src/tools/schedule/update.ts +9 -6
  176. package/src/twitter/session.ts +29 -77
  177. package/src/util/cookie-session.ts +114 -0
  178. package/src/workspace/git-service.ts +6 -4
  179. package/src/__tests__/conversation-routes.test.ts +0 -99
  180. package/src/__tests__/get-weather.test.ts +0 -393
  181. package/src/__tests__/task-tools.test.ts +0 -685
  182. package/src/__tests__/weather-skill-regression.test.ts +0 -276
  183. package/src/autonomy/autonomy-resolver.ts +0 -62
  184. package/src/autonomy/autonomy-store.ts +0 -138
  185. package/src/autonomy/disposition-mapper.ts +0 -31
  186. package/src/autonomy/index.ts +0 -11
  187. package/src/autonomy/types.ts +0 -43
  188. package/src/config/bundled-skills/weather/SKILL.md +0 -38
  189. package/src/config/bundled-skills/weather/TOOLS.json +0 -36
  190. package/src/config/bundled-skills/weather/icon.svg +0 -24
  191. package/src/config/bundled-skills/weather/tools/get-weather.ts +0 -12
  192. package/src/contacts/startup-migration.ts +0 -21
  193. package/src/messaging/triage-engine.ts +0 -344
  194. package/src/tools/weather/service.ts +0 -712
@@ -2,10 +2,6 @@ import * as net from "node:net";
2
2
 
3
3
  import { v4 as uuid } from "uuid";
4
4
 
5
- import {
6
- createAssistantMessage,
7
- createUserMessage,
8
- } from "../../agent/message-types.js";
9
5
  import {
10
6
  type InterfaceId,
11
7
  isChannelId,
@@ -13,16 +9,9 @@ import {
13
9
  parseInterfaceId,
14
10
  } from "../../channels/types.js";
15
11
  import { getConfig } from "../../config/loader.js";
16
- import {
17
- getAttachmentsForMessage,
18
- getFilePathForAttachment,
19
- setAttachmentThumbnail,
20
- } from "../../memory/attachments-store.js";
21
12
  import {
22
13
  createCanonicalGuardianRequest,
23
14
  generateCanonicalRequestCode,
24
- listCanonicalGuardianRequests,
25
- listPendingCanonicalGuardianRequestsByDestinationConversation,
26
15
  resolveCanonicalGuardianRequest,
27
16
  } from "../../memory/canonical-guardian-store.js";
28
17
  import { getAttentionStateByConversationIds } from "../../memory/conversation-attention-store.js";
@@ -33,34 +22,15 @@ import {
33
22
  UNTITLED_FALLBACK,
34
23
  } from "../../memory/conversation-title-service.js";
35
24
  import * as externalConversationStore from "../../memory/external-conversation-store.js";
36
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../runtime/assistant-scope.js";
37
- import { routeGuardianReply } from "../../runtime/guardian-reply-router.js";
38
- import {
39
- resolveLocalIpcAuthContext,
40
- resolveLocalIpcTrustContext,
41
- } from "../../runtime/local-actor-identity.js";
42
25
  import * as pendingInteractions from "../../runtime/pending-interactions.js";
43
- import { checkIngressForSecrets } from "../../security/secret-ingress.js";
44
- import {
45
- compileCustomPatterns,
46
- redactSecrets,
47
- } from "../../security/secret-scanner.js";
48
26
  import { getSubagentManager } from "../../subagent/index.js";
49
- import { silentlyWithLog } from "../../util/silently.js";
50
27
  import { truncate } from "../../util/truncate.js";
51
- import { createApprovalConversationGenerator } from "../approval-generators.js";
52
- import { getAssistantName } from "../identity-helpers.js";
53
- import type { UserMessageAttachment } from "../ipc-contract.js";
54
28
  import type {
55
29
  CancelRequest,
56
30
  ConfirmationResponse,
57
- ConversationSearchRequest,
58
31
  DeleteQueuedMessage,
59
- HistoryRequest,
60
- MessageContentRequest,
61
32
  RegenerateRequest,
62
33
  ReorderThreadsRequest,
63
- SandboxSetRequest,
64
34
  SecretResponse,
65
35
  ServerMessage,
66
36
  SessionCreateRequest,
@@ -68,46 +38,30 @@ import type {
68
38
  SessionSwitchRequest,
69
39
  UndoRequest,
70
40
  UsageRequest,
71
- UserMessage,
72
41
  } from "../ipc-protocol.js";
73
42
  import { normalizeThreadType } from "../ipc-protocol.js";
74
- import { executeRecordingIntent } from "../recording-executor.js";
75
- import { resolveRecordingIntent } from "../recording-intent.js";
76
- import {
77
- classifyRecordingIntentFallback,
78
- containsRecordingKeywords,
79
- } from "../recording-intent-fallback.js";
80
43
  import type { Session } from "../session.js";
81
44
  import {
82
45
  buildSessionErrorMessage,
83
46
  classifySessionError,
84
47
  } from "../session-error.js";
85
- import { resolveChannelCapabilities } from "../session-runtime-assembly.js";
86
- import { generateVideoThumbnail } from "../video-thumbnail.js";
87
48
  import {
88
- handleRecordingPause,
89
- handleRecordingRestart,
90
- handleRecordingResume,
91
- handleRecordingStart,
92
- handleRecordingStop,
93
- } from "./recording.js";
49
+ handleConversationSearch,
50
+ handleHistoryRequest,
51
+ handleMessageContentRequest,
52
+ } from "./session-history.js";
94
53
  import {
95
54
  defineHandlers,
96
55
  type HandlerContext,
97
- type HistorySurface,
98
- type HistoryToolCall,
99
56
  log,
100
- mergeToolResults,
101
- type ParsedHistoryMessage,
102
57
  pendingStandaloneSecrets,
103
- renderHistoryContent,
104
58
  wireEscalationHandler,
105
59
  } from "./shared.js";
60
+ // Re-export for backward compatibility — tests and other consumers import from sessions.js
61
+ export { handleUserMessage } from "./session-user-message.js";
62
+ import { handleUserMessage } from "./session-user-message.js";
106
63
 
107
- const desktopApprovalConversationGenerator =
108
- createApprovalConversationGenerator();
109
-
110
- function syncCanonicalStatusFromIpcConfirmationDecision(
64
+ export function syncCanonicalStatusFromIpcConfirmationDecision(
111
65
  requestId: string,
112
66
  decision: ConfirmationResponse["decision"],
113
67
  ): void {
@@ -128,7 +82,7 @@ function syncCanonicalStatusFromIpcConfirmationDecision(
128
82
  }
129
83
  }
130
84
 
131
- function makeIpcEventSender(params: {
85
+ export function makeIpcEventSender(params: {
132
86
  ctx: HandlerContext;
133
87
  socket: net.Socket;
134
88
  session: Session;
@@ -187,904 +141,6 @@ function makeIpcEventSender(params: {
187
141
  };
188
142
  }
189
143
 
190
- export async function handleUserMessage(
191
- msg: UserMessage,
192
- socket: net.Socket,
193
- ctx: HandlerContext,
194
- ): Promise<void> {
195
- const requestId = uuid();
196
- const rlog = log.child({ sessionId: msg.sessionId, requestId });
197
- try {
198
- ctx.socketToSession.set(socket, msg.sessionId);
199
- const session = await ctx.getOrCreateSession(msg.sessionId, socket, true);
200
- // Only wire the escalation handler if one isn't already set — handleTaskSubmit
201
- // sets a handler with the client's actual screen dimensions, and overwriting it
202
- // here would replace those dimensions with the daemon's defaults.
203
- if (!session.hasEscalationHandler()) {
204
- wireEscalationHandler(session, socket, ctx);
205
- }
206
-
207
- const ipcChannel = parseChannelId(msg.channel) ?? "vellum";
208
- const sendEvent = makeIpcEventSender({
209
- ctx,
210
- socket,
211
- session,
212
- conversationId: msg.sessionId,
213
- sourceChannel: ipcChannel,
214
- });
215
- // Route prompter-originated events (confirmation_request/secret_request)
216
- // through the IPC wrapper so pending-interactions + canonical tracking
217
- // are updated before the message is sent to the client.
218
- session.updateClient(sendEvent, false);
219
- const ipcInterface = parseInterfaceId(msg.interface);
220
- if (!ipcInterface) {
221
- ctx.send(socket, {
222
- type: "error",
223
- message:
224
- "Invalid user_message: interface is required and must be valid",
225
- });
226
- return;
227
- }
228
- const queuedChannelMetadata = {
229
- userMessageChannel: ipcChannel,
230
- assistantMessageChannel: ipcChannel,
231
- userMessageInterface: ipcInterface,
232
- assistantMessageInterface: ipcInterface,
233
- };
234
-
235
- // Update channel capabilities eagerly so both immediate and queued paths
236
- // reflect the latest PTT / microphone state from the client.
237
- session.setChannelCapabilities(
238
- resolveChannelCapabilities(ipcChannel, ipcInterface, {
239
- pttActivationKey: msg.pttActivationKey,
240
- microphonePermissionGranted: msg.microphonePermissionGranted,
241
- }),
242
- );
243
-
244
- const dispatchUserMessage = (
245
- content: string,
246
- attachments: UserMessageAttachment[],
247
- dispatchRequestId: string,
248
- source: "user_message" | "secure_redirect_resume",
249
- activeSurfaceId?: string,
250
- currentPage?: string,
251
- displayContent?: string,
252
- ): void => {
253
- const receivedDescription =
254
- source === "user_message"
255
- ? "User message received"
256
- : "Resuming message after secure credential save";
257
- const queuedDescription =
258
- source === "user_message"
259
- ? "Message queued (session busy)"
260
- : "Resumed message queued (session busy)";
261
-
262
- session.traceEmitter.emit("request_received", receivedDescription, {
263
- requestId: dispatchRequestId,
264
- status: "info",
265
- attributes: { source },
266
- });
267
-
268
- const result = session.enqueueMessage(
269
- content,
270
- attachments,
271
- sendEvent,
272
- dispatchRequestId,
273
- activeSurfaceId,
274
- currentPage,
275
- queuedChannelMetadata,
276
- undefined,
277
- displayContent,
278
- );
279
- if (result.rejected) {
280
- rlog.warn({ source }, "Message rejected — queue is full");
281
- session.traceEmitter.emit(
282
- "request_error",
283
- "Message rejected — queue is full",
284
- {
285
- requestId: dispatchRequestId,
286
- status: "error",
287
- attributes: {
288
- reason: "queue_full",
289
- queueDepth: session.getQueueDepth(),
290
- source,
291
- },
292
- },
293
- );
294
- ctx.send(
295
- socket,
296
- buildSessionErrorMessage(msg.sessionId, {
297
- code: "QUEUE_FULL",
298
- userMessage:
299
- "Message queue is full (max depth: 10). Please wait for current messages to be processed.",
300
- retryable: true,
301
- debugDetails: "Message rejected — session queue is full",
302
- }),
303
- );
304
- return;
305
- }
306
- if (result.queued) {
307
- const position = session.getQueueDepth();
308
- rlog.info({ source, position }, queuedDescription);
309
- session.traceEmitter.emit(
310
- "request_queued",
311
- `Message queued at position ${position}`,
312
- {
313
- requestId: dispatchRequestId,
314
- status: "info",
315
- attributes: { position, source },
316
- },
317
- );
318
- ctx.send(socket, {
319
- type: "message_queued",
320
- sessionId: msg.sessionId,
321
- requestId: dispatchRequestId,
322
- position,
323
- });
324
- return;
325
- }
326
-
327
- rlog.info({ source }, "Processing user message");
328
- session.emitActivityState(
329
- "thinking",
330
- "message_dequeued",
331
- "assistant_turn",
332
- dispatchRequestId,
333
- );
334
- session.setTurnChannelContext({
335
- userMessageChannel: ipcChannel,
336
- assistantMessageChannel: ipcChannel,
337
- });
338
- session.setTurnInterfaceContext({
339
- userMessageInterface: ipcInterface,
340
- assistantMessageInterface: ipcInterface,
341
- });
342
- session.setAssistantId(DAEMON_INTERNAL_ASSISTANT_ID);
343
- // Resolve local IPC actor identity through the same trust pipeline
344
- // used by HTTP channel ingress. The vellum guardian binding provides
345
- // the guardianPrincipalId, and resolveTrustContext classifies the
346
- // local user as 'guardian' via binding match.
347
- session.setTrustContext(resolveLocalIpcTrustContext(ipcChannel));
348
- // Align IPC sessions with the same AuthContext shape as HTTP sessions.
349
- session.setAuthContext(resolveLocalIpcAuthContext(msg.sessionId));
350
- session.setCommandIntent(null);
351
- // Fire-and-forget: don't block the IPC handler so the connection can
352
- // continue receiving messages (e.g. cancel, confirmations, or
353
- // additional user_message that will be queued by the session).
354
- session
355
- .processMessage(
356
- content,
357
- attachments,
358
- sendEvent,
359
- dispatchRequestId,
360
- activeSurfaceId,
361
- currentPage,
362
- undefined,
363
- displayContent,
364
- )
365
- .catch((err) => {
366
- const message = err instanceof Error ? err.message : String(err);
367
- rlog.error(
368
- { err, source },
369
- "Error processing user message (session or provider failure)",
370
- );
371
- ctx.send(socket, {
372
- type: "error",
373
- message: `Failed to process message: ${message}`,
374
- });
375
- const classified = classifySessionError(err, { phase: "agent_loop" });
376
- ctx.send(socket, buildSessionErrorMessage(msg.sessionId, classified));
377
- });
378
- };
379
-
380
- const config = getConfig();
381
- let messageText = msg.content ?? "";
382
-
383
- // Block inbound messages that contain secrets and redirect to secure prompt
384
- if (!msg.bypassSecretCheck) {
385
- const ingressCheck = checkIngressForSecrets(messageText);
386
- if (ingressCheck.blocked) {
387
- rlog.warn(
388
- { detectedTypes: ingressCheck.detectedTypes },
389
- "Blocked user message containing secrets",
390
- );
391
- ctx.send(socket, {
392
- type: "error",
393
- message: ingressCheck.userNotice!,
394
- category: "secret_blocked",
395
- });
396
-
397
- const compiledCustom = config.secretDetection.customPatterns?.length
398
- ? compileCustomPatterns(config.secretDetection.customPatterns)
399
- : undefined;
400
- const redactedMessageText = redactSecrets(
401
- messageText,
402
- {
403
- enabled: true,
404
- base64Threshold: config.secretDetection.entropyThreshold,
405
- },
406
- compiledCustom,
407
- ).trim();
408
-
409
- // Redirect: trigger a secure prompt so the user can enter the secret safely.
410
- // After save, continue the same request with redacted text so the model keeps
411
- // user intent without ever receiving the raw secret value.
412
- session.redirectToSecurePrompt(ingressCheck.detectedTypes, {
413
- onStored: (record) => {
414
- ctx.send(socket, {
415
- type: "assistant_text_delta",
416
- sessionId: msg.sessionId,
417
- text: "Saved your secret securely. Continuing with your request.",
418
- });
419
- ctx.send(socket, {
420
- type: "message_complete",
421
- sessionId: msg.sessionId,
422
- });
423
-
424
- const continuationParts: string[] = [];
425
- if (redactedMessageText.length > 0)
426
- continuationParts.push(redactedMessageText);
427
- continuationParts.push(
428
- `I entered the redacted secret via the Secure Credential UI and saved it as credential ${record.service}/${record.field}. ` +
429
- "Continue with my request using that stored credential and do not ask me to paste the secret again.",
430
- );
431
- const continuationMessage = continuationParts.join("\n\n");
432
- const continuationRequestId = uuid();
433
- dispatchUserMessage(
434
- continuationMessage,
435
- msg.attachments ?? [],
436
- continuationRequestId,
437
- "secure_redirect_resume",
438
- msg.activeSurfaceId,
439
- msg.currentPage,
440
- );
441
- },
442
- });
443
- return;
444
- }
445
- }
446
-
447
- // ── Structured command intent (bypasses text parsing) ──────────────────
448
- if (
449
- config.daemon.standaloneRecording &&
450
- msg.commandIntent?.domain === "screen_recording"
451
- ) {
452
- const action = msg.commandIntent.action;
453
- rlog.info(
454
- { action, source: "commandIntent" },
455
- "Recording command intent received in user_message",
456
- );
457
- if (action === "start") {
458
- const recordingId = handleRecordingStart(
459
- msg.sessionId,
460
- { promptForSource: true },
461
- socket,
462
- ctx,
463
- );
464
- const responseText = recordingId
465
- ? "Starting screen recording."
466
- : "A recording is already active.";
467
- ctx.send(socket, {
468
- type: "assistant_text_delta",
469
- text: responseText,
470
- sessionId: msg.sessionId,
471
- });
472
- ctx.send(socket, {
473
- type: "message_complete",
474
- sessionId: msg.sessionId,
475
- });
476
- await conversationStore.addMessage(
477
- msg.sessionId,
478
- "user",
479
- JSON.stringify([{ type: "text", text: messageText }]),
480
- );
481
- await conversationStore.addMessage(
482
- msg.sessionId,
483
- "assistant",
484
- JSON.stringify([{ type: "text", text: responseText }]),
485
- );
486
- // Keep in-memory session history aligned with DB so regenerate() and
487
- // other history operations that rely on session.messages stay consistent.
488
- // Only push when agent loop is NOT active to avoid corrupting role alternation.
489
- if (!session.isProcessing()) {
490
- session.messages.push({
491
- role: "user",
492
- content: [{ type: "text", text: messageText }],
493
- });
494
- session.messages.push({
495
- role: "assistant",
496
- content: [{ type: "text", text: responseText }],
497
- });
498
- }
499
- return;
500
- } else if (action === "stop") {
501
- const stopped = handleRecordingStop(msg.sessionId, ctx) !== undefined;
502
- const responseText = stopped
503
- ? "Stopping the recording."
504
- : "No active recording to stop.";
505
- ctx.send(socket, {
506
- type: "assistant_text_delta",
507
- text: responseText,
508
- sessionId: msg.sessionId,
509
- });
510
- ctx.send(socket, {
511
- type: "message_complete",
512
- sessionId: msg.sessionId,
513
- });
514
- await conversationStore.addMessage(
515
- msg.sessionId,
516
- "user",
517
- JSON.stringify([{ type: "text", text: messageText }]),
518
- );
519
- await conversationStore.addMessage(
520
- msg.sessionId,
521
- "assistant",
522
- JSON.stringify([{ type: "text", text: responseText }]),
523
- );
524
- if (!session.isProcessing()) {
525
- session.messages.push({
526
- role: "user",
527
- content: [{ type: "text", text: messageText }],
528
- });
529
- session.messages.push({
530
- role: "assistant",
531
- content: [{ type: "text", text: responseText }],
532
- });
533
- }
534
- return;
535
- } else if (action === "restart") {
536
- const restartResult = handleRecordingRestart(
537
- msg.sessionId,
538
- socket,
539
- ctx,
540
- );
541
- ctx.send(socket, {
542
- type: "assistant_text_delta",
543
- text: restartResult.responseText,
544
- sessionId: msg.sessionId,
545
- });
546
- ctx.send(socket, {
547
- type: "message_complete",
548
- sessionId: msg.sessionId,
549
- });
550
- await conversationStore.addMessage(
551
- msg.sessionId,
552
- "user",
553
- JSON.stringify([{ type: "text", text: messageText }]),
554
- );
555
- await conversationStore.addMessage(
556
- msg.sessionId,
557
- "assistant",
558
- JSON.stringify([{ type: "text", text: restartResult.responseText }]),
559
- );
560
- if (!session.isProcessing()) {
561
- session.messages.push({
562
- role: "user",
563
- content: [{ type: "text", text: messageText }],
564
- });
565
- session.messages.push({
566
- role: "assistant",
567
- content: [{ type: "text", text: restartResult.responseText }],
568
- });
569
- }
570
- return;
571
- } else if (action === "pause") {
572
- const paused = handleRecordingPause(msg.sessionId, ctx) !== undefined;
573
- const responseText = paused
574
- ? "Pausing the recording."
575
- : "No active recording to pause.";
576
- ctx.send(socket, {
577
- type: "assistant_text_delta",
578
- text: responseText,
579
- sessionId: msg.sessionId,
580
- });
581
- ctx.send(socket, {
582
- type: "message_complete",
583
- sessionId: msg.sessionId,
584
- });
585
- await conversationStore.addMessage(
586
- msg.sessionId,
587
- "user",
588
- JSON.stringify([{ type: "text", text: messageText }]),
589
- );
590
- await conversationStore.addMessage(
591
- msg.sessionId,
592
- "assistant",
593
- JSON.stringify([{ type: "text", text: responseText }]),
594
- );
595
- if (!session.isProcessing()) {
596
- session.messages.push({
597
- role: "user",
598
- content: [{ type: "text", text: messageText }],
599
- });
600
- session.messages.push({
601
- role: "assistant",
602
- content: [{ type: "text", text: responseText }],
603
- });
604
- }
605
- return;
606
- } else if (action === "resume") {
607
- const resumed = handleRecordingResume(msg.sessionId, ctx) !== undefined;
608
- const responseText = resumed
609
- ? "Resuming the recording."
610
- : "No active recording to resume.";
611
- ctx.send(socket, {
612
- type: "assistant_text_delta",
613
- text: responseText,
614
- sessionId: msg.sessionId,
615
- });
616
- ctx.send(socket, {
617
- type: "message_complete",
618
- sessionId: msg.sessionId,
619
- });
620
- await conversationStore.addMessage(
621
- msg.sessionId,
622
- "user",
623
- JSON.stringify([{ type: "text", text: messageText }]),
624
- );
625
- await conversationStore.addMessage(
626
- msg.sessionId,
627
- "assistant",
628
- JSON.stringify([{ type: "text", text: responseText }]),
629
- );
630
- if (!session.isProcessing()) {
631
- session.messages.push({
632
- role: "user",
633
- content: [{ type: "text", text: messageText }],
634
- });
635
- session.messages.push({
636
- role: "assistant",
637
- content: [{ type: "text", text: responseText }],
638
- });
639
- }
640
- return;
641
- } else {
642
- // Unrecognized action — fall through to normal text handling
643
- rlog.warn(
644
- { action, source: "commandIntent" },
645
- "Unrecognized screen_recording action, falling through to text handling",
646
- );
647
- }
648
- }
649
-
650
- // ── Standalone recording intent interception ──────────────────────────
651
- let originalContentBeforeStrip: string | undefined;
652
- if (config.daemon.standaloneRecording && messageText) {
653
- const name = getAssistantName();
654
- const dynamicNames = [name].filter(Boolean) as string[];
655
- const intentResult = resolveRecordingIntent(messageText, dynamicNames);
656
-
657
- if (
658
- intentResult.kind === "start_only" ||
659
- intentResult.kind === "stop_only" ||
660
- intentResult.kind === "start_and_stop_only" ||
661
- intentResult.kind === "restart_only" ||
662
- intentResult.kind === "pause_only" ||
663
- intentResult.kind === "resume_only"
664
- ) {
665
- const execResult = executeRecordingIntent(intentResult, {
666
- conversationId: msg.sessionId,
667
- socket,
668
- ctx,
669
- });
670
-
671
- if (execResult.handled) {
672
- rlog.info(
673
- { kind: intentResult.kind },
674
- "Recording intent intercepted in user_message",
675
- );
676
- ctx.send(socket, {
677
- type: "assistant_text_delta",
678
- text: execResult.responseText!,
679
- sessionId: msg.sessionId,
680
- });
681
- ctx.send(socket, {
682
- type: "message_complete",
683
- sessionId: msg.sessionId,
684
- });
685
- await conversationStore.addMessage(
686
- msg.sessionId,
687
- "user",
688
- JSON.stringify([{ type: "text", text: messageText }]),
689
- );
690
- await conversationStore.addMessage(
691
- msg.sessionId,
692
- "assistant",
693
- JSON.stringify([{ type: "text", text: execResult.responseText! }]),
694
- );
695
- if (!session.isProcessing()) {
696
- session.messages.push({
697
- role: "user",
698
- content: [{ type: "text", text: messageText }],
699
- });
700
- session.messages.push({
701
- role: "assistant",
702
- content: [{ type: "text", text: execResult.responseText! }],
703
- });
704
- }
705
- return;
706
- }
707
- }
708
-
709
- if (
710
- intentResult.kind === "start_with_remainder" ||
711
- intentResult.kind === "stop_with_remainder" ||
712
- intentResult.kind === "start_and_stop_with_remainder" ||
713
- intentResult.kind === "restart_with_remainder"
714
- ) {
715
- const execResult = executeRecordingIntent(intentResult, {
716
- conversationId: msg.sessionId,
717
- socket,
718
- ctx,
719
- });
720
-
721
- // Preserve the original text so the DB stores the full message
722
- originalContentBeforeStrip = messageText;
723
-
724
- // Continue with stripped text for downstream processing
725
- msg.content = execResult.remainderText ?? messageText;
726
- messageText = msg.content;
727
-
728
- // Execute the recording side effects that executeRecordingIntent deferred
729
- if (intentResult.kind === "stop_with_remainder") {
730
- handleRecordingStop(msg.sessionId, ctx);
731
- }
732
- if (intentResult.kind === "start_with_remainder") {
733
- handleRecordingStart(
734
- msg.sessionId,
735
- { promptForSource: true },
736
- socket,
737
- ctx,
738
- );
739
- }
740
- // start_and_stop_with_remainder / restart_with_remainder — route through
741
- // handleRecordingRestart which properly cleans up maps between stop and start.
742
- if (
743
- intentResult.kind === "restart_with_remainder" ||
744
- intentResult.kind === "start_and_stop_with_remainder"
745
- ) {
746
- const restartResult = handleRecordingRestart(
747
- msg.sessionId,
748
- socket,
749
- ctx,
750
- );
751
- // Only fall back to plain start for start_and_stop_with_remainder.
752
- // restart_with_remainder should NOT silently start a new recording when idle.
753
- if (
754
- !restartResult.initiated &&
755
- restartResult.reason === "no_active_recording" &&
756
- intentResult.kind === "start_and_stop_with_remainder"
757
- ) {
758
- handleRecordingStart(
759
- msg.sessionId,
760
- { promptForSource: true },
761
- socket,
762
- ctx,
763
- );
764
- }
765
- }
766
-
767
- rlog.info(
768
- { remaining: msg.content, kind: intentResult.kind },
769
- "Recording intent with remainder — continuing with remaining text",
770
- );
771
- }
772
-
773
- // 'none' — deterministic resolver found nothing; try LLM fallback
774
- // if the text contains recording-related keywords.
775
- if (
776
- intentResult.kind === "none" &&
777
- containsRecordingKeywords(messageText)
778
- ) {
779
- const fallback = await classifyRecordingIntentFallback(messageText);
780
- rlog.info(
781
- {
782
- fallbackAction: fallback.action,
783
- fallbackConfidence: fallback.confidence,
784
- },
785
- "Recording intent LLM fallback result",
786
- );
787
-
788
- if (fallback.action !== "none" && fallback.confidence === "high") {
789
- const kindMap: Record<
790
- string,
791
- import("../recording-intent.js").RecordingIntentResult
792
- > = {
793
- start: { kind: "start_only" },
794
- stop: { kind: "stop_only" },
795
- restart: { kind: "restart_only" },
796
- pause: { kind: "pause_only" },
797
- resume: { kind: "resume_only" },
798
- };
799
- const mapped = kindMap[fallback.action];
800
- if (mapped) {
801
- const execResult = executeRecordingIntent(mapped, {
802
- conversationId: msg.sessionId,
803
- socket,
804
- ctx,
805
- });
806
-
807
- if (execResult.handled) {
808
- rlog.info(
809
- { kind: mapped.kind, source: "llm_fallback" },
810
- "Recording intent intercepted via LLM fallback",
811
- );
812
- ctx.send(socket, {
813
- type: "assistant_text_delta",
814
- text: execResult.responseText!,
815
- sessionId: msg.sessionId,
816
- });
817
- ctx.send(socket, {
818
- type: "message_complete",
819
- sessionId: msg.sessionId,
820
- });
821
- await conversationStore.addMessage(
822
- msg.sessionId,
823
- "user",
824
- JSON.stringify([{ type: "text", text: messageText }]),
825
- );
826
- await conversationStore.addMessage(
827
- msg.sessionId,
828
- "assistant",
829
- JSON.stringify([
830
- { type: "text", text: execResult.responseText! },
831
- ]),
832
- );
833
- if (!session.isProcessing()) {
834
- session.messages.push({
835
- role: "user",
836
- content: [{ type: "text", text: messageText }],
837
- });
838
- session.messages.push({
839
- role: "assistant",
840
- content: [{ type: "text", text: execResult.responseText! }],
841
- });
842
- }
843
- return;
844
- }
845
- }
846
- }
847
- }
848
- }
849
-
850
- // If a live turn is waiting on confirmation, try to consume this text as
851
- // an inline approval decision before auto-deny. We intentionally do not
852
- // gate on queue depth: users often retry "approve"/"yes" while the queue
853
- // is draining after a prior denial, and requiring an empty queue causes a
854
- // deny/retry cascade where natural-language approvals never land.
855
- if (session.hasAnyPendingConfirmation() && messageText.trim().length > 0) {
856
- try {
857
- const pendingInteractionRequestIdsForConversation = pendingInteractions
858
- .getByConversation(msg.sessionId)
859
- .filter(
860
- (interaction) =>
861
- interaction.kind === "confirmation" &&
862
- interaction.session === session &&
863
- session.hasPendingConfirmation(interaction.requestId),
864
- )
865
- .map((interaction) => interaction.requestId);
866
-
867
- const pendingCanonicalRequestIdsForConversation = [
868
- ...listPendingCanonicalGuardianRequestsByDestinationConversation(
869
- msg.sessionId,
870
- ipcChannel,
871
- )
872
- .filter((request) => request.kind === "tool_approval")
873
- .map((request) => request.id),
874
- ...listCanonicalGuardianRequests({
875
- status: "pending",
876
- conversationId: msg.sessionId,
877
- kind: "tool_approval",
878
- }).map((request) => request.id),
879
- ].filter((pendingRequestId) =>
880
- session.hasPendingConfirmation(pendingRequestId),
881
- );
882
-
883
- const pendingRequestIdsForConversation = Array.from(
884
- new Set([
885
- ...pendingInteractionRequestIdsForConversation,
886
- ...pendingCanonicalRequestIdsForConversation,
887
- ]),
888
- );
889
-
890
- if (pendingRequestIdsForConversation.length > 0) {
891
- // Resolve the local IPC actor's principal via the vellum guardian binding
892
- // for principal-based authorization in the canonical decision primitive.
893
- const localCtx = resolveLocalIpcTrustContext(ipcChannel);
894
- const routerResult = await routeGuardianReply({
895
- messageText: messageText.trim(),
896
- channel: ipcChannel,
897
- actor: {
898
- externalUserId: localCtx.guardianExternalUserId,
899
- channel: ipcChannel,
900
- guardianPrincipalId: localCtx.guardianPrincipalId ?? undefined,
901
- },
902
- conversationId: msg.sessionId,
903
- pendingRequestIds: pendingRequestIdsForConversation,
904
- approvalConversationGenerator: desktopApprovalConversationGenerator,
905
- emissionContext: {
906
- source: "inline_nl",
907
- causedByRequestId: requestId,
908
- decisionText: messageText.trim(),
909
- },
910
- });
911
-
912
- if (
913
- routerResult.consumed &&
914
- routerResult.type !== "nl_keep_pending"
915
- ) {
916
- // Success-path emissions (approved/denied) are handled centrally
917
- // by handleConfirmationResponse (called via the resolver chain).
918
- // However, stale/failed paths never reach handleConfirmationResponse,
919
- // so we emit resolved_stale here for those cases.
920
- if (routerResult.requestId && !routerResult.decisionApplied) {
921
- session.emitConfirmationStateChanged({
922
- sessionId: msg.sessionId,
923
- requestId: routerResult.requestId,
924
- state: "resolved_stale",
925
- source: "inline_nl",
926
- causedByRequestId: requestId,
927
- decisionText: messageText.trim(),
928
- });
929
- }
930
-
931
- const consumedChannelMeta = {
932
- userMessageChannel: ipcChannel,
933
- assistantMessageChannel: ipcChannel,
934
- userMessageInterface: ipcInterface,
935
- assistantMessageInterface: ipcInterface,
936
- provenanceTrustClass: "guardian" as const,
937
- };
938
-
939
- const consumedUserMessage = createUserMessage(
940
- messageText,
941
- msg.attachments ?? [],
942
- );
943
- await conversationStore.addMessage(
944
- msg.sessionId,
945
- "user",
946
- JSON.stringify(consumedUserMessage.content),
947
- consumedChannelMeta,
948
- );
949
-
950
- const replyText =
951
- routerResult.replyText?.trim() ||
952
- (routerResult.decisionApplied
953
- ? "Decision applied."
954
- : "Request already resolved.");
955
- const consumedAssistantMessage = createAssistantMessage(replyText);
956
- await conversationStore.addMessage(
957
- msg.sessionId,
958
- "assistant",
959
- JSON.stringify(consumedAssistantMessage.content),
960
- consumedChannelMeta,
961
- );
962
- // Avoid mutating in-memory history while an agent loop is active;
963
- // the loop owns history reconstruction for the in-flight turn.
964
- if (!session.isProcessing()) {
965
- // Keep in-memory history aligned with persisted transcript so
966
- // session-history operations (undo/regenerate) target the same turn.
967
- session.messages.push(
968
- consumedUserMessage,
969
- consumedAssistantMessage,
970
- );
971
- }
972
-
973
- // Mirror the normal queued/dequeued lifecycle so desktop clients can
974
- // reconcile queued bubble state for this just-sent user message.
975
- ctx.send(socket, {
976
- type: "message_queued",
977
- sessionId: msg.sessionId,
978
- requestId,
979
- position: 0,
980
- });
981
- ctx.send(socket, {
982
- type: "message_dequeued",
983
- sessionId: msg.sessionId,
984
- requestId,
985
- });
986
-
987
- // Only emit the reply delta when no agent turn is in-flight.
988
- // When the agent is active, currentAssistantMessageId on the client
989
- // points to the agent's streaming message and this delta would
990
- // contaminate it. The reply is already persisted to the DB, so the
991
- // client will see it on the next transcript reload / session switch.
992
- if (!session.isProcessing()) {
993
- ctx.send(socket, {
994
- type: "assistant_text_delta",
995
- text: replyText,
996
- sessionId: msg.sessionId,
997
- });
998
- }
999
- ctx.send(socket, {
1000
- type: "message_request_complete",
1001
- sessionId: msg.sessionId,
1002
- requestId,
1003
- runStillActive: session.isProcessing(),
1004
- });
1005
-
1006
- rlog.info(
1007
- {
1008
- routerType: routerResult.type,
1009
- decisionApplied: routerResult.decisionApplied,
1010
- routerRequestId: routerResult.requestId,
1011
- },
1012
- "Consumed pending-confirmation reply before auto-deny",
1013
- );
1014
- return;
1015
- }
1016
- }
1017
- } catch (err) {
1018
- rlog.warn(
1019
- { err },
1020
- "Failed to process pending-confirmation reply; falling back to auto-deny behavior",
1021
- );
1022
- }
1023
- }
1024
-
1025
- // If the session has a pending tool confirmation, auto-deny it so the
1026
- // agent can process the user's follow-up message instead. The agent
1027
- // will see the denial and can re-request the tool if still needed.
1028
- if (session.hasAnyPendingConfirmation()) {
1029
- rlog.info("Auto-denying pending confirmation(s) due to new user message");
1030
- // Emit authoritative confirmation state for each auto-denied request
1031
- // before the prompter clears them.
1032
- for (const interaction of pendingInteractions.getByConversation(
1033
- msg.sessionId,
1034
- )) {
1035
- if (
1036
- interaction.session === session &&
1037
- interaction.kind === "confirmation"
1038
- ) {
1039
- session.emitConfirmationStateChanged({
1040
- sessionId: msg.sessionId,
1041
- requestId: interaction.requestId,
1042
- state: "denied",
1043
- source: "auto_deny",
1044
- causedByRequestId: requestId,
1045
- });
1046
- }
1047
- }
1048
- session.denyAllPendingConfirmations();
1049
- // Keep the pending-interaction tracker aligned with the prompter so
1050
- // stale request IDs are not reused as routing candidates.
1051
- for (const interaction of pendingInteractions.getByConversation(
1052
- msg.sessionId,
1053
- )) {
1054
- if (
1055
- interaction.session === session &&
1056
- interaction.kind === "confirmation"
1057
- ) {
1058
- syncCanonicalStatusFromIpcConfirmationDecision(
1059
- interaction.requestId,
1060
- "deny",
1061
- );
1062
- pendingInteractions.resolve(interaction.requestId);
1063
- }
1064
- }
1065
- }
1066
-
1067
- dispatchUserMessage(
1068
- messageText,
1069
- msg.attachments ?? [],
1070
- requestId,
1071
- "user_message",
1072
- msg.activeSurfaceId,
1073
- msg.currentPage,
1074
- originalContentBeforeStrip,
1075
- );
1076
- } catch (err) {
1077
- const message = err instanceof Error ? err.message : String(err);
1078
- rlog.error({ err }, "Error setting up user message processing");
1079
- ctx.send(socket, {
1080
- type: "error",
1081
- message: `Failed to process message: ${message}`,
1082
- });
1083
- const classified = classifySessionError(err, { phase: "handler" });
1084
- ctx.send(socket, buildSessionErrorMessage(msg.sessionId, classified));
1085
- }
1086
- }
1087
-
1088
144
  export function handleConfirmationResponse(
1089
145
  msg: ConfirmationResponse,
1090
146
  _socket: net.Socket,
@@ -1463,275 +519,6 @@ export function handleCancel(
1463
519
  }
1464
520
  }
1465
521
 
1466
- export function handleHistoryRequest(
1467
- msg: HistoryRequest,
1468
- socket: net.Socket,
1469
- ctx: HandlerContext,
1470
- ): void {
1471
- // Default to unlimited when callers don't specify a limit, preserving
1472
- // backward-compatible behavior of returning full conversation history.
1473
- const limit = msg.limit;
1474
-
1475
- // Resolve include flags: explicit flags override mode, mode provides defaults.
1476
- // Default mode is 'light' when no mode and no include flags are specified.
1477
- const isFullMode = msg.mode === "full";
1478
- const includeAttachments = msg.includeAttachments ?? isFullMode;
1479
- const includeToolImages = msg.includeToolImages ?? isFullMode;
1480
- const includeSurfaceData = msg.includeSurfaceData ?? isFullMode;
1481
-
1482
- const { messages: dbMessages, hasMore } =
1483
- conversationStore.getMessagesPaginated(
1484
- msg.sessionId,
1485
- limit,
1486
- msg.beforeTimestamp,
1487
- msg.beforeMessageId,
1488
- );
1489
-
1490
- const parsed: ParsedHistoryMessage[] = dbMessages.map((m) => {
1491
- let text = "";
1492
- let toolCalls: HistoryToolCall[] = [];
1493
- let toolCallsBeforeText = false;
1494
- let textSegments: string[] = [];
1495
- let contentOrder: string[] = [];
1496
- let surfaces: HistorySurface[] = [];
1497
- try {
1498
- const content = JSON.parse(m.content);
1499
- const rendered = renderHistoryContent(content);
1500
- text = rendered.text;
1501
- toolCalls = rendered.toolCalls;
1502
- toolCallsBeforeText = rendered.toolCallsBeforeText;
1503
- textSegments = rendered.textSegments;
1504
- contentOrder = rendered.contentOrder;
1505
- surfaces = rendered.surfaces;
1506
- if (m.role === "assistant" && toolCalls.length > 0) {
1507
- log.info(
1508
- {
1509
- messageId: m.id,
1510
- toolCallCount: toolCalls.length,
1511
- text: truncate(text, 100, ""),
1512
- },
1513
- "History message with tool calls",
1514
- );
1515
- }
1516
- } catch (err) {
1517
- log.debug(
1518
- { err, messageId: m.id },
1519
- "Failed to parse message content as JSON, using raw text",
1520
- );
1521
- text = m.content;
1522
- textSegments = text ? [text] : [];
1523
- contentOrder = text ? ["text:0"] : [];
1524
- surfaces = [];
1525
- }
1526
- let subagentNotification: ParsedHistoryMessage["subagentNotification"];
1527
- if (m.metadata) {
1528
- try {
1529
- subagentNotification = (
1530
- JSON.parse(m.metadata) as {
1531
- subagentNotification?: ParsedHistoryMessage["subagentNotification"];
1532
- }
1533
- ).subagentNotification;
1534
- } catch (err) {
1535
- log.debug(
1536
- { err, messageId: m.id },
1537
- "Failed to parse message metadata as JSON, ignoring",
1538
- );
1539
- }
1540
- }
1541
- return {
1542
- id: m.id,
1543
- role: m.role,
1544
- text,
1545
- timestamp: m.createdAt,
1546
- toolCalls,
1547
- toolCallsBeforeText,
1548
- textSegments,
1549
- contentOrder,
1550
- surfaces,
1551
- ...(subagentNotification ? { subagentNotification } : {}),
1552
- };
1553
- });
1554
-
1555
- // Merge tool_result data from user messages into the preceding assistant
1556
- // message's toolCalls, and suppress user messages that only contain
1557
- // tool_result blocks (internal agent-loop turns).
1558
- const merged = mergeToolResults(parsed);
1559
-
1560
- const historyMessages = merged.map((m) => {
1561
- let attachments: UserMessageAttachment[] | undefined;
1562
- if (m.role === "assistant" && m.id) {
1563
- const linked = getAttachmentsForMessage(m.id);
1564
- if (linked.length > 0) {
1565
- if (includeAttachments) {
1566
- // Full attachment data: same behavior as before
1567
- const MAX_INLINE_B64_SIZE = 512 * 1024;
1568
- attachments = linked.map((a) => {
1569
- const isFileBacked = !a.dataBase64;
1570
- const omit =
1571
- isFileBacked ||
1572
- (a.mimeType.startsWith("video/") &&
1573
- a.dataBase64.length > MAX_INLINE_B64_SIZE);
1574
-
1575
- if (
1576
- a.mimeType.startsWith("video/") &&
1577
- !a.thumbnailBase64 &&
1578
- a.dataBase64
1579
- ) {
1580
- const attachmentId = a.id;
1581
- const base64 = a.dataBase64;
1582
- silentlyWithLog(
1583
- generateVideoThumbnail(base64).then((thumb) => {
1584
- if (thumb) setAttachmentThumbnail(attachmentId, thumb);
1585
- }),
1586
- "video thumbnail generation",
1587
- );
1588
- }
1589
-
1590
- const fp = getFilePathForAttachment(a.id);
1591
- return {
1592
- id: a.id,
1593
- filename: a.originalFilename,
1594
- mimeType: a.mimeType,
1595
- data: omit ? "" : a.dataBase64,
1596
- ...(omit ? { sizeBytes: a.sizeBytes } : {}),
1597
- ...(a.thumbnailBase64
1598
- ? { thumbnailData: a.thumbnailBase64 }
1599
- : {}),
1600
- ...(fp ? { filePath: fp } : {}),
1601
- };
1602
- });
1603
- } else {
1604
- // Light mode: metadata only, strip base64 data
1605
- attachments = linked.map((a) => {
1606
- const fp = getFilePathForAttachment(a.id);
1607
- return {
1608
- id: a.id,
1609
- filename: a.originalFilename,
1610
- mimeType: a.mimeType,
1611
- data: "",
1612
- sizeBytes: a.sizeBytes,
1613
- ...(a.thumbnailBase64
1614
- ? { thumbnailData: a.thumbnailBase64 }
1615
- : {}),
1616
- ...(fp ? { filePath: fp } : {}),
1617
- };
1618
- });
1619
- }
1620
- }
1621
- }
1622
-
1623
- // In light mode, strip imageData from tool calls
1624
- const filteredToolCalls =
1625
- m.toolCalls.length > 0
1626
- ? includeToolImages
1627
- ? m.toolCalls
1628
- : m.toolCalls.map((tc) => {
1629
- if (tc.imageData) {
1630
- const { imageData: _, ...rest } = tc;
1631
- return rest;
1632
- }
1633
- return tc;
1634
- })
1635
- : m.toolCalls;
1636
-
1637
- // In light mode, strip full data from surfaces (keep metadata)
1638
- const filteredSurfaces =
1639
- m.surfaces.length > 0
1640
- ? includeSurfaceData
1641
- ? m.surfaces
1642
- : m.surfaces.map((s) => ({
1643
- surfaceId: s.surfaceId,
1644
- surfaceType: s.surfaceType,
1645
- title: s.title,
1646
- data: {
1647
- ...(s.surfaceType === "dynamic_page"
1648
- ? {
1649
- ...(s.data.preview ? { preview: s.data.preview } : {}),
1650
- ...(s.data.appId ? { appId: s.data.appId } : {}),
1651
- }
1652
- : {}),
1653
- } as Record<string, unknown>,
1654
- ...(s.actions ? { actions: s.actions } : {}),
1655
- ...(s.display ? { display: s.display } : {}),
1656
- }))
1657
- : m.surfaces;
1658
-
1659
- // Apply text truncation when maxTextChars is set
1660
- let wasTruncated = false;
1661
- let textWasTruncated = false;
1662
- let text = m.text;
1663
- if (msg.maxTextChars !== undefined && text.length > msg.maxTextChars) {
1664
- text = text.slice(0, msg.maxTextChars) + " \u2026 [truncated]";
1665
- wasTruncated = true;
1666
- textWasTruncated = true;
1667
- }
1668
-
1669
- // Apply tool result truncation when maxToolResultChars is set
1670
- const truncatedToolCalls =
1671
- msg.maxToolResultChars !== undefined && filteredToolCalls.length > 0
1672
- ? filteredToolCalls.map((tc) => {
1673
- if (
1674
- tc.result !== undefined &&
1675
- tc.result.length > msg.maxToolResultChars!
1676
- ) {
1677
- wasTruncated = true;
1678
- return {
1679
- ...tc,
1680
- result:
1681
- tc.result.slice(0, msg.maxToolResultChars!) +
1682
- " \u2026 [truncated]",
1683
- };
1684
- }
1685
- return tc;
1686
- })
1687
- : filteredToolCalls;
1688
-
1689
- return {
1690
- ...(m.id ? { id: m.id } : {}),
1691
- role: m.role,
1692
- text,
1693
- timestamp: m.timestamp,
1694
- ...(truncatedToolCalls.length > 0
1695
- ? {
1696
- toolCalls: truncatedToolCalls,
1697
- toolCallsBeforeText: m.toolCallsBeforeText,
1698
- }
1699
- : {}),
1700
- ...(attachments ? { attachments } : {}),
1701
- ...(!textWasTruncated && m.textSegments.length > 0
1702
- ? { textSegments: m.textSegments }
1703
- : {}),
1704
- ...(!textWasTruncated && m.contentOrder.length > 0
1705
- ? { contentOrder: m.contentOrder }
1706
- : {}),
1707
- ...(filteredSurfaces.length > 0 ? { surfaces: filteredSurfaces } : {}),
1708
- ...(m.subagentNotification
1709
- ? { subagentNotification: m.subagentNotification }
1710
- : {}),
1711
- ...(wasTruncated ? { wasTruncated: true } : {}),
1712
- };
1713
- });
1714
-
1715
- const oldestTimestamp =
1716
- historyMessages.length > 0 ? historyMessages[0].timestamp : undefined;
1717
- // Provide the oldest message ID as a tie-breaker cursor so clients can
1718
- // paginate without skipping same-millisecond messages at page boundaries.
1719
- const oldestMessageId =
1720
- historyMessages.length > 0 ? historyMessages[0].id : undefined;
1721
-
1722
- ctx.send(socket, {
1723
- type: "history_response",
1724
- sessionId: msg.sessionId,
1725
- messages: historyMessages,
1726
- hasMore,
1727
- ...(oldestTimestamp !== undefined ? { oldestTimestamp } : {}),
1728
- ...(oldestMessageId ? { oldestMessageId } : {}),
1729
- });
1730
-
1731
- // Surfaces are now included directly in the history_response message (in the surfaces array),
1732
- // so we no longer emit separate ui_surface_show messages during history loading.
1733
- }
1734
-
1735
522
  export function handleUndo(
1736
523
  msg: UndoRequest,
1737
524
  socket: net.Socket,
@@ -1822,17 +609,6 @@ export function handleUsageRequest(
1822
609
  });
1823
610
  }
1824
611
 
1825
- export function handleSandboxSet(
1826
- msg: SandboxSetRequest,
1827
- _socket: net.Socket,
1828
- _ctx: HandlerContext,
1829
- ): void {
1830
- log.warn(
1831
- { enabled: msg.enabled },
1832
- "Received deprecated sandbox_set message. Runtime sandbox overrides are ignored.",
1833
- );
1834
- }
1835
-
1836
612
  export function handleDeleteQueuedMessage(
1837
613
  msg: DeleteQueuedMessage,
1838
614
  socket: net.Socket,
@@ -1861,109 +637,6 @@ export function handleDeleteQueuedMessage(
1861
637
  }
1862
638
  }
1863
639
 
1864
- export function handleConversationSearch(
1865
- msg: ConversationSearchRequest,
1866
- socket: net.Socket,
1867
- ctx: HandlerContext,
1868
- ): void {
1869
- const results = conversationStore.searchConversations(msg.query, {
1870
- limit: msg.limit,
1871
- maxMessagesPerConversation: msg.maxMessagesPerConversation,
1872
- });
1873
- ctx.send(socket, {
1874
- type: "conversation_search_response",
1875
- query: msg.query,
1876
- results,
1877
- });
1878
- }
1879
-
1880
- export function handleMessageContentRequest(
1881
- msg: MessageContentRequest,
1882
- socket: net.Socket,
1883
- ctx: HandlerContext,
1884
- ): void {
1885
- const dbMessage = conversationStore.getMessageById(
1886
- msg.messageId,
1887
- msg.sessionId,
1888
- );
1889
- if (!dbMessage) {
1890
- ctx.send(socket, {
1891
- type: "error",
1892
- message: `Message ${msg.messageId} not found in session ${msg.sessionId}`,
1893
- });
1894
- return;
1895
- }
1896
-
1897
- let text: string | undefined;
1898
- let toolCalls:
1899
- | Array<{ name: string; result?: string; input?: Record<string, unknown> }>
1900
- | undefined;
1901
-
1902
- try {
1903
- const content = JSON.parse(dbMessage.content);
1904
- const rendered = renderHistoryContent(content);
1905
- text = rendered.text || undefined;
1906
- const mergedToolCalls = rendered.toolCalls;
1907
-
1908
- // Handle legacy conversations where tool_result blocks are stored in the
1909
- // following user message rather than inline with the assistant message.
1910
- // This mirrors the mergeToolResults logic used by handleHistoryRequest.
1911
- if (
1912
- dbMessage.role === "assistant" &&
1913
- mergedToolCalls.some((tc) => tc.result === undefined)
1914
- ) {
1915
- const nextMsg = conversationStore.getNextMessage(
1916
- msg.sessionId,
1917
- dbMessage.createdAt,
1918
- dbMessage.id,
1919
- );
1920
- if (nextMsg && nextMsg.role === "user") {
1921
- try {
1922
- const nextContent = JSON.parse(nextMsg.content);
1923
- const nextRendered = renderHistoryContent(nextContent);
1924
- if (
1925
- nextRendered.text.trim() === "" &&
1926
- nextRendered.toolCalls.length > 0
1927
- ) {
1928
- for (const resultEntry of nextRendered.toolCalls) {
1929
- const unresolved = mergedToolCalls.find(
1930
- (tc) => tc.result === undefined,
1931
- );
1932
- if (unresolved) {
1933
- unresolved.result = resultEntry.result;
1934
- unresolved.isError = resultEntry.isError;
1935
- if (resultEntry.imageData)
1936
- unresolved.imageData = resultEntry.imageData;
1937
- }
1938
- }
1939
- }
1940
- } catch {
1941
- // Next message isn't valid JSON — skip merging
1942
- }
1943
- }
1944
- }
1945
-
1946
- if (mergedToolCalls.length > 0) {
1947
- toolCalls = mergedToolCalls.map((tc) => ({
1948
- name: tc.name,
1949
- input: tc.input,
1950
- ...(tc.result !== undefined ? { result: tc.result } : {}),
1951
- }));
1952
- }
1953
- } catch {
1954
- // Raw text content (not JSON)
1955
- text = dbMessage.content || undefined;
1956
- }
1957
-
1958
- ctx.send(socket, {
1959
- type: "message_content_response",
1960
- sessionId: msg.sessionId,
1961
- messageId: msg.messageId,
1962
- ...(text !== undefined ? { text } : {}),
1963
- ...(toolCalls ? { toolCalls } : {}),
1964
- });
1965
- }
1966
-
1967
640
  export function handleReorderThreads(
1968
641
  msg: ReorderThreadsRequest,
1969
642
  _socket: net.Socket,
@@ -1998,7 +671,6 @@ export const sessionHandlers = defineHandlers({
1998
671
  undo: handleUndo,
1999
672
  regenerate: handleRegenerate,
2000
673
  usage_request: handleUsageRequest,
2001
- sandbox_set: handleSandboxSet,
2002
674
  conversation_search: handleConversationSearch,
2003
675
  reorder_threads: handleReorderThreads,
2004
676
  });