@capekai/core 1.0.0

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 (148) hide show
  1. package/README.md +12 -0
  2. package/package.json +105 -0
  3. package/src/adapters/ai-sdk.ts +84 -0
  4. package/src/compaction/contracts.ts +82 -0
  5. package/src/compaction/executor.ts +161 -0
  6. package/src/compaction/policy.ts +318 -0
  7. package/src/compaction/recovery.ts +139 -0
  8. package/src/compaction/task.ts +540 -0
  9. package/src/configuration/contracts.ts +58 -0
  10. package/src/configuration/defaults.ts +27 -0
  11. package/src/configuration/runtime.ts +42 -0
  12. package/src/configuration/single-model.ts +75 -0
  13. package/src/context/assembler.ts +112 -0
  14. package/src/context/index.ts +2 -0
  15. package/src/context/sources.ts +119 -0
  16. package/src/context/workspace.ts +63 -0
  17. package/src/core/agent.ts +401 -0
  18. package/src/core/build-tools.ts +139 -0
  19. package/src/core/chat-handler.ts +858 -0
  20. package/src/core/error-handling.ts +18 -0
  21. package/src/core/fork.ts +103 -0
  22. package/src/core/interrupt.ts +192 -0
  23. package/src/core/message-utils.ts +261 -0
  24. package/src/core/model-utils.ts +149 -0
  25. package/src/core/part-utils.ts +88 -0
  26. package/src/core/provider-utils.ts +67 -0
  27. package/src/core/revert.ts +46 -0
  28. package/src/core/step-handlers.ts +157 -0
  29. package/src/core/stream/finalization.ts +65 -0
  30. package/src/core/stream/stream-config.ts +82 -0
  31. package/src/core/stream-handlers.ts +242 -0
  32. package/src/core/structured-output.ts +68 -0
  33. package/src/core/tool-builders/agent-tools.ts +71 -0
  34. package/src/core/tool-builders/external-tools.ts +179 -0
  35. package/src/core/tool-builders/types.ts +16 -0
  36. package/src/core/tool-builders/workspace-tools.ts +293 -0
  37. package/src/core/tool-capabilities.ts +65 -0
  38. package/src/goals/evaluator.ts +171 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/loop.ts +167 -0
  41. package/src/goals/service.ts +39 -0
  42. package/src/index.ts +10 -0
  43. package/src/internal/ask-authority.ts +29 -0
  44. package/src/internal/composition.ts +44 -0
  45. package/src/internal/configuration.ts +22 -0
  46. package/src/internal/execution.ts +108 -0
  47. package/src/internal/hosts.ts +64 -0
  48. package/src/internal/plugins.ts +71 -0
  49. package/src/internal/providers.ts +32 -0
  50. package/src/internal/sandbox.ts +19 -0
  51. package/src/internal/tools.ts +48 -0
  52. package/src/internal/workspace.ts +25 -0
  53. package/src/kernel/diagnostics.ts +249 -0
  54. package/src/kernel/errors.ts +120 -0
  55. package/src/kernel/events.ts +82 -0
  56. package/src/kernel/index.ts +72 -0
  57. package/src/kernel/kernel.ts +62 -0
  58. package/src/kernel/lifecycle.ts +72 -0
  59. package/src/kernel/plugin.ts +218 -0
  60. package/src/kernel/registry.ts +493 -0
  61. package/src/kernel/scope.ts +776 -0
  62. package/src/kernel/service-key.ts +19 -0
  63. package/src/kernel/types.ts +317 -0
  64. package/src/memory/index.ts +2 -0
  65. package/src/memory/memory-tool.ts +75 -0
  66. package/src/memory/registry.ts +172 -0
  67. package/src/permission/ask-user-api.ts +70 -0
  68. package/src/permission/contracts.ts +135 -0
  69. package/src/permission/permission-request-manager.ts +58 -0
  70. package/src/permission/policy.ts +277 -0
  71. package/src/permission/runtime.ts +612 -0
  72. package/src/plugins/compaction-policy.ts +46 -0
  73. package/src/plugins/compose.ts +171 -0
  74. package/src/plugins/context-sections.ts +246 -0
  75. package/src/plugins/default-agent-driver.ts +14 -0
  76. package/src/plugins/facade-plugins.ts +129 -0
  77. package/src/plugins/goal-domain.ts +82 -0
  78. package/src/plugins/legacy-system-message.ts +152 -0
  79. package/src/plugins/loaded-tools.ts +23 -0
  80. package/src/plugins/memory-domain.ts +264 -0
  81. package/src/plugins/orchestrator-session.ts +29 -0
  82. package/src/plugins/permission-policy.ts +49 -0
  83. package/src/plugins/retry-policy.ts +28 -0
  84. package/src/plugins/scheduler-domain.ts +192 -0
  85. package/src/plugins/service-keys.ts +294 -0
  86. package/src/plugins/session-search-domain.ts +238 -0
  87. package/src/plugins/skills-domain.ts +272 -0
  88. package/src/plugins/subagent-domain.ts +287 -0
  89. package/src/plugins/tool-catalog.ts +78 -0
  90. package/src/plugins/tool-output-policy.ts +52 -0
  91. package/src/plugins/value-plugins.ts +150 -0
  92. package/src/plugins/workflow-domain.ts +198 -0
  93. package/src/plugins/workspace-policy.ts +37 -0
  94. package/src/providers/registry.ts +63 -0
  95. package/src/providers/types.ts +44 -0
  96. package/src/retry/policy.ts +282 -0
  97. package/src/retry/stream-chat.ts +312 -0
  98. package/src/runtime/agent-runtime.ts +83 -0
  99. package/src/runtime/default-agent-driver.ts +23 -0
  100. package/src/runtime/domain-tool-source.ts +156 -0
  101. package/src/runtime/events.ts +61 -0
  102. package/src/runtime/host-dependencies.ts +71 -0
  103. package/src/runtime/host-guidance.ts +22 -0
  104. package/src/runtime/host-layout.ts +23 -0
  105. package/src/runtime/host.ts +129 -0
  106. package/src/runtime/standalone-host.ts +118 -0
  107. package/src/sandbox/controller.ts +204 -0
  108. package/src/sandbox/model.ts +305 -0
  109. package/src/sandbox/provider.ts +53 -0
  110. package/src/sandbox/types.ts +110 -0
  111. package/src/scheduler/host.ts +22 -0
  112. package/src/scheduler/scheduler-tool.ts +172 -0
  113. package/src/session-search/host.ts +56 -0
  114. package/src/session-search/index.ts +23 -0
  115. package/src/session-search/session-search-tool.ts +151 -0
  116. package/src/skills/index.ts +3 -0
  117. package/src/skills/registry.ts +63 -0
  118. package/src/skills/skill-manage-tool.ts +205 -0
  119. package/src/skills/skill-tool.ts +42 -0
  120. package/src/storage/contracts.ts +159 -0
  121. package/src/storage/memory.ts +321 -0
  122. package/src/storage/options.ts +75 -0
  123. package/src/storage/runtime.ts +115 -0
  124. package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
  125. package/src/storage/sqlite.ts +321 -0
  126. package/src/storage/tool-output-artifacts.ts +75 -0
  127. package/src/storage.ts +31 -0
  128. package/src/subagent/child-session.ts +282 -0
  129. package/src/subagent/guidance.ts +8 -0
  130. package/src/subagent/policy.ts +198 -0
  131. package/src/subagent/task-tool.ts +584 -0
  132. package/src/tool-output/contracts.ts +111 -0
  133. package/src/tool-output/policy.ts +410 -0
  134. package/src/tool.ts +1 -0
  135. package/src/tools/executor.ts +258 -0
  136. package/src/tools/install-manifest.ts +40 -0
  137. package/src/tools/llm-api.ts +77 -0
  138. package/src/tools/registry.ts +206 -0
  139. package/src/tools/tool-artifact.ts +182 -0
  140. package/src/tools/tool-source.ts +53 -0
  141. package/src/utils/errors.ts +334 -0
  142. package/src/utils/strip-visualization.ts +50 -0
  143. package/src/workflow/decomposer.ts +139 -0
  144. package/src/workflow/execution.ts +523 -0
  145. package/src/workflow/orchestrator-session.ts +161 -0
  146. package/src/workflow/synthesizer.ts +130 -0
  147. package/src/workspace/contracts.ts +135 -0
  148. package/src/workspace/policy.ts +327 -0
