@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.
@@ -0,0 +1,463 @@
1
+ import { lstatSync, type Stats } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { SUPPORTED_CONTEXT_MODE_DATABASE_USER_VERSION } from "../shared/analytics";
5
+ import { PLUGIN_SESSION_ID } from "./mcp-process";
6
+
7
+ const BUSY_TIMEOUT_MS = 150;
8
+ const MAX_DATABASE_BYTES = 512 * 1024 * 1024;
9
+ const MAX_AGENT_ROWS = 256;
10
+ const MAX_TOOL_ROWS = 512;
11
+ const MAX_DAY_ROWS = 400;
12
+ const MAX_WORKTREE_ROWS = 64;
13
+ const MAX_SOURCE_ROWS = 2_000;
14
+ const MAX_SESSION_ROWS = 20;
15
+ const MS_PER_DAY = 86_400_000;
16
+ const MAX_DATE_MS = 8_640_000_000_000_000;
17
+
18
+ const REQUIRED_SCHEMA: Record<string, readonly string[]> = {
19
+ events: [
20
+ "session_id",
21
+ "worktree",
22
+ "agent",
23
+ "kind",
24
+ "tool",
25
+ "sandbox_bytes",
26
+ "returned_bytes",
27
+ "created_at",
28
+ ],
29
+ sources: ["id", "worktree", "agent", "kind", "label", "bytes_total", "chunk_count", "created_ts"],
30
+ };
31
+
32
+ /** Mirrors upstream `ctx_stats`: tool calls, excluding cost rows and ctx_stats itself. */
33
+ const CALL = "tool IS NOT NULL AND kind <> 'cost' AND tool <> 'ctx_stats'";
34
+ /** The panel's own calls stay out of event, session and savings figures; sources it indexes are listed. */
35
+ const AGENT_WORK = `session_id <> '${PLUGIN_SESSION_ID}'`;
36
+ /** Mirrors upstream `ctx_stats` event kinds: cost rows and ctx_stats itself are bookkeeping. */
37
+ const EVENT_COUNT =
38
+ "SUM(CASE WHEN kind <> 'cost' AND (tool IS NULL OR tool <> 'ctx_stats') THEN 1 ELSE 0 END)";
39
+ /** Savings are clamped per (session, agent), exactly as `ctx_stats` sums its scope rows. */
40
+ const SESSION_GROUPS = `SELECT
41
+ session_id,
42
+ agent,
43
+ ${EVENT_COUNT} AS events,
44
+ SUM(CASE WHEN ${CALL} THEN 1 ELSE 0 END) AS calls,
45
+ SUM(CASE WHEN ${CALL} THEN sandbox_bytes ELSE 0 END) AS produced,
46
+ SUM(CASE WHEN ${CALL} THEN returned_bytes ELSE 0 END) AS returned,
47
+ MAX(created_at) AS last
48
+ FROM events
49
+ WHERE ${AGENT_WORK}
50
+ GROUP BY session_id, agent`;
51
+ const SAVINGS_COLUMNS = `COALESCE(SUM(calls), 0) AS calls,
52
+ COALESCE(SUM(produced), 0) AS produced,
53
+ COALESCE(SUM(returned), 0) AS returned,
54
+ COALESCE(SUM(MAX(0, produced - returned)), 0) AS saved`;
55
+ const TOTALS = `SELECT
56
+ COUNT(DISTINCT session_id) AS sessions,
57
+ COALESCE(SUM(events), 0) AS events,
58
+ ${SAVINGS_COLUMNS},
59
+ MAX(last) AS last
60
+ FROM (${SESSION_GROUPS})`;
61
+ const BY_AGENT = `SELECT
62
+ agent,
63
+ COUNT(DISTINCT session_id) AS sessions,
64
+ ${SAVINGS_COLUMNS}
65
+ FROM (${SESSION_GROUPS})
66
+ GROUP BY agent
67
+ ORDER BY saved DESC, agent
68
+ LIMIT ?`;
69
+ const SOURCE_TOTALS = `SELECT
70
+ COUNT(*) AS sources,
71
+ COALESCE(SUM(chunk_count), 0) AS chunks,
72
+ COALESCE(SUM(bytes_total), 0) AS bytes,
73
+ MAX(created_ts) AS last
74
+ FROM sources`;
75
+ const SOURCES_BY_AGENT = `SELECT agent, COUNT(*) AS sources, COALESCE(SUM(bytes_total), 0) AS bytes
76
+ FROM sources
77
+ GROUP BY agent
78
+ ORDER BY sources DESC, agent
79
+ LIMIT ?`;
80
+ const BY_TOOL = `SELECT
81
+ tool,
82
+ COUNT(*) AS calls,
83
+ MAX(0, SUM(sandbox_bytes) - SUM(returned_bytes)) AS saved
84
+ FROM events
85
+ WHERE ${CALL} AND ${AGENT_WORK}
86
+ GROUP BY tool
87
+ ORDER BY saved DESC, calls DESC, tool
88
+ LIMIT ?`;
89
+ const BY_DAY = `SELECT day, SUM(calls) AS calls, SUM(saved) AS saved
90
+ FROM (
91
+ SELECT
92
+ created_at / ${MS_PER_DAY} AS day,
93
+ COUNT(*) AS calls,
94
+ MAX(0, SUM(sandbox_bytes) - SUM(returned_bytes)) AS saved
95
+ FROM events
96
+ WHERE ${CALL} AND ${AGENT_WORK}
97
+ GROUP BY day, tool
98
+ )
99
+ GROUP BY day
100
+ ORDER BY day DESC
101
+ LIMIT ?`;
102
+ const WORKTREES = `SELECT worktree, MAX(at) AS last
103
+ FROM (
104
+ SELECT worktree, created_at AS at FROM events WHERE ${AGENT_WORK}
105
+ UNION ALL
106
+ SELECT worktree, created_ts AS at FROM sources
107
+ )
108
+ GROUP BY worktree
109
+ ORDER BY last DESC, worktree
110
+ LIMIT ?`;
111
+ const SOURCES = `SELECT agent, worktree, label, kind, bytes_total, chunk_count, created_ts
112
+ FROM sources
113
+ ORDER BY created_ts DESC, id DESC
114
+ LIMIT ?`;
115
+ const RECENT_SESSIONS = `SELECT session_id, agent, worktree, ${EVENT_COUNT} AS events, MAX(created_at) AS last
116
+ FROM events
117
+ WHERE ${AGENT_WORK}
118
+ GROUP BY session_id, agent, worktree
119
+ ORDER BY last DESC
120
+ LIMIT ?`;
121
+
122
+ type DatabaseRow = Record<string, unknown>;
123
+
124
+ export type ContextModeDatabaseErrorCode =
125
+ | "not-regular-database"
126
+ | "database-open-failed"
127
+ | "database-locked"
128
+ | "unsupported-schema"
129
+ | "database-query-failed";
130
+
131
+ export class ContextModeDatabaseError extends Error {
132
+ readonly code: ContextModeDatabaseErrorCode;
133
+ /** The project key directory holding the database. */
134
+ readonly project: string;
135
+
136
+ constructor(code: ContextModeDatabaseErrorCode, path: string, message: string, cause?: unknown) {
137
+ super(message, { cause });
138
+ this.name = "ContextModeDatabaseError";
139
+ this.code = code;
140
+ this.project = basename(dirname(path));
141
+ }
142
+ }
143
+
144
+ export interface DatabaseSavings {
145
+ calls: number;
146
+ producedBytes: number;
147
+ returnedBytes: number;
148
+ savedBytes: number;
149
+ }
150
+
151
+ export interface DatabaseAgentSummary extends DatabaseSavings {
152
+ agent: string;
153
+ sessions: number;
154
+ indexedSources: number;
155
+ indexedBytes: number;
156
+ }
157
+
158
+ export interface DatabaseToolSummary {
159
+ tool: string;
160
+ calls: number;
161
+ savedBytes: number;
162
+ }
163
+
164
+ export interface DatabaseDaySummary {
165
+ date: string;
166
+ calls: number;
167
+ savedBytes: number;
168
+ }
169
+
170
+ export type DatabaseSourceKind = "path" | "content" | "url";
171
+
172
+ export interface DatabaseSourceSnapshot {
173
+ agent: string;
174
+ projectPath: string;
175
+ label: string;
176
+ kind: DatabaseSourceKind;
177
+ bytes: number;
178
+ chunks: number;
179
+ createdAt: string | null;
180
+ }
181
+
182
+ export interface DatabaseSessionSnapshot {
183
+ sessionId: string;
184
+ agent: string;
185
+ projectPath: string;
186
+ events: number;
187
+ lastActivityAt: string | null;
188
+ }
189
+
190
+ export interface ContextModeDatabaseSnapshot extends DatabaseSavings {
191
+ /** Worktree paths recorded in the database, most recently active first. */
192
+ worktrees: string[];
193
+ sessions: number;
194
+ events: number;
195
+ indexedSources: number;
196
+ indexedChunks: number;
197
+ indexedBytes: number;
198
+ lastActivityAt: string | null;
199
+ byAgent: DatabaseAgentSummary[];
200
+ byTool: DatabaseToolSummary[];
201
+ byDay: DatabaseDaySummary[];
202
+ sources: DatabaseSourceSnapshot[];
203
+ /** Most recently active sessions, excluding the plugin's own calls. */
204
+ sessionList: DatabaseSessionSnapshot[];
205
+ /** Human-readable notes for result sets cut at their row limit. */
206
+ truncated: string[];
207
+ }
208
+
209
+ function nonNegativeInteger(value: unknown): number {
210
+ const numeric = typeof value === "bigint" ? Number(value) : Number(value ?? 0);
211
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0;
212
+ return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(numeric));
213
+ }
214
+
215
+ function boundedText(value: unknown, fallback: string, maximum: number): string {
216
+ if (typeof value !== "string") return fallback;
217
+ const trimmed = value.trim();
218
+ if (!trimmed) return fallback;
219
+ return trimmed.length <= maximum ? trimmed : trimmed.slice(0, maximum);
220
+ }
221
+
222
+ function isSourceKind(value: unknown): value is DatabaseSourceKind {
223
+ return value === "path" || value === "url" || value === "content";
224
+ }
225
+
226
+ function isoFromMilliseconds(value: unknown): string | null {
227
+ const milliseconds = nonNegativeInteger(value);
228
+ if (milliseconds === 0 || milliseconds > MAX_DATE_MS) return null;
229
+ return new Date(milliseconds).toISOString();
230
+ }
231
+
232
+ function savings(row: DatabaseRow): DatabaseSavings {
233
+ return {
234
+ calls: nonNegativeInteger(row.calls),
235
+ producedBytes: nonNegativeInteger(row.produced),
236
+ returnedBytes: nonNegativeInteger(row.returned),
237
+ savedBytes: nonNegativeInteger(row.saved),
238
+ };
239
+ }
240
+
241
+ function assertRequiredSchema(database: DatabaseSync, path: string): void {
242
+ for (const [table, required] of Object.entries(REQUIRED_SCHEMA)) {
243
+ const rows = database.prepare(`PRAGMA table_xinfo('${table}')`).all() as DatabaseRow[];
244
+ const columns = new Set(rows.map((row) => row.name));
245
+ if (columns.size === 0) {
246
+ throw new ContextModeDatabaseError(
247
+ "unsupported-schema",
248
+ path,
249
+ `Unsupported Context Mode schema: missing ${table}.`,
250
+ );
251
+ }
252
+ const missing = required.filter((column) => !columns.has(column));
253
+ if (missing.length > 0) {
254
+ throw new ContextModeDatabaseError(
255
+ "unsupported-schema",
256
+ path,
257
+ `Unsupported Context Mode schema: ${table} is missing ${missing.join(", ")}.`,
258
+ );
259
+ }
260
+ }
261
+ }
262
+
263
+ function limited(rows: DatabaseRow[], maximum: number, label: string, truncated: string[]) {
264
+ if (rows.length > maximum) truncated.push(`Only the first ${maximum} ${label} were read.`);
265
+ return rows.slice(0, maximum);
266
+ }
267
+
268
+ function readSnapshot(database: DatabaseSync): ContextModeDatabaseSnapshot {
269
+ const truncated: string[] = [];
270
+ const totals = database.prepare(TOTALS).get() as DatabaseRow;
271
+ const sourceTotals = database.prepare(SOURCE_TOTALS).get() as DatabaseRow;
272
+
273
+ const agentRows = database.prepare(BY_AGENT).all(MAX_AGENT_ROWS + 1) as DatabaseRow[];
274
+ const byAgent = new Map<string, DatabaseAgentSummary>();
275
+ for (const row of limited(agentRows, MAX_AGENT_ROWS, "agents", truncated)) {
276
+ const agent = boundedText(row.agent, "unknown", 128);
277
+ byAgent.set(agent, {
278
+ agent,
279
+ sessions: nonNegativeInteger(row.sessions),
280
+ ...savings(row),
281
+ indexedSources: 0,
282
+ indexedBytes: 0,
283
+ });
284
+ }
285
+ const sourceAgentRows = database
286
+ .prepare(SOURCES_BY_AGENT)
287
+ .all(MAX_AGENT_ROWS + 1) as DatabaseRow[];
288
+ for (const row of limited(sourceAgentRows, MAX_AGENT_ROWS, "source agents", truncated)) {
289
+ const agent = boundedText(row.agent, "unknown", 128);
290
+ const summary = byAgent.get(agent) ?? {
291
+ agent,
292
+ sessions: 0,
293
+ calls: 0,
294
+ producedBytes: 0,
295
+ returnedBytes: 0,
296
+ savedBytes: 0,
297
+ indexedSources: 0,
298
+ indexedBytes: 0,
299
+ };
300
+ summary.indexedSources = nonNegativeInteger(row.sources);
301
+ summary.indexedBytes = nonNegativeInteger(row.bytes);
302
+ byAgent.set(agent, summary);
303
+ }
304
+
305
+ const toolRows = database.prepare(BY_TOOL).all(MAX_TOOL_ROWS + 1) as DatabaseRow[];
306
+ const byTool = limited(toolRows, MAX_TOOL_ROWS, "tools", truncated).map((row) => ({
307
+ tool: boundedText(row.tool, "unknown", 256),
308
+ calls: nonNegativeInteger(row.calls),
309
+ savedBytes: nonNegativeInteger(row.saved),
310
+ }));
311
+
312
+ const dayRows = database.prepare(BY_DAY).all(MAX_DAY_ROWS) as DatabaseRow[];
313
+ const byDay = dayRows.flatMap((row) => {
314
+ const date = isoFromMilliseconds(nonNegativeInteger(row.day) * MS_PER_DAY)?.slice(0, 10);
315
+ if (!date) return [];
316
+ return [
317
+ { date, calls: nonNegativeInteger(row.calls), savedBytes: nonNegativeInteger(row.saved) },
318
+ ];
319
+ });
320
+
321
+ const worktreeRows = database.prepare(WORKTREES).all(MAX_WORKTREE_ROWS + 1) as DatabaseRow[];
322
+ const worktrees = limited(worktreeRows, MAX_WORKTREE_ROWS, "worktrees", truncated).flatMap(
323
+ (row) => {
324
+ const path = boundedText(row.worktree, "", 4_096);
325
+ return path ? [path] : [];
326
+ },
327
+ );
328
+
329
+ const sourceRows = database.prepare(SOURCES).all(MAX_SOURCE_ROWS + 1) as DatabaseRow[];
330
+ const sources = limited(sourceRows, MAX_SOURCE_ROWS, "indexed sources", truncated).map((row) => ({
331
+ agent: boundedText(row.agent, "unknown", 128),
332
+ projectPath: boundedText(row.worktree, "unknown", 4_096),
333
+ label: boundedText(row.label, "(unnamed source)", 4_096),
334
+ kind: isSourceKind(row.kind) ? row.kind : "content",
335
+ bytes: nonNegativeInteger(row.bytes_total),
336
+ chunks: nonNegativeInteger(row.chunk_count),
337
+ createdAt: isoFromMilliseconds(row.created_ts),
338
+ }));
339
+
340
+ const sessionRows = database.prepare(RECENT_SESSIONS).all(MAX_SESSION_ROWS) as DatabaseRow[];
341
+ const sessionList = sessionRows.map((row) => ({
342
+ sessionId: boundedText(row.session_id, "unknown", 256),
343
+ agent: boundedText(row.agent, "unknown", 128),
344
+ projectPath: boundedText(row.worktree, "unknown", 4_096),
345
+ events: nonNegativeInteger(row.events),
346
+ lastActivityAt: isoFromMilliseconds(row.last),
347
+ }));
348
+
349
+ const lastActivity = Math.max(
350
+ nonNegativeInteger(totals.last),
351
+ nonNegativeInteger(sourceTotals.last),
352
+ );
353
+ return {
354
+ worktrees,
355
+ sessions: nonNegativeInteger(totals.sessions),
356
+ events: nonNegativeInteger(totals.events),
357
+ ...savings(totals),
358
+ indexedSources: nonNegativeInteger(sourceTotals.sources),
359
+ indexedChunks: nonNegativeInteger(sourceTotals.chunks),
360
+ indexedBytes: nonNegativeInteger(sourceTotals.bytes),
361
+ lastActivityAt: isoFromMilliseconds(lastActivity),
362
+ byAgent: [...byAgent.values()],
363
+ byTool,
364
+ byDay,
365
+ sources,
366
+ sessionList,
367
+ truncated,
368
+ };
369
+ }
370
+
371
+ function classifyDatabaseError(error: unknown): ContextModeDatabaseErrorCode {
372
+ const message =
373
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
374
+ if (message.includes("database is locked") || message.includes("database is busy")) {
375
+ return "database-locked";
376
+ }
377
+ if (
378
+ message.includes("not a database") ||
379
+ message.includes("database disk image is malformed") ||
380
+ message.includes("unable to open database")
381
+ ) {
382
+ return "database-open-failed";
383
+ }
384
+ return "database-query-failed";
385
+ }
386
+
387
+ const ERROR_MESSAGES: Record<ContextModeDatabaseErrorCode, string> = {
388
+ "not-regular-database": "The database is not a regular file.",
389
+ "database-open-failed": "The database could not be opened read-only.",
390
+ "database-locked": "The database is locked by a writer; try again shortly.",
391
+ "unsupported-schema": "Unsupported Context Mode schema.",
392
+ "database-query-failed": "The database could not be queried safely.",
393
+ };
394
+
395
+ function wrapError(error: unknown, path: string): ContextModeDatabaseError {
396
+ if (error instanceof ContextModeDatabaseError) return error;
397
+ const code = classifyDatabaseError(error);
398
+ return new ContextModeDatabaseError(code, path, ERROR_MESSAGES[code], error);
399
+ }
400
+
401
+ /** Reads bounded aggregates from one Context Mode 2.x `context.db` without writing to it. */
402
+ export function readContextModeDatabase(path: string): ContextModeDatabaseSnapshot {
403
+ let info: Stats;
404
+ try {
405
+ info = lstatSync(path);
406
+ } catch (error) {
407
+ throw new ContextModeDatabaseError(
408
+ "not-regular-database",
409
+ path,
410
+ "The database file could not be inspected.",
411
+ error,
412
+ );
413
+ }
414
+ if (!info.isFile()) {
415
+ throw new ContextModeDatabaseError(
416
+ "not-regular-database",
417
+ path,
418
+ ERROR_MESSAGES["not-regular-database"],
419
+ );
420
+ }
421
+ if (info.size > MAX_DATABASE_BYTES) {
422
+ throw new ContextModeDatabaseError(
423
+ "database-open-failed",
424
+ path,
425
+ `The database exceeds the ${MAX_DATABASE_BYTES}-byte analytics limit.`,
426
+ );
427
+ }
428
+
429
+ let database: DatabaseSync;
430
+ try {
431
+ database = new DatabaseSync(path, {
432
+ readOnly: true,
433
+ allowExtension: false,
434
+ timeout: BUSY_TIMEOUT_MS,
435
+ });
436
+ } catch (error) {
437
+ throw wrapError(error, path);
438
+ }
439
+
440
+ try {
441
+ database.exec("PRAGMA query_only = ON");
442
+ const versionRow = database.prepare("PRAGMA user_version").get() as DatabaseRow | undefined;
443
+ const userVersion = nonNegativeInteger(versionRow?.user_version);
444
+ if (userVersion !== SUPPORTED_CONTEXT_MODE_DATABASE_USER_VERSION) {
445
+ throw new ContextModeDatabaseError(
446
+ "unsupported-schema",
447
+ path,
448
+ `Unsupported Context Mode schema version ${userVersion}; expected ${SUPPORTED_CONTEXT_MODE_DATABASE_USER_VERSION}.`,
449
+ );
450
+ }
451
+ assertRequiredSchema(database, path);
452
+ database.exec("BEGIN DEFERRED");
453
+ try {
454
+ return readSnapshot(database);
455
+ } finally {
456
+ database.exec("ROLLBACK");
457
+ }
458
+ } catch (error) {
459
+ throw wrapError(error, path);
460
+ } finally {
461
+ database.close();
462
+ }
463
+ }
@@ -1,69 +1,58 @@
1
+ import { mkdir } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import type { PluginSettings } from "@getpaseo/plugin/server";
4
- import type {
5
- BinaryStatus,
6
- CommandReport,
7
- ContextModeRefreshInput,
8
- ContextModeSettings,
9
- contextModeSettings,
10
- IntegrationAudit,
11
- } from "../shared";
5
+ import type { BinaryStatus, ContextModeRefreshInput, IntegrationAudit } from "../shared/contracts";
6
+ import { type ContextModePlatform, PANEL_PLATFORM } from "../shared/providers";
7
+ import type { ContextModeSettings, contextModeSettings } from "../shared/settings";
12
8
  import { type BinaryResolution, type LaunchDescriptor, resolveContextModeBinary } from "./binary";
