@ottocode/server 0.1.270 → 0.1.271
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.
|
|
3
|
+
"version": "0.1.271",
|
|
4
4
|
"description": "HTTP API server for ottocode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"typecheck": "tsc --noEmit"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@ottocode/database": "0.1.
|
|
65
|
-
"@ottocode/sdk": "0.1.
|
|
64
|
+
"@ottocode/database": "0.1.271",
|
|
65
|
+
"@ottocode/sdk": "0.1.271",
|
|
66
66
|
"@hono/zod-openapi": "^1.1.5",
|
|
67
67
|
"ai-sdk-ollama": "^3.8.3",
|
|
68
68
|
"drizzle-orm": "^0.44.5",
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const schemas = {
|
|
|
90
90
|
projectPath: { type: 'string' },
|
|
91
91
|
createdAt: { type: 'integer', format: 'int64' },
|
|
92
92
|
lastActiveAt: { type: 'integer', format: 'int64', nullable: true },
|
|
93
|
+
lastViewedAt: { type: 'integer', format: 'int64', nullable: true },
|
|
93
94
|
totalInputTokens: { type: 'integer', nullable: true },
|
|
94
95
|
totalOutputTokens: { type: 'integer', nullable: true },
|
|
95
96
|
totalCachedTokens: { type: 'integer', nullable: true },
|
|
@@ -101,6 +102,17 @@ export const schemas = {
|
|
|
101
102
|
additionalProperties: { type: 'integer' },
|
|
102
103
|
nullable: true,
|
|
103
104
|
},
|
|
105
|
+
fileStats: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
changedFiles: { type: 'integer' },
|
|
109
|
+
additions: { type: 'integer' },
|
|
110
|
+
deletions: { type: 'integer' },
|
|
111
|
+
operations: { type: 'integer' },
|
|
112
|
+
},
|
|
113
|
+
required: ['changedFiles', 'additions', 'deletions', 'operations'],
|
|
114
|
+
nullable: true,
|
|
115
|
+
},
|
|
104
116
|
isRunning: { type: 'boolean' },
|
|
105
117
|
},
|
|
106
118
|
required: ['id', 'agent', 'provider', 'model', 'projectPath', 'createdAt'],
|
|
@@ -143,6 +143,7 @@ export async function createResearchSession(c: Context) {
|
|
|
143
143
|
projectPath: cfg.projectRoot,
|
|
144
144
|
createdAt: now,
|
|
145
145
|
lastActiveAt: now,
|
|
146
|
+
lastViewedAt: now,
|
|
146
147
|
parentSessionId: parentId,
|
|
147
148
|
sessionType: 'research',
|
|
148
149
|
totalInputTokens: null,
|
|
@@ -289,6 +290,7 @@ export async function exportResearchSession(c: Context) {
|
|
|
289
290
|
projectPath: cfg.projectRoot,
|
|
290
291
|
createdAt: now,
|
|
291
292
|
lastActiveAt: now,
|
|
293
|
+
lastViewedAt: now,
|
|
292
294
|
parentSessionId: null,
|
|
293
295
|
sessionType: 'main',
|
|
294
296
|
totalInputTokens: null,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildSessionPreferenceUpdates,
|
|
11
11
|
deleteSessionMessagesAndParts,
|
|
12
12
|
findSessionById,
|
|
13
|
+
getSessionFileStats,
|
|
13
14
|
loadProjectDb,
|
|
14
15
|
normalizeSessionRow,
|
|
15
16
|
} from './service.ts';
|
|
@@ -112,9 +113,16 @@ export function registerSessionCrudRoutes(app: Hono) {
|
|
|
112
113
|
.offset(offset);
|
|
113
114
|
const hasMore = rows.length > limit;
|
|
114
115
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
const fileStats = await getSessionFileStats(db, page);
|
|
117
|
+
const normalized = page.map((r) => {
|
|
118
|
+
const normalizedSession = normalizeSessionRow(r, {
|
|
119
|
+
includeRunning: true,
|
|
120
|
+
});
|
|
121
|
+
const stats = fileStats.get(r.id);
|
|
122
|
+
return stats && stats.changedFiles > 0
|
|
123
|
+
? { ...normalizedSession, fileStats: stats }
|
|
124
|
+
: normalizedSession;
|
|
125
|
+
});
|
|
118
126
|
return c.json({
|
|
119
127
|
items: normalized,
|
|
120
128
|
hasMore,
|
|
@@ -318,6 +326,83 @@ export function registerSessionCrudRoutes(app: Hono) {
|
|
|
318
326
|
},
|
|
319
327
|
);
|
|
320
328
|
|
|
329
|
+
// Mark session as viewed by the user
|
|
330
|
+
openApiRoute(
|
|
331
|
+
app,
|
|
332
|
+
{
|
|
333
|
+
method: 'post',
|
|
334
|
+
path: '/v1/sessions/{sessionId}/viewed',
|
|
335
|
+
tags: ['sessions'],
|
|
336
|
+
operationId: 'markSessionViewed',
|
|
337
|
+
summary: 'Mark a session as viewed',
|
|
338
|
+
parameters: [
|
|
339
|
+
{
|
|
340
|
+
in: 'path',
|
|
341
|
+
name: 'sessionId',
|
|
342
|
+
required: true,
|
|
343
|
+
schema: {
|
|
344
|
+
type: 'string',
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
in: 'query',
|
|
349
|
+
name: 'project',
|
|
350
|
+
required: false,
|
|
351
|
+
schema: {
|
|
352
|
+
type: 'string',
|
|
353
|
+
},
|
|
354
|
+
description:
|
|
355
|
+
'Project root override (defaults to current working directory).',
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
responses: {
|
|
359
|
+
'200': {
|
|
360
|
+
description: 'OK',
|
|
361
|
+
content: {
|
|
362
|
+
'application/json': {
|
|
363
|
+
schema: {
|
|
364
|
+
$ref: '#/components/schemas/Session',
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
'404': {
|
|
370
|
+
description: 'Not Found',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
async (c) => {
|
|
375
|
+
try {
|
|
376
|
+
const sessionId = c.req.param('sessionId');
|
|
377
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
378
|
+
const { cfg, db } = await loadProjectDb(projectRoot);
|
|
379
|
+
const existingSession = await findSessionById(db, sessionId);
|
|
380
|
+
if (
|
|
381
|
+
!existingSession ||
|
|
382
|
+
existingSession.projectPath !== cfg.projectRoot
|
|
383
|
+
) {
|
|
384
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await db
|
|
388
|
+
.update(sessions)
|
|
389
|
+
.set({ lastViewedAt: Date.now() })
|
|
390
|
+
.where(eq(sessions.id, sessionId));
|
|
391
|
+
|
|
392
|
+
const updatedSession = await findSessionById(db, sessionId);
|
|
393
|
+
return c.json(
|
|
394
|
+
normalizeSessionRow(updatedSession ?? existingSession, {
|
|
395
|
+
includeRunning: true,
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
logger.error('Failed to mark session viewed', err);
|
|
400
|
+
const errorResponse = serializeError(err);
|
|
401
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
|
|
321
406
|
// Update session preferences
|
|
322
407
|
openApiRoute(
|
|
323
408
|
app,
|
|
@@ -32,6 +32,26 @@ export type SessionRow = typeof sessions.$inferSelect;
|
|
|
32
32
|
export type MessageRow = typeof messages.$inferSelect;
|
|
33
33
|
export type MessagePartRow = typeof messageParts.$inferSelect;
|
|
34
34
|
|
|
35
|
+
const FILE_EDIT_TOOLS = [
|
|
36
|
+
'Write',
|
|
37
|
+
'Edit',
|
|
38
|
+
'MultiEdit',
|
|
39
|
+
'CopyInto',
|
|
40
|
+
'ApplyPatch',
|
|
41
|
+
'write',
|
|
42
|
+
'edit',
|
|
43
|
+
'multiedit',
|
|
44
|
+
'copy_into',
|
|
45
|
+
'apply_patch',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface SessionFileStats {
|
|
49
|
+
changedFiles: number;
|
|
50
|
+
additions: number;
|
|
51
|
+
deletions: number;
|
|
52
|
+
operations: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
export type ProjectDbContext = {
|
|
36
56
|
cfg: Awaited<ReturnType<typeof loadConfig>>;
|
|
37
57
|
db: Awaited<ReturnType<typeof getDb>>;
|
|
@@ -62,6 +82,161 @@ export function normalizeSessionRow(
|
|
|
62
82
|
return { ...base, isRunning };
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
function getRecord(value: unknown): Record<string, unknown> | null {
|
|
86
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
87
|
+
? (value as Record<string, unknown>)
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getNumber(value: unknown): number {
|
|
92
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addStringFile(files: Set<string>, value: unknown) {
|
|
96
|
+
if (typeof value === 'string' && value.trim()) {
|
|
97
|
+
files.add(value.trim());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addPatchFiles(files: Set<string>, patch: unknown) {
|
|
102
|
+
if (typeof patch !== 'string') return;
|
|
103
|
+
for (const match of patch.matchAll(
|
|
104
|
+
/\*\*\* (?:Update|Add|Delete) File: (.+)/g,
|
|
105
|
+
)) {
|
|
106
|
+
addStringFile(files, match[1]);
|
|
107
|
+
}
|
|
108
|
+
const unifiedMatch = patch.match(/^(?:---|\+\+\+) [ab]\/(.+)$/m);
|
|
109
|
+
if (unifiedMatch) addStringFile(files, unifiedMatch[1]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractFilesFromToolContent(content: unknown): Set<string> {
|
|
113
|
+
const files = new Set<string>();
|
|
114
|
+
const data = getRecord(content);
|
|
115
|
+
if (!data) return files;
|
|
116
|
+
|
|
117
|
+
const args = getRecord(data.args);
|
|
118
|
+
addStringFile(files, data.path);
|
|
119
|
+
addStringFile(files, data.targetPath);
|
|
120
|
+
addStringFile(files, args?.path);
|
|
121
|
+
addStringFile(files, args?.targetPath);
|
|
122
|
+
|
|
123
|
+
const rawFiles = data.files;
|
|
124
|
+
if (Array.isArray(rawFiles)) {
|
|
125
|
+
for (const file of rawFiles) {
|
|
126
|
+
if (typeof file === 'string') addStringFile(files, file);
|
|
127
|
+
else addStringFile(files, getRecord(file)?.path);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
addPatchFiles(files, data.patch ?? args?.patch);
|
|
132
|
+
const result = getRecord(data.result);
|
|
133
|
+
const resultArtifact = getRecord(result?.artifact);
|
|
134
|
+
addPatchFiles(files, resultArtifact?.patch);
|
|
135
|
+
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractArtifactSummary(content: unknown) {
|
|
140
|
+
const data = getRecord(content);
|
|
141
|
+
const artifact =
|
|
142
|
+
getRecord(getRecord(data?.result)?.artifact) ?? getRecord(data?.artifact);
|
|
143
|
+
const summary = getRecord(artifact?.summary);
|
|
144
|
+
return {
|
|
145
|
+
files: getNumber(summary?.files),
|
|
146
|
+
additions: getNumber(summary?.additions),
|
|
147
|
+
deletions: getNumber(summary?.deletions),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function getSessionFileStats(
|
|
152
|
+
db: ProjectDbContext['db'],
|
|
153
|
+
rows: SessionRow[],
|
|
154
|
+
): Promise<Map<string, SessionFileStats>> {
|
|
155
|
+
const sessionIds = rows.map((row) => row.id);
|
|
156
|
+
const statsBySessionId = new Map<string, SessionFileStats>();
|
|
157
|
+
if (sessionIds.length === 0) return statsBySessionId;
|
|
158
|
+
|
|
159
|
+
for (const sessionId of sessionIds) {
|
|
160
|
+
statsBySessionId.set(sessionId, {
|
|
161
|
+
changedFiles: 0,
|
|
162
|
+
additions: 0,
|
|
163
|
+
deletions: 0,
|
|
164
|
+
operations: 0,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const messageRows = await db
|
|
169
|
+
.select({ id: messages.id, sessionId: messages.sessionId })
|
|
170
|
+
.from(messages)
|
|
171
|
+
.where(inArray(messages.sessionId, sessionIds));
|
|
172
|
+
if (messageRows.length === 0) return statsBySessionId;
|
|
173
|
+
|
|
174
|
+
const sessionIdByMessageId = new Map(
|
|
175
|
+
messageRows.map((message) => [message.id, message.sessionId]),
|
|
176
|
+
);
|
|
177
|
+
const parts = await db
|
|
178
|
+
.select({
|
|
179
|
+
messageId: messageParts.messageId,
|
|
180
|
+
type: messageParts.type,
|
|
181
|
+
toolName: messageParts.toolName,
|
|
182
|
+
content: messageParts.content,
|
|
183
|
+
})
|
|
184
|
+
.from(messageParts)
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
inArray(
|
|
188
|
+
messageParts.messageId,
|
|
189
|
+
messageRows.map((message) => message.id),
|
|
190
|
+
),
|
|
191
|
+
inArray(messageParts.toolName, FILE_EDIT_TOOLS),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const fileSetsBySessionId = new Map<string, Set<string>>();
|
|
196
|
+
const minimumFileCounts = new Map<string, number>();
|
|
197
|
+
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
const sessionId = sessionIdByMessageId.get(part.messageId);
|
|
200
|
+
if (!sessionId || !part.toolName) continue;
|
|
201
|
+
|
|
202
|
+
let content: unknown;
|
|
203
|
+
try {
|
|
204
|
+
content = JSON.parse(part.content);
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let fileSet = fileSetsBySessionId.get(sessionId);
|
|
210
|
+
if (!fileSet) {
|
|
211
|
+
fileSet = new Set<string>();
|
|
212
|
+
fileSetsBySessionId.set(sessionId, fileSet);
|
|
213
|
+
}
|
|
214
|
+
for (const file of extractFilesFromToolContent(content)) fileSet.add(file);
|
|
215
|
+
|
|
216
|
+
if (part.type === 'tool_result') {
|
|
217
|
+
const stats = statsBySessionId.get(sessionId);
|
|
218
|
+
if (!stats) continue;
|
|
219
|
+
const summary = extractArtifactSummary(content);
|
|
220
|
+
stats.operations++;
|
|
221
|
+
stats.additions += summary.additions;
|
|
222
|
+
stats.deletions += summary.deletions;
|
|
223
|
+
minimumFileCounts.set(
|
|
224
|
+
sessionId,
|
|
225
|
+
Math.max(minimumFileCounts.get(sessionId) ?? 0, summary.files),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const [sessionId, stats] of statsBySessionId) {
|
|
231
|
+
stats.changedFiles = Math.max(
|
|
232
|
+
fileSetsBySessionId.get(sessionId)?.size ?? 0,
|
|
233
|
+
minimumFileCounts.get(sessionId) ?? 0,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return statsBySessionId;
|
|
238
|
+
}
|
|
239
|
+
|
|
65
240
|
export async function loadProjectDb(
|
|
66
241
|
projectRoot = process.cwd(),
|
|
67
242
|
): Promise<ProjectDbContext> {
|
|
@@ -90,6 +90,7 @@ export async function createBranch({
|
|
|
90
90
|
projectPath: parent.projectPath,
|
|
91
91
|
createdAt: now,
|
|
92
92
|
lastActiveAt: now,
|
|
93
|
+
lastViewedAt: now,
|
|
93
94
|
parentSessionId,
|
|
94
95
|
branchPointMessageId: fromMessageId,
|
|
95
96
|
sessionType: 'branch',
|
|
@@ -168,6 +169,7 @@ export async function createBranch({
|
|
|
168
169
|
projectPath: newSession.projectPath,
|
|
169
170
|
createdAt: newSession.createdAt,
|
|
170
171
|
lastActiveAt: newSession.lastActiveAt ?? null,
|
|
172
|
+
lastViewedAt: now,
|
|
171
173
|
totalInputTokens: null,
|
|
172
174
|
totalOutputTokens: null,
|
|
173
175
|
totalCachedTokens: null,
|