@dotdotgod/context 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ Elastic License 2.0
2
+
3
+ See https://www.elastic.co/licensing/elastic-license
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @dotdotgod/context
2
+
3
+ Local-first execution and retrieval runtime for dotdotgod adapters.
4
+
5
+ The package exposes a stdio MCP server and shared core modules. Claude Code and Codex load the server through their adapter packages. Pi uses the shared modules through native extension tools rather than starting the MCP server.
6
+
7
+ ## Tools
8
+
9
+ - `execute`, `batch_execute`, `execute_file`
10
+ - `index`, `search`, `fetch_and_index`
11
+ - `stats`, `doctor`, `purge`
12
+ - `dotdotgod_project_load`, `dotdotgod_project_impact`, `dotdotgod_project_initialize`
13
+
14
+ Large command output is captured in temporary files and indexed into a project-local SQLite FTS5 store under `.dotdotgod/context/`. The raw output is not returned before output policy filtering.
15
+
16
+ ```bash
17
+ dotdotgod-context
18
+ ```
package/bin/server.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { startServer } from '../src/server.mjs';
3
+
4
+ startServer().catch((error) => {
5
+ console.error(error);
6
+ process.exitCode = 1;
7
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@dotdotgod/context",
3
+ "version": "0.2.23",
4
+ "description": "Local-first MCP execution, indexing, and retrieval runtime for dotdotgod adapters.",
5
+ "type": "module",
6
+ "license": "Elastic-2.0",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "dotdotgod-context": "./bin/server.mjs"
12
+ },
13
+ "types": "./src/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.d.ts",
17
+ "import": "./src/index.mjs"
18
+ },
19
+ "./server": "./src/server.mjs",
20
+ "./hooks": "./src/hooks.mjs"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.25.2",
30
+ "zod": "^3.25.76",
31
+ "@dotdotgod/cli": "0.2.23"
32
+ },
33
+ "engines": {
34
+ "node": ">=22.5.0"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/dotdotgod/dotdotgod-kit.git",
39
+ "directory": "packages/context"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test test/*.test.mjs",
43
+ "verify": "node --check bin/server.mjs && node --check src/server.mjs && pnpm run test",
44
+ "pack:dry-run": "pnpm pack --dry-run --json"
45
+ }
46
+ }
package/src/chunks.mjs ADDED
@@ -0,0 +1,38 @@
1
+ const DEFAULT_CHARS = 4000;
2
+ const DEFAULT_OVERLAP = 400;
3
+
4
+ export function normalizeText(value) {
5
+ return String(value ?? '').replace(/\0/g, '\uFFFD').replace(/\r\n?/g, '\n');
6
+ }
7
+
8
+ export function chunkText(value, options = {}) {
9
+ const text = normalizeText(value);
10
+ const maxChars = Math.max(512, options.maxChars ?? DEFAULT_CHARS);
11
+ const overlap = Math.min(Math.max(0, options.overlap ?? DEFAULT_OVERLAP), Math.floor(maxChars / 3));
12
+ if (!text) return [];
13
+ const chunks = [];
14
+ let start = 0;
15
+ while (start < text.length) {
16
+ let end = Math.min(text.length, start + maxChars);
17
+ if (end < text.length) {
18
+ const boundary = Math.max(text.lastIndexOf('\n\n', end), text.lastIndexOf('\n', end));
19
+ if (boundary > start + Math.floor(maxChars / 2)) end = boundary + 1;
20
+ }
21
+ chunks.push({ text: text.slice(start, end), start, end });
22
+ if (end >= text.length) break;
23
+ start = Math.max(start + 1, end - overlap);
24
+ }
25
+ return chunks;
26
+ }
27
+
28
+ export function excerpt(text, query, maxChars = 1200) {
29
+ const normalized = normalizeText(text);
30
+ if (normalized.length <= maxChars) return normalized;
31
+ const terms = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
32
+ const lower = normalized.toLowerCase();
33
+ const positions = terms.map((term) => lower.indexOf(term)).filter((index) => index >= 0);
34
+ const center = positions.length ? Math.min(...positions) : 0;
35
+ const start = Math.max(0, center - Math.floor(maxChars / 3));
36
+ const end = Math.min(normalized.length, start + maxChars);
37
+ return `${start > 0 ? '…' : ''}${normalized.slice(start, end)}${end < normalized.length ? '…' : ''}`;
38
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { resolveExistingWithinRoot } from './paths.mjs';
3
+
4
+ const MAX_FILE_BYTES = 25 * 1024 * 1024;
5
+ const MAX_FETCH_BYTES = 10 * 1024 * 1024;
6
+
7
+ export function indexFile(store, input, sessionId) {
8
+ const path = resolveExistingWithinRoot(input.root || process.cwd(), input.path);
9
+ const stat = statSync(path);
10
+ if (!stat.isFile()) throw new Error('Only regular files are supported in the first release.');
11
+ if (stat.size > (input.maxBytes ?? MAX_FILE_BYTES)) throw new Error(`File exceeds maximum size: ${stat.size} bytes`);
12
+ return store.index({
13
+ scope: input.scope ?? 'session',
14
+ sessionId,
15
+ label: input.source ?? path,
16
+ kind: 'file',
17
+ text: readFileSync(path, 'utf8'),
18
+ metadata: { path, size: stat.size },
19
+ ttlMs: input.ttlMs ?? null,
20
+ });
21
+ }
22
+
23
+ export async function fetchAndIndex(store, input, sessionId) {
24
+ const url = new URL(input.url);
25
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only http and https URLs are supported.');
26
+ const response = await fetch(url, { signal: AbortSignal.timeout(Math.min(input.timeoutMs ?? 30_000, 120_000)) });
27
+ if (!response.ok) throw new Error(`Fetch failed: HTTP ${response.status}`);
28
+ const length = Number(response.headers.get('content-length') || 0);
29
+ if (length > (input.maxBytes ?? MAX_FETCH_BYTES)) throw new Error(`Response exceeds maximum size: ${length} bytes`);
30
+ const text = await response.text();
31
+ if (Buffer.byteLength(text) > (input.maxBytes ?? MAX_FETCH_BYTES)) throw new Error('Response exceeds maximum size after download.');
32
+ const contentType = response.headers.get('content-type') || 'text/plain';
33
+ return store.index({
34
+ scope: input.scope ?? 'project',
35
+ sessionId,
36
+ label: input.source ?? url.href,
37
+ kind: 'url',
38
+ text,
39
+ metadata: { url: url.href, contentType, status: response.status },
40
+ ttlMs: input.ttlMs ?? 24 * 60 * 60 * 1000,
41
+ });
42
+ }
@@ -0,0 +1,155 @@
1
+ import { createWriteStream, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { resolveWithinRoot } from './paths.mjs';
6
+
7
+ const DIRECT_LIMIT = 12_000;
8
+ const HARD_LIMIT = 10 * 1024 * 1024;
9
+
10
+ function readBounded(path, limit = DIRECT_LIMIT) {
11
+ const value = readFileSync(path);
12
+ if (value.length <= limit) return { text: value.toString('utf8'), truncated: false, bytes: value.length };
13
+ const head = value.subarray(0, Math.floor(limit * 0.7));
14
+ const tail = value.subarray(value.length - Math.floor(limit * 0.3));
15
+ return { text: `${head.toString('utf8')}\n… ${value.length - head.length - tail.length} byte(s) omitted …\n${tail.toString('utf8')}`, truncated: true, bytes: value.length };
16
+ }
17
+
18
+ function commandSpec(input) {
19
+ if (input.executable) return { command: input.executable, args: input.args ?? [], shell: false };
20
+ if (!input.command) throw new Error('command or executable is required');
21
+ return { command: input.command, args: [], shell: input.shell ?? true };
22
+ }
23
+
24
+ function killProcess(child) {
25
+ if (!child.pid) return;
26
+ try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM'); else child.kill('SIGTERM'); } catch {}
27
+ setTimeout(() => {
28
+ try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch {}
29
+ }, 500).unref();
30
+ }
31
+
32
+ export async function executeCommand(input, options = {}) {
33
+ const spec = commandSpec(input);
34
+ const cwd = resolveWithinRoot(options.root || process.cwd(), input.cwd || '.');
35
+ const timeoutMs = Math.min(Math.max(1, input.timeoutMs ?? 120_000), 30 * 60_000);
36
+ const dir = mkdtempSync(join(tmpdir(), 'dotdotgod-context-'));
37
+ const stdoutPath = join(dir, 'stdout.log');
38
+ const stderrPath = join(dir, 'stderr.log');
39
+ const stdout = createWriteStream(stdoutPath);
40
+ const stderr = createWriteStream(stderrPath);
41
+ const startedAt = Date.now();
42
+ let timedOut = false;
43
+ let aborted = false;
44
+
45
+ try {
46
+ const result = await new Promise((resolvePromise, reject) => {
47
+ const child = spawn(spec.command, spec.args, {
48
+ cwd,
49
+ shell: spec.shell,
50
+ detached: process.platform !== 'win32',
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ env: options.env ?? process.env,
53
+ });
54
+ child.stdout.pipe(stdout);
55
+ child.stderr.pipe(stderr);
56
+ const timer = setTimeout(() => { timedOut = true; killProcess(child); }, timeoutMs);
57
+ const onAbort = () => { aborted = true; killProcess(child); };
58
+ options.signal?.addEventListener('abort', onAbort, { once: true });
59
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
60
+ child.once('close', (code, signal) => {
61
+ clearTimeout(timer);
62
+ options.signal?.removeEventListener('abort', onAbort);
63
+ stdout.end(() => stderr.end(() => resolvePromise({ code, signal })));
64
+ });
65
+ });
66
+
67
+ const stdoutResult = readBounded(stdoutPath, input.outputLimit ?? DIRECT_LIMIT);
68
+ const stderrResult = readBounded(stderrPath, input.outputLimit ?? DIRECT_LIMIT);
69
+ const totalBytes = stdoutResult.bytes + stderrResult.bytes;
70
+ const metadata = {
71
+ command: input.command ?? [input.executable, ...(input.args ?? [])].join(' '),
72
+ cwd,
73
+ code: result.code,
74
+ signal: result.signal,
75
+ timedOut,
76
+ aborted,
77
+ durationMs: Date.now() - startedAt,
78
+ stdoutBytes: stdoutResult.bytes,
79
+ stderrBytes: stderrResult.bytes,
80
+ };
81
+ const mode = input.outputMode ?? 'auto';
82
+ const shouldIndex = mode === 'indexed' || (mode === 'auto' && totalBytes > (input.directLimit ?? DIRECT_LIMIT));
83
+ let indexed;
84
+ if (shouldIndex && options.store) {
85
+ const stdoutText = statSync(stdoutPath).size <= HARD_LIMIT ? readFileSync(stdoutPath, 'utf8') : stdoutResult.text;
86
+ const stderrText = statSync(stderrPath).size <= HARD_LIMIT ? readFileSync(stderrPath, 'utf8') : stderrResult.text;
87
+ indexed = options.store.index({
88
+ scope: input.scope ?? 'session',
89
+ sessionId: options.sessionId,
90
+ label: input.label ?? metadata.command,
91
+ kind: 'command',
92
+ text: `# stdout\n${stdoutText}\n\n# stderr\n${stderrText}`,
93
+ metadata,
94
+ ttlMs: input.ttlMs ?? 24 * 60 * 60 * 1000,
95
+ });
96
+ }
97
+ return {
98
+ ok: result.code === 0 && !timedOut && !aborted,
99
+ ...metadata,
100
+ ...(mode === 'discard' ? {} : shouldIndex ? { indexed } : { stdout: stdoutResult.text, stderr: stderrResult.text, truncated: stdoutResult.truncated || stderrResult.truncated }),
101
+ };
102
+ } finally {
103
+ rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ }
106
+
107
+ export async function executeFile(input, options = {}) {
108
+ const path = resolveWithinRoot(options.root || process.cwd(), input.path);
109
+ const dir = mkdtempSync(join(tmpdir(), 'dotdotgod-context-file-'));
110
+ try {
111
+ let executable;
112
+ let args;
113
+ if (input.language === 'python') {
114
+ const wrapper = join(dir, 'run.py');
115
+ writeFileSync(wrapper, `from pathlib import Path\nFILE_CONTENT = Path(${JSON.stringify(path)}).read_text(encoding='utf-8', errors='replace')\n${input.code}\n`);
116
+ executable = process.env.PYTHON || 'python3';
117
+ args = [wrapper];
118
+ } else if (input.language === 'shell') {
119
+ const wrapper = join(dir, 'run.sh');
120
+ writeFileSync(wrapper, `FILE_PATH=${JSON.stringify(path)}\nexport FILE_PATH\n${input.code}\n`);
121
+ executable = process.env.SHELL || '/bin/sh';
122
+ args = [wrapper];
123
+ } else {
124
+ const wrapper = join(dir, 'run.mjs');
125
+ writeFileSync(wrapper, `import { readFileSync } from 'node:fs';\nconst FILE_CONTENT = readFileSync(${JSON.stringify(path)}, 'utf8');\n${input.code}\n`);
126
+ executable = process.execPath;
127
+ args = [wrapper];
128
+ }
129
+ return await executeCommand({ ...input, executable, args, command: undefined, shell: false, cwd: input.cwd ?? options.root }, options);
130
+ } finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ }
134
+
135
+ export async function executeBatch(input, options = {}) {
136
+ const commands = input.commands ?? [];
137
+ if (commands.length === 0) throw new Error('commands must contain at least one command');
138
+ if (commands.length > 100) throw new Error('commands is limited to 100 entries');
139
+ const concurrency = Math.min(8, Math.max(1, input.concurrency ?? 1));
140
+ const results = new Array(commands.length);
141
+ let cursor = 0;
142
+ async function worker() {
143
+ while (true) {
144
+ const index = cursor++;
145
+ if (index >= commands.length) return;
146
+ try {
147
+ results[index] = await executeCommand({ ...commands[index], cwd: commands[index].cwd ?? input.cwd, timeoutMs: commands[index].timeoutMs ?? input.timeoutMs }, options);
148
+ } catch (error) {
149
+ results[index] = { ok: false, error: error instanceof Error ? error.message : String(error) };
150
+ }
151
+ }
152
+ }
153
+ await Promise.all(Array.from({ length: Math.min(concurrency, commands.length) }, () => worker()));
154
+ return { ok: results.every((result) => result.ok), concurrency, results };
155
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ function inputText() { return readFileSync(0, 'utf8'); }
6
+ function parseInput() { const text = inputText().trim(); return text ? JSON.parse(text) : {}; }
7
+ function projectRoot(input) { return resolve(input.cwd || input.project_dir || process.env.CLAUDE_PROJECT_DIR || process.cwd()); }
8
+ function sessionId(input) { return String(input.session_id || input.sessionId || 'default').replace(/[^a-zA-Z0-9._-]/g, '_'); }
9
+ function statePath(input) { return join(projectRoot(input), '.dotdotgod', 'context', 'runtime', `${sessionId(input)}.json`); }
10
+ function readState(input) { try { return JSON.parse(readFileSync(statePath(input), 'utf8')); } catch { return { loadRequired: false, pending: {} }; } }
11
+ function writeState(input, state) {
12
+ const path = statePath(input); mkdirSync(dirname(path), { recursive: true });
13
+ const temp = `${path}.${process.pid}.tmp`; writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`); renameSync(temp, path);
14
+ }
15
+ function fileHash(path) { try { return createHash('sha256').update(readFileSync(path)).digest('hex'); } catch { return 'missing'; } }
16
+ function toolName(input) { return String(input.tool_name || input.toolName || ''); }
17
+ function toolInput(input) { return input.tool_input || input.toolInput || {}; }
18
+ function mcpTool(name, suffix) { return name === suffix || name.endsWith(`__${suffix}`) || name.endsWith(`_${suffix}`); }
19
+ function deny(reason) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }; }
20
+ function commandFrom(input) { const value = toolInput(input); return String(value.command || value.cmd || ''); }
21
+ function isSubstantive(name) { return /^(Bash|Shell|local_shell|shell|shell_command|exec_command|Edit|Write|apply_patch)$/i.test(name); }
22
+ function isImpactGate(name, input) {
23
+ if (!/^(Bash|Shell|local_shell|shell|shell_command|exec_command)$/i.test(name)) return false;
24
+ return /(^|\s)(test|build|lint|verify|commit|push|publish|deploy)(\s|:|$)|\b(git\s+(commit|push)|npm\s+(test|publish)|pnpm\s+[^\n]*(test|build|lint|verify|publish))\b/i.test(commandFrom(input));
25
+ }
26
+ function changedPath(input) {
27
+ const value = toolInput(input);
28
+ const path = value.file_path || value.path;
29
+ return typeof path === 'string' ? resolve(projectRoot(input), path) : null;
30
+ }
31
+
32
+ export function runHook(event) {
33
+ const input = parseInput();
34
+ const name = toolName(input);
35
+ const state = readState(input);
36
+ state.pending ||= {};
37
+
38
+ if (event === 'sessionstart') {
39
+ state.loadRequired = true;
40
+ writeState(input, state);
41
+ return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Dotdotgod project memory has not been loaded for this session. Before substantive shell or write work, call dotdotgod_project_load once with a concise task-specific focus.' } };
42
+ }
43
+
44
+ if (event === 'pretooluse') {
45
+ if (mcpTool(name, 'dotdotgod_project_load') || mcpTool(name, 'dotdotgod_project_impact')) return {};
46
+ if (state.loadRequired && isSubstantive(name)) return deny('Project memory is required first. Call dotdotgod_project_load with the current project root and a concise task-specific focus, then retry this tool.');
47
+ const pendingPaths = Object.keys(state.pending);
48
+ if (pendingPaths.length && isImpactGate(name, input)) return deny(`Graph impact is pending. Call dotdotgod_project_impact with paths=${JSON.stringify(pendingPaths)} and then retry this tool.`);
49
+ return {};
50
+ }
51
+
52
+ if (event === 'posttooluse') {
53
+ if (mcpTool(name, 'dotdotgod_project_load')) state.loadRequired = false;
54
+ else if (mcpTool(name, 'dotdotgod_project_impact')) {
55
+ const checked = toolInput(input).paths;
56
+ if (Array.isArray(checked)) for (const path of checked) {
57
+ const absolute = resolve(projectRoot(input), path);
58
+ if (state.pending[absolute] === fileHash(absolute)) delete state.pending[absolute];
59
+ }
60
+ } else if (/^(Edit|Write|apply_patch)$/i.test(name)) {
61
+ const path = changedPath(input);
62
+ if (path) state.pending[path] = fileHash(path);
63
+ }
64
+ writeState(input, state);
65
+ return {};
66
+ }
67
+
68
+ return {};
69
+ }
70
+
71
+ export function hookMain(event) {
72
+ try { process.stdout.write(`${JSON.stringify(runHook(event))}\n`); }
73
+ catch (error) { process.stderr.write(`dotdotgod hook error: ${error instanceof Error ? error.message : String(error)}\n`); process.stdout.write('{}\n'); }
74
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export class ContextStore {
2
+ constructor(root?: string);
3
+ root: string;
4
+ path: string;
5
+ close(): void;
6
+ index(input: Record<string, unknown>): Record<string, unknown>;
7
+ search(input: Record<string, unknown>): unknown[];
8
+ stats(): Record<string, unknown>;
9
+ purge(input: Record<string, unknown>): Record<string, unknown>;
10
+ }
11
+ export function executeCommand(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
12
+ export function executeBatch(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
13
+ export function executeFile(input: Record<string, any>, options?: Record<string, any>): Promise<Record<string, any>>;
14
+ export function indexFile(store: ContextStore, input: Record<string, any>, sessionId?: string): Record<string, any>;
15
+ export function fetchAndIndex(store: ContextStore, input: Record<string, any>, sessionId?: string): Promise<Record<string, any>>;
16
+ export function projectLoad(input: Record<string, any>): Promise<Record<string, any>>;
17
+ export function projectImpact(input: Record<string, any>): Promise<Record<string, any>>;
18
+ export function projectInitialize(input: Record<string, any>): Promise<Record<string, any>>;
19
+ export function startServer(): Promise<void>;
package/src/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ export { chunkText, excerpt, normalizeText } from './chunks.mjs';
2
+ export { ContextStore, contextDbPath } from './store.mjs';
3
+ export { executeBatch, executeCommand, executeFile } from './execute.mjs';
4
+ export { fetchAndIndex, indexFile } from './content.mjs';
5
+ export { projectImpact, projectInitialize, projectLoad } from './project.mjs';
6
+ export { startServer } from './server.mjs';
package/src/paths.mjs ADDED
@@ -0,0 +1,23 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+
4
+ function assertWithinRoot(base, target, candidate) {
5
+ const rel = relative(base, target);
6
+ if (rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(rel)) {
7
+ throw new Error(`Path escapes project root: ${candidate}`);
8
+ }
9
+ }
10
+
11
+ export function resolveWithinRoot(root, candidate = '.') {
12
+ const base = resolve(root || process.cwd());
13
+ const target = resolve(base, candidate);
14
+ assertWithinRoot(base, target, candidate);
15
+ return target;
16
+ }
17
+
18
+ export function resolveExistingWithinRoot(root, candidate = '.') {
19
+ const base = realpathSync(resolve(root || process.cwd()));
20
+ const target = realpathSync(resolveWithinRoot(base, candidate));
21
+ assertWithinRoot(base, target, candidate);
22
+ return target;
23
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { relative, resolve } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const cli = require.resolve('@dotdotgod/cli/bin/dotdotgod.mjs');
8
+
9
+ function markdownTree(root, maxDepth = 5) {
10
+ const docs = resolve(root, 'docs');
11
+ if (!existsSync(docs)) return [];
12
+ const output = [];
13
+ function walk(dir, depth) {
14
+ if (depth > maxDepth) return;
15
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
16
+ const path = resolve(dir, entry.name);
17
+ if (entry.isDirectory()) { output.push(`${' '.repeat(depth - 1)}- ${relative(root, path)}/`); walk(path, depth + 1); }
18
+ else if (entry.isFile() && entry.name.endsWith('.md')) output.push(`${' '.repeat(depth - 1)}- ${relative(root, path)}`);
19
+ }
20
+ }
21
+ walk(docs, 1);
22
+ return output;
23
+ }
24
+
25
+ async function cliJson(args, cwd) {
26
+ return await new Promise((resolvePromise, reject) => {
27
+ const child = spawn(process.execPath, [cli, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
28
+ const stdout = [];
29
+ const stderr = [];
30
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
31
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
32
+ child.once('error', reject);
33
+ child.once('close', (code) => {
34
+ const out = Buffer.concat(stdout).toString('utf8');
35
+ if (code !== 0) return reject(new Error(Buffer.concat(stderr).toString('utf8') || out || `dotdotgod exited ${code}`));
36
+ try { resolvePromise(JSON.parse(out)); } catch { reject(new Error(`Expected JSON from dotdotgod: ${out.slice(0, 500)}`)); }
37
+ });
38
+ });
39
+ }
40
+
41
+ export async function projectLoad(input) {
42
+ const root = resolve(input.root || process.cwd());
43
+ const tree = markdownTree(root, input.maxDepth ?? (input.focus ? 3 : 5));
44
+ let query = null;
45
+ if (input.focus?.trim()) query = await cliJson(['query', root, input.focus.trim(), '--limit', String(Math.min(input.limit ?? 30, 30)), '--json'], root);
46
+ return { ok: true, root, focus: input.focus?.trim() ?? '', documentationTree: tree, query };
47
+ }
48
+
49
+ export async function projectImpact(input) {
50
+ const root = resolve(input.root || process.cwd());
51
+ const paths = [...new Set(input.paths ?? [])];
52
+ if (paths.length === 0) throw new Error('paths must contain at least one changed path');
53
+ if (paths.length > 20) throw new Error('paths is limited to 20 entries per call');
54
+ const args = ['graph', 'impact', root];
55
+ for (const path of paths) args.push('--changed', path);
56
+ args.push('--json');
57
+ return await cliJson(args, root);
58
+ }
59
+
60
+ export async function projectInitialize(input) {
61
+ const root = resolve(input.root || process.cwd());
62
+ const dryRun = input.dryRun !== false;
63
+ if (!dryRun && input.confirmWrite !== true) throw new Error('confirmWrite: true is required when dryRun is false');
64
+ const args = ['init', root, '--json'];
65
+ if (dryRun) args.push('--dry-run');
66
+ if (input.projectName) args.push('--project-name', input.projectName);
67
+ if (input.template) args.push('--template', input.template);
68
+ if (input.dotdotSetting) args.push('--dotdot-setting');
69
+ return await cliJson(args, root);
70
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { ContextStore } from './store.mjs';
5
+ import { executeBatch, executeCommand, executeFile } from './execute.mjs';
6
+ import { fetchAndIndex, indexFile } from './content.mjs';
7
+ import { projectImpact, projectInitialize, projectLoad } from './project.mjs';
8
+ import { resolveWithinRoot } from './paths.mjs';
9
+
10
+ const root = process.env.DOTDOTGOD_PROJECT_ROOT || process.cwd();
11
+ const sessionId = process.env.DOTDOTGOD_SESSION_ID || crypto.randomUUID();
12
+ const store = new ContextStore(root);
13
+ const server = new McpServer({ name: 'dotdotgod-context', version: '0.2.23' });
14
+
15
+ const scopeSchema = z.enum(['transient', 'session', 'project']).optional();
16
+ const outputModeSchema = z.enum(['auto', 'direct', 'indexed', 'discard']).optional();
17
+ const commandSchema = {
18
+ label: z.string().optional(), command: z.string().optional(), executable: z.string().optional(), args: z.array(z.string()).optional(),
19
+ shell: z.boolean().optional(), cwd: z.string().optional(), timeoutMs: z.number().int().positive().optional(), outputLimit: z.number().int().positive().optional(),
20
+ directLimit: z.number().int().positive().optional(), outputMode: outputModeSchema, scope: scopeSchema, ttlMs: z.number().int().nonnegative().optional(),
21
+ };
22
+
23
+ function success(value) {
24
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value };
25
+ }
26
+ function failure(error) {
27
+ const value = { ok: false, error: error instanceof Error ? error.message : String(error) };
28
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value };
29
+ }
30
+ function projectInput(input) {
31
+ return { ...input, root: resolveWithinRoot(root, input.root || '.') };
32
+ }
33
+ function register(name, description, inputSchema, handler, annotations) {
34
+ server.registerTool(name, { description, inputSchema, annotations }, async (input, extra) => {
35
+ try { return success(await handler(input, extra)); } catch (error) { return failure(error); }
36
+ });
37
+ }
38
+
39
+ register('execute', 'Run one local command while keeping large stdout/stderr outside model context.', commandSchema,
40
+ (input, extra) => executeCommand(input, { root, store, sessionId, signal: extra.signal }), { openWorldHint: true, destructiveHint: true });
41
+ register('batch_execute', 'Run labeled local commands sequentially or with bounded concurrency and index large outputs.', {
42
+ commands: z.array(z.object(commandSchema)).min(1).max(100), concurrency: z.number().int().min(1).max(8).optional(), cwd: z.string().optional(), timeoutMs: z.number().int().positive().optional(),
43
+ }, (input, extra) => executeBatch(input, { root, store, sessionId, signal: extra.signal }), { openWorldHint: true, destructiveHint: true });
44
+ register('execute_file', 'Process a local file in an isolated child process; only bounded stdout/stderr is returned.', {
45
+ path: z.string(), language: z.enum(['javascript', 'python', 'shell']).default('javascript'), code: z.string(), ...commandSchema,
46
+ }, (input, extra) => executeFile(input, { root, store, sessionId, signal: extra.signal }), { openWorldHint: false, destructiveHint: true });
47
+ register('index', 'Index a local text file into the project-local FTS5 store without returning its raw bytes.', {
48
+ 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(),
49
+ }, (input) => ({ ok: true, ...indexFile(store, { ...input, root }, sessionId) }), { readOnlyHint: true });
50
+ register('search', 'Search indexed command, file, and fetched content and return bounded excerpts.', {
51
+ query: z.string().min(1), scope: scopeSchema, source: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), sessionOnly: z.boolean().optional(),
52
+ }, (input) => ({ ok: true, results: store.search({ ...input, sessionId: input.sessionOnly ? sessionId : undefined }) }), { readOnlyHint: true });
53
+ register('fetch_and_index', 'Fetch an HTTP(S) URL locally, index bounded text, and return metadata only.', {
54
+ 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(),
55
+ }, (input) => fetchAndIndex(store, input, sessionId).then((value) => ({ ok: true, ...value })), { openWorldHint: true, readOnlyHint: true });
56
+ register('stats', 'Report local context store counts and location.', {}, () => ({ ok: true, sessionId, ...store.stats() }), { readOnlyHint: true });
57
+ register('doctor', 'Check local Node.js, SQLite FTS5, project storage, and MCP runtime readiness.', {}, () => ({ ok: true, node: process.version, platform: process.platform, root, sessionId, store: store.stats() }), { readOnlyHint: true });
58
+ register('purge', 'Permanently delete one explicit context scope, session, or source.', {
59
+ confirm: z.literal(true), scope: scopeSchema, sessionId: z.string().optional(), sourceId: z.string().optional(),
60
+ }, (input) => ({ ok: true, ...store.purge(input) }), { destructiveHint: true });
61
+ register('dotdotgod_project_load', 'Load a bounded documentation map and optional semantic project query.', {
62
+ root: z.string().optional(), focus: z.string().max(500).optional(), limit: z.number().int().min(1).max(30).optional(), maxDepth: z.number().int().min(1).max(5).optional(),
63
+ }, (input) => projectLoad(projectInput(input)), { readOnlyHint: true });
64
+ register('dotdotgod_project_impact', 'Run bounded graph impact for up to 20 changed paths.', {
65
+ root: z.string().optional(), paths: z.array(z.string()).min(1).max(20),
66
+ }, (input) => projectImpact(projectInput(input)), { readOnlyHint: true });
67
+ register('dotdotgod_project_initialize', 'Initialize project memory; dry-run is default and writes require explicit confirmation.', {
68
+ root: z.string().optional(), dryRun: z.boolean().optional(), confirmWrite: z.boolean().optional(), projectName: z.string().optional(), template: z.string().optional(), dotdotSetting: z.boolean().optional(),
69
+ }, (input) => projectInitialize(projectInput(input)), { destructiveHint: true });
70
+
71
+ export async function startServer() {
72
+ await server.connect(new StdioServerTransport());
73
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { chunkText, excerpt } from './chunks.mjs';
5
+
6
+ function ftsQuery(value) {
7
+ const terms = String(value ?? '').match(/[\p{L}\p{N}_./:@-]+/gu) ?? [];
8
+ if (terms.length === 0) throw new Error('Search query must contain at least one searchable term.');
9
+ return terms.slice(0, 20).map((term) => `"${term.replaceAll('"', '""')}"`).join(' OR ');
10
+ }
11
+
12
+ function safeRoot(root) {
13
+ return resolve(root || process.cwd());
14
+ }
15
+
16
+ export function contextDbPath(root) {
17
+ return join(safeRoot(root), '.dotdotgod', 'context', 'context.sqlite');
18
+ }
19
+
20
+ export class ContextStore {
21
+ constructor(root = process.cwd()) {
22
+ this.root = safeRoot(root);
23
+ this.path = contextDbPath(this.root);
24
+ mkdirSync(dirname(this.path), { recursive: true });
25
+ this.db = new DatabaseSync(this.path);
26
+ this.db.exec(`
27
+ PRAGMA journal_mode=WAL;
28
+ CREATE TABLE IF NOT EXISTS sources (
29
+ id TEXT PRIMARY KEY,
30
+ scope TEXT NOT NULL,
31
+ session_id TEXT,
32
+ label TEXT NOT NULL,
33
+ kind TEXT NOT NULL,
34
+ metadata TEXT NOT NULL,
35
+ created_at INTEGER NOT NULL,
36
+ expires_at INTEGER
37
+ );
38
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
39
+ source_id UNINDEXED,
40
+ scope UNINDEXED,
41
+ session_id UNINDEXED,
42
+ ordinal UNINDEXED,
43
+ body,
44
+ tokenize='porter unicode61'
45
+ );
46
+ `);
47
+ }
48
+
49
+ close() { this.db.close(); }
50
+
51
+ index({ id = crypto.randomUUID(), scope = 'session', sessionId = null, label, kind = 'text', text, metadata = {}, ttlMs = null }) {
52
+ const createdAt = Date.now();
53
+ const expiresAt = ttlMs == null ? null : createdAt + Math.max(0, ttlMs);
54
+ this.db.exec('BEGIN IMMEDIATE');
55
+ try {
56
+ this.db.prepare('INSERT OR REPLACE INTO sources(id, scope, session_id, label, kind, metadata, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
57
+ .run(id, scope, sessionId, label || id, kind, JSON.stringify(metadata), createdAt, expiresAt);
58
+ this.db.prepare('DELETE FROM chunks WHERE source_id = ?').run(id);
59
+ const insert = this.db.prepare('INSERT INTO chunks(source_id, scope, session_id, ordinal, body) VALUES (?, ?, ?, ?, ?)');
60
+ const chunks = chunkText(text);
61
+ chunks.forEach((chunk, ordinal) => insert.run(id, scope, sessionId, ordinal, chunk.text));
62
+ this.db.exec('COMMIT');
63
+ return { id, chunks: chunks.length, bytes: Buffer.byteLength(String(text ?? '')), scope, expiresAt };
64
+ } catch (error) {
65
+ this.db.exec('ROLLBACK');
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ expire() {
71
+ 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);
72
+ const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
73
+ const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
74
+ for (const id of ids) { removeChunks.run(id); removeSource.run(id); }
75
+ return ids.length;
76
+ }
77
+
78
+ search({ query, scope, sessionId, source, limit = 5 }) {
79
+ this.expire();
80
+ const clauses = ['chunks MATCH ?'];
81
+ const params = [ftsQuery(query)];
82
+ if (scope) { clauses.push('c.scope = ?'); params.push(scope); }
83
+ if (sessionId) { clauses.push('c.session_id = ?'); params.push(sessionId); }
84
+ if (source) { clauses.push('(s.id = ? OR s.label LIKE ?)'); params.push(source, `%${source}%`); }
85
+ params.push(Math.min(50, Math.max(1, limit)));
86
+ const rows = this.db.prepare(`
87
+ SELECT c.source_id, c.ordinal, c.body, s.label, s.kind, s.scope, s.metadata, bm25(chunks) AS rank
88
+ FROM chunks c JOIN sources s ON s.id = c.source_id
89
+ WHERE ${clauses.join(' AND ')}
90
+ ORDER BY rank LIMIT ?
91
+ `).all(...params);
92
+ return rows.map((row) => ({
93
+ sourceId: row.source_id,
94
+ label: row.label,
95
+ kind: row.kind,
96
+ scope: row.scope,
97
+ ordinal: Number(row.ordinal),
98
+ rank: row.rank,
99
+ text: excerpt(row.body, query),
100
+ metadata: JSON.parse(row.metadata),
101
+ }));
102
+ }
103
+
104
+ stats() {
105
+ this.expire();
106
+ const sources = this.db.prepare('SELECT count(*) AS count FROM sources').get().count;
107
+ const chunks = this.db.prepare('SELECT count(*) AS count FROM chunks').get().count;
108
+ const byScope = this.db.prepare('SELECT scope, count(*) AS count FROM sources GROUP BY scope ORDER BY scope').all();
109
+ return { path: this.path, sources, chunks, byScope };
110
+ }
111
+
112
+ purge({ scope, sessionId, sourceId }) {
113
+ const selectors = [scope != null, sessionId != null, sourceId != null].filter(Boolean).length;
114
+ if (selectors !== 1) throw new Error('Choose exactly one purge selector: scope, sessionId, or sourceId.');
115
+ let ids;
116
+ if (scope) ids = this.db.prepare('SELECT id FROM sources WHERE scope = ?').all(scope).map((row) => row.id);
117
+ else if (sessionId) ids = this.db.prepare('SELECT id FROM sources WHERE session_id = ?').all(sessionId).map((row) => row.id);
118
+ else ids = [sourceId];
119
+ const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
120
+ const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
121
+ let removed = 0;
122
+ for (const id of ids) {
123
+ removeChunks.run(id);
124
+ removed += Number(removeSource.run(id).changes ?? 0);
125
+ }
126
+ return { removed };
127
+ }
128
+
129
+ destroy() {
130
+ this.close();
131
+ rmSync(dirname(this.path), { recursive: true, force: true });
132
+ }
133
+ }