@dotdotgod/context 0.2.26 → 0.2.27

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/README.md CHANGED
@@ -10,7 +10,8 @@ The package exposes a local stdio MCP server and reusable core modules. Claude C
10
10
 
11
11
  - `execute`, `batch_execute`, `execute_file`
12
12
  - `index`, `search`, `fetch_and_index`
13
- - `stats`, `doctor`, `purge`
13
+ - `session_resume`, `ingestion_job_start`, `ingestion_job_status`, `ingestion_job_cancel`
14
+ - `context_heal`, `stats`, `doctor`, `purge`
14
15
  - `dotdotgod_project_load`, `dotdotgod_project_impact`, `dotdotgod_project_initialize`
15
16
 
16
17
  Run the stdio MCP server directly with:
@@ -46,7 +47,7 @@ Indexed content is stored in the project-local SQLite FTS5 database:
46
47
  .dotdotgod/context/context.sqlite
47
48
  ```
48
49
 
49
- The store is local ignored state, not maintained project truth. Sources can use transient, session, or project scope and carry optional expiry metadata. Writable connections use WAL and a 1-second busy timeout. Source replacement, expiry, and purge update source and FTS rows transactionally. Existing compatible schemas reopen without migration; incompatible schemas fail before automatic repair. Search applies scope, session, and source predicates before candidate fusion, then:
50
+ The store is local ignored state, not maintained project truth. Sources can use transient, session, or project scope and carry optional expiry metadata. Writable connections use WAL and a 1-second busy timeout. Source replacement, expiry, and purge update source and FTS rows transactionally. Recognized older schemas migrate transactionally through a version ledger; unknown, newer, incomplete, or corrupt schemas fail closed before explicit healing. Search applies scope, session, and source predicates before candidate fusion, then:
50
51
 
51
52
  1. retrieves bounded Porter-tokenized FTS5 candidates;
52
53
  2. retrieves bounded label/path candidates;
@@ -126,6 +127,12 @@ The MCP server also exposes bounded project load, graph impact, and initializer
126
127
 
127
128
  Claude Code and Codex adapter hooks may tell the model to call these MCP tools and retry a denied operation. Hooks do not transform an ordinary host shell call into an MCP call. Pi retains its native project-memory lifecycle and calls the shared runtime directly.
128
129
 
130
+ ## Phase 3 Capabilities
131
+
132
+ Search includes a bounded typo-tolerant trigram lane while Porter FTS remains primary. Databases use transactional version-ledger migrations; explicit healing backs up first and accepts only recognized profiles. Opaque session IDs can be resumed without a history-listing API. Durable background ingestion has bounded queue, status, cancellation, and restart recovery.
133
+
134
+ Execution keeps compatibility-oriented filtered inheritance by default; this mode is not credential isolation. Opt-in `allowlist-v1` starts from an empty child environment and copies only explicit names, including caller overrides only when separately allowlisted, with names-only reporting. Strict fetch remains default. Browser rendering is available only through an injected renderer plus `browser: true`; this package bundles no browser, keeps rendered content `external-untrusted`, enforces limits/abort, and makes no sandbox claim.
135
+
129
136
  ## Learn More
130
137
 
131
138
  - [Behavior specification](https://github.com/dotdotgod/dotdotgod-kit/blob/main/docs/spec/CONTEXT_EXECUTION.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/context",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Local-first MCP execution, indexing, and retrieval runtime for dotdotgod adapters.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -28,7 +28,7 @@
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.25.2",
30
30
  "zod": "^3.25.76",
31
- "@dotdotgod/cli": "0.2.26"
31
+ "@dotdotgod/cli": "0.2.27"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=22.5.0"
package/src/content.mjs CHANGED
@@ -74,9 +74,38 @@ export function indexFile(store, input, sessionId, signal) {
74
74
  return indexRegularFile(store, input, sessionId, path, stat);
75
75
  }
76
76
 
77
- export async function fetchAndIndex(store, input, sessionId, signal) {
77
+ export async function fetchAndIndex(store, input, sessionId, signal, capabilities = {}) {
78
78
  const maxBytes = Math.min(input.maxBytes ?? MAX_FETCH_BYTES, MAX_FETCH_BYTES);
79
- const fetched = await safeFetch(input.url, {
79
+ let fetched;
80
+ if (input.browser === true) {
81
+ if (typeof capabilities.renderer !== 'function') throw new Error('Browser rendering capability is unavailable; strict fetch remains the default.');
82
+ const url = new URL(input.url);
83
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP(S) browser rendering is supported.');
84
+ const timeoutMs = Math.min(input.timeoutMs ?? 10_000, 120_000);
85
+ const controller = new AbortController();
86
+ let rejectAbort;
87
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
88
+ const abort = () => { const reason = signal?.reason instanceof Error ? signal.reason : new Error('Browser rendering aborted.'); controller.abort(reason); rejectAbort(reason); };
89
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
90
+ let timer;
91
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(new Error('Browser rendering timed out.')); reject(new Error(`Browser rendering timed out after ${timeoutMs}ms.`)); }, timeoutMs); timer.unref?.(); });
92
+ let rendered;
93
+ try { rendered = await Promise.race([capabilities.renderer({ url: url.href, signal: controller.signal, timeoutMs, maxBytes }), timeout, aborted]); }
94
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
95
+ if (!rendered || typeof rendered !== 'object' || Array.isArray(rendered)) throw new Error('Browser renderer returned an invalid result.');
96
+ if (rendered.body !== undefined && !Buffer.isBuffer(rendered.body)) throw new Error('Browser renderer body must be a Buffer.');
97
+ if (rendered.text !== undefined && typeof rendered.text !== 'string') throw new Error('Browser renderer text must be a string.');
98
+ if (rendered.body === undefined && rendered.text === undefined) throw new Error('Browser renderer must return body or text.');
99
+ const body = rendered.body ?? Buffer.from(rendered.text, 'utf8');
100
+ if (body.length > maxBytes) throw new Error(`Rendered content exceeds maximum size: ${body.length} bytes`);
101
+ const finalUrl = String(rendered.url ?? url.href);
102
+ if (finalUrl.length > 4096 || !['http:', 'https:'].includes(new URL(finalUrl).protocol)) throw new Error('Browser renderer returned an invalid final URL.');
103
+ const contentType = String(rendered.contentType ?? 'text/html; charset=utf-8');
104
+ if (contentType.length > 512 || /[\r\n]/u.test(contentType)) throw new Error('Browser renderer returned invalid content type metadata.');
105
+ const status = rendered.status ?? 200;
106
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw new Error('Browser renderer returned an invalid HTTP status.');
107
+ fetched = { body, text: body.toString('utf8'), url: finalUrl, originalUrl: url.href, redirects: [], contentType, contentEncoding: 'identity', status, bytes: body.length, wireLength: body.length };
108
+ } else fetched = await safeFetch(input.url, {
80
109
  signal,
81
110
  timeoutMs: input.timeoutMs,
82
111
  maxBytes,
@@ -112,6 +141,7 @@ export async function fetchAndIndex(store, input, sessionId, signal) {
112
141
  htmlTruncated: normalized.truncated,
113
142
  htmlFallbackReason: normalized.fallbackReason,
114
143
  } : {}),
144
+ acquisitionMode: input.browser === true ? 'browser-renderer' : 'strict-fetch',
115
145
  },
116
146
  provenance: {
117
147
  sourceType: 'fetched-url',
@@ -34,8 +34,11 @@ function entriesOf(value, label) {
34
34
  * Compose a child-process environment without exposing filtered values.
35
35
  * Override values may be strings or null; null explicitly deletes a variable.
36
36
  */
37
- export function composeEnvironment({ inherited = {}, overrides = {}, platform = process.platform } = {}) {
37
+ export function composeEnvironment({ inherited = {}, overrides = {}, platform = process.platform, mode = 'inherit-filtered-v1', allow = [] } = {}) {
38
38
  const family = platformFamily(platform);
39
+ if (!['inherit-filtered-v1', 'allowlist-v1'].includes(mode)) throw new TypeError(`Unsupported environment policy mode: ${mode}`);
40
+ if (!Array.isArray(allow)) throw new TypeError('allow must be an array');
41
+ const allowed = new Set(allow.map((name) => { validateName(name); return canonicalName(name, family); }));
39
42
  const env = {};
40
43
  const filtered = new Set();
41
44
 
@@ -46,7 +49,7 @@ export function composeEnvironment({ inherited = {}, overrides = {}, platform =
46
49
  }
47
50
  if (value === undefined) continue;
48
51
  const outputName = canonicalName(name, family);
49
- if (isFilteredName(outputName, family)) {
52
+ if (isFilteredName(outputName, family) || (mode === 'allowlist-v1' && !allowed.has(outputName))) {
50
53
  filtered.add(outputName);
51
54
  continue;
52
55
  }
@@ -59,6 +62,7 @@ export function composeEnvironment({ inherited = {}, overrides = {}, platform =
59
62
  if (isFilteredName(outputName, family)) {
60
63
  throw new TypeError(`Environment variable ${name} is reserved by the execution policy`);
61
64
  }
65
+ if (mode === 'allowlist-v1' && !allowed.has(outputName)) throw new TypeError(`Environment variable ${name} is not in the explicit allowlist`);
62
66
  if (value === null) {
63
67
  delete env[outputName];
64
68
  continue;
@@ -72,8 +76,9 @@ export function composeEnvironment({ inherited = {}, overrides = {}, platform =
72
76
  return {
73
77
  env,
74
78
  policy: {
75
- mode: 'inherit-filtered-v1',
79
+ mode,
76
80
  platform: family,
81
+ ...(mode === 'allowlist-v1' ? { allowedNames: [...allowed].sort((left, right) => left.localeCompare(right, 'en')) } : {}),
77
82
  filteredNames: [...filtered].sort((left, right) => left.localeCompare(right, 'en')),
78
83
  },
79
84
  };
package/src/execute.mjs CHANGED
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { Transform } from 'node:stream';
6
+ import { finished } from 'node:stream/promises';
6
7
  import { resolveWithinRoot } from './paths.mjs';
7
8
  import { composeEnvironment } from './environment-policy.mjs';
8
9
 
@@ -50,6 +51,8 @@ export async function executeCommand(input, options = {}) {
50
51
  const environment = composeEnvironment({
51
52
  inherited: options.env ?? process.env,
52
53
  overrides: input.env ?? {},
54
+ mode: input.environmentMode ?? 'inherit-filtered-v1',
55
+ allow: input.allowedEnv ?? [],
53
56
  });
54
57
 
55
58
  try {
@@ -80,14 +83,22 @@ export async function executeCommand(input, options = {}) {
80
83
  const timer = setTimeout(() => { timedOut = true; killProcess(child); }, timeoutMs);
81
84
  const onAbort = () => { aborted = true; killProcess(child); };
82
85
  options.signal?.addEventListener('abort', onAbort, { once: true });
83
- child.once('error', (error) => { clearTimeout(timer); reject(error); });
86
+ let spawnError = false;
87
+ child.once('error', (error) => {
88
+ spawnError = true;
89
+ clearTimeout(timer);
90
+ options.signal?.removeEventListener('abort', onAbort);
91
+ stdout.end(() => stderr.end(() => reject(error)));
92
+ });
84
93
  child.once('close', (code, signal) => {
94
+ if (spawnError) return;
85
95
  clearTimeout(timer);
86
96
  options.signal?.removeEventListener('abort', onAbort);
87
97
  stdout.end(() => stderr.end(() => resolvePromise({ code, signal })));
88
98
  });
89
99
  });
90
100
 
101
+ await Promise.all([finished(stdout), finished(stderr)]);
91
102
  const outputLimit = Math.min(MAX_DIRECT_RETURN, Math.max(1, input.outputLimit ?? DIRECT_LIMIT));
92
103
  const directLimit = Math.min(HARD_LIMIT, Math.max(1, input.directLimit ?? DIRECT_LIMIT));
93
104
  const stdoutResult = readBounded(stdoutPath, outputLimit);
@@ -129,13 +140,13 @@ export async function executeCommand(input, options = {}) {
129
140
  ...(mode === 'discard' ? {} : shouldIndex ? { indexed } : { stdout: stdoutResult.text, stderr: stderrResult.text, truncated: stdoutResult.truncated || stderrResult.truncated }),
130
141
  };
131
142
  } finally {
132
- rmSync(dir, { recursive: true, force: true });
143
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
133
144
  }
134
145
  }
135
146
 
136
147
  export async function executeFile(input, options = {}) {
137
148
  const path = resolveWithinRoot(options.root || process.cwd(), input.path);
138
- const selectorEnvironment = composeEnvironment({ inherited: options.env ?? process.env, overrides: input.env ?? {} }).env;
149
+ const selectorEnvironment = composeEnvironment({ inherited: options.env ?? process.env, overrides: input.env ?? {}, mode: input.environmentMode ?? 'inherit-filtered-v1', allow: input.allowedEnv ?? [] }).env;
139
150
  const dir = mkdtempSync(join(tmpdir(), 'dotdotgod-context-file-'));
140
151
  try {
141
152
  let executable;
@@ -158,7 +169,7 @@ export async function executeFile(input, options = {}) {
158
169
  }
159
170
  return await executeCommand({ ...input, executable, args, command: undefined, shell: false, cwd: input.cwd ?? options.root }, options);
160
171
  } finally {
161
- rmSync(dir, { recursive: true, force: true });
172
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
162
173
  }
163
174
  }
164
175
 
package/src/index.d.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  export type ContextScope = 'transient' | 'session' | 'project';
2
2
  export type DirectoryReason = 'excluded-path' | 'extension-filter' | 'file-byte-limit' | 'file-count-limit' | 'aggregate-byte-limit' | 'visited-entry-limit' | 'depth-limit' | 'file-symlink' | 'directory-symlink' | 'symlink-outside-root' | 'symlink-unresolvable' | 'special-file' | 'directory-read-failed' | 'entry-stat-failed' | 'index-failed';
3
- export interface EnvironmentPolicy { mode: 'inherit-filtered-v1'; platform: 'win32' | 'darwin' | 'posix'; filteredNames: string[] }
3
+ export interface EnvironmentPolicy { mode: 'inherit-filtered-v1' | 'allowlist-v1'; platform: 'win32' | 'darwin' | 'posix'; allowedNames?: string[]; filteredNames: string[] }
4
4
  export interface DirectoryManifestEntry { path: string; absolutePath: string; size: number; identity: { dev: number; ino: number; size: number; mtimeMs: number }; symlink: boolean }
5
5
  export interface DirectoryManifest { root: string; directory: string; files: DirectoryManifestEntry[]; skipped: Array<{ path: string; reason: DirectoryReason }>; failed: Array<{ path: string; reason: DirectoryReason; error?: string }>; aborted: boolean; truncated: boolean; visitedEntries: number; aggregateBytes: number }
6
6
  export interface HtmlNormalization { text: string; title: string | null; links: Array<{ text: string; href: string }>; extractor: 'html-v1'; fallbackReason: 'malformed-html' | null; mimeType: 'text/html' | 'application/xhtml+xml'; charset: 'utf-8' | 'us-ascii'; inputBytes: number; outputBytes: number; truncated: boolean }
7
- export interface ExecutionInput { label?: string; command?: string; executable?: string; args?: string[]; shell?: boolean; cwd?: string; timeoutMs?: number; outputLimit?: number; directLimit?: number; outputMode?: 'auto' | 'direct' | 'indexed' | 'discard'; scope?: ContextScope; ttlMs?: number; env?: Record<string, string | null> }
7
+ export interface ExecutionInput { label?: string; command?: string; executable?: string; args?: string[]; shell?: boolean; cwd?: string; timeoutMs?: number; outputLimit?: number; directLimit?: number; outputMode?: 'auto' | 'direct' | 'indexed' | 'discard'; scope?: ContextScope; ttlMs?: number; env?: Record<string, string | null>; environmentMode?: 'inherit-filtered-v1' | 'allowlist-v1'; allowedEnv?: string[] }
8
8
  export interface IndexInput { path: string; root?: string; source?: string; scope?: ContextScope; ttlMs?: number; maxBytes?: number; includeExtensions?: string[]; excludePaths?: string[]; followFileSymlinks?: boolean; maxDepth?: number; maxVisitedEntries?: number; maxFiles?: number; maxAggregateBytes?: number }
9
- export interface FetchInput { url: string; source?: string; scope?: ContextScope; ttlMs?: number; timeoutMs?: number; maxBytes?: number }
9
+ export interface FetchInput { url: string; source?: string; scope?: ContextScope; ttlMs?: number; timeoutMs?: number; maxBytes?: number; browser?: boolean }
10
+ export interface IndexResult { id: string; chunks: number; bytes: number; scope: ContextScope; expiresAt: number | null }
11
+ export interface FetchResult extends IndexResult {}
12
+ export interface IngestionJob { id: string; state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; kind: 'index' | 'fetch'; input: Record<string, unknown>; sessionId: string | null; result: unknown; error: string | null; createdAt: number; updatedAt: number }
10
13
  export interface DoctorResult { ok: boolean; status: 'OK' | 'WARN' | 'FAIL'; node: string; platform: string; root: string; dbPath: string; checks: Array<{ id: string; status: 'OK' | 'WARN' | 'FAIL'; message: string; details?: unknown }> }
11
14
 
12
15
  export function contextDbPath(root?: string): string;
@@ -19,13 +22,27 @@ export class ContextStore {
19
22
  search(input: { query: string; scope?: ContextScope; sessionId?: string; source?: string; limit?: number }): unknown[];
20
23
  stats(): { path: string; sources: number; chunks: number; byScope: Array<{ scope: ContextScope; count: number }> };
21
24
  purge(input: { scope?: ContextScope; sessionId?: string; sourceId?: string }): { removed: number };
25
+ createJob(input: { id?: string; kind: 'index' | 'fetch'; input: Record<string, unknown>; sessionId?: string | null }): IngestionJob;
26
+ getJob(id: string): IngestionJob | null;
27
+ claimNextJob(): IngestionJob | null;
28
+ finishJob(id: string, state: 'completed' | 'failed' | 'cancelled', value?: unknown): IngestionJob | null;
29
+ cancelJob(id: string): { changed: number; job: IngestionJob | null };
30
+ listPendingJobs(limit?: number): IngestionJob[];
22
31
  }
32
+ export function healContextDatabase(root?: string): { ok: true; backupPath: string; fromVersion: number; toVersion: number; rebuilt: true };
33
+ export function validateSessionId(value: unknown): string;
34
+ export function resolveSessionId(value?: unknown): string;
35
+ export class IngestionJobRunner { sessionId?: string; constructor(store: ContextStore, options?: { sessionId?: string; renderer?: BrowserRenderer; root?: string }); enqueue(kind: 'index' | 'fetch', input: Record<string, unknown>): IngestionJob; status(id: string): IngestionJob | null; cancel(id: string): { changed: number; job: IngestionJob | null }; pump(): Promise<void> }
36
+ export interface BrowserRendererResult { body?: Buffer; text?: string; url?: string; contentType?: string; status?: number }
37
+ export type BrowserRenderer = (input: Readonly<{ url: string; signal: AbortSignal; timeoutMs: number; maxBytes: number }>) => Promise<BrowserRendererResult>;
38
+ export const INGESTION_JOB_LIMITS: Readonly<{ maxPendingJobs: number; concurrency: 1 }>;
39
+ export const PHASE3_TOOL_INPUT_SCHEMAS: Readonly<Record<'session_resume' | 'ingestion_job_start' | 'ingestion_job_status' | 'ingestion_job_cancel' | 'context_heal', Readonly<Record<string, unknown>>>>;
23
40
  export function executeCommand(input: ExecutionInput, options?: { root?: string; store?: ContextStore; sessionId?: string; signal?: AbortSignal | undefined; env?: Record<string, string | undefined>; captureLimitBytes?: number }): Promise<Record<string, any>>;
24
41
  export function executeBatch(input: { commands: ExecutionInput[]; concurrency?: number; cwd?: string; timeoutMs?: number }, options?: Record<string, any>): Promise<Record<string, any>>;
25
42
  export function executeFile(input: ExecutionInput & { path: string; language: 'javascript' | 'python' | 'shell'; code: string }, options?: Record<string, any>): Promise<Record<string, any>>;
26
- export function indexFile(store: ContextStore, input: IndexInput, sessionId?: string, signal?: AbortSignal): Record<string, any>;
27
- export function fetchAndIndex(store: ContextStore, input: FetchInput, sessionId?: string, signal?: AbortSignal): Promise<Record<string, any>>;
28
- export function composeEnvironment(input?: { inherited?: Record<string, string | undefined>; overrides?: Record<string, string | null>; platform?: string }): { env: Record<string, string>; policy: EnvironmentPolicy };
43
+ export function indexFile(store: ContextStore, input: IndexInput, sessionId?: string, signal?: AbortSignal): IndexResult | (Record<string, unknown> & { indexed: IndexResult[] });
44
+ export function fetchAndIndex(store: ContextStore, input: FetchInput, sessionId?: string, signal?: AbortSignal, capabilities?: { renderer?: BrowserRenderer }): Promise<FetchResult>;
45
+ export function composeEnvironment(input?: { inherited?: Record<string, string | undefined>; overrides?: Record<string, string | null>; platform?: string; mode?: 'inherit-filtered-v1' | 'allowlist-v1'; allow?: string[] }): { env: Record<string, string>; policy: EnvironmentPolicy };
29
46
  export const DIRECTORY_INGESTION_LIMITS: Readonly<{ maxDepth: number; maxVisitedEntries: number; maxFiles: number; maxFileBytes: number; maxAggregateBytes: number }>;
30
47
  export function walkDirectoryManifest(input: IndexInput & { signal?: AbortSignal }): DirectoryManifest;
31
48
  export function verifyManifestFile(entry: DirectoryManifestEntry, root: string): string;
package/src/index.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  export { chunkContent, chunkJson, chunkMarkdown, chunkText, excerpt, normalizeText } from './chunks.mjs';
2
- export { ContextStore, contextDbPath } from './store.mjs';
2
+ export { ContextStore, contextDbPath, healContextDatabase } from './store.mjs';
3
3
  export { executeBatch, executeCommand, executeFile } from './execute.mjs';
4
4
  export { fetchAndIndex, indexFile } from './content.mjs';
5
5
  export { composeEnvironment } from './environment-policy.mjs';
6
+ export { INGESTION_JOB_LIMITS, IngestionJobRunner } from './jobs.mjs';
7
+ export { resolveSessionId, validateSessionId } from './session.mjs';
8
+ export { PHASE3_TOOL_INPUT_SCHEMAS } from './tool-contracts.mjs';
6
9
  export { DIRECTORY_INGESTION_LIMITS, readManifestFile, verifyManifestFile, walkDirectoryManifest } from './directory-ingestion.mjs';
7
10
  export { normalizeHtml } from './html-normalize.mjs';
8
11
  export { runDoctor } from './doctor.mjs';
package/src/jobs.mjs ADDED
@@ -0,0 +1,62 @@
1
+ import { fetchAndIndex, indexFile } from './content.mjs';
2
+
3
+ const MAX_PENDING_JOBS = 100;
4
+
5
+ export class IngestionJobRunner {
6
+ constructor(store, { sessionId, renderer, root } = {}) {
7
+ this.store = store;
8
+ this.sessionId = sessionId;
9
+ this.root = root ?? store.root;
10
+ this.renderer = renderer;
11
+ this.running = false;
12
+ this.scheduled = false;
13
+ this.controllers = new Map();
14
+ this.schedule();
15
+ }
16
+
17
+ enqueue(kind, input) {
18
+ if (!['index', 'fetch'].includes(kind)) throw new Error('Unsupported ingestion job kind.');
19
+ const job = this.store.createJob({ kind, input, sessionId: this.sessionId ?? null });
20
+ this.schedule();
21
+ return job;
22
+ }
23
+
24
+ status(id) { return this.store.getJob(id); }
25
+
26
+ cancel(id) {
27
+ this.controllers.get(id)?.abort();
28
+ return this.store.cancelJob(id);
29
+ }
30
+
31
+ schedule() {
32
+ if (this.scheduled || this.running) return;
33
+ this.scheduled = true;
34
+ queueMicrotask(() => { this.scheduled = false; void this.pump(); });
35
+ }
36
+
37
+ async pump() {
38
+ if (this.running) return;
39
+ this.running = true;
40
+ try {
41
+ for (;;) {
42
+ const job = this.store.claimNextJob();
43
+ if (!job) break;
44
+ const controller = new AbortController();
45
+ this.controllers.set(job.id, controller);
46
+ try {
47
+ const value = job.kind === 'index'
48
+ ? indexFile(this.store, { ...job.input, root: this.root }, job.sessionId ?? undefined, controller.signal)
49
+ : await fetchAndIndex(this.store, job.input, job.sessionId ?? undefined, controller.signal, { renderer: this.renderer });
50
+ const current = this.store.getJob(job.id);
51
+ if (current?.state === 'running') this.store.finishJob(job.id, 'completed', value);
52
+ } catch (error) {
53
+ const current = this.store.getJob(job.id);
54
+ if (current?.state === 'running') this.store.finishJob(job.id, controller.signal.aborted ? 'cancelled' : 'failed', error instanceof Error ? error.message : String(error));
55
+ } finally { this.controllers.delete(job.id); }
56
+ await new Promise((resolve) => setImmediate(resolve));
57
+ }
58
+ } finally { this.running = false; }
59
+ }
60
+ }
61
+
62
+ export const INGESTION_JOB_LIMITS = Object.freeze({ maxPendingJobs: MAX_PENDING_JOBS, concurrency: 1 });
package/src/server.mjs CHANGED
@@ -1,21 +1,26 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { ContextStore, contextDbPath } from './store.mjs';
4
+ import { ContextStore, contextDbPath, healContextDatabase } from './store.mjs';
5
5
  import { executeBatch, executeCommand, executeFile } from './execute.mjs';
6
6
  import { fetchAndIndex, indexFile } from './content.mjs';
7
7
  import { runDoctor } from './doctor.mjs';
8
8
  import { projectImpact, projectInitialize, projectLoad } from './project.mjs';
9
9
  import { resolveWithinRoot } from './paths.mjs';
10
+ import { IngestionJobRunner } from './jobs.mjs';
11
+ import { resolveSessionId, validateSessionId } from './session.mjs';
12
+ import { PHASE3_TOOL_INPUT_SCHEMAS } from './tool-contracts.mjs';
10
13
 
11
14
  const root = process.env.DOTDOTGOD_PROJECT_ROOT || process.cwd();
12
- const sessionId = process.env.DOTDOTGOD_SESSION_ID || crypto.randomUUID();
15
+ let sessionId = resolveSessionId(process.env.DOTDOTGOD_SESSION_ID);
13
16
  let contextStore;
17
+ let jobRunner;
14
18
  function getStore() {
15
19
  contextStore ??= new ContextStore(root);
16
20
  return contextStore;
17
21
  }
18
- const server = new McpServer({ name: 'dotdotgod-context', version: '0.2.26' });
22
+ function getJobs() { jobRunner ??= new IngestionJobRunner(getStore(), { sessionId }); return jobRunner; }
23
+ const server = new McpServer({ name: 'dotdotgod-context', version: '0.2.27' });
19
24
 
20
25
  const scopeSchema = z.enum(['transient', 'session', 'project']).optional();
21
26
  const outputModeSchema = z.enum(['auto', 'direct', 'indexed', 'discard']).optional();
@@ -23,7 +28,7 @@ const commandSchema = {
23
28
  label: z.string().optional(), command: z.string().optional(), executable: z.string().optional(), args: z.array(z.string()).optional(),
24
29
  shell: z.boolean().optional(), cwd: z.string().optional(), timeoutMs: z.number().int().positive().optional(), outputLimit: z.number().int().positive().optional(),
25
30
  directLimit: z.number().int().positive().optional(), outputMode: outputModeSchema, scope: scopeSchema, ttlMs: z.number().int().nonnegative().optional(),
26
- env: z.record(z.string(), z.string().nullable()).optional(),
31
+ env: z.record(z.string(), z.string().nullable()).optional(), environmentMode: z.enum(['inherit-filtered-v1', 'allowlist-v1']).optional(), allowedEnv: z.array(z.string()).max(100).optional(),
27
32
  };
28
33
 
29
34
  function success(value) {
@@ -63,8 +68,32 @@ register('search', 'Search indexed command, file, and fetched content and return
63
68
  results: getStore().search({ ...input, sessionId: input.sessionOnly ? sessionId : undefined }),
64
69
  }), { readOnlyHint: true });
65
70
  register('fetch_and_index', 'Fetch an HTTP(S) URL locally, index bounded text, and return metadata only.', {
66
- url: z.string().url(), source: z.string().optional(), scope: scopeSchema, ttlMs: z.number().int().nonnegative().optional(), timeoutMs: z.number().int().positive().optional(), maxBytes: z.number().int().positive().optional(),
71
+ url: z.string().url(), source: z.string().optional(), scope: scopeSchema, ttlMs: z.number().int().nonnegative().optional(), timeoutMs: z.number().int().positive().optional(), maxBytes: z.number().int().positive().optional(), browser: z.boolean().optional(),
67
72
  }, (input, extra) => fetchAndIndex(getStore(), input, sessionId, extra.signal).then((value) => ({ ok: true, ...value })), { openWorldHint: true, readOnlyHint: true });
73
+ function zodFromContract(schema) {
74
+ if (schema.const !== undefined) return z.literal(schema.const);
75
+ if (schema.enum) return z.enum(schema.enum);
76
+ if (schema.type === 'string') {
77
+ let value = z.string();
78
+ if (schema.minLength !== undefined) value = value.min(schema.minLength);
79
+ if (schema.maxLength !== undefined) value = value.max(schema.maxLength);
80
+ if (schema.pattern) value = value.regex(new RegExp(schema.pattern, 'u'));
81
+ if (schema.format === 'uuid') value = value.uuid();
82
+ return value;
83
+ }
84
+ if (schema.type === 'object') {
85
+ if (!schema.properties) return z.record(z.string(), z.unknown());
86
+ const required = new Set(schema.required ?? []);
87
+ return z.object(Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, required.has(key) ? zodFromContract(value) : zodFromContract(value).optional()]))).strict();
88
+ }
89
+ throw new Error('Unsupported shared tool contract schema.');
90
+ }
91
+ const phase3Shape = (name) => zodFromContract(PHASE3_TOOL_INPUT_SCHEMAS[name]).shape;
92
+ register('session_resume', 'Use an explicit opaque session ID for subsequent context operations; historical sessions are not listed.', phase3Shape('session_resume'), (input) => { sessionId = validateSessionId(input.sessionId); if (jobRunner) jobRunner.sessionId = sessionId; return { ok: true, sessionId }; });
93
+ register('ingestion_job_start', 'Queue one durable bounded background index or strict-fetch ingestion job.', phase3Shape('ingestion_job_start'), (input) => ({ ok: true, job: getJobs().enqueue(input.kind, input.input) }));
94
+ register('ingestion_job_status', 'Return bounded status for one background ingestion job.', phase3Shape('ingestion_job_status'), (input) => ({ ok: true, job: getJobs().status(input.id) }), { readOnlyHint: true });
95
+ register('ingestion_job_cancel', 'Cancel one queued or running background ingestion job.', phase3Shape('ingestion_job_cancel'), (input) => ({ ok: true, ...getJobs().cancel(input.id) }), { destructiveHint: true });
96
+ register('context_heal', 'Explicitly back up and migrate only a recognized recoverable context database.', phase3Shape('context_heal'), () => { contextStore?.close(); contextStore = undefined; jobRunner = undefined; return healContextDatabase(root); }, { destructiveHint: true });
68
97
  register('stats', 'Report local context store counts and location.', {}, () => ({ ok: true, sessionId, ...getStore().stats() }), { readOnlyHint: true });
69
98
  register('doctor', 'Run local read-only Node.js, SQLite FTS5, storage, schema, and fetch-policy checks without network or repair actions.', {}, () => ({ sessionId, ...runDoctor({ root, dbPath: contextDbPath(root) }) }), { readOnlyHint: true });
70
99
  register('purge', 'Permanently delete one explicit context scope, session, or source.', {
@@ -0,0 +1,12 @@
1
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
2
+
3
+ export function validateSessionId(value) {
4
+ if (typeof value !== 'string' || !SESSION_ID.test(value)) {
5
+ throw new TypeError('sessionId must be an opaque 1-128 character identifier using letters, digits, dot, underscore, tilde, or hyphen.');
6
+ }
7
+ return value;
8
+ }
9
+
10
+ export function resolveSessionId(value) {
11
+ return value == null ? crypto.randomUUID() : validateSessionId(value);
12
+ }
package/src/store.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
  import { extname } from 'node:path';
@@ -20,6 +20,9 @@ export function contextDbPath(root) {
20
20
  return join(safeRoot(root), '.dotdotgod', 'context', 'context.sqlite');
21
21
  }
22
22
 
23
+ const SCHEMA_VERSION = 3;
24
+ const MAX_PENDING_JOBS = 100;
25
+
23
26
  function inspectSchema(db) {
24
27
  const objects = new Map(db.prepare("SELECT name, type, sql FROM sqlite_master WHERE name IN ('sources', 'chunks')").all().map((row) => [row.name, row]));
25
28
  const sourceRows = db.prepare('PRAGMA table_info(sources)').all();
@@ -41,7 +44,102 @@ function inspectSchema(db) {
41
44
  && chunkShape
42
45
  && missingSources.length === 0
43
46
  && missingChunks.length === 0;
44
- return { compatible, schemaProfile: compatible ? 1 : null, missing: [...missingSources, ...missingChunks] };
47
+ let version = 0;
48
+ if (compatible) {
49
+ const ledger = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
50
+ if (ledger) version = Number(db.prepare('SELECT coalesce(max(version), 0) AS version FROM schema_migrations').get().version);
51
+ else version = 1;
52
+ }
53
+ return { compatible: compatible && version <= SCHEMA_VERSION, schemaProfile: compatible ? version : null, missing: [...missingSources, ...missingChunks] };
54
+ }
55
+
56
+ function migrate(db, fromVersion) {
57
+ if (fromVersion > SCHEMA_VERSION) throw new Error(`Incompatible context database schema version ${fromVersion}; runtime supports ${SCHEMA_VERSION}.`);
58
+ db.exec('BEGIN IMMEDIATE');
59
+ try {
60
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);');
61
+ if (fromVersion < 2) {
62
+ db.exec(`CREATE TABLE IF NOT EXISTS ingestion_jobs (
63
+ id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('queued','running','completed','failed','cancelled')),
64
+ kind TEXT NOT NULL, input TEXT NOT NULL, result TEXT, error TEXT,
65
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
66
+ ); CREATE INDEX IF NOT EXISTS ingestion_jobs_state_created ON ingestion_jobs(state, created_at);`);
67
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, ?)').run(Date.now());
68
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (2, ?)').run(Date.now());
69
+ }
70
+ if (fromVersion < 3) {
71
+ const columns = db.prepare('PRAGMA table_info(ingestion_jobs)').all();
72
+ if (!columns.some((row) => row.name === 'session_id')) db.exec('ALTER TABLE ingestion_jobs ADD COLUMN session_id TEXT;');
73
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (3, ?)').run(Date.now());
74
+ }
75
+ db.exec('COMMIT');
76
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
77
+ }
78
+
79
+ function trigrams(value) {
80
+ const normalized = ` ${String(value).toLocaleLowerCase('en').replace(/[^\p{L}\p{N}]+/gu, ' ').trim()} `;
81
+ const out = new Set();
82
+ for (let index = 0; index + 3 <= normalized.length; index += 1) out.add(normalized.slice(index, index + 3));
83
+ return out;
84
+ }
85
+ function trigramScore(left, right) {
86
+ const a = trigrams(left); const b = trigrams(right);
87
+ if (!a.size || !b.size) return 0;
88
+ let overlap = 0; for (const item of a) if (b.has(item)) overlap += 1;
89
+ return (2 * overlap) / (a.size + b.size);
90
+ }
91
+ function editDistance(left, right, ceiling = 2) {
92
+ if (Math.abs(left.length - right.length) > ceiling) return ceiling + 1;
93
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
94
+ for (let i = 1; i <= left.length; i += 1) {
95
+ const current = [i]; let rowMin = i;
96
+ for (let j = 1; j <= right.length; j += 1) { current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)); rowMin = Math.min(rowMin, current[j]); }
97
+ if (rowMin > ceiling) return ceiling + 1; previous = current;
98
+ }
99
+ return previous[right.length];
100
+ }
101
+ function typoScore(query, value) {
102
+ const queryTerms = normalizeSearchTerms(query);
103
+ const valueTerms = normalizeSearchTerms(value);
104
+ let best = 0;
105
+ for (const needle of queryTerms) for (const token of valueTerms) {
106
+ if (needle.length >= 5 && editDistance(needle, token) <= 2) best = Math.max(best, trigramScore(needle, token));
107
+ }
108
+ return best;
109
+ }
110
+
111
+ export function healContextDatabase(root = process.cwd()) {
112
+ const path = contextDbPath(root);
113
+ if (!existsSync(path)) throw new Error('No context database exists to heal.');
114
+ const stamp = Date.now();
115
+ const backupPath = `${path}.backup-${stamp}`;
116
+ const rebuiltPath = `${path}.healed-${stamp}`;
117
+ let inspection;
118
+ let replaced = false;
119
+ const db = new DatabaseSync(path);
120
+ try {
121
+ db.exec('PRAGMA busy_timeout=1000; PRAGMA wal_checkpoint(FULL);');
122
+ inspection = inspectSchema(db);
123
+ if (!inspection.compatible || ![1, 2, 3].includes(inspection.schemaProfile)) {
124
+ throw new Error('Schema is not a recognized recoverable context database; no rebuild performed.');
125
+ }
126
+ copyFileSync(path, backupPath, 0);
127
+ migrate(db, inspection.schemaProfile);
128
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE);');
129
+ db.exec(`VACUUM INTO '${rebuiltPath.replaceAll("'", "''")}';`);
130
+ } catch (error) {
131
+ rmSync(rebuiltPath, { force: true });
132
+ throw error;
133
+ } finally { db.close(); }
134
+ try {
135
+ renameSync(rebuiltPath, path);
136
+ replaced = true;
137
+ rmSync(`${path}-wal`, { force: true });
138
+ rmSync(`${path}-shm`, { force: true });
139
+ } finally {
140
+ if (!replaced) rmSync(rebuiltPath, { force: true });
141
+ }
142
+ return { ok: true, backupPath, fromVersion: inspection.schemaProfile, toVersion: SCHEMA_VERSION, rebuilt: true };
45
143
  }
