@dotdotgod/cli 0.6.6 → 0.6.7

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/cli",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "CLI for dotdotgod validation, hybrid document/code-outline search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -1,10 +1,11 @@
1
1
  import { resolve } from 'node:path';
2
+ import { vectorCacheSession } from '../query/cache-session.mjs';
2
3
  import { usage } from '../cli/usage.mjs';
3
4
  import { readMemoryConfig } from '../memory/config.mjs';
4
5
  import { collectDocumentationChunks } from '../query/chunks.mjs';
5
6
  import { resolveEmbedder } from '../query/embedder.mjs';
6
7
  import { resolveEmbeddingProfile } from '../query/embedding-config.mjs';
7
- import { isValidNormalizedVectorValues, profileFingerprint, readVectorCache, writeVectorCache } from '../query/store.mjs';
8
+ import { isValidNormalizedVectorValues, profileFingerprint, writeVectorCache } from '../query/store.mjs';
8
9
 
9
10
  import { projectCorpus, keywordCandidates, fuseCandidates, attachRelationships } from '../query/hybrid.mjs';
10
11
 
@@ -69,7 +70,12 @@ export async function buildVectorIndex(root, embedOrOptions = {}) {
69
70
  const config = readMemoryConfig(root);
70
71
  const exclude = config.load?.documentationSummary?.exclude ?? ['docs/plan', 'docs/archive'];
71
72
  const chunks = options.chunks ?? collectDocumentationChunks(root, exclude, config.documentation?.root ?? 'docs');
72
- const cached = readVectorCache(root, resolved.identity);
73
+ return vectorCacheSession(root, resolved.identity, async (state, save, diagnostics) => {
74
+ const selectedFingerprints = new Set(chunks.map((chunk) => chunk.fingerprint));
75
+ state.pending = state.pending.filter((row) => selectedFingerprints.has(row.chunk.fingerprint));
76
+ const recovered = new Set(state.pending.map((row) => row.chunk.fingerprint));
77
+ const cacheRows = [...state.docs, ...state.code, ...state.pending];
78
+ const cached = state.dimensions ? { chunks: cacheRows.map((row) => row.chunk), vectors: Float32Array.from(cacheRows.flatMap((row) => row.vector)), manifest: { dimensions: state.dimensions } } : null;
73
79
  const cachedOffsets = new Map((cached?.chunks ?? []).map((chunk, index) => [chunk.fingerprint, index]));
74
80
  const rows = new Array(chunks.length);
75
81
  const missing = []; let reused = 0;
@@ -77,16 +83,38 @@ export async function buildVectorIndex(root, embedOrOptions = {}) {
77
83
  for (let offset = 0; offset < missing.length; offset += EMBED_BATCH_SIZE) {
78
84
  const batch = missing.slice(offset, offset + EMBED_BATCH_SIZE);
79
85
  const embedded = await resolved.embed(batch.map(({ chunk }) => chunk.passage), 'document');
80
- embedded.forEach((vector, index) => { rows[batch[index].index] = vector; });
86
+ if (!Array.isArray(embedded) || embedded.length !== batch.length) throw new Error('Embedder returned an unexpected vector count.');
87
+ embedded.forEach((vector, index) => {
88
+ const dimensions = state.dimensions ?? vector.length;
89
+ assertVector(vector, dimensions, 'Passage vector');
90
+ state.dimensions = dimensions;
91
+ rows[batch[index].index] = vector;
92
+ const { passage, ...chunk } = batch[index].chunk;
93
+ state.pending.push({ chunk, vector: Array.from(vector) });
94
+ });
95
+ save();
81
96
  }
82
97
  const dimensions = rows[0]?.length ?? cached?.manifest?.dimensions;
83
- if (!dimensions) throw new Error('No documentation chunks were available to establish vector dimensions.');
98
+ if (!dimensions && chunks.length) throw new Error('No documentation chunks were available to establish vector dimensions.');
84
99
  if (rows.some((row) => !row || row.length !== dimensions)) throw new Error('Embedder returned inconsistent passage-vector dimensions.');
85
100
  const vectors = new Float32Array(chunks.length * dimensions);
86
101
  rows.forEach((row, index) => { assertVector(row, dimensions, 'Passage vector'); vectors.set(row, index * dimensions); });
87
102
  const storedChunks = chunks.map(({ passage, ...chunk }) => chunk);
88
- const manifest = writeVectorCache(root, storedChunks, vectors, { provider: resolved.profile.provider, model: resolved.profile.model, dimensions, profileFingerprint: profileFingerprint(resolved.identity), excluded: exclude, embedded: missing.length, reused });
103
+ const refreshed = options.corpora ?? (options.chunks ? [...new Set(chunks.map((chunk) => chunk.kind === 'symbol' ? 'code' : 'docs'))] : ['docs']);
104
+ const counters = {};
105
+ for (const corpus of refreshed) {
106
+ const positions = chunks.map((chunk, index) => ({ chunk, index })).filter(({ chunk }) => (chunk.kind === 'symbol' ? 'code' : 'docs') === corpus);
107
+ state[corpus] = positions.map(({ index }) => ({ chunk: storedChunks[index], vector: Array.from(rows[index]) }));
108
+ counters[corpus] = { chunks: positions.length, embedded: missing.filter(({ chunk }) => (chunk.kind === 'symbol' ? 'code' : 'docs') === corpus).length, recovered: positions.filter(({ chunk }) => recovered.has(chunk.fingerprint)).length };
109
+ counters[corpus].reused = positions.length - counters[corpus].embedded;
110
+ }
111
+ state.pending = [];
112
+ save();
113
+ const metadata = { provider: resolved.profile.provider, model: resolved.profile.model, dimensions, profileFingerprint: profileFingerprint(resolved.identity), excluded: exclude, embedded: missing.length, reused, diagnostics: { ...diagnostics, corpora: counters } };
114
+ // Keep the legacy export readable; authoritative snapshots remain atomic above.
115
+ const manifest = dimensions ? writeVectorCache(root, storedChunks, vectors, metadata) : { chunks: 0, ...metadata };
89
116
  return { chunks: storedChunks, vectors, manifest, embedding: resolved };
117
+ });
90
118
  }
91
119
 
92
120
  export async function queryDocumentation(root, query, options = {}) {
@@ -113,10 +141,10 @@ export async function queryProject(root, query, options = {}) {
113
141
  catch (error) { if (search === 'vector') throw error; vectorError = error.message; }
114
142
  if (!configured && !vectorError && search === 'vector') throw new Error('Embedding is not configured. Set embedding in project or global configuration to use vector search.');
115
143
  }
116
- if (search !== 'keyword' && configured && corpus.chunks.length) {
144
+ if (search !== 'keyword' && configured) {
117
145
  try {
118
- index = await buildVectorIndex(root, { ...options, chunks: corpus.chunks });
119
- const [queryVector] = await index.embedding.embed([query], 'query');
146
+ index = await buildVectorIndex(root, { ...options, chunks: corpus.chunks, corpora: scope === 'all' ? ['docs', 'code'] : [scope] });
147
+ const [queryVector] = index.chunks.length ? await index.embedding.embed([query], 'query') : [];
120
148
  vector = index.chunks.map((chunk, position) => ({ ...chunk, vectorScore: cosineScore(queryVector, index.vectors, position * index.manifest.dimensions, index.manifest.dimensions) }))
121
149
  .sort((a, b) => b.vectorScore - a.vectorScore || a.id.localeCompare(b.id));
122
150
  } catch (error) {
@@ -0,0 +1,82 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { isValidNormalizedVectorValues, readVectorCache, profileFingerprint } from './store.mjs';
6
+
7
+ // One atomic state file keeps completed snapshots and unfinished batches consistent.
8
+ export async function vectorCacheSession(root, identity, action) {
9
+ const directory = join(root, '.dotdotgod', 'vectors');
10
+ mkdirSync(directory, { recursive: true });
11
+ const lock = join(directory, 'build.lock');
12
+ const token = randomUUID();
13
+ const started = Date.now();
14
+ let waited = false;
15
+ while (true) {
16
+ try {
17
+ const fd = openSync(lock, 'wx');
18
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); } finally { closeSync(fd); }
19
+ break;
20
+ } catch (error) {
21
+ if (error.code !== 'EEXIST') throw error;
22
+ waited = true;
23
+ if (Date.now() - started >= 30000) throw new Error('Vector cache lock timed out; another build may be running or the lock needs inspection.');
24
+ try {
25
+ const owner = JSON.parse(readFileSync(lock, 'utf8'));
26
+ if (!Number.isInteger(owner.pid) || owner.pid < 1) throw new Error('Invalid lock owner.');
27
+ try { process.kill(owner.pid, 0); }
28
+ catch (probe) {
29
+ if (probe.code !== 'ESRCH') throw probe;
30
+ const recovery = `${lock}.recovery`;
31
+ try { mkdirSync(recovery); }
32
+ catch (claim) { if (claim.code === 'EEXIST') { await sleep(50); continue; } throw claim; }
33
+ try {
34
+ const current = JSON.parse(readFileSync(lock, 'utf8'));
35
+ if (current.token === owner.token && current.pid === owner.pid) rmSync(lock);
36
+ } finally { rmSync(recovery, { recursive: true, force: true }); }
37
+ continue;
38
+ }
39
+ } catch (probe) { if (probe.code === 'ENOENT') continue; }
40
+ await sleep(50);
41
+ }
42
+ }
43
+ try {
44
+ const path = join(directory, 'snapshots.json');
45
+ const fingerprint = profileFingerprint(identity);
46
+ let state; let reason = 'missing';
47
+ try {
48
+ state = JSON.parse(readFileSync(path, 'utf8'));
49
+ if (state.schema !== 1) { reason = 'schema-mismatch'; state = null; }
50
+ else if (state.profile !== fingerprint) { reason = 'profile-mismatch'; state = null; }
51
+ else {
52
+ if (state.dimensions !== null && (!Number.isInteger(state.dimensions) || state.dimensions < 1)) throw new Error('Invalid cache dimensions.');
53
+ for (const rows of [state.docs, state.code, state.pending]) {
54
+ if (!Array.isArray(rows) || rows.some((row) => typeof row.chunk?.fingerprint !== 'string' || !Array.isArray(row.vector) || row.vector.length !== state.dimensions || !isValidNormalizedVectorValues(row.vector, state.dimensions))) throw new Error('Invalid cache rows.');
55
+ }
56
+ reason = 'valid';
57
+ }
58
+ } catch (error) { state = null; reason = error.code === 'ENOENT' ? 'missing' : 'corrupt'; }
59
+ if (!state) {
60
+ state = { schema: 1, profile: fingerprint, dimensions: null, docs: [], code: [], pending: [] };
61
+ if (reason === 'missing') {
62
+ const legacyDiagnostics = {};
63
+ const legacy = readVectorCache(root, identity, legacyDiagnostics);
64
+ reason = legacyDiagnostics.reason;
65
+ if (legacy) {
66
+ state.dimensions = legacy.manifest.dimensions;
67
+ legacy.chunks.forEach((chunk, i) => state[chunk.kind === 'symbol' ? 'code' : 'docs'].push({ chunk, vector: Array.from(legacy.vectors.subarray(i * state.dimensions, (i + 1) * state.dimensions)) }));
68
+ reason = 'legacy-migrated';
69
+ }
70
+ }
71
+ }
72
+ const save = () => {
73
+ const temporary = `${path}.tmp-${token}`;
74
+ try { writeFileSync(temporary, JSON.stringify(state)); renameSync(temporary, path); }
75
+ finally { rmSync(temporary, { force: true }); }
76
+ };
77
+ return await action(state, save, { reason, lockWaitMs: waited ? Date.now() - started : 0 });
78
+ } finally {
79
+ try { if (JSON.parse(readFileSync(lock, 'utf8')).token === token) rmSync(lock); }
80
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
81
+ }
82
+ }
@@ -29,20 +29,25 @@ export function vectorCachePaths(root) {
29
29
  return { directory, manifest: join(directory, 'manifest.json'), chunks: join(directory, 'chunks.jsonl'), embeddings: join(directory, 'embeddings.f32') };
30
30
  }
31
31
 
32
- export function readVectorCache(root, identity = null) {
32
+ export function readVectorCache(root, identity = null, diagnostics = {}) {
33
+ diagnostics.reason = 'missing';
33
34
  const paths = vectorCachePaths(root);
34
35
  if (!existsSync(paths.manifest) || !existsSync(paths.chunks) || !existsSync(paths.embeddings)) return null;
35
36
  try {
36
37
  const manifest = JSON.parse(readFileSync(paths.manifest, 'utf8'));
38
+ diagnostics.reason = 'schema-mismatch';
37
39
  if (manifest.schemaVersion !== VECTOR_SCHEMA_VERSION || !Number.isInteger(manifest.dimensions) || manifest.dimensions < 1) return null;
40
+ diagnostics.reason = 'profile-mismatch';
38
41
  if (identity && manifest.profileFingerprint !== profileFingerprint(identity)) return null;
42
+ diagnostics.reason = 'corrupt';
39
43
  const chunks = readFileSync(paths.chunks, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
40
44
  const buffer = readFileSync(paths.embeddings);
41
45
  if (buffer.byteLength !== chunks.length * manifest.dimensions * 4) return null;
42
46
  const vectors = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4);
43
47
  if (!isValidNormalizedVectorValues(vectors, manifest.dimensions)) return null;
48
+ diagnostics.reason = 'valid';
44
49
  return { manifest, chunks, vectors: new Float32Array(vectors) };
45
- } catch { return null; }
50
+ } catch { diagnostics.reason = 'corrupt'; return null; }
46
51
  }
47
52
 
48
53
  function atomicWrite(path, data) {