@ottocode/server 0.1.256 → 0.1.258

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.256",
3
+ "version": "0.1.258",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -22,6 +22,18 @@
22
22
  "import": "./src/runtime/agent/runner.ts",
23
23
  "types": "./src/runtime/agent/runner.ts"
24
24
  },
25
+ "./runtime/session/manager": {
26
+ "import": "./src/runtime/session/manager.ts",
27
+ "types": "./src/runtime/session/manager.ts"
28
+ },
29
+ "./runtime/commands/available": {
30
+ "import": "./src/runtime/commands/available.ts",
31
+ "types": "./src/runtime/commands/available.ts"
32
+ },
33
+ "./runtime/share/service": {
34
+ "import": "./src/runtime/share/service.ts",
35
+ "types": "./src/runtime/share/service.ts"
36
+ },
25
37
  "./events/bus": {
26
38
  "import": "./src/events/bus.ts",
27
39
  "types": "./src/events/bus.ts"
@@ -49,8 +61,8 @@
49
61
  "typecheck": "tsc --noEmit"
50
62
  },
51
63
  "dependencies": {
52
- "@ottocode/database": "0.1.256",
53
- "@ottocode/sdk": "0.1.256",
64
+ "@ottocode/database": "0.1.258",
65
+ "@ottocode/sdk": "0.1.258",
54
66
  "ai-sdk-ollama": "^3.8.3",
55
67
  "drizzle-orm": "^0.44.5",
56
68
  "hono": "^4.9.9",
@@ -69,6 +69,70 @@ export const filesPaths = {
69
69
  },
70
70
  },
71
71
  },
72
+ '/v1/files/search': {
73
+ get: {
74
+ tags: ['files'],
75
+ operationId: 'searchFiles',
76
+ summary: 'Search project files',
77
+ description:
78
+ 'Searches files for mentions and quick-open. Excludes dependencies, build artifacts, and gitignored files by default.',
79
+ parameters: [
80
+ projectQueryParam(),
81
+ {
82
+ in: 'query',
83
+ name: 'q',
84
+ required: false,
85
+ schema: { type: 'string', default: '' },
86
+ description: 'Search query',
87
+ },
88
+ {
89
+ in: 'query',
90
+ name: 'maxDepth',
91
+ required: false,
92
+ schema: { type: 'integer' },
93
+ description: 'Maximum directory depth to traverse',
94
+ },
95
+ {
96
+ in: 'query',
97
+ name: 'limit',
98
+ required: false,
99
+ schema: { type: 'integer' },
100
+ description: 'Maximum number of files to return',
101
+ },
102
+ ],
103
+ responses: {
104
+ 200: {
105
+ description: 'OK',
106
+ content: {
107
+ 'application/json': {
108
+ schema: {
109
+ type: 'object',
110
+ properties: {
111
+ files: {
112
+ type: 'array',
113
+ items: { type: 'string' },
114
+ },
115
+ changedFiles: {
116
+ type: 'array',
117
+ items: {
118
+ type: 'object',
119
+ properties: {
120
+ path: { type: 'string' },
121
+ status: { type: 'string' },
122
+ },
123
+ required: ['path', 'status'],
124
+ },
125
+ },
126
+ truncated: { type: 'boolean' },
127
+ },
128
+ required: ['files', 'changedFiles', 'truncated'],
129
+ },
130
+ },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ },
72
136
  '/v1/files/tree': {
73
137
  get: {
74
138
  tags: ['files'],
@@ -104,13 +168,16 @@ export const filesPaths = {
104
168
  enum: ['file', 'directory'],
105
169
  },
106
170
  gitignored: { type: 'boolean' },
171
+ vendor: { type: 'boolean' },
172
+ searchable: { type: 'boolean' },
107
173
  },
108
174
  required: ['name', 'path', 'type'],
109
175
  },
110
176
  },
111
177
  path: { type: 'string' },
178
+ truncated: { type: 'boolean' },
112
179
  },
113
- required: ['items', 'path'],
180
+ required: ['items', 'path', 'truncated'],
114
181
  },
115
182
  },
116
183
  },
