@dotdotgod/context 0.3.1 → 0.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/context",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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.1"
31
+ "@dotdotgod/cli": "0.3.3"
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 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 });
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(projectRoot(input), path) : null;
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 state = readState(input);
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(input, state);
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(projectRoot(input), path);
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(input, state);
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(() => { this.scheduled = false; void this.pump(); });
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 tree = markdownTree(root, input.maxDepth ?? (input.focus ? 3 : 5));
43
+ const focus = input.focus?.trim() ?? '';
44
+ const tree = markdownTree(root, input.maxDepth ?? (focus ? 3 : 5));
44
45
  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 };
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'), () => { contextStore?.close(); contextStore = undefined; jobRunner = undefined; return healContextDatabase(root); }, { destructiveHint: true });
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 });