@equilateral_ai/mindmeld 3.5.0 → 3.5.1

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.
@@ -10,6 +10,15 @@
10
10
 
11
11
  const { executeQuery } = require('./dbOperations');
12
12
  const crypto = require('crypto');
13
+ const { BedrockRuntimeClient, InvokeModelCommand } = require('@aws-sdk/client-bedrock-runtime');
14
+
15
+ let _bedrockClient = null;
16
+ function getBedrockClient() {
17
+ if (!_bedrockClient) {
18
+ _bedrockClient = new BedrockRuntimeClient({ region: 'us-east-2' });
19
+ }
20
+ return _bedrockClient;
21
+ }
13
22
 
14
23
  const SERVER_INFO = {
15
24
  name: 'mindmeld',
@@ -30,6 +39,7 @@ const CORS_HEADERS = {
30
39
  // ============================================================
31
40
 
32
41
  const CATEGORY_WEIGHTS = {
42
+ // Code standard categories
33
43
  'serverless-saas-aws': 1.0,
34
44
  'frontend-development': 1.0,
35
45
  'database': 0.9,
@@ -41,6 +51,16 @@ const CATEGORY_WEIGHTS = {
41
51
  'well-architected': 0.7,
42
52
  'cost-optimization': 0.7,
43
53
  'multi-agent-orchestration': 0.1,
54
+ // Business domains
55
+ 'ip-strategy': 0.6,
56
+ 'architecture-decisions': 0.8,
57
+ 'go-to-market': 0.6,
58
+ 'operations': 0.5,
59
+ 'legal-process': 0.5,
60
+ 'finance': 0.5,
61
+ 'communication': 0.4,
62
+ 'product-strategy': 0.6,
63
+ 'investor-relations': 0.4,
44
64
  };
45
65
 
46
66
  // ============================================================
@@ -87,12 +107,51 @@ const TOOLS = [
87
107
  type: 'object',
88
108
  properties: {
89
109
  maturity: { type: 'array', items: { type: 'string' }, description: 'Filter by maturity: provisional, solidified, reinforced' },
110
+ content_type: { type: 'string', enum: ['code_standard', 'business_invariant'], description: 'Filter by content type. Omit for all.' },
111
+ domain: { type: 'string', description: 'Filter by domain (e.g., "ip-strategy", "architecture-decisions"). Omit for all.' },
112
+ source: { type: 'string', description: 'Filter by corpus source (e.g., "equilateral-standards", "mcp-extraction", "nist-800-53"). Omit for all.' },
90
113
  standard_name: { type: 'string', description: 'Filter by standard name (partial match)' },
91
114
  limit: { type: 'integer', description: 'Max results (default 20)' }
92
115
  }
93
116
  }
94
117
  }
95
118
  }
119
+ },
120
+ {
121
+ name: 'mindmeld_ingest_raw_session',
122
+ description: 'Extract business invariants from a raw conversation transcript using LLM analysis. Returns invariant candidates in BUSINESS-SCHEMA shape with confidence scores. Use dry_run=true to preview before committing.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ session_text: {
127
+ type: 'string',
128
+ description: 'Raw conversation transcript to extract invariants from'
129
+ },
130
+ source_label: {
131
+ type: 'string',
132
+ description: 'Label for provenance tracking (e.g., "claude-chat-2026-02-24-patent-filing")'
133
+ },
134
+ domain_hint: {
135
+ type: 'string',
136
+ description: 'Optional domain hint to guide classification (e.g., "ip-strategy", "architecture-decisions")'
137
+ },
138
+ auto_maturity: {
139
+ type: 'string',
140
+ enum: ['provisional', 'solidified'],
141
+ description: 'Maturity level for extracted invariants. Default: "provisional". Only use "solidified" when multiple independent sessions have validated the same decision.'
142
+ },
143
+ dry_run: {
144
+ type: 'boolean',
145
+ description: 'If true, return extracted candidates without committing to corpus. Default: true'
146
+ },
147
+ model: {
148
+ type: 'string',
149
+ enum: ['haiku', 'sonnet', 'opus'],
150
+ description: 'Claude model for extraction. haiku=fast/cheap, sonnet=balanced (default), opus=deepest extraction'
151
+ }
152
+ },
153
+ required: ['session_text', 'source_label']
154
+ }
96
155
  }
97
156
  ];
98
157
 