@@ -6,14 +6,11 @@ import {
6
6
  getProviderSettings,
7
7
  type ProviderId,
8
8
  isProviderAuthorized,
9
- getGlobalAgentsDir,
10
9
  getAuth,
11
10
  } from '@ottocode/sdk';
12
- import { readdir } from 'node:fs/promises';
13
- import { join } from 'node:path';
14
11
  import type { EmbeddedAppConfig } from '../../index.ts';
15
12
  import type { OttoConfig } from '@ottocode/sdk';
16
- import { loadAgentsConfig } from '../../runtime/agent/registry.ts';
13
+ export { discoverAllAgents } from '../../runtime/agent/registry.ts';
17
14
 
18
15
  export type ProviderDetail = {
19
16
  id: string;
@@ -138,47 +135,3 @@ export async function getAuthTypeForProvider(
138
135
  const auth = await getAuth(provider, projectRoot);
139
136
  return auth?.type as 'api' | 'oauth' | 'wallet' | undefined;
140
137
  }
141
-
142
- export async function discoverAllAgents(
143
- projectRoot: string,
144
- ): Promise<string[]> {
145
- const builtInAgents = ['general', 'build', 'plan', 'init'];
146
- const agentSet = new Set<string>(builtInAgents);
147
-
148
- try {
149
- const agentsJson = await loadAgentsConfig(projectRoot);
150
- for (const agentName of Object.keys(agentsJson)) {
151
- if (agentName.trim()) {
152
- agentSet.add(agentName);
153
- }
154
- }
155
- } catch {}
156
-
157
- try {
158
- const localAgentsPath = join(projectRoot, '.otto', 'agents');
159
- const localFiles = await readdir(localAgentsPath).catch(() => []);
160
- for (const file of localFiles) {
161
- if (file.endsWith('.txt') || file.endsWith('.md')) {
162
- const agentName = file.replace(/\.(txt|md)$/, '');
163
- if (agentName.trim()) {
164
- agentSet.add(agentName);
165
- }
166
- }
167
- }
168
- } catch {}
169
-
170
- try {
171
- const globalAgentsPath = getGlobalAgentsDir();
172
- const globalFiles = await readdir(globalAgentsPath).catch(() => []);
173
- for (const file of globalFiles) {
174
- if (file.endsWith('.txt') || file.endsWith('.md')) {
175
- const agentName = file.replace(/\.(txt|md)$/, '');
176
- if (agentName.trim()) {
177
- agentSet.add(agentName);
178
- }
179
- }
180
- }
181
- } catch {}
182
-
183
- return Array.from(agentSet).sort();
184
- }
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
- import { join, relative } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative, resolve } from 'node:path';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { exec } from 'node:child_process';
6
7
  import { promisify } from 'node:util';
@@ -12,6 +13,12 @@ const execAsync = promisify(exec);
12
13
 
13
14
  const EXCLUDED_FILES = new Set(['.DS_Store', 'bun.lockb']);
14
15
 
