@dotdotgod/context 0.3.2 → 0.3.4
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/package.json +2 -2
- package/src/hooks.mjs +40 -13
- package/src/index.d.ts +1 -1
- package/src/index.mjs +1 -1
- package/src/jobs.mjs +24 -4
- package/src/project.mjs +32 -3
- package/src/server.mjs +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdotgod/context",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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.3.
|
|
31
|
+
"@dotdotgod/cli": "0.3.4"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=22.5.0"
|
package/src/hooks.mjs
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { dirname, join, parse, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
function inputText() { return readFileSync(0, 'utf8'); }
|
|
6
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
7
|
function sessionId(input) { return String(input.session_id || input.sessionId || 'default').replace(/[^a-zA-Z0-9._-]/g, '_'); }
|
|
9
|
-
function
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
function runtimeStatePath(root, id) { return join(root, '.dotdotgod', 'context', 'runtime', `${id}.json`); }
|
|
9
|
+
function declaredProjectRoot(input) {
|
|
10
|
+
const value = input.project_dir || process.env.CLAUDE_PROJECT_DIR;
|
|
11
|
+
return typeof value === 'string' && value.trim() ? resolve(value) : null;
|
|
12
|
+
}
|
|
13
|
+
function findExistingStateRoot(cwd, id) {
|
|
14
|
+
let current = resolve(cwd);
|
|
15
|
+
const filesystemRoot = parse(current).root;
|
|
16
|
+
while (true) {
|
|
17
|
+
if (existsSync(runtimeStatePath(current, id))) return current;
|
|
18
|
+
if (current === filesystemRoot) return null;
|
|
19
|
+
current = dirname(current);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function resolveHookContext(input, event) {
|
|
23
|
+
const id = sessionId(input);
|
|
24
|
+
const declared = declaredProjectRoot(input);
|
|
25
|
+
if (declared) return { root: declared, path: runtimeStatePath(declared, id) };
|
|
26
|
+
if (typeof input.cwd !== 'string' || !input.cwd.trim()) return null;
|
|
27
|
+
const existing = findExistingStateRoot(input.cwd, id);
|
|
28
|
+
if (existing) return { root: existing, path: runtimeStatePath(existing, id) };
|
|
29
|
+
if (event !== 'sessionstart') return null;
|
|
30
|
+
const root = resolve(input.cwd);
|
|
31
|
+
return { root, path: runtimeStatePath(root, id) };
|
|
32
|
+
}
|
|
33
|
+
function readState(path) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return { loadRequired: false, pending: {} }; } }
|
|
34
|
+
function writeState(path, state) {
|
|
35
|
+
if (!path) return;
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
13
37
|
const temp = `${path}.${process.pid}.tmp`; writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`); renameSync(temp, path);
|
|
14
38
|
}
|
|
15
39
|
function fileHash(path) { try { return createHash('sha256').update(readFileSync(path)).digest('hex'); } catch { return 'missing'; } }
|
|
@@ -23,21 +47,23 @@ function isImpactGate(name, input) {
|
|
|
23
47
|
if (!/^(Bash|Shell|local_shell|shell|shell_command|exec_command)$/i.test(name)) return false;
|
|
24
48
|
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
49
|
}
|
|
26
|
-
function changedPath(input) {
|
|
50
|
+
function changedPath(input, root) {
|
|
27
51
|
const value = toolInput(input);
|
|
28
52
|
const path = value.file_path || value.path;
|
|
29
|
-
return typeof path === 'string' ? resolve(
|
|
53
|
+
return root && typeof path === 'string' ? resolve(root, path) : null;
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
export function runHook(event) {
|
|
33
57
|
const input = parseInput();
|
|
34
58
|
const name = toolName(input);
|
|
35
|
-
const
|
|
59
|
+
const context = resolveHookContext(input, event);
|
|
60
|
+
const state = readState(context?.path);
|
|
36
61
|
state.pending ||= {};
|
|
37
62
|
|
|
38
63
|
if (event === 'sessionstart') {
|
|
64
|
+
if (!context) return {};
|
|
39
65
|
state.loadRequired = true;
|
|
40
|
-
writeState(
|
|
66
|
+
writeState(context.path, state);
|
|
41
67
|
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
68
|
}
|
|
43
69
|
|
|
@@ -50,18 +76,19 @@ export function runHook(event) {
|
|
|
50
76
|
}
|
|
51
77
|
|
|
52
78
|
if (event === 'posttooluse') {
|
|
79
|
+
if (!context) return {};
|
|
53
80
|
if (mcpTool(name, 'dotdotgod_project_load')) state.loadRequired = false;
|
|
54
81
|
else if (mcpTool(name, 'dotdotgod_project_impact')) {
|
|
55
82
|
const checked = toolInput(input).paths;
|
|
56
83
|
if (Array.isArray(checked)) for (const path of checked) {
|
|
57
|
-
const absolute = resolve(
|
|
84
|
+
const absolute = resolve(context.root, path);
|
|
58
85
|
if (state.pending[absolute] === fileHash(absolute)) delete state.pending[absolute];
|
|
59
86
|
}
|
|
60
87
|
} else if (/^(Edit|Write|apply_patch)$/i.test(name)) {
|
|
61
|
-
const path = changedPath(input);
|
|
88
|
+
const path = changedPath(input, context.root);
|
|
62
89
|
if (path) state.pending[path] = fileHash(path);
|
|
63
90
|
}
|
|
64
|
-
writeState(
|
|
91
|
+
writeState(context.path, state);
|
|
65
92
|
return {};
|
|
66
93
|
}
|
|
67
94
|
|
package/src/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export class ContextStore {
|
|
|
32
32
|
export function healContextDatabase(root?: string): { ok: true; backupPath: string; fromVersion: number; toVersion: number; rebuilt: true };
|
|
33
33
|
export function validateSessionId(value: unknown): string;
|
|
34
34
|
export function resolveSessionId(value?: unknown): string;
|
|
35
|
-
export class IngestionJobRunner { sessionId?: string; constructor(store: ContextStore, options?: { sessionId?: string; renderer?: BrowserRenderer; root?: string }); enqueue(kind: 'index' | 'fetch', input: Record<string, unknown>): IngestionJob; status(id: string): IngestionJob | null; cancel(id: string): { changed: number; job: IngestionJob | null }; pump(): Promise<void> }
|
|
35
|
+
export class IngestionJobRunner { sessionId?: string; constructor(store: ContextStore, options?: { sessionId?: string; renderer?: BrowserRenderer; root?: string }); enqueue(kind: 'index' | 'fetch', input: Record<string, unknown>): IngestionJob; status(id: string): IngestionJob | null; cancel(id: string): { changed: number; job: IngestionJob | null }; close(): Promise<void>; pump(): Promise<void> }
|
|
36
36
|
export interface BrowserRendererResult { body?: Buffer; text?: string; url?: string; contentType?: string; status?: number }
|
|
37
37
|
export type BrowserRenderer = (input: Readonly<{ url: string; signal: AbortSignal; timeoutMs: number; maxBytes: number }>) => Promise<BrowserRendererResult>;
|
|
38
38
|
export const INGESTION_JOB_LIMITS: Readonly<{ maxPendingJobs: number; concurrency: 1 }>;
|
package/src/index.mjs
CHANGED
|
@@ -11,5 +11,5 @@ export { normalizeHtml } from './html-normalize.mjs';
|
|
|
11
11
|
export { runDoctor } from './doctor.mjs';
|
|
12
12
|
export { createProvenanceMetadata, readProvenanceMetadata, sha256Content } from './provenance.mjs';
|
|
13
13
|
export { normalizeSearchTerms, reciprocalRankFusion, rerankCandidates } from './rank.mjs';
|
|
14
|
-
export { projectImpact, projectInitialize, projectLoad } from './project.mjs';
|
|
14
|
+
export { projectEmbeddingInstall, projectEmbeddingStatus, projectImpact, projectInitialize, projectLoad } from './project.mjs';
|
|
15
15
|
export { startServer } from './server.mjs';
|
package/src/jobs.mjs
CHANGED
|
@@ -10,11 +10,14 @@ export class IngestionJobRunner {
|
|
|
10
10
|
this.renderer = renderer;
|
|
11
11
|
this.running = false;
|
|
12
12
|
this.scheduled = false;
|
|
13
|
+
this.closed = false;
|
|
14
|
+
this.pumpPromise = undefined;
|
|
13
15
|
this.controllers = new Map();
|
|
14
16
|
this.schedule();
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
enqueue(kind, input) {
|
|
20
|
+
if (this.closed) throw new Error('Ingestion job runner is closed.');
|
|
18
21
|
if (!['index', 'fetch'].includes(kind)) throw new Error('Unsupported ingestion job kind.');
|
|
19
22
|
const job = this.store.createJob({ kind, input, sessionId: this.sessionId ?? null });
|
|
20
23
|
this.schedule();
|
|
@@ -29,16 +32,33 @@ export class IngestionJobRunner {
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
schedule() {
|
|
32
|
-
if (this.scheduled || this.running) return;
|
|
35
|
+
if (this.closed || this.scheduled || this.running) return;
|
|
33
36
|
this.scheduled = true;
|
|
34
|
-
queueMicrotask(() => {
|
|
37
|
+
queueMicrotask(() => {
|
|
38
|
+
this.scheduled = false;
|
|
39
|
+
if (this.closed) return;
|
|
40
|
+
const pending = this.pump();
|
|
41
|
+
this.pumpPromise = pending;
|
|
42
|
+
const clearPending = () => {
|
|
43
|
+
if (this.pumpPromise === pending) this.pumpPromise = undefined;
|
|
44
|
+
};
|
|
45
|
+
void pending.then(clearPending, clearPending);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async close() {
|
|
50
|
+
this.closed = true;
|
|
51
|
+
for (const controller of this.controllers.values()) controller.abort();
|
|
52
|
+
if (this.scheduled) await Promise.resolve();
|
|
53
|
+
await this.pumpPromise;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
async pump() {
|
|
38
|
-
if (this.running) return;
|
|
57
|
+
if (this.closed || this.running) return;
|
|
39
58
|
this.running = true;
|
|
40
59
|
try {
|
|
41
60
|
for (;;) {
|
|
61
|
+
if (this.closed) break;
|
|
42
62
|
const job = this.store.claimNextJob();
|
|
43
63
|
if (!job) break;
|
|
44
64
|
const controller = new AbortController();
|
|
@@ -53,7 +73,7 @@ export class IngestionJobRunner {
|
|
|
53
73
|
const current = this.store.getJob(job.id);
|
|
54
74
|
if (current?.state === 'running') this.store.finishJob(job.id, controller.signal.aborted ? 'cancelled' : 'failed', error instanceof Error ? error.message : String(error));
|
|
55
75
|
} finally { this.controllers.delete(job.id); }
|
|
56
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
76
|
+
if (!this.closed) await new Promise((resolve) => setImmediate(resolve));
|
|
57
77
|
}
|
|
58
78
|
} finally { this.running = false; }
|
|
59
79
|
}
|
package/src/project.mjs
CHANGED
|
@@ -40,10 +40,39 @@ async function cliJson(args, cwd) {
|
|
|
40
40
|
|
|
41
41
|
export async function projectLoad(input) {
|
|
42
42
|
const root = resolve(input.root || process.cwd());
|
|
43
|
-
const
|
|
43
|
+
const focus = input.focus?.trim() ?? '';
|
|
44
|
+
const tree = markdownTree(root, input.maxDepth ?? (focus ? 3 : 5));
|
|
44
45
|
let query = null;
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
let queryUnavailable;
|
|
47
|
+
if (focus) {
|
|
48
|
+
try {
|
|
49
|
+
query = await cliJson(['query', root, focus, '--limit', String(Math.min(input.limit ?? 30, 30)), '--json'], root);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const missingRuntime = String(error?.message ?? error).includes('Optional local embedding runtime is not installed');
|
|
52
|
+
queryUnavailable = missingRuntime ? {
|
|
53
|
+
code: 'EMBEDDING_RUNTIME_MISSING',
|
|
54
|
+
message: 'Local semantic search requires an optional embedding runtime; continue with the documentation map or ask the user before installation.',
|
|
55
|
+
recovery: { kind: 'embedding-runtime-install', requiresConfirmation: true, statusTool: 'dotdotgod_embedding_status', installTool: 'dotdotgod_embedding_install', cliCommand: 'dotdotgod embedding install --confirm' },
|
|
56
|
+
} : {
|
|
57
|
+
code: 'QUERY_UNAVAILABLE',
|
|
58
|
+
message: 'Semantic project query is unavailable; continue with the documentation map and targeted reads.',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, root, focus, documentationTree: tree, query, ...(queryUnavailable ? { queryUnavailable } : {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function projectEmbeddingStatus(input = {}) {
|
|
66
|
+
const root = resolve(input.root || process.cwd());
|
|
67
|
+
const result = await cliJson(['embedding', 'status', root, '--json'], root);
|
|
68
|
+
return { ...result, ...(result.location ? { location: '~/.dotdotgod/runtime/embedding' } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function projectEmbeddingInstall(input = {}) {
|
|
72
|
+
if (input.confirm !== true) throw new Error('confirm: true is required after explicit user approval.');
|
|
73
|
+
const root = resolve(input.root || process.cwd());
|
|
74
|
+
const result = await cliJson(['embedding', 'install', root, '--confirm', '--json'], root);
|
|
75
|
+
return { ...result, ...(result.location ? { location: '~/.dotdotgod/runtime/embedding' } : {}) };
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
export async function projectImpact(input) {
|
package/src/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { ContextStore, contextDbPath, healContextDatabase } from './store.mjs';
|
|
|
5
5
|
import { executeBatch, executeCommand, executeFile } from './execute.mjs';
|
|
6
6
|
import { fetchAndIndex, indexFile } from './content.mjs';
|
|
7
7
|
import { runDoctor } from './doctor.mjs';
|
|
8
|
-
import { projectImpact, projectInitialize, projectLoad } from './project.mjs';
|
|
8
|
+
import { projectEmbeddingInstall, projectEmbeddingStatus, projectImpact, projectInitialize, projectLoad } from './project.mjs';
|
|
9
9
|
import { resolveWithinRoot } from './paths.mjs';
|
|
10
10
|
import { IngestionJobRunner } from './jobs.mjs';
|
|
11
11
|
import { resolveSessionId, validateSessionId } from './session.mjs';
|
|
@@ -93,7 +93,7 @@ register('session_resume', 'Use an explicit opaque session ID for subsequent con
|
|
|
93
93
|
register('ingestion_job_start', 'Queue one durable bounded background index or strict-fetch ingestion job.', phase3Shape('ingestion_job_start'), (input) => ({ ok: true, job: getJobs().enqueue(input.kind, input.input) }));
|
|
94
94
|
register('ingestion_job_status', 'Return bounded status for one background ingestion job.', phase3Shape('ingestion_job_status'), (input) => ({ ok: true, job: getJobs().status(input.id) }), { readOnlyHint: true });
|
|
95
95
|
register('ingestion_job_cancel', 'Cancel one queued or running background ingestion job.', phase3Shape('ingestion_job_cancel'), (input) => ({ ok: true, ...getJobs().cancel(input.id) }), { destructiveHint: true });
|
|
96
|
-
register('context_heal', 'Explicitly back up and migrate only a recognized recoverable context database.', phase3Shape('context_heal'), () => {
|
|
96
|
+
register('context_heal', 'Explicitly back up and migrate only a recognized recoverable context database.', phase3Shape('context_heal'), async () => { await jobRunner?.close(); jobRunner = undefined; contextStore?.close(); contextStore = undefined; return healContextDatabase(root); }, { destructiveHint: true });
|
|
97
97
|
register('stats', 'Report local context store counts and location.', {}, () => ({ ok: true, sessionId, ...getStore().stats() }), { readOnlyHint: true });
|
|
98
98
|
register('doctor', 'Run local read-only Node.js, SQLite FTS5, storage, schema, and fetch-policy checks without network or repair actions.', {}, () => ({ sessionId, ...runDoctor({ root, dbPath: contextDbPath(root) }) }), { readOnlyHint: true });
|
|
99
99
|
register('purge', 'Permanently delete one explicit context scope, session, or source.', {
|
|
@@ -102,6 +102,8 @@ register('purge', 'Permanently delete one explicit context scope, session, or so
|
|
|
102
102
|
register('dotdotgod_project_load', 'Load a bounded documentation map and optional semantic project query.', {
|
|
103
103
|
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(),
|
|
104
104
|
}, (input) => projectLoad(projectInput(input)), { readOnlyHint: true });
|
|
105
|
+
register('dotdotgod_embedding_status', 'Inspect the optional persistent embedding runtime without installing or contacting a registry.', { root: z.string().optional() }, (input) => projectEmbeddingStatus(projectInput(input)), { readOnlyHint: true });
|
|
106
|
+
register('dotdotgod_embedding_install', 'After explicit user approval, install the fixed optional embedding runtime persistently. Uses network and dependency install scripts.', { root: z.string().optional(), confirm: z.literal(true) }, (input) => projectEmbeddingInstall(projectInput(input)), { destructiveHint: true, openWorldHint: true });
|
|
105
107
|
register('dotdotgod_project_impact', 'Run bounded graph impact for up to 20 changed paths.', {
|
|
106
108
|
root: z.string().optional(), paths: z.array(z.string()).min(1).max(20),
|
|
107
109
|
}, (input) => projectImpact(projectInput(input)), { readOnlyHint: true });
|