@ottocode/server 0.1.256 → 0.1.257
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 +15 -3
- package/src/openapi/paths/files.ts +68 -1
- package/src/routes/files.ts +171 -11
- package/src/runtime/commands/available.ts +25 -0
- package/src/runtime/session/manager.ts +67 -4
- package/src/runtime/share/service.ts +158 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.257",
|
|
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.
|
|
53
|
-
"@ottocode/sdk": "0.1.
|
|
64
|
+
"@ottocode/database": "0.1.257",
|
|
65
|
+
"@ottocode/sdk": "0.1.257",
|
|
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
|
},
|
package/src/routes/files.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Hono } from 'hono';
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
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', '--
|
|
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
|
-
|
|
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 (
|
|
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
|
|
275
|
-
const
|
|
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(
|
|
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 =
|
|
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);
|
|
@@ -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
|
|
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
|
+
}
|