16
+ const HOME_SEARCH_MAX_DEPTH = 3;
17
+ const HOME_SEARCH_LIMIT = 500;
18
+ const DEFAULT_SEARCH_MAX_DEPTH = 12;
19
+ const DEFAULT_SEARCH_LIMIT = 10_000;
20
+ const TREE_ENTRY_LIMIT = 1000;
21
+
15
22
  const EXCLUDED_DIRS = new Set([
16
23
  'node_modules',
17
24
  '.git',
@@ -37,6 +44,36 @@ const EXCLUDED_DIRS = new Set([
37
44
  '.vscode',
38
45
  ]);
39
46
 
47
+ type SearchPolicy = {
48
+ maxDepth: number;
49
+ limit: number;
50
+ includeIgnored: boolean;
51
+ };
52
+
53
+ function isHomeDirectory(projectRoot: string): boolean {
54
+ return resolve(projectRoot) === resolve(homedir());
55
+ }
56
+
57
+ function clampNumber(value: number, min: number, max: number): number {
58
+ if (!Number.isFinite(value)) return max;
59
+ return Math.min(Math.max(value, min), max);
60
+ }
61
+
62
+ function getSearchPolicy(projectRoot: string): SearchPolicy {
63
+ if (isHomeDirectory(projectRoot)) {
64
+ return {
65
+ maxDepth: HOME_SEARCH_MAX_DEPTH,
66
+ limit: HOME_SEARCH_LIMIT,
67
+ includeIgnored: false,
68
+ };
69
+ }
70
+ return {
71
+ maxDepth: DEFAULT_SEARCH_MAX_DEPTH,
72
+ limit: DEFAULT_SEARCH_LIMIT,
73
+ includeIgnored: false,
74
+ };
75
+ }
76
+
40
77
  function shouldExcludeFile(name: string): boolean {
41
78
  return EXCLUDED_FILES.has(name);
42
79
  }
@@ -45,20 +82,26 @@ function shouldExcludeDir(name: string): boolean {
45
82
  return EXCLUDED_DIRS.has(name);
46
83
  }
47
84
 
85
+ function shouldExcludeSearchDir(name: string): boolean {
86
+ return shouldExcludeDir(name) || name.startsWith('.');
87
+ }
88
+
48
89
  async function listFilesWithRg(
49
90
  projectRoot: string,
91
+ maxDepth: number,
50
92
  limit: number,
51
93
  includeIgnored = false,
94
+ query = '',
52
95
  ): Promise<{ files: string[]; truncated: boolean }> {
53
96
  const rgBin = await resolveBinary('rg');
54
97
 
55
98
  return new Promise((resolve) => {
56
- const args = ['--files', '--hidden', '--glob', '!.*/', '--sort', 'path'];
99
+ const args = ['--files', '--sort', 'path', '--max-depth', String(maxDepth)];
57
100
  if (includeIgnored) {
58
101
  args.push('--no-ignore');
59
102
  }
60
103
  for (const dir of EXCLUDED_DIRS) {
61
- args.push('--glob', `!**/${dir}/`);
104
+ args.push('--glob', `!**/${dir}/**`);
62
105
  }
63
106
 
64
107
  const proc = spawn(rgBin, args, { cwd: projectRoot });
@@ -85,9 +128,12 @@ async function listFilesWithRg(
85
128
 
86
129
  const allFiles = stdout.split('\n').filter(Boolean);
87
130
 
131
+ const normalizedQuery = query.trim().toLowerCase();
88
132
  const filtered = allFiles.filter((f) => {
89
133
  const filename = f.split(/[\\/]/).pop() || f;
90
- return !shouldExcludeFile(filename);
134
+ if (shouldExcludeFile(filename)) return false;
135
+ if (!normalizedQuery) return true;
136
+ return f.toLowerCase().includes(normalizedQuery);
91
137
  });
92
138
 
93
139
  const truncated = filtered.length > limit;
@@ -170,7 +216,7 @@ async function traverseDirectory(
170
216
  const relativePath = relative(projectRoot, fullPath);
171
217
 
172
218
  if (entry.isDirectory()) {
173
- if (shouldExcludeDir(entry.name)) continue;
219
+ if (shouldExcludeSearchDir(entry.name)) continue;
174
220
  if (
175
221
  gitignorePatterns &&
176
222
  matchesGitignorePattern(relativePath, gitignorePatterns)
@@ -271,10 +317,24 @@ export function registerFilesRoutes(app: Hono) {
271
317
  app.get('/v1/files', async (c) => {
272
318
  try {
273
319
  const projectRoot = c.req.query('project') || process.cwd();
274
- const maxDepth = Number.parseInt(c.req.query('maxDepth') || '10', 10);
275
- const limit = Number.parseInt(c.req.query('limit') || '10000', 10);
320
+ const policy = getSearchPolicy(projectRoot);
321
+ const maxDepth = clampNumber(
322
+ Number.parseInt(c.req.query('maxDepth') || String(policy.maxDepth), 10),
323
+ 1,
324
+ policy.maxDepth,
325
+ );
326
+ const limit = clampNumber(
327
+ Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
328
+ 1,
329
+ policy.limit,
330
+ );
276
331
 
277
- let result = await listFilesWithRg(projectRoot, limit, true);
332
+ let result = await listFilesWithRg(
333
+ projectRoot,
334
+ maxDepth,
335
+ limit,
336
+ policy.includeIgnored,
337
+ );
278
338
 
279
339
  if (result.files.length === 0) {
280
340
  const gitignorePatterns = await parseGitignore(projectRoot);
@@ -315,6 +375,11 @@ export function registerFilesRoutes(app: Hono) {
315
375
  }),
316
376
  ),
317
377
  truncated: result.truncated,
378
+ policy: {
379
+ maxDepth,
380
+ limit,
381
+ home: isHomeDirectory(projectRoot),
382
+ },
318
383
  });
319
384
  } catch (err) {
320
385
  logger.error('Files route error:', err);
@@ -322,32 +387,126 @@ export function registerFilesRoutes(app: Hono) {
322
387
  }
323
388
  });
324
389
 
390
+ app.get('/v1/files/search', async (c) => {
391
+ try {
392
+ const projectRoot = c.req.query('project') || process.cwd();
393
+ const query = c.req.query('q') || '';
394
+ const policy = getSearchPolicy(projectRoot);
395
+ const maxDepth = clampNumber(
396
+ Number.parseInt(c.req.query('maxDepth') || String(policy.maxDepth), 10),
397
+ 1,
398
+ policy.maxDepth,
399
+ );
400
+ const limit = clampNumber(
401
+ Number.parseInt(c.req.query('limit') || String(policy.limit), 10),
402
+ 1,
403
+ policy.limit,
404
+ );
405
+
406
+ let result = await listFilesWithRg(
407
+ projectRoot,
408
+ maxDepth,
409
+ limit,
410
+ policy.includeIgnored,
411
+ query,
412
+ );
413
+
414
+ if (result.files.length === 0) {
415
+ const gitignorePatterns = await parseGitignore(projectRoot);
416
+ result = await traverseDirectory(
417
+ projectRoot,
418
+ projectRoot,
419
+ maxDepth,
420
+ 0,
421
+ limit,
422
+ [],
423
+ gitignorePatterns,
424
+ );
425
+ const normalizedQuery = query.trim().toLowerCase();
426
+ if (normalizedQuery) {
427
+ const files = result.files.filter((file) =>
428
+ file.toLowerCase().includes(normalizedQuery),
429
+ );
430
+ result = {
431
+ files: files.slice(0, limit),
432
+ truncated: files.length > limit,
433
+ };
434
+ }
435
+ }
436
+
437
+ const [changedFiles, ignoredFiles] = await Promise.all([
438
+ getChangedFiles(projectRoot),
439
+ getGitIgnoredFiles(projectRoot, result.files),
440
+ ]);
441
+
442
+ result.files.sort((a, b) => {
443
+ const aIgnored = ignoredFiles.has(a);
444
+ const bIgnored = ignoredFiles.has(b);
445
+ if (aIgnored !== bIgnored) return aIgnored ? 1 : -1;
446
+ const aChanged = changedFiles.has(a);
447
+ const bChanged = changedFiles.has(b);
448
+ if (aChanged && !bChanged) return -1;
449
+ if (!aChanged && bChanged) return 1;
450
+ return a.localeCompare(b);
451
+ });
452
+
453
+ return c.json({
454
+ files: result.files,
455
+ ignoredFiles: Array.from(ignoredFiles),
456
+ changedFiles: Array.from(changedFiles.entries()).map(
457
+ ([path, status]) => ({
458
+ path,
459
+ status,
460
+ }),
461
+ ),
462
+ truncated: result.truncated,
463
+ policy: {
464
+ maxDepth,
465
+ limit,
466
+ home: isHomeDirectory(projectRoot),
467
+ },
468
+ });
469
+ } catch (err) {
470
+ logger.error('Files search route error:', err);
471
+ return c.json({ error: serializeError(err) }, 500);
472
+ }
473
+ });
474
+
325
475
  app.get('/v1/files/tree', async (c) => {
326
476
  try {
327
477
  const projectRoot = c.req.query('project') || process.cwd();
328
478
  const dirPath = c.req.query('path') || '.';
329
- const targetDir = join(projectRoot, dirPath);
479
+ const targetDir = resolve(projectRoot, dirPath);
480
+ if (!targetDir.startsWith(resolve(projectRoot))) {
481
+ return c.json({ error: 'Path traversal not allowed' }, 403);
482
+ }
330
483
 
331
484
  const gitignorePatterns = await parseGitignore(projectRoot);
332
485
  const entries = await readdir(targetDir, { withFileTypes: true });
486
+ const truncated = entries.length > TREE_ENTRY_LIMIT;
333
487
 
334
488
  const items: Array<{
335
489
  name: string;
336
490
  path: string;
337
491
  type: 'file' | 'directory';
338
492
  gitignored?: boolean;
493
+ vendor?: boolean;
494
+ searchable?: boolean;
339
495
  }> = [];
340
496
 
341
- for (const entry of entries) {
497
+ for (const entry of entries.slice(0, TREE_ENTRY_LIMIT)) {
342
498
  const relPath = relative(projectRoot, join(targetDir, entry.name));
343
499
 
344
500
  if (entry.isDirectory()) {
345
501
  const ignored = matchesGitignorePattern(relPath, gitignorePatterns);
502
+ const vendor = shouldExcludeDir(entry.name);
346
503
  items.push({
347
504
  name: entry.name,
348
505
  path: relPath,
349
506
  type: 'directory',
350
507
  gitignored: ignored || undefined,
508
+ vendor: vendor || undefined,
509
+ searchable: vendor || ignored ? false : undefined,
351
510
  });
352
511
  } else if (entry.isFile()) {
353
512
  if (shouldExcludeFile(entry.name)) continue;
@@ -357,6 +516,7 @@ export function registerFilesRoutes(app: Hono) {
357
516
  path: relPath,
358
517
  type: 'file',
359
518
  gitignored: ignored || undefined,
519
+ searchable: ignored ? false : undefined,
360
520
  });
361
521
  }
362
522
  }
@@ -369,7 +529,7 @@ export function registerFilesRoutes(app: Hono) {
369
529
  return a.name.localeCompare(b.name);
370
530
  });
371
531
 
372
- return c.json({ items, path: dirPath });
532
+ return c.json({ items, path: dirPath, truncated });
373
533
  } catch (err) {
374
534
  logger.error('Files tree route error:', err);
375
535
  return c.json({ error: serializeError(err) }, 500);
@@ -1,6 +1,8 @@
1
1
  import { getGlobalAgentsJsonPath, getGlobalAgentsDir } from '@ottocode/sdk';
2
2
  import type { ProviderName } from '@ottocode/sdk';
3
3
  import { catalog } from '@ottocode/sdk';
4
+ import { readdir } from 'node:fs/promises';
5
+ import { join } from 'node:path';
4
6
  // Embed default agent prompts; only user overrides read from disk.
5
7
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
6
8
  import AGENT_BUILD from '@ottocode/sdk/prompts/agents/build.txt' with {
@@ -40,6 +42,8 @@ export type AgentConfigEntry = {
40
42
 
41
43
  type AgentsJson = Record<string, AgentConfigEntry>;
42
44
 
45
+ const BUILTIN_AGENT_NAMES = ['build', 'plan', 'general', 'init', 'research'];
46
+
43
47
  function normalizeStringList(value: unknown): string[] {
44
48
  if (!Array.isArray(value)) return [];
45
49
  const seen = new Set<string>();
@@ -221,6 +225,49 @@ export async function loadAgentsConfig(
221
225
  return merged;
222
226
  }
223
227
 
228
+ export async function discoverAllAgents(
229
+ projectRoot: string,
230
+ ): Promise<string[]> {
231
+ const agentSet = new Set<string>(BUILTIN_AGENT_NAMES);
232
+
233
+ try {
234
+ const agentsJson = await loadAgentsConfig(projectRoot);
235
+ for (const agentName of Object.keys(agentsJson)) {
236
+ if (agentName.trim()) {
237
+ agentSet.add(agentName);
238
+ }
239
+ }
240
+ } catch {}
241
+
242
+ try {
243
+ const localAgentsPath = join(projectRoot, '.otto', 'agents');
244
+ const localFiles = await readdir(localAgentsPath).catch(() => []);
245
+ for (const file of localFiles) {
246
+ if (file.endsWith('.txt') || file.endsWith('.md')) {
247
+ const agentName = file.replace(/\.(txt|md)$/, '');
248
+ if (agentName.trim()) {
249
+ agentSet.add(agentName);
250
+ }
251
+ }
252
+ }
253
+ } catch {}
254
+
255
+ try {
256
+ const globalAgentsPath = getGlobalAgentsDir();
257
+ const globalFiles = await readdir(globalAgentsPath).catch(() => []);
258
+ for (const file of globalFiles) {
259
+ if (file.endsWith('.txt') || file.endsWith('.md')) {
260
+ const agentName = file.replace(/\.(txt|md)$/, '');
261
+ if (agentName.trim()) {
262
+ agentSet.add(agentName);
263
+ }
264
+ }
265
+ }
266
+ } catch {}
267
+
268
+ return Array.from(agentSet).sort();
269
+ }
270
+
224
271
  export async function resolveAgentConfig(
225
272
  projectRoot: string,
226
273
  name: string,
@@ -1,5 +1,6 @@
1
1
  // Barrel export for backwards compatibility with @ottocode/server/runtime/agent-registry
2
2
  export {
3
+ discoverAllAgents,
3
4
  resolveAgentConfig,
4
5
  defaultToolsForAgent,
5
6
  type AgentConfigEntry,
@@ -73,6 +73,7 @@ export type AskServerRequest = {
73
73
  credentials?: InjectableCredentials;
74
74
  agentPrompt?: string;
75
75
  tools?: string[];
76
+ images?: Array<{ data: string; mediaType: string }>;
76
77
  };
77
78
 
78
79
  export type AskServerResponse = {
@@ -305,7 +306,9 @@ async function processAskRequest(
305
306
  } as SessionRow;
306
307
  }
307
308
 
308
- validateProviderModel(providerForMessage, modelForMessage, cfg);
309
+ validateProviderModel(providerForMessage, modelForMessage, cfg, {
310
+ wantsVision: Boolean(request.images?.length),
311
+ });
309
312
 
310
313
  if (!request.skipFileConfig && !request.config && !request.credentials) {
311
314
  await ensureProviderEnv(cfg, providerForMessage);
@@ -327,6 +330,7 @@ async function processAskRequest(
327
330
  oneShot: !request.sessionId && !request.last,
328
331
  reasoningText,
329
332
  reasoningLevel,
333
+ images: request.images,
330
334
  });
331
335
 
332
336
  const headerAgent = session.agent ?? agentName;
@@ -0,0 +1,25 @@
1
+ export type AvailableSlashCommand = {
2
+ name: string;
3
+ description: string;
4
+ input?: { hint: string };
5
+ };
6
+
7
+ const AVAILABLE_SLASH_COMMANDS: AvailableSlashCommand[] = [
8
+ {
9
+ name: 'compact',
10
+ description: 'Compact conversation to reduce context size',
11
+ },
12
+ {
13
+ name: 'init',
14
+ description: 'Generate AGENTS.md and .agents docs from the repo structure',
15
+ },
16
+ {
17
+ name: 'share',
18
+ description: 'Share the current session publicly',
19
+ },
20
+ ];
21
+
22
+ /** Returns slash commands that can be sent as chat messages to the server runtime. */
23
+ export function listAvailableSlashCommands(): AvailableSlashCommand[] {
24
+ return AVAILABLE_SLASH_COMMANDS;
25
+ }
@@ -1,7 +1,7 @@
1
- import { desc, eq } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, inArray, ne } from 'drizzle-orm';
2
2
  import type { OttoConfig } from '@ottocode/sdk';
3
3
  import type { DB } from '@ottocode/database';
4
- import { sessions } from '@ottocode/database/schema';
4
+ import { messageParts, messages, sessions } from '@ottocode/database/schema';
5
5
  import {
6
6
  validateProviderModel,
7
7
  isProviderAuthorized,
@@ -11,6 +11,8 @@ import {
11
11
  import { publish } from '../../events/bus.ts';
12
12
 
13
13
  type SessionRow = typeof sessions.$inferSelect;
14
+ type MessageRow = typeof messages.$inferSelect;
15
+ type MessagePartRow = typeof messageParts.$inferSelect;
14
16
 
15
17
  type CreateSessionInput = {
16
18
  db: DB;
@@ -69,7 +71,7 @@ export async function createSession({
69
71
 
70
72
  type GetSessionInput = {
71
73
  db: DB;
72
- projectPath: string;
74
+ projectPath?: string;
73
75
  sessionId: string;
74
76
  };
75
77
 
@@ -84,7 +86,7 @@ export async function getSessionById({
84
86
  .where(eq(sessions.id, sessionId));
85
87
  if (!rows.length) return undefined;
86
88
  const row = rows[0];
87
- if (row.projectPath !== projectPath) return undefined;
89
+ if (projectPath && row.projectPath !== projectPath) return undefined;
88
90
  return row;
89
91
  }
90
92
 
@@ -102,3 +104,64 @@ export async function getLastSession({
102
104
  .limit(1);
103
105
  return rows[0];
104
106
  }
107
+
108
+ type ListSessionsInput = {
109
+ db: DB;
110
+ projectPath?: string;
111
+ limit?: number;
112
+ };
113
+
114
+ export async function listSessions({
115
+ db,
116
+ projectPath,
117
+ limit = 100,
118
+ }: ListSessionsInput): Promise<SessionRow[]> {
119
+ return await db
120
+ .select()
121
+ .from(sessions)
122
+ .where(
123
+ projectPath
124
+ ? and(
125
+ eq(sessions.projectPath, projectPath),
126
+ ne(sessions.sessionType, 'research'),
127
+ )
128
+ : ne(sessions.sessionType, 'research'),
129
+ )
130
+ .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt))
131
+ .limit(limit);
132
+ }
133
+
134
+ export type SessionHistoryMessage = MessageRow & {
135
+ parts: MessagePartRow[];
136
+ };
137
+
138
+ export async function getSessionHistoryMessages(
139
+ db: DB,
140
+ sessionId: string,
141
+ ): Promise<SessionHistoryMessage[]> {
142
+ const rows = await db
143
+ .select()
144
+ .from(messages)
145
+ .where(eq(messages.sessionId, sessionId))
146
+ .orderBy(asc(messages.createdAt));
147
+ const messageIds = rows.map((message) => message.id);
148
+ const parts = messageIds.length
149
+ ? await db
150
+ .select()
151
+ .from(messageParts)
152
+ .where(inArray(messageParts.messageId, messageIds))
153
+ .orderBy(asc(messageParts.messageId), asc(messageParts.index))
154
+ : [];
155
+ const partsByMessageId = new Map<string, MessagePartRow[]>();
156
+ for (const part of parts) {
157
+ const existing = partsByMessageId.get(part.messageId);
158
+ if (existing) existing.push(part);
159
+ else partsByMessageId.set(part.messageId, [part]);
160
+ }
161
+ return rows.map((message) => ({
162
+ ...message,
163
+ parts: (partsByMessageId.get(message.id) ?? []).sort(
164
+ (a, b) => a.index - b.index,
165
+ ),
166
+ }));
167
+ }
@@ -0,0 +1,158 @@
1
+ import { getDb } from '@ottocode/database';
2
+ import {
3
+ messageParts,
4
+ messages,
5
+ sessions,
6
+ shares,
7
+ } from '@ottocode/database/schema';
8
+ import { loadConfig } from '@ottocode/sdk';
9
+ import { eq, inArray } from 'drizzle-orm';
10
+ import { userInfo } from 'node:os';
11
+
12
+ const SHARE_API_URL =
13
+ process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
14
+
15
+ function getUsername(): string {
16
+ try {
17
+ return userInfo().username;
18
+ } catch {
19
+ return 'anonymous';
20
+ }
21
+ }
22
+
23
+ export type ShareSessionResult = {
24
+ shared: true;
25
+ shareId: string;
26
+ url: string;
27
+ message?: string;
28
+ };
29
+
30
+ /** Shares an otto session and returns the public share URL. */
31
+ export async function shareSession(args: {
32
+ sessionId: string;
33
+ projectRoot?: string;
34
+ }): Promise<ShareSessionResult> {
35
+ const cfg = await loadConfig(args.projectRoot || process.cwd());
36
+ const db = await getDb(cfg.projectRoot);
37
+
38
+ const session = await db
39
+ .select()
40
+ .from(sessions)
41
+ .where(eq(sessions.id, args.sessionId))
42
+ .limit(1);
43
+ if (!session.length) {
44
+ throw new Error('Session not found');
45
+ }
46
+
47
+ const existingShare = await db
48
+ .select()
49
+ .from(shares)
50
+ .where(eq(shares.sessionId, args.sessionId))
51
+ .limit(1);
52
+ if (existingShare.length) {
53
+ return {
54
+ shared: true,
55
+ shareId: existingShare[0].shareId,
56
+ url: existingShare[0].url,
57
+ message: 'Already shared',
58
+ };
59
+ }
60
+
61
+ const allMessages = await db
62
+ .select()
63
+ .from(messages)
64
+ .where(eq(messages.sessionId, args.sessionId))
65
+ .orderBy(messages.createdAt);
66
+
67
+ if (!allMessages.length) {
68
+ throw new Error('Session has no messages');
69
+ }
70
+
71
+ const msgParts = await db
72
+ .select()
73
+ .from(messageParts)
74
+ .where(
75
+ inArray(
76
+ messageParts.messageId,
77
+ allMessages.map((message) => message.id),
78
+ ),
79
+ )
80
+ .orderBy(messageParts.index);
81
+
82
+ const partsByMessage = new Map<string, typeof msgParts>();
83
+ for (const part of msgParts) {
84
+ const list = partsByMessage.get(part.messageId) || [];
85
+ list.push(part);
86
+ partsByMessage.set(part.messageId, list);
87
+ }
88
+
89
+ const lastMessageId = allMessages[allMessages.length - 1].id;
90
+ const sess = session[0];
91
+ const sessionData = {
92
+ title: sess.title,
93
+ username: getUsername(),
94
+ agent: sess.agent,
95
+ provider: sess.provider,
96
+ model: sess.model,
97
+ createdAt: sess.createdAt,
98
+ stats: {
99
+ inputTokens: sess.totalInputTokens ?? 0,
100
+ outputTokens: sess.totalOutputTokens ?? 0,
101
+ cachedTokens: sess.totalCachedTokens ?? 0,
102
+ cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
103
+ reasoningTokens: sess.totalReasoningTokens ?? 0,
104
+ toolTimeMs: sess.totalToolTimeMs ?? 0,
105
+ toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
106
+ },
107
+ messages: allMessages.map((message) => ({
108
+ id: message.id,
109
+ role: message.role,
110
+ createdAt: message.createdAt,
111
+ parts: (partsByMessage.get(message.id) || []).map((part) => ({
112
+ type: part.type,
113
+ content: part.content,
114
+ toolName: part.toolName,
115
+ toolCallId: part.toolCallId,
116
+ })),
117
+ })),
118
+ };
119
+
120
+ const res = await fetch(`${SHARE_API_URL}/share`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({
124
+ sessionData,
125
+ title: sess.title,
126
+ lastMessageId,
127
+ }),
128
+ });
129
+
130
+ if (!res.ok) {
131
+ const err = await res.text();
132
+ throw new Error(`Failed to create share: ${err}`);
133
+ }
134
+
135
+ const data = (await res.json()) as {
136
+ shareId: string;
137
+ secret: string;
138
+ url: string;
139
+ };
140
+
141
+ await db.insert(shares).values({
142
+ sessionId: args.sessionId,
143
+ shareId: data.shareId,
144
+ secret: data.secret,
145
+ url: data.url,
146
+ title: sess.title,
147
+ description: null,
148
+ createdAt: Date.now(),
149
+ lastSyncedAt: Date.now(),
150
+ lastSyncedMessageId: lastMessageId,
151
+ });
152
+
153
+ return {
154
+ shared: true,
155
+ shareId: data.shareId,
156
+ url: data.url,
157
+ };
158
+ }