@mindrian_os/cli 2.0.0-beta.31 → 2.0.0-beta.35
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/bin/mindrian-brain-mcp-client.cjs +15 -1
- package/commands/doctor.md +2 -2
- package/data/brain-census.generated.json +2827 -1523
- package/lib/core/brain-client.cjs +320 -20
- package/lib/core/brain-prewarm.cjs +164 -0
- package/lib/core/directive-envelope.cjs +13 -0
- package/lib/core/doctor/class-m-brain-smoke.cjs +160 -26
- package/lib/core/doctor/class-m-brain-smoke.test.cjs +441 -82
- package/lib/core/integration-registry.cjs +96 -20
- package/lib/core/intel-pipeline.cjs +28 -7
- package/lib/core/part8-egress-guard.cjs +27 -0
- package/lib/mcp/brain-composition-census.cjs +22 -6
- package/lib/mcp/brain-route-bound.cjs +66 -0
- package/lib/mcp/brain-router.cjs +47 -11
- package/lib/mcp/tool-router.cjs +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/references/brain/room-hierarchy-schema.md +11 -5
- package/scripts/build-brain-census.cjs +321 -4
- package/scripts/collect-cold-install-evidence.cjs +411 -0
- package/scripts/doctor.cjs +22 -7
- package/scripts/room-registry +4 -4
- package/scripts/session-start +25 -2
- package/scripts/whitespace-to-brain.cjs +2 -1
- package/skills/doctor/SKILL.md +2 -2
- package/skills/larry-personality/SKILL.md +4 -3
- package/scripts/sync-rooms-brain +0 -438
|
@@ -1110,6 +1110,244 @@ async function schema() {
|
|
|
1110
1110
|
return result;
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* _inferRungFromQuestion(question) -> one of the four Theo rung ids.
|
|
1115
|
+
*
|
|
1116
|
+
* Theo's `classify_problem_type` tool takes NO parameters (the caller
|
|
1117
|
+
* classifies), and there is no free-text rung classifier anywhere in this
|
|
1118
|
+
* repo (`brain-derivation.cjs::classifyProblemType` scores MINTO triples,
|
|
1119
|
+
* not text). This helper fills that local gap. It is PURE and LOCAL: it
|
|
1120
|
+
* never sends the question anywhere, which is the whole reason it exists
|
|
1121
|
+
* (Canon Part 8 -- the question is user data and stays local).
|
|
1122
|
+
*
|
|
1123
|
+
* Precedence is FIXED and load-bearing (do not reorder): Wicked is the
|
|
1124
|
+
* orthogonal stakeholder-conflict axis and wins outright; UnDefined's
|
|
1125
|
+
* markers are the least ambiguous of the remaining three; WellDefined's
|
|
1126
|
+
* markers are next; IllDefined's markers are the broadest and are also the
|
|
1127
|
+
* default, so they run last and cost nothing when they lose.
|
|
1128
|
+
*
|
|
1129
|
+
* Multi-word markers match as a substring; single-word markers match on a
|
|
1130
|
+
* word boundary so `which` does not fire inside `whichever`. Non-string or
|
|
1131
|
+
* empty input returns `'IllDefined'` and never throws.
|
|
1132
|
+
*
|
|
1133
|
+
* @param {*} question
|
|
1134
|
+
* @returns {'Wicked'|'UnDefined'|'WellDefined'|'IllDefined'}
|
|
1135
|
+
*/
|
|
1136
|
+
const _RUNG_MARKERS = [
|
|
1137
|
+
{ rung: 'Wicked', markers: ['stakeholder', 'disagree', 'values', 'political'] },
|
|
1138
|
+
{ rung: 'UnDefined', markers: ['future of', 'no boundary', 'unbounded'] },
|
|
1139
|
+
{ rung: 'WellDefined', markers: ['measure', 'spec', 'test', 'kpi', 'how do we'] },
|
|
1140
|
+
{ rung: 'IllDefined', markers: ['next big thing', 'which', 'should we', 'opportunity'] },
|
|
1141
|
+
];
|
|
1142
|
+
|
|
1143
|
+
function _matchesRungMarker(text, marker) {
|
|
1144
|
+
if (marker.indexOf(' ') !== -1) return text.indexOf(marker) !== -1;
|
|
1145
|
+
return new RegExp('\\b' + marker + '\\b').test(text);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function _inferRungFromQuestion(question) {
|
|
1149
|
+
if (typeof question !== 'string' || !question.trim()) return 'IllDefined';
|
|
1150
|
+
const lc = question.trim().toLowerCase();
|
|
1151
|
+
for (let i = 0; i < _RUNG_MARKERS.length; i++) {
|
|
1152
|
+
const entry = _RUNG_MARKERS[i];
|
|
1153
|
+
for (let j = 0; j < entry.markers.length; j++) {
|
|
1154
|
+
if (_matchesRungMarker(lc, entry.markers[j])) return entry.rung;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return 'IllDefined';
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* _composeTheoAsk(payload, question, deps) -> composed envelope payload.
|
|
1162
|
+
*
|
|
1163
|
+
* Composes `directive`, `next_gate` and `grounding` onto a Theo
|
|
1164
|
+
* `answer_mode: 'structured_rows'` payload so downstream consumers
|
|
1165
|
+
* (`wrapDirective`, `brainRoute`, `rs-chain-feeder.cjs`) get a real
|
|
1166
|
+
* framework and chain instead of an empty scaffold. `deps` is the
|
|
1167
|
+
* injectable wire seam (`{ recommendChain, query }`) the offline test suite
|
|
1168
|
+
* drives; it defaults to this module's own wrappers so production behavior
|
|
1169
|
+
* needs no injection.
|
|
1170
|
+
*
|
|
1171
|
+
* Runs entirely inside one outer try/catch and NEVER throws: on any failure
|
|
1172
|
+
* it still returns the same object shape with `options: []` and
|
|
1173
|
+
* `chain_status: 'unreachable'`, so a rows response with a failed chain
|
|
1174
|
+
* still returns `grounding`.
|
|
1175
|
+
*
|
|
1176
|
+
* Canon Part 8: the only two extra wire calls this makes carry a closed
|
|
1177
|
+
* rung enum (`recommendChain`) and framework names Theo itself just
|
|
1178
|
+
* returned (`query`, label-anchored on `(f:Framework)`, never
|
|
1179
|
+
* relationship-first -- Theo's read allow-list refuses
|
|
1180
|
+
* `DirectedRelationshipTypeScan`). `query_terms` (the question's own words)
|
|
1181
|
+
* is the ONLY key removed from the payload, because it is the question
|
|
1182
|
+
* echoed back; the row projection is by the four NAMED keys
|
|
1183
|
+
* (chapterId/section/score/snippet), never a blind row copy, for the same
|
|
1184
|
+
* reason plus shape stability.
|
|
1185
|
+
*
|
|
1186
|
+
* @param {object} payload - the raw Theo structured_rows payload
|
|
1187
|
+
* @param {string} question - the original question (local only, never sent)
|
|
1188
|
+
* @param {{recommendChain?: Function, query?: Function}} [deps]
|
|
1189
|
+
* @returns {Promise<object>}
|
|
1190
|
+
*/
|
|
1191
|
+
function _projectGroundingRows(rows) {
|
|
1192
|
+
return Array.isArray(rows)
|
|
1193
|
+
? rows
|
|
1194
|
+
.filter((r) => r && typeof r === 'object')
|
|
1195
|
+
.map((r) => ({ chapterId: r.chapterId, section: r.section, score: r.score, snippet: r.snippet }))
|
|
1196
|
+
: [];
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function _composeTheoAsk(payload, question, deps) {
|
|
1200
|
+
deps = deps || {};
|
|
1201
|
+
const recommend = deps.recommendChain || recommendChain;
|
|
1202
|
+
const runQuery = deps.query || query;
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
const rung = _inferRungFromQuestion(question);
|
|
1206
|
+
|
|
1207
|
+
let chainRes = null;
|
|
1208
|
+
let chainStatus;
|
|
1209
|
+
try {
|
|
1210
|
+
chainRes = await recommend(rung, 4);
|
|
1211
|
+
if (chainRes == null || chainRes.error || !Array.isArray(chainRes.chain)) {
|
|
1212
|
+
chainStatus = 'unreachable';
|
|
1213
|
+
} else if (chainRes.chain.length === 0) {
|
|
1214
|
+
chainStatus = 'empty';
|
|
1215
|
+
} else {
|
|
1216
|
+
chainStatus = 'ok';
|
|
1217
|
+
}
|
|
1218
|
+
} catch (_e) {
|
|
1219
|
+
chainStatus = 'unreachable';
|
|
1220
|
+
}
|
|
1221
|
+
const steps = chainStatus === 'ok' ? chainRes.chain : [];
|
|
1222
|
+
|
|
1223
|
+
// COMMANDS: exactly one runQuery call, only when there are steps, in its
|
|
1224
|
+
// own try/catch (on any failure every option gets commands: []).
|
|
1225
|
+
// Label-anchored and never relationship-first: Theo's read allow-list
|
|
1226
|
+
// refuses a template that starts from [:USES_FRAMEWORK].
|
|
1227
|
+
const commandsByFramework = new Map();
|
|
1228
|
+
if (steps.length > 0) {
|
|
1229
|
+
try {
|
|
1230
|
+
const names = steps
|
|
1231
|
+
.map((s) => s && s.framework)
|
|
1232
|
+
.filter((n) => typeof n === 'string');
|
|
1233
|
+
const result = await runQuery(
|
|
1234
|
+
'MATCH (f:Framework) WHERE f.name IN $names OPTIONAL MATCH (c:MindrianCommand)-[:USES_FRAMEWORK]->(f) RETURN f.name AS framework, collect(DISTINCT c.name) AS commands',
|
|
1235
|
+
{ names: names }
|
|
1236
|
+
);
|
|
1237
|
+
const records = (result && Array.isArray(result.records)) ? result.records : [];
|
|
1238
|
+
for (const rec of records) {
|
|
1239
|
+
if (!rec || typeof rec.framework !== 'string') continue;
|
|
1240
|
+
const slugs = Array.isArray(rec.commands)
|
|
1241
|
+
? rec.commands
|
|
1242
|
+
.filter((c) => typeof c === 'string' && c.length > 0)
|
|
1243
|
+
.map((c) => c.replace(/^\/mos:/, ''))
|
|
1244
|
+
: [];
|
|
1245
|
+
commandsByFramework.set(rec.framework, slugs);
|
|
1246
|
+
}
|
|
1247
|
+
} catch (_e) {
|
|
1248
|
+
// any failure here -> every option gets commands: [] below (Map stays empty)
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// CONFIDENCE: top step reads exactly 0.9; a missing/non-finite degree -> 0.5.
|
|
1253
|
+
// theo_rank and the command-bearing-first sort below never touch this
|
|
1254
|
+
// computation -- the sort reorders `options`, it never recomputes a
|
|
1255
|
+
// confidence value (Quick 260911-ddd, DDD-01).
|
|
1256
|
+
const top = steps.reduce((max, s) => {
|
|
1257
|
+
const d = s && Number.isFinite(s.degree) ? s.degree : 0;
|
|
1258
|
+
return d > max ? d : max;
|
|
1259
|
+
}, 0);
|
|
1260
|
+
const options = steps.map((s, idx) => {
|
|
1261
|
+
const degree = s && s.degree;
|
|
1262
|
+
let confidence = 0.5;
|
|
1263
|
+
if (top > 0 && Number.isFinite(degree)) {
|
|
1264
|
+
confidence = Math.max(0.5, Math.min(0.9, Math.round((0.5 + 0.4 * (degree / top)) * 100) / 100));
|
|
1265
|
+
}
|
|
1266
|
+
// Quick 260911-ddd (DDD-01): theo_rank carries Theo's own rank so it
|
|
1267
|
+
// is never lost by the reorder below. Theo's `step` field when it is
|
|
1268
|
+
// a finite number, else the 1-based index of this step WITHIN Theo's
|
|
1269
|
+
// own chain array, computed here before any sort so a later reorder
|
|
1270
|
+
// can never leak into this number.
|
|
1271
|
+
const theoRank = (s && Number.isFinite(s.step)) ? s.step : idx + 1;
|
|
1272
|
+
return {
|
|
1273
|
+
framework: s && s.framework,
|
|
1274
|
+
confidence: confidence,
|
|
1275
|
+
commands: commandsByFramework.get(s && s.framework) || [],
|
|
1276
|
+
theo_rank: theoRank,
|
|
1277
|
+
};
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// Quick 260911-ddd (DDD-01): stable partition, command-bearing options
|
|
1281
|
+
// first, Theo's relative order preserved inside each group. Theo's
|
|
1282
|
+
// ranking is NEVER altered at the source -- this reorders the plugin's
|
|
1283
|
+
// OWN options array only. A single forward pass into two queues, then
|
|
1284
|
+
// concatenated, is stable BY CONSTRUCTION; it does not rest on
|
|
1285
|
+
// Array.prototype.sort's engine-stability semantics. When every option
|
|
1286
|
+
// or no option carries a command, one queue is empty and the
|
|
1287
|
+
// concatenation is the identity, so Theo's order survives unchanged
|
|
1288
|
+
// for free.
|
|
1289
|
+
//
|
|
1290
|
+
// DOWNSTREAM CONSEQUENCE, stated here so it is not mistaken for a
|
|
1291
|
+
// regression later: lib/mcp/brain-router.cjs:398 derives topConf from
|
|
1292
|
+
// options[0].confidence. On a chain whose top-ranked framework has no
|
|
1293
|
+
// command, the routed confidence now reads the first command-bearing
|
|
1294
|
+
// option's confidence (0.83 on the live IllDefined shape, previously
|
|
1295
|
+
// 0.9) -- that is the intended meaning of the change (confidence
|
|
1296
|
+
// describes the option actually surfaced to the user), not a bug.
|
|
1297
|
+
// brain-router.cjs is deliberately NOT touched to compensate for this.
|
|
1298
|
+
const commandBearing = [];
|
|
1299
|
+
const commandLess = [];
|
|
1300
|
+
for (const opt of options) {
|
|
1301
|
+
if (Array.isArray(opt.commands) && opt.commands.length > 0) {
|
|
1302
|
+
commandBearing.push(opt);
|
|
1303
|
+
} else {
|
|
1304
|
+
commandLess.push(opt);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const sortedOptions = commandBearing.concat(commandLess);
|
|
1308
|
+
|
|
1309
|
+
const out = Object.assign({}, payload);
|
|
1310
|
+
delete out.query_terms;
|
|
1311
|
+
out.directive = {
|
|
1312
|
+
guided: { questions: [], framework: (sortedOptions[0] && sortedOptions[0].framework) || null, stage: rung },
|
|
1313
|
+
};
|
|
1314
|
+
out.next_gate = { sub_shape: 'F.1', options: sortedOptions };
|
|
1315
|
+
out.grounding = {
|
|
1316
|
+
source: 'theo',
|
|
1317
|
+
answer_mode: payload.answer_mode,
|
|
1318
|
+
rows: _projectGroundingRows(payload.rows),
|
|
1319
|
+
problem_type: rung,
|
|
1320
|
+
problem_type_source: 'heuristic',
|
|
1321
|
+
chain_coverage: (chainRes && chainRes.coverage && typeof chainRes.coverage === 'object') ? chainRes.coverage : null,
|
|
1322
|
+
chain_status: chainStatus,
|
|
1323
|
+
confidence_source: 'theo_degree_normalized',
|
|
1324
|
+
// Quick 260911-ddd (DDD-01): names the applied ordering so a
|
|
1325
|
+
// consumer never has to branch on the key's presence -- present here
|
|
1326
|
+
// AND on the trailing catch path below.
|
|
1327
|
+
option_order: 'command_bearing_first',
|
|
1328
|
+
};
|
|
1329
|
+
return out;
|
|
1330
|
+
} catch (_e) {
|
|
1331
|
+
const rung = _inferRungFromQuestion(question);
|
|
1332
|
+
const out = Object.assign({}, payload);
|
|
1333
|
+
delete out.query_terms;
|
|
1334
|
+
out.directive = { guided: { questions: [], framework: null, stage: rung } };
|
|
1335
|
+
out.next_gate = { sub_shape: 'F.1', options: [] };
|
|
1336
|
+
out.grounding = {
|
|
1337
|
+
source: 'theo',
|
|
1338
|
+
answer_mode: payload.answer_mode,
|
|
1339
|
+
rows: _projectGroundingRows(payload.rows),
|
|
1340
|
+
problem_type: rung,
|
|
1341
|
+
problem_type_source: 'heuristic',
|
|
1342
|
+
chain_coverage: null,
|
|
1343
|
+
chain_status: 'unreachable',
|
|
1344
|
+
confidence_source: 'theo_degree_normalized',
|
|
1345
|
+
option_order: 'command_bearing_first',
|
|
1346
|
+
};
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1113
1351
|
/**
|
|
1114
1352
|
* Natural-language methodology question against the Brain (wraps brain_ask).
|
|
1115
1353
|
*
|
|
@@ -1119,9 +1357,14 @@ async function schema() {
|
|
|
1119
1357
|
* question. Canon Part 8: the question string carries only generic methodology
|
|
1120
1358
|
* language -- never user artifacts, meeting text, or personal identifiers.
|
|
1121
1359
|
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1360
|
+
* Two response shapes on success:
|
|
1361
|
+
* - INCUMBENT: carries `directive` already -- returned BYTE-UNCHANGED, the
|
|
1362
|
+
* same object reference, no copy.
|
|
1363
|
+
* - THEO structured_rows: `{ answer_mode: 'structured_rows', rows: [...] }`
|
|
1364
|
+
* with no `directive` -- composed through `_composeTheoAsk` into a full
|
|
1365
|
+
* envelope payload (`directive`, `next_gate`, `grounding` added).
|
|
1366
|
+
* A `{ text: 'Error: ...' } / { error: ... }` sentinel passes through
|
|
1367
|
+
* UNCHANGED on a server-side error; null when the Brain is unreachable or no
|
|
1125
1368
|
* API key is configured (graceful degradation -- mirrors query()).
|
|
1126
1369
|
*
|
|
1127
1370
|
* @param {string} question
|
|
@@ -1129,7 +1372,15 @@ async function schema() {
|
|
|
1129
1372
|
*/
|
|
1130
1373
|
async function ask(question) {
|
|
1131
1374
|
if (typeof question !== 'string' || !question.trim()) return null;
|
|
1132
|
-
|
|
1375
|
+
const raw = await callTool('brain_ask', { question: question });
|
|
1376
|
+
if (raw == null) return raw; // transport-null contract, byte-locked
|
|
1377
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return raw;
|
|
1378
|
+
if (raw.error) return raw; // egress_blocked / tier_denied / rate_limited / invalid_key
|
|
1379
|
+
if (raw.directive) return raw; // incumbent proof -- same object reference
|
|
1380
|
+
if (raw.answer_mode === 'structured_rows' && Array.isArray(raw.rows)) {
|
|
1381
|
+
return _composeTheoAsk(raw, question);
|
|
1382
|
+
}
|
|
1383
|
+
return raw;
|
|
1133
1384
|
}
|
|
1134
1385
|
|
|
1135
1386
|
/**
|
|
@@ -1162,24 +1413,64 @@ async function ask(question) {
|
|
|
1162
1413
|
* @param {object} [params] - generic-handles-only params object
|
|
1163
1414
|
* @returns {Promise<{op: string, source?: string, count: number, rows: Array, degraded?: boolean}>}
|
|
1164
1415
|
*/
|
|
1416
|
+
/**
|
|
1417
|
+
* _normalizeAskOpResult(result, operation) -- the askOp() shape recognizer,
|
|
1418
|
+
* extracted so tests can drive it directly with zero network. Copies the
|
|
1419
|
+
* dual-shape recognizer STRUCTURE from
|
|
1420
|
+
* lib/core/enrichment-queue.cjs::captureReadinessMiss: one entry point, arms
|
|
1421
|
+
* keyed on payload SHAPE never on key presence alone.
|
|
1422
|
+
*
|
|
1423
|
+
* Theo op answers carry `coverage`, never `count`, so a `count`-keyed
|
|
1424
|
+
* recognizer alone reads a live three-row answer as a degraded zero. Arms,
|
|
1425
|
+
* in order:
|
|
1426
|
+
* 1. INCUMBENT: `count` is a number and `rows` is an array -- the exact
|
|
1427
|
+
* object askOp built before this task. MUST run first so an incumbent
|
|
1428
|
+
* payload can never take arm 2.
|
|
1429
|
+
* 2. THEO: `rows` is an array (no numeric `count` required) -- reports
|
|
1430
|
+
* `coverage.matched` as `count` when it is a finite number, else
|
|
1431
|
+
* `rows.length`.
|
|
1432
|
+
* 3. Everything else (null, an `{error:...}` sentinel, a `{text:...}`
|
|
1433
|
+
* passthrough, a non-array `rows`) -- the existing degraded sentinel.
|
|
1434
|
+
* Degrade ONLY when `rows` is not an array.
|
|
1435
|
+
*
|
|
1436
|
+
* @param {*} result
|
|
1437
|
+
* @param {string} operation
|
|
1438
|
+
* @returns {{op: string, source?: string, count: number, rows: Array, coverage?: object, degraded?: boolean}}
|
|
1439
|
+
*/
|
|
1440
|
+
function _normalizeAskOpResult(result, operation) {
|
|
1441
|
+
if (result && typeof result === 'object'
|
|
1442
|
+
&& typeof result.count === 'number' && Array.isArray(result.rows)) {
|
|
1443
|
+
return {
|
|
1444
|
+
op: result.op || operation,
|
|
1445
|
+
source: result.source,
|
|
1446
|
+
count: result.count,
|
|
1447
|
+
rows: result.rows,
|
|
1448
|
+
...(result.degraded ? { degraded: true } : {}),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
if (result && typeof result === 'object' && Array.isArray(result.rows)) {
|
|
1452
|
+
const coverage = result.coverage;
|
|
1453
|
+
const matched = (coverage && typeof coverage === 'object' && Number.isFinite(coverage.matched))
|
|
1454
|
+
? coverage.matched
|
|
1455
|
+
: result.rows.length;
|
|
1456
|
+
const out = {
|
|
1457
|
+
op: result.op || operation,
|
|
1458
|
+
source: 'theo',
|
|
1459
|
+
count: matched,
|
|
1460
|
+
rows: result.rows,
|
|
1461
|
+
};
|
|
1462
|
+
if (coverage && typeof coverage === 'object') out.coverage = coverage;
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
return { op: operation, count: 0, rows: [], degraded: true };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1165
1468
|
async function askOp(operation, params = {}) {
|
|
1166
1469
|
try {
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
&& typeof result.count === 'number' && Array.isArray(result.rows)) {
|
|
1172
|
-
return {
|
|
1173
|
-
op: result.op || operation,
|
|
1174
|
-
source: result.source,
|
|
1175
|
-
count: result.count,
|
|
1176
|
-
rows: result.rows,
|
|
1177
|
-
...(result.degraded ? { degraded: true } : {}),
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
// Unreachable Brain (null), an error/text passthrough, or any unexpected
|
|
1181
|
-
// shape -> graceful degraded sentinel.
|
|
1182
|
-
return { op: operation, count: 0, rows: [], degraded: true };
|
|
1470
|
+
return _normalizeAskOpResult(
|
|
1471
|
+
await callTool('brain_ask', { op: operation, params: params || {} }),
|
|
1472
|
+
operation
|
|
1473
|
+
);
|
|
1183
1474
|
} catch (_err) {
|
|
1184
1475
|
return { op: operation, count: 0, rows: [], degraded: true };
|
|
1185
1476
|
}
|
|
@@ -2300,6 +2591,10 @@ module.exports = {
|
|
|
2300
2591
|
smartSearch,
|
|
2301
2592
|
ask,
|
|
2302
2593
|
askOp,
|
|
2594
|
+
// Quick 260910-hni: the pure local rung heuristic ask()/_composeTheoAsk
|
|
2595
|
+
// uses to classify a question before the one recommend_chain wire call.
|
|
2596
|
+
// A NAMED export per the locked design, not a _test-only member.
|
|
2597
|
+
_inferRungFromQuestion,
|
|
2303
2598
|
schema,
|
|
2304
2599
|
stats,
|
|
2305
2600
|
getBrainUrl,
|
|
@@ -2361,5 +2656,10 @@ module.exports = {
|
|
|
2361
2656
|
// so the exact D-01/D-02 schedule is unit-tested with zero sleeps.
|
|
2362
2657
|
_parseRetryAfterMs,
|
|
2363
2658
|
_rateLimitWaitMs,
|
|
2659
|
+
// Quick 260910-hni test surface: the composer's injectable deps seam
|
|
2660
|
+
// and the widened askOp recognizer, both offline-drivable with zero
|
|
2661
|
+
// network (see tests/test-339-theo-ask-compose.cjs).
|
|
2662
|
+
_composeTheoAsk,
|
|
2663
|
+
_normalizeAskOpResult,
|
|
2364
2664
|
},
|
|
2365
2665
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Copyright (c) 2026 Mindrian. BSL 1.1.
|
|
6
|
+
*
|
|
7
|
+
* Quick 260911-ddd (DDD-02) -- a content-free Brain pre-warm fired at MCP
|
|
8
|
+
* shim startup so the Brain is already warm by the time the first question
|
|
9
|
+
* arrives, instead of leaving every cold Render wake to silently degrade
|
|
10
|
+
* /mos:act's Tier 3 race to the local heuristic (see
|
|
11
|
+
* lib/mcp/brain-route-bound.cjs for the measured cold-wake numbers this
|
|
12
|
+
* exists to cover).
|
|
13
|
+
*
|
|
14
|
+
* WHY THE TIMEOUT DOES NOT BOUND THE WAKE ITSELF: the request reaching
|
|
15
|
+
* Render is what wakes the instance; aborting our own wait does not cancel
|
|
16
|
+
* that in-flight wake on Render's side. The timeout here only bounds how
|
|
17
|
+
* long THIS PROCESS holds the marker write open waiting for a verdict --
|
|
18
|
+
* which is why 15000 ms is generous without costing anyone anything: even
|
|
19
|
+
* if we give up waiting, the wake we triggered keeps running on Render and
|
|
20
|
+
* the NEXT real call benefits from it.
|
|
21
|
+
*
|
|
22
|
+
* CANON PART 8 (D-02): the probe sends theo_health with NO ARGUMENTS
|
|
23
|
+
* (`callTool('theo_health', {})`, the exact shape class-m-brain-smoke.cjs:225
|
|
24
|
+
* already ships) and the marker persists ONLY {at, ok, origin_host} --
|
|
25
|
+
* never a response body, never a header value, never a question. Every fs
|
|
26
|
+
* and network failure is swallowed; this function NEVER throws and NEVER
|
|
27
|
+
* rejects.
|
|
28
|
+
*
|
|
29
|
+
* MCP STDIO SAFETY: this file writes NOTHING to stdout, ever, because it
|
|
30
|
+
* can run inside an MCP stdio process where a stray stdout byte corrupts
|
|
31
|
+
* the JSON-RPC transport. A single stderr line only when MINDRIAN_DEBUG is
|
|
32
|
+
* set.
|
|
33
|
+
*
|
|
34
|
+
* The deps seam ({ probe, originHost, now, homeDir, timeoutMs }, all
|
|
35
|
+
* optional) mirrors the same injectable-probe discipline
|
|
36
|
+
* lib/core/doctor/class-m-brain-smoke.cjs already uses for its own
|
|
37
|
+
* theoHealthFn seam, so a test drives every arm through deps and never
|
|
38
|
+
* touches a real Brain.
|
|
39
|
+
*
|
|
40
|
+
* No em-dashes. CJS only.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const path = require('path');
|
|
45
|
+
const os = require('os');
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PREWARM_TIMEOUT_MS = 15000;
|
|
48
|
+
|
|
49
|
+
const debugLog = (msg) => {
|
|
50
|
+
if (!process.env.MINDRIAN_DEBUG) return;
|
|
51
|
+
try {
|
|
52
|
+
process.stderr.write('[brain-prewarm] ' + msg + '\n');
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
// swallow -- this function must never throw
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive the timeout (ms) for the probe race. Reads
|
|
60
|
+
* MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS when it parses to a finite positive
|
|
61
|
+
* integer; otherwise DEFAULT_PREWARM_TIMEOUT_MS.
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
function _resolveTimeoutMs() {
|
|
65
|
+
const raw = process.env.MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS;
|
|
66
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return DEFAULT_PREWARM_TIMEOUT_MS;
|
|
67
|
+
const parsed = Number(raw);
|
|
68
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return DEFAULT_PREWARM_TIMEOUT_MS;
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reduce a Brain origin URL to its host only (never the full URL, never a
|
|
74
|
+
* path, never a query string -- the marker holds origin_host, not the URL).
|
|
75
|
+
* @param {string} url
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
function _hostOnly(url) {
|
|
79
|
+
if (typeof url !== 'string' || url.length === 0) return null;
|
|
80
|
+
try {
|
|
81
|
+
return new URL(url).host || null;
|
|
82
|
+
} catch (_e) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* markerPath(homeDir) -- the on-disk location of the pre-warm marker.
|
|
89
|
+
* @param {string} [homeDir] defaults to MINDRIAN_HOME or ~/.mindrian
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function markerPath(homeDir) {
|
|
93
|
+
const home = homeDir || process.env.MINDRIAN_HOME || path.join(os.homedir(), '.mindrian');
|
|
94
|
+
return path.join(home, 'brain-prewarm.json');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* prewarm(deps) -- fires one content-free theo_health probe and persists a
|
|
99
|
+
* minimal marker. NEVER throws, NEVER rejects, NEVER writes to stdout.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} [deps]
|
|
102
|
+
* @param {() => Promise<any>} [deps.probe] defaults to
|
|
103
|
+
* `() => require('./brain-client.cjs').callTool('theo_health', {})`,
|
|
104
|
+
* required LAZILY so importing this module costs nothing.
|
|
105
|
+
* @param {string} [deps.originHost] defaults to the host of
|
|
106
|
+
* `require('./brain-client.cjs').getBrainUrl()`.
|
|
107
|
+
* @param {() => Date} [deps.now] defaults to `() => new Date()`.
|
|
108
|
+
* @param {string} [deps.homeDir] defaults to MINDRIAN_HOME or ~/.mindrian.
|
|
109
|
+
* @param {number} [deps.timeoutMs] defaults to
|
|
110
|
+
* MINDRIAN_BRAIN_PREWARM_TIMEOUT_MS or 15000.
|
|
111
|
+
* @returns {Promise<{ at: string, ok: boolean, origin_host: string|null }>}
|
|
112
|
+
*/
|
|
113
|
+
async function prewarm(deps) {
|
|
114
|
+
deps = deps || {};
|
|
115
|
+
const probe = deps.probe || (() => require('./brain-client.cjs').callTool('theo_health', {}));
|
|
116
|
+
const originHost = (typeof deps.originHost === 'string')
|
|
117
|
+
? deps.originHost
|
|
118
|
+
: _hostOnly((() => {
|
|
119
|
+
try {
|
|
120
|
+
return require('./brain-client.cjs').getBrainUrl();
|
|
121
|
+
} catch (_e) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
})());
|
|
125
|
+
const now = deps.now || (() => new Date());
|
|
126
|
+
const homeDir = deps.homeDir;
|
|
127
|
+
const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0 ? deps.timeoutMs : _resolveTimeoutMs();
|
|
128
|
+
|
|
129
|
+
let ok = false;
|
|
130
|
+
try {
|
|
131
|
+
const TIMEOUT_SENTINEL = Symbol('brain-prewarm-timeout');
|
|
132
|
+
const result = await Promise.race([
|
|
133
|
+
Promise.resolve()
|
|
134
|
+
.then(() => probe())
|
|
135
|
+
.catch(() => null),
|
|
136
|
+
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs)),
|
|
137
|
+
]);
|
|
138
|
+
ok = !!(result && result !== TIMEOUT_SENTINEL && typeof result === 'object');
|
|
139
|
+
} catch (_e) {
|
|
140
|
+
ok = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Canon Part 8 (D-02): EXACTLY three keys, nothing else. Never any part
|
|
144
|
+
// of the probe response body, never a key, never a question.
|
|
145
|
+
const marker = { at: now().toISOString(), ok: ok, origin_host: originHost || null };
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const filePath = markerPath(homeDir);
|
|
149
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
150
|
+
fs.writeFileSync(filePath, JSON.stringify(marker), 'utf8');
|
|
151
|
+
debugLog('marker written: ' + JSON.stringify(marker));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
debugLog('marker write failed: ' + (e && e.message ? e.message : String(e)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return marker;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Directly spawnable: `node lib/core/brain-prewarm.cjs`.
|
|
160
|
+
if (require.main === module) {
|
|
161
|
+
prewarm().catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { prewarm, markerPath };
|
|
@@ -159,6 +159,15 @@ function _copyIfPlainObject(value) {
|
|
|
159
159
|
* `lib/mcp/no-instructions.test.cjs` exists to prevent). When neither field
|
|
160
160
|
* is present, the returned object keeps the exact same seven keys in the
|
|
161
161
|
* exact same insertion order it had before this change.
|
|
162
|
+
*
|
|
163
|
+
* Quick 260910-hni adds `grounding` as a THIRD named additive field, same
|
|
164
|
+
* shape guard and same reason: an untyped top-level copy would let
|
|
165
|
+
* arbitrary Brain-returned keys reach the model, exactly the leakage class
|
|
166
|
+
* `lib/mcp/no-instructions.test.cjs` exists to prevent. `_copyIfPlainObject`
|
|
167
|
+
* is a SHALLOW copy, so `grounding.rows` stays the same array reference,
|
|
168
|
+
* exactly as `refusal.next_moves` already behaves. Absence of `grounding`
|
|
169
|
+
* leaves the seven keys and their insertion order untouched
|
|
170
|
+
* (`tests/test-257-envelope-passthrough.cjs` Arm 3 pins this).
|
|
162
171
|
* @param {object|null} brainResponse
|
|
163
172
|
* @param {object} signals
|
|
164
173
|
* @returns {object} DirectiveEnvelope per CAPABILITY-MAP spec.
|
|
@@ -206,6 +215,10 @@ function wrapDirective(brainResponse, signals) {
|
|
|
206
215
|
if (refusal !== null) {
|
|
207
216
|
envelope.refusal = refusal;
|
|
208
217
|
}
|
|
218
|
+
const grounding = _copyIfPlainObject(brainResponse.grounding);
|
|
219
|
+
if (grounding !== null) {
|
|
220
|
+
envelope.grounding = grounding;
|
|
221
|
+
}
|
|
209
222
|
|
|
210
223
|
return envelope;
|
|
211
224
|
}
|