@obsidicore/cascade-engine 0.2.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cascade/checkpoints.d.ts +55 -0
  3. package/dist/cascade/checkpoints.js +123 -0
  4. package/dist/cascade/checkpoints.js.map +1 -0
  5. package/dist/cascade/engine.d.ts +72 -0
  6. package/dist/cascade/engine.js +170 -0
  7. package/dist/cascade/engine.js.map +1 -0
  8. package/dist/cascade/gates.d.ts +46 -0
  9. package/dist/cascade/gates.js +199 -0
  10. package/dist/cascade/gates.js.map +1 -0
  11. package/dist/cascade/research.d.ts +50 -0
  12. package/dist/cascade/research.js +127 -0
  13. package/dist/cascade/research.js.map +1 -0
  14. package/dist/cli.d.ts +19 -0
  15. package/dist/cli.js +165 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/control/kalman.d.ts +53 -0
  18. package/dist/control/kalman.js +83 -0
  19. package/dist/control/kalman.js.map +1 -0
  20. package/dist/control/pid.d.ts +57 -0
  21. package/dist/control/pid.js +95 -0
  22. package/dist/control/pid.js.map +1 -0
  23. package/dist/control/stability.d.ts +42 -0
  24. package/dist/control/stability.js +117 -0
  25. package/dist/control/stability.js.map +1 -0
  26. package/dist/db/index.d.ts +26 -0
  27. package/dist/db/index.js +116 -0
  28. package/dist/db/index.js.map +1 -0
  29. package/dist/db/schema.sql +282 -0
  30. package/dist/graph/amem.d.ts +80 -0
  31. package/dist/graph/amem.js +190 -0
  32. package/dist/graph/amem.js.map +1 -0
  33. package/dist/graph/entities.d.ts +66 -0
  34. package/dist/graph/entities.js +187 -0
  35. package/dist/graph/entities.js.map +1 -0
  36. package/dist/graph/queries.d.ts +48 -0
  37. package/dist/graph/queries.js +176 -0
  38. package/dist/graph/queries.js.map +1 -0
  39. package/dist/hitl/dashboard.d.ts +51 -0
  40. package/dist/hitl/dashboard.js +135 -0
  41. package/dist/hitl/dashboard.js.map +1 -0
  42. package/dist/hitl/interventions.d.ts +36 -0
  43. package/dist/hitl/interventions.js +150 -0
  44. package/dist/hitl/interventions.js.map +1 -0
  45. package/dist/hitl/steering.d.ts +37 -0
  46. package/dist/hitl/steering.js +118 -0
  47. package/dist/hitl/steering.js.map +1 -0
  48. package/dist/index.d.ts +12 -0
  49. package/dist/index.js +701 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/memory/consolidation.d.ts +51 -0
  52. package/dist/memory/consolidation.js +122 -0
  53. package/dist/memory/consolidation.js.map +1 -0
  54. package/dist/memory/ncd.d.ts +40 -0
  55. package/dist/memory/ncd.js +90 -0
  56. package/dist/memory/ncd.js.map +1 -0
  57. package/dist/memory/sm2.d.ts +44 -0
  58. package/dist/memory/sm2.js +119 -0
  59. package/dist/memory/sm2.js.map +1 -0
  60. package/dist/memory/tiers.d.ts +49 -0
  61. package/dist/memory/tiers.js +145 -0
  62. package/dist/memory/tiers.js.map +1 -0
  63. package/dist/server.d.ts +6 -0
  64. package/dist/server.js +6 -0
  65. package/dist/server.js.map +1 -0
  66. package/dist/trust/ingestion.d.ts +38 -0
  67. package/dist/trust/ingestion.js +147 -0
  68. package/dist/trust/ingestion.js.map +1 -0
  69. package/dist/trust/patterns.d.ts +26 -0
  70. package/dist/trust/patterns.js +78 -0
  71. package/dist/trust/patterns.js.map +1 -0
  72. package/dist/trust/scoring.d.ts +39 -0
  73. package/dist/trust/scoring.js +206 -0
  74. package/dist/trust/scoring.js.map +1 -0
  75. package/package.json +58 -0
