@mindrian_os/install 1.13.0-beta.12 → 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.
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Brain HTTP Client calls mindrian-brain.onrender.com
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
- * Get the Brain API key from environment.
118
- * Checks: MINDRIAN_BRAIN_KEY, then falls back to reading .env in CWD.
119
- * Every .env candidate path is gated by checkFilePermissions (SEC-02).
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 (process.env.MINDRIAN_BRAIN_KEY) {
123
- return process.env.MINDRIAN_BRAIN_KEY;
153
+ if (_memoizedAt && (Date.now() - _memoizedAt) < _GETKEY_MEMO_MS) {
154
+ return _memoizedKey;
124
155
  }
125
- // Try reading .env from CWD
126
- try {
127
- const fs = require('fs');
128
- const path = require('path');
129
- const envPath = path.join(process.cwd(), '.env');
130
- if (fs.existsSync(envPath) && checkFilePermissions(envPath)) {
131
- const content = fs.readFileSync(envPath, 'utf8');
132
- const match = content.match(/MINDRIAN_BRAIN_KEY=(.+)/);
133
- if (match) return match[1].trim();
134
- }
135
- } catch (e) {}
136
- // Fallback: try reading ~/.mindrian.env (global backup)
137
- try {
138
- const fs = require('fs');
139
- const path = require('path');
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
- * framework names, phase identifiers, problem types per Canon Part 8
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 brain-router.cjs, brain-derivation.cjs's
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 all read `result.records`,
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 only `query` is normalized.
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
- return callTool('brain_schema', {});
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
  };