mio-agent-runtime 0.13.4 → 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -17,6 +17,7 @@
17
17
  "!server/mio-intelligence-mcp/__tests__"
18
18
  ],
19
19
  "scripts": {
20
+ "pretest": "node scripts/link-workspace.cjs",
20
21
  "check": "node scripts/check-syntax.cjs",
21
22
  "test": "node --test \"__tests__/*.test.js\" \"server/mio-intelligence-mcp/__tests__/*.test.js\"",
22
23
  "prepack": "npm run check",
@@ -33,8 +34,9 @@
33
34
  "@akemi-mio/evolution-strategy": "^0.1.0",
34
35
  "@akemi-mio/evolution-safety": "^0.1.0",
35
36
  "@akemi-mio/evolution-scheduler": "^0.1.0",
36
- "@akemi-mio/observer": "^0.1.3",
37
- "@akemi-mio/insight": "^0.1.0"
37
+ "@akemi-mio/observer": "^0.1.4",
38
+ "@akemi-mio/insight": "^0.1.1",
39
+ "@akemi-mio/creativity": "^0.1.0"
38
40
  },
39
41
  "main": "index.js",
40
42
  "directories": {
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // Creates the node_modules junctions mio-cli needs to require the workspace
5
+ // packages it depends on at runtime (@akemi-mio/insight etc. already exist as
6
+ // junctions in a normal checkout; a fresh clone has none). Idempotent: an
7
+ // existing link (or directory) is left alone. Windows junctions, because this
8
+ // checkout lives on NTFS and junctions need no admin rights.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ const REPO = path.resolve(__dirname, '..', '..', '..')
14
+ const links = [
15
+ {
16
+ at: path.join(REPO, 'packages', 'mio-cli', 'node_modules', '@akemi-mio', 'creativity'),
17
+ target: path.join(REPO, 'packages', 'creativity'),
18
+ },
19
+ {
20
+ at: path.join(REPO, 'packages', 'creativity', 'node_modules', '@akemi-mio', 'core'),
21
+ target: path.join(REPO, 'packages', 'core'),
22
+ },
23
+ // Root-level links: the new tests under tests/main/creativity/ import
24
+ // '@akemi-mio/creativity/*' from the repo root, and the lockfile's
25
+ // link:true entries are not materialized on disk (ground rule 4 forbids
26
+ // the `npm install` that would create them).
27
+ {
28
+ at: path.join(REPO, 'node_modules', '@akemi-mio', 'creativity'),
29
+ target: path.join(REPO, 'packages', 'creativity'),
30
+ },
31
+ {
32
+ at: path.join(REPO, 'node_modules', '@akemi-mio', 'core'),
33
+ target: path.join(REPO, 'packages', 'core'),
34
+ },
35
+ ]
36
+
37
+ for (const { at, target } of links) {
38
+ let exists = false
39
+ try {
40
+ fs.lstatSync(at)
41
+ exists = true
42
+ } catch (_) {
43
+ exists = false
44
+ }
45
+ if (exists) {
46
+ console.log(`exists ${at}`)
47
+ continue
48
+ }
49
+ fs.mkdirSync(path.dirname(at), { recursive: true })
50
+ fs.symlinkSync(target, at, 'junction')
51
+ console.log(`linked ${at} -> ${target}`)
52
+ }
@@ -3,6 +3,37 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const crypto = require('crypto')
6
+ const { evaluateNovelty } = require('@akemi-mio/creativity/NoveltyScorer')
7
+
8
+ // Cache validity key for a JSONL file: a (size, mtimeMs) pair, the same
9
+ // guard server/insight-store.js uses (commit 0ba7fa8). Every getter below used
10
+ // to latch its parsed array forever, so a long-running MCP process answered
11
+ // `mio.creativity.list` from the copy it loaded at startup and a
12
+ // `mio creativity generate` in another process stayed invisible until restart.
13
+ function fileSignature(filePath) {
14
+ try {
15
+ const st = fs.statSync(filePath)
16
+ return `${st.size}:${st.mtimeMs}`
17
+ } catch (_) {
18
+ return 'missing'
19
+ }
20
+ }
21
+
22
+ // One clamp for every LLM score: coerce, reject NaN/Infinity (a model that
23
+ // returns "high" used to store NaN and poison the sum gate), then bound to
24
+ // 0-100. Missing values fall back so old records keep a neutral score.
25
+ function clamp100(value, fallback = 50) {
26
+ const n = Number(value)
27
+ if (!Number.isFinite(n)) return fallback
28
+ return Math.min(100, Math.max(0, n))
29
+ }
30
+
31
+ // ISO timestamp for provenance: accepts epoch ms or date strings, returns
32
+ // undefined when the value is missing or unparsable.
33
+ function isoOf(value) {
34
+ const t = typeof value === 'number' ? value : Date.parse(value || '')
35
+ return Number.isFinite(t) ? new Date(t).toISOString() : undefined
36
+ }
6
37
 
7
38
  // ═══════════════════════════════════════════════
8
39
  // JSONL Store — ideas, combos, experiments
@@ -17,6 +48,9 @@ class CreativityStore {
17
48
  this._hypotheses = null
18
49
  this._combos = null
19
50
  this._experiments = null
51
+ this._hypothesesSig = null
52
+ this._combosSig = null
53
+ this._experimentsSig = null
20
54
  }
21
55
 
22
56
  _ensureDir() {
@@ -42,8 +76,10 @@ class CreativityStore {
42
76
  }
43
77
 
44
78
  getHypotheses(opts = {}) {
45
- if (this._hypotheses === null) {
79
+ const sig = fileSignature(this.hypothesesPath)
80
+ if (this._hypotheses === null || this._hypothesesSig !== sig) {
46
81
  this._hypotheses = this._readJsonl(this.hypothesesPath)
82
+ this._hypothesesSig = sig
47
83
  }
48
84
  let list = this._hypotheses
49
85
  if (opts.status) list = list.filter(h => h.status === opts.status)
@@ -53,6 +89,7 @@ class CreativityStore {
53
89
 
54
90
  addHypothesis(h) {
55
91
  this._appendJsonl(this.hypothesesPath, h)
92
+ this._hypothesesSig = fileSignature(this.hypothesesPath)
56
93
  if (this._hypotheses) this._hypotheses.push(h)
57
94
  }
58
95
 
@@ -63,30 +100,37 @@ class CreativityStore {
63
100
  Object.assign(all[idx], patch)
64
101
  this._hypotheses = all
65
102
  this._rewriteFile(this.hypothesesPath, all)
103
+ this._hypothesesSig = fileSignature(this.hypothesesPath)
66
104
  return true
67
105
  }
68
106
 
69
107
  getCombos(limit) {
70
- if (this._combos === null) {
108
+ const sig = fileSignature(this.combosPath)
109
+ if (this._combos === null || this._combosSig !== sig) {
71
110
  this._combos = this._readJsonl(this.combosPath)
111
+ this._combosSig = sig
72
112
  }
73
113
  return limit ? this._combos.slice(-limit) : this._combos
74
114
  }
75
115
 
76
116
  addCombo(c) {
77
117
  this._appendJsonl(this.combosPath, c)
118
+ this._combosSig = fileSignature(this.combosPath)
78
119
  if (this._combos) this._combos.push(c)
79
120
  }
80
121
 
81
122
  getExperiments() {
82
- if (this._experiments === null) {
123
+ const sig = fileSignature(this.experimentsPath)
124
+ if (this._experiments === null || this._experimentsSig !== sig) {
83
125
  this._experiments = this._readJsonl(this.experimentsPath)
126
+ this._experimentsSig = sig
84
127
  }
85
128
  return this._experiments
86
129
  }
87
130
 
88
131
  addExperiment(e) {
89
132
  this._appendJsonl(this.experimentsPath, e)
133
+ this._experimentsSig = fileSignature(this.experimentsPath)
90
134
  if (this._experiments) this._experiments.push(e)
91
135
  }
92
136
 
@@ -138,48 +182,61 @@ function mixConcepts(sources, exploredPairs, rng) {
138
182
  }
139
183
 
140
184
  // ═══════════════════════════════════════════════
141
- // Novelty Scorer — reject duplicates
185
+ // Creativity Engine — main orchestrator
142
186
  // ═══════════════════════════════════════════════
143
187
 
144
- function bigramSet(text) {
145
- const s = new Set()
146
- const lower = text.toLowerCase()
147
- for (let i = 0; i < lower.length - 1; i++) {
148
- s.add(lower.slice(i, i + 2))
149
- }
150
- return s
188
+ // Concept sources drawn from stored insights, backing
189
+ // `mio.creativity.generate { fromInsights: true }` / `mio creativity generate
190
+ // --from-insights`. The *caller* supplies the insight records because only the
191
+ // entry point knows which store is authoritative (MIO_HOME for the CLI, the
192
+ // MCP server's MIO_DATA_DIR); this only maps them onto the { name, content,
193
+ // type, weight } shape the concept mixer pairs up. Highest score first, capped
194
+ // so one busy insight run cannot turn into dozens of LLM calls.
195
+ function sourcesFromInsights(insights, limit = 10) {
196
+ return (Array.isArray(insights) ? insights : [])
197
+ .filter(i => i && (i.title || i.description || i.content))
198
+ .slice()
199
+ .sort((a, b) => (b.score || 0) - (a.score || 0))
200
+ .slice(0, limit)
201
+ .map(i => ({
202
+ name: String(i.title || i.id || 'insight').replace(/\s+/g, ' ').slice(0, 60),
203
+ content: [
204
+ i.description || i.content || i.title || '',
205
+ Array.isArray(i.evidence) && i.evidence.length > 0 ? `Evidence: ${i.evidence.join('; ')}` : '',
206
+ ].filter(Boolean).join('\n'),
207
+ type: 'insight',
208
+ // > 0.7: IdeaGenerator.applyTemperature keeps any weight>0.7 source unconditionally.
209
+ weight: 0.78,
210
+ origin: `insight:${i.id || i.title || 'unknown'}`,
211
+ ...(i.id ? { originId: i.id } : {}),
212
+ ...(isoOf(i.createdAt) ? { timestamp: isoOf(i.createdAt) } : {}),
213
+ }))
151
214
  }
152
215
 
153
- function jaccard(a, b) {
154
- if (a.size === 0 && b.size === 0) return 1
155
- let inter = 0
156
- for (const x of a) if (b.has(x)) inter++
157
- return inter / (a.size + b.size - inter)
158
- }
216
+ const STRATEGY_CYCLE = ['explore', 'signal', 'stable']
159
217
 
160
- function evaluateNovelty(idea, recent, rejected) {
161
- const ideaBigrams = bigramSet(idea.title + ' ' + idea.idea)
162
- for (const r of recent) {
163
- const sim = jaccard(ideaBigrams, bigramSet(r.title + ' ' + r.idea))
164
- if (sim > 0.7) {
165
- return { shouldReject: true, rejectReason: `too similar to recent: "${r.title}" (sim=${sim.toFixed(2)})`, adjustedNovelty: idea.novelty }
166
- }
218
+ // One row shape for every list variant — the fields JSON/CLI consumers already
219
+ // depend on. `noveltyScore` is added only by sort=novelty (relative ranking),
220
+ // never confused with the quality sum `score`.
221
+ function listRow(h) {
222
+ return {
223
+ id: h.id,
224
+ title: h.title,
225
+ idea: h.idea,
226
+ status: h.status,
227
+ novelty: h.novelty,
228
+ feasibility: h.feasibility,
229
+ impact: h.impact,
230
+ ...(h.logic !== undefined ? { logic: h.logic } : {}),
231
+ score: h.novelty + h.feasibility + h.impact,
232
+ sourceLabels: h.sourceLabels,
233
+ createdAt: h.createdAt,
234
+ fermentedAt: h.fermentedAt,
235
+ fermentCount: h.fermentCount,
236
+ ...(h.mergedInto !== undefined ? { mergedInto: h.mergedInto } : {}),
167
237
  }
168
- for (const r of rejected) {
169
- const sim = jaccard(ideaBigrams, bigramSet(r.title + ' ' + r.idea))
170
- if (sim > 0.6) {
171
- return { shouldReject: true, rejectReason: `matches rejected: "${r.title}" (sim=${sim.toFixed(2)})`, adjustedNovelty: idea.novelty }
172
- }
173
- }
174
- return { shouldReject: false, adjustedNovelty: Math.min(100, idea.novelty + 5) }
175
238
  }
176
239
 
177
- // ═══════════════════════════════════════════════
178
- // Creativity Engine — main orchestrator
179
- // ═══════════════════════════════════════════════
180
-
181
- const STRATEGY_CYCLE = ['explore', 'signal', 'stable']
182
-
183
240
  class CreativityEngine {
184
241
  constructor(dataDir, chatJson) {
185
242
  this.store = new CreativityStore(dataDir)
@@ -221,56 +278,84 @@ class CreativityEngine {
221
278
 
222
279
  // Generate hypotheses via LLM
223
280
  const ideas = []
281
+ // A failed pair used to vanish: `catch {}` swallowed the exception and a
282
+ // chatJson `{ error }` payload fell through the `if (result.data)` guard,
283
+ // so the caller saw `ideas: []` with no explanation of whether the LLM was
284
+ // down, the JSON was unparseable, or every pair really was a duplicate.
285
+ const errors = []
224
286
  for (const [a, b] of pairs) {
225
287
  const prompt = this._buildGenerationPrompt(a, b, strat, recent.slice(-10), rejected.slice(-5))
226
288
  try {
227
289
  const result = await this.chatJson(prompt, {
228
- system: `You are a creative hypothesis generator. Output JSON with fields: title, idea, expectedBenefit, risk, novelty (0-100), feasibility (0-100), impact (0-100). Generate ONE novel hypothesis by combining the given concepts.`,
290
+ system: [
291
+ 'You are a creative hypothesis generator. Generate ONE novel hypothesis by combining the given concepts.',
292
+ 'Reasoning rules:',
293
+ '1. First state the synergy mechanism: WHY these two concepts combine (what shared structure or complementary gap), not just THAT they pair.',
294
+ '2. Then give a causal chain: premise -> mechanism -> outcome. Each step must follow from the previous one; no unexplained jumps.',
295
+ '3. Be concrete: name the component, the change, and the observable effect. Vague claims ("improve efficiency", "be better") score 0 on logic.',
296
+ '4. State the strongest counter-risk honestly; an idea with no real risk is not being analyzed.',
297
+ 'Output JSON with fields: title (<=60 chars), idea (the full causal chain, 100-250 words), expectedBenefit, risk, novelty (0-100), feasibility (0-100), impact (0-100), logic (0-100: how tight and complete the causal chain is).',
298
+ ].join('\n'),
229
299
  temperature: strat === 'explore' ? 0.8 : 0.4,
300
+ maxTokens: 900,
230
301
  })
231
302
 
232
- if (result.data) {
233
- const h = {
234
- id: crypto.randomUUID(),
235
- title: result.data.title || 'Untitled',
236
- idea: result.data.idea || '',
237
- expectedBenefit: result.data.expectedBenefit || '',
238
- risk: result.data.risk || '',
239
- novelty: Math.min(100, Math.max(0, result.data.novelty || 50)),
240
- feasibility: Math.min(100, Math.max(0, result.data.feasibility || 50)),
241
- impact: Math.min(100, Math.max(0, result.data.impact || 50)),
242
- sourceLabels: [a.name, b.name],
243
- status: 'active',
244
- createdAt: Date.now(),
245
- strategy: strat,
246
- }
247
-
248
- // Novelty check
249
- const verdict = evaluateNovelty(h, recent, rejected)
250
- if (verdict.shouldReject) {
251
- h.status = 'rejected'
252
- h.rejectionReason = verdict.rejectReason
253
- } else {
254
- h.novelty = verdict.adjustedNovelty
255
- }
303
+ if (!result || !result.data) {
304
+ errors.push({
305
+ pair: [a.name, b.name],
306
+ error: (result && result.error) || 'LLM returned no data',
307
+ })
308
+ continue
309
+ }
256
310
 
257
- this.store.addHypothesis(h)
258
- ideas.push(h)
311
+ const h = {
312
+ id: crypto.randomUUID(),
313
+ title: String(result.data.title || 'Untitled').slice(0, 60),
314
+ idea: result.data.idea || '',
315
+ expectedBenefit: result.data.expectedBenefit || '',
316
+ risk: result.data.risk || '',
317
+ novelty: clamp100(result.data.novelty || 50),
318
+ feasibility: clamp100(result.data.feasibility || 50),
319
+ impact: clamp100(result.data.impact || 50),
320
+ // logic is the fourth scoring dimension: a high-novelty idea with a
321
+ // broken causal chain must be visibly weaker than one that reasons
322
+ // cleanly. Defaults to 50 so records written before it existed keep
323
+ // a neutral value instead of NaN.
324
+ logic: clamp100(result.data.logic ?? 50),
325
+ sourceLabels: [a.name, b.name],
326
+ status: 'active',
327
+ createdAt: Date.now(),
328
+ strategy: strat,
329
+ }
259
330
 
260
- // Record combo
261
- this.store.addCombo({
262
- id: crypto.randomUUID(),
263
- sources: [a.name, b.name],
264
- description: h.title,
265
- createdAt: Date.now(),
266
- })
331
+ // Novelty check
332
+ const verdict = evaluateNovelty(h, recent, rejected)
333
+ if (verdict.shouldReject) {
334
+ h.status = 'rejected'
335
+ h.rejectionReason = verdict.rejectReason
336
+ } else {
337
+ h.novelty = verdict.adjustedNovelty
267
338
  }
339
+
340
+ this.store.addHypothesis(h)
341
+ ideas.push(h)
342
+
343
+ // Record combo
344
+ this.store.addCombo({
345
+ id: crypto.randomUUID(),
346
+ sources: [a.name, b.name],
347
+ description: h.title,
348
+ createdAt: Date.now(),
349
+ })
268
350
  } catch (err) {
269
- // LLM call failed, skip this pair
351
+ errors.push({
352
+ pair: [a.name, b.name],
353
+ error: String((err && err.message) || err),
354
+ })
270
355
  }
271
356
  }
272
357
 
273
- return { ideas, strategy: strat, pairsAttempted: pairs.length }
358
+ return { ideas, strategy: strat, pairsAttempted: pairs.length, errors }
274
359
  }
275
360
 
276
361
  async ferment(limit = 5) {
@@ -283,48 +368,105 @@ class CreativityEngine {
283
368
  }
284
369
 
285
370
  const results = []
371
+ // Same reason as generate(): a failing review used to be indistinguishable
372
+ // from a hypothesis that simply had nothing new to say.
373
+ const errors = []
286
374
  for (const h of fermentable) {
287
375
  const prompt = `Review and refine this hypothesis. Current state:
288
376
  Title: ${h.title}
289
377
  Idea: ${h.idea}
290
- Novelty: ${h.novelty}, Feasibility: ${h.feasibility}, Impact: ${h.impact}
378
+ Novelty: ${h.novelty}, Feasibility: ${h.feasibility}, Impact: ${h.impact}, Logic: ${h.logic ?? '(not scored)'}
291
379
 
292
- Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impact, verdict (promote/keep/reject/merge), reason.`
380
+ Review checklist (apply in order):
381
+ 1. Causal chain: does the idea explain WHY A leads to B at each step? If a step is asserted without a mechanism, lower logic and rewrite the step.
382
+ 2. Evidence: is each claim traceable to the given idea text, or is it speculation presented as fact? Flag speculation.
383
+ 3. Falsifiability: can you state how this idea would prove wrong? If not, say so in reason.
384
+ 4. Concrete over vague: reject filler claims ("improve efficiency") by rewriting them with the specific component and effect.
385
+
386
+ Verdict rules:
387
+ - promote: the idea is mature AND the refined scores justify landing (novelty + feasibility + impact > 200 with logic >= 60).
388
+ - merge: only when this idea is clearly complementary to another stored one; set mergeWithId to that hypothesis id.
389
+ - reject: the causal chain cannot be repaired, or the premise lost its supporting signals.
390
+ - keep: everything else.
391
+
392
+ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibility (0-100), impact (0-100), logic (0-100), verdict (promote/keep/reject/merge), reason, mergeWithId (only for merge).`
293
393
 
294
394
  try {
295
395
  const result = await this.chatJson(prompt, {
296
- system: 'You are a creative hypothesis reviewer. Output JSON with refined scores and a verdict.',
396
+ system: 'You are a critical creative hypothesis reviewer. Reward tight causal reasoning, penalize vagueness. Output JSON only.',
297
397
  temperature: 0.3,
398
+ maxTokens: 900,
298
399
  })
299
400
 
300
- if (result.data) {
301
- const patch = {
302
- title: result.data.title || h.title,
303
- idea: result.data.idea || h.idea,
304
- expectedBenefit: result.data.expectedBenefit || h.expectedBenefit,
305
- risk: result.data.risk || h.risk,
306
- novelty: Math.min(100, Math.max(0, result.data.novelty || h.novelty)),
307
- feasibility: Math.min(100, Math.max(0, result.data.feasibility || h.feasibility)),
308
- impact: Math.min(100, Math.max(0, result.data.impact || h.impact)),
309
- fermentedAt: Date.now(),
310
- fermentCount: (h.fermentCount || 0) + 1,
311
- }
401
+ if (!result || !result.data) {
402
+ errors.push({
403
+ id: h.id,
404
+ title: h.title,
405
+ error: (result && result.error) || 'LLM returned no data',
406
+ })
407
+ continue
408
+ }
409
+
410
+ const patch = {
411
+ title: String(result.data.title || h.title).slice(0, 60),
412
+ idea: result.data.idea || h.idea,
413
+ expectedBenefit: result.data.expectedBenefit || h.expectedBenefit,
414
+ risk: result.data.risk || h.risk,
415
+ novelty: clamp100(result.data.novelty || h.novelty),
416
+ feasibility: clamp100(result.data.feasibility || h.feasibility),
417
+ impact: clamp100(result.data.impact || h.impact),
418
+ logic: clamp100(result.data.logic ?? h.logic ?? 50),
419
+ fermentedAt: Date.now(),
420
+ fermentCount: (h.fermentCount || 0) + 1,
421
+ }
312
422
 
313
- if (result.data.verdict === 'reject') {
314
- patch.status = 'rejected'
315
- } else if (result.data.verdict === 'promote' && (h.novelty + h.feasibility + h.impact) > 200) {
316
- patch.status = 'validated'
423
+ let verdict = result.data.verdict
424
+ if (verdict === 'reject') {
425
+ patch.status = 'rejected'
426
+ } else if (verdict === 'merge') {
427
+ // merge used to fall through silently: the verdict was echoed back
428
+ // in the results but nothing was merged. Now it behaves like
429
+ // IdeaFermentationEngine -- the reviewed idea is folded into the
430
+ // target and marked merged, or downgraded to keep when the target
431
+ // id is unknown (an unresolvable merge is not a merge).
432
+ const target = result.data.mergeWithId
433
+ ? this.store.getHypotheses().find((x) => x.id === result.data.mergeWithId)
434
+ : null
435
+ if (target && target.id !== h.id) {
436
+ target.idea = [target.idea, patch.idea].filter(Boolean).join('\n---\n')
437
+ target.fermentedAt = patch.fermentedAt
438
+ target.fermentCount = (target.fermentCount || 0) + 1
439
+ this.store.updateHypothesis(target.id, {
440
+ idea: target.idea,
441
+ fermentedAt: target.fermentedAt,
442
+ fermentCount: target.fermentCount,
443
+ })
444
+ patch.status = 'merged'
445
+ patch.mergedInto = target.id
446
+ } else {
447
+ // An unresolvable merge target is not a merge; report it honestly
448
+ // as keep so the caller never sees a merge that did not happen.
449
+ verdict = 'keep'
317
450
  }
451
+ }
318
452
 
319
- this.store.updateHypothesis(h.id, patch)
320
- results.push({ id: h.id, title: h.title, verdict: result.data.verdict, reason: result.data.reason })
453
+ // The gate is unconditional: patch.logic already folds in the
454
+ // review's score, the stored score, or 50. A reviewer that omits
455
+ // the field falls back to the stored/neutral value — omitting logic
456
+ // must not waive the gate it exists to enforce.
457
+ const logicOk = Number.isFinite(patch.logic) && patch.logic >= 60
458
+ if (verdict === 'promote' && (patch.novelty + patch.feasibility + patch.impact) > 200 && logicOk) {
459
+ patch.status = 'validated'
321
460
  }
461
+
462
+ this.store.updateHypothesis(h.id, patch)
463
+ results.push({ id: h.id, title: h.title, verdict, reason: result.data.reason })
322
464
  } catch (err) {
323
- // skip
465
+ errors.push({ id: h.id, title: h.title, error: String((err && err.message) || err) })
324
466
  }
325
467
  }
326
468
 
327
- return { fermented: results.length, results }
469
+ return { fermented: results.length, results, errors }
328
470
  }
329
471
 
330
472
  status() {
@@ -332,44 +474,58 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
332
474
  const active = this.store.getHypotheses({ status: 'active' })
333
475
  const validated = this.store.getHypotheses({ status: 'validated' })
334
476
  const rejected = this.store.getHypotheses({ status: 'rejected' })
477
+ // draft is a real state -- ferment() reviews draft + active -- so hiding it
478
+ // here made `status` and `list --status draft` disagree.
479
+ const draft = this.store.getHypotheses({ status: 'draft' })
335
480
 
336
481
  return {
337
482
  ...count,
338
483
  active: active.length,
339
484
  validated: validated.length,
340
485
  rejected: rejected.length,
486
+ draft: draft.length,
341
487
  recentIdeas: active.slice(-5).map(h => ({
342
488
  id: h.id,
343
489
  title: h.title,
344
490
  novelty: h.novelty,
345
491
  feasibility: h.feasibility,
346
492
  impact: h.impact,
493
+ // Undefined keys vanish over JSON (the CLI path); keep them out here
494
+ // too so the engine and the CLI round-trip deep-equal.
495
+ ...(h.logic !== undefined ? { logic: h.logic } : {}),
347
496
  score: h.novelty + h.feasibility + h.impact,
348
497
  })),
349
498
  }
350
499
  }
351
500
 
501
+ // The MCP schema and the CLI usage text both promise a default of 20; the
502
+ // implementation returned the whole store, so a large one flooded the tool
503
+ // result. `limit: 0` (and only 0) opts back into "everything".
352
504
  list(opts = {}) {
353
- return this.store.getHypotheses(opts).map(h => ({
354
- id: h.id,
355
- title: h.title,
356
- idea: h.idea,
357
- status: h.status,
358
- novelty: h.novelty,
359
- feasibility: h.feasibility,
360
- impact: h.impact,
361
- score: h.novelty + h.feasibility + h.impact,
362
- sourceLabels: h.sourceLabels,
363
- createdAt: h.createdAt,
364
- fermentedAt: h.fermentedAt,
365
- fermentCount: h.fermentCount,
366
- }))
505
+ const limit = opts.limit === undefined ? 20 : opts.limit
506
+ if (opts.sort === 'novelty') {
507
+ // Relative novelty ranking (read-only, nothing written back). Pairwise
508
+ // Jaccard is O(n²) — bound candidates to the newest 200 (spec Risk 3).
509
+ const candidates = this.store.getHypotheses({ status: opts.status }).slice(-200)
510
+ const rows = candidates.map((h) => {
511
+ const others = candidates.filter((x) => x.id !== h.id)
512
+ const verdict = evaluateNovelty(h, others, [])
513
+ return { ...listRow(h), noveltyScore: verdict.adjustedNovelty }
514
+ })
515
+ rows.sort((a, b) => b.noveltyScore - a.noveltyScore || (b.createdAt || 0) - (a.createdAt || 0))
516
+ return limit === 0 ? rows : rows.slice(0, limit)
517
+ }
518
+ return this.store.getHypotheses({ status: opts.status, limit }).map(listRow)
367
519
  }
368
520
 
369
521
  _buildGenerationPrompt(a, b, strategy, recentHypotheses, rejectedHypotheses) {
522
+ // Long sources used to be pasted unbounded, crowding the instructions out
523
+ // of the context window; 600 chars keeps the pairing signal without the
524
+ // flood.
525
+ const clip = (s, n = 600) => (s.length > n ? s.slice(0, n) + '…' : s)
370
526
  let prompt = `Combine these two concepts into ONE novel hypothesis:\n\n`
371
- prompt += `Concept A: "${a.name}" — ${a.content}\n`
372
- prompt += `Concept B: "${b.name}" — ${b.content}\n\n`
527
+ prompt += `Concept A: "${a.name}" — ${clip(a.content)}\n`
528
+ prompt += `Concept B: "${b.name}" — ${clip(b.content)}\n\n`
373
529
  prompt += `Strategy: ${strategy}\n`
374
530
 
375
531
  if (strategy === 'explore') {
@@ -380,6 +536,15 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
380
536
  prompt += `Stable combination. Conservative refinement of existing ideas.\n`
381
537
  }
382
538
 
539
+ // The logical-quality bar, stated where the model composes rather than
540
+ // only in the system prompt: synergy mechanism, explicit causal chain,
541
+ // and no claims that cannot name their mechanism.
542
+ prompt += `\nQuality requirements:\n`
543
+ prompt += `- The idea must explain the synergy mechanism: WHY these two concepts combine.\n`
544
+ prompt += `- Write the proposal as a causal chain (premise -> mechanism -> outcome), each step following from the last.\n`
545
+ prompt += `- Be specific: component, change, observable effect. No bare claims like "improve efficiency".\n`
546
+ prompt += `- The title must be self-contained (a reader sees only the title in lists).\n`
547
+
383
548
  if (recentHypotheses.length > 0) {
384
549
  prompt += `\nRecent ideas (avoid duplicates):\n`
385
550
  for (const h of recentHypotheses) {
@@ -394,9 +559,9 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
394
559
  }
395
560
  }
396
561
 
397
- prompt += `\nOutput JSON: {"title": "...", "idea": "...", "expectedBenefit": "...", "risk": "...", "novelty": 0-100, "feasibility": 0-100, "impact": 0-100}`
562
+ prompt += `\nOutput JSON: {"title": "...", "idea": "...", "expectedBenefit": "...", "risk": "...", "novelty": 0-100, "feasibility": 0-100, "impact": 0-100, "logic": 0-100}`
398
563
  return prompt
399
564
  }
400
565
  }
401
566
 
402
- module.exports = { CreativityEngine, CreativityStore }
567
+ module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights, isoOf }