@mindrian_os/install 1.13.0-beta.11 → 1.13.0-beta.13
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 +68 -3
- package/bin/cli.js +114 -57
- package/commands/act.md +16 -2
- package/commands/auto-explore.md +1 -0
- package/commands/doctor.md +1 -1
- package/commands/operator.md +1 -1
- package/commands/pipeline.md +16 -1
- package/commands/setup.md +7 -3
- package/commands/suggest-next.md +17 -3
- package/lib/core/active-plugin-root.cjs +207 -0
- package/lib/core/brain-client.cjs +451 -36
- package/lib/core/cache-prune.cjs +208 -0
- package/lib/core/framework-chain-composer.cjs +156 -43
- package/lib/core/migrations/phase-109-nodes-provenance.cjs +47 -0
- package/lib/core/navigation/memory-events.cjs +17 -1
- package/lib/core/navigation/neighborhood.cjs +5 -4
- package/lib/core/navigation/packet.cjs +87 -1
- package/lib/core/navigation.cjs +6 -0
- package/lib/core/resolve-brain-key.cjs +201 -0
- package/lib/hmi/jtbd-taxonomy.json +2 -1
- package/lib/memory/framework-chain-composer.test.cjs +54 -20
- package/lib/memory/navigation-hook-resolver.test.cjs +177 -0
- package/lib/memory/run-feynman-tests.cjs +102 -0
- package/lib/memory/security-trifecta.test.cjs +23 -6
- package/lib/memory/suggest-next-workflow.test.cjs +176 -0
- package/lib/memory/workflow-layer-e2e.test.cjs +262 -0
- package/lib/workflow/ROOM.md +1 -1
- package/package.json +4 -1
- package/references/brain/command-triggers-schema.md +10 -221
- package/references/methodology/index.md +11 -74
- package/skills/brain-connector/SKILL.md +12 -8
- package/skills/pws-methodology/SKILL.md +7 -5
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Brain HTTP Client
|
|
4
|
+
* Brain HTTP Client -- calls the Brain HTTP server (currently
|
|
5
|
+
* `https://mindrian-brain.onrender.com`, moving to `https://brain.mindrian.ai`;
|
|
6
|
+
* override via the MINDRIAN_BRAIN_URL env var for staging / self-hosted).
|
|
5
7
|
*
|
|
6
8
|
* Replaces direct MCP tool calls (mcp__neo4j-brain__*, mcp__pinecone-brain__*)
|
|
7
9
|
* with a single HTTP API that handles Neo4j + Pinecone behind one key.
|
|
@@ -20,6 +22,13 @@
|
|
|
20
22
|
|
|
21
23
|
const BRAIN_URL = process.env.MINDRIAN_BRAIN_URL || 'https://mindrian-brain.onrender.com';
|
|
22
24
|
|
|
25
|
+
// Per-request hard timeout for every Brain HTTP call (init handshake + tool
|
|
26
|
+
// calls). Node's global fetch() has NO default timeout, so without this a
|
|
27
|
+
// slow/wedged Brain hangs the calling /mos: command indefinitely. The Render
|
|
28
|
+
// service answers in ~1-2s normally; 20s is a generous-but-bounded ceiling.
|
|
29
|
+
// Override via MINDRIAN_BRAIN_TIMEOUT_MS (brain-router wants ~2000 for Tier 3).
|
|
30
|
+
const BRAIN_REQUEST_TIMEOUT_MS = Number(process.env.MINDRIAN_BRAIN_TIMEOUT_MS) || 20000;
|
|
31
|
+
|
|
23
32
|
// Phase 87-07 (CASCADE-06): Brain session cache with 5-minute TTL.
|
|
24
33
|
// Every callTool() previously re-ran the `initialize` handshake (~1 network
|
|
25
34
|
// round-trip). With a long-lived MCP server this is wasted work -- sessions
|
|
@@ -40,6 +49,13 @@ const BRAIN_URL = process.env.MINDRIAN_BRAIN_URL || 'https://mindrian-brain.onre
|
|
|
40
49
|
// users; sha256 is effectively free at these volumes and eliminates the
|
|
41
50
|
// concern entirely (R-87-07-RACE).
|
|
42
51
|
const crypto = require('node:crypto');
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
// Phase 123 Plan-07: getApiKey() delegates to the single Brain-key resolver.
|
|
55
|
+
// The legacy inline 3-path lookup (env -> CWD .env -> ~/.mindrian.env) is gone;
|
|
56
|
+
// the resolver does env -> ~/.mindrian.env -> CWD .env (D-31 order) + SEC-02
|
|
57
|
+
// POSIX permission check + explicit reason strings. See HARNESS-123-15.
|
|
58
|
+
const { resolveBrainKey } = require('./resolve-brain-key.cjs');
|
|
43
59
|
const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
44
60
|
/** @type {Map<string, {promise: Promise<string>, expiresAt: number}>} */
|
|
45
61
|
const sessionCache = new Map();
|
|
@@ -113,37 +129,45 @@ function checkFilePermissions(envPath) {
|
|
|
113
129
|
}
|
|
114
130
|
checkFilePermissions._warned = false;
|
|
115
131
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
// Phase 123 Plan-07: getApiKey() delegates to lib/core/resolve-brain-key.cjs.
|
|
133
|
+
//
|
|
134
|
+
// Order: MINDRIAN_BRAIN_KEY env -> ~/.mindrian.env -> CWD .env -> not-found.
|
|
135
|
+
// NOTE: this REVERSES the previous CWD-first-then-~/.mindrian.env order.
|
|
136
|
+
// Rationale: the global backup (~/.mindrian.env, mode 0600 per SEC-02) is more
|
|
137
|
+
// trustworthy than a project's potentially-stale .env file. Cited: Phase 123
|
|
138
|
+
// D-31. The resolver returns { key, source, available, reason }; we surface
|
|
139
|
+
// the key (or null) here and log a non-null reason ONCE per process via
|
|
140
|
+
// console.error -- SEC-02 group/world-bit rejection routes through this
|
|
141
|
+
// channel, never as a silent null.
|
|
142
|
+
//
|
|
143
|
+
// The legacy inline 3-path lookup and the per-file checkFilePermissions gate
|
|
144
|
+
// previously inlined here are gone -- the resolver owns both responsibilities
|
|
145
|
+
// now. checkFilePermissions remains exported on _test for backward-compat
|
|
146
|
+
// with security-trifecta.test.cjs (the helper itself still works locally;
|
|
147
|
+
// it's just not called by getApiKey anymore).
|
|
148
|
+
let _memoizedKey = null;
|
|
149
|
+
let _memoizedAt = 0;
|
|
150
|
+
let _reasonLoggedThisProcess = false;
|
|
151
|
+
const _GETKEY_MEMO_MS = 60 * 1000;
|
|
121
152
|
function getApiKey() {
|
|
122
|
-
if (
|
|
123
|
-
return
|
|
153
|
+
if (_memoizedAt && (Date.now() - _memoizedAt) < _GETKEY_MEMO_MS) {
|
|
154
|
+
return _memoizedKey;
|
|
124
155
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const globalEnvPath = path.join(require('os').homedir(), '.mindrian.env');
|
|
141
|
-
if (fs.existsSync(globalEnvPath) && checkFilePermissions(globalEnvPath)) {
|
|
142
|
-
const content = fs.readFileSync(globalEnvPath, 'utf8');
|
|
143
|
-
const match = content.match(/MINDRIAN_BRAIN_KEY=(.+)/);
|
|
144
|
-
if (match) return match[1].trim();
|
|
145
|
-
}
|
|
146
|
-
} catch (e) {}
|
|
156
|
+
const r = resolveBrainKey();
|
|
157
|
+
if (r && r.available) {
|
|
158
|
+
_memoizedKey = r.key;
|
|
159
|
+
_memoizedAt = Date.now();
|
|
160
|
+
return _memoizedKey;
|
|
161
|
+
}
|
|
162
|
+
if (r && r.reason && !_reasonLoggedThisProcess) {
|
|
163
|
+
// SEC-02 reject + not-found-with-reason both route through stderr ONCE
|
|
164
|
+
// per process -- never a silent null. The session-start status line is
|
|
165
|
+
// the user-visible surface; this is the in-process diagnostic.
|
|
166
|
+
process.stderr.write('[mindrian-os] Brain key not loaded: ' + r.reason + '\n');
|
|
167
|
+
_reasonLoggedThisProcess = true;
|
|
168
|
+
}
|
|
169
|
+
_memoizedKey = null;
|
|
170
|
+
_memoizedAt = Date.now();
|
|
147
171
|
return null;
|
|
148
172
|
}
|
|
149
173
|
|
|
@@ -189,6 +213,7 @@ async function _ensureSession(apiKey) {
|
|
|
189
213
|
const promise = (async () => {
|
|
190
214
|
const initRes = await fetch(`${BRAIN_URL}/mcp`, {
|
|
191
215
|
method: 'POST',
|
|
216
|
+
signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS),
|
|
192
217
|
headers: {
|
|
193
218
|
'Content-Type': 'application/json',
|
|
194
219
|
'Accept': 'application/json, text/event-stream',
|
|
@@ -249,6 +274,7 @@ async function callTool(toolName, args) {
|
|
|
249
274
|
// Call the tool
|
|
250
275
|
const toolRes = await fetch(`${BRAIN_URL}/mcp`, {
|
|
251
276
|
method: 'POST',
|
|
277
|
+
signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS),
|
|
252
278
|
headers: {
|
|
253
279
|
'Content-Type': 'application/json',
|
|
254
280
|
'Accept': 'application/json, text/event-stream',
|
|
@@ -306,7 +332,7 @@ async function callTool(toolName, args) {
|
|
|
306
332
|
* as { cypher, params }. The Brain tool declares `params:
|
|
307
333
|
* z.record(z.any()).optional()`, so a parameterized Cypher gets its
|
|
308
334
|
* bindings through cleanly. `params` MUST be a generic-handles-only object
|
|
309
|
-
*
|
|
335
|
+
* -- framework names, phase identifiers, problem types per Canon Part 8 --
|
|
310
336
|
* NEVER user content (artifact bodies, meeting text, personal identifiers,
|
|
311
337
|
* proprietary numbers). Previously the second arg was silently dropped, so
|
|
312
338
|
* callers (rs-explain-command.cjs, rs-thesis-command.cjs, rs-nl-to-query)
|
|
@@ -319,9 +345,9 @@ async function callTool(toolName, args) {
|
|
|
319
345
|
* `JSON.stringify(records)` where `records` is a BARE ARRAY of row objects.
|
|
320
346
|
* `callTool` returns that array directly (or `{ text: 'Error: ...' }` on a
|
|
321
347
|
* Cypher error, or `null` when the Brain is unreachable / no API key).
|
|
322
|
-
* Consumers across the codebase
|
|
348
|
+
* Consumers across the codebase -- brain-router.cjs, brain-derivation.cjs's
|
|
323
349
|
* `renderRecords`, rs-chain-feeder.cjs, rs-experts-command.cjs,
|
|
324
|
-
* rs-explain-command.cjs, rs-thesis-command.cjs
|
|
350
|
+
* rs-explain-command.cjs, rs-thesis-command.cjs -- all read `result.records`,
|
|
325
351
|
* so the bare-array shape silently dropped every row. `query` therefore now
|
|
326
352
|
* ALWAYS returns `{ records: [...] }` on a successful brain_query; an
|
|
327
353
|
* unreachable Brain / missing key still returns `null`; a Cypher-error
|
|
@@ -329,7 +355,7 @@ async function callTool(toolName, args) {
|
|
|
329
355
|
* unchanged so callers that inspect the failure can still see it; any other
|
|
330
356
|
* unexpected shape collapses to `{ records: [] }` so callers never crash.
|
|
331
357
|
* `search`, `smartSearch`, `schema`, `stats`, `write`, `callTool` are
|
|
332
|
-
* deliberately untouched
|
|
358
|
+
* deliberately untouched -- only `query` is normalized.
|
|
333
359
|
*/
|
|
334
360
|
async function query(cypher, params) {
|
|
335
361
|
const args = { cypher: cypher };
|
|
@@ -393,11 +419,49 @@ async function smartSearch(queryText, options = {}) {
|
|
|
393
419
|
return pineconeResult;
|
|
394
420
|
}
|
|
395
421
|
|
|
422
|
+
// brain_schema is near-static (the teaching graph's label/relationship/property
|
|
423
|
+
// taxonomy changes ~never) and is hit by several modules. Memoize it
|
|
424
|
+
// process-wide for 30 minutes.
|
|
425
|
+
let _schemaCache = null;
|
|
426
|
+
let _schemaCacheAt = 0;
|
|
427
|
+
const SCHEMA_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
428
|
+
|
|
396
429
|
/**
|
|
397
|
-
* Get Neo4j schema.
|
|
430
|
+
* Get the Brain Neo4j schema (node labels, relationship types, property keys).
|
|
431
|
+
* Memoized for 30 minutes (process-wide).
|
|
398
432
|
*/
|
|
399
433
|
async function schema() {
|
|
400
|
-
|
|
434
|
+
if (_schemaCache && (Date.now() - _schemaCacheAt) < SCHEMA_CACHE_TTL_MS) {
|
|
435
|
+
return _schemaCache;
|
|
436
|
+
}
|
|
437
|
+
const result = await callTool('brain_schema', {});
|
|
438
|
+
if (result != null) {
|
|
439
|
+
_schemaCache = result;
|
|
440
|
+
_schemaCacheAt = Date.now();
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Natural-language methodology question against the Brain (wraps brain_ask).
|
|
447
|
+
*
|
|
448
|
+
* brain_ask auto-routes Pinecone/Neo4j server-side and handles its own
|
|
449
|
+
* fallback -- it is the highest-level Brain entry point. Prefer it over a
|
|
450
|
+
* hand-rolled Cypher query when the caller has a natural-language methodology
|
|
451
|
+
* question. Canon Part 8: the question string carries only generic methodology
|
|
452
|
+
* language -- never user artifacts, meeting text, or personal identifiers.
|
|
453
|
+
*
|
|
454
|
+
* Returns the parsed brain_ask payload ({ question, keyword, source, count,
|
|
455
|
+
* results: [...] }) on success; a { text: 'Error: ...' } / { error: ... }
|
|
456
|
+
* passthrough on a server-side error; null when the Brain is unreachable or no
|
|
457
|
+
* API key is configured (graceful degradation -- mirrors query()).
|
|
458
|
+
*
|
|
459
|
+
* @param {string} question
|
|
460
|
+
* @returns {Promise<object|null>}
|
|
461
|
+
*/
|
|
462
|
+
async function ask(question) {
|
|
463
|
+
if (typeof question !== 'string' || !question.trim()) return null;
|
|
464
|
+
return callTool('brain_ask', { question: question });
|
|
401
465
|
}
|
|
402
466
|
|
|
403
467
|
/**
|
|
@@ -836,6 +900,339 @@ async function getFrameworkChain(persona) {
|
|
|
836
900
|
return getTier0Chain(personaLower);
|
|
837
901
|
}
|
|
838
902
|
|
|
903
|
+
// ============================================================================
|
|
904
|
+
// Phase 110-03: Brain Context Packet wire enforcement (Canon Part 8 + Part 9).
|
|
905
|
+
// ============================================================================
|
|
906
|
+
//
|
|
907
|
+
// sendPacket(packet, opts) is the SOLE typed-packet wire path. It runs:
|
|
908
|
+
// (1) D-08 layer-3 origin allowlist (the belt to the schema's suspenders --
|
|
909
|
+
// a caller who bypassed ajv still gets caught here).
|
|
910
|
+
// (2) Unknown-job guard against the closed D-02 vocabulary (12 jobs).
|
|
911
|
+
// (3) ajv in-validation (reject hard: throw + log brain_packet_rejected).
|
|
912
|
+
// (4) POST tools/call name:'brain_packet' via the existing callTool transport
|
|
913
|
+
// (degrade soft when the tool is absent: { advice:null, reason:
|
|
914
|
+
// 'brain_packet_tool_absent' }; on transport error: { advice:null,
|
|
915
|
+
// reason: 'brain_unreachable' }). Never throws on Brain-side problems.
|
|
916
|
+
// (5) ajv out-validation (reject hard, degrade soft: log
|
|
917
|
+
// brain_response_rejected + return { advice:null, reason:
|
|
918
|
+
// 'response_schema_invalid' }). Never throws, never partial-ingests.
|
|
919
|
+
// (6) Returns the validated out payload on success.
|
|
920
|
+
//
|
|
921
|
+
// ajv@8.18.0 is transitive via @modelcontextprotocol/sdk -- do NOT add it to
|
|
922
|
+
// package.json (CLAUDE.md "What NOT to Use"). strict:false at runtime; the
|
|
923
|
+
// strict:true build gate lives in scripts/build-brain-packet-schema.cjs. The
|
|
924
|
+
// schema is draft 2020-12, so Ajv2020 is required (ajv/dist/2020).
|
|
925
|
+
//
|
|
926
|
+
// SHIPPED_JOBS is the D-02 closed vocabulary (locked in 110-CONTEXT D-02).
|
|
927
|
+
|
|
928
|
+
const Ajv2020 = require('ajv/dist/2020').default || require('ajv/dist/2020');
|
|
929
|
+
|
|
930
|
+
const _BRAIN_PACKET_SCHEMA_PATH = process.env.MINDRIAN_BRAIN_PACKET_SCHEMA
|
|
931
|
+
|| path.join(__dirname, '..', '..', 'data', 'brain-packet-schema.json');
|
|
932
|
+
|
|
933
|
+
const SHIPPED_JOBS = new Set([
|
|
934
|
+
'select_methodology',
|
|
935
|
+
'suggest_next_move',
|
|
936
|
+
'detect_contradiction',
|
|
937
|
+
'summarize_neighborhood',
|
|
938
|
+
'classify_room_budding',
|
|
939
|
+
'rank_assumptions',
|
|
940
|
+
'generate_feynman_explanation',
|
|
941
|
+
'strengthen_minto',
|
|
942
|
+
'prepare_investor_brief',
|
|
943
|
+
'opportunity_react',
|
|
944
|
+
'opportunity_reflect',
|
|
945
|
+
'opportunity_rank',
|
|
946
|
+
]);
|
|
947
|
+
|
|
948
|
+
let _ajv = null;
|
|
949
|
+
let _schemaRaw = null;
|
|
950
|
+
const _jobValidators = new Map(); // job -> { in: fn, out: fn }
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Lazily compile the brain-packet schema. Reads data/brain-packet-schema.json
|
|
954
|
+
* (or process.env.MINDRIAN_BRAIN_PACKET_SCHEMA, the test seam set up by 110-01)
|
|
955
|
+
* once at first use; subsequent calls reuse the in-memory Ajv instance.
|
|
956
|
+
*
|
|
957
|
+
* NOTE on the schema's pointer shape: the 110-01 schema's per-job $defs are
|
|
958
|
+
* { in: {...}, out: {...} } -- NOT { properties: { in, out } }. So the JSON
|
|
959
|
+
* pointer for a job half is #/$defs/<job>/<half>, not the planner's interfaces
|
|
960
|
+
* sketch shape #/$defs/<job>/properties/<half>. Compiled wrappers carry the
|
|
961
|
+
* root's $defs inline because ajv 8.x cannot resolve a deep JSON pointer into
|
|
962
|
+
* a schema indexed only by its absolute $id (a known ajv@8 quirk -- the build
|
|
963
|
+
* script in scripts/build-brain-packet-schema.cjs validates the root by
|
|
964
|
+
* compiling it directly with the same Ajv2020 class).
|
|
965
|
+
*/
|
|
966
|
+
function _ensureSchema() {
|
|
967
|
+
if (_ajv) return;
|
|
968
|
+
_schemaRaw = JSON.parse(fs.readFileSync(_BRAIN_PACKET_SCHEMA_PATH, 'utf8'));
|
|
969
|
+
_ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
970
|
+
// Pre-add the root so cross-refs (FocusNode, Origin, BankedOpportunities,
|
|
971
|
+
// BrainResponse, etc.) resolve when sub-schemas reference them. Wrapper
|
|
972
|
+
// schemas below carry $defs inline as a defensive duplicate.
|
|
973
|
+
_ajv.addSchema(_schemaRaw);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Return the memoized in/out validator for a given (job, half). Half is
|
|
978
|
+
* 'in' or 'out'. Compiles on first use.
|
|
979
|
+
*
|
|
980
|
+
* @param {string} job - D-02 jobname (must be in SHIPPED_JOBS).
|
|
981
|
+
* @param {'in'|'out'} half
|
|
982
|
+
* @returns {Function} a compiled ajv validator function.
|
|
983
|
+
*/
|
|
984
|
+
function _validatorFor(job, half) {
|
|
985
|
+
_ensureSchema();
|
|
986
|
+
let pair = _jobValidators.get(job);
|
|
987
|
+
if (!pair) { pair = {}; _jobValidators.set(job, pair); }
|
|
988
|
+
if (!pair[half]) {
|
|
989
|
+
pair[half] = _ajv.compile({
|
|
990
|
+
$id: 'urn:mindrian:brain-packet:' + job + ':' + half,
|
|
991
|
+
$ref: '#/$defs/' + job + '/' + half,
|
|
992
|
+
$defs: _schemaRaw.$defs,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return pair[half];
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Reset the lazy schema state. Test hook only -- NOT part of the public API.
|
|
1000
|
+
* Used by 110-05 (the per-job validation suite) and 110-04 (the pre-commit
|
|
1001
|
+
* tripwire) to swap the schema seam (MINDRIAN_BRAIN_PACKET_SCHEMA) and re-run.
|
|
1002
|
+
*/
|
|
1003
|
+
function _resetSchema() {
|
|
1004
|
+
_ajv = null;
|
|
1005
|
+
_schemaRaw = null;
|
|
1006
|
+
_jobValidators.clear();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Best-effort memory_event log. Skips silently if opts.db is absent OR if the
|
|
1011
|
+
* navigation re-export throws (Brain telemetry should never break a Brain call).
|
|
1012
|
+
*
|
|
1013
|
+
* @param {object} db - SQLite db handle from openRoomDb (or undefined).
|
|
1014
|
+
* @param {string} eventType - one of 'brain_packet_rejected' /
|
|
1015
|
+
* 'brain_response_rejected' / 'brain_legacy_path_used'
|
|
1016
|
+
* (Phase 110-02 added these to EVENT_TYPES).
|
|
1017
|
+
* @param {object} payload - { job, errors, source_path, ... } scalars only.
|
|
1018
|
+
*/
|
|
1019
|
+
function _logEventBestEffort(db, eventType, payload) {
|
|
1020
|
+
if (!db) return;
|
|
1021
|
+
try {
|
|
1022
|
+
// Lazy-require to avoid a circular load between brain-client.cjs and
|
|
1023
|
+
// navigation.cjs (navigation.cjs does NOT require brain-client.cjs today,
|
|
1024
|
+
// but this stays robust if a future closure of the loop ever happens).
|
|
1025
|
+
require('./navigation.cjs').logMemoryEvent(db, eventType, payload || {});
|
|
1026
|
+
} catch (_e) { /* best-effort */ }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Detect a "Brain doesn't recognize this tool" error in the callTool result.
|
|
1031
|
+
* Per the callTool JSDoc around line ~329, the result on a server-side error
|
|
1032
|
+
* is shaped { text: 'Error: ...' } (Cypher errors pass through the same way).
|
|
1033
|
+
* A missing brain_packet tool will show up as -32602 / 'unknown tool' / 'No
|
|
1034
|
+
* such tool' / a 404-ish marker. Be liberal: D-04 says "degrade gracefully
|
|
1035
|
+
* when the Brain doesn't recognize the contract."
|
|
1036
|
+
*
|
|
1037
|
+
* @param {*} result
|
|
1038
|
+
* @returns {boolean}
|
|
1039
|
+
*/
|
|
1040
|
+
function _looksLikeUnknownToolError(result) {
|
|
1041
|
+
if (!result) return false;
|
|
1042
|
+
let t = '';
|
|
1043
|
+
if (typeof result === 'string') t = result;
|
|
1044
|
+
else if (typeof result === 'object') t = result.text || result.error || JSON.stringify(result);
|
|
1045
|
+
const s = String(t).toLowerCase();
|
|
1046
|
+
return s.includes('unknown tool')
|
|
1047
|
+
|| s.includes('no such tool')
|
|
1048
|
+
|| s.includes('method not found')
|
|
1049
|
+
|| s.includes('-32602')
|
|
1050
|
+
|| (s.includes('brain_packet') && (s.includes('not') || s.includes('unknown')))
|
|
1051
|
+
|| s.includes('tool not found');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Unwrap the callTool result to the Brain response object the schema wants to
|
|
1056
|
+
* validate. callTool returns:
|
|
1057
|
+
* - an array (the tools/call content array on a successful brain_query-style
|
|
1058
|
+
* parsed result),
|
|
1059
|
+
* - or a parsed object (when content[0] is text and parses as JSON),
|
|
1060
|
+
* - or { text: '...' } (text content that did not parse as JSON, or an error
|
|
1061
|
+
* string),
|
|
1062
|
+
* - or null (unreachable / no API key -- handled before this is called).
|
|
1063
|
+
*
|
|
1064
|
+
* For brain_packet specifically, the Brain side (when implemented) will return
|
|
1065
|
+
* a BrainResponse-shaped JSON object: { job_id, suggestions: [...] }. Older
|
|
1066
|
+
* test transports inject [{ type:'text', text: JSON.stringify(...) }] (the raw
|
|
1067
|
+
* MCP content array shape). Both shapes are unwrapped to the response object.
|
|
1068
|
+
*
|
|
1069
|
+
* @param {*} result
|
|
1070
|
+
* @returns {object}
|
|
1071
|
+
*/
|
|
1072
|
+
function _parseBrainResult(result) {
|
|
1073
|
+
if (Array.isArray(result)) {
|
|
1074
|
+
for (const item of result) {
|
|
1075
|
+
if (item && item.type === 'text' && typeof item.text === 'string') {
|
|
1076
|
+
try { return JSON.parse(item.text); } catch (_) { /* fall through */ }
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return { suggestions: [] };
|
|
1080
|
+
}
|
|
1081
|
+
if (result && typeof result === 'object') {
|
|
1082
|
+
// text-only error/string shape -- try to parse, else return { suggestions: [] }
|
|
1083
|
+
if (typeof result.text === 'string' && !result.job_id && !result.suggestions) {
|
|
1084
|
+
try { return JSON.parse(result.text); } catch (_) { return { suggestions: [] }; }
|
|
1085
|
+
}
|
|
1086
|
+
return result;
|
|
1087
|
+
}
|
|
1088
|
+
if (typeof result === 'string') {
|
|
1089
|
+
try { return JSON.parse(result); } catch (_) { return { suggestions: [] }; }
|
|
1090
|
+
}
|
|
1091
|
+
return { suggestions: [] };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ----------------------------------------------------------------------------
|
|
1095
|
+
// _warnLegacyOnce -- the D-10 dual-path deprecation guard.
|
|
1096
|
+
//
|
|
1097
|
+
// As of v1.13.0-beta.3 there is NO legacy free-form Brain *job* call site --
|
|
1098
|
+
// new job-style work goes through sendPacket(). The guard ships now as a
|
|
1099
|
+
// forward-looking contract: if a free-form job helper is ever added before
|
|
1100
|
+
// v1.14.0, it MUST call _warnLegacyOnce() first; in v1.14.0 both the helper
|
|
1101
|
+
// and this guard are deleted.
|
|
1102
|
+
//
|
|
1103
|
+
// query() / write() / search() / schema() / callTool() / ask() / stats() are
|
|
1104
|
+
// NOT "legacy" -- raw-Cypher methodology lookups carry only generic handles
|
|
1105
|
+
// (framework names, phase identifiers, problem-type enums) and are Part-8
|
|
1106
|
+
// clean by construction; they are PERMANENT.
|
|
1107
|
+
//
|
|
1108
|
+
// Module-level flag idiom mirrors checkFilePermissions._warned (the existing
|
|
1109
|
+
// once-per-process warning pattern in this file).
|
|
1110
|
+
// ----------------------------------------------------------------------------
|
|
1111
|
+
|
|
1112
|
+
let _legacyPathWarned = false;
|
|
1113
|
+
|
|
1114
|
+
function _warnLegacyOnce(db) {
|
|
1115
|
+
if (_legacyPathWarned) return;
|
|
1116
|
+
_legacyPathWarned = true;
|
|
1117
|
+
// eslint-disable-next-line no-console
|
|
1118
|
+
console.warn('[mindrian-os] legacy free-form Brain job call detected. '
|
|
1119
|
+
+ 'Migrate to brain-client.sendPacket() -- the legacy job path is removed in v1.14.0.');
|
|
1120
|
+
_logEventBestEffort(db, 'brain_legacy_path_used', {
|
|
1121
|
+
source_path: 'system:brain-legacy',
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Validate-then-route a Brain Context Packet. The ONLY door for typed Brain
|
|
1127
|
+
* job calls.
|
|
1128
|
+
*
|
|
1129
|
+
* Per CONTEXT D-07 "reject hard, degrade soft":
|
|
1130
|
+
* - Bad in-packet (a programmer error in OUR code) -> throw.
|
|
1131
|
+
* - Bad origin (D-08 layer 3) -> throw.
|
|
1132
|
+
* - Unknown job -> throw.
|
|
1133
|
+
* - Brain unreachable -> { advice: null, reason: 'brain_unreachable' } (no throw).
|
|
1134
|
+
* - brain_packet tool absent on the Brain -> { advice: null, reason:
|
|
1135
|
+
* 'brain_packet_tool_absent' } (no throw, no log -- D-04 graceful degrade
|
|
1136
|
+
* is NOT a bad-response situation; a half-trusted response IS one).
|
|
1137
|
+
* - Bad out-response -> { advice: null, reason: 'response_schema_invalid' }
|
|
1138
|
+
* + log brain_response_rejected. NEVER throws, NEVER partial-ingests.
|
|
1139
|
+
*
|
|
1140
|
+
* @param {object} packet - MUST come from lib/core/navigation.cjs::buildBrainPacket
|
|
1141
|
+
* (D-01 chokepoint; D-08 layer 2 enforces lexically;
|
|
1142
|
+
* D-08 layer 3 is the origin check below).
|
|
1143
|
+
* @param {object} [opts] - { db?, roomDir?, __transport? }
|
|
1144
|
+
* db -> the SQLite handle for the memory_event log
|
|
1145
|
+
* roomDir -> reserved for future use; not read today
|
|
1146
|
+
* __transport -> optional test seam: a function
|
|
1147
|
+
* (toolName, args) => Promise<result> that
|
|
1148
|
+
* replaces callTool (lets tests inject a
|
|
1149
|
+
* fake without require.cache surgery)
|
|
1150
|
+
* @returns {Promise<object>} the validated Brain out on success; a no-advice
|
|
1151
|
+
* sentinel on a soft-degrade.
|
|
1152
|
+
* @throws on a bad in-packet, a bad origin (D-08 layer 3), or an unknown job.
|
|
1153
|
+
*/
|
|
1154
|
+
async function sendPacket(packet, opts) {
|
|
1155
|
+
const o = opts || {};
|
|
1156
|
+
|
|
1157
|
+
// (1) D-08 layer 3 -- the belt to the schema's suspenders. Runs FIRST so a
|
|
1158
|
+
// caller who bypassed ajv still gets caught.
|
|
1159
|
+
if (!packet || typeof packet.origin !== 'string') {
|
|
1160
|
+
throw new Error('brain packet missing origin (D-08): packets must come from buildBrainPacket');
|
|
1161
|
+
}
|
|
1162
|
+
if (packet.origin === 'test_fixture' && process.env.MINDRIAN_TEST_MODE !== '1') {
|
|
1163
|
+
throw new Error('brain packet origin "test_fixture" only valid when MINDRIAN_TEST_MODE=1');
|
|
1164
|
+
}
|
|
1165
|
+
if (packet.origin !== 'navigation_api' && packet.origin !== 'test_fixture') {
|
|
1166
|
+
throw new Error('brain packet origin "' + packet.origin
|
|
1167
|
+
+ '" not in the closed allowlist (D-08): navigation_api | test_fixture');
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// (2) Unknown-job guard.
|
|
1171
|
+
if (!SHIPPED_JOBS.has(packet.job)) {
|
|
1172
|
+
throw new Error('brain packet: unknown job "' + packet.job
|
|
1173
|
+
+ '" (not in the D-02 closed vocabulary)');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// (3) in-validation -- reject hard.
|
|
1177
|
+
const inFn = _validatorFor(packet.job, 'in');
|
|
1178
|
+
if (!inFn(packet)) {
|
|
1179
|
+
// Pitfall 1: snapshot errors immediately -- validate.errors is overwritten
|
|
1180
|
+
// on every subsequent validate() call.
|
|
1181
|
+
const errsSnapshot = (inFn.errors || []).slice();
|
|
1182
|
+
const errStr = errsSnapshot
|
|
1183
|
+
.map(function (e) { return (e.instancePath || '(root)') + ' ' + (e.message || ''); })
|
|
1184
|
+
.join('; ');
|
|
1185
|
+
_logEventBestEffort(o.db, 'brain_packet_rejected', {
|
|
1186
|
+
job: packet.job,
|
|
1187
|
+
errors: errsSnapshot.length,
|
|
1188
|
+
source_path: 'system:brain-packet',
|
|
1189
|
+
});
|
|
1190
|
+
throw new Error('brain packet rejected for job "' + packet.job + '": ' + errStr);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// (4) Build the wire envelope + POST. Reuse callTool's transport (it does
|
|
1194
|
+
// tools/call over Streamable HTTP + SSE-parse). Tests can inject a fake
|
|
1195
|
+
// via opts.__transport.
|
|
1196
|
+
const transport = (typeof o.__transport === 'function') ? o.__transport : callTool;
|
|
1197
|
+
let result;
|
|
1198
|
+
try {
|
|
1199
|
+
result = await transport('brain_packet', { packet: packet });
|
|
1200
|
+
} catch (_e) {
|
|
1201
|
+
// Network / transport error: treat like the Brain being unreachable.
|
|
1202
|
+
// No log -- it is not a leak, and not a contract violation either.
|
|
1203
|
+
return { advice: null, reason: 'brain_unreachable' };
|
|
1204
|
+
}
|
|
1205
|
+
if (result == null) {
|
|
1206
|
+
// callTool returns null when there is no API key or when the HTTP layer
|
|
1207
|
+
// returned a non-ok status -- functionally the same as "Brain unreachable".
|
|
1208
|
+
return { advice: null, reason: 'brain_unreachable' };
|
|
1209
|
+
}
|
|
1210
|
+
if (_looksLikeUnknownToolError(result)) {
|
|
1211
|
+
// The live Brain has no brain_packet tool yet (D-04 generalized).
|
|
1212
|
+
// Degrade soft, NO brain_response_rejected log -- this is NOT a bad
|
|
1213
|
+
// response; it is the absence of a contract handler.
|
|
1214
|
+
return { advice: null, reason: 'brain_packet_tool_absent' };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// (5) out-validation -- reject hard but degrade soft.
|
|
1218
|
+
const parsed = _parseBrainResult(result);
|
|
1219
|
+
const outFn = _validatorFor(packet.job, 'out');
|
|
1220
|
+
if (!outFn(parsed)) {
|
|
1221
|
+
const errsSnapshot = (outFn.errors || []).slice();
|
|
1222
|
+
_logEventBestEffort(o.db, 'brain_response_rejected', {
|
|
1223
|
+
job: packet.job,
|
|
1224
|
+
errors: errsSnapshot.length,
|
|
1225
|
+
source_path: 'system:brain-packet',
|
|
1226
|
+
});
|
|
1227
|
+
// NEVER throw on a bad response; NEVER partial-ingest.
|
|
1228
|
+
return { advice: null, reason: 'response_schema_invalid' };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// (6) Success -- the caller gets a known-good shape (typically then calls
|
|
1232
|
+
// navigation.storeBrainSuggestions).
|
|
1233
|
+
return parsed;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
839
1236
|
module.exports = {
|
|
840
1237
|
isAvailable,
|
|
841
1238
|
getApiKey,
|
|
@@ -844,16 +1241,27 @@ module.exports = {
|
|
|
844
1241
|
write,
|
|
845
1242
|
search,
|
|
846
1243
|
smartSearch,
|
|
1244
|
+
ask,
|
|
847
1245
|
schema,
|
|
848
1246
|
stats,
|
|
849
1247
|
enrichCausalEdges,
|
|
850
1248
|
hatAwareRecommend,
|
|
851
1249
|
suggestValidationSteps,
|
|
852
1250
|
getFrameworkChain,
|
|
1251
|
+
// Phase 110-03 (Brain Context Packet Contract wire-level enforcement):
|
|
1252
|
+
// sendPacket is the SOLE typed-packet wire path; _warnLegacyOnce is the
|
|
1253
|
+
// forward-looking D-10 deprecation guard with no current call site.
|
|
1254
|
+
sendPacket,
|
|
1255
|
+
_warnLegacyOnce,
|
|
853
1256
|
// SEC-01/SEC-02 + CASCADE-06 test surface: not part of the public API.
|
|
854
1257
|
// See lib/memory/security-trifecta.test.cjs + brain-cache-lru.test.cjs.
|
|
855
1258
|
// Helpers are small and pure. sessionCache + _ensureSession + _hashKey +
|
|
856
1259
|
// SESSION_TTL_MS are exposed for Phase 87-07 cache-behavior tests.
|
|
1260
|
+
// Phase 110-03: ajv middleware test seam (_resetSchema clears the lazy
|
|
1261
|
+
// ajv state so a test can swap MINDRIAN_BRAIN_PACKET_SCHEMA and re-run;
|
|
1262
|
+
// _setLegacyWarned resets the once-per-session deprecation flag;
|
|
1263
|
+
// _validatorFor / _parseBrainResult / _looksLikeUnknownToolError are
|
|
1264
|
+
// the helpers Plan 110-05 covers in its per-job round-trip suite).
|
|
857
1265
|
_test: {
|
|
858
1266
|
sanitizeCypherInput,
|
|
859
1267
|
checkFilePermissions,
|
|
@@ -861,5 +1269,12 @@ module.exports = {
|
|
|
861
1269
|
SESSION_TTL_MS,
|
|
862
1270
|
_hashKey,
|
|
863
1271
|
_ensureSession,
|
|
1272
|
+
_resetSchema,
|
|
1273
|
+
_validatorFor,
|
|
1274
|
+
_parseBrainResult,
|
|
1275
|
+
_looksLikeUnknownToolError,
|
|
1276
|
+
_ensureSchema,
|
|
1277
|
+
SHIPPED_JOBS,
|
|
1278
|
+
_setLegacyWarned: function (v) { _legacyPathWarned = !!v; },
|
|
864
1279
|
},
|
|
865
1280
|
};
|