mindforge-cc 10.7.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.mindforge/MINDFORGE-V2-SCHEMA.json +43 -10
  2. package/.mindforge/config.json +6 -1
  3. package/CHANGELOG.md +64 -0
  4. package/MINDFORGE.md +3 -3
  5. package/README.md +49 -4
  6. package/RELEASENOTES.md +80 -0
  7. package/SECURITY.md +20 -8
  8. package/bin/autonomous/audit-writer.js +13 -0
  9. package/bin/autonomous/auto-runner.js +74 -16
  10. package/bin/autonomous/context-refactorer.js +26 -11
  11. package/bin/autonomous/state-manager.js +62 -6
  12. package/bin/autonomous/stuck-monitor.js +46 -7
  13. package/bin/autonomous/wave-executor.js +66 -25
  14. package/bin/dashboard/api-router.js +43 -0
  15. package/bin/dashboard/metrics-aggregator.js +28 -1
  16. package/bin/dashboard/server.js +67 -4
  17. package/bin/dashboard/sse-bridge.js +4 -4
  18. package/bin/engine/feedback-loop.js +8 -0
  19. package/bin/engine/intelligence-interlock.js +32 -15
  20. package/bin/engine/logic-drift-detector.js +2 -1
  21. package/bin/engine/nexus-tracer.js +3 -2
  22. package/bin/engine/remediation-engine.js +155 -32
  23. package/bin/engine/self-corrective-synthesizer.js +84 -10
  24. package/bin/engine/sre-manager.js +12 -4
  25. package/bin/engine/temporal-hub.js +131 -34
  26. package/bin/governance/approve.js +41 -5
  27. package/bin/governance/impact-analyzer.js +28 -0
  28. package/bin/governance/policy-engine.js +10 -3
  29. package/bin/governance/quantum-crypto.js +32 -19
  30. package/bin/governance/rbac-manager.js +74 -2
  31. package/bin/governance/ztai-manager.js +49 -7
  32. package/bin/hindsight-injector.js +3 -3
  33. package/bin/memory/eis-client.js +71 -34
  34. package/bin/memory/embedding-engine.js +61 -0
  35. package/bin/memory/knowledge-graph.js +58 -5
  36. package/bin/memory/knowledge-indexer.js +53 -6
  37. package/bin/memory/knowledge-store.js +22 -0
  38. package/bin/migrations/10.7.0-to-11.0.0.js +110 -0
  39. package/bin/migrations/schema-versions.js +13 -0
  40. package/bin/models/anthropic-provider.js +45 -0
  41. package/bin/models/cloud-broker.js +68 -20
  42. package/bin/models/gemini-provider.js +51 -0
  43. package/bin/models/model-client.js +20 -0
  44. package/bin/models/model-router.js +28 -8
  45. package/bin/models/openai-provider.js +44 -0
  46. package/bin/utils/file-io.js +63 -1
  47. package/bin/utils/index.js +58 -0
  48. package/docs/getting-started.md +1 -1
  49. package/docs/user-guide.md +2 -2
  50. package/package.json +2 -2
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
12
  const crypto = require('crypto');
13
+ const { execFileSync } = require('child_process');
13
14
 
14
15
  const REASON = process.argv[2] || 'Manual approval for sensitive changes.';
15
16
  const ROOT = path.resolve(__dirname, '../../');
@@ -19,14 +20,47 @@ if (!fs.existsSync(APPROVALS_DIR)) {
19
20
  fs.mkdirSync(APPROVALS_DIR, { recursive: true });
20
21
  }
21
22
 
