@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 +1 -1
- package/src/commands/query.mjs +36 -8
- package/src/query/cache-session.mjs +82 -0
- package/src/query/store.mjs +7 -2
package/package.json
CHANGED
package/src/commands/query.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/query/store.mjs
CHANGED
|
@@ -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) {
|