46
144
 
47
145
  export class ContextStore {
@@ -49,12 +147,14 @@ export class ContextStore {
49
147
  this.root = safeRoot(root);
50
148
  this.path = contextDbPath(this.root);
51
149
  const existing = existsSync(this.path);
150
+ let schemaVersion = 1;
52
151
  if (existing) {
53
152
  if (statSync(this.path).size === 0) throw new Error('Incompatible context database schema; empty database cannot be repaired automatically.');
54
153
  const inspection = new DatabaseSync(this.path, { readOnly: true });
55
154
  try {
56
155
  const result = inspectSchema(inspection);
57
- if (!result.compatible) throw new Error(`Incompatible context database schema; run doctor before repair${result.missing.length ? ` (missing: ${result.missing.join(', ')})` : ''}.`);
156
+ if (!result.compatible) throw new Error(`Incompatible context database schema; run doctor before explicit healing${result.missing.length ? ` (missing: ${result.missing.join(', ')})` : ''}.`);
157
+ schemaVersion = result.schemaProfile;
58
158
  } finally {
59
159
  inspection.close();
60
160
  }
@@ -83,6 +183,8 @@ export class ContextStore {
83
183
  tokenize='porter unicode61'
84
184
  );
85
185
  `);
186
+ migrate(this.db, schemaVersion);
187
+ this.db.prepare("UPDATE ingestion_jobs SET state = 'queued', updated_at = ? WHERE state = 'running'").run(Date.now());
86
188
  } catch (error) {
87
189
  this.db.close();
88
190
  throw error;
@@ -161,6 +263,27 @@ export class ContextStore {
161
263
  AND (${labelClauses.join(' OR ')})${filterSql}
162
264
  ORDER BY lower(s.label), c.ordinal LIMIT ?
163
265
  `).all(...labelParams, ...filterParams, candidateLimit);
266
+ const typoWindow = Math.min(500, candidateLimit * 5);
267
+ const typoHalf = Math.max(1, Math.floor(typoWindow / 2));
268
+ const trigramRows = this.db.prepare(`
269
+ SELECT * FROM (
270
+ SELECT ${select.replace('SELECT ', '')}, 0 AS rank
271
+ FROM chunks c JOIN sources s ON s.id = c.source_id
272
+ WHERE 1 = 1${filterSql}
273
+ ORDER BY c.rowid ASC LIMIT ?
274
+ )
275
+ UNION ALL
276
+ SELECT * FROM (
277
+ SELECT ${select.replace('SELECT ', '')}, 0 AS rank
278
+ FROM chunks c JOIN sources s ON s.id = c.source_id
279
+ WHERE 1 = 1${filterSql}
280
+ ORDER BY c.rowid DESC LIMIT ?
281
+ )
282
+ `).all(...filterParams, typoHalf, ...filterParams, typoWindow - typoHalf)
283
+ .map((row) => ({ ...row, trigramScore: Math.max(typoScore(query, row.body), Number(row.ordinal) === 0 ? typoScore(query, row.label) : 0) }))
284
+ .filter((row) => row.trigramScore >= 0.5)
285
+ .sort((left, right) => right.trigramScore - left.trigramScore || String(left.source_id).localeCompare(String(right.source_id), 'en') || Number(left.ordinal) - Number(right.ordinal))
286
+ .slice(0, candidateLimit);
164
287
  const toCandidate = (row) => {
165
288
  let storedMetadata = {};
166
289
  try { storedMetadata = JSON.parse(row.metadata); } catch { /* legacy invalid metadata */ }
@@ -169,6 +292,7 @@ export class ContextStore {
169
292
  const fused = reciprocalRankFusion([
170
293
  { name: 'porter-bm25', candidates: porterRows.map(toCandidate) },
171
294
  { name: 'label-path', candidates: labelRows.map(toCandidate) },
295
+ { name: 'trigram-v1', candidates: trigramRows.map(toCandidate) },
172
296
  ], { limit: candidateLimit });
173
297
  return rerankCandidates(fused, query, { limit: resultLimit }).map((entry) => {
174
298
  const row = entry.candidate;
@@ -190,6 +314,53 @@ export class ContextStore {
190
314
  });
191
315
  }
192
316
 
317
+ createJob({ id = crypto.randomUUID(), kind, input, sessionId = null }) {
318
+ const now = Date.now();
319
+ const serializedInput = JSON.stringify(input);
320
+ if (Buffer.byteLength(serializedInput) > 64 * 1024) throw new Error('Background ingestion job input exceeds 64 KiB.');
321
+ this.db.exec('BEGIN IMMEDIATE');
322
+ try {
323
+ const pending = Number(this.db.prepare("SELECT count(*) AS count FROM ingestion_jobs WHERE state IN ('queued','running')").get().count);
324
+ if (pending >= MAX_PENDING_JOBS) throw new Error('Background ingestion queue is full.');
325
+ this.db.prepare('INSERT INTO ingestion_jobs(id, state, kind, input, session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
326
+ .run(id, 'queued', kind, serializedInput, sessionId, now, now);
327
+ this.db.exec('COMMIT');
328
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
329
+ return this.getJob(id);
330
+ }
331
+
332
+ getJob(id) {
333
+ const row = this.db.prepare('SELECT * FROM ingestion_jobs WHERE id = ?').get(id);
334
+ if (!row) return null;
335
+ return { id: row.id, state: row.state, kind: row.kind, input: JSON.parse(row.input), sessionId: row.session_id ?? null, result: row.result ? JSON.parse(row.result) : null, error: row.error, createdAt: row.created_at, updatedAt: row.updated_at };
336
+ }
337
+
338
+ claimNextJob() {
339
+ const row = this.db.prepare("SELECT id FROM ingestion_jobs WHERE state = 'queued' ORDER BY created_at, id LIMIT 1").get();
340
+ if (!row) return null;
341
+ const changed = this.db.prepare("UPDATE ingestion_jobs SET state = 'running', updated_at = ? WHERE id = ? AND state = 'queued'").run(Date.now(), row.id).changes;
342
+ return changed ? this.getJob(row.id) : null;
343
+ }
344
+
345
+ finishJob(id, state, value = null) {
346
+ if (!['completed', 'failed', 'cancelled'].includes(state)) throw new Error('Invalid terminal job state.');
347
+ let result = state === 'completed' ? JSON.stringify(value) : null;
348
+ if (result && Buffer.byteLength(result) > 256 * 1024) result = JSON.stringify({ truncated: true, message: 'Completed result exceeded the durable status limit.' });
349
+ const error = state === 'failed' ? String(value ?? 'Job failed').slice(0, 4096) : null;
350
+ this.db.prepare("UPDATE ingestion_jobs SET state = ?, result = ?, error = ?, updated_at = ? WHERE id = ? AND state IN ('queued','running')")
351
+ .run(state, result, error, Date.now(), id);
352
+ return this.getJob(id);
353
+ }
354
+
355
+ cancelJob(id) {
356
+ const changed = this.db.prepare("UPDATE ingestion_jobs SET state = 'cancelled', updated_at = ? WHERE id = ? AND state IN ('queued','running')").run(Date.now(), id).changes;
357
+ return { changed: Number(changed), job: this.getJob(id) };
358
+ }
359
+
360
+ listPendingJobs(limit = 20) {
361
+ return this.db.prepare("SELECT id FROM ingestion_jobs WHERE state IN ('queued','running') ORDER BY created_at, id LIMIT ?").all(Math.min(100, Math.max(1, limit))).map((row) => this.getJob(row.id));
362
+ }
363
+
193
364
  stats() {
194
365
  this.expire();
195
366
  const sources = this.db.prepare('SELECT count(*) AS count FROM sources').get().count;
@@ -0,0 +1,13 @@
1
+ const object = (properties, required = []) => Object.freeze({ type: 'object', properties, required, additionalProperties: false, $schema: 'http://json-schema.org/draft-07/schema#' });
2
+ const string = (options = {}) => Object.freeze({ type: 'string', ...options });
3
+
4
+ export const PHASE3_TOOL_INPUT_SCHEMAS = Object.freeze({
5
+ session_resume: object({ sessionId: string({ minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' }) }, ['sessionId']),
6
+ ingestion_job_start: object({
7
+ kind: Object.freeze({ type: 'string', enum: ['index', 'fetch'] }),
8
+ input: Object.freeze({ type: 'object', additionalProperties: Object.freeze({}) }),
9
+ }, ['kind', 'input']),
10
+ ingestion_job_status: object({ id: string({ format: 'uuid' }) }, ['id']),
11
+ ingestion_job_cancel: object({ id: string({ format: 'uuid' }) }, ['id']),
12
+ context_heal: object({ confirm: Object.freeze({ const: true, type: 'boolean' }) }, ['confirm']),
13
+ });