@@ -0,0 +1,858 @@
1
+ import type { ResponseFormat } from '@capekai/types';
2
+ import {
3
+ emitRuntimeEvent,
4
+ generateSessionTitle,
5
+ hasManualSessionTitle,
6
+ isDefaultSessionTitle,
7
+ isSandboxActive,
8
+ emitTerminal,
9
+ } from '../runtime/host-dependencies';
10
+ import { getDefaultPreconfig, getPreconfigOrAgent } from '../context';
11
+ import {
12
+ addMessageToQueue,
13
+ buildEffectiveContextHistory,
14
+ createMessage,
15
+ createPart,
16
+ deleteQueuedMessage,
17
+ getAttachment,
18
+ getMessage,
19
+ getNextQueuedMessage,
20
+ getPartsByMessage,
21
+ getResponseFormat,
22
+ getSession,
23
+ getWorkspace,
24
+ listLatestMessagesWithPartsPage,
25
+ listMessagesWithParts,
26
+ updateMessage,
27
+ updatePart,
28
+ updateSession,
29
+ } from '../storage/runtime';
30
+ import type { AskBroadcastFn } from '../runtime/host';
31
+ import type { RuntimeDelivery, RuntimeEvent, RuntimeEventContext } from '../runtime/events';
32
+ import { getCompactionService } from '../compaction/policy';
33
+ import { executeCompaction } from '../compaction/executor';
34
+ import { getGoalDomain } from '../goals/service';
35
+ import { interruptManager } from './interrupt';
36
+ import { getApiKeyForProvider } from '../configuration/runtime';
37
+ import { getProvider } from '../providers/registry';
38
+ import { resolveModelId, resolveProviderId } from './provider-utils';
39
+ import { revertToStep } from './revert';
40
+ import { streamChatWithRetry } from '../retry/stream-chat';
41
+
42
+ export type RuntimeRequestContext<Origin = unknown> = RuntimeEventContext<Origin>;
43
+
44
+ function deliver<Origin>(ctx: RuntimeRequestContext<Origin>, audience: RuntimeDelivery<Origin>['audience'], event: RuntimeEvent): void {
45
+ const delivery: RuntimeDelivery<Origin> = { audience, event };
46
+ ctx.observe?.(delivery);
47
+ ctx.emit(delivery);
48
+ }
49
+
50
+ function deliverToOrigin<Origin>(ctx: RuntimeRequestContext<Origin>, origin: Origin, event: RuntimeEvent): void {
51
+ deliver(ctx, { scope: 'origin', origin }, event);
52
+ }
53
+
54
+ function deliverToSession<Origin>(ctx: RuntimeRequestContext<Origin>, sessionId: string, event: RuntimeEvent): void {
55
+ deliver(ctx, { scope: 'session', sessionId }, event);
56
+ }
57
+
58
+ // ── Chat helpers ───────────────────────────────────────────────
59
+
60
+ async function drainQueue<Origin>(
61
+ ctx: RuntimeRequestContext<Origin>,
62
+ sessionId: string,
63
+ ): Promise<{ content: string; attachments?: Array<{ id: string; kind: string }> } | null> {
64
+ const nextMsg = await getNextQueuedMessage(sessionId);
65
+
66
+ if (!nextMsg) {
67
+ return null;
68
+ }
69
+
70
+ deliverToSession(ctx, sessionId, {
71
+ kind: 'queue',
72
+ action: 'sending',
73
+ sessionId,
74
+ queueId: nextMsg.id,
75
+ });
76
+
77
+ await deleteQueuedMessage(nextMsg.id);
78
+
79
+ return {
80
+ content: nextMsg.content,
81
+ ...(nextMsg.attachments ? { attachments: nextMsg.attachments } : {}),
82
+ };
83
+ }
84
+
85
+ // ── Chat turn ──────────────────────────────────────────────────
86
+
87
+ interface ChatTurnResult {
88
+ streamCompleted: boolean;
89
+ interrupted: boolean;
90
+ needsAutoCompaction: boolean;
91
+ contextOverflow: boolean;
92
+ isFatal: boolean;
93
+ isQueueDrainable: boolean;
94
+ errorMessage?: string;
95
+ errorCode?: string;
96
+ errorType?: 'rate_limit' | 'server' | 'timeout' | 'auth' | 'context_overflow' | 'invalid_request';
97
+ retryAfterMs?: number;
98
+ }
99
+
100
+ async function runSingleChatTurn<Origin>(
101
+ ctx: RuntimeRequestContext<Origin>,
102
+ origin: Origin,
103
+ sessionId: string,
104
+ content: string,
105
+ preconfig: NonNullable<Awaited<ReturnType<typeof getPreconfigOrAgent>>>,
106
+ modelId: string,
107
+ provider: string,
108
+ workspacePath: string | null | undefined,
109
+ additionalPaths: string[] | undefined,
110
+ session: NonNullable<Awaited<ReturnType<typeof getSession>>>,
111
+ attachments?: Array<{ id: string; kind: string }>,
112
+ responseFormat?: ResponseFormat,
113
+ existingUserMessageId?: string,
114
+ ): Promise<ChatTurnResult> {
115
+ let userMsgId: string;
116
+
117
+ if (existingUserMessageId) {
118
+ userMsgId = existingUserMessageId;
119
+ } else {
120
+ userMsgId = crypto.randomUUID();
121
+
122
+ const userMessage = {
123
+ id: userMsgId,
124
+ sessionId,
125
+ role: 'user' as const,
126
+ createdAt: Date.now(),
127
+ };
128
+ await createMessage(userMessage);
129
+
130
+ const textPartId = crypto.randomUUID();
131
+ const textPart = {
132
+ id: textPartId,
133
+ messageId: userMsgId,
134
+ createdAt: Date.now(),
135
+ type: 'text' as const,
136
+ text: content,
137
+ };
138
+ await createPart(textPart, sessionId);
139
+
140
+ deliverToSession(ctx, sessionId, { kind: 'message', action: 'created', message: userMessage });
141
+ deliverToSession(ctx, sessionId, { kind: 'part', action: 'created', sessionId, part: textPart });
142
+ void regenerateSessionTitle(ctx, origin, sessionId);
143
+ }
144
+
145
+ if (attachments && attachments.length > 0) {
146
+ for (const attachment of attachments) {
147
+ const attachmentRecord = await getAttachment(sessionId, attachment.id);
148
+ if (!attachmentRecord) continue;
149
+
150
+ const partId = crypto.randomUUID();
151
+ const serverUrl = `/api/sessions/${sessionId}/attachments/${attachmentRecord.id}/content?key=${attachmentRecord.accessKey}`;
152
+
153
+ if (attachmentRecord.kind === 'image') {
154
+ const imagePart = {
155
+ id: partId,
156
+ messageId: userMsgId,
157
+ createdAt: Date.now(),
158
+ type: 'image' as const,
159
+ url: serverUrl,
160
+ mimeType: attachmentRecord.mimeType,
161
+ };
162
+ await createPart(imagePart, sessionId);
163
+ deliverToSession(ctx, sessionId, { kind: 'part', action: 'created', sessionId, part: imagePart });
164
+ } else {
165
+ const filePart = {
166
+ id: partId,
167
+ messageId: userMsgId,
168
+ createdAt: Date.now(),
169
+ type: 'file' as const,
170
+ url: serverUrl,
171
+ mimeType: attachmentRecord.mimeType,
172
+ filename: attachmentRecord.filename,
173
+ };
174
+ await createPart(filePart, sessionId);
175
+ deliverToSession(ctx, sessionId, { kind: 'part', action: 'created', sessionId, part: filePart });
176
+ }
177
+ }
178
+ }
179
+
180
+ const { messages: history } = await buildEffectiveContextHistory(sessionId);
181
+
182
+ const askBroadcastFn: AskBroadcastFn = (message) => {
183
+ if (message.type === 'ask.request') {
184
+ const authority = message.authority ?? { visibilityScope: 'controller_only' as const, resolutionMode: 'controller_only' as const };
185
+ deliver(ctx, { scope: 'ask_targets', sessionId, authority }, {
186
+ kind: 'ask',
187
+ action: 'requested',
188
+ sessionId: message.sessionId,
189
+ toolCallId: message.toolCallId,
190
+ toolName: message.toolName,
191
+ ask: message.ask,
192
+ requestId: message.requestId,
193
+ authority: message.authority,
194
+ });
195
+ } else {
196
+ deliver(ctx, { scope: 'controller', sessionId }, {
197
+ kind: 'ask',
198
+ action: 'timed_out',
199
+ sessionId: message.sessionId,
200
+ toolCallId: message.toolCallId,
201
+ requestId: message.requestId,
202
+ });
203
+ }
204
+ };
205
+
206
+ let pendingCompaction = false;
207
+ let retryCancelled = false;
208
+ const effectiveProvider = isSandboxActive() ? 'sandbox' : provider;
209
+
210
+ try {
211
+ for await (const event of streamChatWithRetry({
212
+ sessionId,
213
+ preconfig,
214
+ messages: history,
215
+ modelId: modelId,
216
+ providerId: effectiveProvider,
217
+ variant: session.selectedVariant || undefined,
218
+ workspacePath: workspacePath ?? undefined,
219
+ workspaceId: session.workspaceId || undefined,
220
+ additionalPaths,
221
+ broadcastFn: askBroadcastFn,
222
+ responseFormat,
223
+ })) {
224
+ switch (event.type) {
225
+ case 'message.created':
226
+ deliverToSession(ctx, sessionId, { kind: 'message', action: 'created', message: event.message });
227
+ break;
228
+
229
+ case 'message.updated':
230
+ updateMessage(event.message.id, event.message, { syncFts: false });
231
+ if (event.message.role === 'assistant' && event.message.mode !== 'retry_failed') {
232
+ emitTerminal(event.message, sessionId);
233
+ }
234
+ deliverToSession(ctx, sessionId, { kind: 'message', action: 'updated', message: event.message });
235
+ break;
236
+
237
+ case 'part.created':
238
+ deliverToSession(ctx, sessionId, { kind: 'part', action: 'created', sessionId, part: event.part });
239
+ break;
240
+
241
+ case 'part.updated':
242
+ deliverToSession(ctx, sessionId, { kind: 'part', action: 'updated', sessionId, part: event.part });
243
+ break;
244
+
245
+ case 'part.append':
246
+ deliverToSession(ctx, sessionId, {
247
+ kind: 'part',
248
+ action: 'append',
249
+ sessionId,
250
+ partId: event.partId,
251
+ field: event.field,
252
+ delta: event.delta,
253
+ });
254
+ break;
255
+
256
+ case 'usage': {
257
+ deliverToSession(ctx, sessionId, {
258
+ kind: 'usage',
259
+ sessionId,
260
+ usage: event.usage,
261
+ model: event.model,
262
+ variant: event.variant ?? undefined,
263
+ });
264
+ updateSession(sessionId, {
265
+ promptTokens: event.usage.promptTokens,
266
+ completionTokens: event.usage.completionTokens,
267
+ totalTokens: event.usage.totalTokens,
268
+ cacheReadTokens: event.usage.cacheReadTokens,
269
+ cacheWriteTokens: event.usage.cacheWriteTokens,
270
+ noCacheTokens: event.usage.noCacheTokens,
271
+ });
272
+ break;
273
+ }
274
+
275
+ case 'needs_compaction':
276
+ pendingCompaction = true;
277
+ break;
278
+
279
+ case 'chat.retry':
280
+ deliverToSession(ctx, sessionId, {
281
+ kind: 'retry',
282
+ sessionId: event.sessionId,
283
+ status: event.status,
284
+ attempt: event.retryNumber,
285
+ maxAttempts: event.maxRetries,
286
+ errorType: event.errorType,
287
+ message: event.message,
288
+ delayMs: event.delayMs,
289
+ retryAt: event.retryAt,
290
+ });
291
+ if (event.status === 'cancelled') {
292
+ retryCancelled = true;
293
+ }
294
+ break;
295
+
296
+ case 'error.rate_limit':
297
+ deliverToOrigin(ctx, origin, {
298
+ kind: 'failure',
299
+ category: 'rate_limit',
300
+ code: 'rate_limit',
301
+ message: event.message,
302
+ retryAfterMs: event.retryAfterMs,
303
+ sessionId,
304
+ });
305
+ return {
306
+ streamCompleted: false,
307
+ interrupted: false,
308
+ needsAutoCompaction: false,
309
+ contextOverflow: false,
310
+ isFatal: true,
311
+ isQueueDrainable: false,
312
+ errorMessage: event.message,
313
+ errorType: 'rate_limit',
314
+ retryAfterMs: event.retryAfterMs,
315
+ };
316
+
317
+ case 'error.server':
318
+ deliverToOrigin(ctx, origin, {
319
+ kind: 'failure',
320
+ category: 'server',
321
+ code: 'server_error',
322
+ message: event.message,
323
+ retryAfterMs: event.retryAfterMs,
324
+ sessionId,
325
+ });
326
+ return {
327
+ streamCompleted: false,
328
+ interrupted: false,
329
+ needsAutoCompaction: false,
330
+ contextOverflow: false,
331
+ isFatal: false,
332
+ isQueueDrainable: true,
333
+ errorMessage: event.message,
334
+ errorType: 'server',
335
+ retryAfterMs: event.retryAfterMs,
336
+ };
337
+
338
+ case 'error.timeout':
339
+ deliverToOrigin(ctx, origin, {
340
+ kind: 'failure',
341
+ category: 'timeout',
342
+ code: 'timeout',
343
+ message: event.message,
344
+ retryAfterMs: event.retryAfterMs,
345
+ sessionId,
346
+ });
347
+ return {
348
+ streamCompleted: false,
349
+ interrupted: false,
350
+ needsAutoCompaction: false,
351
+ contextOverflow: false,
352
+ isFatal: false,
353
+ isQueueDrainable: true,
354
+ errorMessage: event.message,
355
+ errorType: 'timeout',
356
+ retryAfterMs: event.retryAfterMs,
357
+ };
358
+
359
+ case 'error':
360
+ deliverToOrigin(ctx, origin, {
361
+ kind: 'failure',
362
+ category: 'generic',
363
+ code: event.code,
364
+ message: event.message,
365
+ sessionId,
366
+ });
367
+ return {
368
+ streamCompleted: false,
369
+ interrupted: false,
370
+ needsAutoCompaction: false,
371
+ contextOverflow: false,
372
+ isFatal: true,
373
+ isQueueDrainable: false,
374
+ errorMessage: event.message,
375
+ errorType: 'server',
376
+ };
377
+
378
+ case 'error.auth':
379
+ deliverToOrigin(ctx, origin, {
380
+ kind: 'failure',
381
+ category: 'generic',
382
+ code: 'authentication',
383
+ message: event.message,
384
+ });
385
+ return {
386
+ streamCompleted: false,
387
+ interrupted: false,
388
+ needsAutoCompaction: false,
389
+ contextOverflow: false,
390
+ isFatal: true,
391
+ isQueueDrainable: false,
392
+ errorMessage: event.message,
393
+ errorType: 'auth',
394
+ };
395
+
396
+ case 'error.context_overflow': {
397
+ return {
398
+ streamCompleted: false,
399
+ interrupted: false,
400
+ needsAutoCompaction: false,
401
+ contextOverflow: true,
402
+ isFatal: false,
403
+ isQueueDrainable: false,
404
+ errorMessage: event.message,
405
+ errorType: 'context_overflow',
406
+ };
407
+ }
408
+
409
+ case 'error.invalid_request':
410
+ deliverToOrigin(ctx, origin, {
411
+ kind: 'failure',
412
+ category: 'generic',
413
+ code: 'invalid_request',
414
+ message: event.message,
415
+ });
416
+ return {
417
+ streamCompleted: false,
418
+ interrupted: false,
419
+ needsAutoCompaction: false,
420
+ contextOverflow: false,
421
+ isFatal: true,
422
+ isQueueDrainable: false,
423
+ errorMessage: event.message,
424
+ errorType: 'invalid_request',
425
+ };
426
+ }
427
+ }
428
+
429
+ const wasInterrupted = await (async () => {
430
+ const msgs = await listMessagesWithParts(sessionId);
431
+ const lastAssistant = [...msgs].reverse().find(m => m.message.role === 'assistant');
432
+ return lastAssistant && 'status' in lastAssistant.message
433
+ ? lastAssistant.message.status === 'interrupted'
434
+ : false;
435
+ })();
436
+
437
+ return {
438
+ streamCompleted: !retryCancelled,
439
+ interrupted: wasInterrupted || retryCancelled,
440
+ needsAutoCompaction: pendingCompaction,
441
+ contextOverflow: false,
442
+ isFatal: false,
443
+ isQueueDrainable: false,
444
+ };
445
+ } catch (err: unknown) {
446
+ const message = err instanceof Error ? err.message : 'Chat failed';
447
+ console.error('Unexpected chat error:', err);
448
+ deliverToOrigin(ctx, origin, { kind: 'failure', category: 'generic', code: 'chat_error', message });
449
+ return {
450
+ streamCompleted: false,
451
+ interrupted: false,
452
+ needsAutoCompaction: false,
453
+ contextOverflow: false,
454
+ isFatal: true,
455
+ isQueueDrainable: false,
456
+ errorMessage: message,
457
+ errorType: 'server',
458
+ };
459
+ }
460
+ }
461
+
462
+ export async function regenerateSessionTitle<Origin>(
463
+ ctx: RuntimeRequestContext<Origin>,
464
+ origin: Origin,
465
+ sessionId: string,
466
+ options?: { force?: boolean },
467
+ ): Promise<void> {
468
+ const session = await getSession(sessionId);
469
+ if (!session) {
470
+ console.warn('[session-title] Skipping title generation: session not found', sessionId);
471
+ return;
472
+ }
473
+ if (!options?.force && (!isDefaultSessionTitle(session.title) || hasManualSessionTitle(session.metadata))) {
474
+ console.info('[session-title] Skipping auto title generation', {
475
+ sessionId,
476
+ title: session.title,
477
+ manuallyRenamed: hasManualSessionTitle(session.metadata),
478
+ });
479
+ return;
480
+ }
481
+
482
+ try {
483
+ const messages = await listMessagesWithParts(sessionId);
484
+ console.info('[session-title] Generating session title', {
485
+ sessionId,
486
+ force: options?.force === true,
487
+ messageCount: messages.length,
488
+ });
489
+ const title = await generateSessionTitle(messages);
490
+ if (!title) {
491
+ console.warn('[session-title] Skipping title update: no title generated', sessionId);
492
+ deliverToOrigin(ctx, origin, {
493
+ kind: 'failure',
494
+ category: 'generic',
495
+ code: 'title_generation_error',
496
+ message: 'Could not generate a title from the conversation.',
497
+ sessionId,
498
+ });
499
+ return;
500
+ }
501
+ const updated = await updateSession(sessionId, { title });
502
+ if (updated) {
503
+ console.info('[session-title] Updated session title', { sessionId, title });
504
+ deliverToSession(ctx, sessionId, { kind: 'session', action: 'renamed', session: updated });
505
+ }
506
+ } catch (err: unknown) {
507
+ const message = err instanceof Error ? err.message : String(err);
508
+ console.warn('[session-title] Failed to generate session title', { sessionId, message });
509
+ deliverToOrigin(ctx, origin, {
510
+ kind: 'failure',
511
+ category: 'generic',
512
+ code: 'title_generation_error',
513
+ message: `Title generation failed: ${message}`,
514
+ sessionId,
515
+ });
516
+ }
517
+ }
518
+
519
+ // ── Chat handler ───────────────────────────────────────────────
520
+
521
+ export async function handleChat<Origin>(
522
+ ctx: RuntimeRequestContext<Origin>,
523
+ origin: Origin,
524
+ sessionId: string,
525
+ content: string,
526
+ attachments?: Array<{ id: string; kind: string }>,
527
+ responseFormatId?: string,
528
+ goalCondition?: string,
529
+ goalMaxTurns?: number,
530
+ ): Promise<void> {
531
+ const session = await getSession(sessionId);
532
+ if (!session) {
533
+ deliverToOrigin(ctx, origin, { kind: 'failure', category: 'generic', code: 'not_found', message: 'Session not found' });
534
+ return;
535
+ }
536
+
537
+ if (session.status === 'closed') {
538
+ deliverToOrigin(ctx, origin, {
539
+ kind: 'failure',
540
+ category: 'generic',
541
+ code: 'session_closed',
542
+ message: 'Cannot send messages to an archived session. Reopen it first.',
543
+ });
544
+ return;
545
+ }
546
+
547
+ if (interruptManager.isSessionActive(sessionId)) {
548
+ const queuedMessage = await addMessageToQueue(sessionId, content, attachments);
549
+ ctx.attachOriginToSession(origin, sessionId);
550
+ deliverToOrigin(ctx, origin, { kind: 'queue', action: 'added', sessionId, message: queuedMessage });
551
+ return;
552
+ }
553
+
554
+ const workspace = session.workspaceId ? await getWorkspace(session.workspaceId) : null;
555
+ const workspacePath = workspace?.path;
556
+ const additionalPaths = workspace?.additionalPaths;
557
+
558
+ const preconfig = session.preconfigId
559
+ ? await getPreconfigOrAgent(session.preconfigId)
560
+ : await getDefaultPreconfig();
561
+
562
+ if (!preconfig) {
563
+ deliverToOrigin(ctx, origin, { kind: 'failure', category: 'generic', code: 'no_preconfig', message: 'No preconfig found' });
564
+ return;
565
+ }
566
+
567
+ const modelId = resolveModelId(session, preconfig);
568
+ const provider = resolveProviderId(session, preconfig);
569
+
570
+ const apiKey = getApiKeyForProvider(provider);
571
+
572
+ const isConnectableProvider = getProvider(provider) !== undefined;
573
+ if (!apiKey && !isConnectableProvider) {
574
+ deliverToOrigin(ctx, origin, {
575
+ kind: 'failure',
576
+ category: 'generic',
577
+ code: 'no_api_key',
578
+ message: `No API key configured for provider: ${provider}. Register the provider or configure its API key in runtime configuration.`,
579
+ });
580
+ return;
581
+ }
582
+
583
+ const responseFormatRecord = responseFormatId ? await getResponseFormat(responseFormatId) : null;
584
+ const responseFormat = responseFormatRecord ?? undefined;
585
+
586
+ if (goalCondition) {
587
+ const goalAbortController = new AbortController();
588
+ const checkInterval = setInterval(() => {
589
+ if (interruptManager.isSessionInterrupted(sessionId) && !goalAbortController.signal.aborted) {
590
+ goalAbortController.abort(new Error('Goal loop cancelled by user'));
591
+ }
592
+ }, 200);
593
+
594
+ try {
595
+ await getGoalDomain().runGoalLoop({
596
+ sessionId,
597
+ condition: goalCondition,
598
+ initialPrompt: content,
599
+ maxTurns: goalMaxTurns,
600
+ abortSignal: goalAbortController.signal,
601
+ broadcast: emitRuntimeEvent,
602
+ runTurn: async (turnContent: string) => {
603
+ const result = await runSingleChatTurn(
604
+ ctx, origin, sessionId, turnContent, preconfig, modelId, provider,
605
+ workspacePath, additionalPaths, session, undefined, responseFormat,
606
+ );
607
+ return {
608
+ streamCompleted: result.streamCompleted,
609
+ interrupted: result.interrupted,
610
+ };
611
+ },
612
+ });
613
+ } finally {
614
+ clearInterval(checkInterval);
615
+ }
616
+ return;
617
+ }
618
+
619
+ let currentContent: string = content;
620
+ let currentAttachments: Array<{ id: string; kind: string }> | undefined = attachments;
621
+ let overflowRetryDepth = 0;
622
+
623
+ while (true) {
624
+ const result = await runSingleChatTurn(
625
+ ctx,
626
+ origin,
627
+ sessionId,
628
+ currentContent,
629
+ preconfig,
630
+ modelId,
631
+ provider,
632
+ workspacePath,
633
+ additionalPaths,
634
+ session,
635
+ currentAttachments,
636
+ responseFormat,
637
+ );
638
+
639
+ if (result.contextOverflow) {
640
+ if (overflowRetryDepth >= 1) {
641
+ deliverToOrigin(ctx, origin, {
642
+ kind: 'failure',
643
+ category: 'generic',
644
+ code: 'context_overflow',
645
+ message: result.errorMessage ?? 'Context overflow',
646
+ });
647
+ return;
648
+ }
649
+
650
+ const currentSession = await getSession(sessionId);
651
+ const isMainSession = currentSession && !currentSession.parentId;
652
+
653
+ if (isMainSession && !getCompactionService().shouldSkipCompaction(sessionId)) {
654
+ const replayText = await getCompactionService().buildReplayText(sessionId);
655
+ const execResult = await executeCompaction(sessionId, 'overflow');
656
+
657
+ if (execResult.ok) {
658
+ getCompactionService().clearCompactionFailure(sessionId);
659
+ overflowRetryDepth++;
660
+ currentContent = replayText ?? 'Continue from where we left off, using the compacted context.';
661
+ continue;
662
+ } else if (!execResult.skipped) {
663
+ getCompactionService().recordCompactionFailure(sessionId);
664
+ console.warn(`[handleChat] Overflow compaction failed for session ${sessionId}: ${execResult.error}`);
665
+ }
666
+ }
667
+
668
+ deliverToOrigin(ctx, origin, {
669
+ kind: 'failure',
670
+ category: 'generic',
671
+ code: 'context_overflow',
672
+ message: result.errorMessage ?? 'Context overflow',
673
+ });
674
+ return;
675
+ }
676
+
677
+ if (result.isFatal || result.interrupted) {
678
+ return;
679
+ }
680
+
681
+ if (result.isQueueDrainable) {
682
+ const next = await drainQueue(ctx, sessionId);
683
+ if (next) {
684
+ currentContent = next.content;
685
+ currentAttachments = next.attachments;
686
+ continue;
687
+ }
688
+ }
689
+
690
+ if (result.streamCompleted && result.needsAutoCompaction) {
691
+ const currentSession = await getSession(sessionId);
692
+ if (currentSession && !currentSession.parentId && !getCompactionService().shouldSkipCompaction(sessionId)) {
693
+ const execResult = await executeCompaction(sessionId, 'auto');
694
+ if (execResult.ok) {
695
+ getCompactionService().clearCompactionFailure(sessionId);
696
+ } else if (!execResult.skipped) {
697
+ getCompactionService().recordCompactionFailure(sessionId);
698
+ console.warn(`[handleChat] Auto-compaction failed for session ${sessionId}: ${execResult.error}`);
699
+ }
700
+ }
701
+ const next = await drainQueue(ctx, sessionId);
702
+ if (next) {
703
+ currentContent = next.content;
704
+ currentAttachments = next.attachments;
705
+ continue;
706
+ }
707
+ return;
708
+ }
709
+
710
+ if (result.streamCompleted) {
711
+ const next = await drainQueue(ctx, sessionId);
712
+ if (next) {
713
+ currentContent = next.content;
714
+ currentAttachments = next.attachments;
715
+ continue;
716
+ }
717
+ return;
718
+ }
719
+
720
+ return;
721
+ }
722
+ }
723
+
724
+ export async function handleSessionEditMessage<Origin>(
725
+ ctx: RuntimeRequestContext<Origin>,
726
+ origin: Origin,
727
+ msg: { sessionId: string; messageId: string; content: string },
728
+ ): Promise<void> {
729
+ try {
730
+ const session = await getSession(msg.sessionId);
731
+ if (!session) {
732
+ deliverToOrigin(ctx, origin, {
733
+ kind: 'failure',
734
+ category: 'generic',
735
+ code: 'not_found',
736
+ message: 'Session not found',
737
+ sessionId: msg.sessionId,
738
+ });
739
+ return;
740
+ }
741
+
742
+ if (session.status === 'closed') {
743
+ deliverToOrigin(ctx, origin, {
744
+ kind: 'failure',
745
+ category: 'generic',
746
+ code: 'session_closed',
747
+ message: 'Cannot edit messages in an archived session.',
748
+ sessionId: msg.sessionId,
749
+ });
750
+ return;
751
+ }
752
+
753
+ if (interruptManager.isSessionActive(msg.sessionId)) {
754
+ deliverToOrigin(ctx, origin, {
755
+ kind: 'failure',
756
+ category: 'generic',
757
+ code: 'session_busy',
758
+ message: 'Cannot edit while the session is streaming.',
759
+ sessionId: msg.sessionId,
760
+ });
761
+ return;
762
+ }
763
+
764
+ const target = await getMessage(msg.messageId);
765
+ if (!target || target.sessionId !== msg.sessionId || target.role !== 'user') {
766
+ deliverToOrigin(ctx, origin, {
767
+ kind: 'failure',
768
+ category: 'generic',
769
+ code: 'invalid_message',
770
+ message: 'Only user messages can be edited.',
771
+ sessionId: msg.sessionId,
772
+ });
773
+ return;
774
+ }
775
+
776
+ const parts = await getPartsByMessage(msg.messageId);
777
+ const textPart = parts.find((p) => p.type === 'text');
778
+ if (!textPart || textPart.type !== 'text') {
779
+ deliverToOrigin(ctx, origin, {
780
+ kind: 'failure',
781
+ category: 'generic',
782
+ code: 'invalid_message',
783
+ message: 'Message has no editable text.',
784
+ sessionId: msg.sessionId,
785
+ });
786
+ return;
787
+ }
788
+ const updatedPart = await updatePart(textPart.id, { text: msg.content });
789
+ if (updatedPart) {
790
+ deliverToSession(ctx, msg.sessionId, {
791
+ kind: 'part',
792
+ action: 'updated',
793
+ sessionId: msg.sessionId,
794
+ part: updatedPart,
795
+ });
796
+ }
797
+
798
+ await revertToStep({
799
+ sessionId: msg.sessionId,
800
+ targetMessageId: msg.messageId,
801
+ keepTarget: true,
802
+ });
803
+
804
+ const currentState = await listLatestMessagesWithPartsPage(msg.sessionId, 50);
805
+ deliverToSession(ctx, msg.sessionId, {
806
+ kind: 'session',
807
+ action: 'state',
808
+ sessionId: msg.sessionId,
809
+ messages: currentState.messages,
810
+ });
811
+
812
+ const workspace = session.workspaceId ? await getWorkspace(session.workspaceId) : null;
813
+ const workspacePath = workspace?.path;
814
+ const additionalPaths = workspace?.additionalPaths;
815
+
816
+ const preconfig = session.preconfigId
817
+ ? await getPreconfigOrAgent(session.preconfigId)
818
+ : await getDefaultPreconfig();
819
+ if (!preconfig) {
820
+ deliverToOrigin(ctx, origin, {
821
+ kind: 'failure',
822
+ category: 'generic',
823
+ code: 'no_preconfig',
824
+ message: 'No preconfig found',
825
+ sessionId: msg.sessionId,
826
+ });
827
+ return;
828
+ }
829
+
830
+ const modelId = resolveModelId(session, preconfig);
831
+ const provider = resolveProviderId(session, preconfig);
832
+
833
+ await runSingleChatTurn(
834
+ ctx,
835
+ origin,
836
+ msg.sessionId,
837
+ msg.content,
838
+ preconfig,
839
+ modelId,
840
+ provider,
841
+ workspacePath,
842
+ additionalPaths,
843
+ session,
844
+ undefined,
845
+ undefined,
846
+ msg.messageId,
847
+ );
848
+ } catch (error) {
849
+ const message = error instanceof Error ? error.message : 'Edit failed';
850
+ deliverToOrigin(ctx, origin, {
851
+ kind: 'failure',
852
+ category: 'generic',
853
+ code: 'edit_error',
854
+ message,
855
+ sessionId: msg.sessionId,
856
+ });
857
+ }
858
+ }