@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.
- package/OVERVIEW.md +3 -3
- package/README.md +13 -15
- package/client/analytics-dashboard.tsx +745 -613
- package/client/controls.tsx +295 -0
- package/client/diagnostics-section.tsx +178 -0
- package/client/{context-mode-footer.tsx → footer.tsx} +22 -20
- package/client/knowledge-section.tsx +725 -697
- package/client/{context-mode-pill.tsx → pill.tsx} +5 -5
- package/client/scoped-knowledge-section.tsx +14 -20
- package/client/screen-scope.ts +12 -6
- package/client/settings-screen.tsx +94 -97
- package/client/surface.tsx +377 -0
- package/index.client.tsx +3 -3
- package/index.server.ts +18 -27
- package/npm-shrinkwrap.json +8 -1747
- package/package.json +7 -7
- package/server/actions.ts +91 -102
- package/server/analytics.ts +257 -308
- package/server/binary.ts +119 -94
- package/server/database.ts +463 -0
- package/server/handlers.ts +40 -95
- package/server/integration.ts +230 -361
- package/server/knowledge.ts +65 -137
- package/server/mcp-process.ts +135 -86
- package/server/storage-discovery.ts +46 -199
- package/shared/actions.ts +2 -27
- package/shared/analytics.ts +60 -33
- package/shared/contracts.ts +16 -47
- package/shared/knowledge.ts +62 -65
- package/shared/providers.ts +62 -0
- package/shared/settings.ts +33 -18
- package/client/actions-section.tsx +0 -621
- package/client/context-mode-surface.tsx +0 -444
- package/server/context-mode-db.ts +0 -481
- package/shared/index.ts +0 -5
|
@@ -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
|
+
}
|
package/server/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
36
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
43
|
+
probeOptions?: () => Promise<ContextModeProcessOptions>;
|
|
44
|
+
inspect?: (
|
|
48
45
|
launch: LaunchDescriptor,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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 =
|
|
90
|
-
|
|
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
|
|
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
|
|
136
|
+
async function cached(
|
|
146
137
|
key: string,
|
|
147
138
|
ttlMs: number,
|
|
148
139
|
fresh: boolean,
|
|
149
|
-
load: () => Promise<
|
|
150
|
-
): Promise<
|
|
140
|
+
load: () => Promise<BinaryStatus>,
|
|
141
|
+
): Promise<BinaryStatus> {
|
|
151
142
|
const timestamp = now().getTime();
|
|
152
|
-
const existing = cache.get(key)
|
|
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,
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
195
|
-
return
|
|
196
|
-
|
|
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
|
-
|
|
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
|
}
|