13
9
  import {
14
10
  createIntegrationAudit,
15
- type IntegrationDependencies,
16
11
  type ProviderConfigSource,
17
12
  readProviderOverrides,
18
13
  } from "./integration";
19
14
  import {
20
- callContextModeTool,
15
+ type ContextModeProcessOptions,
21
16
  inspectContextMode,
22
17
  type McpInspection,
23
18
  ProcessFailure,
24
19
  } from "./mcp-process";
25
20
 
26
21
  type SettingsHandle = PluginSettings<typeof contextModeSettings.schema>;
27
- type ToolName = "ctx_doctor" | "ctx_stats";
28
- type FailureReport = Extract<CommandReport, { state: "unavailable" }>;
29
-
30
- interface ProbeOptions {
31
- env: NodeJS.ProcessEnv;
32
- }
22
+ type FailureReport = Extract<BinaryStatus, { state: "unavailable" }>;
33
23
 
34
24
  /**
35
- * Panel probes belong to no provider. Without an explicit storage root Context Mode falls back to
36
- * whichever provider config directory exists, and writes its sidecar and content store there.
25
+ * Panel probes belong to no project. Context Mode records every tool call (doctor and stats
26
+ * included) in the store of its cwd, so probes run in a plugin-owned data dir and cwd to keep
27
+ * the user's stats clean.
37
28
  */
