mindforge-cc 11.9.1 → 11.9.2
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/.agent/mindforge/consult.md +1 -1
- package/.agent/mindforge/cost-report.md +1 -1
- package/.claude/commands/mindforge/consult.md +1 -1
- package/.claude/commands/mindforge/cost-report.md +1 -1
- package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
- package/.mindforge/config.json +3 -3
- package/.mindforge/engine/cost-tracking/router.md +1 -1
- package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
- package/.mindforge/personas/cost-optimizer.md +2 -2
- package/.mindforge/personas/multi-model-bridge.md +1 -1
- package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
- package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
- package/CHANGELOG.md +208 -0
- package/MINDFORGE.md +3 -3
- package/README.md +50 -2
- package/RELEASENOTES.md +53 -0
- package/bin/autonomous/audit-writer.js +48 -33
- package/bin/dashboard/api-router.js +11 -10
- package/bin/dashboard/error-response.js +44 -0
- package/bin/dashboard/frontend/index.html +20 -3
- package/bin/dashboard/metrics-aggregator.js +29 -8
- package/bin/dashboard/revops-api.js +12 -2
- package/bin/dashboard/server.js +85 -5
- package/bin/dashboard/temporal-api.js +11 -5
- package/bin/engine/remediation-engine.js +12 -1
- package/bin/engine/temporal-hub.js +41 -9
- package/bin/eval/eval-harness.js +212 -1
- package/bin/eval/golden-set-retrieval.json +9 -0
- package/bin/governance/policy-engine.js +8 -0
- package/bin/hindsight-injector.js +8 -2
- package/bin/hooks/instinct-capture-hook.js +7 -1
- package/bin/learning/instinct-cli.js +7 -24
- package/bin/memory/knowledge-capture.js +23 -3
- package/bin/memory/knowledge-graph.js +70 -31
- package/bin/memory/vector-hub.js +304 -31
- package/bin/mindforge-cli.js +43 -11
- package/bin/models/cost-tracker.js +22 -23
- package/bin/models/model-router.js +28 -7
- package/bin/models/usage-record.js +71 -0
- package/bin/utils/file-lock.js +106 -0
- package/bin/utils/mindforge-params.js +124 -0
- package/bin/validate-config.js +34 -16
- package/changelogs/v11.9.2.md +209 -0
- package/docs/References/config-reference.md +73 -14
- package/docs/sdk-reference.md +1 -1
- package/package.json +4 -2
|
@@ -17,6 +17,7 @@ const crypto = require('crypto');
|
|
|
17
17
|
const Store = require('./knowledge-store');
|
|
18
18
|
const Embedder = require('./embedding-engine');
|
|
19
19
|
const EISClient = require('./eis-client');
|
|
20
|
+
const { withFileLock } = require('../utils/file-lock');
|
|
20
21
|
|
|
21
22
|
// ── Edge Types ────────────────────────────────────────────────────────────────
|
|
22
23
|
const EDGE_TYPES = Object.freeze({
|
|
@@ -59,6 +60,19 @@ function ensureDir(dir) {
|
|
|
59
60
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* LOCK-01: runs fn() holding the exclusive lock on graph-edges.jsonl.
|
|
65
|
+
* Every edge write below is a READ-modify-APPEND (readAllEdges -> mutate -> append),
|
|
66
|
+
* so the read MUST be inside the same critical section as the append or concurrent
|
|
67
|
+
* writers lose updates. Measured over 4 runs of 8 processes x 20 reinforceEdge calls:
|
|
68
|
+
* unlocked lost 93-129 of 160 increments (final traversal_count 31-67); locked lost 0
|
|
69
|
+
* of 160 in every run.
|
|
70
|
+
* Re-entrant: applyDecay() holds this lock and calls deprecateEdge(), which re-takes it.
|
|
71
|
+
*/
|
|
72
|
+
function withEdgesLock(fn, opts = {}) {
|
|
73
|
+
return withFileLock(getPaths().EDGES_PATH, fn, { maxTries: 150, label: 'graph-edges', ...opts });
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
// ── Edge CRUD ─────────────────────────────────────────────────────────────────
|
|
63
77
|
|
|
64
78
|
/**
|
|
@@ -108,8 +122,12 @@ function addEdge(edge) {
|
|
|
108
122
|
const payload = JSON.stringify({ ...record, checksum: '' });
|
|
109
123
|
record.checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
// LOCK-01: serialise against the read-modify-append paths below, whose readAllEdges()
|
|
126
|
+
// snapshot would otherwise go stale if this append landed inside their window.
|
|
127
|
+
withEdgesLock(() => {
|
|
128
|
+
fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(record) + '\n');
|
|
129
|
+
invalidateAdjacencyCache();
|
|
130
|
+
});
|
|
113
131
|
return id;
|
|
114
132
|
}
|
|
115
133
|
|
|
@@ -144,19 +162,23 @@ function readAllEdges() {
|
|
|
144
162
|
*/
|
|
145
163
|
function deprecateEdge(edgeId, reason) {
|
|
146
164
|
const paths = getPaths();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
165
|
+
// LOCK-01: read-modify-append is ONE critical section — the readAllEdges() snapshot
|
|
166
|
+
// must not be able to go stale before our append lands.
|
|
167
|
+
return withEdgesLock(() => {
|
|
168
|
+
const edges = readAllEdges();
|
|
169
|
+
const edge = edges.find(e => e.id === edgeId);
|
|
170
|
+
if (!edge) return;
|
|
171
|
+
|
|
172
|
+
const deprecated = {
|
|
173
|
+
...edge,
|
|
174
|
+
deprecated: true,
|
|
175
|
+
deprecated_reason: reason,
|
|
176
|
+
deprecated_at: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(deprecated) + '\n');
|
|
180
|
+
invalidateAdjacencyCache();
|
|
181
|
+
});
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/**
|
|
@@ -165,22 +187,26 @@ function deprecateEdge(edgeId, reason) {
|
|
|
165
187
|
*/
|
|
166
188
|
function reinforceEdge(edgeId) {
|
|
167
189
|
const paths = getPaths();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
// LOCK-01: weight += 0.1 and traversal_count += 1 are read-modify-writes. Unlocked,
|
|
191
|
+
// 8 concurrent processes x 20 reinforcements lost 93-129 of 160 increments (4 runs).
|
|
192
|
+
return withEdgesLock(() => {
|
|
193
|
+
const edges = readAllEdges();
|
|
194
|
+
const edge = edges.find(e => e.id === edgeId);
|
|
195
|
+
if (!edge) return;
|
|
196
|
+
|
|
197
|
+
const reinforced = {
|
|
198
|
+
...edge,
|
|
199
|
+
weight: Math.min(2.0, edge.weight + 0.1),
|
|
200
|
+
last_traversed: new Date().toISOString(),
|
|
201
|
+
traversal_count: (edge.traversal_count || 0) + 1,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Recompute checksum
|
|
205
|
+
const payload = JSON.stringify({ ...reinforced, checksum: '' });
|
|
206
|
+
reinforced.checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
|
207
|
+
|
|
208
|
+
fs.appendFileSync(paths.EDGES_PATH, JSON.stringify(reinforced) + '\n');
|
|
209
|
+
});
|
|
184
210
|
}
|
|
185
211
|
|
|
186
212
|
// ── Adjacency Index (with persistent cache) ─────────────────────────────────
|
|
@@ -489,6 +515,19 @@ function autoCreateEdges(entryId, vectors) {
|
|
|
489
515
|
* @returns {{ decayed: number, pruned: number }}
|
|
490
516
|
*/
|
|
491
517
|
function applyDecay() {
|
|
518
|
+
// LOCK-01: the ENTIRE pass is one read-modify-write — one readAllEdges() snapshot
|
|
519
|
+
// drives appends at the bottom of this loop AND nested deprecateEdge() calls. The
|
|
520
|
+
// nested calls re-take this same lock; withFileLock is re-entrant for that reason.
|
|
521
|
+
// staleMs is raised far above the 10s module default. This pass is O(n^2) — the
|
|
522
|
+
// nested deprecateEdge() re-reads the whole edges file per prune — and was measured
|
|
523
|
+
// holding this lock 9.1s at 2,000 edges and 14.4s at 2,500. Past STALE_MS another
|
|
524
|
+
// process classifies this LIVE lock as orphaned, unlinks it, and enters the critical
|
|
525
|
+
// section alongside us, so mutual exclusion would silently vanish on any real-sized
|
|
526
|
+
// graph — the same "looks guarded, isn't" failure this item exists to remove.
|
|
527
|
+
return withEdgesLock(() => applyDecayLocked(), { staleMs: 900000 });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function applyDecayLocked() {
|
|
492
531
|
const edges = readAllEdges();
|
|
493
532
|
const now = Date.now();
|
|
494
533
|
let decayed = 0;
|
package/bin/memory/vector-hub.js
CHANGED
|
@@ -9,6 +9,135 @@ const crypto = require('crypto');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
|
|
12
|
+
// ── FTS retrieval (FTS-01) ───────────────────────────────────────────────────
|
|
13
|
+
// A MATCH argument is an FTS *query expression*, not a literal. Wrapping the
|
|
14
|
+
// whole user query in double quotes made it ONE phrase, so a multi-word query
|
|
15
|
+
// only matched documents holding those exact ADJACENT words. Default behaviour
|
|
16
|
+
// is now search-box semantics: tokenise, drop FTS operator keywords, quote each
|
|
17
|
+
// term (which neutralises every FTS metacharacter) and OR the results together.
|
|
18
|
+
// Callers that genuinely need adjacency pass { phrase: true }.
|
|
19
|
+
//
|
|
20
|
+
// The OR alone is NOT the fix. FTS4 has no ranker, so the first `limit` rows
|
|
21
|
+
// are docid order over documents containing ANY term ("how", "the", "work") and
|
|
22
|
+
// measured mean recall@10 stays 0.0000. Ranking is what recovers it: tf-idf
|
|
23
|
+
// scored in JS from FTS4's matchinfo('pcnx') blob. Measured via
|
|
24
|
+
// `npm run eval:retrieval` on the repo doc corpus (517 docs, 10 golden
|
|
25
|
+
// queries): recall@10 0.0000 -> 0.6417, nDCG@10 0.0000 -> 0.5698.
|
|
26
|
+
const FTS_OPERATOR_WORDS = new Set(['and', 'or', 'not', 'near']);
|
|
27
|
+
const FTS_MAX_TERMS = 32;
|
|
28
|
+
const FTS_DEFAULT_LIMIT = 10;
|
|
29
|
+
const FTS_MAX_LIMIT = 100;
|
|
30
|
+
// Candidate rows pulled PER TERM before ranking. Bounds the work done for a very
|
|
31
|
+
// common term; see _rankedFtsSearch for why the pool is per term and not per
|
|
32
|
+
// query.
|
|
33
|
+
const FTS_RANK_POOL = 2000;
|
|
34
|
+
// BM25-style term-frequency saturation. matchinfo('pcnx') carries no document
|
|
35
|
+
// length, so a raw term count would systematically favour long documents;
|
|
36
|
+
// saturating tf at tf/(tf + k) keeps a repeated term ranked above a single
|
|
37
|
+
// occurrence without letting file size dominate the score.
|
|
38
|
+
const FTS_TF_SATURATION = 1.2;
|
|
39
|
+
// The only tables a ranked search may touch, with each FTS table's per-column
|
|
40
|
+
// relevance weights in DECLARED column order. _rankedFtsSearch interpolates
|
|
41
|
+
// these identifiers into SQL (SQLite cannot bind an identifier), so they are
|
|
42
|
+
// allowlisted here rather than trusted — no caller string can reach that SQL.
|
|
43
|
+
// A hit in the identifier column is a title match and is worth more than one in
|
|
44
|
+
// the body; traces_search declares `id` notindexed so it can never produce a hit
|
|
45
|
+
// at all, and weight 0 documents that rather than implying otherwise.
|
|
46
|
+
const FTS_SEARCH_TABLES = {
|
|
47
|
+
traces: { ftsTable: 'traces_search', columnWeights: [0, 1, 1, 1] }, // id, trace_id, content, agent
|
|
48
|
+
knowledge: { ftsTable: 'knowledge_search', columnWeights: [3, 1, 1] }, // id, content, tags
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tokenise a raw user query into individual FTS4 MATCH expressions.
|
|
53
|
+
* @param {string} rawQuery - MUST be a string; anything else is a caller bug
|
|
54
|
+
* @param {{phrase?: boolean}} [opts] - phrase:true yields a single
|
|
55
|
+
* exact-adjacency phrase expression instead of one expression per term
|
|
56
|
+
* @returns {string[]} MATCH expressions; empty when the query holds no term
|
|
57
|
+
* @throws {TypeError} when rawQuery is not a string
|
|
58
|
+
*/
|
|
59
|
+
function buildFtsTerms(rawQuery, opts = {}) {
|
|
60
|
+
// Reject a non-string LOUDLY. Stringifying would turn an accidentally-passed
|
|
61
|
+
// options object into "[object Object]" and silently search for that literal —
|
|
62
|
+
// a wrong answer that looks like a working search. bin/engine/
|
|
63
|
+
// remediation-engine.js did exactly that. Failing at the boundary is honest.
|
|
64
|
+
if (typeof rawQuery !== 'string') {
|
|
65
|
+
const got = rawQuery === null ? 'null' : typeof rawQuery;
|
|
66
|
+
throw new TypeError(`[VectorHub] search query must be a string, received ${got}`);
|
|
67
|
+
}
|
|
68
|
+
if (opts.phrase) {
|
|
69
|
+
// FTS query syntax has no escape for a double quote inside a phrase, so
|
|
70
|
+
// strip them rather than emit an unparseable expression.
|
|
71
|
+
const phrase = rawQuery.replace(/"/g, ' ').trim();
|
|
72
|
+
return phrase ? [`"${phrase}"`] : [];
|
|
73
|
+
}
|
|
74
|
+
const terms = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
// Split on every character that is not a letter, digit or underscore: the
|
|
77
|
+
// resulting tokens cannot contain an FTS metacharacter, so quoting is total.
|
|
78
|
+
for (const token of rawQuery.split(/[^\p{L}\p{N}_]+/u)) {
|
|
79
|
+
if (!token) continue;
|
|
80
|
+
const lower = token.toLowerCase();
|
|
81
|
+
if (FTS_OPERATOR_WORDS.has(lower) || seen.has(lower)) continue;
|
|
82
|
+
seen.add(lower);
|
|
83
|
+
terms.push(`"${token}"`);
|
|
84
|
+
if (terms.length >= FTS_MAX_TERMS) break;
|
|
85
|
+
}
|
|
86
|
+
return terms;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Clamp a caller-supplied row limit into a sane bounded integer.
|
|
91
|
+
* @param {*} limit
|
|
92
|
+
* @returns {number} an integer in [1, FTS_MAX_LIMIT]
|
|
93
|
+
*/
|
|
94
|
+
function clampFtsLimit(limit) {
|
|
95
|
+
const n = parseInt(limit, 10);
|
|
96
|
+
if (!Number.isFinite(n) || n < 1) return FTS_DEFAULT_LIMIT;
|
|
97
|
+
return Math.min(n, FTS_MAX_LIMIT);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Score one FTS4 matchinfo('pcnx') blob as a weighted tf-idf sum over every
|
|
102
|
+
* phrase/column pair.
|
|
103
|
+
*
|
|
104
|
+
* Blob layout (little-endian uint32): [p, c, n, then 3 values per (phrase,
|
|
105
|
+
* column) pair — hits in THIS row, hits in ALL rows, number of documents with at
|
|
106
|
+
* least one hit]. tf is saturated (see FTS_TF_SATURATION) because 'pcnx' carries
|
|
107
|
+
* no document length to normalise by; idf is the BM25 form clamped at 0, so a
|
|
108
|
+
* term present in nearly every document can never drive a score negative.
|
|
109
|
+
* @param {Uint8Array|Buffer|null} blob - value of matchinfo(<table>, 'pcnx')
|
|
110
|
+
* @param {number[]} [columnWeights] - per-column multipliers in declared column
|
|
111
|
+
* order; missing entries default to 1
|
|
112
|
+
* @returns {number} non-negative relevance score; 0 when the blob is unusable
|
|
113
|
+
*/
|
|
114
|
+
function scoreMatchInfo(blob, columnWeights) {
|
|
115
|
+
if (!blob || typeof blob.length !== 'number' || blob.length < 12) return 0;
|
|
116
|
+
const buf = Buffer.from(blob);
|
|
117
|
+
const u32 = (i) => buf.readUInt32LE(i * 4);
|
|
118
|
+
const phrases = u32(0);
|
|
119
|
+
const columns = u32(1);
|
|
120
|
+
const totalRows = u32(2);
|
|
121
|
+
if (!phrases || !columns) return 0;
|
|
122
|
+
let score = 0;
|
|
123
|
+
for (let p = 0; p < phrases; p++) {
|
|
124
|
+
for (let c = 0; c < columns; c++) {
|
|
125
|
+
const base = 3 + 3 * (p * columns + c);
|
|
126
|
+
// Truncated blob: return what was scored rather than read past the end.
|
|
127
|
+
if ((base + 3) * 4 > buf.length) return score;
|
|
128
|
+
const hitsThisRow = u32(base);
|
|
129
|
+
if (!hitsThisRow) continue;
|
|
130
|
+
const weight = (columnWeights && columnWeights[c] !== undefined) ? columnWeights[c] : 1;
|
|
131
|
+
if (weight === 0) continue;
|
|
132
|
+
const docsWithHits = u32(base + 2);
|
|
133
|
+
const tf = hitsThisRow / (hitsThisRow + FTS_TF_SATURATION);
|
|
134
|
+
const idf = Math.max(0, Math.log((totalRows - docsWithHits + 0.5) / (docsWithHits + 0.5)));
|
|
135
|
+
score += weight * tf * idf;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return score;
|
|
139
|
+
}
|
|
140
|
+
|
|
12
141
|
/**
|
|
13
142
|
* VectorHub — Unified Persistence Layer
|
|
14
143
|
* Traces, remediations, skills, knowledge, and graph edges.
|
|
@@ -203,10 +332,22 @@ class VectorHub {
|
|
|
203
332
|
|
|
204
333
|
// ── FTS4 Virtual Tables (FTS4 is available in all sql.js builds) ────────
|
|
205
334
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
335
|
+
// FTS-01: traces_search must be keyed on the traces PRIMARY KEY (id), not
|
|
336
|
+
// trace_id. With a trace_id key every new span in a trace DELETEd its
|
|
337
|
+
// siblings' index rows, so only the last span per trace stayed searchable
|
|
338
|
+
// (live DB: 5,082 content-bearing traces, 2,827 index rows, 2,255 = 44.4%
|
|
339
|
+
// unsearchable). CREATE VIRTUAL TABLE IF NOT EXISTS cannot add a column to a
|
|
340
|
+
// database created before this fix, so detect the old signature and rebuild.
|
|
341
|
+
// The index is 100% derivable from `traces`, so the rebuild is lossless.
|
|
342
|
+
const ftsMigration = this._ensureTracesSearchSchema();
|
|
343
|
+
// Never let the rebuild be silent: it DROPS and recreates the index table, and a
|
|
344
|
+
// parity shortfall means rows silently stopped being searchable. Both are reported.
|
|
345
|
+
if (ftsMigration && ftsMigration.migrated && ftsMigration.expected > 0) {
|
|
346
|
+
console.log(`[VectorHub] traces_search migrated to the id-keyed schema; rebuilt ${ftsMigration.indexed}/${ftsMigration.expected} rows`);
|
|
347
|
+
}
|
|
348
|
+
if (ftsMigration && ftsMigration.migrated && ftsMigration.indexed !== ftsMigration.expected) {
|
|
349
|
+
console.warn(`[VectorHub] traces_search parity BROKEN after rebuild: indexed=${ftsMigration.indexed} expected=${ftsMigration.expected}`);
|
|
350
|
+
}
|
|
210
351
|
|
|
211
352
|
this._db.run(`
|
|
212
353
|
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_search
|
|
@@ -228,6 +369,120 @@ class VectorHub {
|
|
|
228
369
|
console.log(`[VectorHub] Initialized WASM SQLite persistence at ${this.dbPath}`);
|
|
229
370
|
}
|
|
230
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Create traces_search with the correct (id-keyed) schema, migrating and
|
|
374
|
+
* repopulating an older trace_id-keyed table in place when one exists.
|
|
375
|
+
* Idempotent — the sqlite_master signature check makes a re-run a no-op.
|
|
376
|
+
* `id` is declared notindexed: it is an opaque UUID, contributes no useful
|
|
377
|
+
* search term, and leaving it unindexed keeps MATCH semantics identical to the
|
|
378
|
+
* pre-fix table (content, agent and trace_id all remain searchable).
|
|
379
|
+
* @returns {{migrated: boolean, indexed: number|null, expected: number|null}}
|
|
380
|
+
*/
|
|
381
|
+
_ensureTracesSearchSchema() {
|
|
382
|
+
const existing = this.query(
|
|
383
|
+
'SELECT sql FROM sqlite_master WHERE type = ? AND name = ?',
|
|
384
|
+
['table', 'traces_search']
|
|
385
|
+
);
|
|
386
|
+
const currentSql = existing.length ? String(existing[0].sql || '') : null;
|
|
387
|
+
if (currentSql !== null && /fts4\s*\(\s*id\s*,/i.test(currentSql)) {
|
|
388
|
+
return { migrated: false, indexed: null, expected: null };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (currentSql !== null) {
|
|
392
|
+
this._db.run('DROP TABLE traces_search');
|
|
393
|
+
}
|
|
394
|
+
this._db.run(`
|
|
395
|
+
CREATE VIRTUAL TABLE traces_search
|
|
396
|
+
USING fts4(id, trace_id, content, agent, notindexed=id, tokenize=porter)
|
|
397
|
+
`);
|
|
398
|
+
return { migrated: true, ...this.rebuildTracesSearch() };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Rebuild traces_search from the `traces` base table.
|
|
403
|
+
*
|
|
404
|
+
* Explicitly re-runnable and idempotent: every index row is derivable from
|
|
405
|
+
* `traces`, so this clears the index and re-inserts exactly one row per
|
|
406
|
+
* content-bearing trace. Running it twice yields the same counts. This is the
|
|
407
|
+
* backfill that recovers rows lost to the old trace_id-keyed DELETE; no schema
|
|
408
|
+
* migration is required to run it once the schema is id-keyed.
|
|
409
|
+
* @returns {{indexed: number, expected: number}} post-rebuild row counts —
|
|
410
|
+
* equal when the index is complete
|
|
411
|
+
*/
|
|
412
|
+
rebuildTracesSearch() {
|
|
413
|
+
this._db.run('DELETE FROM traces_search');
|
|
414
|
+
this._db.run(
|
|
415
|
+
`INSERT INTO traces_search (id, trace_id, content, agent)
|
|
416
|
+
SELECT id, trace_id, content, agent
|
|
417
|
+
FROM traces
|
|
418
|
+
WHERE content IS NOT NULL AND content <> ?`,
|
|
419
|
+
['']
|
|
420
|
+
);
|
|
421
|
+
const indexed = this.query('SELECT COUNT(*) AS c FROM traces_search')[0].c;
|
|
422
|
+
const expected = this.query(
|
|
423
|
+
'SELECT COUNT(*) AS c FROM traces WHERE content IS NOT NULL AND content <> ?',
|
|
424
|
+
['']
|
|
425
|
+
)[0].c;
|
|
426
|
+
return { indexed, expected };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Run an FTS MATCH per term, then return the top `limit` base-table rows ranked
|
|
431
|
+
* by summed tf-idf.
|
|
432
|
+
*
|
|
433
|
+
* One MATCH per term rather than a single OR-joined MATCH: an OR-join returns
|
|
434
|
+
* its rows in docid order, so LIMIT cuts the candidate pool at the OLDEST
|
|
435
|
+
* FTS_RANK_POOL matches. Measured on the 5,082-row live trace index, a query of
|
|
436
|
+
* "celestial" (2,888 matches) plus a unique token could not retrieve the
|
|
437
|
+
* uniquely-matching document AT ALL, because 2,000 older rows filled the pool
|
|
438
|
+
* first — and since traces are append-only, the newest rows are always the
|
|
439
|
+
* first to be dropped. Scored per term the arithmetic is identical (matchinfo
|
|
440
|
+
* sums over phrases either way), but the pool can only be exhausted by a term's
|
|
441
|
+
* OWN document frequency, so a rare, discriminating term never loses.
|
|
442
|
+
* @param {string} baseTable - key of FTS_SEARCH_TABLES
|
|
443
|
+
* @param {string[]} terms - MATCH expressions from buildFtsTerms()
|
|
444
|
+
* @param {number} limit - clamped row limit
|
|
445
|
+
* @returns {Array<Object>} base-table rows, most relevant first
|
|
446
|
+
*/
|
|
447
|
+
_rankedFtsSearch(baseTable, terms, limit) {
|
|
448
|
+
const config = FTS_SEARCH_TABLES[baseTable];
|
|
449
|
+
if (!config) {
|
|
450
|
+
throw new Error(`[VectorHub] refusing to search unknown table: ${baseTable}`);
|
|
451
|
+
}
|
|
452
|
+
if (terms.length === 0) return [];
|
|
453
|
+
|
|
454
|
+
// id -> { score, order }; `order` is the first-seen position, used as an
|
|
455
|
+
// explicit tiebreak so the ranking never depends on sort stability.
|
|
456
|
+
const scored = new Map();
|
|
457
|
+
for (const term of terms) {
|
|
458
|
+
const rows = this.query(
|
|
459
|
+
`SELECT id, matchinfo(${config.ftsTable}, 'pcnx') AS mi
|
|
460
|
+
FROM ${config.ftsTable}
|
|
461
|
+
WHERE ${config.ftsTable} MATCH ?
|
|
462
|
+
LIMIT ?`,
|
|
463
|
+
[term, FTS_RANK_POOL]
|
|
464
|
+
);
|
|
465
|
+
for (const row of rows) {
|
|
466
|
+
const add = scoreMatchInfo(row.mi, config.columnWeights);
|
|
467
|
+
const prev = scored.get(row.id);
|
|
468
|
+
scored.set(row.id, prev
|
|
469
|
+
? { score: prev.score + add, order: prev.order }
|
|
470
|
+
: { score: add, order: scored.size });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const ids = [...scored.entries()]
|
|
475
|
+
.sort((a, b) => (b[1].score - a[1].score) || (a[1].order - b[1].order))
|
|
476
|
+
.slice(0, limit)
|
|
477
|
+
.map(([id]) => id);
|
|
478
|
+
if (ids.length === 0) return [];
|
|
479
|
+
|
|
480
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
481
|
+
const rows = this.query(`SELECT * FROM ${baseTable} WHERE id IN (${placeholders})`, ids);
|
|
482
|
+
const byId = new Map(rows.map(r => [r.id, r]));
|
|
483
|
+
return ids.map(id => byId.get(id)).filter(Boolean);
|
|
484
|
+
}
|
|
485
|
+
|
|
231
486
|
/**
|
|
232
487
|
* Persist the in-memory database to disk (UC-09).
|
|
233
488
|
*
|
|
@@ -396,12 +651,14 @@ class VectorHub {
|
|
|
396
651
|
[entry.id, entry.trace_id, entry.span_id, entry.event, entry.timestamp, entry.agent, entry.content, entry.metadata, entry.drift_score, entry.mesh_node_id]
|
|
397
652
|
);
|
|
398
653
|
|
|
399
|
-
// Update FTS index if content exists
|
|
654
|
+
// Update the FTS index if content exists. Keyed on entry.id (the traces
|
|
655
|
+
// PRIMARY KEY) — keying the DELETE on trace_id wiped every sibling span's
|
|
656
|
+
// index row, which is what made 44.4% of trace content unsearchable (FTS-01).
|
|
400
657
|
if (entry.content) {
|
|
401
|
-
this._db.run('DELETE FROM traces_search WHERE
|
|
658
|
+
this._db.run('DELETE FROM traces_search WHERE id = ?', [entry.id]);
|
|
402
659
|
this._db.run(
|
|
403
|
-
'INSERT INTO traces_search (trace_id, content, agent) VALUES (?, ?, ?)',
|
|
404
|
-
[entry.trace_id, entry.content, entry.agent]
|
|
660
|
+
'INSERT INTO traces_search (id, trace_id, content, agent) VALUES (?, ?, ?, ?)',
|
|
661
|
+
[entry.id, entry.trace_id, entry.content, entry.agent]
|
|
405
662
|
);
|
|
406
663
|
}
|
|
407
664
|
|
|
@@ -436,26 +693,34 @@ class VectorHub {
|
|
|
436
693
|
}
|
|
437
694
|
|
|
438
695
|
/**
|
|
439
|
-
* Full-text search for traces.
|
|
696
|
+
* Full-text search for traces, ranked by tf-idf.
|
|
697
|
+
*
|
|
698
|
+
* Multi-word queries are ORed across terms and ranked (see _rankedFtsSearch).
|
|
699
|
+
* FTS4 has no built-in ranker, so without the ranking step the result is docid
|
|
700
|
+
* order and mean recall@10 measures 0.0000.
|
|
701
|
+
* @param {string} rawQuery - the user's query text
|
|
702
|
+
* @param {{phrase?: boolean, limit?: number}} [opts]
|
|
703
|
+
* phrase:true searches for the exact adjacent word sequence (old behaviour);
|
|
704
|
+
* limit is clamped to [1, 100] and defaults to 10.
|
|
705
|
+
* @returns {Promise<Array<Object>>} trace rows, most relevant first
|
|
706
|
+
* @throws {TypeError} when rawQuery is not a string
|
|
440
707
|
*/
|
|
441
|
-
async searchTraces(rawQuery) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
FROM traces t
|
|
447
|
-
JOIN traces_search ts ON t.trace_id = ts.trace_id
|
|
448
|
-
WHERE traces_search MATCH ?
|
|
449
|
-
LIMIT 10`,
|
|
450
|
-
[ftsQuery]
|
|
708
|
+
async searchTraces(rawQuery, opts = {}) {
|
|
709
|
+
return this._rankedFtsSearch(
|
|
710
|
+
'traces',
|
|
711
|
+
buildFtsTerms(rawQuery, opts),
|
|
712
|
+
clampFtsLimit(opts.limit)
|
|
451
713
|
);
|
|
452
714
|
}
|
|
453
715
|
|
|
454
716
|
/**
|
|
455
717
|
* Full-text search for traces (alias for backward compat).
|
|
718
|
+
* @param {string} rawQuery
|
|
719
|
+
* @param {{phrase?: boolean, limit?: number}} [opts]
|
|
720
|
+
* @returns {Promise<Array<Object>>}
|
|
456
721
|
*/
|
|
457
|
-
async searchFTS(rawQuery) {
|
|
458
|
-
return this.searchTraces(rawQuery);
|
|
722
|
+
async searchFTS(rawQuery, opts = {}) {
|
|
723
|
+
return this.searchTraces(rawQuery, opts);
|
|
459
724
|
}
|
|
460
725
|
|
|
461
726
|
/**
|
|
@@ -512,16 +777,22 @@ class VectorHub {
|
|
|
512
777
|
return record.id;
|
|
513
778
|
}
|
|
514
779
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
780
|
+
/**
|
|
781
|
+
* Full-text search for knowledge entries, ranked by tf-idf (see searchTraces).
|
|
782
|
+
* @param {string} rawQuery
|
|
783
|
+
* @param {number|{phrase?: boolean, limit?: number}} [limitOrOpts] - a bare
|
|
784
|
+
* number is still accepted for backward compatibility
|
|
785
|
+
* @returns {Promise<Array<Object>>} knowledge rows, most relevant first
|
|
786
|
+
* @throws {TypeError} when rawQuery is not a string
|
|
787
|
+
*/
|
|
788
|
+
async searchKnowledge(rawQuery, limitOrOpts = {}) {
|
|
789
|
+
const opts = (limitOrOpts !== null && typeof limitOrOpts === 'object')
|
|
790
|
+
? limitOrOpts
|
|
791
|
+
: { limit: limitOrOpts };
|
|
792
|
+
return this._rankedFtsSearch(
|
|
793
|
+
'knowledge',
|
|
794
|
+
buildFtsTerms(rawQuery, opts),
|
|
795
|
+
clampFtsLimit(opts.limit)
|
|
525
796
|
);
|
|
526
797
|
}
|
|
527
798
|
|
|
@@ -611,6 +882,7 @@ const lazyHub = new Proxy({}, {
|
|
|
611
882
|
get(_, prop) {
|
|
612
883
|
if (prop === 'VectorHub') return VectorHub;
|
|
613
884
|
if (prop === 'createVectorHub') return createVectorHub;
|
|
885
|
+
if (prop === 'buildFtsTerms') return buildFtsTerms;
|
|
614
886
|
if (!_instance) _instance = new VectorHub();
|
|
615
887
|
return typeof _instance[prop] === 'function'
|
|
616
888
|
? _instance[prop].bind(_instance)
|
|
@@ -621,3 +893,4 @@ const lazyHub = new Proxy({}, {
|
|
|
621
893
|
module.exports = lazyHub;
|
|
622
894
|
module.exports.VectorHub = VectorHub;
|
|
623
895
|
module.exports.createVectorHub = createVectorHub;
|
|
896
|
+
module.exports.buildFtsTerms = buildFtsTerms;
|
package/bin/mindforge-cli.js
CHANGED
|
@@ -25,8 +25,10 @@ const ROOT = path.resolve(__dirname, '..');
|
|
|
25
25
|
const COMMANDS = {
|
|
26
26
|
'security-scan': {
|
|
27
27
|
script: 'bin/validate-config.js',
|
|
28
|
-
description: 'Validate configuration and run security checks'
|
|
29
|
-
defaultArgs:
|
|
28
|
+
description: 'Validate configuration and run security checks'
|
|
29
|
+
// No defaultArgs: validate-config.js already defaults to MINDFORGE.md
|
|
30
|
+
// (bin/validate-config.js:13). Declaring it here would prepend a positional
|
|
31
|
+
// that shadows a user-supplied config path.
|
|
30
32
|
},
|
|
31
33
|
'health': {
|
|
32
34
|
script: 'bin/installer-core.js',
|
|
@@ -57,20 +59,31 @@ const COMMANDS = {
|
|
|
57
59
|
script: 'bin/skill-validator.js',
|
|
58
60
|
description: 'Run Level 1 & 2 validation on a SKILL.md file'
|
|
59
61
|
},
|
|
62
|
+
// NOTE: install-skill / register-skill / audit-skill deliberately carry NO
|
|
63
|
+
// defaultArgs. Supplying their subcommand token here reaches skill-registry's
|
|
64
|
+
// write paths, which perform no existence or validation checks:
|
|
65
|
+
// - audit-skill <name> <ver> <tier> appended a hash-chained
|
|
66
|
+
// {event:'skill_installed', validation_passed:true} entry for a skill that
|
|
67
|
+
// does not exist, and exited 0. For a product whose central claim is a
|
|
68
|
+
// tamper-evident audit chain, a CLI that mints authentic-looking false
|
|
69
|
+
// entries on request is worse than one that refuses.
|
|
70
|
+
// - register-skill <name> <ver> 1 wrote a malformed 5-column row above the
|
|
71
|
+
// table header of .mindforge/org/skills/MANIFEST.md, which ships in the
|
|
72
|
+
// tarball, and exited 0.
|
|
73
|
+
// Without defaultArgs these refuse with "Invalid or missing action" (exit 1),
|
|
74
|
+
// which is the pre-11.9.2 behaviour. Re-enable only once skill-registry gates
|
|
75
|
+
// on skill existence and fixes its table-separator match.
|
|
60
76
|
'install-skill': {
|
|
61
77
|
script: 'bin/skill-registry.js',
|
|
62
|
-
description: 'Install a skill to the correct tier folder (
|
|
63
|
-
defaultArgs: ['install']
|
|
78
|
+
description: 'Install a skill to the correct tier folder (pass the "install" action explicitly)'
|
|
64
79
|
},
|
|
65
80
|
'register-skill': {
|
|
66
81
|
script: 'bin/skill-registry.js',
|
|
67
|
-
description: 'Register a skill in MANIFEST.md'
|
|
68
|
-
defaultArgs: ['register']
|
|
82
|
+
description: 'Register a skill in MANIFEST.md (pass the "register" action explicitly)'
|
|
69
83
|
},
|
|
70
84
|
'audit-skill': {
|
|
71
85
|
script: 'bin/skill-registry.js',
|
|
72
|
-
description: 'Record skill life cycle events in audit log'
|
|
73
|
-
defaultArgs: ['audit']
|
|
86
|
+
description: 'Record skill life cycle events in audit log (pass the "audit" action explicitly)'
|
|
74
87
|
},
|
|
75
88
|
'remember': {
|
|
76
89
|
script: 'bin/memory/cli.js',
|
|
@@ -98,6 +111,15 @@ const COMMANDS = {
|
|
|
98
111
|
description: 'Invoke a specialized identity from /agents/',
|
|
99
112
|
defaultArgs: ['identity']
|
|
100
113
|
},
|
|
114
|
+
// spawn-agent.js reads MODE from ARGS[0] and supports spawn|identity|subagent.
|
|
115
|
+
// 'subagent' had no router entry, so it was reached as `mindforge spawn subagent
|
|
116
|
+
// <name>` — which prepending 'spawn' now shadows (MODE='spawn', TARGET='subagent').
|
|
117
|
+
// Promoting it to a first-class command keeps the only implemented mode reachable.
|
|
118
|
+
'subagent': {
|
|
119
|
+
script: 'bin/spawn-agent.js',
|
|
120
|
+
description: 'Invoke a subagent definition from /subagents/',
|
|
121
|
+
defaultArgs: ['subagent']
|
|
122
|
+
},
|
|
101
123
|
'temporal': {
|
|
102
124
|
script: 'bin/engine/temporal-cli.js',
|
|
103
125
|
description: 'Manage time-travel debugging and state history'
|
|
@@ -129,10 +151,14 @@ const COMMANDS = {
|
|
|
129
151
|
script: 'bin/engine/learning-manager.js',
|
|
130
152
|
description: 'Consult or initialize the project agentic learning memory'
|
|
131
153
|
},
|
|
154
|
+
// No defaultArgs: 'record' reaches a writer that resolves its target relative to
|
|
155
|
+
// the spawn cwd, which is still ROOT (deferred to v12). In a consumer install that
|
|
156
|
+
// writes inside node_modules/mindforge-cc/ and is destroyed by the next npm ci.
|
|
157
|
+
// Activating a writer while its path resolution is known-wrong is worse than
|
|
158
|
+
// leaving it inert; re-enable together with the cwd fix.
|
|
132
159
|
'record-learning': {
|
|
133
160
|
script: 'bin/engine/learning-manager.js',
|
|
134
|
-
description: 'Append a new Learning Entry to the Evolution Log'
|
|
135
|
-
defaultArgs: ['record']
|
|
161
|
+
description: 'Append a new Learning Entry to the Evolution Log (pass the "record" action explicitly)'
|
|
136
162
|
},
|
|
137
163
|
'verify': {
|
|
138
164
|
script: 'bin/engine/verify-cli.js',
|
|
@@ -177,7 +203,13 @@ if (!target) {
|
|
|
177
203
|
}
|
|
178
204
|
|
|
179
205
|
const scriptPath = path.join(ROOT, target.script);
|
|
180
|
-
|
|
206
|
+
|
|
207
|
+
// defaultArgs are PREPENDED, never replaced. They carry the subcommand token or
|
|
208
|
+
// mode flag the child script requires (e.g. 'inject' for hindsight, '--check' for
|
|
209
|
+
// health), so dropping them when the user supplies an argument silently changes
|
|
210
|
+
// which code path runs — `health --force` used to lose '--check' and fall through
|
|
211
|
+
// to installer-core's install() path.
|
|
212
|
+
const finalArgs = [...(target.defaultArgs || []), ...COMMAND_ARGS];
|
|
181
213
|
|
|
182
214
|
console.log(`🚀 Executing: ${COMMAND} (${target.description})`);
|
|
183
215
|
|