@ottocode/server 0.1.237 → 0.1.243

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.237",
3
+ "version": "0.1.243",
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.237",
53
- "@ottocode/database": "0.1.237",
52
+ "@ottocode/sdk": "0.1.243",
53
+ "@ottocode/database": "0.1.243",
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'),
@@ -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 {
@@ -17,8 +17,18 @@ export function registerSkillsRoutes(app: Hono) {
17
17
  const projectRoot = c.req.query('project') || process.cwd();
18
18
  const repoRoot = (await findGitRoot(projectRoot)) ?? projectRoot;
19
19
  const skills = await discoverSkills(projectRoot, repoRoot);
20
+ // Dedupe by name (same skill may exist in multiple source dirs like
21
+ // ~/.claude/skills and ~/.codex/skills). `discoverSkills` already
22
+ // dedupes via its internal Map, but be defensive here for UI consistency.
23
+ const seen = new Set<string>();
24
+ const unique = skills.filter((s) => {
25
+ const key = s.name.trim();
26
+ if (!key || seen.has(key)) return false;
27
+ seen.add(key);
28
+ return true;
29
+ });
20
30
  return c.json({
21
- skills: skills.map((s) => ({
31
+ skills: unique.map((s) => ({
22
32
  name: s.name,
23
33
  description: s.description,
24
34
  scope: s.scope,
@@ -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
  };
@@ -144,6 +148,22 @@ const defaultToolExtras: Record<string, string[]> = {
144
148
  'websearch',
145
149
  'update_todos',
146
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
+ ],
147
167
  git: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
148
168
  commit: ['git_status', 'git_diff', 'git_commit', 'read', 'ls'],
149
169
  research: [
@@ -308,6 +328,7 @@ export async function resolveAgentConfig(
308
328
  if (n === 'build') return AGENT_BUILD;
309
329
  if (n === 'plan') return AGENT_PLAN;
310
330
  if (n === 'general') return AGENT_GENERAL;
331
+ if (n === 'init') return AGENT_INIT;
311
332
  if (n === 'research') return AGENT_RESEARCH;
312
333
  return undefined;
313
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(
@@ -121,7 +153,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
121
153
  oneShot: opts.oneShot,
122
154
  guidedMode: cfg.defaults.guidedMode,
123
155
  spoofPrompt: undefined,
124
- includeProjectTree: isFirstMessage,
156
+ includeProjectTree: false,
125
157
  userContext: opts.userContext,
126
158
  contextSummary,
127
159
  isOpenAIOAuth: oauth.isOpenAIOAuth,
@@ -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
  );
@@ -305,24 +347,3 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
305
347
  mcpToolsRecord,
306
348
  };
307
349
  }
308
-
309
- export function buildMessages(
310
- additionalSystemMessages: Array<{ role: string; content: string }>,
311
- history: Array<{ role: string; content: string | Array<unknown> }>,
312
- isFirstMessage: boolean,
313
- ): Array<{ role: string; content: string | Array<unknown> }> {
314
- const messagesWithSystemInstructions: Array<{
315
- role: string;
316
- content: string | Array<unknown>;
317
- }> = [...additionalSystemMessages, ...history];
318
-
319
- if (!isFirstMessage) {
320
- messagesWithSystemInstructions.push({
321
- role: 'user',
322
- content:
323
- 'SYSTEM REMINDER: You are continuing an existing session. When you have completed the task, you MUST stream a text summary of what you did to the user, and THEN call the `finish` tool. Do not call `finish` without a summary.',
324
- });
325
- }
326
-
327
- return messagesWithSystemInstructions;
328
- }
@@ -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 {
@@ -75,6 +80,28 @@ function summarizeTraceValue(value: unknown, max = 160): string {
75
80
  return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
76
81
  }
77
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
+
78
105
  export async function runSessionLoop(sessionId: string) {
79
106
  setRunning(sessionId, true);
80
107
 
@@ -162,13 +189,13 @@ async function runAssistant(opts: RunOpts) {
162
189
  messagesWithSystemInstructions.push({
163
190
  role: 'system',
164
191
  content:
165
- 'SYSTEM REMINDER: You are continuing an existing session. Continue executing directly, use tools as needed, and provide a concise final summary when complete.',
192
+ '[system-reminder] Continuing an existing session. Execute directly, use tools as needed, and call `finish` at the end. For simple questions, your answer IS the response — do not add a "Summary:" recap.',
166
193
  });
167
194
  } else {
168
195
  messagesWithSystemInstructions.push({
169
196
  role: 'user',
170
197
  content:
171
- 'SYSTEM REMINDER: You are continuing an existing session. When you have completed the task, you MUST stream a text summary of what you did to the user, and THEN call the `finish` tool. Do not call `finish` without a summary.',
198
+ '<system-reminder>Continuing an existing session. Answer or complete the work directly, then call `finish`. For simple questions, your answer IS the response do NOT add a labeled "Summary:" line or recap trivial replies.</system-reminder>',
172
199
  });
173
200
  }
174
201
  }
@@ -177,13 +204,13 @@ async function runAssistant(opts: RunOpts) {
177
204
  messagesWithSystemInstructions.push({
178
205
  role: 'system',
179
206
  content:
180
- 'SYSTEM REMINDER: Your previous response stopped mid-task. Continue immediately from where you left off and finish the actual implementation, not just a plan update.',
207
+ '[system-reminder] Your previous response stopped mid-task. Resume from where you left off and complete the actual work not a plan-only update.',
181
208
  });
182
209
  } else {
183
210
  messagesWithSystemInstructions.push({
184
211
  role: 'user',
185
212
  content:
186
- 'SYSTEM REMINDER: Your previous response stopped before calling `finish`. Continue executing immediately from where you left off, avoid plan-only updates, and call `finish` only after streaming the final user summary.',
213
+ '<system-reminder>Your previous response stopped before calling `finish`. Resume from where you left off, do the actual work (no plan-only updates), then stream a summary and call `finish`.</system-reminder>',
187
214
  });
188
215
  }
189
216
  }
@@ -332,6 +359,22 @@ async function runAssistant(opts: RunOpts) {
332
359
  runSessionLoop,
333
360
  );
334
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
+
335
378
  const baseOnAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
336
379
  const onAbort = async (event: Parameters<typeof baseOnAbort>[0]) => {
337
380
  _abortedByUser = true;