@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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * AI SDK warning handler.
3
+ *
4
+ * The AI SDK logs provider warnings (e.g. unsupported features like
5
+ * `maxOutputTokens` caps) to the console via `globalThis.AI_SDK_LOG_WARNINGS`.
6
+ * These warnings are noisy during normal operation. We suppress them by
7
+ * default and only surface them when debug mode is enabled.
8
+ *
9
+ * See: https://ai-sdk.dev (AI_SDK_LOG_WARNINGS global)
10
+ */
11
+
12
+ import { isDebugEnabled } from './debug/state.ts';
13
+
14
+ type AiSdkWarning =
15
+ | { type: 'unsupported'; feature: string; details?: string }
16
+ | { type: 'compatibility'; feature: string; details?: string }
17
+ | { type: 'other'; message: string };
18
+
19
+ type LogWarningsOptions = {
20
+ warnings: AiSdkWarning[];
21
+ provider: string;
22
+ model: string;
23
+ };
24
+
25
+ function formatWarning(
26
+ warning: AiSdkWarning,
27
+ provider: string,
28
+ model: string,
29
+ ): string {
30
+ const prefix = `AI SDK Warning (${provider} / ${model}):`;
31
+ switch (warning.type) {
32
+ case 'unsupported': {
33
+ let message = `${prefix} The feature "${warning.feature}" is not supported.`;
34
+ if (warning.details) message += ` ${warning.details}`;
35
+ return message;
36
+ }
37
+ case 'compatibility': {
38
+ let message = `${prefix} The feature "${warning.feature}" is used in a compatibility mode.`;
39
+ if (warning.details) message += ` ${warning.details}`;
40
+ return message;
41
+ }
42
+ case 'other':
43
+ return `${prefix} ${warning.message}`;
44
+ default:
45
+ return `${prefix} ${JSON.stringify(warning)}`;
46
+ }
47
+ }
48
+
49
+ let installed = false;
50
+
51
+ /**
52
+ * Install a custom AI SDK warning handler that suppresses warnings unless
53
+ * debug mode is enabled. Safe to call multiple times (installs once).
54
+ */
55
+ export function installAiSdkWarningHandler(): void {
56
+ if (installed) return;
57
+ installed = true;
58
+
59
+ (
60
+ globalThis as unknown as {
61
+ AI_SDK_LOG_WARNINGS?: ((options: LogWarningsOptions) => void) | false;
62
+ }
63
+ ).AI_SDK_LOG_WARNINGS = (options: LogWarningsOptions) => {
64
+ if (!isDebugEnabled()) return;
65
+ if (!options.warnings?.length) return;
66
+ for (const warning of options.warnings) {
67
+ console.warn(formatWarning(warning, options.provider, options.model));
68
+ }
69
+ };
70
+ }
@@ -0,0 +1,84 @@
1
+ import type { DB } from '@ottocode/database';
2
+ import type { OttoConfig, ProviderId } from '@ottocode/sdk';
3
+ import {
4
+ buildCompactionContext,
5
+ getModelLimits,
6
+ isCompactCommand,
7
+ } from '../message/compaction.ts';
8
+ import {
9
+ buildInitCommandUserPrompt,
10
+ buildInitProjectSnapshot,
11
+ getInitCommandSystemPrompt,
12
+ isInitCommand,
13
+ } from './init.ts';
14
+
15
+ export type BuiltinCommandPromptMessage = {
16
+ role: 'system' | 'user';
17
+ content: string;
18
+ };
19
+
20
+ export type BuiltinCommandSpec = {
21
+ id: 'compact' | 'init';
22
+ agent?: string;
23
+ oneShot?: boolean;
24
+ /**
25
+ * Controls prompt construction only. The run still executes in the current
26
+ * session and persists messages/tool activity through the normal pipeline.
27
+ */
28
+ omitHistory?: boolean;
29
+ isCompactCommand?: boolean;
30
+ compactionContext?: string;
31
+ additionalPromptMessages?: BuiltinCommandPromptMessage[];
32
+ };
33
+
34
+ /**
35
+ * Returns a prepared built-in slash command spec when the message matches one.
36
+ *
37
+ * These commands still run through the normal current-session agent pipeline;
38
+ * this only customizes the prompt setup and agent selection.
39
+ */
40
+ export async function prepareBuiltinCommand(args: {
41
+ cfg: OttoConfig;
42
+ db: DB;
43
+ sessionId: string;
44
+ provider: ProviderId;
45
+ model: string;
46
+ content: string;
47
+ }): Promise<BuiltinCommandSpec | null> {
48
+ if (isCompactCommand(args.content)) {
49
+ const limits = getModelLimits(args.provider, args.model);
50
+ const contextTokenLimit = limits
51
+ ? Math.max(Math.floor(limits.context * 0.5), 15000)
52
+ : 15000;
53
+ const compactionContext = await buildCompactionContext(
54
+ args.db,
55
+ args.sessionId,
56
+ contextTokenLimit,
57
+ );
58
+ return {
59
+ id: 'compact',
60
+ isCompactCommand: true,
61
+ omitHistory: true,
62
+ compactionContext,
63
+ };
64
+ }
65
+
66
+ if (isInitCommand(args.content)) {
67
+ const snapshot = await buildInitProjectSnapshot(args.cfg.projectRoot);
68
+ return {
69
+ id: 'init',
70
+ agent: 'init',
71
+ oneShot: true,
72
+ omitHistory: true,
73
+ additionalPromptMessages: [
74
+ { role: 'system', content: getInitCommandSystemPrompt() },
75
+ {
76
+ role: 'user',
77
+ content: buildInitCommandUserPrompt(args.cfg.projectRoot, snapshot),
78
+ },
79
+ ],
80
+ };
81
+ }
82
+
83
+ return null;
84
+ }
@@ -0,0 +1,358 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { basename, join, relative } from 'node:path';
3
+
4
+ const IGNORED_NAMES = new Set([
5
+ '.git',
6
+ '.idea',
7
+ '.next',
8
+ '.turbo',
9
+ '.venv',
10
+ '.vscode',
11
+ 'dist',
12
+ 'build',
13
+ 'coverage',
14
+ 'node_modules',
15
+ 'target',
16
+ ]);
17
+
18
+ const KEY_DIRECTORY_LIMIT = 24;
19
+ const WORKSPACE_LIMIT = 24;
20
+
21
+ /** Returns true when the message is the built-in /init command. */
22
+ export function isInitCommand(content: string): boolean {
23
+ return content.trim().toLowerCase() === '/init';
24
+ }
25
+
26
+ /** System instructions for the focused /init repo-analysis run. */
27
+ export function getInitCommandSystemPrompt(): string {
28
+ return [
29
+ 'You are generating durable agent instructions for this repository.',
30
+ '',
31
+ 'Your job is to inspect the real project structure and source code, then create or refresh AGENTS.md documentation for future coding agents.',
32
+ '',
33
+ 'Hard rules:',
34
+ '- Do not trust existing markdown as the source of truth; code, config, and filesystem structure win when there is a conflict.',
35
+ '- Inspect actual package manifests, source directories, route wiring, schemas, app entrypoints, and build configuration before writing.',
36
+ '- Prefer a small number of strong docs over many tiny docs.',
37
+ '- If this is a monorepo, create a root AGENTS.md that acts as the routing/index doc and points to a small set of focused docs under .agents/.',
38
+ '- If this is not a monorepo, root AGENTS.md may stay mostly self-contained with only minimal supporting docs.',
39
+ '- Root AGENTS.md must tell future agents which .agents doc to read for mobile, server/api, web/tui clients, database, and cross-cutting changes when applicable.',
40
+ '- Avoid oversplitting. Only create supporting docs when a domain is meaningfully distinct. Aim for roughly 3-6 supporting docs max when splitting is needed.',
41
+ '- Keep docs actionable for coding agents: architecture, key file paths, workflow rules, and when to consult related docs.',
42
+ '- Reuse and update existing AGENTS.md or .agents docs when appropriate instead of duplicating content.',
43
+ '',
44
+ 'Expected process:',
45
+ '1. Scan the repository structure and key code/config files with tools.',
46
+ '2. Decide the minimum useful documentation split.',
47
+ '3. Write or update AGENTS.md and any needed .agents/*.md files.',
48
+ '4. Finish with a concise summary of what you generated and why.',
49
+ ].join('\n');
50
+ }
51
+
52
+ /** Builds a concise filesystem-grounded snapshot to seed the /init run. */
53
+ export async function buildInitProjectSnapshot(
54
+ projectRoot: string,
55
+ ): Promise<string> {
56
+ const rootManifest = await readJson<Record<string, unknown>>(
57
+ join(projectRoot, 'package.json'),
58
+ );
59
+ const workspacePatterns = extractWorkspacePatterns(rootManifest?.workspaces);
60
+ const workspaceDirs = await collectWorkspaceDirs(
61
+ projectRoot,
62
+ workspacePatterns,
63
+ );
64
+ const topLevelDirs = await listDirectoryNames(
65
+ projectRoot,
66
+ true,
67
+ KEY_DIRECTORY_LIMIT,
68
+ );
69
+ const topLevelFiles = await listDirectoryNames(
70
+ projectRoot,
71
+ false,
72
+ KEY_DIRECTORY_LIMIT,
73
+ );
74
+ const appDirs = await listDirectoryNames(
75
+ join(projectRoot, 'apps'),
76
+ true,
77
+ KEY_DIRECTORY_LIMIT,
78
+ );
79
+ const packageDirs = await listDirectoryNames(
80
+ join(projectRoot, 'packages'),
81
+ true,
82
+ KEY_DIRECTORY_LIMIT,
83
+ );
84
+ const docsDirs = await listDirectoryNames(
85
+ join(projectRoot, 'docs'),
86
+ false,
87
+ KEY_DIRECTORY_LIMIT,
88
+ );
89
+ const routeFiles = await listDirectoryNames(
90
+ join(projectRoot, 'packages/server/src/routes'),
91
+ false,
92
+ KEY_DIRECTORY_LIMIT,
93
+ );
94
+ const schemaFiles = await listDirectoryNames(
95
+ join(projectRoot, 'packages/database/src/schema'),
96
+ false,
97
+ KEY_DIRECTORY_LIMIT,
98
+ );
99
+ const sdkDirs = await listDirectoryNames(
100
+ join(projectRoot, 'packages/sdk/src'),
101
+ true,
102
+ KEY_DIRECTORY_LIMIT,
103
+ );
104
+ const webSdkDirs = await listDirectoryNames(
105
+ join(projectRoot, 'packages/web-sdk/src'),
106
+ true,
107
+ KEY_DIRECTORY_LIMIT,
108
+ );
109
+ const existingAgentDocs = await summarizeExistingAgentDocs(projectRoot);
110
+ const workspaceSummaries = await Promise.all(
111
+ workspaceDirs.slice(0, WORKSPACE_LIMIT).map(async (workspaceDir) => {
112
+ const rel = toProjectRelative(projectRoot, workspaceDir);
113
+ const manifest = await readJson<Record<string, unknown>>(
114
+ join(workspaceDir, 'package.json'),
115
+ );
116
+ return summarizeWorkspace(rel, manifest);
117
+ }),
118
+ );
119
+
120
+ const runtimeSignals = [
121
+ (await exists(join(projectRoot, 'bun.lock'))) ? 'bun.lock' : null,
122
+ (await exists(join(projectRoot, 'bunfig.toml'))) ? 'bunfig.toml' : null,
123
+ (await exists(join(projectRoot, 'biome.json'))) ? 'biome.json' : null,
124
+ (await exists(join(projectRoot, 'tsconfig.base.json')))
125
+ ? 'tsconfig.base.json'
126
+ : null,
127
+ (await exists(join(projectRoot, 'docker'))) ? 'docker/' : null,
128
+ ].filter((value): value is string => Boolean(value));
129
+
130
+ const lines = [
131
+ `Project root: ${projectRoot}`,
132
+ `Repo shape: ${workspacePatterns.length > 0 || workspaceDirs.length > 1 ? 'monorepo/workspace' : 'single-project or minimal workspace'}`,
133
+ rootManifest?.name ? `Root package: ${String(rootManifest.name)}` : null,
134
+ workspacePatterns.length
135
+ ? `Workspace globs: ${workspacePatterns.join(', ')}`
136
+ : 'Workspace globs: none declared',
137
+ runtimeSignals.length
138
+ ? `Tooling signals: ${runtimeSignals.join(', ')}`
139
+ : null,
140
+ topLevelDirs.length
141
+ ? `Top-level directories: ${topLevelDirs.join(', ')}`
142
+ : null,
143
+ topLevelFiles.length
144
+ ? `Top-level files: ${topLevelFiles.join(', ')}`
145
+ : null,
146
+ appDirs.length ? `apps/: ${appDirs.join(', ')}` : null,
147
+ packageDirs.length ? `packages/: ${packageDirs.join(', ')}` : null,
148
+ docsDirs.length ? `docs/: ${docsDirs.join(', ')}` : null,
149
+ routeFiles.length
150
+ ? `packages/server/src/routes: ${routeFiles.join(', ')}`
151
+ : null,
152
+ schemaFiles.length
153
+ ? `packages/database/src/schema: ${schemaFiles.join(', ')}`
154
+ : null,
155
+ sdkDirs.length ? `packages/sdk/src dirs: ${sdkDirs.join(', ')}` : null,
156
+ webSdkDirs.length
157
+ ? `packages/web-sdk/src dirs: ${webSdkDirs.join(', ')}`
158
+ : null,
159
+ existingAgentDocs,
160
+ workspaceSummaries.length ? 'Workspace packages:' : null,
161
+ ...workspaceSummaries.map((summary) => `- ${summary}`),
162
+ ]
163
+ .filter((line): line is string => Boolean(line))
164
+ .join('\n');
165
+
166
+ return lines;
167
+ }
168
+
169
+ /** Builds the primary user prompt for /init after the repo snapshot is collected. */
170
+ export function buildInitCommandUserPrompt(
171
+ projectRoot: string,
172
+ snapshot: string,
173
+ ): string {
174
+ return [
175
+ 'Generate or refresh the repository agent documentation for this project.',
176
+ '',
177
+ 'Deliverables:',
178
+ '- Root AGENTS.md at the repository root.',
179
+ '- Supporting docs under .agents/ only when needed.',
180
+ '',
181
+ 'What to analyze before writing:',
182
+ '- monorepo boundaries and workspace responsibilities',
183
+ '- how server/API routes are wired',
184
+ '- how web and TUI clients are wired',
185
+ '- whether mobile deserves its own doc',
186
+ '- database/schema/migration locations',
187
+ '- SDK/shared packages and build/test tooling',
188
+ '',
189
+ 'Document design rules:',
190
+ '- Root AGENTS.md should be the primary entrypoint and routing guide for future LLM agents.',
191
+ '- Root AGENTS.md should point to the exact .agents docs to read for different task types.',
192
+ '- If a task touches multiple layers, root AGENTS.md should say to read multiple docs.',
193
+ '- Prefer fewer, stronger docs. Do not scatter tiny markdown files.',
194
+ '- Keep instructions concrete and file-path-aware.',
195
+ '- Mention repository-specific norms that matter for safe changes.',
196
+ '',
197
+ 'Repository snapshot (filesystem-grounded, still verify with tools):',
198
+ '<project-snapshot>',
199
+ snapshot,
200
+ '</project-snapshot>',
201
+ '',
202
+ `Project root for file writes: ${projectRoot}`,
203
+ '',
204
+ 'Now inspect the real code/config with tools, decide the right doc split, and write the docs.',
205
+ ].join('\n');
206
+ }
207
+
208
+ async function summarizeExistingAgentDocs(
209
+ projectRoot: string,
210
+ ): Promise<string | null> {
211
+ const rootAgents = (await exists(join(projectRoot, 'AGENTS.md')))
212
+ ? 'AGENTS.md'
213
+ : null;
214
+ const subdocs = await listDirectoryNames(
215
+ join(projectRoot, '.agents'),
216
+ false,
217
+ KEY_DIRECTORY_LIMIT,
218
+ );
219
+ if (!rootAgents && subdocs.length === 0) return null;
220
+ return `Existing agent docs: ${[rootAgents, ...subdocs].filter(Boolean).join(', ')}`;
221
+ }
222
+
223
+ async function collectWorkspaceDirs(
224
+ projectRoot: string,
225
+ patterns: string[],
226
+ ): Promise<string[]> {
227
+ const found = new Set<string>();
228
+ for (const pattern of patterns) {
229
+ for (const match of await expandWorkspacePattern(projectRoot, pattern)) {
230
+ found.add(match);
231
+ }
232
+ }
233
+
234
+ if (found.size === 0) {
235
+ for (const fallbackRoot of ['apps', 'packages', 'examples']) {
236
+ for (const dir of await listSubdirectories(
237
+ join(projectRoot, fallbackRoot),
238
+ )) {
239
+ found.add(dir);
240
+ }
241
+ }
242
+ }
243
+
244
+ return Array.from(found).sort((a, b) => a.localeCompare(b));
245
+ }
246
+
247
+ async function expandWorkspacePattern(
248
+ projectRoot: string,
249
+ pattern: string,
250
+ ): Promise<string[]> {
251
+ const normalized = pattern.replace(/\\/g, '/').replace(/\/$/, '');
252
+ if (!normalized.endsWith('/*')) return [];
253
+ const base = normalized.slice(0, -2);
254
+ if (!base || base.includes('*')) return [];
255
+ return listSubdirectories(join(projectRoot, base));
256
+ }
257
+
258
+ function extractWorkspacePatterns(workspaces: unknown): string[] {
259
+ if (Array.isArray(workspaces)) {
260
+ return workspaces.filter(
261
+ (value): value is string => typeof value === 'string',
262
+ );
263
+ }
264
+ if (
265
+ workspaces &&
266
+ typeof workspaces === 'object' &&
267
+ Array.isArray((workspaces as { packages?: unknown }).packages)
268
+ ) {
269
+ return (workspaces as { packages: unknown[] }).packages.filter(
270
+ (value): value is string => typeof value === 'string',
271
+ );
272
+ }
273
+ return [];
274
+ }
275
+
276
+ function summarizeWorkspace(
277
+ relPath: string,
278
+ manifest?: Record<string, unknown>,
279
+ ): string {
280
+ const name =
281
+ typeof manifest?.name === 'string'
282
+ ? manifest.name
283
+ : `(${basename(relPath)})`;
284
+ const scripts =
285
+ manifest?.scripts && typeof manifest.scripts === 'object'
286
+ ? Object.keys(manifest.scripts as Record<string, unknown>).slice(0, 6)
287
+ : [];
288
+ const flags = [
289
+ typeof manifest?.private === 'boolean'
290
+ ? manifest.private
291
+ ? 'private'
292
+ : 'public'
293
+ : null,
294
+ scripts.length ? `scripts: ${scripts.join(', ')}` : null,
295
+ ]
296
+ .filter((value): value is string => Boolean(value))
297
+ .join(' | ');
298
+ return flags ? `${relPath} → ${name} (${flags})` : `${relPath} → ${name}`;
299
+ }
300
+
301
+ async function listSubdirectories(path: string): Promise<string[]> {
302
+ try {
303
+ const entries = await readdir(path, { withFileTypes: true });
304
+ return entries
305
+ .filter((entry) => entry.isDirectory() && !shouldIgnore(entry.name))
306
+ .map((entry) => join(path, entry.name))
307
+ .sort((a, b) => a.localeCompare(b));
308
+ } catch {
309
+ return [];
310
+ }
311
+ }
312
+
313
+ async function listDirectoryNames(
314
+ path: string,
315
+ directoriesOnly: boolean,
316
+ limit: number,
317
+ ): Promise<string[]> {
318
+ try {
319
+ const entries = await readdir(path, { withFileTypes: true });
320
+ return entries
321
+ .filter((entry) => !shouldIgnore(entry.name))
322
+ .filter((entry) =>
323
+ directoriesOnly ? entry.isDirectory() : !entry.isDirectory(),
324
+ )
325
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
326
+ .sort((a, b) => a.localeCompare(b))
327
+ .slice(0, limit);
328
+ } catch {
329
+ return [];
330
+ }
331
+ }
332
+
333
+ function shouldIgnore(name: string): boolean {
334
+ return IGNORED_NAMES.has(name) || name.startsWith('.DS_Store');
335
+ }
336
+
337
+ async function readJson<T>(path: string): Promise<T | undefined> {
338
+ try {
339
+ const file = Bun.file(path);
340
+ if (!(await file.exists())) return undefined;
341
+ return (await file.json()) as T;
342
+ } catch {
343
+ return undefined;
344
+ }
345
+ }
346
+
347
+ async function exists(path: string): Promise<boolean> {
348
+ try {
349
+ return await Bun.file(path).exists();
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
355
+ function toProjectRelative(projectRoot: string, absPath: string): string {
356
+ const rel = relative(projectRoot, absPath).replace(/\\/g, '/');
357
+ return rel.length > 0 ? rel : '.';
358
+ }
@@ -12,6 +12,46 @@ export interface ModelLimits {
12
12
  output: number;
13
13
  }
14
14
 
15
+ export function shouldAutoCompactBeforeOverflow(args: {
16
+ autoCompactThresholdTokens?: number | null;
17
+ modelContextWindow?: number | null;
18
+ currentContextTokens?: number | null;
19
+ estimatedInputTokens?: number | null;
20
+ isCompactCommand?: boolean;
21
+ compactionRetries?: number;
22
+ }): boolean {
23
+ const threshold = Number(args.autoCompactThresholdTokens ?? 0);
24
+ if (!Number.isFinite(threshold) || threshold <= 0) {
25
+ return false;
26
+ }
27
+ if (args.isCompactCommand) {
28
+ return false;
29
+ }
30
+ if ((args.compactionRetries ?? 0) > 0) {
31
+ return false;
32
+ }
33
+
34
+ const modelContextWindow = Number(args.modelContextWindow ?? 0);
35
+ if (!Number.isFinite(modelContextWindow) || modelContextWindow <= threshold) {
36
+ return false;
37
+ }
38
+
39
+ const currentContextTokens = Math.max(
40
+ 0,
41
+ Math.floor(Number(args.currentContextTokens ?? 0)),
42
+ );
43
+ if (currentContextTokens <= 0) {
44
+ return false;
45
+ }
46
+
47
+ const estimatedInputTokens = Math.max(
48
+ 0,
49
+ Math.floor(Number(args.estimatedInputTokens ?? 0)),
50
+ );
51
+
52
+ return currentContextTokens + estimatedInputTokens >= threshold;
53
+ }
54
+
15
55
  export function getModelLimits(
16
56
  provider: string,
17
57
  model: string,
@@ -3,6 +3,7 @@ export {
3
3
  estimateTokens,
4
4
  type ModelLimits,
5
5
  getModelLimits,
6
+ shouldAutoCompactBeforeOverflow,
6
7
  isCompacted,
7
8
  COMPACTED_PLACEHOLDER,
8
9
  } from './compaction-limits.ts';