@omercnet/paseo-context-mode 3.1.3 → 3.1.4-next.434.1

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.
@@ -1,56 +1,50 @@
1
- import { basename } from "node:path";
1
+ import { realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import type {
4
+ AgentAnalytics,
3
5
  AnalyticsByteAccounting,
4
6
  AnalyticsDashboard,
5
7
  AnalyticsWarning,
6
8
  IndexedSourceAnalytics,
7
- ProviderAnalytics,
9
+ ProjectAnalytics,
10
+ SessionAnalytics,
8
11
  } from "../shared/analytics";
12
+ import { CONTEXT_MODE_ANALYTICS_SCHEMA_VERSION } from "../shared/analytics";
9
13
  import {
10
- CONTEXT_MODE_ANALYTICS_SCHEMA_VERSION,
11
- SUPPORTED_CONTEXT_MODE_DATABASE_VERSION,
12
- } from "../shared/analytics";
14
+ CONTEXT_MODE_PROVIDERS,
15
+ IN_PROCESS_PROVIDERS,
16
+ providerForPlatform,
17
+ } from "../shared/providers";
13
18
  import {
14
19
  ContextModeDatabaseError,
15
20
  type ContextModeDatabaseSnapshot,
21
+ type DatabaseSavings,
16
22
  readContextModeDatabase,
17
- type SessionDatabaseSnapshot,
18
- } from "./context-mode-db";
19
- import { type ProviderConfigSource, readProviderOverrides } from "./integration";
23
+ } from "./database";
24
+ import { type ProviderOverrides, resolveProvider } from "./integration";
20
25
  import {
21
- discoverContextModeStorageRoots,
22
- listRegularContextModeDatabases,
23
- type StorageDiscoveryOptions,
24
- type StorageDiscoveryResult,
26
+ type ContextModeProjectDatabase,
27
+ discoverContextModeDatabases,
28
+ resolveContextModeDataDir,
25
29
  } from "./storage-discovery";
26
30
 
27
31
  const MAX_WARNINGS = 20;
28
32
  const MAX_SOURCES = 2_000;
29
- const MAX_PROVIDERS = 32;
33
+ const MAX_AGENTS = 32;
34
+ const MAX_PROJECTS = 50;
35
+ const MAX_RECENT_SESSIONS = 20;
30
36
  const MAX_CATEGORIES = 128;
31
37
  const MAX_DAYS = 400;
32
38
 