38
- function contextModeProbeOptions(env: NodeJS.ProcessEnv = process.env): ProbeOptions {
29
+ export async function contextModeProbeOptions(
30
+ platform: ContextModePlatform = PANEL_PLATFORM,
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ ): Promise<Required<ContextModeProcessOptions>> {
39
33
  const home = env.PASEO_HOME?.trim() || join(homedir(), ".paseo");
40
- return { env: { CONTEXT_MODE_DIR: join(home, "plugin-data", "context-mode", "probe") } };
34
+ const dataDir = join(home, "plugin-data", "context-mode", "probe");
35
+ const cwd = join(dataDir, "cwd");
36
+ await mkdir(cwd, { recursive: true });
37
+ return { platform, cwd, dataDir };
41
38
  }
42
39
 
43
40
  export interface ContextModeHandlerDependencies {
44
41
  now?: () => Date;
45
42
  resolveBinary?: typeof resolveContextModeBinary;
46
- inspect?: (launch: LaunchDescriptor, options: ProbeOptions) => Promise<McpInspection>;
47
- callTool?: (
43
+ probeOptions?: () => Promise<ContextModeProcessOptions>;
44
+ inspect?: (
48
45
  launch: LaunchDescriptor,
49
- name: ToolName,
50
- arguments_: Record<string, never>,
51
- options: ProbeOptions,
52
- ) => Promise<string>;
53
- createAudit?: (
54
- settings: ContextModeSettings,
55
- runtime: { path: string; version: string | null },
56
- dependencies?: IntegrationDependencies,
57
- ) => Promise<IntegrationAudit>;
46
+ options: ContextModeProcessOptions,
47
+ ) => Promise<McpInspection>;
48
+ createAudit?: typeof createIntegrationAudit;
58
49
  }
59
50
 
60
51
  type HandlerContext = { paseo?: ProviderConfigSource };
61
52
 
62
53
  export interface ContextModeHandlers {
63
54
  status(input: ContextModeRefreshInput, context?: HandlerContext): Promise<BinaryStatus>;
64
- doctor(input: ContextModeRefreshInput, context?: HandlerContext): Promise<CommandReport>;
65
- stats(input: ContextModeRefreshInput, context?: HandlerContext): Promise<CommandReport>;
66
- audit(input: ContextModeRefreshInput, context?: HandlerContext): Promise<IntegrationAudit>;
55
+ audit(input: Record<string, never>, context?: HandlerContext): Promise<IntegrationAudit>;
67
56
  }
68
57
 
69
58
  interface CacheEntry<T> {
@@ -86,11 +75,13 @@ export function createContextModeHandlers(
86
75
  ): ContextModeHandlers {
87
76
  const now = dependencies.now ?? (() => new Date());
88
77
  const resolveBinary = dependencies.resolveBinary ?? resolveContextModeBinary;
89
- const inspect = dependencies.inspect ?? inspectContextMode;
90
- const callTool = dependencies.callTool ?? callContextModeTool;
78
+ const inspect =
79
+ dependencies.inspect ??
80
+ ((launch: LaunchDescriptor, options: ContextModeProcessOptions) =>
81
+ inspectContextMode(launch, {}, options));
82
+ const probeOptions = dependencies.probeOptions ?? (() => contextModeProbeOptions());
91
83
  const buildAudit = dependencies.createAudit ?? createIntegrationAudit;
92
- const cache = new Map<string, CacheEntry<BinaryStatus | CommandReport>>();
93
- const probe = contextModeProbeOptions();
84
+ const cache = new Map<string, CacheEntry<BinaryStatus>>();
94
85
 
95
86
  async function settingsAndBinary(paseo: ProviderConfigSource | undefined): Promise<
96
87
  | {
@@ -142,14 +133,14 @@ export function createContextModeHandlers(
142
133
  }
143
134
  }
144
135
 
145
- async function cached<T extends BinaryStatus | CommandReport>(
136
+ async function cached(
146
137
  key: string,
147
138
  ttlMs: number,
148
139
  fresh: boolean,
149
- load: () => Promise<T>,
150
- ): Promise<T> {
140
+ load: () => Promise<BinaryStatus>,
141
+ ): Promise<BinaryStatus> {
151
142
  const timestamp = now().getTime();
152
- const existing = cache.get(key) as CacheEntry<T> | undefined;
143
+ const existing = cache.get(key);
153
144
  if (!fresh && existing && existing.expiresAt > timestamp) return existing.value;
154
145
  const value = load();
155
146
  cache.set(key, { expiresAt: timestamp + ttlMs, value });
@@ -168,14 +159,12 @@ export function createContextModeHandlers(
168
159
  return cached(cacheKey, 10_000, fresh, async () => {
169
160
  const checkedAt = now().toISOString();
170
161
  try {
171
- const inspection = await inspect(resolved.binary.launch, probe);
162
+ const inspection = await inspect(resolved.binary.launch, await probeOptions());
172
163
  return {
173
164
  state: "ready",
174
165
  binaryPath: resolved.binary.path,
175
166
  source: resolved.binary.source,
176
167
  version: inspection.version,
177
- supportsDoctor: inspection.tools.includes("ctx_doctor"),
178
- supportsStats: inspection.tools.includes("ctx_stats"),
179
168
  checkedAt,
180
169
  };
181
170
  } catch (error) {
@@ -184,58 +173,14 @@ export function createContextModeHandlers(
184
173
  });
185
174
  }
186
175
 
187
- async function report(
188
- name: ToolName,
189
- ttlMs: number,
190
- fresh: boolean,
191
- context: HandlerContext | undefined,
192
- ): Promise<CommandReport> {
176
+ /** Provider activation only: no Context Mode process is started, so polling it is cheap. */
177
+ async function audit(context?: HandlerContext): Promise<IntegrationAudit> {
193
178
  const resolved = await settingsAndBinary(context?.paseo);
194
- if (resolved.state === "unavailable") return resolved.report;
195
- return cached(`${name}:${resolved.binary.path}`, ttlMs, fresh, async () => {
196
- const checkedAt = now().toISOString();
197
- try {
198
- return {
199
- state: "ready",
200
- binaryPath: resolved.binary.path,
201
- output: await callTool(resolved.binary.launch, name, {}, probe),
202
- checkedAt,
203
- };
204
- } catch (error) {
205
- return failureResult(error, checkedAt);
206
- }
179
+ if (resolved.state === "unavailable") return { providers: [] };
180
+ return buildAudit(resolved.settings, {
181
+ providers: await readProviderOverrides(context?.paseo),
207
182
  });
208
183
  }
209
184
 
210
- async function audit(
211
- _input: ContextModeRefreshInput,
212
- context?: HandlerContext,
213
- ): Promise<IntegrationAudit> {
214
- const resolved = await settingsAndBinary(context?.paseo);
215
- if (resolved.state === "unavailable") {
216
- return {
217
- runtimePath: "",
218
- runtimeVersion: null,
219
- injectionEnabled: false,
220
- providers: [],
221
- checkedAt: resolved.report.checkedAt,
222
- };
223
- }
224
- const inspection = await inspect(resolved.binary.launch, probe).catch(() => ({
225
- version: null,
226
- tools: [],
227
- }));
228
- return buildAudit(
229
- resolved.settings,
230
- { path: resolved.binary.path, version: inspection.version },
231
- { providers: await readProviderOverrides(context?.paseo) },
232
- );
233
- }
234
-
235
- return {
236
- status,
237
- doctor: ({ fresh }, context) => report("ctx_doctor", 10_000, fresh, context),
238
- stats: ({ fresh }, context) => report("ctx_stats", 5_000, fresh, context),
239
- audit,
240
- };
185
+ return { status, audit: (_input, context) => audit(context) };
241
186
  }