@geraldmaron/construct 1.0.23 → 1.1.0
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/README.md +0 -2
- package/bin/construct +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
|
@@ -4,31 +4,36 @@
|
|
|
4
4
|
* Stores distilled insights that specialists learn during work:
|
|
5
5
|
* - patterns, anti-patterns, dependency relationships, decisions, insights
|
|
6
6
|
* - each scoped to a role (cx-engineer, cx-architect, etc.)
|
|
7
|
-
* - vector-indexed for semantic search via
|
|
7
|
+
* - vector-indexed for semantic search via embedded LanceDB
|
|
8
8
|
*
|
|
9
9
|
* Storage layout:
|
|
10
10
|
* .cx/observations/index.json — lightweight listing for fast filtering
|
|
11
11
|
* .cx/observations/<id>.json — full observation record
|
|
12
|
-
* .cx/observations/vectors.json — local vector index for semantic search
|
|
13
12
|
*/
|
|
14
13
|
import crypto from 'node:crypto';
|
|
15
14
|
import fs from 'node:fs';
|
|
16
15
|
import path from 'node:path';
|
|
17
|
-
import { cosineSimilarity, rankByBm25 } from './storage/embeddings.mjs';
|
|
18
|
-
import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
|
|
19
16
|
import { embedText as embedTextEngine } from './storage/embeddings-engine.mjs';
|
|
20
17
|
import { VectorClient } from './storage/vector-client.mjs';
|
|
21
|
-
import { withFileLockSync } from './storage/file-lock.mjs';
|
|
22
18
|
import { ensureCxDir } from './project-init-shared.mjs';
|
|
23
19
|
|
|
24
20
|
const OBS_DIR = '.cx/observations';
|
|
25
21
|
const INDEX_FILE = 'index.json';
|
|
26
|
-
const VECTORS_FILE = 'vectors.json';
|
|
27
22
|
const MAX_INDEX = 1000;
|
|
28
23
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
24
|
+
// VectorClient resolves its store from CONSTRUCT_LANCEDB_PATH and otherwise
|
|
25
|
+
// falls back to process.cwd(). Observations are scoped to rootDir, so when no
|
|
26
|
+
// explicit path is configured (tests, unmanaged checkouts) the vector store
|
|
27
|
+
// must follow rootDir — not the caller's cwd — or a reader in a different cwd
|
|
28
|
+
// than the writer sees an empty table. A configured path (managed installs)
|
|
29
|
+
// is left untouched so the home-global store keeps working.
|
|
30
|
+
|
|
31
|
+
function vectorClientFor(rootDir) {
|
|
32
|
+
const env = process.env.CONSTRUCT_LANCEDB_PATH
|
|
33
|
+
? process.env
|
|
34
|
+
: { ...process.env, CONSTRUCT_LANCEDB_PATH: path.join(rootDir, '.cx', 'lancedb') };
|
|
35
|
+
return new VectorClient({ env });
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
export function observationSearchText(obs) {
|
|
34
39
|
return [obs.summary, obs.content, ...(obs.tags || [])].filter(Boolean).join(' ');
|
|
@@ -40,9 +45,6 @@ export function observationContentHash(text) {
|
|
|
40
45
|
const MAX_SUMMARY = 500;
|
|
41
46
|
const MAX_CONTENT = 2000;
|
|
42
47
|
const MAX_TAGS = 10;
|
|
43
|
-
// Size guardrail: surfaces in `construct doctor` when the observation tree
|
|
44
|
-
// exceeds this byte cap. Doesn't drop data — the operator decides whether to
|
|
45
|
-
// run `construct memory consolidate` (which prunes archive) or raise the cap.
|
|
46
48
|
const MAX_BYTES = 50 * 1024 * 1024;
|
|
47
49
|
|
|
48
50
|
const VALID_CATEGORIES = new Set([
|
|
@@ -50,8 +52,6 @@ const VALID_CATEGORIES = new Set([
|
|
|
50
52
|
]);
|
|
51
53
|
|
|
52
54
|
function ensureDir(dir, rootDir = null) {
|
|
53
|
-
// Preserves the construct context invariant: any code that creates a .cx/
|
|
54
|
-
// subdirectory also initializes context.md from the project template.
|
|
55
55
|
if (rootDir) ensureCxDir(rootDir);
|
|
56
56
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
57
57
|
}
|
|
@@ -64,10 +64,6 @@ function indexPath(rootDir) {
|
|
|
64
64
|
return path.join(obsDir(rootDir), INDEX_FILE);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function vectorsPath(rootDir) {
|
|
68
|
-
return path.join(obsDir(rootDir), VECTORS_FILE);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
67
|
function generateId() {
|
|
72
68
|
const ts = Date.now().toString(36);
|
|
73
69
|
const rand = crypto.randomBytes(4).toString('hex');
|
|
@@ -132,30 +128,6 @@ function writeIndex(rootDir, entries) {
|
|
|
132
128
|
return { dropped };
|
|
133
129
|
}
|
|
134
130
|
|
|
135
|
-
function readVectors(rootDir) {
|
|
136
|
-
const p = vectorsPath(rootDir);
|
|
137
|
-
if (!fs.existsSync(p)) return [];
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
140
|
-
} catch {
|
|
141
|
-
return [];
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function writeVectors(rootDir, records) {
|
|
146
|
-
ensureDir(obsDir(rootDir), rootDir);
|
|
147
|
-
const dropped = Math.max(0, records.length - MAX_INDEX);
|
|
148
|
-
if (dropped > 0) {
|
|
149
|
-
logCapDrop(rootDir, 'observation-vectors', dropped, records.length);
|
|
150
|
-
}
|
|
151
|
-
fs.writeFileSync(vectorsPath(rootDir), JSON.stringify(records, null, 2) + '\n');
|
|
152
|
-
return { dropped };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Add a new observation and vectorize it for semantic search.
|
|
157
|
-
* Uses SQL + neural embeddings when available, falls back to local JSON + hashing.
|
|
158
|
-
*/
|
|
159
131
|
export async function addObservation(rootDir, {
|
|
160
132
|
role = 'unknown',
|
|
161
133
|
category = 'insight',
|
|
@@ -175,10 +147,9 @@ export async function addObservation(rootDir, {
|
|
|
175
147
|
const clampedSummary = clamp(String(summary), MAX_SUMMARY);
|
|
176
148
|
const clampedContent = clamp(String(content), MAX_CONTENT);
|
|
177
149
|
const clampedTags = (Array.isArray(tags) ? tags : []).slice(0, MAX_TAGS).map(String);
|
|
178
|
-
// Structured metadata: opt-in. Capped at 2 KB stringified so a misuse can't
|
|
179
|
-
// bloat the per-id JSON. Plain object only; rejects arrays and functions.
|
|
180
150
|
const clampedExtras = sanitizeExtras(extras);
|
|
181
151
|
|
|
152
|
+
const effectiveProject = project ? String(project) : rootDir.split('/').pop();
|
|
182
153
|
const record = {
|
|
183
154
|
id,
|
|
184
155
|
role: String(role),
|
|
@@ -186,7 +157,7 @@ export async function addObservation(rootDir, {
|
|
|
186
157
|
summary: clampedSummary,
|
|
187
158
|
content: clampedContent,
|
|
188
159
|
tags: clampedTags,
|
|
189
|
-
project:
|
|
160
|
+
project: effectiveProject,
|
|
190
161
|
confidence: Math.max(0, Math.min(1, Number(confidence) || 0.8)),
|
|
191
162
|
source: source || null,
|
|
192
163
|
gitSha: gitSha ? String(gitSha).slice(0, 40) : null,
|
|
@@ -195,37 +166,30 @@ export async function addObservation(rootDir, {
|
|
|
195
166
|
updatedAt: now,
|
|
196
167
|
};
|
|
197
168
|
|
|
198
|
-
// Lock the per-store index so concurrent writers (CLI + hooks) cannot
|
|
199
|
-
// corrupt the index/vectors JSON pair. The observation JSON file is
|
|
200
|
-
// unique-per-id and doesn't need the lock; the shared structures do.
|
|
201
169
|
ensureDir(obsDir(rootDir), rootDir);
|
|
202
170
|
fs.writeFileSync(
|
|
203
171
|
path.join(obsDir(rootDir), `${id}.json`),
|
|
204
172
|
JSON.stringify(record, null, 2) + '\n',
|
|
205
173
|
);
|
|
206
174
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
project: record.project,
|
|
216
|
-
createdAt: now,
|
|
217
|
-
});
|
|
218
|
-
indexDropped = writeIndex(rootDir, index).dropped;
|
|
175
|
+
const index = readIndex(rootDir);
|
|
176
|
+
index.unshift({
|
|
177
|
+
id,
|
|
178
|
+
role: record.role,
|
|
179
|
+
category: record.category,
|
|
180
|
+
summary: record.summary,
|
|
181
|
+
project: record.project,
|
|
182
|
+
createdAt: now,
|
|
219
183
|
});
|
|
184
|
+
const indexDropped = writeIndex(rootDir, index).dropped;
|
|
220
185
|
if (indexDropped > 0) {
|
|
221
186
|
record.capDropped = indexDropped;
|
|
222
187
|
process.stderr.write(`[observation-store] observation cap reached: ${indexDropped} oldest entries evicted (cap=${MAX_INDEX})\n`);
|
|
223
188
|
}
|
|
224
189
|
|
|
225
|
-
// Try SQL storage with neural embeddings
|
|
226
190
|
try {
|
|
227
|
-
const client =
|
|
228
|
-
if (await client.isHealthy()
|
|
191
|
+
const client = vectorClientFor(rootDir);
|
|
192
|
+
if (await client.isHealthy()) {
|
|
229
193
|
const searchText = observationSearchText(record);
|
|
230
194
|
const { embedding, model } = await embedTextEngine(searchText);
|
|
231
195
|
await client.storeObservation({
|
|
@@ -234,34 +198,15 @@ export async function addObservation(rootDir, {
|
|
|
234
198
|
contentHash: observationContentHash(searchText),
|
|
235
199
|
model,
|
|
236
200
|
});
|
|
237
|
-
await client.close();
|
|
238
|
-
return record;
|
|
239
201
|
}
|
|
240
202
|
await client.close();
|
|
241
203
|
} catch (err) {
|
|
242
|
-
|
|
243
|
-
// network) must not look identical to "no backend configured" — surface it, then
|
|
244
|
-
// preserve the observation in the local index so the write never silently vanishes.
|
|
245
|
-
|
|
246
|
-
process.stderr.write(`[observation-store] SQL store failed; kept observation in local index: ${err?.message || err}\n`);
|
|
204
|
+
process.stderr.write(`[observation-store] vector store failed; kept observation in local index: ${err?.message || err}\n`);
|
|
247
205
|
}
|
|
248
206
|
|
|
249
|
-
// Fallback: local JSON vector index with hashing-bow-v1.
|
|
250
|
-
const searchText = [record.summary, record.content, ...record.tags].filter(Boolean).join(' ');
|
|
251
|
-
const embedding = embedText(searchText);
|
|
252
|
-
withFileLockSync(vectorsPath(rootDir), () => {
|
|
253
|
-
const vectors = readVectors(rootDir);
|
|
254
|
-
vectors.push({ id, embedding, role: record.role, category: record.category, project: record.project });
|
|
255
|
-
writeVectors(rootDir, vectors.slice(-MAX_INDEX));
|
|
256
|
-
});
|
|
257
|
-
|
|
258
207
|
return record;
|
|
259
208
|
}
|
|
260
209
|
|
|
261
|
-
/**
|
|
262
|
-
* Search observations using SQL + neural embeddings when available,
|
|
263
|
-
* falling back to hybrid cosine + BM25 on local JSON.
|
|
264
|
-
*/
|
|
265
210
|
export async function searchObservations(rootDir, query, {
|
|
266
211
|
role = null,
|
|
267
212
|
category = null,
|
|
@@ -270,16 +215,15 @@ export async function searchObservations(rootDir, query, {
|
|
|
270
215
|
} = {}) {
|
|
271
216
|
if (!query) return [];
|
|
272
217
|
|
|
273
|
-
// Try SQL + neural embeddings first
|
|
274
218
|
try {
|
|
275
|
-
const client =
|
|
276
|
-
if (await client.isHealthy()
|
|
219
|
+
const client = vectorClientFor(rootDir);
|
|
220
|
+
if (await client.isHealthy()) {
|
|
277
221
|
const { embedding } = await embedTextEngine(String(query));
|
|
278
222
|
const results = await client.searchObservations({
|
|
279
223
|
project: project || rootDir.split('/').pop(),
|
|
280
224
|
queryEmbedding: embedding,
|
|
281
225
|
limit,
|
|
282
|
-
minSimilarity: 0.
|
|
226
|
+
minSimilarity: 0.01,
|
|
283
227
|
role,
|
|
284
228
|
category,
|
|
285
229
|
});
|
|
@@ -288,6 +232,7 @@ export async function searchObservations(rootDir, query, {
|
|
|
288
232
|
id: r.id,
|
|
289
233
|
role: r.role,
|
|
290
234
|
category: r.category,
|
|
235
|
+
project: r.project,
|
|
291
236
|
summary: r.summary,
|
|
292
237
|
content: r.content,
|
|
293
238
|
tags: r.tags || [],
|
|
@@ -299,65 +244,12 @@ export async function searchObservations(rootDir, query, {
|
|
|
299
244
|
}
|
|
300
245
|
await client.close();
|
|
301
246
|
} catch {
|
|
302
|
-
//
|
|
247
|
+
// If Vector search fails, return empty since fallback is removed.
|
|
303
248
|
}
|
|
304
249
|
|
|
305
|
-
|
|
306
|
-
const queryEmbedding = embedText(String(query));
|
|
307
|
-
let vectors = readVectors(rootDir);
|
|
308
|
-
|
|
309
|
-
if (role) vectors = vectors.filter((v) => v.role === role);
|
|
310
|
-
if (category) vectors = vectors.filter((v) => v.category === category);
|
|
311
|
-
if (project) vectors = vectors.filter((v) => v.project === project);
|
|
312
|
-
|
|
313
|
-
// Cosine pass
|
|
314
|
-
const cosineScored = vectors
|
|
315
|
-
.map((v) => ({
|
|
316
|
-
id: v.id,
|
|
317
|
-
score: cosineSimilarity(queryEmbedding, v.embedding || []),
|
|
318
|
-
}))
|
|
319
|
-
.filter(({ score }) => score > 0.05);
|
|
320
|
-
|
|
321
|
-
// Load full records for BM25 pass
|
|
322
|
-
const candidateIds = new Set(cosineScored.map((v) => v.id));
|
|
323
|
-
const recentIndex = readIndex(rootDir);
|
|
324
|
-
const filtered = role ? recentIndex.filter((e) => e.role === role) : recentIndex;
|
|
325
|
-
const filteredCat = category ? filtered.filter((e) => e.category === category) : filtered;
|
|
326
|
-
const filteredProj = project ? filteredCat.filter((e) => e.project === project) : filteredCat;
|
|
327
|
-
for (const entry of filteredProj.slice(0, Math.max(limit * 3, 30))) candidateIds.add(entry.id);
|
|
328
|
-
|
|
329
|
-
const candidateRecords = [...candidateIds]
|
|
330
|
-
.map((id) => getObservation(rootDir, id))
|
|
331
|
-
.filter(Boolean)
|
|
332
|
-
.map((r) => ({ ...r, text: [r.summary, r.content, ...(r.tags || [])].filter(Boolean).join(' ') }));
|
|
333
|
-
|
|
334
|
-
const bm25Scored = rankByBm25(candidateRecords, query, { limit: limit * 2 });
|
|
335
|
-
|
|
336
|
-
// Merge: highest score wins per id
|
|
337
|
-
const scoreMap = new Map();
|
|
338
|
-
for (const { id, score } of cosineScored) {
|
|
339
|
-
scoreMap.set(id, score);
|
|
340
|
-
}
|
|
341
|
-
for (const item of bm25Scored) {
|
|
342
|
-
const prev = scoreMap.get(item.id) || 0;
|
|
343
|
-
const bm25Max = bm25Scored[0]?.score || 1;
|
|
344
|
-
const normalized = Math.min(item.score / bm25Max, 1);
|
|
345
|
-
scoreMap.set(item.id, Math.max(prev, normalized));
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return [...scoreMap.entries()]
|
|
349
|
-
.sort((a, b) => b[1] - a[1])
|
|
350
|
-
.slice(0, limit)
|
|
351
|
-
.map(([id, score]) => {
|
|
352
|
-
const record = getObservation(rootDir, id);
|
|
353
|
-
return record ? { ...record, score } : null;
|
|
354
|
-
})
|
|
355
|
-
.filter(Boolean);
|
|
250
|
+
return [];
|
|
356
251
|
}
|
|
357
252
|
|
|
358
|
-
/**
|
|
359
|
-
* List observations from the index with optional filters.
|
|
360
|
-
*/
|
|
361
253
|
export function listObservations(rootDir, {
|
|
362
254
|
role = null,
|
|
363
255
|
category = null,
|
|
@@ -371,9 +263,6 @@ export function listObservations(rootDir, {
|
|
|
371
263
|
return entries.slice(0, limit);
|
|
372
264
|
}
|
|
373
265
|
|
|
374
|
-
/**
|
|
375
|
-
* Load a full observation record by ID.
|
|
376
|
-
*/
|
|
377
266
|
export function getObservation(rootDir, id) {
|
|
378
267
|
const filePath = path.join(obsDir(rootDir), `${id}.json`);
|
|
379
268
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -384,31 +273,17 @@ export function getObservation(rootDir, id) {
|
|
|
384
273
|
}
|
|
385
274
|
}
|
|
386
275
|
|
|
387
|
-
/**
|
|
388
|
-
* Delete an observation by id.
|
|
389
|
-
*/
|
|
390
276
|
export function deleteObservation(rootDir, id) {
|
|
391
277
|
const filePath = path.join(obsDir(rootDir), `${id}.json`);
|
|
392
278
|
if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true });
|
|
393
279
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (filtered.length !== index.length) writeIndex(rootDir, filtered);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
withFileLockSync(vectorsPath(rootDir), () => {
|
|
401
|
-
const vectors = readVectors(rootDir);
|
|
402
|
-
const filteredVec = vectors.filter((v) => v.id !== id);
|
|
403
|
-
if (filteredVec.length !== vectors.length) writeVectors(rootDir, filteredVec);
|
|
404
|
-
});
|
|
280
|
+
const index = readIndex(rootDir);
|
|
281
|
+
const filtered = index.filter((e) => e.id !== id);
|
|
282
|
+
if (filtered.length !== index.length) writeIndex(rootDir, filtered);
|
|
405
283
|
|
|
406
284
|
return true;
|
|
407
285
|
}
|
|
408
286
|
|
|
409
|
-
/**
|
|
410
|
-
* Count observations, optionally filtered.
|
|
411
|
-
*/
|
|
412
287
|
export function countObservations(rootDir, { role = null, project = null } = {}) {
|
|
413
288
|
let entries = readIndex(rootDir);
|
|
414
289
|
if (role) entries = entries.filter((e) => e.role === role);
|
|
@@ -416,9 +291,6 @@ export function countObservations(rootDir, { role = null, project = null } = {})
|
|
|
416
291
|
return entries.length;
|
|
417
292
|
}
|
|
418
293
|
|
|
419
|
-
// On-disk byte size of the observation tree (live + archive) at rootDir.
|
|
420
|
-
// Backs the `Observation size` doctor surface so size pressure shows up
|
|
421
|
-
// independently of count-based caps.
|
|
422
294
|
export function getObservationsSize(rootDir) {
|
|
423
295
|
const root = obsDir(rootDir);
|
|
424
296
|
if (!fs.existsSync(root)) return 0;
|
|
@@ -439,4 +311,4 @@ export function getObservationsSize(rootDir) {
|
|
|
439
311
|
export function checkObservationsSize(rootDir, { cap = MAX_BYTES } = {}) {
|
|
440
312
|
const size = getObservationsSize(rootDir);
|
|
441
313
|
return { size, cap, ok: size <= cap };
|
|
442
|
-
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ollama/capability-store.mjs — durable record of local-model agentic coherence.
|
|
3
|
+
*
|
|
4
|
+
* `construct doctor --probe-local` measures whether each Ollama model can drive
|
|
5
|
+
* the agentic loop or collapses into word salad, and writes the verdict here.
|
|
6
|
+
* sync and doctor READ this store (they never probe — probing loads each model and
|
|
7
|
+
* costs minutes) to skip Modelfile provisioning for collapsed models, inform the
|
|
8
|
+
* MCP-trim decision, and warn when a configured default model cannot do agentic
|
|
9
|
+
* work. Verdicts are keyed by the model's `ollama list` digest so a re-pulled tag
|
|
10
|
+
* goes stale and is re-probed rather than trusted blindly.
|
|
11
|
+
*
|
|
12
|
+
* Lives at ~/.cx/local-models.json because local-model capability is a property of
|
|
13
|
+
* the machine's Ollama install, not of any one project.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { cxDir } from "../paths.mjs";
|
|
18
|
+
|
|
19
|
+
const STORE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export function localModelsPath() {
|
|
22
|
+
return path.join(cxDir(), "local-models.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readCapabilityStore() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(localModelsPath(), "utf8"));
|
|
28
|
+
if (raw && raw.version === STORE_VERSION && raw.models && typeof raw.models === "object") return raw;
|
|
29
|
+
} catch { /* missing or malformed — treat as empty */ }
|
|
30
|
+
return { version: STORE_VERSION, models: {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Persist one probe result. probeResult is the object probeAgenticCoherence
|
|
34
|
+
// returns ({ ok, coherent, repeatRatio, uniqueRatio, ... }); a probe that errored
|
|
35
|
+
// (ok=false) is not recorded, so a transient failure never overwrites a real verdict.
|
|
36
|
+
|
|
37
|
+
export function recordProbeResult(model, probeResult, digest = null) {
|
|
38
|
+
if (!probeResult || probeResult.ok !== true) return readCapabilityStore();
|
|
39
|
+
const store = readCapabilityStore();
|
|
40
|
+
store.models[model] = {
|
|
41
|
+
verdict: probeResult.coherent ? "COHERENT" : "COLLAPSED",
|
|
42
|
+
coherent: probeResult.coherent === true,
|
|
43
|
+
calledTool: probeResult.calledTool === true,
|
|
44
|
+
repeatRatio: probeResult.repeatRatio ?? null,
|
|
45
|
+
uniqueRatio: probeResult.uniqueRatio ?? null,
|
|
46
|
+
digest: digest ?? null,
|
|
47
|
+
probedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
const dir = path.dirname(localModelsPath());
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(localModelsPath(), `${JSON.stringify(store, null, 2)}\n`);
|
|
52
|
+
return store;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getModelVerdict(model) {
|
|
56
|
+
return readCapabilityStore().models[model] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A verdict is stale when no digest is recorded for it or the current digest
|
|
60
|
+
// differs from the one it was measured against — callers re-probe rather than
|
|
61
|
+
// trust a verdict about different model bytes.
|
|
62
|
+
|
|
63
|
+
export function isVerdictStale(model, currentDigest) {
|
|
64
|
+
const entry = getModelVerdict(model);
|
|
65
|
+
if (!entry) return true;
|
|
66
|
+
if (!currentDigest || !entry.digest) return true;
|
|
67
|
+
return entry.digest !== currentDigest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// COLLAPSED only counts when the digest still matches: a stale or unknown verdict
|
|
71
|
+
// must not strand a model. Callers warn-and-allow-override rather than hide.
|
|
72
|
+
|
|
73
|
+
export function isKnownCollapsed(model, currentDigest = null) {
|
|
74
|
+
const entry = getModelVerdict(model);
|
|
75
|
+
if (!entry) return false;
|
|
76
|
+
if (currentDigest && isVerdictStale(model, currentDigest)) return false;
|
|
77
|
+
return entry.verdict === "COLLAPSED";
|
|
78
|
+
}
|