package/dist/index.js ADDED
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Research Cascade MCP Server
4
+ *
5
+ * Stdio-based MCP server providing 12+ tools for progressive research
6
+ * with knowledge graph, trust scoring, and self-regulation.
7
+ *
8
+ * NEVER console.log() — corrupts stdio JSON-RPC. Use console.error() only.
9
+ */
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { getDb, closeDb, generateId, contentHash } from './db/index.js';
14
+ import { ingestFinding } from './trust/ingestion.js';
15
+ import { consolidateRound } from './memory/consolidation.js';
16
+ import { checkInterventions } from './hitl/interventions.js';
17
+ import { renderDashboard, buildDashboardData } from './hitl/dashboard.js';
18
+ import { createNote, linkNotes, extractNotesFromFinding, updateMaturity, getNoteStats, searchNotes } from './graph/amem.js';
19
+ const server = new McpServer({
20
+ name: 'cascade-engine',
21
+ version: '0.1.0',
22
+ });
23
+ // ============================================================
24
+ // TOOL 1: store_plan — Save immutable research plan
25
+ // ============================================================
26
+ server.tool('store_plan', 'Save an immutable research plan for a cascade. Locks questions and criteria at round start to prevent HARKing.', {
27
+ cascade_id: z.string().describe('Cascade ID to attach the plan to'),
28
+ plan: z.object({
29
+ questions: z.array(z.string()).describe('Research questions to investigate'),
30
+ success_criteria: z.array(z.string()).describe('How we know when we have a good answer'),
31
+ scope_boundaries: z.array(z.string()).optional().describe('What is explicitly out of scope'),
32
+ max_rounds: z.number().optional().default(5),
33
+ token_budget: z.number().optional().default(500000),
34
+ }).describe('The research plan to lock in'),
35
+ }, async ({ cascade_id, plan }) => {
36
+ const db = getDb();
37
+ // Check if cascade exists
38
+ const existing = db.prepare('SELECT id, plan_json FROM cascades WHERE id = ?').get(cascade_id);
39
+ if (existing?.plan_json) {
40
+ return { content: [{ type: 'text', text: `Error: Plan already locked for cascade ${cascade_id}. Plans are immutable to prevent HARKing.` }] };
41
+ }
42
+ const planJson = JSON.stringify(plan);
43
+ if (existing) {
44
+ db.prepare('UPDATE cascades SET plan_json = ?, max_rounds = ?, token_budget = ?, updated_at = datetime(\'now\') WHERE id = ?')
45
+ .run(planJson, plan.max_rounds, plan.token_budget, cascade_id);
46
+ }
47
+ else {
48
+ db.prepare('INSERT INTO cascades (id, question, plan_json, max_rounds, token_budget) VALUES (?, ?, ?, ?, ?)')
49
+ .run(cascade_id, plan.questions[0] || 'Unnamed cascade', planJson, plan.max_rounds, plan.token_budget);
50
+ }
51
+ return {
52
+ content: [{ type: 'text', text: `Plan locked for cascade ${cascade_id}. ${plan.questions.length} questions, max ${plan.max_rounds} rounds, ${plan.token_budget} token budget.` }],
53
+ };
54
+ });
55
+ // ============================================================
56
+ // TOOL 2: store_finding — Ingest finding with trust scoring
57
+ // ============================================================
58
+ server.tool('store_finding', 'Store a research finding. Generates content-addressable ID. Trust scoring applied automatically.', {
59
+ cascade_id: z.string(),
60
+ thread_id: z.string().optional(),
61
+ claim: z.string().describe('The factual claim or finding'),
62
+ evidence: z.string().optional().describe('Supporting evidence or context'),
63
+ source_url: z.string().optional(),
64
+ source_type: z.enum(['primary', 'secondary', 'tertiary']).optional(),
65
+ confidence: z.number().min(0).max(1).optional().default(0.5),
66
+ cascade_round: z.number(),
67
+ }, async ({ cascade_id, thread_id, claim, evidence, source_url, source_type, confidence, cascade_round }) => {
68
+ // Route through full trust scoring pipeline
69
+ const result = ingestFinding(cascade_id, claim, evidence, source_url, source_type, confidence, cascade_round, thread_id);
70
+ const statusIcon = result.action === 'admitted' ? 'ADMITTED' :
71
+ result.action === 'quarantined' ? 'QUARANTINED' : 'REJECTED';
72
+ return {
73
+ content: [{ type: 'text', text: `Finding ${result.findingId}: ${statusIcon} (trust: ${result.trustScore.toFixed(3)}, confidence: ${confidence})\nSignals: source=${result.signals.sourceReputation.toFixed(2)} corroboration=${result.signals.crossCorroboration.toFixed(2)} instruction=${result.signals.instructionScore.toFixed(2)} grade=${result.signals.gradeAssessment.toFixed(2)}\nReason: ${result.reason}` }],
74
+ };
75
+ });
76
+ // ============================================================
77
+ // TOOL 3: get_findings — Query findings (FTS + filters)
78
+ // ============================================================
79
+ server.tool('get_findings', 'Query stored findings using full-text search and/or filters.', {
80
+ cascade_id: z.string().optional(),
81
+ query: z.string().optional().describe('FTS search query'),
82
+ min_confidence: z.number().min(0).max(1).optional(),
83
+ include_quarantined: z.boolean().optional().default(false),
84
+ round: z.number().optional(),
85
+ limit: z.number().optional().default(20),
86
+ }, async ({ cascade_id, query, min_confidence, include_quarantined, round, limit }) => {
87
+ const db = getDb();
88
+ const params = [];
89
+ let sql;
90
+ if (query) {
91
+ sql = `SELECT f.id, f.claim, f.evidence, f.source_url, f.confidence, f.trust_composite,
92
+ f.grade_level, f.quarantined, f.cascade_round, f.created_at
93
+ FROM findings f
94
+ JOIN findings_fts fts ON f.rowid = fts.rowid
95
+ WHERE findings_fts MATCH ?`;
96
+ params.push(query);
97
+ }
98
+ else {
99
+ sql = `SELECT id, claim, evidence, source_url, confidence, trust_composite,
100
+ grade_level, quarantined, cascade_round, created_at
101
+ FROM findings WHERE 1=1`;
102
+ }
103
+ if (cascade_id) {
104
+ sql += ' AND cascade_id = ?';
105
+ params.push(cascade_id);
106
+ }
107
+ if (min_confidence !== undefined) {
108
+ sql += ' AND confidence >= ?';
109
+ params.push(min_confidence);
110
+ }
111
+ if (!include_quarantined) {
112
+ sql += ' AND quarantined = 0';
113
+ }
114
+ if (round !== undefined) {
115
+ sql += ' AND cascade_round = ?';
116
+ params.push(round);
117
+ }
118
+ sql += ' ORDER BY confidence DESC LIMIT ?';
119
+ params.push(limit);
120
+ const findings = db.prepare(sql).all(...params);
121
+ return {
122
+ content: [{ type: 'text', text: JSON.stringify(findings, null, 2) }],
123
+ };
124
+ });
125
+ // ============================================================
126
+ // TOOL 4: add_entity — Add KG entity
127
+ // ============================================================
128
+ server.tool('add_entity', 'Add an entity to the knowledge graph. Upserts on (name, entity_type).', {
129
+ name: z.string(),
130
+ entity_type: z.string().describe('e.g., concept, person, tool, technique, paper'),
131
+ properties: z.record(z.any()).optional().default({}),
132
+ tier: z.enum(['peripheral', 'working', 'core']).optional().default('working'),
133
+ importance: z.number().min(0).max(1).optional().default(0.5),
134
+ }, async ({ name, entity_type, properties, tier, importance }) => {
135
+ const db = getDb();
136
+ const propsJson = JSON.stringify(properties);
137
+ const result = db.prepare(`INSERT INTO kg_entities (name, entity_type, properties, tier, importance)
138
+ VALUES (?, ?, ?, ?, ?)
139
+ ON CONFLICT(name, entity_type) DO UPDATE SET
140
+ properties = json_patch(properties, ?),
141
+ importance = MAX(importance, ?),
142
+ last_accessed = datetime('now')`)
143
+ .run(name, entity_type, propsJson, tier, importance, propsJson, importance);
144
+ const entity = db.prepare('SELECT id FROM kg_entities WHERE name = ? AND entity_type = ?')
145
+ .get(name, entity_type);
146
+ return {
147
+ content: [{ type: 'text', text: `Entity ${entity.id}: "${name}" (${entity_type}, tier: ${tier})` }],
148
+ };
149
+ });
150
+ // ============================================================
151
+ // TOOL 5: add_link — Add KG edge
152
+ // ============================================================
153
+ server.tool('add_link', 'Add a directional link between two knowledge graph entities.', {
154
+ source_name: z.string(),
155
+ source_type: z.string(),
156
+ target_name: z.string(),
157
+ target_type: z.string(),
158
+ relation_type: z.string().describe('e.g., relates_to, causes, contradicts, supports, uses, part_of'),
159
+ weight: z.number().min(0).max(1).optional().default(1.0),
160
+ properties: z.record(z.any()).optional().default({}),
161
+ }, async ({ source_name, source_type, target_name, target_type, relation_type, weight, properties }) => {
162
+ const db = getDb();
163
+ const source = db.prepare('SELECT id FROM kg_entities WHERE name = ? AND entity_type = ?')
164
+ .get(source_name, source_type);
165
+ const target = db.prepare('SELECT id FROM kg_entities WHERE name = ? AND entity_type = ?')
166
+ .get(target_name, target_type);
167
+ if (!source)
168
+ return { content: [{ type: 'text', text: `Error: Source entity "${source_name}" (${source_type}) not found. Add it first.` }] };
169
+ if (!target)
170
+ return { content: [{ type: 'text', text: `Error: Target entity "${target_name}" (${target_type}) not found. Add it first.` }] };
171
+ db.prepare(`INSERT INTO kg_edges (source_id, target_id, relation_type, weight, properties)
172
+ VALUES (?, ?, ?, ?, ?)
173
+ ON CONFLICT(source_id, target_id, relation_type) DO UPDATE SET
174
+ weight = MAX(weight, ?),
175
+ activation_count = activation_count + 1,
176
+ last_activated = datetime('now')`)
177
+ .run(source.id, target.id, relation_type, weight, JSON.stringify(properties), weight);
178
+ return {
179
+ content: [{ type: 'text', text: `Link: "${source_name}" -[${relation_type}]-> "${target_name}" (weight: ${weight})` }],
180
+ };
181
+ });
182
+ // ============================================================
183
+ // TOOL 6: query_graph — Recursive CTE traversal (≤3 hops)
184
+ // ============================================================
185
+ server.tool('query_graph', 'Traverse the knowledge graph from a starting entity. Uses recursive CTE, bounded to 3 hops max. Follows edges in both directions by default.', {
186
+ start_name: z.string(),
187
+ start_type: z.string(),
188
+ max_hops: z.number().min(1).max(3).optional().default(2),
189
+ direction: z.enum(['outgoing', 'incoming', 'both']).optional().default('both').describe('Edge direction to follow'),
190
+ relation_filter: z.string().optional().describe('Filter edges by relation type'),
191
+ min_weight: z.number().min(0).max(1).optional().default(0.0),
192
+ }, async ({ start_name, start_type, max_hops, direction, relation_filter, min_weight }) => {
193
+ const db = getDb();
194
+ const start = db.prepare('SELECT id FROM kg_entities WHERE name = ? AND entity_type = ?')
195
+ .get(start_name, start_type);
196
+ if (!start)
197
+ return { content: [{ type: 'text', text: `Entity "${start_name}" (${start_type}) not found.` }] };
198
+ let relationClause = '';
199
+ if (relation_filter) {
200
+ relationClause = `AND e.relation_type = '${relation_filter.replace(/'/g, "''")}'`;
201
+ }
202
+ // Build directional join clauses
203
+ let edgeJoins;
204
+ if (direction === 'outgoing') {
205
+ edgeJoins = `
206
+ SELECT e.target_id, t.depth + 1,
207
+ t.path || ' -[' || e.relation_type || ']-> ' || tgt.name,
208
+ t.visited || ',' || CAST(e.target_id AS TEXT)
209
+ FROM traverse t
210
+ JOIN kg_edges e ON e.source_id = t.entity_id
211
+ JOIN kg_entities tgt ON tgt.id = e.target_id
212
+ WHERE t.depth < ${max_hops} AND e.weight >= ${min_weight}
213
+ AND t.visited NOT LIKE '%,' || CAST(e.target_id AS TEXT) || ',%'
214
+ ${relationClause}`;
215
+ }
216
+ else if (direction === 'incoming') {
217
+ edgeJoins = `
218
+ SELECT e.source_id, t.depth + 1,
219
+ t.path || ' <-[' || e.relation_type || ']- ' || src.name,
220
+ t.visited || ',' || CAST(e.source_id AS TEXT)
221
+ FROM traverse t
222
+ JOIN kg_edges e ON e.target_id = t.entity_id
223
+ JOIN kg_entities src ON src.id = e.source_id
224
+ WHERE t.depth < ${max_hops} AND e.weight >= ${min_weight}
225
+ AND t.visited NOT LIKE '%,' || CAST(e.source_id AS TEXT) || ',%'
226
+ ${relationClause}`;
227
+ }
228
+ else {
229
+ // both directions
230
+ edgeJoins = `
231
+ SELECT e.target_id, t.depth + 1,
232
+ t.path || ' -[' || e.relation_type || ']-> ' || tgt.name,
233
+ t.visited || ',' || CAST(e.target_id AS TEXT)
234
+ FROM traverse t
235
+ JOIN kg_edges e ON e.source_id = t.entity_id
236
+ JOIN kg_entities tgt ON tgt.id = e.target_id
237
+ WHERE t.depth < ${max_hops} AND e.weight >= ${min_weight}
238
+ AND t.visited NOT LIKE '%,' || CAST(e.target_id AS TEXT) || ',%'
239
+ ${relationClause}
240
+ UNION ALL
241
+ SELECT e.source_id, t.depth + 1,
242
+ t.path || ' <-[' || e.relation_type || ']- ' || src.name,
243
+ t.visited || ',' || CAST(e.source_id AS TEXT)
244
+ FROM traverse t
245
+ JOIN kg_edges e ON e.target_id = t.entity_id
246
+ JOIN kg_entities src ON src.id = e.source_id
247
+ WHERE t.depth < ${max_hops} AND e.weight >= ${min_weight}
248
+ AND t.visited NOT LIKE '%,' || CAST(e.source_id AS TEXT) || ',%'
249
+ ${relationClause}`;
250
+ }
251
+ const sql = `
252
+ WITH RECURSIVE traverse(entity_id, depth, path, visited) AS (
253
+ SELECT ${start.id}, 0, '${start_name.replace(/'/g, "''")}', ',${start.id},'
254
+ UNION ALL
255
+ ${edgeJoins}
256
+ )
257
+ SELECT DISTINCT
258
+ ent.id, ent.name, ent.entity_type, ent.tier,
259
+ ent.community_id, ent.betweenness, ent.importance,
260
+ t.depth, t.path
261
+ FROM traverse t
262
+ JOIN kg_entities ent ON ent.id = t.entity_id
263
+ WHERE t.depth > 0
264
+ ORDER BY t.depth, ent.importance DESC`;
265
+ const results = db.prepare(sql).all();
266
+ // Update activation counts for traversed edges
267
+ db.prepare(`UPDATE kg_edges SET activation_count = activation_count + 1, last_activated = datetime('now')
268
+ WHERE source_id = ? OR target_id = ?`).run(start.id, start.id);
269
+ return {
270
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
271
+ };
272
+ });
273
+ // ============================================================
274
+ // TOOL 7: store_hypothesis — Add/update hypothesis
275
+ // ============================================================
276
+ server.tool('store_hypothesis', 'Store or update a research hypothesis in the cascade.', {
277
+ cascade_id: z.string(),
278
+ statement: z.string(),
279
+ parent_id: z.string().optional(),
280
+ affinity: z.number().min(0).max(1).optional().default(0.5),
281
+ status: z.enum(['proposed', 'testing', 'supported', 'refuted', 'uncertain', 'archived']).optional().default('proposed'),
282
+ supporting_ids: z.array(z.string()).optional().default([]),
283
+ contradicting_ids: z.array(z.string()).optional().default([]),
284
+ }, async ({ cascade_id, statement, parent_id, affinity, status, supporting_ids, contradicting_ids }) => {
285
+ const db = getDb();
286
+ const id = contentHash(statement);
287
+ const existing = db.prepare('SELECT id FROM hypotheses WHERE id = ?').get(id);
288
+ if (existing) {
289
+ // Merge new IDs into existing arrays (flat, no nesting)
290
+ const current = db.prepare('SELECT supporting, contradicting FROM hypotheses WHERE id = ?').get(id);
291
+ const existingSupporting = JSON.parse(current.supporting || '[]');
292
+ const existingContradicting = JSON.parse(current.contradicting || '[]');
293
+ const mergedSupporting = [...new Set([...existingSupporting, ...supporting_ids])];
294
+ const mergedContradicting = [...new Set([...existingContradicting, ...contradicting_ids])];
295
+ db.prepare(`UPDATE hypotheses SET
296
+ affinity = ?, status = ?,
297
+ supporting = ?,
298
+ contradicting = ?,
299
+ updated_at = datetime('now')
300
+ WHERE id = ?`)
301
+ .run(affinity, status, JSON.stringify(mergedSupporting), JSON.stringify(mergedContradicting), id);
302
+ }
303
+ else {
304
+ const generation = parent_id
305
+ ? (db.prepare('SELECT generation FROM hypotheses WHERE id = ?').get(parent_id)?.generation ?? 0) + 1
306
+ : 0;
307
+ db.prepare(`INSERT INTO hypotheses (id, cascade_id, statement, parent_id, affinity, generation, status, supporting, contradicting)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
309
+ .run(id, cascade_id, statement, parent_id, affinity, generation, status, JSON.stringify(supporting_ids), JSON.stringify(contradicting_ids));
310
+ }
311
+ return {
312
+ content: [{ type: 'text', text: `Hypothesis ${id}: "${statement.slice(0, 80)}..." (affinity: ${affinity}, status: ${status})` }],
313
+ };
314
+ });
315
+ // ============================================================
316
+ // TOOL 8: get_hypotheses — Query hypothesis population
317
+ // ============================================================
318
+ server.tool('get_hypotheses', 'Query hypotheses for a cascade, optionally filtered by status.', {
319
+ cascade_id: z.string(),
320
+ status: z.enum(['proposed', 'testing', 'supported', 'refuted', 'uncertain', 'archived']).optional(),
321
+ min_affinity: z.number().min(0).max(1).optional(),
322
+ }, async ({ cascade_id, status, min_affinity }) => {
323
+ const db = getDb();
324
+ let sql = 'SELECT * FROM hypotheses WHERE cascade_id = ?';
325
+ const params = [cascade_id];
326
+ if (status) {
327
+ sql += ' AND status = ?';
328
+ params.push(status);
329
+ }
330
+ if (min_affinity !== undefined) {
331
+ sql += ' AND affinity >= ?';
332
+ params.push(min_affinity);
333
+ }
334
+ sql += ' ORDER BY affinity DESC';
335
+ const results = db.prepare(sql).all(...params);
336
+ return {
337
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
338
+ };
339
+ });
340
+ // ============================================================
341
+ // TOOL 9: cascade_init — Initialize a new research cascade
342
+ // ============================================================
343
+ server.tool('cascade_init', 'Initialize a new research cascade with a question. Returns cascade ID.', {
344
+ question: z.string().describe('The research question to investigate'),
345
+ max_rounds: z.number().optional().default(5),
346
+ token_budget: z.number().optional().default(500000),
347
+ }, async ({ question, max_rounds, token_budget }) => {
348
+ const db = getDb();
349
+ const id = generateId();
350
+ db.prepare(`INSERT INTO cascades (id, question, status, max_rounds, token_budget)
351
+ VALUES (?, ?, 'planning', ?, ?)`)
352
+ .run(id, question, max_rounds, token_budget);
353
+ return {
354
+ content: [{ type: 'text', text: `Cascade initialized: ${id}\nQuestion: ${question}\nMax rounds: ${max_rounds}\nToken budget: ${token_budget}\n\nNext: store_plan to lock research criteria, then update_status to begin.` }],
355
+ };
356
+ });
357
+ // ============================================================
358
+ // TOOL 10: get_status — Cascade state + metrics
359
+ // ============================================================
360
+ server.tool('get_status', 'Get current cascade status including progress, findings count, hypothesis count, and PID state.', {
361
+ cascade_id: z.string().optional().describe('Specific cascade ID, or omit for all active cascades'),
362
+ }, async ({ cascade_id }) => {
363
+ const db = getDb();
364
+ if (cascade_id) {
365
+ const cascade = db.prepare('SELECT * FROM cascades WHERE id = ?').get(cascade_id);
366
+ if (!cascade)
367
+ return { content: [{ type: 'text', text: `Cascade ${cascade_id} not found.` }] };
368
+ const findingsCount = db.prepare('SELECT COUNT(*) as n FROM findings WHERE cascade_id = ?').get(cascade_id).n;
369
+ const quarantinedCount = db.prepare('SELECT COUNT(*) as n FROM findings WHERE cascade_id = ? AND quarantined = 1').get(cascade_id).n;
370
+ const hypothesesCount = db.prepare('SELECT COUNT(*) as n FROM hypotheses WHERE cascade_id = ?').get(cascade_id).n;
371
+ const threadsCount = db.prepare('SELECT COUNT(*) as n FROM threads WHERE cascade_id = ?').get(cascade_id).n;
372
+ const entityCount = db.prepare('SELECT COUNT(*) as n FROM kg_entities').get().n;
373
+ const edgeCount = db.prepare('SELECT COUNT(*) as n FROM kg_edges').get().n;
374
+ // Auto-apply pending steer events
375
+ const pendingSteers = db.prepare('SELECT * FROM steer_events WHERE cascade_id = ? AND applied = 0 ORDER BY created_at').all(cascade_id);
376
+ const appliedSteers = [];
377
+ for (const steer of pendingSteers) {
378
+ db.prepare('UPDATE steer_events SET applied = 1 WHERE id = ?').run(steer.id);
379
+ appliedSteers.push(`[${steer.event_type}] ${steer.instruction}`);
380
+ }
381
+ // --- Phase 7 activation: check interventions ---
382
+ const interventions = checkInterventions(cascade_id);
383
+ const blockingInterventions = interventions.filter(i => i.level === 'blocking');
384
+ const advisoryInterventions = interventions.filter(i => i.level === 'advisory');
385
+ // Note stats
386
+ const noteCount = db.prepare('SELECT COUNT(*) as n FROM atomic_notes WHERE cascade_id = ?').get(cascade_id)?.n || 0;
387
+ const status = {
388
+ ...cascade,
389
+ pid_state: cascade.pid_state_json ? JSON.parse(cascade.pid_state_json) : null,
390
+ plan: cascade.plan_json ? JSON.parse(cascade.plan_json) : null,
391
+ counts: { findings: findingsCount, quarantined: quarantinedCount, hypotheses: hypothesesCount, threads: threadsCount, entities: entityCount, edges: edgeCount, notes: noteCount },
392
+ applied_steers: appliedSteers.length > 0 ? appliedSteers : undefined,
393
+ interventions: blockingInterventions.length > 0
394
+ ? { blocking: blockingInterventions.map(i => i.description), advisory: advisoryInterventions.map(i => i.description) }
395
+ : advisoryInterventions.length > 0
396
+ ? { advisory: advisoryInterventions.map(i => i.description) }
397
+ : undefined,
398
+ exploration_budget: Math.max(0, 1 - cascade.current_round / cascade.max_rounds),
399
+ };
400
+ return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
401
+ }
402
+ // All active cascades
403
+ const cascades = db.prepare("SELECT id, question, status, current_round, max_rounds, created_at FROM cascades WHERE status NOT IN ('complete') ORDER BY updated_at DESC").all();
404
+ return { content: [{ type: 'text', text: JSON.stringify(cascades, null, 2) }] };
405
+ });
406
+ // ============================================================
407
+ // TOOL 11: update_status — Advance phase/round
408
+ // ============================================================
409
+ server.tool('update_status', 'Update cascade status and/or advance to next round.', {
410
+ cascade_id: z.string(),
411
+ status: z.enum(['planning', 'investigating', 'validating', 'synthesizing', 'complete', 'stalled']).optional(),
412
+ advance_round: z.boolean().optional().default(false).describe('Increment current_round by 1'),
413
+ pid_state: z.object({
414
+ error: z.number(),
415
+ integral: z.number(),
416
+ derivative: z.number(),
417
+ output: z.number(),
418
+ kp: z.number().optional(),
419
+ ki: z.number().optional(),
420
+ kd: z.number().optional(),
421
+ }).optional(),
422
+ tokens_used: z.number().optional().describe('Add to running token count'),
423
+ }, async ({ cascade_id, status, advance_round, pid_state, tokens_used }) => {
424
+ const db = getDb();
425
+ const cascade = db.prepare('SELECT * FROM cascades WHERE id = ?').get(cascade_id);
426
+ if (!cascade)
427
+ return { content: [{ type: 'text', text: `Cascade ${cascade_id} not found.` }] };
428
+ const updates = ["updated_at = datetime('now')"];
429
+ const params = [];
430
+ if (status) {
431
+ updates.push('status = ?');
432
+ params.push(status);
433
+ }
434
+ if (advance_round) {
435
+ const newRound = cascade.current_round + 1;
436
+ updates.push('current_round = ?');
437
+ params.push(newRound);
438
+ updates.push('exploration_budget = ?');
439
+ params.push(Math.max(0, 1 - newRound / cascade.max_rounds));
440
+ }
441
+ if (pid_state) {
442
+ updates.push('pid_state_json = ?');
443
+ params.push(JSON.stringify(pid_state));
444
+ }
445
+ if (tokens_used) {
446
+ updates.push('tokens_used = tokens_used + ?');
447
+ params.push(tokens_used);
448
+ }
449
+ params.push(cascade_id);
450
+ db.prepare(`UPDATE cascades SET ${updates.join(', ')} WHERE id = ?`).run(...params);
451
+ // --- Phase 6 activation: run consolidation when advancing rounds ---
452
+ let consolidationReport;
453
+ if (advance_round) {
454
+ try {
455
+ const result = consolidateRound(cascade_id, cascade.current_round);
456
+ // Extract notes from this round's findings
457
+ const roundFindings = db.prepare('SELECT id FROM findings WHERE cascade_id = ? AND cascade_round = ? AND quarantined = 0')
458
+ .all(cascade_id, cascade.current_round);
459
+ let notesCreated = 0;
460
+ for (const f of roundFindings) {
461
+ const noteIds = extractNotesFromFinding(f.id, cascade_id, cascade.current_round);
462
+ notesCreated += noteIds.length;
463
+ }
464
+ // Update note maturity
465
+ const maturityResult = updateMaturity();
466
+ consolidationReport = `Consolidation: dedup ${result.deduped.removed} removed, ${result.tierChanges.promoted} promoted, ${result.tierChanges.demoted} demoted, ${result.pruned.archived} pruned, ${result.scheduled} scheduled for SM-2. ${notesCreated} notes created, ${maturityResult.promoted} matured. (${result.durationMs}ms)`;
467
+ }
468
+ catch (err) {
469
+ consolidationReport = `Consolidation error: ${err.message}`;
470
+ }
471
+ }
472
+ const updated = db.prepare('SELECT id, status, current_round, max_rounds, tokens_used, token_budget FROM cascades WHERE id = ?').get(cascade_id);
473
+ const response = { ...updated };
474
+ if (consolidationReport)
475
+ response.consolidation = consolidationReport;
476
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
477
+ });
478
+ // ============================================================
479
+ // TOOL 12: get_metrics — Information-theoretic dashboard
480
+ // ============================================================
481
+ server.tool('get_metrics', 'Get cascade quality metrics: coverage, depth, confidence distribution, source diversity.', {
482
+ cascade_id: z.string(),
483
+ }, async ({ cascade_id }) => {
484
+ const db = getDb();
485
+ const cascade = db.prepare('SELECT * FROM cascades WHERE id = ?').get(cascade_id);
486
+ if (!cascade)
487
+ return { content: [{ type: 'text', text: `Cascade ${cascade_id} not found.` }] };
488
+ // Aggregate metrics
489
+ const findings = db.prepare('SELECT confidence, trust_composite, source_url, grade_level, cascade_round FROM findings WHERE cascade_id = ? AND quarantined = 0').all(cascade_id);
490
+ const avgConfidence = findings.length ? findings.reduce((s, f) => s + f.confidence, 0) / findings.length : 0;
491
+ const avgTrust = findings.length ? findings.reduce((s, f) => s + (f.trust_composite || 0.5), 0) / findings.length : 0;
492
+ // Source diversity
493
+ const domains = new Set(findings.map((f) => {
494
+ try {
495
+ return new URL(f.source_url || '').hostname;
496
+ }
497
+ catch {
498
+ return 'unknown';
499
+ }
500
+ }));
501
+ // Confidence distribution
502
+ const confBuckets = { high: 0, medium: 0, low: 0 };
503
+ for (const f of findings) {
504
+ if (f.confidence >= 0.7)
505
+ confBuckets.high++;
506
+ else if (f.confidence >= 0.4)
507
+ confBuckets.medium++;
508
+ else
509
+ confBuckets.low++;
510
+ }
511
+ // Grade distribution
512
+ const gradeDist = {};
513
+ for (const f of findings) {
514
+ const g = f.grade_level || 'ungraded';
515
+ gradeDist[g] = (gradeDist[g] || 0) + 1;
516
+ }
517
+ // Hypotheses status
518
+ const hypStats = db.prepare(`SELECT status, COUNT(*) as n FROM hypotheses WHERE cascade_id = ? GROUP BY status`).all(cascade_id);
519
+ // Graph stats
520
+ const entityCount = db.prepare('SELECT COUNT(*) as n FROM kg_entities').get().n;
521
+ const edgeCount = db.prepare('SELECT COUNT(*) as n FROM kg_edges').get().n;
522
+ const avgDegree = entityCount > 0 ? (2 * edgeCount) / entityCount : 0;
523
+ // Recent metrics from metrics table
524
+ const recentMetrics = db.prepare(`SELECT metric_name, metric_value, recorded_at FROM metrics
525
+ WHERE cascade_id = ? ORDER BY recorded_at DESC LIMIT 20`).all(cascade_id);
526
+ // Note stats
527
+ const noteStats = getNoteStats();
528
+ // Consolidation history
529
+ const lastConsolidation = db.prepare(`SELECT * FROM consolidation_log
530
+ WHERE cascade_id = ? ORDER BY created_at DESC LIMIT 1`)
531
+ .get(cascade_id);
532
+ // SM-2 review stats
533
+ const sm2Due = db.prepare("SELECT COUNT(*) as n FROM sm2_schedule WHERE next_review <= datetime('now')").get().n;
534
+ const dashboard = {
535
+ cascade: { id: cascade_id, status: cascade.status, round: cascade.current_round, maxRounds: cascade.max_rounds },
536
+ quality: {
537
+ totalFindings: findings.length,
538
+ avgConfidence: Math.round(avgConfidence * 1000) / 1000,
539
+ avgTrust: Math.round(avgTrust * 1000) / 1000,
540
+ confidenceDistribution: confBuckets,
541
+ gradeDistribution: gradeDist,
542
+ sourceDiversity: domains.size,
543
+ },
544
+ hypotheses: hypStats,
545
+ graph: { entities: entityCount, edges: edgeCount, avgDegree: Math.round(avgDegree * 100) / 100 },
546
+ notes: noteStats,
547
+ memory: {
548
+ lastConsolidation: lastConsolidation ? {
549
+ promoted: lastConsolidation.items_promoted,
550
+ demoted: lastConsolidation.items_demoted,
551
+ pruned: lastConsolidation.items_pruned,
552
+ durationMs: lastConsolidation.duration_ms,
553
+ } : null,
554
+ sm2ItemsDue: sm2Due,
555
+ },
556
+ tokens: { used: cascade.tokens_used, budget: cascade.token_budget, remaining: cascade.token_budget - cascade.tokens_used },
557
+ explorationBudget: Math.max(0, 1 - cascade.current_round / cascade.max_rounds),
558
+ recentMetrics,
559
+ };
560
+ // --- Phase 7 activation: render ANSI dashboard to stderr ---
561
+ const dashData = buildDashboardData(cascade_id);
562
+ if (dashData) {
563
+ console.error(renderDashboard(dashData));
564
+ }
565
+ return { content: [{ type: 'text', text: JSON.stringify(dashboard, null, 2) }] };
566
+ });
567
+ // ============================================================
568
+ // TOOL 13: store_checkpoint — Step-level checkpointing
569
+ // ============================================================
570
+ server.tool('store_checkpoint', 'Save a checkpoint for crash recovery. Each step is checkpointed independently.', {
571
+ task_id: z.string(),
572
+ round_index: z.number(),
573
+ step_index: z.number(),
574
+ step_name: z.string(),
575
+ status: z.enum(['pending', 'running', 'done', 'failed', 'skipped']),
576
+ state_snapshot: z.string().optional().describe('JSON state to restore from'),
577
+ error_message: z.string().optional(),
578
+ }, async ({ task_id, round_index, step_index, step_name, status, state_snapshot, error_message }) => {
579
+ const db = getDb();
580
+ const idempotencyKey = `${task_id}:${round_index}:${step_index}:${step_name}`;
581
+ db.prepare(`INSERT INTO cascade_checkpoints (task_id, round_index, step_index, step_name, status, state_snapshot, idempotency_key, error_message)
582
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
583
+ ON CONFLICT(task_id, round_index, step_index) DO UPDATE SET
584
+ status = excluded.status,
585
+ state_snapshot = COALESCE(excluded.state_snapshot, state_snapshot),
586
+ error_message = excluded.error_message,
587
+ completed_at = CASE WHEN excluded.status IN ('done','failed','skipped') THEN datetime('now') ELSE NULL END`)
588
+ .run(task_id, round_index, step_index, step_name, status, state_snapshot, idempotencyKey, error_message);
589
+ return {
590
+ content: [{ type: 'text', text: `Checkpoint: ${task_id} R${round_index}S${step_index} "${step_name}" → ${status}` }],
591
+ };
592
+ });
593
+ // ============================================================
594
+ // TOOL 14: steer — Submit human steering event
595
+ // ============================================================
596
+ server.tool('steer', 'Submit a steering event to redirect an active cascade.', {
597
+ cascade_id: z.string(),
598
+ event_type: z.enum(['redirect', 'narrow', 'broaden', 'add_question', 'drop_hypothesis', 'approve', 'reject']),
599
+ instruction: z.string(),
600
+ target_id: z.string().optional().describe('ID of hypothesis or finding to target'),
601
+ }, async ({ cascade_id, event_type, instruction, target_id }) => {
602
+ const db = getDb();
603
+ db.prepare('INSERT INTO steer_events (cascade_id, event_type, instruction, target_id) VALUES (?, ?, ?, ?)')
604
+ .run(cascade_id, event_type, instruction, target_id);
605
+ return {
606
+ content: [{ type: 'text', text: `Steer event queued: ${event_type} — "${instruction}". Will be applied on next cascade iteration.` }],
607
+ };
608
+ });
609
+ // ============================================================
610
+ // TOOL 15: record_metric — Store a metric value
611
+ // ============================================================
612
+ server.tool('record_metric', 'Record a metric value for tracking cascade health over time.', {
613
+ cascade_id: z.string(),
614
+ round_index: z.number().optional(),
615
+ metric_name: z.string().describe('e.g., entropy, coverage, confidence_avg, pid_error, ncd_dedup_ratio'),
616
+ metric_value: z.number(),
617
+ }, async ({ cascade_id, round_index, metric_name, metric_value }) => {
618
+ const db = getDb();
619
+ db.prepare('INSERT INTO metrics (cascade_id, round_index, metric_name, metric_value) VALUES (?, ?, ?, ?)')
620
+ .run(cascade_id, round_index, metric_name, metric_value);
621
+ return {
622
+ content: [{ type: 'text', text: `Metric recorded: ${metric_name} = ${metric_value}` }],
623
+ };
624
+ });
625
+ // ============================================================
626
+ // TOOL 16: create_note — A-MEM atomic note creation
627
+ // ============================================================
628
+ server.tool('create_note', 'Create an atomic Zettelkasten note from an insight. Auto-links to related notes by keyword overlap. Content-addressable (idempotent).', {
629
+ content: z.string().describe('The atomic insight or observation'),
630
+ note_type: z.enum(['insight', 'connection', 'question', 'contradiction', 'synthesis']).optional().default('insight'),
631
+ keywords: z.array(z.string()).optional().default([]),
632
+ source_finding_id: z.string().optional(),
633
+ cascade_id: z.string().optional(),
634
+ cascade_round: z.number().optional(),
635
+ }, async ({ content, note_type, keywords, source_finding_id, cascade_id, cascade_round }) => {
636
+ const noteId = createNote(content, note_type, keywords, source_finding_id, undefined, cascade_id, cascade_round);
637
+ // Auto-link to related notes
638
+ if (cascade_id) {
639
+ const db = getDb();
640
+ const existing = db.prepare(`SELECT id, content FROM atomic_notes WHERE id != ? AND cascade_id = ? LIMIT 50`)
641
+ .all(noteId, cascade_id);
642
+ let linksCreated = 0;
643
+ const contentWords = new Set(content.toLowerCase().split(/\s+/).filter(w => w.length > 5));
644
+ for (const other of existing) {
645
+ const otherWords = new Set(other.content.toLowerCase().split(/\s+/).filter((w) => w.length > 5));
646
+ let overlap = 0;
647
+ for (const w of contentWords) {
648
+ if (otherWords.has(w))
649
+ overlap++;
650
+ }
651
+ if (overlap >= 2) {
652
+ linkNotes(noteId, other.id, 'relates_to', Math.min(1.0, overlap * 0.2));
653
+ linksCreated++;
654
+ }
655
+ }
656
+ return {
657
+ content: [{ type: 'text', text: `Note ${noteId}: "${content.slice(0, 60)}..." (${note_type}, ${linksCreated} auto-links)` }],
658
+ };
659
+ }
660
+ return {
661
+ content: [{ type: 'text', text: `Note ${noteId}: "${content.slice(0, 60)}..." (${note_type})` }],
662
+ };
663
+ });
664
+ // ============================================================
665
+ // TOOL 17: search_notes — Search Zettelkasten notes
666
+ // ============================================================
667
+ server.tool('search_notes', 'Search atomic notes by keyword. Returns notes sorted by access frequency.', {
668
+ keyword: z.string(),
669
+ limit: z.number().optional().default(10),
670
+ }, async ({ keyword, limit }) => {
671
+ const notes = searchNotes(keyword, limit);
672
+ return { content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }] };
673
+ });
674
+ // ============================================================
675
+ // Server startup
676
+ // ============================================================
677
+ export async function startServer() {
678
+ // Initialize DB on startup to catch schema errors early
679
+ try {
680
+ getDb();
681
+ console.error('[cascade-engine] Database initialized');
682
+ }
683
+ catch (err) {
684
+ console.error('[cascade-engine] DB initialization failed:', err);
685
+ process.exit(1);
686
+ }
687
+ const transport = new StdioServerTransport();
688
+ await server.connect(transport);
689
+ console.error('[cascade-engine] MCP server running on stdio');
690
+ }
691
+ // Direct execution (node dist/index.js)
692
+ const isDirectRun = process.argv[1]?.endsWith('index.js');
693
+ if (isDirectRun) {
694
+ startServer().catch((err) => {
695
+ console.error('[cascade-engine] Fatal:', err);
696
+ closeDb();
697
+ process.exit(1);
698
+ });
699
+ }
700
+ export default startServer;
701
+ //# sourceMappingURL=index.js.map