@dotdotgod/context 0.2.25 → 0.2.26

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
@@ -36,6 +36,8 @@ Each command has one **10 MiB combined capture ceiling** shared by stdout and st
36
36
 
37
37
  `batch_execute` accepts up to 100 commands, uses concurrency from 1 through 8, and preserves input order. Execution and explicit shell mode remain write-capable operations subject to the host's approval model.
38
38
 
39
+ `execute`, `batch_execute`, and `execute_file` share an `inherit-filtered-v1` child-environment policy. It preserves compatibility-oriented inheritance while filtering `NODE_OPTIONS`, `PYTHONPATH`, `RUBYOPT`, `LD_PRELOAD`, and supported-platform `DYLD_*` variables. Callers may provide string overrides or remove inherited variables with `null`, but cannot restore reserved variables. Results report filtered names without values. This policy applies only to these dotdotgod tools and does not isolate ordinary inherited credentials.
40
+
39
41
  ## Local Storage And Retrieval
40
42
 
41
43
  Indexed content is stored in the project-local SQLite FTS5 database:
@@ -44,7 +46,7 @@ Indexed content is stored in the project-local SQLite FTS5 database:
44
46
  .dotdotgod/context/context.sqlite
45
47
  ```
46
48
 
47
- The store is local ignored state, not maintained project truth. Sources can use transient, session, or project scope and carry optional expiry metadata. Search applies scope, session, and source predicates before candidate fusion, then:
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:
48
50
 
49
51
  1. retrieves bounded Porter-tokenized FTS5 candidates;
50
52
  2. retrieves bounded label/path candidates;
@@ -56,7 +58,17 @@ This is bounded indexing and retrieval, not LLM summarization. Search does not a
56
58
 
57
59
  ## Structural Ingestion
58
60
 
59
- Local `index` accepts one regular text file up to **25 MiB**. Directory ingestion is not supported.
61
+ Local `index` accepts one project-contained regular text file or directory. A file is limited to **25 MiB**. Directory defaults allow at most depth 16, 10,000 visited entries, 1,000 indexed files, 25 MiB per file, and 100 MiB in aggregate; callers may configure the traversal limits explicitly.
62
+
63
+ Directory ingestion:
64
+
65
+ - walks paths in deterministic lexical order and returns metadata-only indexed, skipped, and failed entries;
66
+ - accepts explicit `includeExtensions` and `excludePaths` filters but does not interpret `.gitignore` files;
67
+ - skips directory and file symlinks by default; optional file-symlink following remains limited to targets inside the project;
68
+ - rejects special files and verifies file identity through a bounded descriptor read before indexing;
69
+ - stops further traversal/indexing on cancellation while reporting already committed sources.
70
+
71
+ Content chunking then applies these rules:
60
72
 
61
73
  - Markdown is split around headings, prose blocks, and fenced blocks while retaining heading and fence metadata.
62
74
  - JSON is parsed into deterministic key-path chunks; invalid JSON falls back to bounded text chunks.
@@ -91,7 +103,7 @@ The application-level fetch policy:
91
103
  - supports identity, gzip, deflate, and Brotli encodings and rejects unsupported encodings;
92
104
  - accepts bounded text-oriented MIME types and fails closed for unsupported types or HTTP statuses.
93
105
 
94
- HTML is handled as bounded untrusted text; the package does not claim rich HTML extraction. These controls provide application-level URL and address validation, not a network namespace or process sandbox.
106
+ Accepted HTML is normalized by a bounded `html-v1` extractor. It validates supported UTF-8/US-ASCII declarations, removes scripts, styles, noscript and hidden/non-content elements, and preserves basic titles, headings, paragraphs, lists, tables, code, and link text. Link metadata is separately bounded. The extractor does not invoke a browser, execute JavaScript, load subresources, follow links, or provide standards-complete HTML rendering. HTML remains `external-untrusted`; normalization is not rendering sanitization or a prompt-injection guarantee. These controls provide application-level URL and address validation, not a network namespace or process sandbox.
95
107
 
96
108
  ## Diagnostics And Deletion
97
109
 
@@ -100,7 +112,7 @@ HTML is handled as bounded untrusted text; the package does not claim rich HTML
100
112
  - the minimum Node.js runtime;
101
113
  - project and storage-path readiness;
102
114
  - SQLite FTS5 with the Porter tokenizer;
103
- - compatibility of an existing context database schema;
115
+ - compatibility of an existing context database schema without migration or repair;
104
116
  - current store statistics when supplied;
105
117
  - the configured strict fetch policy.
106
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/context",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
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.25"
31
+ "@dotdotgod/cli": "0.2.26"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=22.5.0"
package/src/content.mjs CHANGED
@@ -1,27 +1,79 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
+ import { readManifestFile, walkDirectoryManifest } from './directory-ingestion.mjs';
3
+ import { normalizeHtml } from './html-normalize.mjs';
2
4
  import { resolveExistingWithinRoot } from './paths.mjs';
3
5
  import { safeFetch } from './safe-fetch.mjs';
4
6
 
5
7
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
6
8
  const MAX_FETCH_BYTES = 10 * 1024 * 1024;
7
9
 
8
- export function indexFile(store, input, sessionId) {
9
- const path = resolveExistingWithinRoot(input.root || process.cwd(), input.path);
10
- const stat = statSync(path);
11
- if (!stat.isFile()) throw new Error('Only regular files are supported in the first release.');
10
+ function indexRegularFile(store, input, sessionId, path, stat, label = input.source ?? path, bytes) {
12
11
  const maxBytes = Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES);
13
12
  if (stat.size > maxBytes) throw new Error(`File exceeds maximum size: ${stat.size} bytes`);
13
+ const content = bytes ?? readFileSync(path);
14
+ if (content.length > maxBytes) throw new Error(`File exceeds maximum size: ${content.length} bytes`);
14
15
  return store.index({
15
16
  scope: input.scope ?? 'session',
16
17
  sessionId,
17
- label: input.source ?? path,
18
+ label,
18
19
  kind: 'file',
19
- text: readFileSync(path, 'utf8'),
20
+ text: content.toString('utf8'),
20
21
  metadata: { path, size: stat.size },
21
22
  ttlMs: input.ttlMs ?? null,
22
23
  });
23
24
  }
24
25
 
26
+ function indexDirectory(store, input, sessionId, signal) {
27
+ const manifest = walkDirectoryManifest({
28
+ root: input.root || process.cwd(),
29
+ path: input.path,
30
+ includeExtensions: input.includeExtensions,
31
+ excludePaths: input.excludePaths,
32
+ followFileSymlinks: input.followFileSymlinks,
33
+ maxDepth: input.maxDepth,
34
+ maxVisitedEntries: input.maxVisitedEntries,
35
+ maxFiles: input.maxFiles,
36
+ maxFileBytes: Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES),
37
+ maxAggregateBytes: input.maxAggregateBytes,
38
+ signal,
39
+ });
40
+ const indexed = [];
41
+ const failed = [...manifest.failed];
42
+ const aggregateLimit = input.maxAggregateBytes ?? 100 * 1024 * 1024;
43
+ let indexedBytes = 0;
44
+ for (const entry of manifest.files) {
45
+ if (signal?.aborted) break;
46
+ try {
47
+ const remaining = aggregateLimit - indexedBytes;
48
+ if (remaining < 0) throw new Error('Directory aggregate byte limit exceeded.');
49
+ const bytes = readManifestFile(entry, manifest.root, Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES, remaining));
50
+ const label = input.source ? `${input.source}/${entry.path}` : entry.absolutePath;
51
+ indexed.push({ path: entry.path, ...indexRegularFile(store, input, sessionId, entry.absolutePath, entry, label, bytes) });
52
+ indexedBytes += bytes.length;
53
+ } catch (error) {
54
+ failed.push({ path: entry.path, reason: 'index-failed', error: error instanceof Error ? error.message : String(error) });
55
+ }
56
+ }
57
+ return {
58
+ directory: manifest.directory,
59
+ indexed,
60
+ skipped: manifest.skipped,
61
+ failed,
62
+ aborted: manifest.aborted || signal?.aborted === true,
63
+ truncated: manifest.truncated,
64
+ visitedEntries: manifest.visitedEntries,
65
+ aggregateBytes: indexedBytes,
66
+ };
67
+ }
68
+
69
+ export function indexFile(store, input, sessionId, signal) {
70
+ const path = resolveExistingWithinRoot(input.root || process.cwd(), input.path);
71
+ const stat = statSync(path);
72
+ if (stat.isDirectory()) return indexDirectory(store, input, sessionId, signal);
73
+ if (!stat.isFile()) throw new Error('Only regular files and directories are supported.');
74
+ return indexRegularFile(store, input, sessionId, path, stat);
75
+ }
76
+
25
77
  export async function fetchAndIndex(store, input, sessionId, signal) {
26
78
  const maxBytes = Math.min(input.maxBytes ?? MAX_FETCH_BYTES, MAX_FETCH_BYTES);
27
79
  const fetched = await safeFetch(input.url, {
@@ -30,12 +82,19 @@ export async function fetchAndIndex(store, input, sessionId, signal) {
30
82
  maxBytes,
31
83
  maxWireBytes: maxBytes,
32
84
  });
85
+ const isHtml = /^(?:text\/html|application\/xhtml\+xml)(?:;|$)/iu.test(fetched.contentType);
86
+ const normalized = isHtml ? normalizeHtml(fetched.body, {
87
+ contentType: fetched.contentType,
88
+ maxInputBytes: maxBytes,
89
+ maxOutputBytes: maxBytes,
90
+ }) : null;
91
+ const text = normalized?.text ?? fetched.text;
33
92
  return store.index({
34
93
  scope: input.scope ?? 'project',
35
94
  sessionId,
36
- label: input.source ?? fetched.url,
95
+ label: input.source ?? normalized?.title ?? fetched.url,
37
96
  kind: 'url',
38
- text: fetched.text,
97
+ text,
39
98
  metadata: {
40
99
  url: fetched.url,
41
100
  originalUrl: fetched.originalUrl,
@@ -46,8 +105,20 @@ export async function fetchAndIndex(store, input, sessionId, signal) {
46
105
  status: fetched.status,
47
106
  bytes: fetched.bytes,
48
107
  wireLength: fetched.wireLength,
108
+ ...(normalized ? {
109
+ htmlTitle: normalized.title,
110
+ htmlInputBytes: normalized.inputBytes,
111
+ htmlOutputBytes: normalized.outputBytes,
112
+ htmlTruncated: normalized.truncated,
113
+ htmlFallbackReason: normalized.fallbackReason,
114
+ } : {}),
115
+ },
116
+ provenance: {
117
+ sourceType: 'fetched-url',
118
+ origin: fetched.url,
119
+ contentType: normalized ? 'text/markdown' : fetched.contentType,
120
+ extractor: normalized?.extractor,
49
121
  },
50
- provenance: { sourceType: 'fetched-url', origin: fetched.url, contentType: fetched.contentType },
51
122
  ttlMs: input.ttlMs ?? 24 * 60 * 60 * 1000,
52
123
  });
53
124
  }
@@ -0,0 +1,182 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ const DEFAULT_LIMITS = Object.freeze({
5
+ maxDepth: 16,
6
+ maxVisitedEntries: 10_000,
7
+ maxFiles: 1_000,
8
+ maxFileBytes: 25 * 1024 * 1024,
9
+ maxAggregateBytes: 100 * 1024 * 1024,
10
+ });
11
+
12
+ function inside(base, target) {
13
+ const rel = relative(base, target);
14
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
15
+ }
16
+
17
+ function relativePath(base, target) {
18
+ return relative(base, target).split(sep).join('/');
19
+ }
20
+
21
+ function boundedInteger(value, fallback, name) {
22
+ const selected = value ?? fallback;
23
+ if (!Number.isSafeInteger(selected) || selected < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
24
+ return selected;
25
+ }
26
+
27
+ function normalizeOptions(options) {
28
+ const includeExtensions = options.includeExtensions?.map((value) => {
29
+ const extension = String(value).toLowerCase();
30
+ return extension.startsWith('.') ? extension : `.${extension}`;
31
+ });
32
+ const excludePaths = (options.excludePaths ?? []).map((value) => {
33
+ const normalized = String(value).replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/$/, '');
34
+ if (!normalized || normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
35
+ throw new TypeError(`Invalid excluded path: ${value}`);
36
+ }
37
+ return normalized;
38
+ });
39
+ return {
40
+ includeExtensions: includeExtensions ? new Set(includeExtensions) : null,
41
+ excludePaths,
42
+ followFileSymlinks: options.followFileSymlinks === true,
43
+ maxDepth: boundedInteger(options.maxDepth, DEFAULT_LIMITS.maxDepth, 'maxDepth'),
44
+ maxVisitedEntries: boundedInteger(options.maxVisitedEntries, DEFAULT_LIMITS.maxVisitedEntries, 'maxVisitedEntries'),
45
+ maxFiles: boundedInteger(options.maxFiles, DEFAULT_LIMITS.maxFiles, 'maxFiles'),
46
+ maxFileBytes: boundedInteger(options.maxFileBytes, DEFAULT_LIMITS.maxFileBytes, 'maxFileBytes'),
47
+ maxAggregateBytes: boundedInteger(options.maxAggregateBytes, DEFAULT_LIMITS.maxAggregateBytes, 'maxAggregateBytes'),
48
+ };
49
+ }
50
+
51
+ function excluded(path, prefixes) {
52
+ return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
53
+ }
54
+
55
+ function extensionAllowed(path, extensions) {
56
+ if (!extensions) return true;
57
+ const slash = path.lastIndexOf('/');
58
+ const dot = path.lastIndexOf('.');
59
+ return dot > slash && extensions.has(path.slice(dot).toLowerCase());
60
+ }
61
+
62
+ function snapshot(path, target, stat, symlink) {
63
+ return {
64
+ path,
65
+ absolutePath: target,
66
+ size: stat.size,
67
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs },
68
+ symlink,
69
+ };
70
+ }
71
+
72
+ export function walkDirectoryManifest(options = {}) {
73
+ const root = realpathSync(resolve(options.root || process.cwd()));
74
+ const requested = resolve(root, options.path ?? '.');
75
+ if (!inside(root, requested)) throw new Error(`Directory escapes project root: ${options.path}`);
76
+ if (lstatSync(requested).isSymbolicLink()) throw new Error(`Directory symlinks are not followed: ${options.path ?? '.'}`);
77
+ const directory = realpathSync(requested);
78
+ if (!inside(root, directory)) throw new Error(`Directory escapes project root: ${options.path}`);
79
+ if (!statSync(directory).isDirectory()) throw new Error(`Not a directory: ${options.path ?? '.'}`);
80
+
81
+ const policy = normalizeOptions(options);
82
+ const files = [];
83
+ const skipped = [];
84
+ const failed = [];
85
+ let visitedEntries = 0;
86
+ let aggregateBytes = 0;
87
+ let aborted = false;
88
+ let truncated = false;
89
+
90
+ function skip(path, reason) { skipped.push({ path, reason }); }
91
+ function fail(path, reason) { failed.push({ path, reason }); }
92
+
93
+ function visit(current, depth) {
94
+ if (aborted || options.signal?.aborted) { aborted = true; return; }
95
+ let names;
96
+ try { names = readdirSync(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); }
97
+ catch { fail(relativePath(root, current), 'directory-read-failed'); return; }
98
+
99
+ for (const name of names) {
100
+ if (options.signal?.aborted) { aborted = true; return; }
101
+ const absolutePath = resolve(current, name);
102
+ const path = relativePath(root, absolutePath);
103
+ if (visitedEntries >= policy.maxVisitedEntries) { skip(path, 'visited-entry-limit'); truncated = true; return; }
104
+ visitedEntries += 1;
105
+ if (excluded(path, policy.excludePaths)) { skip(path, 'excluded-path'); continue; }
106
+
107
+ let entry;
108
+ try { entry = lstatSync(absolutePath); }
109
+ catch { fail(path, 'entry-stat-failed'); continue; }
110
+
111
+ if (entry.isSymbolicLink()) {
112
+ let target;
113
+ let targetStat;
114
+ try {
115
+ target = realpathSync(absolutePath);
116
+ if (!inside(root, target)) { skip(path, 'symlink-outside-root'); continue; }
117
+ targetStat = statSync(target);
118
+ } catch { skip(path, 'symlink-unresolvable'); continue; }
119
+ if (targetStat.isDirectory()) { skip(path, 'directory-symlink'); continue; }
120
+ if (!policy.followFileSymlinks) { skip(path, 'file-symlink'); continue; }
121
+ if (!targetStat.isFile()) { skip(path, 'special-file'); continue; }
122
+ addFile(path, target, targetStat, true);
123
+ continue;
124
+ }
125
+
126
+ if (entry.isDirectory()) {
127
+ if (depth >= policy.maxDepth) { skip(path, 'depth-limit'); continue; }
128
+ visit(absolutePath, depth + 1);
129
+ if (aborted || truncated) return;
130
+ continue;
131
+ }
132
+ if (!entry.isFile()) { skip(path, 'special-file'); continue; }
133
+ addFile(path, absolutePath, entry, false);
134
+ }
135
+ }
136
+
137
+ function addFile(path, absolutePath, stat, symlink) {
138
+ if (!extensionAllowed(path, policy.includeExtensions)) { skip(path, 'extension-filter'); return; }
139
+ if (stat.size > policy.maxFileBytes) { skip(path, 'file-byte-limit'); return; }
140
+ if (files.length >= policy.maxFiles) { skip(path, 'file-count-limit'); return; }
141
+ if (aggregateBytes + stat.size > policy.maxAggregateBytes) { skip(path, 'aggregate-byte-limit'); return; }
142
+ files.push(snapshot(path, absolutePath, stat, symlink));
143
+ aggregateBytes += stat.size;
144
+ }
145
+
146
+ visit(directory, 0);
147
+ return { root, directory, files, skipped, failed, aborted, truncated, visitedEntries, aggregateBytes };
148
+ }
149
+
150
+ function matchesIdentity(stat, identity) {
151
+ return stat.dev === identity.dev && stat.ino === identity.ino && stat.size === identity.size && stat.mtimeMs === identity.mtimeMs;
152
+ }
153
+
154
+ export function verifyManifestFile(entry, root) {
155
+ const base = realpathSync(resolve(root));
156
+ const target = realpathSync(entry.absolutePath);
157
+ if (!inside(base, target)) throw new Error(`Manifest file escapes project root: ${entry.path}`);
158
+ const stat = statSync(target);
159
+ if (!stat.isFile()) throw new Error(`Manifest entry is not a regular file: ${entry.path}`);
160
+ if (!matchesIdentity(stat, entry.identity ?? {})) throw new Error(`Manifest file changed before indexing: ${entry.path}`);
161
+ return target;
162
+ }
163
+
164
+ export function readManifestFile(entry, root, maxBytes = DEFAULT_LIMITS.maxFileBytes) {
165
+ const target = verifyManifestFile(entry, root);
166
+ const noFollow = constants.O_NOFOLLOW ?? 0;
167
+ const descriptor = openSync(target, constants.O_RDONLY | noFollow);
168
+ try {
169
+ const before = fstatSync(descriptor);
170
+ if (!before.isFile() || !matchesIdentity(before, entry.identity ?? {})) throw new Error(`Manifest file changed before indexing: ${entry.path}`);
171
+ if (before.size > maxBytes) throw new Error(`Manifest file exceeds maximum size: ${entry.path}`);
172
+ const bytes = readFileSync(descriptor);
173
+ const after = fstatSync(descriptor);
174
+ if (!matchesIdentity(after, entry.identity ?? {}) || bytes.length !== before.size) throw new Error(`Manifest file changed while indexing: ${entry.path}`);
175
+ if (bytes.length > maxBytes) throw new Error(`Manifest file exceeds maximum size: ${entry.path}`);
176
+ return bytes;
177
+ } finally {
178
+ closeSync(descriptor);
179
+ }
180
+ }
181
+
182
+ export { DEFAULT_LIMITS as DIRECTORY_INGESTION_LIMITS };
package/src/doctor.mjs CHANGED
@@ -92,11 +92,20 @@ function schemaCheck(dbPath) {
92
92
  const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')").all().map((row) => row.name));
93
93
  const missing = [...EXPECTED_TABLES].filter((name) => !names.has(name));
94
94
  if (missing.length > 0) return check('schema-compatibility', 'FAIL', 'Context database is missing required tables.', { dbPath, missing });
95
- const sourceColumns = new Set(db.prepare('PRAGMA table_info(sources)').all().map((row) => row.name));
96
- const requiredColumns = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
97
- const missingColumns = requiredColumns.filter((name) => !sourceColumns.has(name));
98
- if (missingColumns.length > 0) return check('schema-compatibility', 'FAIL', 'Sources table is incompatible.', { dbPath, missingColumns });
99
- return check('schema-compatibility', 'OK', 'Existing context database has the required schema.', { dbPath });
95
+ const sourceRows = db.prepare('PRAGMA table_info(sources)').all();
96
+ const chunkRows = db.prepare('PRAGMA table_info(chunks)').all();
97
+ const requiredSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
98
+ const requiredChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'];
99
+ const missingColumns = [...requiredSources.filter((name) => !sourceRows.some((row) => row.name === name)), ...requiredChunks.filter((name) => !chunkRows.some((row) => row.name === name))];
100
+ const sourceShape = sourceRows.length === requiredSources.length && sourceRows.every((row, index) => row.name === requiredSources[index])
101
+ && sourceRows[0].pk === 1
102
+ && sourceRows.every((row) => ['scope', 'label', 'kind', 'metadata', 'created_at'].includes(row.name) ? row.notnull === 1 : true);
103
+ const chunkShape = chunkRows.length === requiredChunks.length && chunkRows.every((row, index) => row.name === requiredChunks[index]);
104
+ const chunkSql = String(db.prepare("SELECT sql FROM sqlite_master WHERE name = 'chunks'").get()?.sql ?? '').toLowerCase();
105
+ if (missingColumns.length > 0 || !sourceShape || !chunkShape || !chunkSql.includes('using fts5') || !chunkSql.includes("tokenize='porter unicode61'")) {
106
+ return check('schema-compatibility', 'FAIL', 'Context database schema is incompatible.', { dbPath, schemaProfile: 'unknown', missingColumns });
107
+ }
108
+ return check('schema-compatibility', 'OK', 'Existing context database matches compatibility profile 1.', { dbPath, schemaProfile: 1 });
100
109
  } catch (error) {
101
110
  return check('schema-compatibility', 'FAIL', 'Context database could not be inspected read-only.', { dbPath, error: error.message });
102
111
  } finally {
@@ -0,0 +1,80 @@
1
+ const COMMON_FILTERED_NAMES = new Set(['NODE_OPTIONS', 'PYTHONPATH', 'RUBYOPT']);
2
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+
4
+ function platformFamily(platform) {
5
+ if (platform === 'win32') return 'win32';
6
+ if (platform === 'darwin') return 'darwin';
7
+ return 'posix';
8
+ }
9
+
10
+ function canonicalName(name, family) {
11
+ return family === 'win32' ? name.toUpperCase() : name;
12
+ }
13
+
14
+ function validateName(name) {
15
+ if (!VALID_NAME.test(name)) throw new TypeError(`Invalid environment variable name: ${JSON.stringify(name)}`);
16
+ }
17
+
18
+ function isFilteredName(name, family) {
19
+ const comparable = family === 'win32' ? name.toUpperCase() : name;
20
+ if (COMMON_FILTERED_NAMES.has(comparable)) return true;
21
+ if (family === 'darwin') return comparable === 'LD_PRELOAD' || comparable.startsWith('DYLD_');
22
+ if (family === 'posix') return comparable === 'LD_PRELOAD';
23
+ return false;
24
+ }
25
+
26
+ function entriesOf(value, label) {
27
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
28
+ throw new TypeError(`${label} must be an object`);
29
+ }
30
+ return Object.entries(value).sort(([left], [right]) => left.localeCompare(right, 'en'));
31
+ }
32
+
33
+ /**
34
+ * Compose a child-process environment without exposing filtered values.
35
+ * Override values may be strings or null; null explicitly deletes a variable.
36
+ */
37
+ export function composeEnvironment({ inherited = {}, overrides = {}, platform = process.platform } = {}) {
38
+ const family = platformFamily(platform);
39
+ const env = {};
40
+ const filtered = new Set();
41
+
42
+ for (const [name, value] of entriesOf(inherited, 'inherited')) {
43
+ validateName(name);
44
+ if (typeof value !== 'string' && value !== undefined) {
45
+ throw new TypeError(`Inherited environment variable ${name} must be a string or undefined`);
46
+ }
47
+ if (value === undefined) continue;
48
+ const outputName = canonicalName(name, family);
49
+ if (isFilteredName(outputName, family)) {
50
+ filtered.add(outputName);
51
+ continue;
52
+ }
53
+ env[outputName] = value;
54
+ }
55
+
56
+ for (const [name, value] of entriesOf(overrides, 'overrides')) {
57
+ validateName(name);
58
+ const outputName = canonicalName(name, family);
59
+ if (isFilteredName(outputName, family)) {
60
+ throw new TypeError(`Environment variable ${name} is reserved by the execution policy`);
61
+ }
62
+ if (value === null) {
63
+ delete env[outputName];
64
+ continue;
65
+ }
66
+ if (typeof value !== 'string') {
67
+ throw new TypeError(`Environment override ${name} must be a string or null`);
68
+ }
69
+ env[outputName] = value;
70
+ }
71
+
72
+ return {
73
+ env,
74
+ policy: {
75
+ mode: 'inherit-filtered-v1',
76
+ platform: family,
77
+ filteredNames: [...filtered].sort((left, right) => left.localeCompare(right, 'en')),
78
+ },
79
+ };
80
+ }
package/src/execute.mjs CHANGED
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { Transform } from 'node:stream';
6
6
  import { resolveWithinRoot } from './paths.mjs';
7
+ import { composeEnvironment } from './environment-policy.mjs';
7
8
 
8
9
  const DIRECT_LIMIT = 12_000;
9
10
  const MAX_DIRECT_RETURN = 1024 * 1024;
@@ -32,6 +33,7 @@ function killProcess(child) {
32
33
  }
33
34
 
34
35
  export async function executeCommand(input, options = {}) {
36
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error('Execution aborted.');
35
37
  const spec = commandSpec(input);
36
38
  const cwd = resolveWithinRoot(options.root || process.cwd(), input.cwd || '.');
37
39
  const timeoutMs = Math.min(Math.max(1, input.timeoutMs ?? 120_000), 30 * 60_000);
@@ -45,6 +47,10 @@ export async function executeCommand(input, options = {}) {
45
47
  let aborted = false;
46
48
  let captureLimitExceeded = false;
47
49
  const captureLimitBytes = Math.min(HARD_LIMIT, Math.max(1024, options.captureLimitBytes ?? HARD_LIMIT));
50
+ const environment = composeEnvironment({
51
+ inherited: options.env ?? process.env,
52
+ overrides: input.env ?? {},
53
+ });
48
54
 
49
55
  try {
50
56
  const result = await new Promise((resolvePromise, reject) => {
@@ -53,7 +59,7 @@ export async function executeCommand(input, options = {}) {
53
59
  shell: spec.shell,
54
60
  detached: process.platform !== 'win32',
55
61
  stdio: ['ignore', 'pipe', 'pipe'],
56
- env: options.env ?? process.env,
62
+ env: environment.env,
57
63
  });
58
64
  let capturedBytes = 0;
59
65
  const boundedCapture = () => new Transform({
@@ -99,6 +105,7 @@ export async function executeCommand(input, options = {}) {
99
105
  durationMs: Date.now() - startedAt,
100
106
  stdoutBytes: stdoutResult.bytes,
101
107
  stderrBytes: stderrResult.bytes,
108
+ environmentPolicy: environment.policy,
102
109
  };
103
110
  const mode = input.outputMode ?? 'auto';
104
111
  const shouldIndex = mode === 'indexed' || (mode === 'auto' && totalBytes > directLimit);
@@ -128,6 +135,7 @@ export async function executeCommand(input, options = {}) {
128
135
 
129
136
  export async function executeFile(input, options = {}) {
130
137
  const path = resolveWithinRoot(options.root || process.cwd(), input.path);
138
+ const selectorEnvironment = composeEnvironment({ inherited: options.env ?? process.env, overrides: input.env ?? {} }).env;
131
139
  const dir = mkdtempSync(join(tmpdir(), 'dotdotgod-context-file-'));
132
140
  try {
133
141
  let executable;
@@ -135,12 +143,12 @@ export async function executeFile(input, options = {}) {
135
143
  if (input.language === 'python') {
136
144
  const wrapper = join(dir, 'run.py');
137
145
  writeFileSync(wrapper, `from pathlib import Path\nFILE_CONTENT = Path(${JSON.stringify(path)}).read_text(encoding='utf-8', errors='replace')\n${input.code}\n`);
138
- executable = process.env.PYTHON || 'python3';
146
+ executable = selectorEnvironment.PYTHON || 'python3';
139
147
  args = [wrapper];
140
148
  } else if (input.language === 'shell') {
141
149
  const wrapper = join(dir, 'run.sh');
142
150
  writeFileSync(wrapper, `FILE_PATH=${JSON.stringify(path)}\nexport FILE_PATH\n${input.code}\n`);
143
- executable = process.env.SHELL || '/bin/sh';
151
+ executable = selectorEnvironment.SHELL || '/bin/sh';
144
152
  args = [wrapper];
145
153
  } else {
146
154
  const wrapper = join(dir, 'run.mjs');
@@ -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,37 @@
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'; platform: 'win32' | 'darwin' | 'posix'; 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> }
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 }
10
+ 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
+
1
12
  export function contextDbPath(root?: string): string;
2
13
  export class ContextStore {
3
14
  constructor(root?: string);
4
15
  root: string;
5
16
  path: string;
6
17
  close(): void;
7
- index(input: Record<string, unknown>): Record<string, unknown>;
8
- search(input: Record<string, unknown>): unknown[];
9
- stats(): Record<string, unknown>;
10
- purge(input: Record<string, unknown>): Record<string, unknown>;
18
+ 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>;
19
+ search(input: { query: string; scope?: ContextScope; sessionId?: string; source?: string; limit?: number }): unknown[];
20
+ stats(): { path: string; sources: number; chunks: number; byScope: Array<{ scope: ContextScope; count: number }> };
21
+ purge(input: { scope?: ContextScope; sessionId?: string; sourceId?: string }): { removed: number };
11
22
  }
12
- export function executeCommand(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
13
- export function executeBatch(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
14
- export function executeFile(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
15
- export function indexFile(store: ContextStore, input: Record<string, any>, sessionId?: string): Record<string, any>;
16
- export function fetchAndIndex(store: ContextStore, input: Record<string, any>, sessionId?: string, signal?: AbortSignal): Promise<Record<string, any>>;
17
- export function runDoctor(input?: Record<string, any>): Record<string, any>;
23
+ 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
+ export function executeBatch(input: { commands: ExecutionInput[]; concurrency?: number; cwd?: string; timeoutMs?: number }, options?: Record<string, any>): Promise<Record<string, any>>;
25
+ 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 };
29
+ export const DIRECTORY_INGESTION_LIMITS: Readonly<{ maxDepth: number; maxVisitedEntries: number; maxFiles: number; maxFileBytes: number; maxAggregateBytes: number }>;
30
+ export function walkDirectoryManifest(input: IndexInput & { signal?: AbortSignal }): DirectoryManifest;
31
+ export function verifyManifestFile(entry: DirectoryManifestEntry, root: string): string;
32
+ export function readManifestFile(entry: DirectoryManifestEntry, root: string, maxBytes?: number): Buffer;
33
+ export function normalizeHtml(value: unknown, options?: { contentType?: string; maxInputBytes?: number; maxOutputBytes?: number }): HtmlNormalization;
34
+ export function runDoctor(input?: { root?: string; dbPath?: string; stats?: Record<string, unknown>; fetchPolicy?: { mode?: string; encodings?: string[] } }): DoctorResult;
18
35
  export function projectLoad(input: Record<string, any>): Promise<Record<string, any>>;
19
36
  export function projectImpact(input: Record<string, any>): Promise<Record<string, any>>;
20
37
  export function projectInitialize(input: Record<string, any>): Promise<Record<string, any>>;
package/src/index.mjs CHANGED
@@ -2,6 +2,9 @@ export { chunkContent, chunkJson, chunkMarkdown, chunkText, excerpt, normalizeTe
2
2
  export { ContextStore, contextDbPath } 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 { DIRECTORY_INGESTION_LIMITS, readManifestFile, verifyManifestFile, walkDirectoryManifest } from './directory-ingestion.mjs';
7
+ export { normalizeHtml } from './html-normalize.mjs';
5
8
  export { runDoctor } from './doctor.mjs';
6
9
  export { createProvenanceMetadata, readProvenanceMetadata, sha256Content } from './provenance.mjs';
7
10
  export { normalizeSearchTerms, reciprocalRankFusion, rerankCandidates } from './rank.mjs';
@@ -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
@@ -15,7 +15,7 @@ function getStore() {
15
15
  contextStore ??= new ContextStore(root);
16
16
  return contextStore;
17
17
  }
18
- const server = new McpServer({ name: 'dotdotgod-context', version: '0.2.25' });
18
+ const server = new McpServer({ name: 'dotdotgod-context', version: '0.2.26' });
19
19
 
20
20
  const scopeSchema = z.enum(['transient', 'session', 'project']).optional();
21
21
  const outputModeSchema = z.enum(['auto', 'direct', 'indexed', 'discard']).optional();
@@ -23,6 +23,7 @@ const commandSchema = {
23
23
  label: z.string().optional(), command: z.string().optional(), executable: z.string().optional(), args: z.array(z.string()).optional(),
24
24
  shell: z.boolean().optional(), cwd: z.string().optional(), timeoutMs: z.number().int().positive().optional(), outputLimit: z.number().int().positive().optional(),
25
25
  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(),
26
27
  };
27
28
 
28
29
  function success(value) {
@@ -49,9 +50,11 @@ register('batch_execute', 'Run labeled local commands sequentially or with bound
49
50
  register('execute_file', 'Process a local file in an isolated child process; only bounded stdout/stderr is returned.', {
50
51
  path: z.string(), language: z.enum(['javascript', 'python', 'shell']).default('javascript'), code: z.string(), ...commandSchema,
51
52
  }, (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 its raw bytes.', {
53
+ register('index', 'Index a local text file or bounded directory into the project-local FTS5 store without returning raw bytes.', {
53
54
  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
- }, (input) => ({ ok: true, ...indexFile(getStore(), { ...input, root }, sessionId) }), { readOnlyHint: true });
55
+ includeExtensions: z.array(z.string()).max(100).optional(), excludePaths: z.array(z.string()).max(500).optional(), followFileSymlinks: z.boolean().optional(),
56
+ maxDepth: z.number().int().nonnegative().optional(), maxVisitedEntries: z.number().int().nonnegative().optional(), maxFiles: z.number().int().nonnegative().optional(), maxAggregateBytes: z.number().int().nonnegative().optional(),
57
+ }, (input, extra) => ({ ok: true, ...indexFile(getStore(), { ...input, root }, sessionId, extra.signal) }), { readOnlyHint: true });
55
58
  register('search', 'Search indexed command, file, and fetched content and return bounded excerpts.', {
56
59
  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
60
  }, (input) => ({
package/src/store.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, rmSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, 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,40 +20,72 @@ export function contextDbPath(root) {
20
20
  return join(safeRoot(root), '.dotdotgod', 'context', 'context.sqlite');
21
21
  }
22
22
 
23
+ function inspectSchema(db) {
24
+ 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
+ const sourceRows = db.prepare('PRAGMA table_info(sources)').all();
26
+ const chunkRows = db.prepare('PRAGMA table_info(chunks)').all();
27
+ const requiredSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
28
+ const requiredChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'];
29
+ const missingSources = requiredSources.filter((name) => !sourceRows.some((row) => row.name === name));
30
+ const missingChunks = requiredChunks.filter((name) => !chunkRows.some((row) => row.name === name));
31
+ const sourceShape = sourceRows.length === requiredSources.length && sourceRows.every((row, index) => row.name === requiredSources[index])
32
+ && sourceRows[0].pk === 1
33
+ && sourceRows.every((row) => ['scope', 'label', 'kind', 'metadata', 'created_at'].includes(row.name) ? row.notnull === 1 : true);
34
+ const chunkShape = chunkRows.length === requiredChunks.length && chunkRows.every((row, index) => row.name === requiredChunks[index]);
35
+ const chunkSql = String(objects.get('chunks')?.sql ?? '').toLowerCase();
36
+ const compatible = objects.get('sources')?.type === 'table'
37
+ && objects.get('chunks')?.type === 'table'
38
+ && chunkSql.includes('using fts5')
39
+ && chunkSql.includes("tokenize='porter unicode61'")
40
+ && sourceShape
41
+ && chunkShape
42
+ && missingSources.length === 0
43
+ && missingChunks.length === 0;
44
+ return { compatible, schemaProfile: compatible ? 1 : null, missing: [...missingSources, ...missingChunks] };
45
+ }
46
+
23
47
  export class ContextStore {
24
48
  constructor(root = process.cwd()) {
25
49
  this.root = safeRoot(root);
26
50
  this.path = contextDbPath(this.root);
51
+ const existing = existsSync(this.path);
52
+ if (existing) {
53
+ if (statSync(this.path).size === 0) throw new Error('Incompatible context database schema; empty database cannot be repaired automatically.');
54
+ const inspection = new DatabaseSync(this.path, { readOnly: true });
55
+ try {
56
+ 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(', ')})` : ''}.`);
58
+ } finally {
59
+ inspection.close();
60
+ }
61
+ }
27
62
  mkdirSync(dirname(this.path), { recursive: true });
28
63
  this.db = new DatabaseSync(this.path);
29
- this.db.exec(`
30
- PRAGMA journal_mode=WAL;
31
- CREATE TABLE IF NOT EXISTS sources (
32
- id TEXT PRIMARY KEY,
33
- scope TEXT NOT NULL,
34
- session_id TEXT,
35
- label TEXT NOT NULL,
36
- kind TEXT NOT NULL,
37
- metadata TEXT NOT NULL,
38
- created_at INTEGER NOT NULL,
39
- expires_at INTEGER
40
- );
41
- CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
42
- source_id UNINDEXED,
43
- scope UNINDEXED,
44
- session_id UNINDEXED,
45
- ordinal UNINDEXED,
46
- body,
47
- tokenize='porter unicode61'
48
- );
49
- `);
50
- const sourceColumns = new Set(this.db.prepare('PRAGMA table_info(sources)').all().map((row) => row.name));
51
- const chunkColumns = new Set(this.db.prepare('PRAGMA table_info(chunks)').all().map((row) => row.name));
52
- const missingSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'].filter((name) => !sourceColumns.has(name));
53
- const missingChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'].filter((name) => !chunkColumns.has(name));
54
- if (missingSources.length || missingChunks.length) {
64
+ try {
65
+ this.db.exec('PRAGMA busy_timeout=1000; PRAGMA journal_mode=WAL;');
66
+ if (!existing) this.db.exec(`
67
+ CREATE TABLE sources (
68
+ id TEXT PRIMARY KEY,
69
+ scope TEXT NOT NULL,
70
+ session_id TEXT,
71
+ label TEXT NOT NULL,
72
+ kind TEXT NOT NULL,
73
+ metadata TEXT NOT NULL,
74
+ created_at INTEGER NOT NULL,
75
+ expires_at INTEGER
76
+ );
77
+ CREATE VIRTUAL TABLE chunks USING fts5(
78
+ source_id UNINDEXED,
79
+ scope UNINDEXED,
80
+ session_id UNINDEXED,
81
+ ordinal UNINDEXED,
82
+ body,
83
+ tokenize='porter unicode61'
84
+ );
85
+ `);
86
+ } catch (error) {
55
87
  this.db.close();
56
- throw new Error(`Incompatible context database schema; run doctor before repair (missing: ${[...missingSources, ...missingChunks].join(', ')}).`);
88
+ throw error;
57
89
  }
58
90
  }
59
91
 
@@ -67,7 +99,7 @@ export class ContextStore {
67
99
  const contentType = String(provenance.contentType ?? metadata.contentType ?? '').toLowerCase();
68
100
  const extension = extname(String(metadata.path ?? '')).toLowerCase();
69
101
  const format = provenance.format ?? (contentType.includes('json') || extension === '.json' ? 'json' : contentType.includes('markdown') || ['.md', '.mdx'].includes(extension) ? 'markdown' : 'text');
70
- const extractor = format === 'json' ? 'json-v1' : format === 'markdown' ? 'markdown-v1' : 'plain';
102
+ const extractor = provenance.extractor ?? (format === 'json' ? 'json-v1' : format === 'markdown' ? 'markdown-v1' : 'plain');
71
103
  const normalizedMetadata = createProvenanceMetadata(metadata, { sourceType, origin, content: text, extractor, indexedAt: new Date(createdAt).toISOString() });
72
104
  this.db.exec('BEGIN IMMEDIATE');
73
105
  try {
@@ -87,10 +119,18 @@ export class ContextStore {
87
119
 
88
120
  expire() {
89
121
  const ids = this.db.prepare('SELECT id FROM sources WHERE expires_at IS NOT NULL AND expires_at <= ?').all(Date.now()).map((row) => row.id);
122
+ if (ids.length === 0) return 0;
90
123
  const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
91
124
  const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
92
- for (const id of ids) { removeChunks.run(id); removeSource.run(id); }
93
- return ids.length;
125
+ this.db.exec('BEGIN IMMEDIATE');
126
+ try {
127
+ for (const id of ids) { removeChunks.run(id); removeSource.run(id); }
128
+ this.db.exec('COMMIT');
129
+ return ids.length;
130
+ } catch (error) {
131
+ this.db.exec('ROLLBACK');
132
+ throw error;
133
+ }
94
134
  }
95
135
 
96
136
  search({ query, scope, sessionId, source, limit = 5 }) {
@@ -168,11 +208,18 @@ export class ContextStore {
168
208
  const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
169
209
  const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
170
210
  let removed = 0;
171
- for (const id of ids) {
172
- removeChunks.run(id);
173
- removed += Number(removeSource.run(id).changes ?? 0);
211
+ this.db.exec('BEGIN IMMEDIATE');
212
+ try {
213
+ for (const id of ids) {
214
+ removeChunks.run(id);
215
+ removed += Number(removeSource.run(id).changes ?? 0);
216
+ }
217
+ this.db.exec('COMMIT');
218
+ return { removed };
219
+ } catch (error) {
220
+ this.db.exec('ROLLBACK');
221
+ throw error;
174
222
  }
175
- return { removed };
176
223
  }
177
224
 
178
225
  destroy() {