@ottocode/server 0.1.236 → 0.1.242

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.236",
3
+ "version": "0.1.242",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/sdk": "0.1.236",
53
- "@ottocode/database": "0.1.236",
52
+ "@ottocode/sdk": "0.1.242",
53
+ "@ottocode/database": "0.1.242",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.3.6"
package/src/index.ts CHANGED
@@ -25,10 +25,14 @@ import { registerProviderUsageRoutes } from './routes/provider-usage.ts';
25
25
  import { registerDoctorRoutes } from './routes/doctor.ts';
26
26
  import { registerSkillsRoutes } from './routes/skills.ts';
27
27
  import type { AgentConfigEntry } from './runtime/agent/registry.ts';
28
+ import { installAiSdkWarningHandler } from './runtime/ai-sdk-warnings.ts';
28
29
 
29
30
  const globalTerminalManager = new TerminalManager();
30
31
  setTerminalManager(globalTerminalManager);
31
32
 
33
+ // Suppress noisy AI SDK provider warnings unless debug mode is enabled.
34
+ installAiSdkWarningHandler();
35
+
32
36
  function initApp() {
33
37
  const app = new Hono();
34
38
 
@@ -190,8 +194,9 @@ export type EmbeddedAppConfig = {
190
194
  provider?: ProviderId;
191
195
  model?: string;
192
196
  agent?: string;
193
- toolApproval?: 'auto' | 'dangerous' | 'all';
197
+ toolApproval?: 'auto' | 'dangerous' | 'all' | 'yolo';
194
198
  fullWidthContent?: boolean;
199
+ autoCompactThresholdTokens?: number | null;
195
200
  };
196
201
  /** Additional CORS origins for proxies/Tailscale (e.g., ['https://myapp.ts.net', 'https://example.com']) */
197
202
  corsOrigins?: string[];
@@ -305,3 +310,6 @@ export { logger } from '@ottocode/sdk';
305
310
 
306
311
  // Export server state management
307
312
  export { setServerPort, getServerPort, getServerInfo } from './state.ts';
313
+
314
+ // Export WebSocket handler for Bun.serve()
315
+ export { websocket as bunWebSocket } from './ws.ts';
@@ -179,6 +179,10 @@ export const configPaths = {
179
179
  provider: { type: 'string' },
180
180
  model: { type: 'string' },
181
181
  fullWidthContent: { type: 'boolean' },
182
+ autoCompactThresholdTokens: {
183
+ type: 'integer',
184
+ nullable: true,
185
+ },
182
186
  reasoningText: { type: 'boolean' },
183
187
  reasoningLevel: {
184
188
  type: 'string',
@@ -210,6 +214,10 @@ export const configPaths = {
210
214
  provider: { type: 'string' },
211
215
  model: { type: 'string' },
212
216
  fullWidthContent: { type: 'boolean' },
217
+ autoCompactThresholdTokens: {
218
+ type: 'integer',
219
+ nullable: true,
220
+ },
213
221
  reasoningText: { type: 'boolean' },
214
222
  reasoningLevel: {
215
223
  type: 'string',
@@ -197,6 +197,7 @@ export const schemas = {
197
197
  provider: { $ref: '#/components/schemas/Provider' },
198
198
  model: { type: 'string' },
199
199
  fullWidthContent: { type: 'boolean' },
200
+ autoCompactThresholdTokens: { type: 'integer', nullable: true },
200
201
  reasoningText: { type: 'boolean' },
201
202
  reasoningLevel: {
202
203
  type: 'string',
package/src/presets.ts CHANGED
@@ -7,6 +7,9 @@ import AGENT_PLAN from '@ottocode/sdk/prompts/agents/plan.txt' with {
7
7
  import AGENT_GENERAL from '@ottocode/sdk/prompts/agents/general.txt' with {
8
8
  type: 'text',
9
9
  };
10
+ import AGENT_INIT from '@ottocode/sdk/prompts/agents/init.txt' with {
11
+ type: 'text',
12
+ };
10
13
  import AGENT_RESEARCH from '@ottocode/sdk/prompts/agents/research.txt' with {
11
14
  type: 'text',
12
15
  };
@@ -36,6 +39,10 @@ export const BUILTIN_AGENTS = {
36
39
  prompt: AGENT_GENERAL,
37
40
  tools: defaultToolsForAgent('general'),
38
41
  },
42
+ init: {
43
+ prompt: AGENT_INIT,
44
+ tools: defaultToolsForAgent('init'),
45
+ },
39
46
  research: {
40
47
  prompt: AGENT_RESEARCH,
41
48
  tools: defaultToolsForAgent('research'),
@@ -54,6 +61,8 @@ export const BUILTIN_AGENTS = {
54
61
  */
55
62
  export const BUILTIN_TOOLS = [
56
63
  'read',
64
+ 'edit',
65
+ 'multiedit',
57
66
  'write',
58
67
  'ls',
59
68
  'tree',
@@ -17,7 +17,7 @@ export function registerAgentsRoute(app: Hono) {
17
17
  if (embeddedConfig) {
18
18
  const agents = embeddedConfig.agents
19
19
  ? Object.keys(embeddedConfig.agents)
20
- : ['general', 'build', 'plan'];
20
+ : ['general', 'build', 'plan', 'init'];
21
21
  return c.json({
22
22
  agents,
23
23
  default: getDefault(
@@ -16,12 +16,13 @@ export function registerDefaultsRoute(app: Hono) {
16
16
  agent?: string;
17
17
  provider?: string;
18
18
  model?: string;
19
- toolApproval?: 'auto' | 'dangerous' | 'all';
19
+ toolApproval?: 'auto' | 'dangerous' | 'all' | 'yolo';
20
20
  guidedMode?: boolean;
21
21
  reasoningText?: boolean;
22
22
  reasoningLevel?: ReasoningLevel;
23
23
  theme?: string;
24
24
  fullWidthContent?: boolean;
25
+ autoCompactThresholdTokens?: number | null;
25
26
  scope?: 'global' | 'local';
26
27
  }>();
27
28
 
@@ -30,12 +31,13 @@ export function registerDefaultsRoute(app: Hono) {
30
31
  agent: string;
31
32
  provider: ProviderId;
32
33
  model: string;
33
- toolApproval: 'auto' | 'dangerous' | 'all';
34
+ toolApproval: 'auto' | 'dangerous' | 'all' | 'yolo';
34
35
  guidedMode: boolean;
35
36
  reasoningText: boolean;
36
37
  reasoningLevel: ReasoningLevel;
37
38
  theme: string;
38
39
  fullWidthContent: boolean;
40
+ autoCompactThresholdTokens: number | null;
39
41
  }> = {};
40
42
 
41
43
  if (body.agent) updates.agent = body.agent;
@@ -49,6 +51,14 @@ export function registerDefaultsRoute(app: Hono) {
49
51
  if (body.theme) updates.theme = body.theme;
50
52
  if (body.fullWidthContent !== undefined)
51
53
  updates.fullWidthContent = body.fullWidthContent;
54
+ if (body.autoCompactThresholdTokens !== undefined) {
55
+ const threshold = body.autoCompactThresholdTokens;
56
+ if (threshold === null) {
57
+ updates.autoCompactThresholdTokens = null;
58
+ } else if (Number.isFinite(threshold) && threshold > 0) {
59
+ updates.autoCompactThresholdTokens = Math.floor(threshold);
60
+ }
61
+ }
52
62
 
53
63
  await setConfig(scope, updates, projectRoot);
54
64
 
@@ -58,7 +58,7 @@ export function registerMainConfigRoute(app: Hono) {
58
58
  undefined,
59
59
  embeddedConfig?.defaults?.toolApproval,
60
60
  cfg.defaults.toolApproval,
61
- ) as 'auto' | 'dangerous' | 'all',
61
+ ) as 'auto' | 'dangerous' | 'all' | 'yolo',
62
62
  guidedMode: cfg.defaults.guidedMode ?? false,
63
63
  reasoningText: cfg.defaults.reasoningText ?? true,
64
64
  reasoningLevel: cfg.defaults.reasoningLevel ?? 'high',
@@ -69,6 +69,12 @@ export function registerMainConfigRoute(app: Hono) {
69
69
  embeddedConfig?.defaults?.fullWidthContent,
70
70
  cfg.defaults.fullWidthContent,
71
71
  ) ?? false,
72
+ autoCompactThresholdTokens:
73
+ getDefault(
74
+ undefined,
75
+ embeddedConfig?.defaults?.autoCompactThresholdTokens,
76
+ cfg.defaults.autoCompactThresholdTokens,
77
+ ) ?? null,
72
78
  };
73
79
 
74
80
  return c.json({
@@ -72,7 +72,7 @@ export async function getAuthTypeForProvider(
72
72
  export async function discoverAllAgents(
73
73
  projectRoot: string,
74
74
  ): Promise<string[]> {
75
- const builtInAgents = ['general', 'build', 'plan'];
75
+ const builtInAgents = ['general', 'build', 'plan', 'init'];
76
76
  const agentSet = new Set<string>(builtInAgents);
77
77
 
78
78
  try {
@@ -6,7 +6,16 @@ import { eq, and, inArray } from 'drizzle-orm';
6
6
  import { serializeError } from '../runtime/errors/api-error.ts';
7
7
  import { logger } from '@ottocode/sdk';
8
8
 
9
- const FILE_EDIT_TOOLS = ['Write', 'ApplyPatch', 'write', 'apply_patch'];
9
+ const FILE_EDIT_TOOLS = [
10
+ 'Write',
11
+ 'Edit',
12
+ 'MultiEdit',
13
+ 'ApplyPatch',
14
+ 'write',
15
+ 'edit',
16
+ 'multiedit',
17
+ 'apply_patch',
18
+ ];
10
19
 
11
20
  interface FileOperation {
12
21
  path: string;
@@ -62,7 +71,7 @@ function extractFilePathFromToolCall(
62
71
 
63
72
  const name = toolName.toLowerCase();
64
73
 
65
- if (name === 'write') {
74
+ if (name === 'write' || name === 'edit' || name === 'multiedit') {
66
75
  if (args && typeof args.path === 'string') return args.path;
67
76
  if (typeof c.path === 'string') return c.path;
68
77
  }
@@ -189,6 +198,13 @@ function extractDataFromToolResult(
189
198
  patch = (args?.patch as string | undefined) ?? c.patch;
190
199
  }
191
200
 
201
+ if (
202
+ (name === 'edit' || name === 'multiedit') &&
203
+ typeof c.result?.artifact?.patch === 'string'
204
+ ) {
205
+ patch = c.result.artifact.patch;
206
+ }
207
+
192
208
  if (name === 'write') {
193
209
  writeContent = args?.content as string | undefined;
194
210
  }
@@ -213,6 +229,7 @@ function extractDataFromToolResult(
213
229
  function getOperationType(toolName: string): 'write' | 'patch' | 'create' {
214
230
  const name = toolName.toLowerCase();
215
231
  if (name === 'write') return 'write';
232
+ if (name === 'edit' || name === 'multiedit') return 'patch';
216
233
  if (name === 'applypatch' || name === 'apply_patch') return 'patch';
217
234
  return 'write';
218
235
  }
@@ -3,6 +3,7 @@ import type { Hono } from 'hono';
3
3
  import { streamSSE } from 'hono/streaming';
4
4
  import type { TerminalManager } from '@ottocode/sdk';
5
5
  import { logger } from '@ottocode/sdk';
6
+ import { upgradeWebSocket } from '../ws.ts';
6
7
 
7
8
  export function registerTerminalsRoutes(
8
9
  app: Hono,
@@ -67,6 +68,99 @@ export function registerTerminalsRoutes(
67
68
  return c.json({ terminal: terminal.toJSON() });
68
69
  });
69
70
 
71
+ app.get(
72
+ '/v1/terminals/:id/ws',
73
+ upgradeWebSocket((c) => {
74
+ const id = c.req.param('id');
75
+
76
+ let onData: ((data: string) => void) | null = null;
77
+ let onExit: ((exitCode: number) => void) | null = null;
78
+
79
+ return {
80
+ onOpen(_event, ws) {
81
+ const terminal = terminalManager.get(id);
82
+ if (!terminal) {
83
+ ws.close(4004, 'Terminal not found');
84
+ return;
85
+ }
86
+
87
+ const history = terminal.read();
88
+ for (const chunk of history) {
89
+ ws.send(chunk);
90
+ }
91
+
92
+ onData = (data: string) => {
93
+ try {
94
+ ws.send(data);
95
+ } catch {
96
+ // ws may be closed
97
+ }
98
+ };
99
+
100
+ onExit = (exitCode: number) => {
101
+ try {
102
+ ws.send(JSON.stringify({ type: 'exit', exitCode }));
103
+ ws.close(1000, 'Process exited');
104
+ } catch {
105
+ // ws may already be closed
106
+ }
107
+ };
108
+
109
+ terminal.onData(onData);
110
+ terminal.onExit(onExit);
111
+
112
+ if (terminal.status === 'exited') {
113
+ onExit(terminal.exitCode ?? 0);
114
+ }
115
+ },
116
+ onMessage(event, _ws) {
117
+ const terminal = terminalManager.get(id);
118
+ if (!terminal) return;
119
+
120
+ const raw = event.data;
121
+ const message =
122
+ typeof raw === 'string'
123
+ ? raw
124
+ : raw instanceof ArrayBuffer
125
+ ? new TextDecoder().decode(raw)
126
+ : String(raw);
127
+
128
+ if (message.startsWith('{')) {
129
+ try {
130
+ const msg = JSON.parse(message);
131
+ if (msg.type === 'resize' && msg.cols > 0 && msg.rows > 0) {
132
+ terminal.resize(msg.cols, msg.rows);
133
+ return;
134
+ }
135
+ } catch {
136
+ // not JSON, treat as input
137
+ }
138
+ }
139
+
140
+ terminal.write(message);
141
+ },
142
+ onClose() {
143
+ const terminal = terminalManager.get(id);
144
+ if (terminal) {
145
+ if (onData) terminal.removeDataListener(onData);
146
+ if (onExit) terminal.removeExitListener(onExit);
147
+ }
148
+ onData = null;
149
+ onExit = null;
150
+ },
151
+ onError() {
152
+ const terminal = terminalManager.get(id);
153
+ if (terminal) {
154
+ if (onData) terminal.removeDataListener(onData);
155
+ if (onExit) terminal.removeExitListener(onExit);
156
+ }
157
+ onData = null;
158
+ onExit = null;
159
+ },
160
+ };
161
+ }),
162
+ );
163
+
70
164
  const handleTerminalOutput = async (c: Context) => {
71
165
  const id = c.req.param('id');
72
166
  const terminal = terminalManager.get(id);
@@ -14,6 +14,10 @@ import AGENT_PLAN from '@ottocode/sdk/prompts/agents/plan.txt' with {
14
14
  import AGENT_GENERAL from '@ottocode/sdk/prompts/agents/general.txt' with {
15
15
  type: 'text',
16
16
  };
17
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
18
+ import AGENT_INIT from '@ottocode/sdk/prompts/agents/init.txt' with {
19
+ type: 'text',
20
+ };
17
21
  import AGENT_RESEARCH from '@ottocode/sdk/prompts/agents/research.txt' with {
18
22
  type: 'text',
19
23
  };
@@ -116,6 +120,8 @@ const baseToolSet = ['progress_update', 'finish', 'skill'] as const;
116
120
  const defaultToolExtras: Record<string, string[]> = {
117
121
  build: [
118
122
  'read',
123
+ 'edit',
124
+ 'multiedit',
119
125
  'write',
120
126
  'ls',
121
127
  'tree',
@@ -131,6 +137,8 @@ const defaultToolExtras: Record<string, string[]> = {
131
137
  plan: ['read', 'ls', 'tree', 'ripgrep', 'update_todos', 'websearch'],
132
138
  general: [
133
139
  'read',
140
+ 'edit',
141
+ 'multiedit',
134
142
  'write',
135
143
  'ls',
136
144
  'tree',
@@ -140,6 +148,22 @@ const defaultToolExtras: Record<string, string[]> = {
140
148
  'websearch',
141
149
  'update_todos',
142
150
  ],
151
+ init: [
152
+ 'read',
153
+ 'edit',
154
+ 'multiedit',
155
+ 'write',
156
+ 'ls',
157
+ 'tree',
158
+ 'bash',
159
+ 'update_todos',
160
+ 'glob',
161
+ 'ripgrep',
162
+ 'git_status',
163
+ 'terminal',
164
+ 'apply_patch',
165
+ 'websearch',
166
+ ],
143
167
  git: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
144
168
  commit: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
145
169
  research: [
@@ -304,6 +328,7 @@ export async function resolveAgentConfig(
304
328
  if (n === 'build') return AGENT_BUILD;
305
329
  if (n === 'plan') return AGENT_PLAN;
306
330
  if (n === 'general') return AGENT_GENERAL;
331
+ if (n === 'init') return AGENT_INIT;
307
332
  if (n === 'research') return AGENT_RESEARCH;
308
333
  return undefined;
309
334
  };
@@ -9,6 +9,10 @@ export type ReasoningState = {
9
9
  partId: string;
10
10
  text: string;
11
11
  providerMetadata?: unknown;
12
+ persisted: boolean;
13
+ opts: RunOpts;
14
+ sharedCtx: ToolAdapterContext;
15
+ getStepIndex: () => number;
12
16
  };
13
17
 
14
18
  export function serializeReasoningContent(state: ReasoningState): string {
@@ -23,7 +27,7 @@ export async function handleReasoningStart(
23
27
  reasoningId: string,
24
28
  providerMetadata: unknown,
25
29
  opts: RunOpts,
26
- db: Awaited<ReturnType<typeof getDb>>,
30
+ _db: Awaited<ReturnType<typeof getDb>>,
27
31
  sharedCtx: ToolAdapterContext,
28
32
  getStepIndex: () => number,
29
33
  reasoningStates: Map<string, ReasoningState>,
@@ -33,21 +37,33 @@ export async function handleReasoningStart(
33
37
  partId: reasoningPartId,
34
38
  text: '',
35
39
  providerMetadata,
40
+ persisted: false,
41
+ opts,
42
+ sharedCtx,
43
+ getStepIndex,
36
44
  };
37
45
  reasoningStates.set(reasoningId, state);
46
+ }
47
+
48
+ async function persistReasoningPart(
49
+ state: ReasoningState,
50
+ db: Awaited<ReturnType<typeof getDb>>,
51
+ ): Promise<void> {
52
+ if (state.persisted) return;
38
53
  try {
39
54
  await db.insert(messageParts).values({
40
- id: reasoningPartId,
41
- messageId: opts.assistantMessageId,
42
- index: await sharedCtx.nextIndex(),
43
- stepIndex: getStepIndex(),
55
+ id: state.partId,
56
+ messageId: state.opts.assistantMessageId,
57
+ index: await state.sharedCtx.nextIndex(),
58
+ stepIndex: state.getStepIndex(),
44
59
  type: 'reasoning',
45
60
  content: serializeReasoningContent(state),
46
- agent: opts.agent,
47
- provider: opts.provider,
48
- model: opts.model,
61
+ agent: state.opts.agent,
62
+ provider: state.opts.provider,
63
+ model: state.opts.model,
49
64
  startedAt: Date.now(),
50
65
  });
66
+ state.persisted = true;
51
67
  } catch {}
52
68
  }
53
69
 
@@ -66,6 +82,14 @@ export async function handleReasoningDelta(
66
82
  if (providerMetadata != null) {
67
83
  state.providerMetadata = providerMetadata;
68
84
  }
85
+
86
+ // Skip empty-text updates (e.g. Anthropic signature_delta from adaptive
87
+ // thinking emits reasoning-delta with `text: ""`). Publishing/persisting
88
+ // these would create an empty reasoning placeholder shown as `{"text":""}`.
89
+ if (!text) return;
90
+
91
+ await persistReasoningPart(state, db);
92
+
69
93
  publish({
70
94
  type: 'reasoning.delta',
71
95
  sessionId: opts.sessionId,
@@ -92,9 +116,11 @@ export async function handleReasoningEnd(
92
116
  const state = reasoningStates.get(reasoningId);
93
117
  if (!state) return;
94
118
  if (!state.text || state.text.trim() === '') {
95
- try {
96
- await db.delete(messageParts).where(eq(messageParts.id, state.partId));
97
- } catch {}
119
+ if (state.persisted) {
120
+ try {
121
+ await db.delete(messageParts).where(eq(messageParts.id, state.partId));
122
+ } catch {}
123
+ }
98
124
  reasoningStates.delete(reasoningId);
99
125
  return;
100
126
  }
@@ -1,4 +1,9 @@
1
- import { loadConfig, logger, getSessionSystemPromptPath } from '@ottocode/sdk';
1
+ import {
2
+ loadConfig,
3
+ logger,
4
+ getSessionSystemPromptPath,
5
+ getModelFamily,
6
+ } from '@ottocode/sdk';
2
7
  import { wrapLanguageModel } from 'ai';
3
8
  import { devToolsMiddleware } from '@ai-sdk/devtools';
4
9
  import { getDb } from '@ottocode/database';
@@ -74,6 +79,33 @@ export function mergeProviderOptions(
74
79
  return base;
75
80
  }
76
81
 
82
+ const EDITING_TOOL_NAMES = ['edit', 'multiedit', 'write', 'apply_patch'];
83
+ const MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS = new Set([
84
+ 'build',
85
+ 'general',
86
+ 'init',
87
+ ]);
88
+
89
+ export function applyModelFamilyEditToolPolicy(
90
+ agent: string,
91
+ tools: string[],
92
+ provider: RunOpts['provider'],
93
+ model: string,
94
+ ): string[] {
95
+ if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
96
+
97
+ const family = getModelFamily(provider, model);
98
+ const next = tools.filter(
99
+ (toolName) => !EDITING_TOOL_NAMES.includes(toolName),
100
+ );
101
+ const preferredEditingTools =
102
+ family === 'anthropic' || family === 'openai'
103
+ ? ['write', 'apply_patch']
104
+ : ['write', 'edit', 'multiedit'];
105
+
106
+ return Array.from(new Set([...next, ...preferredEditingTools]));
107
+ }
108
+
77
109
  export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
78
110
  const cfgTimer = time('runner:loadConfig+db');
79
111
  const cfg = await loadConfig(opts.projectRoot);
@@ -88,7 +120,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
88
120
 
89
121
  const historyTimer = time('runner:buildHistory');
90
122
  let history: Awaited<ReturnType<typeof buildHistoryMessages>>;
91
- if (opts.isCompactCommand && opts.compactionContext) {
123
+ if (opts.omitHistory || (opts.isCompactCommand && opts.compactionContext)) {
92
124
  history = [];
93
125
  } else {
94
126
  history = await buildHistoryMessages(
@@ -218,6 +250,10 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
218
250
  });
219
251
  }
220
252
 
253
+ if (opts.additionalPromptMessages?.length) {
254
+ additionalSystemMessages.push(...opts.additionalPromptMessages);
255
+ }
256
+
221
257
  const toolsTimer = time('runner:discoverTools');
222
258
  const discovered = await discoverProjectTools(cfg.projectRoot);
223
259
  const allTools = discovered.tools;
@@ -236,7 +272,13 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
236
272
  toolsTimer.end({
237
273
  count: allTools.length + Object.keys(mcpToolsRecord).length,
238
274
  });
239
- const allowedNames = new Set([...(agentCfg.tools || []), 'finish']);
275
+ const allowedToolNames = applyModelFamilyEditToolPolicy(
276
+ agentCfg.name,
277
+ agentCfg.tools || [],
278
+ opts.provider,
279
+ opts.model,
280
+ );
281
+ const allowedNames = new Set([...allowedToolNames, 'finish']);
240
282
  const gated = allTools.filter(
241
283
  (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
242
284
  );
@@ -1,5 +1,6 @@
1
1
  import { hasToolCall, streamText } from 'ai';
2
- import { messageParts } from '@ottocode/database/schema';
2
+ import type { getDb } from '@ottocode/database';
3
+ import { messageParts, sessions } from '@ottocode/database/schema';
3
4
  import { eq } from 'drizzle-orm';
4
5
  import { publish, subscribe } from '../../events/bus.ts';
5
6
  import { time } from '../debug/index.ts';
@@ -22,7 +23,11 @@ import {
22
23
  createAbortHandler,
23
24
  createFinishHandler,
24
25
  } from '../stream/handlers.ts';
25
- import { pruneSession } from '../message/compaction.ts';
26
+ import {
27
+ pruneSession,
28
+ getModelLimits,
29
+ shouldAutoCompactBeforeOverflow,
30
+ } from '../message/compaction.ts';
26
31
  import { triggerDeferredTitleGeneration } from '../message/service.ts';
27
32
  import { setupRunner } from './runner-setup.ts';
28
33
  import {
@@ -51,7 +56,12 @@ export {
51
56
  getRunnerState,
52
57
  } from '../session/queue.ts';
53
58
 
54
- const DEFAULT_TRACED_TOOL_INPUTS = new Set(['write', 'apply_patch']);
59
+ const DEFAULT_TRACED_TOOL_INPUTS = new Set([
60
+ 'write',
61
+ 'edit',
62
+ 'multiedit',
63
+ 'apply_patch',
64
+ ]);
55
65
 
56
66
  function shouldTraceToolInput(name: string): boolean {
57
67
  void DEFAULT_TRACED_TOOL_INPUTS;
@@ -70,6 +80,28 @@ function summarizeTraceValue(value: unknown, max = 160): string {
70
80
  return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
71
81
  }
72
82
 
83
+ async function shouldPreemptivelyAutoCompact(
84
+ db: Awaited<ReturnType<typeof getDb>>,
85
+ opts: RunOpts,
86
+ threshold: number | null | undefined,
87
+ ): Promise<boolean> {
88
+ const limits = getModelLimits(opts.provider, opts.model);
89
+ const sessionRows = await db
90
+ .select({ currentContextTokens: sessions.currentContextTokens })
91
+ .from(sessions)
92
+ .where(eq(sessions.id, opts.sessionId))
93
+ .limit(1);
94
+
95
+ return shouldAutoCompactBeforeOverflow({
96
+ autoCompactThresholdTokens: threshold,
97
+ modelContextWindow: limits?.context ?? null,
98
+ currentContextTokens: sessionRows[0]?.currentContextTokens ?? 0,
99
+ estimatedInputTokens: opts.estimatedInputTokens ?? 0,
100
+ isCompactCommand: opts.isCompactCommand,
101
+ compactionRetries: opts.compactionRetries,
102
+ });
103
+ }
104
+
73
105
  export async function runSessionLoop(sessionId: string) {
74
106
  setRunning(sessionId, true);
75
107
 
@@ -327,6 +359,22 @@ async function runAssistant(opts: RunOpts) {
327
359
  runSessionLoop,
328
360
  );
329
361
 
362
+ if (
363
+ await shouldPreemptivelyAutoCompact(
364
+ db,
365
+ opts,
366
+ cfg.defaults.autoCompactThresholdTokens,
367
+ )
368
+ ) {
369
+ const autoCompactError = Object.assign(
370
+ new Error('Configured auto-compaction threshold reached'),
371
+ { code: 'context_length_exceeded' },
372
+ );
373
+ await onError(autoCompactError);
374
+ unsubscribeFinish();
375
+ return;
376
+ }
377
+
330
378
  const baseOnAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
331
379
  const onAbort = async (event: Parameters<typeof baseOnAbort>[0]) => {
332
380
  _abortedByUser = true;