@omercnet/paseo-context-mode 0.0.0
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/LICENSE +21 -0
- package/NOTICE +7 -0
- package/README.md +74 -0
- package/client/actions-section.tsx +621 -0
- package/client/analytics-dashboard.tsx +721 -0
- package/client/context-mode-pill.tsx +106 -0
- package/client/context-mode-surface.tsx +414 -0
- package/client/knowledge-section.tsx +780 -0
- package/client/settings-screen.tsx +226 -0
- package/index.client.tsx +43 -0
- package/index.server.ts +54 -0
- package/package.json +72 -0
- package/paseo-plugin.json +5 -0
- package/server/actions.ts +146 -0
- package/server/analytics.ts +424 -0
- package/server/binary.ts +182 -0
- package/server/context-mode-db.ts +481 -0
- package/server/handlers.ts +200 -0
- package/server/integration.ts +441 -0
- package/server/knowledge.ts +187 -0
- package/server/mcp-process.ts +357 -0
- package/server/storage-discovery.ts +225 -0
- package/shared/actions.ts +57 -0
- package/shared/analytics.ts +134 -0
- package/shared/contracts.ts +95 -0
- package/shared/index.ts +5 -0
- package/shared/knowledge.ts +150 -0
- package/shared/settings.ts +51 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { lstatSync, type Stats } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
|
|
5
|
+
const BUSY_TIMEOUT_MS = 150;
|
|
6
|
+
const MAX_DATABASE_BYTES = 512 * 1024 * 1024;
|
|
7
|
+
const MAX_DISTINCT_IDENTITIES = 5_000;
|
|
8
|
+
const MAX_GROUP_ROWS = 4_096;
|
|
9
|
+
const MAX_SOURCE_ROWS = 2_000;
|
|
10
|
+
|
|
11
|
+
const REQUIRED_SESSION_SCHEMA: Record<string, readonly string[]> = {
|
|
12
|
+
session_events: [
|
|
13
|
+
"session_id",
|
|
14
|
+
"category",
|
|
15
|
+
"data",
|
|
16
|
+
"project_dir",
|
|
17
|
+
"bytes_avoided",
|
|
18
|
+
"bytes_returned",
|
|
19
|
+
"created_at",
|
|
20
|
+
],
|
|
21
|
+
session_meta: ["session_id", "project_dir"],
|
|
22
|
+
session_resume: ["session_id", "snapshot", "created_at"],
|
|
23
|
+
tool_calls: ["session_id", "tool", "calls", "bytes_returned"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const REQUIRED_CONTENT_SCHEMA: Record<string, readonly string[]> = {
|
|
27
|
+
sources: ["id", "label", "chunk_count", "code_chunk_count", "indexed_at"],
|
|
28
|
+
chunks: ["title", "content", "source_id", "content_type"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type DatabaseRow = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
export type ContextModeDatabaseErrorCode =
|
|
34
|
+
| "not-regular-database"
|
|
35
|
+
| "database-open-failed"
|
|
36
|
+
| "unsupported-schema"
|
|
37
|
+
| "database-query-failed";
|
|
38
|
+
|
|
39
|
+
export class ContextModeDatabaseError extends Error {
|
|
40
|
+
readonly code: ContextModeDatabaseErrorCode;
|
|
41
|
+
readonly database: string;
|
|
42
|
+
|
|
43
|
+
constructor(code: ContextModeDatabaseErrorCode, path: string, message: string, cause?: unknown) {
|
|
44
|
+
super(message, { cause });
|
|
45
|
+
this.name = "ContextModeDatabaseError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.database = basename(path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DatabaseCategorySummary {
|
|
52
|
+
category: string;
|
|
53
|
+
count: number;
|
|
54
|
+
savedBytes: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DatabaseDaySummary {
|
|
58
|
+
date: string;
|
|
59
|
+
calls: number;
|
|
60
|
+
savedBytes: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionDatabaseSnapshot {
|
|
64
|
+
kind: "session";
|
|
65
|
+
sessions: string[];
|
|
66
|
+
projects: string[];
|
|
67
|
+
events: number;
|
|
68
|
+
toolCalls: number;
|
|
69
|
+
eventDataBytes: number;
|
|
70
|
+
avoidedBytes: number;
|
|
71
|
+
returnedBytes: number;
|
|
72
|
+
snapshotBytes: number;
|
|
73
|
+
byCategory: DatabaseCategorySummary[];
|
|
74
|
+
byDay: DatabaseDaySummary[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ContentSourceSnapshot {
|
|
78
|
+
source: string;
|
|
79
|
+
chunks: number;
|
|
80
|
+
codeChunks: number;
|
|
81
|
+
indexedBytes: number;
|
|
82
|
+
indexedAt: string | null;
|
|
83
|
+
detail?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ContentDatabaseSnapshot {
|
|
87
|
+
kind: "content";
|
|
88
|
+
indexedBytes: number;
|
|
89
|
+
chunks: number;
|
|
90
|
+
codeChunks: number;
|
|
91
|
+
sources: ContentSourceSnapshot[];
|
|
92
|
+
byDay: DatabaseDaySummary[];
|
|
93
|
+
inconsistencies: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ContextModeDatabaseSnapshot = SessionDatabaseSnapshot | ContentDatabaseSnapshot;
|
|
97
|
+
|
|
98
|
+
function nonNegativeInteger(value: unknown): number {
|
|
99
|
+
const numeric = typeof value === "bigint" ? Number(value) : Number(value ?? 0);
|
|
100
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
|
|
101
|
+
return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(numeric));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function boundedAdd(left: number, right: number): number {
|
|
105
|
+
return Math.min(Number.MAX_SAFE_INTEGER, left + right);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function boundedText(value: unknown, fallback: string, maximum: number): string {
|
|
109
|
+
if (typeof value !== "string") return fallback;
|
|
110
|
+
const trimmed = value.trim();
|
|
111
|
+
if (!trimmed) return fallback;
|
|
112
|
+
return trimmed.length <= maximum ? trimmed : trimmed.slice(0, maximum);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizedTimestamp(value: unknown): string | null {
|
|
116
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
117
|
+
const input = value.trim().replace(" ", "T");
|
|
118
|
+
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(input) ? input : `${input}Z`;
|
|
119
|
+
const milliseconds = Date.parse(zoned);
|
|
120
|
+
return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function columnsFor(database: DatabaseSync, table: string): Set<string> {
|
|
124
|
+
const rows = database.prepare(`PRAGMA table_xinfo('${table}')`).all() as DatabaseRow[];
|
|
125
|
+
return new Set(
|
|
126
|
+
rows.flatMap((row) => (typeof row.name === "string" && row.name ? [row.name] : [])),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertRequiredSchema(
|
|
131
|
+
database: DatabaseSync,
|
|
132
|
+
path: string,
|
|
133
|
+
expected: Record<string, readonly string[]>,
|
|
134
|
+
): void {
|
|
135
|
+
for (const [table, requiredColumns] of Object.entries(expected)) {
|
|
136
|
+
const columns = columnsFor(database, table);
|
|
137
|
+
if (columns.size === 0) {
|
|
138
|
+
throw new ContextModeDatabaseError(
|
|
139
|
+
"unsupported-schema",
|
|
140
|
+
path,
|
|
141
|
+
`Unsupported Context Mode schema: missing ${table}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const missing = requiredColumns.filter((column) => !columns.has(column));
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
throw new ContextModeDatabaseError(
|
|
147
|
+
"unsupported-schema",
|
|
148
|
+
path,
|
|
149
|
+
`Unsupported Context Mode schema: ${table} is missing ${missing.join(", ")}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function incrementSummary(
|
|
156
|
+
summaries: Map<string, { count: number; savedBytes: number }>,
|
|
157
|
+
key: string,
|
|
158
|
+
count: number,
|
|
159
|
+
savedBytes: number,
|
|
160
|
+
): void {
|
|
161
|
+
const current = summaries.get(key) ?? { count: 0, savedBytes: 0 };
|
|
162
|
+
current.count = boundedAdd(current.count, count);
|
|
163
|
+
current.savedBytes = boundedAdd(current.savedBytes, savedBytes);
|
|
164
|
+
summaries.set(key, current);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readSessionDatabase(database: DatabaseSync, path: string): SessionDatabaseSnapshot {
|
|
168
|
+
assertRequiredSchema(database, path, REQUIRED_SESSION_SCHEMA);
|
|
169
|
+
const sessions = new Set<string>();
|
|
170
|
+
const projects = new Set<string>();
|
|
171
|
+
|
|
172
|
+
for (const row of database
|
|
173
|
+
.prepare("SELECT DISTINCT session_id, project_dir FROM session_events LIMIT ?")
|
|
174
|
+
.all(MAX_DISTINCT_IDENTITIES) as DatabaseRow[]) {
|
|
175
|
+
const session = boundedText(row.session_id, "", 4_096);
|
|
176
|
+
const project = boundedText(row.project_dir, "", 4_096);
|
|
177
|
+
if (session) sessions.add(session);
|
|
178
|
+
if (project) projects.add(project);
|
|
179
|
+
}
|
|
180
|
+
for (const row of database
|
|
181
|
+
.prepare("SELECT session_id, project_dir FROM session_meta LIMIT ?")
|
|
182
|
+
.all(MAX_DISTINCT_IDENTITIES) as DatabaseRow[]) {
|
|
183
|
+
const session = boundedText(row.session_id, "", 4_096);
|
|
184
|
+
const project = boundedText(row.project_dir, "", 4_096);
|
|
185
|
+
if (session) sessions.add(session);
|
|
186
|
+
if (project) projects.add(project);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const totals = database
|
|
190
|
+
.prepare(`SELECT
|
|
191
|
+
COUNT(*) AS events,
|
|
192
|
+
COALESCE(SUM(length(CAST(data AS BLOB))), 0) AS data_bytes,
|
|
193
|
+
COALESCE(SUM(bytes_avoided), 0) AS avoided_bytes,
|
|
194
|
+
COALESCE(SUM(bytes_returned), 0) AS returned_bytes
|
|
195
|
+
FROM session_events`)
|
|
196
|
+
.get() as DatabaseRow;
|
|
197
|
+
const resumeTotals = database
|
|
198
|
+
.prepare(
|
|
199
|
+
"SELECT COALESCE(SUM(length(CAST(snapshot AS BLOB))), 0) AS snapshot_bytes FROM session_resume",
|
|
200
|
+
)
|
|
201
|
+
.get() as DatabaseRow;
|
|
202
|
+
const toolTotals = database
|
|
203
|
+
.prepare("SELECT COALESCE(SUM(calls), 0) AS calls FROM tool_calls")
|
|
204
|
+
.get() as DatabaseRow;
|
|
205
|
+
const searchReturns = database
|
|
206
|
+
.prepare(
|
|
207
|
+
"SELECT COALESCE(SUM(bytes_returned), 0) AS bytes_returned FROM tool_calls WHERE tool IN ('ctx_search', 'ctx_fetch_and_index')",
|
|
208
|
+
)
|
|
209
|
+
.get() as DatabaseRow;
|
|
210
|
+
|
|
211
|
+
const byCategory = (
|
|
212
|
+
database
|
|
213
|
+
.prepare(`SELECT
|
|
214
|
+
category,
|
|
215
|
+
COUNT(*) AS count,
|
|
216
|
+
COALESCE(SUM(length(CAST(data AS BLOB)) + bytes_avoided), 0) AS saved_bytes
|
|
217
|
+
FROM session_events
|
|
218
|
+
GROUP BY category
|
|
219
|
+
ORDER BY count DESC
|
|
220
|
+
LIMIT ?`)
|
|
221
|
+
.all(MAX_GROUP_ROWS) as DatabaseRow[]
|
|
222
|
+
).map((row) => ({
|
|
223
|
+
category: boundedText(row.category, "unknown", 256),
|
|
224
|
+
count: nonNegativeInteger(row.count),
|
|
225
|
+
savedBytes: nonNegativeInteger(row.saved_bytes),
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const daySummaries = new Map<string, { count: number; savedBytes: number }>();
|
|
229
|
+
for (const row of database
|
|
230
|
+
.prepare(`SELECT
|
|
231
|
+
date(created_at) AS day,
|
|
232
|
+
COUNT(*) AS calls,
|
|
233
|
+
COALESCE(SUM(length(CAST(data AS BLOB)) + bytes_avoided), 0) AS saved_bytes
|
|
234
|
+
FROM session_events
|
|
235
|
+
WHERE created_at IS NOT NULL
|
|
236
|
+
GROUP BY date(created_at)
|
|
237
|
+
ORDER BY day DESC
|
|
238
|
+
LIMIT ?`)
|
|
239
|
+
.all(MAX_GROUP_ROWS) as DatabaseRow[]) {
|
|
240
|
+
const day = boundedText(row.day, "", 10);
|
|
241
|
+
if (day)
|
|
242
|
+
incrementSummary(
|
|
243
|
+
daySummaries,
|
|
244
|
+
day,
|
|
245
|
+
nonNegativeInteger(row.calls),
|
|
246
|
+
nonNegativeInteger(row.saved_bytes),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
for (const row of database
|
|
250
|
+
.prepare(`SELECT
|
|
251
|
+
date(created_at) AS day,
|
|
252
|
+
COALESCE(SUM(length(CAST(snapshot AS BLOB))), 0) AS saved_bytes
|
|
253
|
+
FROM session_resume
|
|
254
|
+
WHERE created_at IS NOT NULL
|
|
255
|
+
GROUP BY date(created_at)
|
|
256
|
+
ORDER BY day DESC
|
|
257
|
+
LIMIT ?`)
|
|
258
|
+
.all(MAX_GROUP_ROWS) as DatabaseRow[]) {
|
|
259
|
+
const day = boundedText(row.day, "", 10);
|
|
260
|
+
if (day) incrementSummary(daySummaries, day, 0, nonNegativeInteger(row.saved_bytes));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
kind: "session",
|
|
265
|
+
sessions: [...sessions],
|
|
266
|
+
projects: [...projects],
|
|
267
|
+
events: nonNegativeInteger(totals.events),
|
|
268
|
+
toolCalls: nonNegativeInteger(toolTotals.calls),
|
|
269
|
+
eventDataBytes: nonNegativeInteger(totals.data_bytes),
|
|
270
|
+
avoidedBytes: nonNegativeInteger(totals.avoided_bytes),
|
|
271
|
+
returnedBytes: boundedAdd(
|
|
272
|
+
nonNegativeInteger(totals.returned_bytes),
|
|
273
|
+
nonNegativeInteger(searchReturns.bytes_returned),
|
|
274
|
+
),
|
|
275
|
+
snapshotBytes: nonNegativeInteger(resumeTotals.snapshot_bytes),
|
|
276
|
+
byCategory,
|
|
277
|
+
byDay: [...daySummaries].map(([date, summary]) => ({
|
|
278
|
+
date,
|
|
279
|
+
calls: summary.count,
|
|
280
|
+
savedBytes: summary.savedBytes,
|
|
281
|
+
})),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readContentDatabase(database: DatabaseSync, path: string): ContentDatabaseSnapshot {
|
|
286
|
+
assertRequiredSchema(database, path, REQUIRED_CONTENT_SCHEMA);
|
|
287
|
+
const totals = database
|
|
288
|
+
.prepare(`SELECT
|
|
289
|
+
COUNT(*) AS chunks,
|
|
290
|
+
COALESCE(SUM(CASE WHEN content_type = 'code' THEN 1 ELSE 0 END), 0) AS code_chunks,
|
|
291
|
+
COALESCE(SUM(length(CAST(title AS BLOB)) + length(CAST(content AS BLOB))), 0) AS indexed_bytes
|
|
292
|
+
FROM chunks`)
|
|
293
|
+
.get() as DatabaseRow;
|
|
294
|
+
const chunksBySource = new Map<
|
|
295
|
+
string,
|
|
296
|
+
{ chunks: number; codeChunks: number; indexedBytes: number }
|
|
297
|
+
>();
|
|
298
|
+
for (const row of database
|
|
299
|
+
.prepare(`SELECT
|
|
300
|
+
source_id,
|
|
301
|
+
COUNT(*) AS chunks,
|
|
302
|
+
COALESCE(SUM(CASE WHEN content_type = 'code' THEN 1 ELSE 0 END), 0) AS code_chunks,
|
|
303
|
+
COALESCE(SUM(length(CAST(title AS BLOB)) + length(CAST(content AS BLOB))), 0) AS indexed_bytes
|
|
304
|
+
FROM chunks
|
|
305
|
+
GROUP BY source_id
|
|
306
|
+
LIMIT ?`)
|
|
307
|
+
.all(MAX_SOURCE_ROWS + 1) as DatabaseRow[]) {
|
|
308
|
+
const sourceId = String(row.source_id ?? "");
|
|
309
|
+
chunksBySource.set(sourceId, {
|
|
310
|
+
chunks: nonNegativeInteger(row.chunks),
|
|
311
|
+
codeChunks: nonNegativeInteger(row.code_chunks),
|
|
312
|
+
indexedBytes: nonNegativeInteger(row.indexed_bytes),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const sources: ContentSourceSnapshot[] = [];
|
|
317
|
+
const inconsistencies: string[] = [];
|
|
318
|
+
const days = new Map<string, { count: number; savedBytes: number }>();
|
|
319
|
+
const sourceRows = database
|
|
320
|
+
.prepare(
|
|
321
|
+
"SELECT id, label, chunk_count, code_chunk_count, indexed_at FROM sources ORDER BY id LIMIT ?",
|
|
322
|
+
)
|
|
323
|
+
.all(MAX_SOURCE_ROWS + 1) as DatabaseRow[];
|
|
324
|
+
if (sourceRows.length > MAX_SOURCE_ROWS || chunksBySource.size > MAX_SOURCE_ROWS) {
|
|
325
|
+
inconsistencies.push(`Source analytics were limited to ${MAX_SOURCE_ROWS} rows.`);
|
|
326
|
+
}
|
|
327
|
+
for (const row of sourceRows.slice(0, MAX_SOURCE_ROWS)) {
|
|
328
|
+
const sourceId = String(row.id ?? "");
|
|
329
|
+
const actual = chunksBySource.get(sourceId) ?? {
|
|
330
|
+
chunks: 0,
|
|
331
|
+
codeChunks: 0,
|
|
332
|
+
indexedBytes: 0,
|
|
333
|
+
};
|
|
334
|
+
chunksBySource.delete(sourceId);
|
|
335
|
+
const storedChunks = nonNegativeInteger(row.chunk_count);
|
|
336
|
+
const storedCodeChunks = nonNegativeInteger(row.code_chunk_count);
|
|
337
|
+
const source = boundedText(row.label, "(unnamed source)", 4_096);
|
|
338
|
+
const indexedAt = normalizedTimestamp(row.indexed_at);
|
|
339
|
+
let detail: string | undefined;
|
|
340
|
+
if (storedChunks !== actual.chunks || storedCodeChunks !== actual.codeChunks) {
|
|
341
|
+
detail = `Stored counts ${storedChunks}/${storedCodeChunks} differ from readable chunks ${actual.chunks}/${actual.codeChunks}.`;
|
|
342
|
+
if (inconsistencies.length < 10) inconsistencies.push(`${source}: ${detail}`);
|
|
343
|
+
}
|
|
344
|
+
sources.push({
|
|
345
|
+
source,
|
|
346
|
+
chunks: actual.chunks,
|
|
347
|
+
codeChunks: actual.codeChunks,
|
|
348
|
+
indexedBytes: actual.indexedBytes,
|
|
349
|
+
indexedAt,
|
|
350
|
+
...(detail ? { detail } : {}),
|
|
351
|
+
});
|
|
352
|
+
const day = indexedAt?.slice(0, 10);
|
|
353
|
+
if (day) incrementSummary(days, day, 0, actual.indexedBytes);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (chunksBySource.size > 0 && inconsistencies.length < 10) {
|
|
357
|
+
const orphanChunks = [...chunksBySource.values()].reduce(
|
|
358
|
+
(total, value) => boundedAdd(total, value.chunks),
|
|
359
|
+
0,
|
|
360
|
+
);
|
|
361
|
+
inconsistencies.push(`${orphanChunks} chunks reference a missing or unscanned source row.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
kind: "content",
|
|
366
|
+
indexedBytes: nonNegativeInteger(totals.indexed_bytes),
|
|
367
|
+
chunks: nonNegativeInteger(totals.chunks),
|
|
368
|
+
codeChunks: nonNegativeInteger(totals.code_chunks),
|
|
369
|
+
sources,
|
|
370
|
+
byDay: [...days].map(([date, summary]) => ({
|
|
371
|
+
date,
|
|
372
|
+
calls: summary.count,
|
|
373
|
+
savedBytes: summary.savedBytes,
|
|
374
|
+
})),
|
|
375
|
+
inconsistencies,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function classifyDatabaseError(error: unknown): ContextModeDatabaseErrorCode {
|
|
380
|
+
const message =
|
|
381
|
+
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
382
|
+
if (
|
|
383
|
+
message.includes("not a database") ||
|
|
384
|
+
message.includes("database disk image is malformed") ||
|
|
385
|
+
message.includes("database is locked") ||
|
|
386
|
+
message.includes("database is busy") ||
|
|
387
|
+
message.includes("unable to open database")
|
|
388
|
+
) {
|
|
389
|
+
return "database-open-failed";
|
|
390
|
+
}
|
|
391
|
+
return "database-query-failed";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function readContextModeDatabase(path: string): ContextModeDatabaseSnapshot {
|
|
395
|
+
let info: Stats;
|
|
396
|
+
try {
|
|
397
|
+
info = lstatSync(path);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw new ContextModeDatabaseError(
|
|
400
|
+
"not-regular-database",
|
|
401
|
+
path,
|
|
402
|
+
"The database file could not be inspected.",
|
|
403
|
+
error,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (!path.endsWith(".db") || !info.isFile()) {
|
|
407
|
+
throw new ContextModeDatabaseError(
|
|
408
|
+
"not-regular-database",
|
|
409
|
+
path,
|
|
410
|
+
"Only regular .db files can be inspected.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (info.size > MAX_DATABASE_BYTES) {
|
|
414
|
+
throw new ContextModeDatabaseError(
|
|
415
|
+
"database-open-failed",
|
|
416
|
+
path,
|
|
417
|
+
`The database exceeds the ${MAX_DATABASE_BYTES}-byte analytics limit.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let database: DatabaseSync;
|
|
422
|
+
try {
|
|
423
|
+
database = new DatabaseSync(path, {
|
|
424
|
+
readOnly: true,
|
|
425
|
+
allowExtension: false,
|
|
426
|
+
timeout: BUSY_TIMEOUT_MS,
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new ContextModeDatabaseError(
|
|
430
|
+
"database-open-failed",
|
|
431
|
+
path,
|
|
432
|
+
"The database could not be opened read-only.",
|
|
433
|
+
error,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
database.exec("PRAGMA query_only = ON");
|
|
439
|
+
const versionRow = database.prepare("PRAGMA user_version").get() as DatabaseRow | undefined;
|
|
440
|
+
const userVersion = nonNegativeInteger(versionRow?.user_version);
|
|
441
|
+
if (userVersion !== 0) {
|
|
442
|
+
throw new ContextModeDatabaseError(
|
|
443
|
+
"unsupported-schema",
|
|
444
|
+
path,
|
|
445
|
+
`Unsupported Context Mode schema version ${userVersion}; expected the 1.0.169 layout.`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const tableRows = database
|
|
449
|
+
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'")
|
|
450
|
+
.all() as DatabaseRow[];
|
|
451
|
+
const tables = new Set(
|
|
452
|
+
tableRows.flatMap((row) => (typeof row.name === "string" ? [row.name] : [])),
|
|
453
|
+
);
|
|
454
|
+
const hasSessionSchema = tables.has("session_events");
|
|
455
|
+
const hasContentSchema = tables.has("sources") || tables.has("chunks");
|
|
456
|
+
if (hasSessionSchema && hasContentSchema) {
|
|
457
|
+
throw new ContextModeDatabaseError(
|
|
458
|
+
"unsupported-schema",
|
|
459
|
+
path,
|
|
460
|
+
"Unsupported Context Mode schema: session and content tables are mixed.",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (hasSessionSchema) return readSessionDatabase(database, path);
|
|
464
|
+
if (hasContentSchema) return readContentDatabase(database, path);
|
|
465
|
+
throw new ContextModeDatabaseError(
|
|
466
|
+
"unsupported-schema",
|
|
467
|
+
path,
|
|
468
|
+
"Unsupported Context Mode schema: no recognized Context Mode tables.",
|
|
469
|
+
);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (error instanceof ContextModeDatabaseError) throw error;
|
|
472
|
+
throw new ContextModeDatabaseError(
|
|
473
|
+
classifyDatabaseError(error),
|
|
474
|
+
path,
|
|
475
|
+
"The database could not be queried safely.",
|
|
476
|
+
error,
|
|
477
|
+
);
|
|
478
|
+
} finally {
|
|
479
|
+
database.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { PluginSettings } from "@getpaseo/plugin/server";
|
|
2
|
+
import type {
|
|
3
|
+
BinaryStatus,
|
|
4
|
+
CommandReport,
|
|
5
|
+
ContextModeRefreshInput,
|
|
6
|
+
ContextModeSettings,
|
|
7
|
+
contextModeSettings,
|
|
8
|
+
IntegrationAudit,
|
|
9
|
+
} from "../shared";
|
|
10
|
+
import { type BinaryResolution, type LaunchDescriptor, resolveContextModeBinary } from "./binary";
|
|
11
|
+
import { createIntegrationAudit } from "./integration";
|
|
12
|
+
import {
|
|
13
|
+
callContextModeTool,
|
|
14
|
+
inspectContextMode,
|
|
15
|
+
type McpInspection,
|
|
16
|
+
ProcessFailure,
|
|
17
|
+
} from "./mcp-process";
|
|
18
|
+
|
|
19
|
+
type SettingsHandle = PluginSettings<typeof contextModeSettings.schema>;
|
|
20
|
+
type ToolName = "ctx_doctor" | "ctx_stats";
|
|
21
|
+
type FailureReport = Extract<CommandReport, { state: "unavailable" }>;
|
|
22
|
+
|
|
23
|
+
export interface ContextModeHandlerDependencies {
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
resolveBinary?: (settings: ContextModeSettings) => Promise<BinaryResolution>;
|
|
26
|
+
inspect?: (launch: LaunchDescriptor) => Promise<McpInspection>;
|
|
27
|
+
callTool?: (launch: LaunchDescriptor, name: ToolName) => Promise<string>;
|
|
28
|
+
createAudit?: (
|
|
29
|
+
settings: ContextModeSettings,
|
|
30
|
+
runtime: { path: string; version: string | null },
|
|
31
|
+
) => Promise<IntegrationAudit>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ContextModeHandlers {
|
|
35
|
+
status(input: ContextModeRefreshInput): Promise<BinaryStatus>;
|
|
36
|
+
doctor(input: ContextModeRefreshInput): Promise<CommandReport>;
|
|
37
|
+
stats(input: ContextModeRefreshInput): Promise<CommandReport>;
|
|
38
|
+
audit(input: ContextModeRefreshInput): Promise<IntegrationAudit>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CacheEntry<T> {
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
value: Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function failureResult(error: unknown, checkedAt: string): FailureReport {
|
|
47
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const message = rawMessage.slice(0, 4_096);
|
|
49
|
+
if (error instanceof ProcessFailure) {
|
|
50
|
+
return { state: "unavailable", code: error.code, message, checkedAt };
|
|
51
|
+
}
|
|
52
|
+
return { state: "unavailable", code: "command-failed", message, checkedAt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createContextModeHandlers(
|
|
56
|
+
settingsHandle: SettingsHandle,
|
|
57
|
+
dependencies: ContextModeHandlerDependencies = {},
|
|
58
|
+
): ContextModeHandlers {
|
|
59
|
+
const now = dependencies.now ?? (() => new Date());
|
|
60
|
+
const resolveBinary = dependencies.resolveBinary ?? resolveContextModeBinary;
|
|
61
|
+
const inspect = dependencies.inspect ?? inspectContextMode;
|
|
62
|
+
const callTool = dependencies.callTool ?? callContextModeTool;
|
|
63
|
+
const buildAudit = dependencies.createAudit ?? createIntegrationAudit;
|
|
64
|
+
const cache = new Map<string, CacheEntry<BinaryStatus | CommandReport>>();
|
|
65
|
+
|
|
66
|
+
async function settingsAndBinary(): Promise<
|
|
67
|
+
| {
|
|
68
|
+
state: "ready";
|
|
69
|
+
settings: ContextModeSettings;
|
|
70
|
+
binary: Extract<BinaryResolution, { state: "found" }>;
|
|
71
|
+
}
|
|
72
|
+
| { state: "unavailable"; report: FailureReport }
|
|
73
|
+
> {
|
|
74
|
+
const checkedAt = now().toISOString();
|
|
75
|
+
try {
|
|
76
|
+
const settings = await settingsHandle.read();
|
|
77
|
+
if (settings.status !== "ready") {
|
|
78
|
+
return {
|
|
79
|
+
state: "unavailable",
|
|
80
|
+
report: {
|
|
81
|
+
state: "unavailable",
|
|
82
|
+
code: "invalid-settings",
|
|
83
|
+
message: `Context Mode settings are invalid: ${settings.error}`.slice(0, 4_096),
|
|
84
|
+
checkedAt,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const binary = await resolveBinary(settings.values);
|
|
89
|
+
if (binary.state === "missing") {
|
|
90
|
+
return {
|
|
91
|
+
state: "unavailable",
|
|
92
|
+
report: {
|
|
93
|
+
state: "unavailable",
|
|
94
|
+
code: binary.code,
|
|
95
|
+
message: binary.message.slice(0, 4_096),
|
|
96
|
+
checkedAt,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { state: "ready", settings: settings.values, binary };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
state: "unavailable",
|
|
104
|
+
report: {
|
|
105
|
+
state: "unavailable",
|
|
106
|
+
code: "invalid-settings",
|
|
107
|
+
message: `Could not read Context Mode settings: ${
|
|
108
|
+
error instanceof Error ? error.message : String(error)
|
|
109
|
+
}`.slice(0, 4_096),
|
|
110
|
+
checkedAt,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cached<T extends BinaryStatus | CommandReport>(
|
|
117
|
+
key: string,
|
|
118
|
+
ttlMs: number,
|
|
119
|
+
fresh: boolean,
|
|
120
|
+
load: () => Promise<T>,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
const timestamp = now().getTime();
|
|
123
|
+
const existing = cache.get(key) as CacheEntry<T> | undefined;
|
|
124
|
+
if (!fresh && existing && existing.expiresAt > timestamp) return existing.value;
|
|
125
|
+
const value = load();
|
|
126
|
+
cache.set(key, { expiresAt: timestamp + ttlMs, value });
|
|
127
|
+
const result = await value;
|
|
128
|
+
if (result.state === "unavailable" && result.code !== "not-installed") cache.delete(key);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function status({ fresh }: ContextModeRefreshInput): Promise<BinaryStatus> {
|
|
133
|
+
const resolved = await settingsAndBinary();
|
|
134
|
+
if (resolved.state === "unavailable") return resolved.report;
|
|
135
|
+
const cacheKey = `status:${resolved.binary.source}:${resolved.binary.path}`;
|
|
136
|
+
return cached(cacheKey, 10_000, fresh, async () => {
|
|
137
|
+
const checkedAt = now().toISOString();
|
|
138
|
+
try {
|
|
139
|
+
const inspection = await inspect(resolved.binary.launch);
|
|
140
|
+
return {
|
|
141
|
+
state: "ready",
|
|
142
|
+
binaryPath: resolved.binary.path,
|
|
143
|
+
source: resolved.binary.source,
|
|
144
|
+
version: inspection.version,
|
|
145
|
+
supportsDoctor: inspection.tools.includes("ctx_doctor"),
|
|
146
|
+
supportsStats: inspection.tools.includes("ctx_stats"),
|
|
147
|
+
checkedAt,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return failureResult(error, checkedAt);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function report(name: ToolName, ttlMs: number, fresh: boolean): Promise<CommandReport> {
|
|
156
|
+
const resolved = await settingsAndBinary();
|
|
157
|
+
if (resolved.state === "unavailable") return resolved.report;
|
|
158
|
+
return cached(`${name}:${resolved.binary.path}`, ttlMs, fresh, async () => {
|
|
159
|
+
const checkedAt = now().toISOString();
|
|
160
|
+
try {
|
|
161
|
+
return {
|
|
162
|
+
state: "ready",
|
|
163
|
+
binaryPath: resolved.binary.path,
|
|
164
|
+
output: await callTool(resolved.binary.launch, name),
|
|
165
|
+
checkedAt,
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return failureResult(error, checkedAt);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function audit(_input: ContextModeRefreshInput): Promise<IntegrationAudit> {
|
|
174
|
+
const resolved = await settingsAndBinary();
|
|
175
|
+
if (resolved.state === "unavailable") {
|
|
176
|
+
return {
|
|
177
|
+
runtimePath: "",
|
|
178
|
+
runtimeVersion: null,
|
|
179
|
+
injectionEnabled: false,
|
|
180
|
+
providers: [],
|
|
181
|
+
checkedAt: resolved.report.checkedAt,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const inspection = await inspect(resolved.binary.launch).catch(() => ({
|
|
185
|
+
version: null,
|
|
186
|
+
tools: [],
|
|
187
|
+
}));
|
|
188
|
+
return buildAudit(resolved.settings, {
|
|
189
|
+
path: resolved.binary.path,
|
|
190
|
+
version: inspection.version,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
status,
|
|
196
|
+
doctor: ({ fresh }) => report("ctx_doctor", 10_000, fresh),
|
|
197
|
+
stats: ({ fresh }) => report("ctx_stats", 5_000, fresh),
|
|
198
|
+
audit,
|
|
199
|
+
};
|
|
200
|
+
}
|