23
+ /**
24
+ * Attempts to retrieve the GPG signing key configured in git.
25
+ * Returns null if no key is configured or git is unavailable.
26
+ */
27
+ function getGPGSigningKey() {
28
+ try {
29
+ const key = execFileSync('git', ['config', 'user.signingkey'], { encoding: 'utf8' }).trim();
30
+ return key || null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Verifies the identity of the approver using GPG if available.
38
+ * Falls back to git identity only (with warning) if no GPG key is configured.
39
+ * @param {string} approver - The approver identity string
40
+ */
41
+ function verifyApproverIdentity(approver) {
42
+ const gpgKey = getGPGSigningKey();
43
+
44
+ if (!gpgKey) {
45
+ console.warn('[GOVERNANCE] No GPG signing key configured — approval accepted with git identity only');
46
+ return { verified: false, method: 'git_identity', identity: approver };
47
+ }
48
+
49
+ return { verified: true, method: 'gpg_key', identity: approver, keyId: gpgKey };
50
+ }
51
+
22
52
  async function approve() {
23
53
  const pkgPath = path.join(ROOT, 'package.json');
24
54
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
25
55
 
26
56
  const id = `MF-AUTH-${Date.now().toString(36).toUpperCase()}`;
27
57
  const timestamp = new Date().toISOString();
28
-
29
- // Calculate a mock signature based on current state (can be hardened with real crypto sign later)
58
+ const approver = process.env.USER || 'MindForge User';
59
+
60
+ // Verify approver identity (GPG if available, git identity fallback)
61
+ const identityVerification = verifyApproverIdentity(approver);
62
+
63
+ // Calculate a signature based on current state
30
64
  const signature = crypto.createHash('sha256')
31
65
  .update(`${id}:${REASON}:${timestamp}:${os.hostname()}`)
32
66
  .digest('hex');
@@ -36,20 +70,22 @@ async function approve() {
36
70
  project: pkg.name,
37
71
  version: pkg.version,
38
72
  tier: 3,
39
- approved_by: process.env.USER || 'MindForge User',
73
+ approved_by: approver,
40
74
  timestamp,
41
75
  reason: REASON,
42
- signature: `sha256:${signature}`
76
+ signature: `sha256:${signature}`,
77
+ identity_verification: identityVerification
43
78
  };
44
79
 
45
80
  const filename = `approval-${id.toLowerCase()}.json`;
46
81
  const filePath = path.join(APPROVALS_DIR, filename);
47
82
 
48
83
  fs.writeFileSync(filePath, JSON.stringify(record, null, 2));
49
-
84
+
50
85
  console.log('\n✅ Governance approval generated!\n');
51
86
  console.log(`ID: ${id}`);
52
87
  console.log(`Reason: ${REASON}`);
88
+ console.log(`Verified: ${identityVerification.verified ? 'GPG (' + identityVerification.keyId + ')' : 'git identity only (no GPG key)'}`);
53
89
  console.log(`File: .planning/approvals/${filename}`);
54
90
  console.log('\nCommit this file to unblock Tier 3 gates in CI.\n');
55
91
  }
@@ -137,6 +137,34 @@ class ImpactAnalyzer {
137
137
  static resetSession(sessionId) {
138
138
  this.sessionState.delete(sessionId);
139
139
  }
140
+
141
+ /**
142
+ * Returns the current entropy count for a session without incrementing.
143
+ * Useful for diagnostics and monitoring.
144
+ */
145
+ static getSessionEntropy(sessionId) {
146
+ return this.sessionState.get(sessionId) || 0;
147
+ }
148
+
149
+ /**
150
+ * Clears all session state entries. Use during process cleanup or testing.
151
+ */
152
+ static clearAllSessions() {
153
+ this.sessionState.clear();
154
+ }
155
+
156
+ /**
157
+ * Clears sessions that have exceeded a given entropy threshold.
158
+ * Prevents unbounded memory growth from abandoned sessions.
159
+ * @param {number} maxEntropy - Sessions above this count are purged.
160
+ */
161
+ static clearStaleSessions(maxEntropy = 50) {
162
+ for (const [sessionId, count] of this.sessionState.entries()) {
163
+ if (count > maxEntropy) {
164
+ this.sessionState.delete(sessionId);
165
+ }
166
+ }
167
+ }
140
168
  }
141
169
 
142
170
  module.exports = ImpactAnalyzer;
@@ -95,14 +95,21 @@ class PolicyEngine {
95
95
  // [ENTERPRISE] Tier 3 Reasoning/PQ Proof Bypass
96
96
  if (intent.tier >= 3 && (intent.reasoning_proof || intent.pq_proof)) {
97
97
  const quantumCrypto = require('./quantum-crypto');
98
- const isProofValid = intent.pq_proof ?
99
- quantumCrypto.verifyZKProof(intent.pq_proof, intent.id) : true;
98
+ let isProofValid = true;
99
+
100
+ if (intent.pq_proof) {
101
+ const zkResult = quantumCrypto.verifyZKProof(intent.pq_proof, intent.id);
102
+ isProofValid = zkResult.verified === true;
103
+ if (!isProofValid) {
104
+ console.log(`[APO-ZK] [${requestId}] ZK proof denied: ${zkResult.reason}${zkResult.simulated ? ' (simulated)' : ''}`);
105
+ }
106
+ }
100
107
 
101
108
  if (isProofValid) {
102
109
  console.log(`[APO-BYPASS] [${requestId}] Tier 3 'Sovereign Proof' verified (${intent.pq_proof ? 'ZK-PQ' : 'Standard'}). Overriding Blast Radius limit.`);
103
110
  // Continue to permit check
104
111
  } else {
105
- verdict = { verdict: 'DENY', reason: 'Invalid or Malformed ZK-Proof detected.', requestId };
112
+ verdict = { verdict: 'DENY', reason: 'ZK proof verification failed. Configure a verifier module or provide a valid proof.', requestId };
106
113
  this.logAudit(intent, impactScore, verdict);
107
114
  return verdict;
108
115
  }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * MindForge v7 — Post-Quantum Agentic Security (PQAS)
3
3
  * Simulated Lattice-Based Cryptography (Dilithium-5 / Kyber-1024)
4
+ *
5
+ * @typedef {Object} ZKVerifierProvider
6
+ * @property {(proof: string, intentId: string) => {verified: boolean, reason?: string}} verify
4
7
  */
5
8
  'use strict';
6
9
 
@@ -47,6 +50,7 @@ class QuantumCrypto {
47
50
 
48
51
  /**
49
52
  * Signs data using simulated Dilithium-5.
53
+ * @returns {{ signature: string, simulated: true, algorithm: string }}
50
54
  */
51
55
  async signPQ(data, privateKey) {
52
56
  if (!this.pqasEnabled) throw new Error('PQAS is disabled.');
@@ -54,14 +58,15 @@ class QuantumCrypto {
54
58
  throw new Error('Invalid Post-Quantum private key format.');
55
59
  }
56
60
 
57
- // Simulate the lattice-based signature overhead
58
61
  const hash = crypto.createHash('sha3-512').update(data).digest('hex');
59
62
  const salt = crypto.randomBytes(16).toString('hex');
60
-
61
- // Dilithium signatures are significantly larger than Ed25519
62
- const simulatedSignature = `pqas_sig_d5_${Buffer.from(hash + salt).toString('base64')}_${crypto.randomBytes(128).toString('base64')}`;
63
-
64
- return simulatedSignature;
63
+ const signature = `pqas_sig_d5_${Buffer.from(hash + salt).toString('base64')}_${crypto.randomBytes(128).toString('base64')}`;
64
+
65
+ return {
66
+ signature,
67
+ simulated: true,
68
+ algorithm: 'Dilithium-5'
69
+ };
65
70
  }
66
71
 
67
72
  /**
@@ -69,10 +74,11 @@ class QuantumCrypto {
69
74
  */
70
75
  verifyPQ(data, signature, publicKey) {
71
76
  if (!publicKey.startsWith('mfq7_dilithium5_pub_')) return false;
72
- if (!signature.startsWith('pqas_sig_d5_')) return false;
77
+ const sig = typeof signature === 'object' && signature.signature ? signature.signature : signature;
78
+ if (!sig.startsWith('pqas_sig_d5_')) return false;
73
79
 
74
80
  try {
75
- const parts = signature.split('_');
81
+ const parts = sig.split('_');
76
82
  const blob = Buffer.from(parts[3], 'base64').toString('utf8');
77
83
  const hashInSig = blob.slice(0, 128);
78
84
 
@@ -102,17 +108,24 @@ class QuantumCrypto {
102
108
  }
103
109
 
104
110
  verifyZKProof(proof, intentId) {
105
- if (!proof.startsWith('zkp_v1_')) return false;
106
- // SECURITY: Real ZK verification is not yet implemented.
107
- // Governance gate MUST block by default — fail-closed.
108
- console.warn(
109
- `[SECURITY][quantum-crypto] verifyZKProof is a STUB — real ZK verification not yet implemented. ` +
110
- `Blocking proof for intent="${intentId}". All governance checks will fail until a real verifier is integrated.`
111
- );
112
- throw new Error(
113
- 'ZK proof verification is not implemented. Governance gate denies by default. ' +
114
- 'Integrate a real ZK verifier (e.g., snarkjs/circom) before enabling this path.'
115
- );
111
+ if (!proof || !proof.startsWith('zkp_v1_')) {
112
+ return { verified: false, reason: 'invalid_proof_format' };
113
+ }
114
+
115
+ try {
116
+ const verifierModule = configManager.get('security.zk_verifier_module');
117
+ if (verifierModule) {
118
+ const verifier = require(verifierModule);
119
+ return verifier.verify(proof, intentId);
120
+ }
121
+ } catch (e) { /* no external verifier configured */ }
122
+
123
+ return {
124
+ verified: false,
125
+ reason: 'no_verifier_configured',
126
+ simulated: true,
127
+ message: 'ZK proof verification requires an external verifier module (e.g., snarkjs/circom). Configure via security.zk_verifier_module in config.json.'
128
+ };
116
129
  }
117
130
  }
118
131
 
@@ -18,6 +18,7 @@ class RBACManager {
18
18
  'did:mindforge:researcher': ['knowledge-detective'],
19
19
  'did:mindforge:tool': ['system-operator']
20
20
  };
21
+ this.temporaryElevations = new Map(); // key: `${did}:${role}`, value: { timer, expiresAt }
21
22
  }
22
23
 
23
24
  /**
@@ -84,10 +85,72 @@ class RBACManager {
84
85
  fs.writeFileSync(this.rolesPath, JSON.stringify(current, null, 2));
85
86
  }
86
87
 
88
+ /**
89
+ * Temporarily elevates an agent to a role for a limited duration.
90
+ * The elevation auto-expires after ttlMs milliseconds.
91
+ * @param {string} did - Agent DID
92
+ * @param {string} role - Role to temporarily grant
93
+ * @param {number} ttlMs - Time-to-live in milliseconds (default: 1 hour)
94
+ */
95
+ elevateRole(did, role, ttlMs = 3600000) {
96
+ const key = `${did}:${role}`;
97
+
98
+ // Clear existing elevation if any
99
+ if (this.temporaryElevations.has(key)) {
100
+ clearTimeout(this.temporaryElevations.get(key).timer);
101
+ }
102
+
103
+ const timer = setTimeout(() => {
104
+ this.temporaryElevations.delete(key);
105
+ }, ttlMs);
106
+
107
+ // Prevent timer from keeping process alive
108
+ if (timer.unref) timer.unref();
109
+
110
+ this.temporaryElevations.set(key, {
111
+ timer,
112
+ expiresAt: Date.now() + ttlMs
113
+ });
114
+
115
+ return { did, role, expiresAt: Date.now() + ttlMs };
116
+ }
117
+
118
+ /**
119
+ * Checks if an agent currently has a temporary role elevation.
120
+ * @param {string} did - Agent DID
121
+ * @param {string} role - Role to check
122
+ */
123
+ hasTemporaryElevation(did, role) {
124
+ const key = `${did}:${role}`;
125
+ const elevation = this.temporaryElevations.get(key);
126
+ if (!elevation) return false;
127
+ if (Date.now() > elevation.expiresAt) {
128
+ clearTimeout(elevation.timer);
129
+ this.temporaryElevations.delete(key);
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Revokes a temporary elevation before its TTL expires.
137
+ * @param {string} did - Agent DID
138
+ * @param {string} role - Role to revoke
139
+ */
140
+ revokeElevation(did, role) {
141
+ const key = `${did}:${role}`;
142
+ const elevation = this.temporaryElevations.get(key);
143
+ if (elevation) {
144
+ clearTimeout(elevation.timer);
145
+ this.temporaryElevations.delete(key);
146
+ }
147
+ }
148
+
87
149
  /**
88
150
  * Checks if an agent has a specific permission based on their roles.
89
- * @param {string} did
90
- * @param {string} permission
151
+ * Also checks temporary elevations.
152
+ * @param {string} did
153
+ * @param {string} permission
91
154
  */
92
155
  hasPermission(did, permission) {
93
156
  const roles = this.getRoles(did);
@@ -99,9 +162,18 @@ class RBACManager {
99
162
  'guest-agent': ['read_src']
100
163
  };
101
164
 
165
+ // Check static roles first
102
166
  for (const role of roles) {
103
167
  if (PERMISSION_MAP[role]?.includes(permission)) return true;
104
168
  }
169
+
170
+ // Check temporary elevations
171
+ for (const [role, permissions] of Object.entries(PERMISSION_MAP)) {
172
+ if (permissions.includes(permission) && this.hasTemporaryElevation(did, role)) {
173
+ return true;
174
+ }
175
+ }
176
+
105
177
  return false;
106
178
  }
107
179
  }
@@ -122,9 +122,10 @@ class QuantumSafeKeyProvider extends KeyProvider {
122
122
  async sign(did, data) {
123
123
  const record = this.keyStore.get(did);
124
124
  if (!record) throw new Error(`PQ record not found for ${did}`);
125
-
125
+
126
126
  console.log(`[PQAS-DILITHIUM] Delegating signature to lattice enclave [DID: ${did}]`);
127
- return await this.quantumCrypto.signPQ(data, record.privateKey);
127
+ const result = await this.quantumCrypto.signPQ(data, record.privateKey);
128
+ return result;
128
129
  }
129
130
 
130
131
  async rotate(did) {
@@ -148,24 +149,34 @@ class ZTAIManager {
148
149
 
149
150
  /**
150
151
  * Registers a new agent and assigns a provider based on Trust Tier.
152
+ * @param {string} persona - Agent persona identifier
153
+ * @param {number} tier - Trust tier (1-4)
154
+ * @param {string|null} sessionId - Optional session scope for isolation
151
155
  */
152
- async registerAgent(persona, tier = 1) {
156
+ async registerAgent(persona, tier = 1, sessionId = null) {
153
157
  const uuid = crypto.randomUUID();
154
158
  const did = `did:mindforge:${uuid}`;
155
-
159
+
156
160
  // Tier 3 agents use the SecureEnclaveProvider
157
161
  const providerType = tier >= 3 ? 'enclave' : 'local';
158
162
  const provider = this.providers[providerType];
159
-
163
+
160
164
  const publicKeyPEM = await provider.generate(did);
161
165
 
162
- this.agentRegistry.set(did, {
166
+ const agentData = {
163
167
  publicKey: publicKeyPEM,
164
168
  persona,
165
169
  tier,
166
170
  providerType,
167
171
  createdAt: new Date().toISOString()
168
- });
172
+ };
173
+
174
+ // Store sessionId if provided for session-scoped isolation
175
+ if (sessionId) {
176
+ agentData.sessionId = sessionId;
177
+ }
178
+
179
+ this.agentRegistry.set(did, agentData);
169
180
 
170
181
  return did;
171
182
  }
@@ -219,6 +230,37 @@ class ZTAIManager {
219
230
  return this.agentRegistry.get(did);
220
231
  }
221
232
 
233
+ /**
234
+ * Returns all agents registered under a specific session.
235
+ * @param {string} sessionId - Session identifier to filter by
236
+ */
237
+ getSessionAgents(sessionId) {
238
+ const results = [];
239
+ for (const [did, agent] of this.agentRegistry.entries()) {
240
+ if (agent.sessionId === sessionId) {
241
+ results.push({ did, ...agent });
242
+ }
243
+ }
244
+ return results;
245
+ }
246
+
247
+ /**
248
+ * Revokes all agents belonging to a session. Used for session cleanup.
249
+ * @param {string} sessionId - Session identifier
250
+ */
251
+ revokeSessionAgents(sessionId) {
252
+ const dids = [];
253
+ for (const [did, agent] of this.agentRegistry.entries()) {
254
+ if (agent.sessionId === sessionId) {
255
+ dids.push(did);
256
+ }
257
+ }
258
+ for (const did of dids) {
259
+ this.revokeAgent(did);
260
+ }
261
+ return dids;
262
+ }
263
+
222
264
  /**
223
265
  * Specialized signing for FinOps budget decisions (Pillar V).
224
266
  */
@@ -43,9 +43,9 @@ class HindsightInjector {
43
43
  }
44
44
 
45
45
  // 4. Capture the new state immediately
46
- TemporalHub.captureState(hindsightEvent.id, {
47
- event: 'hindsight_injected',
48
- target_id: auditId
46
+ await TemporalHub.captureState(hindsightEvent.id, {
47
+ event: 'hindsight_injected',
48
+ target_id: auditId
49
49
  });
50
50
 
51
51
  return { success: true, event: hindsightEvent };
@@ -22,19 +22,42 @@ class EISClient {
22
22
  * @param {Array} entries - Local knowledge entries to sync.
23
23
  */
24
24
  async push(entries) {
25
- console.log(`[EIS-SYNC] Pushing ${entries.length} entries to Enterprise Intelligence Service...`);
26
-
27
- // Simulate network request
28
- return new Promise((resolve) => {
29
- setTimeout(() => {
30
- const results = entries.map(e => ({
31
- id: e.id,
32
- status: 'synced',
33
- version: crypto.createHash('sha256').update(JSON.stringify(e)).digest('hex').slice(0, 8)
34
- }));
35
- resolve(results);
36
- }, 500);
37
- });
25
+ if (!this.endpoint || this.endpoint === 'http://localhost:7340') {
26
+ return {
27
+ synced: entries.length,
28
+ hashes: entries.map(e => e.id || crypto.createHash('sha256').update(JSON.stringify(e)).digest('hex').slice(0, 8))
29
+ };
30
+ }
31
+
32
+ const url = `${this.endpoint}/api/v1/knowledge/push`;
33
+ const body = JSON.stringify({ entries, orgId: this.orgId });
34
+
35
+ let lastError;
36
+ for (let attempt = 0; attempt < 3; attempt++) {
37
+ try {
38
+ const headers = await this.getAuthHeader('push', 'knowledge');
39
+ headers['Content-Type'] = 'application/json';
40
+
41
+ const response = await fetch(url, {
42
+ method: 'POST',
43
+ headers,
44
+ body,
45
+ signal: AbortSignal.timeout(10000)
46
+ });
47
+
48
+ if (!response.ok) {
49
+ throw new Error(`EIS push failed: ${response.status}`);
50
+ }
51
+
52
+ return await response.json();
53
+ } catch (e) {
54
+ lastError = e;
55
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
56
+ }
57
+ }
58
+
59
+ console.warn(`[EIS] Push failed after 3 retries: ${lastError.message}`);
60
+ return { synced: 0, error: lastError.message };
38
61
  }
39
62
 
40
63
  /**
@@ -42,35 +65,49 @@ class EISClient {
42
65
  * @param {Object} filter - Filter criteria (e.g. since timestamp).
43
66
  */
44
67
  async pull(filter = {}) {
45
- console.log(`[EIS-SYNC] Pulling new organizational knowledge from ${this.endpoint}...`);
46
-
47
- // Simulate network response
48
- return new Promise((resolve) => {
49
- setTimeout(() => {
50
- // Return empty array for now as this is a simulation
51
- resolve([]);
52
- }, 300);
53
- });
68
+ if (!this.endpoint || this.endpoint === 'http://localhost:7340') {
69
+ return [];
70
+ }
71
+
72
+ const url = `${this.endpoint}/api/v1/knowledge/pull`;
73
+ const body = JSON.stringify({ filter, orgId: this.orgId });
74
+
75
+ let lastError;
76
+ for (let attempt = 0; attempt < 3; attempt++) {
77
+ try {
78
+ const headers = await this.getAuthHeader('pull', 'knowledge');
79
+ headers['Content-Type'] = 'application/json';
80
+
81
+ const response = await fetch(url, {
82
+ method: 'POST',
83
+ headers,
84
+ body,
85
+ signal: AbortSignal.timeout(10000)
86
+ });
87
+
88
+ if (!response.ok) {
89
+ throw new Error(`EIS pull failed: ${response.status}`);
90
+ }
91
+
92
+ return await response.json();
93
+ } catch (e) {
94
+ lastError = e;
95
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
96
+ }
97
+ }
98
+
99
+ console.warn(`[EIS] Pull failed after 3 retries: ${lastError.message}`);
100
+ return [];
54
101
  }
55
102
 
56
- /**
57
- * Verifies the authenticity of a remote knowledge entry.
58
- * @param {Object} entry - The remote entry.
59
- * @param {String} signature - The ZTAI signature from the remote agent.
60
- */
103
+ // TODO: implement when remote nodes are available
61
104
  verifyRemoteProvenance(entry, signature) {
62
105
  if (!signature) return false;
63
- // Real implementation would use ZTAIManager to verify the DID signature
64
106
  return true;
65
107
  }
66
108
 
67
- /**
68
- * Resolves a remote node reference.
69
- * @param {String} nodeId - The ID of the remote node.
70
- */
109
+ // TODO: implement when remote nodes are available
71
110
  async resolveRemoteNode(nodeId) {
72
- console.log(`[EIS-RESOLVE] Resolving remote node: ${nodeId}`);
73
- // Real implementation would fetch from the EIS API
74
111
  return null;
75
112
  }
76
113
 
@@ -130,6 +130,65 @@ function computeTfIdfVector(tokens, df, N) {
130
130
  return capped;
131
131
  }
132
132
 
133
+ // ── BM25 Scoring ─────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * BM25 relevance scoring with document length normalization.
137
+ * @param {string[]} queryTokens - Tokenized query
138
+ * @param {string[]} docTokens - Tokenized document
139
+ * @param {Object<string, number>} docFrequency - term → number of docs containing term
140
+ * @param {number} totalDocs - Total documents in corpus
141
+ * @param {number} avgDocLength - Average document length across corpus
142
+ * @returns {number} BM25 score
143
+ */
144
+ function bm25Score(queryTokens, docTokens, docFrequency, totalDocs, avgDocLength) {
145
+ const k1 = 1.5;
146
+ const b = 0.75;
147
+ let score = 0;
148
+ const docLength = docTokens.length;
149
+
150
+ for (const term of queryTokens) {
151
+ const tf = docTokens.filter(t => t === term).length;
152
+ const df = docFrequency[term] || 0;
153
+ const idf = Math.log((totalDocs - df + 0.5) / (df + 0.5) + 1);
154
+ const tfNorm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLength / avgDocLength)));
155
+ score += idf * tfNorm;
156
+ }
157
+ return score;
158
+ }
159
+
160
+ /**
161
+ * Build a reusable BM25 index structure from knowledge entries.
162
+ * Applies 2x weighting to compound terms (camelCase/underscore bigrams).
163
+ * @param {object[]} entries - Knowledge entries with { id, topic, content, tags }
164
+ * @returns {{ docFrequency: Object<string, number>, avgDocLength: number, tokenizedDocs: Array<{id: string, tokens: string[]}> }}
165
+ */
166
+ function buildBM25Index(entries) {
167
+ const tokenizedDocs = entries
168
+ .filter(e => !e.deprecated)
169
+ .map(e => {
170
+ const text = `${e.topic || ''} ${e.content || ''} ${(e.tags || []).join(' ')}`;
171
+ const unigrams = tokenize(text);
172
+ const bi = bigrams(unigrams);
173
+ // Weight compound terms at 2x by duplicating bigrams
174
+ const tokens = [...unigrams, ...bi, ...bi];
175
+ return { id: e.id, tokens };
176
+ });
177
+
178
+ const docFrequency = {};
179
+ for (const doc of tokenizedDocs) {
180
+ const unique = new Set(doc.tokens);
181
+ for (const term of unique) {
182
+ docFrequency[term] = (docFrequency[term] || 0) + 1;
183
+ }
184
+ }
185
+
186
+ const totalTokens = tokenizedDocs.reduce((sum, doc) => sum + doc.tokens.length, 0);
187
+ const avgDocLength = tokenizedDocs.length > 0 ? totalTokens / tokenizedDocs.length : 0;
188
+
189
+ return { docFrequency, avgDocLength, tokenizedDocs };
190
+ }
191
+
133
192
  // ── Similarity ────────────────────────────────────────────────────────────────
134
193
 
135
194
  /**
@@ -321,6 +380,8 @@ module.exports = {
321
380
  inferEdges,
322
381
  saveCache,
323
382
  loadCache,
383
+ bm25Score,
384
+ buildBM25Index,
324
385
  SIMILARITY_THRESHOLD,
325
386
  SHADOW_THRESHOLD,
326
387
  };