33
- interface MutableByteAccounting {
34
- eventDataBytes: number;
35
- avoidedBytes: number;
36
- returnedBytes: number;
37
- snapshotBytes: number;
39
+ interface MutableBytes extends DatabaseSavings {
38
40
  indexedBytes: number;
39
41
  }
40
42
 
41
- interface MutableAggregate {
42
- databases: number;
43
- events: number;
44
- toolCalls: number;
43
+ interface MutableAgent extends MutableBytes {
44
+ agent: string;
45
+ projects: number;
46
+ sessions: number;
45
47
  indexedSources: number;
46
- indexedChunks: number;
47
- sessions: Set<string>;
48
- projects: Set<string>;
49
- bytes: MutableByteAccounting;
50
- }
51
-
52
- interface MutableProviderAggregate extends MutableAggregate {
53
- provider: string;
54
48
  }
55
49
 
56
50
  interface MutableSummary {
@@ -58,22 +52,16 @@ interface MutableSummary {
58
52
  savedBytes: number;
59
53
  }
60
54
 
61
- interface MutableSource {
62
- provider: string;
63
- source: string;
64
- chunks: number;
65
- codeChunks: number;
66
- indexedBytes: number;
67
- indexedAt: string | null;
68
- state: "ready" | "empty" | "unavailable";
69
- detail?: string;
70
- }
71
-
72
- export interface ContextModeAnalyticsDependencies extends StorageDiscoveryOptions {
55
+ export interface ContextModeAnalyticsDependencies {
56
+ home?: string;
57
+ env?: NodeJS.ProcessEnv;
73
58
  now?: () => Date;
74
- discoverStorage?: (options?: StorageDiscoveryOptions) => Promise<StorageDiscoveryResult>;
75
- listDatabases?: typeof listRegularContextModeDatabases;
59
+ discover?: typeof discoverContextModeDatabases;
76
60
  readDatabase?: (path: string) => ContextModeDatabaseSnapshot;
61
+ /** Detected Context Mode runtime version; must resolve to null rather than reject. */
62
+ resolveVersion?: () => Promise<string | null>;
63
+ /** Paseo's configured providers; each may move its agents' data dir through its own env. */
64
+ providers?: ProviderOverrides;
77
65
  }
78
66
 
79
67
  class WarningCollector {
@@ -81,14 +69,13 @@ class WarningCollector {
81
69
  #omitted = 0;
82
70
 
83
71
  add(warning: AnalyticsWarning): void {
84
- const normalized: AnalyticsWarning = {
85
- ...warning,
86
- ...(warning.provider ? { provider: warning.provider.slice(0, 128) } : {}),
87
- ...(warning.database ? { database: warning.database.slice(0, 512) } : {}),
88
- message: warning.message.slice(0, 512),
89
- };
90
- if (this.items.length < MAX_WARNINGS - 1) this.items.push(normalized);
91
- else this.#omitted++;
72
+ if (this.items.length < MAX_WARNINGS - 1) {
73
+ this.items.push({
74
+ ...warning,
75
+ ...(warning.project ? { project: warning.project.slice(0, 128) } : {}),
76
+ message: warning.message.slice(0, 512),
77
+ });
78
+ } else this.#omitted++;
92
79
  }
93
80
 
94
81
  finish(): AnalyticsWarning[] {
@@ -106,147 +93,66 @@ class WarningCollector {
106
93
  }
107
94
  }
108
95
 
109
- function createAggregate(): MutableAggregate {
110
- return {
111
- databases: 0,
112
- events: 0,
113
- toolCalls: 0,
114
- indexedSources: 0,
115
- indexedChunks: 0,
116
- sessions: new Set<string>(),
117
- projects: new Set<string>(),
118
- bytes: {
119
- eventDataBytes: 0,
120
- avoidedBytes: 0,
121
- returnedBytes: 0,
122
- snapshotBytes: 0,
123
- indexedBytes: 0,
124
- },
125
- };
126
- }
127
-
128
96
  function boundedAdd(left: number, right: number): number {
129
97
  return Math.min(Number.MAX_SAFE_INTEGER, left + right);
130
98
  }
131
99
 
132
- function addBytes(target: MutableByteAccounting, source: Partial<MutableByteAccounting>): void {
133
- target.eventDataBytes = boundedAdd(target.eventDataBytes, source.eventDataBytes ?? 0);
134
- target.avoidedBytes = boundedAdd(target.avoidedBytes, source.avoidedBytes ?? 0);
135
- target.returnedBytes = boundedAdd(target.returnedBytes, source.returnedBytes ?? 0);
136
- target.snapshotBytes = boundedAdd(target.snapshotBytes, source.snapshotBytes ?? 0);
137
- target.indexedBytes = boundedAdd(target.indexedBytes, source.indexedBytes ?? 0);
100
+ function emptyBytes(): MutableBytes {
101
+ return { calls: 0, producedBytes: 0, returnedBytes: 0, savedBytes: 0, indexedBytes: 0 };
138
102
  }
139
103
 
140
- function savedBytes(bytes: MutableByteAccounting): number {
141
- return [bytes.eventDataBytes, bytes.avoidedBytes, bytes.snapshotBytes, bytes.indexedBytes].reduce(
142
- boundedAdd,
143
- 0,
144
- );
145
- }
146
-
147
- function byteAccounting(bytes: MutableByteAccounting): AnalyticsByteAccounting {
148
- return { ...bytes, savedBytes: savedBytes(bytes) };
149
- }
150
-
151
- function tokenFields(bytes: MutableByteAccounting): {
152
- inputTokens: number;
153
- outputTokens: number;
154
- savedTokens: number;
155
- savingsPercent: number;
156
- } {
157
- const saved = savedBytes(bytes);
158
- const possibleInput = boundedAdd(saved, bytes.returnedBytes);
159
- return {
160
- inputTokens: Math.floor(possibleInput / 4),
161
- outputTokens: Math.floor(bytes.returnedBytes / 4),
162
- savedTokens: Math.floor(saved / 4),
163
- savingsPercent: possibleInput > 0 ? Math.round((saved / possibleInput) * 1_000) / 10 : 0,
164
- };
104
+ function addBytes(target: MutableBytes, source: MutableBytes): void {
105
+ target.calls = boundedAdd(target.calls, source.calls);
106
+ target.producedBytes = boundedAdd(target.producedBytes, source.producedBytes);
107
+ target.returnedBytes = boundedAdd(target.returnedBytes, source.returnedBytes);
108
+ target.savedBytes = boundedAdd(target.savedBytes, source.savedBytes);
109
+ target.indexedBytes = boundedAdd(target.indexedBytes, source.indexedBytes);
165
110
  }
166
111
 
167
112
  function incrementSummary(
168
113
  summaries: Map<string, MutableSummary>,
169
114
  key: string,
170
115
  count: number,
171
- bytes: number,
116
+ savedBytes: number,
172
117
  ): void {
173
118
  const current = summaries.get(key) ?? { count: 0, savedBytes: 0 };
174
119
  current.count = boundedAdd(current.count, count);
175
- current.savedBytes = boundedAdd(current.savedBytes, bytes);
120
+ current.savedBytes = boundedAdd(current.savedBytes, savedBytes);
176
121
  summaries.set(key, current);
177
122
  }
178
123
 
179
- function mergeSessionSnapshot(
180
- target: MutableAggregate,
181
- snapshot: SessionDatabaseSnapshot,
182
- sessionNamespace: string,
183
- categoryTotals?: Map<string, MutableSummary>,
184
- dayTotals?: Map<string, MutableSummary>,
185
- ): void {
186
- target.events = boundedAdd(target.events, snapshot.events);
187
- target.toolCalls = boundedAdd(target.toolCalls, snapshot.toolCalls);
188
- addBytes(target.bytes, snapshot);
189
- for (const session of snapshot.sessions) target.sessions.add(`${sessionNamespace}\0${session}`);
190
- for (const project of snapshot.projects) target.projects.add(project);
191
- if (categoryTotals) {
192
- for (const category of snapshot.byCategory) {
193
- incrementSummary(categoryTotals, category.category, category.count, category.savedBytes);
194
- }
195
- }
196
- if (dayTotals) {
197
- for (const day of snapshot.byDay) {
198
- incrementSummary(dayTotals, day.date, day.calls, day.savedBytes);
199
- }
200
- }
201
- }
202
-
203
- function mergeSource(target: Map<string, MutableSource>, source: MutableSource): void {
204
- const key = `${source.provider}\0${source.source}`;
205
- const current = target.get(key);
206
- if (!current) {
207
- target.set(key, source);
208
- return;
209
- }
210
- current.chunks = boundedAdd(current.chunks, source.chunks);
211
- current.codeChunks = boundedAdd(current.codeChunks, source.codeChunks);
212
- current.indexedBytes = boundedAdd(current.indexedBytes, source.indexedBytes);
213
- if (source.indexedAt && (!current.indexedAt || source.indexedAt > current.indexedAt)) {
214
- current.indexedAt = source.indexedAt;
215
- }
216
- if (source.state === "ready") current.state = "ready";
217
- else if (source.state === "unavailable" && current.state === "empty")
218
- current.state = "unavailable";
219
- if (!current.detail && source.detail) current.detail = source.detail;
220
- }
221
-
222
- function providerOutput(aggregate: MutableProviderAggregate): ProviderAnalytics {
124
+ /** Token estimate at 4 bytes per token; reduction is saved over produced, as `ctx_stats` reports. */
125
+ function savingsFields(bytes: MutableBytes): {
126
+ savedTokens: number;
127
+ savingsPercent: number;
128
+ byteAccounting: AnalyticsByteAccounting;
129
+ } {
130
+ const reduction = bytes.producedBytes > 0 ? bytes.savedBytes / bytes.producedBytes : 0;
223
131
  return {
224
- provider: aggregate.provider,
225
- databases: aggregate.databases,
226
- projects: aggregate.projects.size,
227
- sessions: aggregate.sessions.size,
228
- events: aggregate.events,
229
- toolCalls: aggregate.toolCalls,
230
- indexedSources: aggregate.indexedSources,
231
- indexedChunks: aggregate.indexedChunks,
232
- ...tokenFields(aggregate.bytes),
233
- byteAccounting: byteAccounting(aggregate.bytes),
132
+ savedTokens: Math.floor(bytes.savedBytes / 4),
133
+ savingsPercent: Math.min(100, Math.round(reduction * 1_000) / 10),
134
+ byteAccounting: {
135
+ producedBytes: bytes.producedBytes,
136
+ returnedBytes: bytes.returnedBytes,
137
+ savedBytes: bytes.savedBytes,
138
+ indexedBytes: bytes.indexedBytes,
139
+ },
234
140
  };
235
141
  }
236
142
 
237
- function databaseWarning(provider: string, path: string, error: unknown): AnalyticsWarning {
143
+ function laterTimestamp(left: string | null, right: string | null): string | null {
144
+ if (!left) return right;
145
+ if (!right) return left;
146
+ return right > left ? right : left;
147
+ }
148
+
149
+ function databaseWarning(project: string, error: unknown): AnalyticsWarning {
238
150
  if (error instanceof ContextModeDatabaseError) {
239
- return {
240
- code: error.code,
241
- provider,
242
- database: error.database,
243
- message: error.message,
244
- };
151
+ return { code: error.code, project: error.project, message: error.message };
245
152
  }
246
153
  return {
247
154
  code: "database-query-failed",
248
- provider,
249
- database: basename(path),
155
+ project,
250
156
  message: "The database could not be inspected.",
251
157
  };
252
158
  }
@@ -254,118 +160,158 @@ function databaseWarning(provider: string, path: string, error: unknown): Analyt
254
160
  export async function collectContextModeAnalytics(
255
161
  dependencies: ContextModeAnalyticsDependencies = {},
256
162
  ): Promise<AnalyticsDashboard> {
257
- const discoverStorage = dependencies.discoverStorage ?? discoverContextModeStorageRoots;
258
- const listDatabases = dependencies.listDatabases ?? listRegularContextModeDatabases;
163
+ const discover = dependencies.discover ?? discoverContextModeDatabases;
259
164
  const readDatabase = dependencies.readDatabase ?? readContextModeDatabase;
260
- const warningCollector = new WarningCollector();
261
- const discovery = await discoverStorage({
262
- home: dependencies.home,
263
- env: dependencies.env,
264
- providers: dependencies.providers,
265
- });
266
- for (const warning of discovery.warnings) warningCollector.add(warning);
165
+ const env = dependencies.env ?? process.env;
166
+ const home = dependencies.home ?? homedir();
167
+ const providers = dependencies.providers ?? {};
168
+ const dataDirOf = (id: string) =>
169
+ resolveContextModeDataDir({ ...env, ...resolveProvider(id, providers).env }, home);
170
+ // Symlinked aliases of one directory must not count its databases twice.
171
+ const canonical = (dir: string) => realpath(dir).catch(() => dir);
172
+ // A configured provider's own CONTEXT_MODE_DATA_DIR is where its injected agents write.
173
+ const dataDirs = new Set(
174
+ await Promise.all(
175
+ [resolveContextModeDataDir(env, home), ...Object.keys(providers).map(dataDirOf)].map(
176
+ canonical,
177
+ ),
178
+ ),
179
+ );
180
+ // Resume and Forget run through a provider, so each row names one that reaches its store,
181
+ // preferring the provider that recorded it.
182
+ const actionable = [
183
+ ...CONTEXT_MODE_PROVIDERS.map((entry) => entry.provider),
184
+ ...IN_PROCESS_PROVIDERS.map((entry) => entry.provider),
185
+ ...Object.keys(providers).filter((id) => {
186
+ const { base, platform } = resolveProvider(id, providers);
187
+ return platform !== null || IN_PROCESS_PROVIDERS.some((entry) => entry.provider === base);
188
+ }),
189
+ ];
190
+ const storeOf = new Map(
191
+ await Promise.all(actionable.map(async (id) => [id, await canonical(dataDirOf(id))] as const)),
192
+ );
193
+ const storeProvider = (agent: string, dataDir: string): string | null => {
194
+ const own = providerForPlatform(agent)?.provider;
195
+ const candidates = own ? [own, ...actionable] : actionable;
196
+ return candidates.find((id) => storeOf.get(id) === dataDir) ?? null;
197
+ };
198
+ const warnings = new WarningCollector();
199
+ const databases: (ContextModeProjectDatabase & { dataDir: string })[] = [];
200
+ for (const dataDir of dataDirs) {
201
+ const discovery = await discover(dataDir);
202
+ databases.push(...discovery.databases.map((database) => ({ ...database, dataDir })));
203
+ for (const warning of discovery.warnings) warnings.add(warning);
204
+ }
267
205
 
268
- const total = createAggregate();
269
- const providers: MutableProviderAggregate[] = [];
206
+ const total = emptyBytes();
207
+ let sessions = 0;
208
+ let events = 0;
209
+ let indexedSources = 0;
210
+ let indexedChunks = 0;
211
+ let lastActivityAt: string | null = null;
212
+ const agents = new Map<string, MutableAgent>();
213
+ const projects: ProjectAnalytics[] = [];
270
214
  const categories = new Map<string, MutableSummary>();
271
215
  const days = new Map<string, MutableSummary>();
272
- const sourceMap = new Map<string, MutableSource>();
273
- let attemptedDatabases = 0;
274
- let successfulDatabases = 0;
275
-
276
- for (const root of discovery.roots) {
277
- const provider: MutableProviderAggregate = { provider: root.provider, ...createAggregate() };
278
- providers.push(provider);
279
-
280
- for (const [directoryKind, directory] of [
281
- ["session", root.sessionsDir],
282
- ["content", root.contentDir],
283
- ] as const) {
284
- const listing = await listDatabases(root.provider, directory);
285
- for (const warning of listing.warnings) warningCollector.add(warning);
286
-
287
- for (const path of listing.files) {
288
- attemptedDatabases++;
289
- try {
290
- const snapshot = readDatabase(path);
291
- successfulDatabases++;
292
- total.databases = boundedAdd(total.databases, 1);
293
- provider.databases = boundedAdd(provider.databases, 1);
294
-
295
- if (snapshot.kind === "session") {
296
- mergeSessionSnapshot(total, snapshot, root.provider, categories, days);
297
- mergeSessionSnapshot(provider, snapshot, root.provider);
298
- continue;
299
- }
300
-
301
- total.indexedSources = boundedAdd(total.indexedSources, snapshot.sources.length);
302
- total.indexedChunks = boundedAdd(total.indexedChunks, snapshot.chunks);
303
- provider.indexedSources = boundedAdd(provider.indexedSources, snapshot.sources.length);
304
- provider.indexedChunks = boundedAdd(provider.indexedChunks, snapshot.chunks);
305
- addBytes(total.bytes, { indexedBytes: snapshot.indexedBytes });
306
- addBytes(provider.bytes, { indexedBytes: snapshot.indexedBytes });
307
- for (const day of snapshot.byDay) {
308
- incrementSummary(days, day.date, day.calls, day.savedBytes);
309
- }
310
- for (const source of snapshot.sources) {
311
- mergeSource(sourceMap, {
312
- provider: root.provider,
313
- source: source.source,
314
- chunks: source.chunks,
315
- codeChunks: source.codeChunks,
316
- indexedBytes: source.indexedBytes,
317
- indexedAt: source.indexedAt,
318
- state: source.chunks > 0 ? "ready" : "empty",
319
- ...(source.detail ? { detail: source.detail } : {}),
320
- });
321
- }
322
- for (const message of snapshot.inconsistencies) {
323
- warningCollector.add({
324
- code: "inconsistent-index",
325
- provider: root.provider,
326
- database: basename(path),
327
- message,
328
- });
329
- }
330
- } catch (error) {
331
- const warning = databaseWarning(root.provider, path, error);
332
- warningCollector.add(warning);
333
- if (directoryKind === "content") {
334
- mergeSource(sourceMap, {
335
- provider: root.provider,
336
- source: `[database] ${basename(path)}`,
337
- chunks: 0,
338
- codeChunks: 0,
339
- indexedBytes: 0,
340
- indexedAt: null,
341
- state: "unavailable",
342
- detail: warning.message,
343
- });
344
- }
345
- }
216
+ const sources: IndexedSourceAnalytics[] = [];
217
+ const recentSessions: SessionAnalytics[] = [];
218
+ let unsupportedSchema = false;
219
+
220
+ for (const database of databases) {
221
+ let snapshot: ContextModeDatabaseSnapshot;
222
+ try {
223
+ snapshot = readDatabase(database.path);
224
+ } catch (error) {
225
+ if (error instanceof ContextModeDatabaseError && error.code === "unsupported-schema") {
226
+ unsupportedSchema = true;
346
227
  }
228
+ warnings.add(databaseWarning(database.project, error));
229
+ continue;
347
230
  }
348
- }
349
231
 
350
- const allSources = [...sourceMap.values()].sort(
351
- (left, right) =>
352
- left.provider.localeCompare(right.provider) || left.source.localeCompare(right.source),
353
- );
354
- if (allSources.length > MAX_SOURCES) {
355
- warningCollector.add({
356
- code: "result-truncated",
357
- message: `Only the first ${MAX_SOURCES} indexed sources are included in the dashboard.`,
232
+ addBytes(total, snapshot);
233
+ sessions = boundedAdd(sessions, snapshot.sessions);
234
+ events = boundedAdd(events, snapshot.events);
235
+ indexedSources = boundedAdd(indexedSources, snapshot.indexedSources);
236
+ indexedChunks = boundedAdd(indexedChunks, snapshot.indexedChunks);
237
+ lastActivityAt = laterTimestamp(lastActivityAt, snapshot.lastActivityAt);
238
+ projects.push({
239
+ path: snapshot.worktrees[0] ?? database.project,
240
+ worktrees: snapshot.worktrees.length,
241
+ sessions: snapshot.sessions,
242
+ toolCalls: snapshot.calls,
243
+ indexedSources: snapshot.indexedSources,
244
+ ...savingsFields(snapshot),
245
+ lastActivityAt: snapshot.lastActivityAt,
358
246
  });
247
+
248
+ for (const summary of snapshot.byAgent) {
249
+ const agent = agents.get(summary.agent) ?? {
250
+ agent: summary.agent,
251
+ projects: 0,
252
+ sessions: 0,
253
+ indexedSources: 0,
254
+ ...emptyBytes(),
255
+ };
256
+ agent.projects = boundedAdd(agent.projects, 1);
257
+ agent.sessions = boundedAdd(agent.sessions, summary.sessions);
258
+ agent.indexedSources = boundedAdd(agent.indexedSources, summary.indexedSources);
259
+ addBytes(agent, summary);
260
+ agents.set(summary.agent, agent);
261
+ }
262
+ for (const tool of snapshot.byTool) {
263
+ incrementSummary(categories, tool.tool, tool.calls, tool.savedBytes);
264
+ }
265
+ for (const day of snapshot.byDay) incrementSummary(days, day.date, day.calls, day.savedBytes);
266
+ for (const source of snapshot.sources) {
267
+ sources.push({
268
+ agent: source.agent,
269
+ projectPath: source.projectPath,
270
+ source: source.label,
271
+ kind: source.kind,
272
+ chunks: source.chunks,
273
+ indexedBytes: source.bytes,
274
+ indexedAt: source.createdAt,
275
+ provider: storeProvider(source.agent, database.dataDir),
276
+ });
277
+ }
278
+ recentSessions.push(
279
+ ...snapshot.sessionList.map((session) => ({
280
+ ...session,
281
+ provider: storeProvider(session.agent, database.dataDir),
282
+ })),
283
+ );
284
+ for (const message of snapshot.truncated) {
285
+ warnings.add({ code: "result-truncated", project: database.project, message });
286
+ }
359
287
  }
360
- const sources: IndexedSourceAnalytics[] = allSources.slice(0, MAX_SOURCES);
361
- const allProviders = providers.map(providerOutput);
288
+
289
+ const allAgents = [...agents.values()]
290
+ .sort(
291
+ (left, right) => right.savedBytes - left.savedBytes || left.agent.localeCompare(right.agent),
292
+ )
293
+ .map(
294
+ (agent): AgentAnalytics => ({
295
+ agent: agent.agent,
296
+ projects: agent.projects,
297
+ sessions: agent.sessions,
298
+ toolCalls: agent.calls,
299
+ indexedSources: agent.indexedSources,
300
+ ...savingsFields(agent),
301
+ }),
302
+ );
362
303
  const allCategories = [...categories]
363
304
  .map(([category, summary]) => ({
364
305
  category,
365
306
  count: summary.count,
366
307
  savedTokens: Math.floor(summary.savedBytes / 4),
367
308
  }))
368
- .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category));
309
+ .sort(
310
+ (left, right) =>
311
+ right.savedTokens - left.savedTokens ||
312
+ right.count - left.count ||
313
+ left.category.localeCompare(right.category),
314
+ );
369
315
  const allDays = [...days]
370
316
  .map(([date, summary]) => ({
371
317
  date,
@@ -373,64 +319,67 @@ export async function collectContextModeAnalytics(
373
319
  savedTokens: Math.floor(summary.savedBytes / 4),
374
320
  }))
375
321
  .sort((left, right) => left.date.localeCompare(right.date));
322
+ sources.sort(
323
+ (left, right) =>
324
+ (right.indexedAt ?? "").localeCompare(left.indexedAt ?? "") ||
325
+ left.source.localeCompare(right.source),
326
+ );
327
+ recentSessions.sort(
328
+ (left, right) =>
329
+ (right.lastActivityAt ?? "").localeCompare(left.lastActivityAt ?? "") ||
330
+ left.sessionId.localeCompare(right.sessionId),
331
+ );
332
+ projects.sort(
333
+ (left, right) =>
334
+ right.byteAccounting.savedBytes - left.byteAccounting.savedBytes ||
335
+ left.path.localeCompare(right.path),
336
+ );
376
337
  for (const [count, maximum, label] of [
377
- [allProviders.length, MAX_PROVIDERS, "provider"],
378
- [allCategories.length, MAX_CATEGORIES, "category"],
338
+ [allAgents.length, MAX_AGENTS, "agent"],
339
+ [allCategories.length, MAX_CATEGORIES, "tool"],
379
340
  [allDays.length, MAX_DAYS, "daily"],
341
+ [sources.length, MAX_SOURCES, "indexed source"],
342
+ [projects.length, MAX_PROJECTS, "project"],
380
343
  ] as const) {
381
344
  if (count > maximum) {
382
- warningCollector.add({
345
+ warnings.add({
383
346
  code: "result-truncated",
384
347
  message: `Only the first ${maximum} ${label} analytics rows are included.`,
385
348
  });
386
349
  }
387
350
  }
388
- const byProvider = allProviders.slice(0, MAX_PROVIDERS);
389
- const byCategory = allCategories.slice(0, MAX_CATEGORIES);
390
- const byDay = allDays.slice(-MAX_DAYS);
391
- const hadWarnings = warningCollector.hasWarnings;
392
- const completeness =
393
- attemptedDatabases > 0 && successfulDatabases === 0
394
- ? "unsupported"
395
- : hadWarnings
396
- ? "partial"
397
- : "complete";
398
- const warnings = warningCollector.finish();
351
+
352
+ // A locked or unopenable store is a transient gap; only a schema mismatch warrants "update Context Mode".
353
+ const unsupported = projects.length === 0 && unsupportedSchema;
354
+ const coverage = warnings.hasWarnings ? "partial" : "complete";
355
+ const contextModeVersion = dependencies.resolveVersion
356
+ ? await dependencies.resolveVersion()
357
+ : null;
399
358
 
400
359
  return {
401
360
  schemaVersion: CONTEXT_MODE_ANALYTICS_SCHEMA_VERSION,
402
- contextModeVersion: SUPPORTED_CONTEXT_MODE_DATABASE_VERSION,
403
- completeness,
404
- warnings,
361
+ contextModeVersion,
362
+ completeness: unsupported ? "unsupported" : coverage,
363
+ warnings: warnings.finish(),
405
364
  totals: {
406
- providers: providers.length,
407
- databases: total.databases,
408
- projects: total.projects.size,
409
- sessions: total.sessions.size,
410
- events: total.events,
411
- toolCalls: total.toolCalls,
412
- indexedSources: total.indexedSources,
413
- indexedChunks: total.indexedChunks,
414
- ...tokenFields(total.bytes),
415
- byteAccounting: byteAccounting(total.bytes),
365
+ agents: agents.size,
366
+ projects: projects.length,
367
+ sessions,
368
+ events,
369
+ toolCalls: total.calls,
370
+ indexedSources,
371
+ indexedChunks,
372
+ inputTokens: Math.floor(total.producedBytes / 4),
373
+ outputTokens: Math.floor(total.returnedBytes / 4),
374
+ ...savingsFields(total),
375
+ lastActivityAt,
416
376
  },
417
- byProvider,
418
- byCategory,
419
- byDay,
420
- sources,
377
+ byAgent: allAgents.slice(0, MAX_AGENTS),
378
+ byProject: projects.slice(0, MAX_PROJECTS),
379
+ recentSessions: recentSessions.slice(0, MAX_RECENT_SESSIONS),
380
+ byCategory: allCategories.slice(0, MAX_CATEGORIES),
381
+ byDay: allDays.slice(-MAX_DAYS),
382
+ sources: sources.slice(0, MAX_SOURCES),
421
383
  generatedAt: (dependencies.now ?? (() => new Date()))().toISOString(),
422
384
  };
423
385
  }
424
-
425
- export function createContextModeAnalyticsHandler(
426
- dependencies: ContextModeAnalyticsDependencies = {},
427
- ): (
428
- _input: Record<string, never>,
429
- context?: { paseo?: ProviderConfigSource },
430
- ) => Promise<AnalyticsDashboard> {
431
- return async (_input, context) =>
432
- collectContextModeAnalytics({
433
- ...dependencies,
434
- providers: dependencies.providers ?? (await readProviderOverrides(context?.paseo)),
435
- });
436
- }