@@ -100,10 +159,89 @@ const TOOLS = [
100
159
  // Auth: API Token Validation
101
160
  // ============================================================
102
161
 
162
+ /**
163
+ * Validate a Cognito JWT access token.
164
+ * Decodes the JWT, verifies issuer and expiry, looks up user by email.
165
+ * Full signature verification uses Cognito JWKS (cached).
166
+ */
167
+ let _jwksCache = null;
168
+ let _jwksCacheTime = 0;
169
+ const COGNITO_ISSUER = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_638OhwuV1';
170
+ const JWKS_URL = `${COGNITO_ISSUER}/.well-known/jwks.json`;
171
+ const JWKS_CACHE_TTL = 3600000; // 1 hour
172
+
173
+ async function fetchJwks() {
174
+ if (_jwksCache && (Date.now() - _jwksCacheTime) < JWKS_CACHE_TTL) {
175
+ return _jwksCache;
176
+ }
177
+ const https = require('https');
178
+ return new Promise((resolve, reject) => {
179
+ https.get(JWKS_URL, (res) => {
180
+ let data = '';
181
+ res.on('data', (chunk) => { data += chunk; });
182
+ res.on('end', () => {
183
+ try {
184
+ _jwksCache = JSON.parse(data);
185
+ _jwksCacheTime = Date.now();
186
+ resolve(_jwksCache);
187
+ } catch (e) { reject(e); }
188
+ });
189
+ }).on('error', reject);
190
+ });
191
+ }
192
+
193
+ function base64UrlDecode(str) {
194
+ str = str.replace(/-/g, '+').replace(/_/g, '/');
195
+ while (str.length % 4) str += '=';
196
+ return Buffer.from(str, 'base64');
197
+ }
198
+
199
+ async function validateCognitoJwt(token) {
200
+ // Decode header and payload without verification first
201
+ const parts = token.split('.');
202
+ if (parts.length !== 3) return null;
203
+
204
+ let header, payload;
205
+ try {
206
+ header = JSON.parse(base64UrlDecode(parts[0]).toString());
207
+ payload = JSON.parse(base64UrlDecode(parts[1]).toString());
208
+ } catch (e) { return null; }
209
+
210
+ // Check issuer and expiry
211
+ if (payload.iss !== COGNITO_ISSUER) return null;
212
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
213
+ if (payload.token_use !== 'access') return null;
214
+
215
+ // Verify signature using JWKS
216
+ try {
217
+ const jwks = await fetchJwks();
218
+ const key = jwks.keys?.find(k => k.kid === header.kid);
219
+ if (!key) return null;
220
+
221
+ // Build RSA public key from JWK
222
+ const keyObject = crypto.createPublicKey({ key, format: 'jwk' });
223
+ const verify = crypto.createVerify('RSA-SHA256');
224
+ verify.update(`${parts[0]}.${parts[1]}`);
225
+ if (!verify.verify(keyObject, base64UrlDecode(parts[2]))) return null;
226
+ } catch (e) {
227
+ console.error('[MCP] JWT signature verification failed:', e.message);
228
+ return null;
229
+ }
230
+
231
+ // Extract user info — Cognito access tokens have 'username' and 'sub'
232
+ return {
233
+ sub: payload.sub,
234
+ username: payload.username,
235
+ email: payload.username, // Cognito username is typically email
236
+ scope: payload.scope,
237
+ };
238
+ }
239
+
103
240
  async function validateApiToken(headers) {
104
- // Support both header formats:
241
+ // Support multiple auth methods:
105
242
  // 1. X-MindMeld-Token: mm_live_xxx (existing clients, stdio bridge)
106
- // 2. Authorization: Bearer mm_live_xxx (Claude.ai connector, standard OAuth)
243
+ // 2. Authorization: Bearer mm_live_xxx (API token)
244
+ // 3. Authorization: Bearer <cognito-jwt> (OAuth via Cognito)
107
245
  let token = headers['x-mindmeld-token'] || headers['X-MindMeld-Token'];
108
246
 
109
247
  if (!token) {
@@ -114,9 +252,47 @@ async function validateApiToken(headers) {
114
252
  }
115
253
 
116
254
  if (!token) {
117
- return { error: 'auth_invalid', message: 'Authentication required. Provide X-MindMeld-Token header or Authorization: Bearer token.' };
255
+ return { error: 'auth_required', message: 'Authentication required.' };
118
256
  }
119
257
 
258
+ // Check if token looks like a JWT (has 3 dot-separated parts, starts with eyJ)
259
+ if (token.startsWith('eyJ') && token.split('.').length === 3) {
260
+ const jwtUser = await validateCognitoJwt(token);
261
+ if (!jwtUser) {
262
+ return { error: 'auth_invalid', message: 'Invalid or expired OAuth token' };
263
+ }
264
+
265
+ // Look up user by email/username in our database
266
+ const result = await executeQuery(`
267
+ SELECT u.email_address, c.client_id, c.subscription_tier, c.subscription_status,
268
+ ue.company_id
269
+ FROM rapport.users u
270
+ JOIN rapport.user_entitlements ue ON u.email_address = ue.email_address
271
+ JOIN rapport.clients c ON ue.client_id = c.client_id
272
+ WHERE u.email_address = $1 OR u.cognito_sub = $2
273
+ LIMIT 1
274
+ `, [jwtUser.email, jwtUser.sub]);
275
+
276
+ if (result.rows.length === 0) {
277
+ return { error: 'auth_invalid', message: 'User not found. Sign up at mindmeld.dev' };
278
+ }
279
+
280
+ const row = result.rows[0];
281
+ if (!row.subscription_tier || row.subscription_tier === 'free') {
282
+ return { error: 'auth_invalid', message: 'Active MindMeld subscription required. Subscribe at app.mindmeld.dev' };
283
+ }
284
+
285
+ return {
286
+ user: {
287
+ email: row.email_address,
288
+ client_id: row.client_id,
289
+ company_id: row.company_id,
290
+ subscription_tier: row.subscription_tier
291
+ }
292
+ };
293
+ }
294
+
295
+ // API token path (mm_live_xxx)
120
296
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
121
297
 
122
298
  const result = await executeQuery(`
@@ -164,7 +340,7 @@ function rankStandards(standards, recentCategories) {
164
340
  let score = 0;
165
341
  score += (standard.correlation || 1.0) * 40;
166
342
 
167
- const maturityScores = { enforced: 30, validated: 20, recommended: 10, provisional: 5 };
343
+ const maturityScores = { enforced: 30, reinforced: 25, validated: 20, solidified: 15, recommended: 10, provisional: 5 };
168
344
  score += maturityScores[standard.maturity] || 0;
169
345
 
170
346
  const categoryWeight = CATEGORY_WEIGHTS[standard.category] || 0.5;
@@ -184,6 +360,8 @@ function rankStandards(standards, recentCategories) {
184
360
  || (Array.isArray(standard.keywords) && standard.keywords.includes('workflow'));
185
361
  if (isWorkflow) score += 10;
186
362
 
363
+ if (standard.rationale) score += 5;
364
+
187
365
  if (recentCategories && recentCategories[standard.category]) {
188
366
  const usageCount = recentCategories[standard.category];
189
367
  let rawBonus;
@@ -211,11 +389,14 @@ function formatInjection(sessionId, standards) {
211
389
  sections.push('Licensed for use within MindMeld platform only. Redistribution prohibited.');
212
390
  sections.push('');
213
391
 
214
- if (standards.length > 0) {
392
+ const codeStandards = standards.filter(s => s.content_type !== 'business_invariant');
393
+ const businessInvariants = standards.filter(s => s.content_type === 'business_invariant');
394
+
395
+ if (codeStandards.length > 0) {
215
396
  sections.push('## Relevant Standards');
216
397
  sections.push('');
217
398
 
218
- for (const standard of standards) {
399
+ for (const standard of codeStandards) {
219
400
  sections.push(`### ${standard.element}`);
220
401
  sections.push(`**Category**: ${standard.category}`);
221
402
  sections.push(`**Rule**: ${standard.rule}`);
@@ -245,6 +426,30 @@ function formatInjection(sessionId, standards) {
245
426
  }
246
427
  }
247
428
 
429
+ if (businessInvariants.length > 0) {
430
+ sections.push('## Business Invariants');
431
+ sections.push('');
432
+
433
+ for (const invariant of businessInvariants) {
434
+ sections.push(`### ${invariant.element}`);
435
+ sections.push(`**Domain**: ${invariant.category}`);
436
+ sections.push(`**Invariant**: ${invariant.rule}`);
437
+ if (invariant.rationale) {
438
+ sections.push(`**Rationale**: ${invariant.rationale}`);
439
+ }
440
+ if (invariant.consequences) {
441
+ sections.push(`**If violated**: ${invariant.consequences}`);
442
+ }
443
+ if (invariant.exceptions && Array.isArray(invariant.exceptions) && invariant.exceptions.length > 0) {
444
+ sections.push('**Exceptions**:');
445
+ for (const ex of invariant.exceptions) {
446
+ sections.push(`- ${ex}`);
447
+ }
448
+ }
449
+ sections.push('');
450
+ }
451
+ }
452
+
248
453
  sections.push('---');
249
454
  sections.push('*Context provided by MindMeld - mindmeld.dev*');
250
455
 
@@ -263,6 +468,8 @@ async function callTool(name, args, user) {
263
468
  return await toolRecordCorrection(args, user);
264
469
  case 'mindmeld_get_standards':
265
470
  return await toolGetStandards(args, user);
471
+ case 'mindmeld_ingest_raw_session':
472
+ return await toolIngestRawSession(args, user);
266
473
  default:
267
474
  throw new Error(`Unknown tool: ${name}`);
268
475
  }
@@ -272,22 +479,29 @@ async function toolInitSession(args, user) {
272
479
  const { project_path, task_description } = args;
273
480
  const sessionId = crypto.randomUUID();
274
481
 
275
- // Try to match project by name for the user's company
482
+ // Try to match project by name for the user's company, auto-create if not found
276
483
  let projectId = null;
277
- if (project_path) {
278
- const projectName = project_path.split('/').filter(Boolean).pop();
279
- try {
280
- const projectResult = await executeQuery(`
281
- SELECT project_id FROM rapport.projects
282
- WHERE company_id = $1 AND LOWER(project_name) = LOWER($2)
283
- LIMIT 1
284
- `, [user.company_id, projectName]);
285
- if (projectResult.rows.length > 0) {
286
- projectId = projectResult.rows[0].project_id;
287
- }
288
- } catch (err) {
289
- console.error('[MCP] Project lookup failed:', err.message);
484
+ const projectName = project_path ? project_path.split('/').filter(Boolean).pop() : 'default';
485
+ try {
486
+ const projectResult = await executeQuery(`
487
+ SELECT project_id FROM rapport.projects
488
+ WHERE company_id = $1 AND LOWER(project_name) = LOWER($2)
489
+ LIMIT 1
490
+ `, [user.company_id, projectName]);
491
+ if (projectResult.rows.length > 0) {
492
+ projectId = projectResult.rows[0].project_id;
493
+ } else {
494
+ // Auto-create project so session INSERT never fails on NOT NULL
495
+ const newId = crypto.randomUUID();
496
+ await executeQuery(`
497
+ INSERT INTO rapport.projects (project_id, company_id, project_name, description, created_at)
498
+ VALUES ($1, $2, $3, $4, NOW())
499
+ ON CONFLICT (project_id) DO NOTHING
500
+ `, [newId, user.company_id, projectName, `Auto-created from MCP session (${project_path || 'no path'})`]);
501
+ projectId = newId;
290
502
  }
503
+ } catch (err) {
504
+ console.error('[MCP] Project lookup/create failed:', err.message);
291
505
  }
292
506
 
293
507
  // Get recency data for scoring boost
@@ -311,9 +525,12 @@ async function toolInitSession(args, user) {
311
525
  }
312
526
 
313
527
  // Default to broad categories (no filesystem scanning in Lambda)
528
+ // Includes business domains so invariants can surface via recency boost
314
529
  const categories = [
315
530
  'serverless-saas-aws', 'frontend-development', 'database', 'backend',
316
- 'compliance-security', 'well-architected', 'cost-optimization', 'deployment', 'testing'
531
+ 'compliance-security', 'well-architected', 'cost-optimization', 'deployment', 'testing',
532
+ 'ip-strategy', 'architecture-decisions', 'go-to-market', 'operations',
533
+ 'legal-process', 'finance', 'communication', 'product-strategy', 'investor-relations'
317
534
  ];
318
535
 
319
536
  // Merge recency categories
@@ -321,16 +538,14 @@ async function toolInitSession(args, user) {
321
538
  if (!categories.includes(cat)) categories.push(cat);
322
539
  }
323
540
 
324
- // Query standards
541
+ // Query standards — tenant-isolated via get_effective_standards()
542
+ const maturityList = ['enforced', 'validated', 'recommended', 'provisional', 'solidified', 'reinforced'];
325
543
  const result = await executeQuery(`
326
- SELECT pattern_id, element, title, rule, category, keywords, correlation,
327
- maturity, applicable_files, anti_patterns, examples, cost_impact, source
328
- FROM rapport.standards_patterns
329
- WHERE category = ANY($1::varchar[])
330
- AND maturity IN ('enforced', 'validated', 'recommended')
331
- ORDER BY CASE WHEN maturity = 'enforced' THEN 1 WHEN maturity = 'validated' THEN 2 ELSE 3 END,
544
+ SELECT * FROM rapport.get_effective_standards($1, $2::varchar[], $3::varchar[])
545
+ ORDER BY CASE WHEN maturity = 'enforced' THEN 1 WHEN maturity = 'reinforced' THEN 2
546
+ WHEN maturity = 'validated' THEN 3 WHEN maturity = 'solidified' THEN 4 ELSE 5 END,
332
547
  correlation DESC
333
- `, [categories]);
548
+ `, [user.company_id, categories, maturityList]);
334
549
 
335
550
  if (result.rows.length === 0) {
336
551
  return {
@@ -368,21 +583,24 @@ async function toolInitSession(args, user) {
368
583
  // Format injection markdown
369
584
  const formattedInjection = formatInjection(sessionId, top);
370
585
 
371
- // Fire-and-forget: record session + standards
372
- executeQuery(`
373
- INSERT INTO rapport.sessions (session_id, project_id, email_address, started_at, session_data)
374
- VALUES ($1, $2, $3, NOW(), $4)
375
- ON CONFLICT (session_id) DO NOTHING
376
- `, [sessionId, projectId, user.email, JSON.stringify({ source: 'mcp', task_description: task_description || null })])
377
- .catch(err => console.error('[MCP] Session record failed:', err.message));
378
-
379
- for (const standard of top) {
380
- executeQuery(`
381
- INSERT INTO rapport.session_standards (session_id, standard_id, standard_name, relevance_score, created_at)
382
- VALUES ($1, $2, $3, $4, NOW())
383
- ON CONFLICT (session_id, standard_id) DO UPDATE SET relevance_score = EXCLUDED.relevance_score
384
- `, [sessionId, standard.pattern_id, standard.element, standard.relevance_score])
385
- .catch(err => console.error('[MCP] Standard record failed:', err.message));
586
+ // Record session first (must complete before session_standards FK references it)
587
+ try {
588
+ await executeQuery(`
589
+ INSERT INTO rapport.sessions (session_id, project_id, email_address, started_at, session_data)
590
+ VALUES ($1, $2, $3, NOW(), $4)
591
+ ON CONFLICT (session_id) DO NOTHING
592
+ `, [sessionId, projectId, user.email, JSON.stringify({ source: 'mcp', task_description: task_description || null })]);
593
+
594
+ // Now safe to insert session_standards — session row exists
595
+ for (const standard of top) {
596
+ await executeQuery(`
597
+ INSERT INTO rapport.session_standards (session_id, standard_id, standard_name, relevance_score, created_at)
598
+ VALUES ($1, $2, $3, $4, NOW())
599
+ ON CONFLICT (session_id, standard_id) DO UPDATE SET relevance_score = EXCLUDED.relevance_score
600
+ `, [sessionId, standard.pattern_id, standard.element, standard.relevance_score]);
601
+ }
602
+ } catch (err) {
603
+ console.error('[MCP] Session/standards record failed:', err.message);
386
604
  }
387
605
 
388
606
  // Get corpus size for summary
@@ -466,21 +684,40 @@ async function toolGetStandards(args, user) {
466
684
  const limit = Math.min(parseInt(filter.limit) || 20, 100);
467
685
  const maturityFilter = filter.maturity;
468
686
  const nameFilter = filter.standard_name;
687
+ const contentTypeFilter = filter.content_type;
688
+ const domainFilter = filter.domain;
689
+ const sourceFilter = filter.source;
469
690
 
470
691
  let query = `
471
- SELECT pattern_id as standard_id, element as name, maturity,
692
+ SELECT pattern_id as standard_id, element as name, maturity, source,
693
+ content_type, domain, rule, rationale, consequences, exceptions, source_context,
472
694
  COUNT(*) OVER() as total_count,
473
695
  (SELECT COUNT(*) FROM rapport.session_standards WHERE standard_id = sp.pattern_id) as session_count
474
696
  FROM rapport.standards_patterns sp
475
- WHERE 1=1
697
+ WHERE (company_id IS NULL OR company_id = $1)
476
698
  `;
477
- const params = [];
699
+ const params = [user.company_id];
478
700
 
479
701
  if (maturityFilter && Array.isArray(maturityFilter) && maturityFilter.length > 0) {
480
702
  params.push(maturityFilter);
481
703
  query += ` AND maturity = ANY($${params.length}::varchar[])`;
482
704
  }
483
705
 
706
+ if (contentTypeFilter) {
707
+ params.push(contentTypeFilter);
708
+ query += ` AND content_type = $${params.length}`;
709
+ }
710
+
711
+ if (domainFilter) {
712
+ params.push(domainFilter);
713
+ query += ` AND domain = $${params.length}`;
714
+ }
715
+
716
+ if (sourceFilter) {
717
+ params.push(sourceFilter);
718
+ query += ` AND source = $${params.length}`;
719
+ }
720
+
484
721
  if (nameFilter) {
485
722
  params.push(`%${nameFilter}%`);
486
723
  query += ` AND (element ILIKE $${params.length} OR title ILIKE $${params.length})`;
@@ -496,39 +733,311 @@ async function toolGetStandards(args, user) {
496
733
  const summaryResult = await executeQuery(`
497
734
  SELECT
498
735
  COUNT(*) as total_standards,
499
- COUNT(*) FILTER (WHERE maturity = 'enforced') as reinforced_count,
500
- COUNT(*) FILTER (WHERE maturity = 'validated') as solidified_count,
736
+ COUNT(*) FILTER (WHERE content_type = 'code_standard' OR content_type IS NULL) as code_standards_count,
737
+ COUNT(*) FILTER (WHERE content_type = 'business_invariant') as business_invariants_count,
738
+ COUNT(*) FILTER (WHERE maturity IN ('enforced', 'reinforced')) as reinforced_count,
739
+ COUNT(*) FILTER (WHERE maturity IN ('validated', 'solidified')) as solidified_count,
501
740
  COUNT(*) FILTER (WHERE maturity IN ('recommended', 'provisional')) as provisional_count
502
741
  FROM rapport.standards_patterns
503
742
  `);
743
+ const sourcesResult = await executeQuery(`
744
+ SELECT source, COUNT(*) as count
745
+ FROM rapport.standards_patterns
746
+ GROUP BY source
747
+ ORDER BY count DESC
748
+ `);
504
749
  const summary = summaryResult.rows[0] || {};
750
+ const sourceBreakdown = {};
751
+ for (const row of sourcesResult.rows) {
752
+ sourceBreakdown[row.source || 'unknown'] = parseInt(row.count, 10);
753
+ }
505
754
 
506
755
  const response = {
507
- standards: result.rows.map(r => ({
508
- standard_id: r.standard_id,
509
- name: r.name,
510
- maturity: r.maturity,
511
- rule_count: 1,
512
- session_count: parseInt(r.session_count, 10) || 0,
513
- })),
756
+ standards: result.rows.map(r => {
757
+ const entry = {
758
+ standard_id: r.standard_id,
759
+ name: r.name,
760
+ content_type: r.content_type || 'code_standard',
761
+ source: r.source,
762
+ maturity: r.maturity,
763
+ session_count: parseInt(r.session_count, 10) || 0,
764
+ };
765
+ if (r.content_type === 'business_invariant') {
766
+ entry.domain = r.domain;
767
+ entry.invariant = r.rule;
768
+ entry.rationale = r.rationale;
769
+ entry.consequences = r.consequences;
770
+ if (r.exceptions && Array.isArray(r.exceptions) && r.exceptions.length > 0) {
771
+ entry.exceptions = r.exceptions;
772
+ }
773
+ if (r.source_context) entry.source_label = r.source_context;
774
+ }
775
+ return entry;
776
+ }),
514
777
  corpus_summary: {
515
778
  total_standards: parseInt(summary.total_standards, 10) || 0,
516
- total_rules: parseInt(summary.total_standards, 10) || 0,
779
+ code_standards: parseInt(summary.code_standards_count, 10) || 0,
780
+ business_invariants: parseInt(summary.business_invariants_count, 10) || 0,
517
781
  reinforced_count: parseInt(summary.reinforced_count, 10) || 0,
518
782
  solidified_count: parseInt(summary.solidified_count, 10) || 0,
519
783
  provisional_count: parseInt(summary.provisional_count, 10) || 0,
784
+ by_source: sourceBreakdown,
520
785
  }
521
786
  };
522
787
 
523
788
  return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
524
789
  }
525
790
 
791
+ // ============================================================
792
+ // Tool: Ingest Raw Session (LLM Extraction)
793
+ // ============================================================
794
+
795
+ async function toolIngestRawSession(args, user) {
796
+ const {
797
+ session_text,
798
+ source_label,
799
+ domain_hint,
800
+ auto_maturity = 'provisional',
801
+ dry_run = true,
802
+ model = 'sonnet'
803
+ } = args;
804
+
805
+ const MODEL_IDS = {
806
+ haiku: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
807
+ sonnet: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
808
+ opus: 'us.anthropic.claude-opus-4-5-20251101-v1:0'
809
+ };
810
+ const modelId = MODEL_IDS[model] || MODEL_IDS.sonnet;
811
+
812
+ // Validate input size
813
+ if (!session_text || session_text.length < 100) {
814
+ return { content: [{ type: 'text', text: JSON.stringify({
815
+ error: 'session_text_too_short',
816
+ message: 'Conversation text must be at least 100 characters'
817
+ }) }], isError: true };
818
+ }
819
+ if (session_text.length > 200000) {
820
+ return { content: [{ type: 'text', text: JSON.stringify({
821
+ error: 'session_text_too_long',
822
+ message: 'Conversation text must be under 200,000 characters. Split into smaller segments.'
823
+ }) }], isError: true };
824
+ }
825
+
826
+ // --- LLM Extraction ---
827
+ const systemPrompt = `You are an expert at identifying organizational knowledge, business rules, and architectural decisions from conversation transcripts.
828
+
829
+ Your task: Extract business invariants — rules, constraints, decisions, and standards that an organization should follow consistently.
830
+
831
+ A business invariant is NOT:
832
+ - A one-time task or action item
833
+ - A personal preference without organizational impact
834
+ - A fact or observation without a prescriptive element
835
+ - Code-level implementation details (those are code standards, not business invariants)
836
+
837
+ A business invariant IS:
838
+ - A decision that constrains future behavior ("always do X", "never do Y")
839
+ - A rule with organizational reasoning behind it
840
+ - A constraint that has consequences if violated
841
+ - A standard that should be consistently applied across similar situations
842
+
843
+ For each invariant found, output:
844
+ - id: kebab-case unique identifier
845
+ - domain: one of [ip-strategy, architecture-decisions, go-to-market, operations, legal-process, finance, communication, product-strategy, investor-relations]
846
+ - priority: 10 (critical), 20 (important), or 30 (advisory)
847
+ - invariant: the rule as a single, complete statement
848
+ - rationale: WHY this is the standard (the reasoning, not just the rule)
849
+ - consequences: what goes wrong if this is violated
850
+ - applies_to: array of activities this constrains
851
+ - exceptions: array of when this doesn't apply (empty array if universal)
852
+ - confidence: 0.0-1.0 how confident you are this is a real organizational invariant vs a one-time comment
853
+
854
+ Output valid JSON only. No markdown, no explanation. Format:
855
+ { "invariants": [...] }
856
+
857
+ If no business invariants are found, return { "invariants": [] }.`;
858
+
859
+ const userPrompt = domain_hint
860
+ ? `Extract business invariants from this conversation. Domain hint: ${domain_hint}\n\n---\n\n${session_text}`
861
+ : `Extract business invariants from this conversation.\n\n---\n\n${session_text}`;
862
+
863
+ let candidates;
864
+ try {
865
+ const client = getBedrockClient();
866
+ const command = new InvokeModelCommand({
867
+ modelId: modelId,
868
+ contentType: 'application/json',
869
+ accept: 'application/json',
870
+ body: JSON.stringify({
871
+ anthropic_version: 'bedrock-2023-05-31',
872
+ max_tokens: 16384,
873
+ system: systemPrompt,
874
+ messages: [{ role: 'user', content: userPrompt }]
875
+ })
876
+ });
877
+ const response = await client.send(command);
878
+ const responseBody = JSON.parse(new TextDecoder().decode(response.body));
879
+ const text = responseBody.content?.[0]?.text || '{"invariants":[]}';
880
+
881
+ // Parse JSON (handle markdown code blocks if present)
882
+ const cleaned = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
883
+ candidates = JSON.parse(cleaned).invariants || [];
884
+ } catch (err) {
885
+ console.error('[MCP] Bedrock extraction failed:', err.message);
886
+ return { content: [{ type: 'text', text: JSON.stringify({
887
+ error: 'extraction_failed',
888
+ message: `LLM extraction failed: ${err.message}`
889
+ }) }], isError: true };
890
+ }
891
+
892
+ if (candidates.length === 0) {
893
+ return { content: [{ type: 'text', text: JSON.stringify({
894
+ candidates: [],
895
+ extraction_summary: { total_extracted: 0, message: 'No business invariants found in this conversation' }
896
+ }) }] };
897
+ }
898
+
899
+ // --- Deduplication against existing corpus ---
900
+ let existingInvariants = [];
901
+ try {
902
+ const existing = await executeQuery(`
903
+ SELECT pattern_id, element, rule, domain
904
+ FROM rapport.standards_patterns
905
+ WHERE content_type = 'business_invariant'
906
+ `);
907
+ existingInvariants = existing.rows;
908
+ } catch (err) {
909
+ console.error('[MCP] Dedup query failed:', err.message);
910
+ }
911
+
912
+ // Simple dedup: check if candidate ID or invariant text closely matches existing
913
+ for (const candidate of candidates) {
914
+ candidate.dedup_status = 'new';
915
+ for (const existing of existingInvariants) {
916
+ if (candidate.id === existing.pattern_id) {
917
+ candidate.dedup_status = 'duplicate_id';
918
+ candidate.existing_id = existing.pattern_id;
919
+ break;
920
+ }
921
+ // Fuzzy text match: if >60% of words overlap, flag as potential duplicate
922
+ const candidateWords = new Set(candidate.invariant.toLowerCase().split(/\s+/).filter(w => w.length > 3));
923
+ const existingWords = new Set(existing.rule.toLowerCase().split(/\s+/).filter(w => w.length > 3));
924
+ const overlap = [...candidateWords].filter(w => existingWords.has(w)).length;
925
+ const similarity = overlap / Math.max(candidateWords.size, existingWords.size);
926
+ if (similarity > 0.6) {
927
+ candidate.dedup_status = 'potential_duplicate';
928
+ candidate.existing_id = existing.pattern_id;
929
+ candidate.similarity = Math.round(similarity * 100);
930
+ break;
931
+ }
932
+ }
933
+ }
934
+
935
+ // --- Commit (if not dry_run) ---
936
+ let committed = 0;
937
+ if (!dry_run) {
938
+ for (const candidate of candidates) {
939
+ if (candidate.dedup_status === 'duplicate_id') continue;
940
+ if (candidate.dedup_status === 'potential_duplicate') continue;
941
+ if (candidate.confidence < 0.5) continue;
942
+
943
+ const patternId = candidate.id;
944
+ const title = patternId.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
945
+ const keywords = (candidate.applies_to || [])
946
+ .flatMap(a => a.split(/\s+/))
947
+ .filter(w => w.length > 2)
948
+ .slice(0, 10);
949
+
950
+ try {
951
+ const fileName = `mcp-extraction/${source_label}/${patternId}.yaml`;
952
+ await executeQuery(`
953
+ INSERT INTO rapport.standards_patterns (
954
+ pattern_id, file_name, element, title, rule, category, domain,
955
+ content_type, maturity, correlation, source, scope,
956
+ rationale, consequences, exceptions, source_context,
957
+ applicable_files, keywords, priority, active,
958
+ company_id,
959
+ created_at, last_updated
960
+ ) VALUES (
961
+ $1, $2, $3, $3, $4, $5, $5,
962
+ 'business_invariant', $6, 1.00, 'mcp-extraction', 'organization',
963
+ $7, $8, $9, $10,
964
+ $11, $12, $13, TRUE,
965
+ $14,
966
+ NOW(), NOW()
967
+ )
968
+ ON CONFLICT (pattern_id) DO UPDATE SET
969
+ rule = EXCLUDED.rule,
970
+ rationale = EXCLUDED.rationale,
971
+ consequences = EXCLUDED.consequences,
972
+ exceptions = EXCLUDED.exceptions,
973
+ source_context = EXCLUDED.source_context,
974
+ last_updated = NOW(),
975
+ last_seen_at = NOW(),
976
+ occurrence_count = COALESCE(rapport.standards_patterns.occurrence_count, 0) + 1
977
+ `, [
978
+ patternId, fileName, title, candidate.invariant,
979
+ candidate.domain, auto_maturity,
980
+ candidate.rationale, candidate.consequences,
981
+ JSON.stringify(candidate.exceptions || []),
982
+ source_label,
983
+ candidate.applies_to || [],
984
+ keywords,
985
+ candidate.priority || 20,
986
+ user.company_id
987
+ ]);
988
+ candidate.committed = true;
989
+ committed++;
990
+ } catch (err) {
991
+ console.error(`[MCP] Failed to commit ${patternId}:`, err.message);
992
+ candidate.committed = false;
993
+ candidate.commit_error = err.message;
994
+ }
995
+ }
996
+ }
997
+
998
+ // --- Response ---
999
+ const result = {
1000
+ candidates: candidates.map(c => ({
1001
+ id: c.id,
1002
+ domain: c.domain,
1003
+ priority: c.priority,
1004
+ invariant: c.invariant,
1005
+ rationale: c.rationale,
1006
+ consequences: c.consequences,
1007
+ applies_to: c.applies_to,
1008
+ exceptions: c.exceptions,
1009
+ confidence: c.confidence,
1010
+ dedup_status: c.dedup_status,
1011
+ existing_id: c.existing_id,
1012
+ similarity: c.similarity,
1013
+ committed: c.committed
1014
+ })),
1015
+ extraction_summary: {
1016
+ total_extracted: candidates.length,
1017
+ new_invariants: candidates.filter(c => c.dedup_status === 'new').length,
1018
+ duplicates: candidates.filter(c => c.dedup_status !== 'new').length,
1019
+ committed: committed,
1020
+ dry_run: dry_run,
1021
+ model: model,
1022
+ source_label: source_label,
1023
+ domains_covered: [...new Set(candidates.map(c => c.domain))],
1024
+ average_confidence: Math.round(
1025
+ candidates.reduce((sum, c) => sum + (c.confidence || 0), 0) / candidates.length * 100
1026
+ ) / 100
1027
+ }
1028
+ };
1029
+
1030
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1031
+ }
1032
+
526
1033
  // ============================================================
527
1034
  // JSON-RPC Message Router
528
1035
  // ============================================================
529
1036
 
530
1037
  async function handleJsonRpc(message, user) {
531
1038
  const { method, params, id } = message;
1039
+ const toolName = method === 'tools/call' ? params?.name : null;
1040
+ console.log(`[MCP] JSON-RPC method=${method}${toolName ? ` tool=${toolName}` : ''} id=${id} user=${user?.email}`);
532
1041
 
533
1042
  switch (method) {
534
1043
  case 'initialize':