@ottocode/server 0.1.210 → 0.1.212

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.210",
3
+ "version": "0.1.212",
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.210",
53
- "@ottocode/database": "0.1.210",
52
+ "@ottocode/sdk": "0.1.212",
53
+ "@ottocode/database": "0.1.212",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.1.8"
@@ -13,6 +13,7 @@ export type OttoEventType =
13
13
  | 'session.created'
14
14
  | 'session.updated'
15
15
  | 'message.created'
16
+ | 'message.updated'
16
17
  | 'message.part.delta'
17
18
  | 'reasoning.delta'
18
19
  | 'message.completed'
@@ -10,14 +10,7 @@ import { resolveBinary } from '@ottocode/sdk/tools/bin-manager';
10
10
 
11
11
  const execAsync = promisify(exec);
12
12
 
13
- const EXCLUDED_FILES = new Set([
14
- '.DS_Store',
15
- 'bun.lockb',
16
- '.env',
17
- '.env.local',
18
- '.env.production',
19
- '.env.development',
20
- ]);
13
+ const EXCLUDED_FILES = new Set(['.DS_Store', 'bun.lockb']);
21
14
 
22
15
  const EXCLUDED_DIRS = new Set([
23
16
  'node_modules',
@@ -35,6 +28,13 @@ const EXCLUDED_DIRS = new Set([
35
28
  '.cache',
36
29
  '__pycache__',
37
30
  '.tsbuildinfo',
31
+ 'target',
32
+ '.cargo',
33
+ '.rustup',
34
+ 'vendor',
35
+ '.gradle',
36
+ '.idea',
37
+ '.vscode',
38
38
  ]);
39
39
 
40
40
  function shouldExcludeFile(name: string): boolean {
@@ -48,11 +48,18 @@ function shouldExcludeDir(name: string): boolean {
48
48
  async function listFilesWithRg(
49
49
  projectRoot: string,
50
50
  limit: number,
51
+ includeIgnored = false,
51
52
  ): Promise<{ files: string[]; truncated: boolean }> {
52
53
  const rgBin = await resolveBinary('rg');
53
54
 
54
55
  return new Promise((resolve) => {
55
- const args = ['--files', '--hidden', '--glob', '!.git/', '--sort', 'path'];
56
+ const args = ['--files', '--hidden', '--glob', '!.*/', '--sort', 'path'];
57
+ if (includeIgnored) {
58
+ args.push('--no-ignore');
59
+ }
60
+ for (const dir of EXCLUDED_DIRS) {
61
+ args.push('--glob', `!**/${dir}/`);
62
+ }
56
63
 
57
64
  const proc = spawn(rgBin, args, { cwd: projectRoot });
58
65
  let stdout = '';
@@ -232,14 +239,42 @@ async function getChangedFiles(
232
239
  }
233
240
  }
234
241
 
242
+ async function getGitIgnoredFiles(
243
+ projectRoot: string,
244
+ files: string[],
245
+ ): Promise<Set<string>> {
246
+ if (files.length === 0) return new Set();
247
+ try {
248
+ return new Promise((resolve) => {
249
+ const proc = spawn('git', ['check-ignore', '--stdin'], {
250
+ cwd: projectRoot,
251
+ });
252
+ let stdout = '';
253
+ proc.stdout.on('data', (data) => {
254
+ stdout += data.toString();
255
+ });
256
+ proc.on('close', () => {
257
+ resolve(new Set(stdout.split('\n').filter(Boolean)));
258
+ });
259
+ proc.on('error', () => {
260
+ resolve(new Set());
261
+ });
262
+ proc.stdin.write(files.join('\n'));
263
+ proc.stdin.end();
264
+ });
265
+ } catch (_err) {
266
+ return new Set();
267
+ }
268
+ }
269
+
235
270
  export function registerFilesRoutes(app: Hono) {
236
271
  app.get('/v1/files', async (c) => {
237
272
  try {
238
273
  const projectRoot = c.req.query('project') || process.cwd();
239
274
  const maxDepth = Number.parseInt(c.req.query('maxDepth') || '10', 10);
240
- const limit = Number.parseInt(c.req.query('limit') || '1000', 10);
275
+ const limit = Number.parseInt(c.req.query('limit') || '10000', 10);
241
276
 
242
- let result = await listFilesWithRg(projectRoot, limit);
277
+ let result = await listFilesWithRg(projectRoot, limit, true);
243
278
 
244
279
  if (result.files.length === 0) {
245
280
  const gitignorePatterns = await parseGitignore(projectRoot);
@@ -254,9 +289,15 @@ export function registerFilesRoutes(app: Hono) {
254
289
  );
255
290
  }
256
291
 
257
- const changedFiles = await getChangedFiles(projectRoot);
292
+ const [changedFiles, ignoredFiles] = await Promise.all([
293
+ getChangedFiles(projectRoot),
294
+ getGitIgnoredFiles(projectRoot, result.files),
295
+ ]);
258
296
 
259
297
  result.files.sort((a, b) => {
298
+ const aIgnored = ignoredFiles.has(a);
299
+ const bIgnored = ignoredFiles.has(b);
300
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
260
301
  const aChanged = changedFiles.has(a);
261
302
  const bChanged = changedFiles.has(b);
262
303
  if (aChanged && !bChanged) return -1;
@@ -266,6 +307,7 @@ export function registerFilesRoutes(app: Hono) {
266
307
 
267
308
  return c.json({
268
309
  files: result.files,
310
+ ignoredFiles: Array.from(ignoredFiles),
269
311
  changedFiles: Array.from(changedFiles.entries()).map(
270
312
  ([path, status]) => ({
271
313
  path,
@@ -293,25 +335,37 @@ export function registerFilesRoutes(app: Hono) {
293
335
  name: string;
294
336
  path: string;
295
337
  type: 'file' | 'directory';
338
+ gitignored?: boolean;
296
339
  }> = [];
297
340
 
298
341
  for (const entry of entries) {
299
- if (entry.name.startsWith('.') && entry.name !== '.otto') continue;
300
342
  const relPath = relative(projectRoot, join(targetDir, entry.name));
301
343
 
302
344
  if (entry.isDirectory()) {
303
- if (shouldExcludeDir(entry.name)) continue;
304
- if (matchesGitignorePattern(relPath, gitignorePatterns)) continue;
305
- items.push({ name: entry.name, path: relPath, type: 'directory' });
345
+ const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
346
+ items.push({
347
+ name: entry.name,
348
+ path: relPath,
349
+ type: 'directory',
350
+ gitignored: ignored || undefined,
351
+ });
306
352
  } else if (entry.isFile()) {
307
353
  if (shouldExcludeFile(entry.name)) continue;
308
- if (matchesGitignorePattern(relPath, gitignorePatterns)) continue;
309
- items.push({ name: entry.name, path: relPath, type: 'file' });
354
+ const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
355
+ items.push({
356
+ name: entry.name,
357
+ path: relPath,
358
+ type: 'file',
359
+ gitignored: ignored || undefined,
360
+ });
310
361
  }
311
362
  }
312
363
 
313
364
  items.sort((a, b) => {
314
365
  if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
366
+ const aIgnored = a.gitignored ?? false;
367
+ const bIgnored = b.gitignored ?? false;
368
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
315
369
  return a.name.localeCompare(b.name);
316
370
  });
317
371
 
@@ -15,6 +15,7 @@ import {
15
15
  detectOAuth,
16
16
  adaptSimpleCall,
17
17
  } from '../../runtime/provider/oauth-adapter.ts';
18
+ import { appendCoAuthorTrailer } from '@ottocode/sdk';
18
19
 
19
20
  const execFileAsync = promisify(execFile);
20
21
 
@@ -36,9 +37,14 @@ export function registerCommitRoutes(app: Hono) {
36
37
 
37
38
  const { gitRoot } = validation;
38
39
 
39
- const { stdout } = await execFileAsync('git', ['commit', '-m', message], {
40
- cwd: gitRoot,
41
- });
40
+ const fullMessage = appendCoAuthorTrailer(message);
41
+ const { stdout } = await execFileAsync(
42
+ 'git',
43
+ ['commit', '-m', fullMessage],
44
+ {
45
+ cwd: gitRoot,
46
+ },
47
+ );
42
48
 
43
49
  return c.json({
44
50
  status: 'ok',
@@ -383,7 +383,18 @@ export function registerSessionsRoutes(app: Hono) {
383
383
  // Delete message parts first (foreign key constraint)
384
384
  await db
385
385
  .delete(messageParts)
386
- .where(eq(messageParts.messageId, messageId));
386
+ .where(
387
+ and(
388
+ eq(messageParts.messageId, messageId),
389
+ or(
390
+ eq(messageParts.type, 'error'),
391
+ and(
392
+ eq(messageParts.type, 'tool_call'),
393
+ eq(messageParts.toolName, 'finish'),
394
+ ),
395
+ ),
396
+ ),
397
+ );
387
398
  // Delete message
388
399
  await db.delete(messages).where(eq(messages.id, messageId));
389
400
 
@@ -758,7 +769,6 @@ export function registerSessionsRoutes(app: Hono) {
758
769
  return c.json({ error: 'Session not found' }, 404);
759
770
  }
760
771
 
761
- // Delete only error parts - preserve valid text/tool content
762
772
  await db
763
773
  .delete(messageParts)
764
774
  .where(
@@ -63,7 +63,11 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
63
63
  debugLog('[RUNNER] Using minimal history for /compact command');
64
64
  history = [];
65
65
  } else {
66
- history = await buildHistoryMessages(db, opts.sessionId);
66
+ history = await buildHistoryMessages(
67
+ db,
68
+ opts.sessionId,
69
+ opts.assistantMessageId,
70
+ );
67
71
  }
68
72
  historyTimer.end({ messages: history.length });
69
73
 
@@ -454,7 +454,13 @@ async function runAssistant(opts: RunOpts) {
454
454
  publish({
455
455
  type: 'message.created',
456
456
  sessionId: opts.sessionId,
457
- payload: { id: continuationMessageId, role: 'assistant' },
457
+ payload: {
458
+ id: continuationMessageId,
459
+ role: 'assistant',
460
+ agent: opts.agent,
461
+ provider: opts.provider,
462
+ model: opts.model,
463
+ },
458
464
  });
459
465
 
460
466
  enqueueAssistantRun(
@@ -12,6 +12,7 @@ import { ToolHistoryTracker } from './tool-history-tracker.ts';
12
12
  export async function buildHistoryMessages(
13
13
  db: Awaited<ReturnType<typeof getDb>>,
14
14
  sessionId: string,
15
+ currentMessageId?: string,
15
16
  ): Promise<ModelMessage[]> {
16
17
  const rows = await db
17
18
  .select()
@@ -26,10 +27,12 @@ export async function buildHistoryMessages(
26
27
  if (
27
28
  m.role === 'assistant' &&
28
29
  m.status !== 'complete' &&
29
- m.status !== 'completed'
30
+ m.status !== 'completed' &&
31
+ m.status !== 'error' &&
32
+ m.id !== currentMessageId
30
33
  ) {
31
34
  debugLog(
32
- `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status} (current turn still in progress)`,
35
+ `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status}`,
33
36
  );
34
37
  logPendingToolParts(db, m.id);
35
38
  continue;
@@ -124,7 +124,7 @@ export async function dispatchAssistantMessage(
124
124
  publish({
125
125
  type: 'message.created',
126
126
  sessionId,
127
- payload: { id: userMessageId, role: 'user' },
127
+ payload: { id: userMessageId, role: 'user', agent, provider, model },
128
128
  });
129
129
 
130
130
  const assistantMessageId = crypto.randomUUID();
@@ -141,7 +141,13 @@ export async function dispatchAssistantMessage(
141
141
  publish({
142
142
  type: 'message.created',
143
143
  sessionId,
144
- payload: { id: assistantMessageId, role: 'assistant' },
144
+ payload: {
145
+ id: assistantMessageId,
146
+ role: 'assistant',
147
+ agent,
148
+ provider,
149
+ model,
150
+ },
145
151
  });
146
152
 
147
153
  debugLog(
@@ -216,7 +216,13 @@ export function createErrorHandler(
216
216
  publish({
217
217
  type: 'message.created',
218
218
  sessionId: opts.sessionId,
219
- payload: { id: compactMessageId, role: 'assistant' },
219
+ payload: {
220
+ id: compactMessageId,
221
+ role: 'assistant',
222
+ agent: opts.agent,
223
+ provider: opts.provider,
224
+ model: opts.model,
225
+ },
220
226
  });
221
227
 
222
228
  let compactionSucceeded = false;
@@ -288,7 +294,13 @@ export function createErrorHandler(
288
294
  publish({
289
295
  type: 'message.created',
290
296
  sessionId: opts.sessionId,
291
- payload: { id: retryMessageId, role: 'assistant' },
297
+ payload: {
298
+ id: retryMessageId,
299
+ role: 'assistant',
300
+ agent: opts.agent,
301
+ provider: opts.provider,
302
+ model: opts.model,
303
+ },
292
304
  });
293
305
 
294
306
  enqueueAssistantRun(
@@ -1,4 +1,6 @@
1
1
  import type { getDb } from '@ottocode/database';
2
+ import { messageParts } from '@ottocode/database/schema';
3
+ import { eq, desc } from 'drizzle-orm';
2
4
  import { time } from '../debug/index.ts';
3
5
  import type { ToolAdapterContext } from '../../tools/adapter.ts';
4
6
  import type { RunOpts } from '../session/queue.ts';
@@ -16,8 +18,13 @@ export async function setupToolContext(
16
18
  const firstToolTimer = time('runner:first-tool-call');
17
19
  let firstToolSeen = false;
18
20
 
19
- // Simple counter starting at 0 - first event gets 0, second gets 1, etc.
20
- let currentIndex = 0;
21
+ const existingParts = await db
22
+ .select({ index: messageParts.index })
23
+ .from(messageParts)
24
+ .where(eq(messageParts.messageId, opts.assistantMessageId))
25
+ .orderBy(desc(messageParts.index))
26
+ .limit(1);
27
+ let currentIndex = existingParts.length > 0 ? existingParts[0].index + 1 : 0;
21
28
  const nextIndex = () => currentIndex++;
22
29
 
23
30
  const sharedCtx: RunnerToolContext = {