@dotdotgod/context 0.2.25 → 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 +24 -5
- package/package.json +2 -2
- package/src/content.mjs +112 -11
- package/src/directory-ingestion.mjs +182 -0
- package/src/doctor.mjs +14 -5
- package/src/environment-policy.mjs +85 -0
- package/src/execute.mjs +25 -6
- package/src/html-normalize.mjs +195 -0
- package/src/index.d.ts +44 -10
- package/src/index.mjs +7 -1
- package/src/jobs.mjs +62 -0
- package/src/provenance.mjs +1 -1
- package/src/server.mjs +38 -6
- package/src/session.mjs +12 -0
- package/src/store.mjs +253 -35
- package/src/tool-contracts.mjs +13 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const DEFAULT_INPUT_BYTES = 10 * 1024 * 1024;
|
|
2
|
+
const DEFAULT_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
const EXTRACTOR = 'html-v1';
|
|
4
|
+
const ACCEPTED_MIME = new Set(['text/html', 'application/xhtml+xml']);
|
|
5
|
+
const ACCEPTED_CHARSET = new Set(['utf-8', 'utf8', 'us-ascii']);
|
|
6
|
+
const MAX_LINKS = 100;
|
|
7
|
+
const MAX_LINK_METADATA_BYTES = 64 * 1024;
|
|
8
|
+
const BLOCK_TAGS = new Set(['address', 'article', 'aside', 'blockquote', 'br', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', 'footer', 'header', 'hr', 'main', 'nav', 'p', 'section', 'tr']);
|
|
9
|
+
const DROP_CONTENT = new Set(['script', 'style', 'noscript', 'template', 'svg', 'math', 'iframe', 'object', 'embed', 'form']);
|
|
10
|
+
|
|
11
|
+
function positiveLimit(value, fallback) {
|
|
12
|
+
if (value === undefined) return fallback;
|
|
13
|
+
if (!Number.isFinite(value) || value <= 0) throw new TypeError('HTML byte limits must be positive finite numbers.');
|
|
14
|
+
return Math.floor(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseMediaType(value) {
|
|
18
|
+
const raw = String(value ?? 'text/html');
|
|
19
|
+
const [type, ...parameters] = raw.split(';');
|
|
20
|
+
const mimeType = type.trim().toLowerCase();
|
|
21
|
+
if (!ACCEPTED_MIME.has(mimeType)) throw new Error(`Unsupported HTML MIME type: ${mimeType || '(empty)'}`);
|
|
22
|
+
let charset = 'utf-8';
|
|
23
|
+
for (const parameter of parameters) {
|
|
24
|
+
const match = /^\s*charset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s;]+))\s*$/iu.exec(parameter);
|
|
25
|
+
if (match) charset = (match[1] || match[2] || match[3]).toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
if (!ACCEPTED_CHARSET.has(charset)) throw new Error(`Unsupported HTML charset: ${charset}`);
|
|
28
|
+
return { mimeType, charset: charset === 'utf8' ? 'utf-8' : charset };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decodeEntities(value) {
|
|
32
|
+
const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
|
|
33
|
+
return value.replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z]+);/giu, (whole, entity) => {
|
|
34
|
+
if (entity[0] !== '#') return named[entity.toLowerCase()] ?? whole;
|
|
35
|
+
const hexadecimal = entity[1]?.toLowerCase() === 'x';
|
|
36
|
+
const number = Number.parseInt(entity.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10);
|
|
37
|
+
if (!Number.isInteger(number) || number < 0 || number > 0x10ffff || (number >= 0xd800 && number <= 0xdfff)) return '\uFFFD';
|
|
38
|
+
return String.fromCodePoint(number);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeWhitespace(value) {
|
|
43
|
+
return value
|
|
44
|
+
.replace(/[\t\f\v ]+/gu, ' ')
|
|
45
|
+
.replace(/ *\n */gu, '\n')
|
|
46
|
+
.replace(/\n{3,}/gu, '\n\n')
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function boundedUtf8(value, maxBytes) {
|
|
51
|
+
if (Buffer.byteLength(value) <= maxBytes) return { value, truncated: false };
|
|
52
|
+
let output = '';
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
for (const point of value) {
|
|
55
|
+
const size = Buffer.byteLength(point);
|
|
56
|
+
if (bytes + size > maxBytes) break;
|
|
57
|
+
output += point;
|
|
58
|
+
bytes += size;
|
|
59
|
+
}
|
|
60
|
+
return { value: output.trimEnd(), truncated: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tagInfo(raw) {
|
|
64
|
+
const closing = /^<\s*\//u.test(raw);
|
|
65
|
+
const match = /^<\s*\/?\s*([a-z][a-z0-9:-]*)/iu.exec(raw);
|
|
66
|
+
if (!match) return null;
|
|
67
|
+
const name = match[1].toLowerCase();
|
|
68
|
+
return { name, closing, selfClosing: /\/\s*>$/u.test(raw), attributes: closing ? '' : raw.slice(match[0].length, -1) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hiddenElement(attributes) {
|
|
72
|
+
return /(?:^|\s)hidden(?:\s|=|$)/iu.test(attributes)
|
|
73
|
+
|| /(?:^|\s)aria-hidden\s*=\s*(?:["']?true["']?)/iu.test(attributes)
|
|
74
|
+
|| /(?:^|\s)type\s*=\s*(?:["']?hidden["']?)/iu.test(attributes)
|
|
75
|
+
|| /(?:^|\s)style\s*=\s*(["'])[^"']*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^"']*\1/iu.test(attributes);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hrefFrom(attributes) {
|
|
79
|
+
const match = /(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/iu.exec(attributes);
|
|
80
|
+
return decodeEntities(match ? (match[1] ?? match[2] ?? match[3] ?? '') : '').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function normalizeHtml(value, options = {}) {
|
|
84
|
+
const maxInputBytes = positiveLimit(options.maxInputBytes, DEFAULT_INPUT_BYTES);
|
|
85
|
+
const maxOutputBytes = positiveLimit(options.maxOutputBytes, DEFAULT_OUTPUT_BYTES);
|
|
86
|
+
const { mimeType, charset } = parseMediaType(options.contentType);
|
|
87
|
+
const input = Buffer.isBuffer(value) ? value : Buffer.from(String(value ?? ''), 'utf8');
|
|
88
|
+
const inputBytes = input.length;
|
|
89
|
+
if (inputBytes > maxInputBytes) throw new Error(`HTML input exceeds maximum size: ${inputBytes} bytes`);
|
|
90
|
+
if (charset === 'us-ascii' && input.some((byte) => byte > 0x7f)) throw new Error('HTML body contains bytes outside the declared US-ASCII charset.');
|
|
91
|
+
let html;
|
|
92
|
+
try { html = new TextDecoder('utf-8', { fatal: true }).decode(input); }
|
|
93
|
+
catch { throw new Error('HTML body is not valid UTF-8.'); }
|
|
94
|
+
const declaredCharsets = [...html.matchAll(/<meta\b[^>]*charset\s*=\s*["']?([^\s"'/>;]+)/giu)].map((match) => match[1].toLowerCase().replace('utf8', 'utf-8'));
|
|
95
|
+
for (const match of html.matchAll(/<meta\b(?=[^>]*http-equiv\s*=\s*["']?content-type["']?)[^>]*content\s*=\s*(?:"[^"]*charset\s*=\s*([^\s";]+)[^"]*"|'[^']*charset\s*=\s*([^\s';]+)[^']*'|[^>]*charset\s*=\s*([^\s;>]+))[^>]*>/giu)) {
|
|
96
|
+
declaredCharsets.push((match[1] || match[2] || match[3]).toLowerCase().replace('utf8', 'utf-8'));
|
|
97
|
+
}
|
|
98
|
+
if (declaredCharsets.some((declared) => !ACCEPTED_CHARSET.has(declared) || declared !== charset)) throw new Error('HTML document charset conflicts with the response charset.');
|
|
99
|
+
html = html.replace(/\0/gu, '\uFFFD').replace(/\r\n?/gu, '\n');
|
|
100
|
+
const lower = html.toLowerCase();
|
|
101
|
+
const output = [];
|
|
102
|
+
const headings = [];
|
|
103
|
+
const links = [];
|
|
104
|
+
const skip = [];
|
|
105
|
+
const anchors = [];
|
|
106
|
+
let title = null;
|
|
107
|
+
let titleStart = -1;
|
|
108
|
+
let preDepth = 0;
|
|
109
|
+
let malformed = false;
|
|
110
|
+
let cursor = 0;
|
|
111
|
+
|
|
112
|
+
const append = (text) => { if (!skip.length && text) output.push(text); };
|
|
113
|
+
while (cursor < html.length) {
|
|
114
|
+
const open = html.indexOf('<', cursor);
|
|
115
|
+
if (open < 0) {
|
|
116
|
+
append(decodeEntities(html.slice(cursor)));
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
append(decodeEntities(html.slice(cursor, open)));
|
|
120
|
+
if (lower.startsWith('<!--', open)) {
|
|
121
|
+
const end = lower.indexOf('-->', open + 4);
|
|
122
|
+
if (end < 0) { malformed = true; break; }
|
|
123
|
+
cursor = end + 3;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const close = html.indexOf('>', open + 1);
|
|
127
|
+
if (close < 0) { malformed = true; break; }
|
|
128
|
+
const raw = html.slice(open, close + 1);
|
|
129
|
+
if (/^<!|^<\?/u.test(raw)) { cursor = close + 1; continue; }
|
|
130
|
+
const tag = tagInfo(raw);
|
|
131
|
+
if (!tag) { malformed = true; cursor = close + 1; continue; }
|
|
132
|
+
|
|
133
|
+
if (skip.length) {
|
|
134
|
+
if (!tag.closing && !tag.selfClosing && tag.name === skip.at(-1)) skip.push(tag.name);
|
|
135
|
+
else if (tag.closing && tag.name === skip.at(-1)) skip.pop();
|
|
136
|
+
cursor = close + 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!tag.closing && (DROP_CONTENT.has(tag.name) || hiddenElement(tag.attributes))) {
|
|
140
|
+
if (!tag.selfClosing) skip.push(tag.name);
|
|
141
|
+
cursor = close + 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (tag.name === 'title') {
|
|
145
|
+
if (!tag.closing) titleStart = output.length;
|
|
146
|
+
else if (titleStart >= 0) {
|
|
147
|
+
title = normalizeWhitespace(output.splice(titleStart).join('')) || null;
|
|
148
|
+
titleStart = -1;
|
|
149
|
+
}
|
|
150
|
+
} else if (/^h[1-6]$/u.test(tag.name)) {
|
|
151
|
+
if (!tag.closing) {
|
|
152
|
+
append(`\n${'#'.repeat(Number(tag.name[1]))} `);
|
|
153
|
+
headings.push(tag.name);
|
|
154
|
+
} else if (headings.at(-1) === tag.name) {
|
|
155
|
+
headings.pop();
|
|
156
|
+
append('\n');
|
|
157
|
+
}
|
|
158
|
+
} else if (tag.name === 'li') append(tag.closing ? '\n' : '\n- ');
|
|
159
|
+
else if (tag.name === 'pre') {
|
|
160
|
+
if (tag.closing) { preDepth = Math.max(0, preDepth - 1); append('\n```\n'); }
|
|
161
|
+
else { append('\n```\n'); preDepth += 1; }
|
|
162
|
+
} else if (tag.name === 'code' && !tag.closing) append(preDepth ? '' : '`');
|
|
163
|
+
else if (tag.name === 'code' && tag.closing) append(preDepth ? '' : '`');
|
|
164
|
+
else if (tag.name === 'a') {
|
|
165
|
+
if (!tag.closing) anchors.push({ start: output.length, href: hrefFrom(tag.attributes) });
|
|
166
|
+
else {
|
|
167
|
+
const anchor = anchors.pop();
|
|
168
|
+
if (anchor) {
|
|
169
|
+
const text = normalizeWhitespace(output.slice(anchor.start).join(''));
|
|
170
|
+
if (text && anchor.href && links.length < MAX_LINKS) {
|
|
171
|
+
const link = { text: boundedUtf8(text, 500).value, href: boundedUtf8(anchor.href, 2048).value };
|
|
172
|
+
if (Buffer.byteLength(JSON.stringify([...links, link])) <= MAX_LINK_METADATA_BYTES) links.push(link);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else if (tag.name === 'td' || tag.name === 'th') append(tag.closing ? ' | ' : '| ');
|
|
177
|
+
else if (BLOCK_TAGS.has(tag.name)) append(tag.name === 'br' || tag.closing ? '\n' : '\n');
|
|
178
|
+
cursor = close + 1;
|
|
179
|
+
}
|
|
180
|
+
if (skip.length || titleStart >= 0 || anchors.length || headings.length) malformed = true;
|
|
181
|
+
const normalized = normalizeWhitespace(output.join(''));
|
|
182
|
+
const bounded = boundedUtf8(normalized, maxOutputBytes);
|
|
183
|
+
return {
|
|
184
|
+
text: bounded.value,
|
|
185
|
+
title,
|
|
186
|
+
links,
|
|
187
|
+
extractor: EXTRACTOR,
|
|
188
|
+
fallbackReason: malformed ? 'malformed-html' : null,
|
|
189
|
+
mimeType,
|
|
190
|
+
charset,
|
|
191
|
+
inputBytes,
|
|
192
|
+
outputBytes: Buffer.byteLength(bounded.value),
|
|
193
|
+
truncated: bounded.truncated,
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,20 +1,54 @@
|
|
|
1
|
+
export type ContextScope = 'transient' | 'session' | 'project';
|
|
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' | 'allowlist-v1'; platform: 'win32' | 'darwin' | 'posix'; allowedNames?: string[]; filteredNames: string[] }
|
|
4
|
+
export interface DirectoryManifestEntry { path: string; absolutePath: string; size: number; identity: { dev: number; ino: number; size: number; mtimeMs: number }; symlink: boolean }
|
|
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
|
+
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>; environmentMode?: 'inherit-filtered-v1' | 'allowlist-v1'; allowedEnv?: string[] }
|
|
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; 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 }
|
|
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 }> }
|
|
14
|
+
|
|
1
15
|
export function contextDbPath(root?: string): string;
|
|
2
16
|
export class ContextStore {
|
|
3
17
|
constructor(root?: string);
|
|
4
18
|
root: string;
|
|
5
19
|
path: string;
|
|
6
20
|
close(): void;
|
|
7
|
-
index(input: Record<string, unknown>): Record<string, unknown>;
|
|
8
|
-
search(input:
|
|
9
|
-
stats():
|
|
10
|
-
purge(input:
|
|
21
|
+
index(input: { id?: string; scope?: ContextScope; sessionId?: string | null; label?: string; kind?: string; text: string; metadata?: Record<string, unknown>; ttlMs?: number | null; provenance?: Record<string, unknown> }): Record<string, unknown>;
|
|
22
|
+
search(input: { query: string; scope?: ContextScope; sessionId?: string; source?: string; limit?: number }): unknown[];
|
|
23
|
+
stats(): { path: string; sources: number; chunks: number; byScope: Array<{ scope: ContextScope; count: number }> };
|
|
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[];
|
|
11
31
|
}
|
|
12
|
-
export function
|
|
13
|
-
export function
|
|
14
|
-
export function
|
|
15
|
-
export
|
|
16
|
-
export
|
|
17
|
-
export
|
|
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>>>>;
|
|
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>>;
|
|
41
|
+
export function executeBatch(input: { commands: ExecutionInput[]; concurrency?: number; cwd?: string; timeoutMs?: number }, options?: Record<string, any>): Promise<Record<string, any>>;
|
|
42
|
+
export function executeFile(input: ExecutionInput & { path: string; language: 'javascript' | 'python' | 'shell'; code: string }, options?: Record<string, any>): Promise<Record<string, any>>;
|
|
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 };
|
|
46
|
+
export const DIRECTORY_INGESTION_LIMITS: Readonly<{ maxDepth: number; maxVisitedEntries: number; maxFiles: number; maxFileBytes: number; maxAggregateBytes: number }>;
|
|
47
|
+
export function walkDirectoryManifest(input: IndexInput & { signal?: AbortSignal }): DirectoryManifest;
|
|
48
|
+
export function verifyManifestFile(entry: DirectoryManifestEntry, root: string): string;
|
|
49
|
+
export function readManifestFile(entry: DirectoryManifestEntry, root: string, maxBytes?: number): Buffer;
|
|
50
|
+
export function normalizeHtml(value: unknown, options?: { contentType?: string; maxInputBytes?: number; maxOutputBytes?: number }): HtmlNormalization;
|
|
51
|
+
export function runDoctor(input?: { root?: string; dbPath?: string; stats?: Record<string, unknown>; fetchPolicy?: { mode?: string; encodings?: string[] } }): DoctorResult;
|
|
18
52
|
export function projectLoad(input: Record<string, any>): Promise<Record<string, any>>;
|
|
19
53
|
export function projectImpact(input: Record<string, any>): Promise<Record<string, any>>;
|
|
20
54
|
export function projectInitialize(input: Record<string, any>): Promise<Record<string, any>>;
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
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
|
+
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';
|
|
9
|
+
export { DIRECTORY_INGESTION_LIMITS, readManifestFile, verifyManifestFile, walkDirectoryManifest } from './directory-ingestion.mjs';
|
|
10
|
+
export { normalizeHtml } from './html-normalize.mjs';
|
|
5
11
|
export { runDoctor } from './doctor.mjs';
|
|
6
12
|
export { createProvenanceMetadata, readProvenanceMetadata, sha256Content } from './provenance.mjs';
|
|
7
13
|
export { normalizeSearchTerms, reciprocalRankFusion, rerankCandidates } from './rank.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/provenance.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const TRUST_BY_SOURCE_TYPE = Object.freeze({
|
|
|
11
11
|
unknown: 'unknown',
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
const EXTRACTORS = new Set(['plain', 'markdown-v1', 'json-v1']);
|
|
14
|
+
const EXTRACTORS = new Set(['plain', 'markdown-v1', 'json-v1', 'html-v1']);
|
|
15
15
|
const HASH_PATTERN = /^[a-f0-9]{64}$/u;
|
|
16
16
|
|
|
17
17
|
function asMetadata(value) {
|
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
|
-
|
|
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
|
-
|
|
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,6 +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(),
|
|
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(),
|
|
26
32
|
};
|
|
27
33
|
|
|
28
34
|
function success(value) {
|
|
@@ -49,9 +55,11 @@ register('batch_execute', 'Run labeled local commands sequentially or with bound
|
|
|
49
55
|
register('execute_file', 'Process a local file in an isolated child process; only bounded stdout/stderr is returned.', {
|
|
50
56
|
path: z.string(), language: z.enum(['javascript', 'python', 'shell']).default('javascript'), code: z.string(), ...commandSchema,
|
|
51
57
|
}, (input, extra) => executeFile(input, { root, store: getStore(), sessionId, signal: extra.signal }), { openWorldHint: false, destructiveHint: true });
|
|
52
|
-
register('index', 'Index a local text file into the project-local FTS5 store without returning
|
|
58
|
+
register('index', 'Index a local text file or bounded directory into the project-local FTS5 store without returning raw bytes.', {
|
|
53
59
|
path: z.string(), root: z.string().optional(), source: z.string().optional(), scope: scopeSchema, ttlMs: z.number().int().nonnegative().optional(), maxBytes: z.number().int().positive().optional(),
|
|
54
|
-
|
|
60
|
+
includeExtensions: z.array(z.string()).max(100).optional(), excludePaths: z.array(z.string()).max(500).optional(), followFileSymlinks: z.boolean().optional(),
|
|
61
|
+
maxDepth: z.number().int().nonnegative().optional(), maxVisitedEntries: z.number().int().nonnegative().optional(), maxFiles: z.number().int().nonnegative().optional(), maxAggregateBytes: z.number().int().nonnegative().optional(),
|
|
62
|
+
}, (input, extra) => ({ ok: true, ...indexFile(getStore(), { ...input, root }, sessionId, extra.signal) }), { readOnlyHint: true });
|
|
55
63
|
register('search', 'Search indexed command, file, and fetched content and return bounded excerpts.', {
|
|
56
64
|
query: z.string().min(1), scope: scopeSchema, source: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), sessionOnly: z.boolean().optional(),
|
|
57
65
|
}, (input) => ({
|
|
@@ -60,8 +68,32 @@ register('search', 'Search indexed command, file, and fetched content and return
|
|
|
60
68
|
results: getStore().search({ ...input, sessionId: input.sessionOnly ? sessionId : undefined }),
|
|
61
69
|
}), { readOnlyHint: true });
|
|
62
70
|
register('fetch_and_index', 'Fetch an HTTP(S) URL locally, index bounded text, and return metadata only.', {
|
|
63
|
-
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(),
|
|
64
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 });
|
|
65
97
|
register('stats', 'Report local context store counts and location.', {}, () => ({ ok: true, sessionId, ...getStore().stats() }), { readOnlyHint: true });
|
|
66
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 });
|
|
67
99
|
register('purge', 'Permanently delete one explicit context scope, session, or source.', {
|
package/src/session.mjs
ADDED
|
@@ -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
|
+
}
|