@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.270",
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.270",
65
- "@ottocode/sdk": "0.1.270",
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",
@@ -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 normalized = page.map((r) =>
116
- normalizeSessionRow(r, { includeRunning: true }),
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,
@@ -50,6 +50,7 @@ export async function createSession({
50
50
  projectPath: cfg.projectRoot,
51
51
  createdAt: now,
52
52
  lastActiveAt: now,
53
+ lastViewedAt: now,
53
54
  totalInputTokens: null,
54
55
  totalOutputTokens: null,
55
56
  totalCachedTokens: null,