@ottocode/server 0.1.173

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 (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,468 @@
1
+ import { generateText, streamText } from 'ai';
2
+ import { eq, asc } from 'drizzle-orm';
3
+ import type { OttoConfig } from '@ottocode/sdk';
4
+ import type { DB } from '@ottocode/database';
5
+ import { messages, messageParts, sessions } from '@ottocode/database/schema';
6
+ import { publish } from '../../events/bus.ts';
7
+ import { enqueueAssistantRun } from '../session/queue.ts';
8
+ import { runSessionLoop } from '../agent/runner.ts';
9
+ import { resolveModel } from '../provider/index.ts';
10
+ import { getFastModelForAuth, type ProviderId } from '@ottocode/sdk';
11
+ import { debugLog } from '../debug/index.ts';
12
+ import { isCompactCommand, buildCompactionContext } from './compaction.ts';
13
+ import { detectOAuth, adaptSimpleCall } from '../provider/oauth-adapter.ts';
14
+
15
+ type SessionRow = typeof sessions.$inferSelect;
16
+
17
+ type DispatchOptions = {
18
+ cfg: OttoConfig;
19
+ db: DB;
20
+ session: SessionRow;
21
+ agent: string;
22
+ provider: ProviderId;
23
+ model: string;
24
+ content: string;
25
+ oneShot?: boolean;
26
+ userContext?: string;
27
+ reasoningText?: boolean;
28
+ images?: Array<{ data: string; mediaType: string }>;
29
+ files?: Array<{
30
+ type: 'image' | 'pdf' | 'text';
31
+ name: string;
32
+ data: string;
33
+ mediaType: string;
34
+ textContent?: string;
35
+ }>;
36
+ };
37
+
38
+ export async function dispatchAssistantMessage(
39
+ options: DispatchOptions,
40
+ ): Promise<{ assistantMessageId: string }> {
41
+ const {
42
+ cfg,
43
+ db,
44
+ session,
45
+ agent,
46
+ provider,
47
+ model,
48
+ content,
49
+ oneShot,
50
+ userContext,
51
+ reasoningText,
52
+ images,
53
+ files,
54
+ } = options;
55
+
56
+ debugLog(
57
+ `[MESSAGE_SERVICE] dispatchAssistantMessage called with userContext: ${userContext ? `${userContext.substring(0, 50)}...` : 'NONE'}`,
58
+ );
59
+
60
+ const sessionId = session.id;
61
+ const now = Date.now();
62
+ const userMessageId = crypto.randomUUID();
63
+
64
+ await db.insert(messages).values({
65
+ id: userMessageId,
66
+ sessionId,
67
+ role: 'user',
68
+ status: 'complete',
69
+ agent,
70
+ provider,
71
+ model,
72
+ createdAt: now,
73
+ });
74
+ await db.insert(messageParts).values({
75
+ id: crypto.randomUUID(),
76
+ messageId: userMessageId,
77
+ index: 0,
78
+ type: 'text',
79
+ content: JSON.stringify({ text: String(content) }),
80
+ agent,
81
+ provider,
82
+ model,
83
+ });
84
+
85
+ if (images && images.length > 0) {
86
+ for (let i = 0; i < images.length; i++) {
87
+ const img = images[i];
88
+ await db.insert(messageParts).values({
89
+ id: crypto.randomUUID(),
90
+ messageId: userMessageId,
91
+ index: i + 1,
92
+ type: 'image',
93
+ content: JSON.stringify({ data: img.data, mediaType: img.mediaType }),
94
+ agent,
95
+ provider,
96
+ model,
97
+ });
98
+ }
99
+ }
100
+
101
+ let nextIndex = (images?.length ?? 0) + 1;
102
+ if (files && files.length > 0) {
103
+ for (const file of files) {
104
+ const partType = file.type === 'image' ? 'image' : 'file';
105
+ await db.insert(messageParts).values({
106
+ id: crypto.randomUUID(),
107
+ messageId: userMessageId,
108
+ index: nextIndex++,
109
+ type: partType,
110
+ content: JSON.stringify({
111
+ type: file.type,
112
+ name: file.name,
113
+ data: file.data,
114
+ mediaType: file.mediaType,
115
+ textContent: file.textContent,
116
+ }),
117
+ agent,
118
+ provider,
119
+ model,
120
+ });
121
+ }
122
+ }
123
+
124
+ publish({
125
+ type: 'message.created',
126
+ sessionId,
127
+ payload: { id: userMessageId, role: 'user' },
128
+ });
129
+
130
+ const assistantMessageId = crypto.randomUUID();
131
+ await db.insert(messages).values({
132
+ id: assistantMessageId,
133
+ sessionId,
134
+ role: 'assistant',
135
+ status: 'pending',
136
+ agent,
137
+ provider,
138
+ model,
139
+ createdAt: Date.now(),
140
+ });
141
+ publish({
142
+ type: 'message.created',
143
+ sessionId,
144
+ payload: { id: assistantMessageId, role: 'assistant' },
145
+ });
146
+
147
+ debugLog(
148
+ `[MESSAGE_SERVICE] Enqueuing assistant run with userContext: ${userContext ? `${userContext.substring(0, 50)}...` : 'NONE'}`,
149
+ );
150
+
151
+ const isCompact = isCompactCommand(content);
152
+ let compactionContext: string | undefined;
153
+
154
+ if (isCompact) {
155
+ debugLog('[MESSAGE_SERVICE] Detected /compact command, building context');
156
+ const { getModelLimits } = await import('./compaction.ts');
157
+ const limits = getModelLimits(provider, model);
158
+ const contextTokenLimit = limits
159
+ ? Math.max(Math.floor(limits.context * 0.5), 15000)
160
+ : 15000;
161
+ compactionContext = await buildCompactionContext(
162
+ db,
163
+ sessionId,
164
+ contextTokenLimit,
165
+ );
166
+ debugLog(
167
+ `[message-service] /compact context length: ${compactionContext.length}, limit: ${contextTokenLimit} tokens`,
168
+ );
169
+ }
170
+
171
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
172
+
173
+ enqueueAssistantRun(
174
+ {
175
+ sessionId,
176
+ assistantMessageId,
177
+ agent,
178
+ provider,
179
+ model,
180
+ projectRoot: cfg.projectRoot,
181
+ oneShot: Boolean(oneShot),
182
+ userContext,
183
+ reasoningText,
184
+ isCompactCommand: isCompact,
185
+ compactionContext,
186
+ toolApprovalMode,
187
+ },
188
+ runSessionLoop,
189
+ );
190
+
191
+ void touchSessionLastActive({ db, sessionId });
192
+
193
+ return { assistantMessageId };
194
+ }
195
+
196
+ const TITLE_CONCURRENCY_LIMIT = 1;
197
+ const titleQueue: Array<() => void> = [];
198
+ let titleActiveCount = 0;
199
+ const titleInFlight = new Set<string>();
200
+ const titlePending = new Set<string>();
201
+
202
+ function scheduleSessionTitle(args: {
203
+ cfg: OttoConfig;
204
+ db: DB;
205
+ sessionId: string;
206
+ content: unknown;
207
+ }) {
208
+ const { cfg, db, sessionId, content } = args;
209
+
210
+ if (titleInFlight.has(sessionId) || titlePending.has(sessionId)) {
211
+ return;
212
+ }
213
+
214
+ const processNext = () => {
215
+ if (titleQueue.length === 0) {
216
+ return;
217
+ }
218
+ if (titleActiveCount >= TITLE_CONCURRENCY_LIMIT) {
219
+ return;
220
+ }
221
+ const next = titleQueue.shift();
222
+ if (!next) return;
223
+ titleActiveCount++;
224
+ next();
225
+ };
226
+
227
+ const task = async () => {
228
+ titleInFlight.add(sessionId);
229
+ titlePending.delete(sessionId);
230
+ try {
231
+ await generateSessionTitle({ cfg, db, sessionId, content });
232
+ } catch (err) {
233
+ debugLog('[TITLE_GEN] Title generation error:');
234
+ debugLog(err);
235
+ } finally {
236
+ titleInFlight.delete(sessionId);
237
+ titleActiveCount--;
238
+ processNext();
239
+ }
240
+ };
241
+
242
+ titlePending.add(sessionId);
243
+ titleQueue.push(task);
244
+ processNext();
245
+ }
246
+
247
+ function enqueueSessionTitle(args: {
248
+ cfg: OttoConfig;
249
+ db: DB;
250
+ sessionId: string;
251
+ content: unknown;
252
+ }) {
253
+ scheduleSessionTitle(args);
254
+ }
255
+
256
+ async function generateSessionTitle(args: {
257
+ cfg: OttoConfig;
258
+ db: DB;
259
+ sessionId: string;
260
+ content: unknown;
261
+ }): Promise<void> {
262
+ const { cfg, db, sessionId, content } = args;
263
+
264
+ try {
265
+ const existingSession = await db
266
+ .select()
267
+ .from(sessions)
268
+ .where(eq(sessions.id, sessionId));
269
+
270
+ if (!existingSession.length) {
271
+ debugLog('[TITLE_GEN] Session not found, aborting');
272
+ return;
273
+ }
274
+
275
+ const sess = existingSession[0];
276
+ if (sess.title && sess.title !== 'New Session') {
277
+ debugLog('[TITLE_GEN] Session already has a title, skipping');
278
+ return;
279
+ }
280
+
281
+ const provider = sess.provider ?? cfg.defaults.provider;
282
+ const modelName = sess.model ?? cfg.defaults.model;
283
+
284
+ debugLog('[TITLE_GEN] Generating title for session');
285
+ debugLog(`[TITLE_GEN] Provider: ${provider}, Model: ${modelName}`);
286
+
287
+ const { getAuth } = await import('@ottocode/sdk');
288
+ const auth = await getAuth(provider, cfg.projectRoot);
289
+ const oauth = detectOAuth(provider, auth);
290
+
291
+ const titleModel = getFastModelForAuth(provider, auth?.type) ?? modelName;
292
+ debugLog(`[TITLE_GEN] Using title model: ${titleModel}`);
293
+ const model = await resolveModel(provider, titleModel, cfg);
294
+
295
+ debugLog(
296
+ `[TITLE_GEN] oauth: needsSpoof=${oauth.needsSpoof}, isOpenAIOAuth=${oauth.isOpenAIOAuth}`,
297
+ );
298
+
299
+ const promptText = String(content ?? '').slice(0, 2000);
300
+
301
+ const titleInstructions = `Generate a brief title (6-8 words) summarizing what the user wants to do.
302
+ Rules: Plain text only. No markdown, no quotes, no punctuation, no emojis.
303
+ Focus on the core task or topic. Be specific but concise.
304
+ Examples: "Fix TypeScript build errors", "Add dark mode toggle", "Refactor auth middleware"
305
+
306
+ Output ONLY the title, nothing else.`;
307
+
308
+ const adapted = adaptSimpleCall(oauth, {
309
+ instructions: titleInstructions,
310
+ userContent: promptText,
311
+ });
312
+
313
+ debugLog(
314
+ `[TITLE_GEN] mode=${adapted.forceStream ? 'openai-oauth' : oauth.needsSpoof ? 'spoof' : 'api-key'}`,
315
+ );
316
+
317
+ let modelTitle = '';
318
+ try {
319
+ if (adapted.forceStream || oauth.needsSpoof) {
320
+ const result = streamText({
321
+ model,
322
+ system: adapted.system,
323
+ messages: adapted.messages,
324
+ providerOptions: adapted.providerOptions,
325
+ });
326
+ for await (const chunk of result.textStream) {
327
+ modelTitle += chunk;
328
+ }
329
+ modelTitle = modelTitle.trim();
330
+ } else {
331
+ debugLog('[TITLE_GEN] Using generateText...');
332
+ const out = await generateText({
333
+ model,
334
+ system: adapted.system,
335
+ messages: adapted.messages,
336
+ });
337
+ modelTitle = (out?.text || '').trim();
338
+ }
339
+
340
+ debugLog('[TITLE_GEN] Raw response from model:');
341
+ debugLog(`[TITLE_GEN] "${modelTitle}"`);
342
+ } catch (err) {
343
+ debugLog('[TITLE_GEN] Error generating title:');
344
+ debugLog(err);
345
+ }
346
+
347
+ if (!modelTitle) {
348
+ debugLog('[TITLE_GEN] No title returned, aborting');
349
+ return;
350
+ }
351
+
352
+ const sanitized = sanitizeTitle(modelTitle);
353
+ debugLog(`[TITLE_GEN] After sanitization: "${sanitized}"`);
354
+
355
+ if (!sanitized || sanitized === 'New Session') {
356
+ debugLog('[TITLE_GEN] Sanitized title is empty or default, aborting');
357
+ return;
358
+ }
359
+
360
+ await db
361
+ .update(sessions)
362
+ .set({ title: sanitized, updatedAt: Date.now() })
363
+ .where(eq(sessions.id, sessionId));
364
+
365
+ debugLog(`[TITLE_GEN] Setting final title: "${sanitized}"`);
366
+
367
+ publish({
368
+ type: 'session.updated',
369
+ sessionId,
370
+ payload: { id: sessionId, title: sanitized },
371
+ });
372
+ } catch (err) {
373
+ debugLog('[TITLE_GEN] Error in generateSessionTitle:');
374
+ debugLog(err);
375
+ }
376
+ }
377
+
378
+ function sanitizeTitle(raw: string): string {
379
+ let s = raw.trim();
380
+ s = s.replace(/^#+\s*/, '');
381
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
382
+ s = s.replace(/\*([^*]+)\*/g, '$1');
383
+ s = s.replace(/__([^_]+)__/g, '$1');
384
+ s = s.replace(/_([^_]+)_/g, '$1');
385
+ s = s.replace(/`([^`]+)`/g, '$1');
386
+ s = s.replace(/~~([^~]+)~~/g, '$1');
387
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
388
+ s = s.replace(/^["']|["']$/g, '');
389
+ s = s.replace(/^[-–—•*]\s*/, '');
390
+ s = s.replace(/[.!?:;,]+$/, '');
391
+ s = s.replace(/\s+/g, ' ');
392
+ if (s.length > 80) s = s.slice(0, 80).trim();
393
+ return s;
394
+ }
395
+
396
+ async function touchSessionLastActive(args: {
397
+ db: DB;
398
+ sessionId: string;
399
+ }): Promise<void> {
400
+ const { db, sessionId } = args;
401
+ try {
402
+ await db
403
+ .update(sessions)
404
+ .set({ lastActiveAt: Date.now() })
405
+ .where(eq(sessions.id, sessionId))
406
+ .run();
407
+ } catch (err) {
408
+ debugLog('[touchSessionLastActive] Error:', err);
409
+ }
410
+ }
411
+
412
+ export async function triggerDeferredTitleGeneration(args: {
413
+ cfg: OttoConfig;
414
+ db: DB;
415
+ sessionId: string;
416
+ }): Promise<void> {
417
+ const { cfg, db, sessionId } = args;
418
+
419
+ try {
420
+ const userMessages = await db
421
+ .select()
422
+ .from(messages)
423
+ .where(eq(messages.sessionId, sessionId))
424
+ .orderBy(asc(messages.createdAt))
425
+ .limit(1);
426
+
427
+ if (!userMessages.length || userMessages[0].role !== 'user') {
428
+ debugLog(
429
+ '[TITLE_GEN] No user message found for deferred title generation',
430
+ );
431
+ return;
432
+ }
433
+
434
+ const parts = await db
435
+ .select()
436
+ .from(messageParts)
437
+ .where(eq(messageParts.messageId, userMessages[0].id))
438
+ .orderBy(asc(messageParts.index))
439
+ .limit(1);
440
+
441
+ if (!parts.length) {
442
+ debugLog(
443
+ '[TITLE_GEN] No message parts found for deferred title generation',
444
+ );
445
+ return;
446
+ }
447
+
448
+ let content = '';
449
+ try {
450
+ const parsed = JSON.parse(parts[0].content ?? '{}');
451
+ content = String(parsed.text ?? '');
452
+ } catch {
453
+ debugLog('[TITLE_GEN] Failed to parse message part content');
454
+ return;
455
+ }
456
+
457
+ if (!content) {
458
+ debugLog('[TITLE_GEN] Empty content for deferred title generation');
459
+ return;
460
+ }
461
+
462
+ debugLog('[TITLE_GEN] Triggering deferred title generation');
463
+ enqueueSessionTitle({ cfg, db, sessionId, content });
464
+ } catch (err) {
465
+ debugLog('[TITLE_GEN] Error in triggerDeferredTitleGeneration:');
466
+ debugLog(err);
467
+ }
468
+ }
@@ -0,0 +1,204 @@
1
+ import { debugLog } from '../debug/index.ts';
2
+
3
+ type ToolResultPart = {
4
+ type: string;
5
+ state?: string;
6
+ toolCallId?: string;
7
+ input?: unknown;
8
+ output?: unknown;
9
+ [key: string]: unknown;
10
+ };
11
+
12
+ type ToolResultInfo = {
13
+ toolName: string;
14
+ callId: string;
15
+ args: unknown;
16
+ result: unknown;
17
+ };
18
+
19
+ type TargetDescriptor = {
20
+ keys: string[];
21
+ summary: string;
22
+ };
23
+
24
+ type TrackedPart = {
25
+ part: ToolResultPart;
26
+ summary: string;
27
+ summarized: boolean;
28
+ };
29
+
30
+ export class ToolHistoryTracker {
31
+ private readonly targets = new Map<string, TrackedPart>();
32
+
33
+ register(part: ToolResultPart, info: ToolResultInfo) {
34
+ const descriptor = describeToolResult(info);
35
+ if (!descriptor) return;
36
+
37
+ const entry: TrackedPart = {
38
+ part,
39
+ summary: descriptor.summary,
40
+ summarized: false,
41
+ };
42
+
43
+ for (const key of descriptor.keys) {
44
+ const previous = this.targets.get(key);
45
+ if (previous && previous.part !== part) {
46
+ this.applySummary(previous);
47
+ }
48
+ this.targets.set(key, entry);
49
+ }
50
+ }
51
+
52
+ private applySummary(entry: TrackedPart) {
53
+ if (entry.summarized) return;
54
+ // Keep this entry as a tool result so the history still produces tool_result blocks.
55
+ entry.part.state = 'output-available';
56
+ entry.part.output = entry.summary;
57
+ (entry.part as { summaryText?: string }).summaryText = entry.summary;
58
+ delete (entry.part as { errorText?: unknown }).errorText;
59
+ delete (entry.part as { rawInput?: unknown }).rawInput;
60
+ delete (entry.part as { callProviderMetadata?: unknown })
61
+ .callProviderMetadata;
62
+ delete (entry.part as { providerMetadata?: unknown }).providerMetadata;
63
+ entry.summarized = true;
64
+ debugLog(`[history] summarized tool output -> ${entry.summary}`);
65
+ }
66
+ }
67
+
68
+ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
69
+ const { toolName } = info;
70
+ switch (toolName) {
71
+ case 'read':
72
+ return describeRead(info);
73
+ case 'glob':
74
+ case 'grep':
75
+ return describePatternTool(info, toolName);
76
+ case 'write':
77
+ return describeWrite(info);
78
+ case 'apply_patch':
79
+ return describePatch(info);
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function describeRead(info: ToolResultInfo): TargetDescriptor | null {
86
+ const args = getRecord(info.args);
87
+ if (!args) return null;
88
+ const path = getString(args.path);
89
+ if (!path) return null;
90
+ const startLine = getNumber(args.startLine);
91
+ const endLine = getNumber(args.endLine);
92
+
93
+ let rangeLabel = 'entire file';
94
+ let rangeKey = 'all';
95
+ if (startLine !== undefined || endLine !== undefined) {
96
+ const start = startLine ?? 1;
97
+ const end = endLine ?? 'end';
98
+ rangeLabel = `lines ${start}–${end}`;
99
+ rangeKey = `${start}-${end}`;
100
+ }
101
+
102
+ const key = `read:${normalizePath(path)}:${rangeKey}`;
103
+ const summary = `[previous read] ${normalizePath(path)} (${rangeLabel})`;
104
+ return { keys: [key], summary };
105
+ }
106
+
107
+ function describePatternTool(
108
+ info: ToolResultInfo,
109
+ toolName: string,
110
+ ): TargetDescriptor | null {
111
+ const args = getRecord(info.args);
112
+ if (!args) return null;
113
+ const pattern =
114
+ getString(args.pattern) ??
115
+ getString(args.filePattern) ??
116
+ getString(args.path);
117
+ if (!pattern) return null;
118
+ const key = `${toolName}:${pattern}`;
119
+ const summary = `[previous ${toolName}] ${pattern}`;
120
+ return { keys: [key], summary };
121
+ }
122
+
123
+ function describeWrite(info: ToolResultInfo): TargetDescriptor | null {
124
+ const result = getRecord(info.result);
125
+ if (!result) return null;
126
+ const path = getString(result.path);
127
+ if (!path) return null;
128
+ const bytes = getNumber(result.bytes);
129
+ const sizeLabel =
130
+ typeof bytes === 'number' && Number.isFinite(bytes)
131
+ ? `${bytes} bytes`
132
+ : 'unknown size';
133
+ const key = `write:${normalizePath(path)}`;
134
+ const summary = `[previous write] ${normalizePath(path)} (${sizeLabel})`;
135
+ return { keys: [key], summary };
136
+ }
137
+
138
+ function describePatch(info: ToolResultInfo): TargetDescriptor | null {
139
+ const result = getRecord(info.result);
140
+ if (!result) return null;
141
+
142
+ const files = new Set<string>();
143
+
144
+ const changes = getArray(result.changes);
145
+ if (changes) {
146
+ for (const change of changes) {
147
+ const changeObj = getRecord(change);
148
+ const filePath = changeObj && getString(changeObj.filePath);
149
+ if (filePath) files.add(normalizePath(filePath));
150
+ }
151
+ }
152
+
153
+ const artifact = getRecord(result.artifact);
154
+ if (artifact) {
155
+ const summary = getRecord(artifact.summary);
156
+ const summaryFiles = getArray(summary?.files);
157
+ if (summaryFiles) {
158
+ for (const item of summaryFiles) {
159
+ if (typeof item === 'string') files.add(normalizePath(item));
160
+ else {
161
+ const record = getRecord(item);
162
+ const value = record && getString(record.path);
163
+ if (value) files.add(normalizePath(value));
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ const fileList = Array.from(files);
170
+ const keys = fileList.length
171
+ ? fileList.map((file) => `apply_patch:${file}`)
172
+ : [`apply_patch:${info.callId}`];
173
+
174
+ const summary =
175
+ fileList.length > 0
176
+ ? `[previous patch] ${fileList.join(', ')}`
177
+ : '[previous patch] (unknown files)';
178
+
179
+ return { keys, summary };
180
+ }
181
+
182
+ function getRecord(value: unknown): Record<string, unknown> | null {
183
+ return value && typeof value === 'object' && !Array.isArray(value)
184
+ ? (value as Record<string, unknown>)
185
+ : null;
186
+ }
187
+
188
+ function getArray(value: unknown): unknown[] | null {
189
+ return Array.isArray(value) ? value : null;
190
+ }
191
+
192
+ function getString(value: unknown): string | null {
193
+ if (typeof value === 'string') return value;
194
+ return null;
195
+ }
196
+
197
+ function getNumber(value: unknown): number | undefined {
198
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
199
+ return undefined;
200
+ }
201
+
202
+ function normalizePath(path: string): string {
203
+ return path.replace(/\\/g, '/');
204
+ }