mio-agent-runtime 0.13.4 → 0.13.5

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.13.5",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -33,8 +33,8 @@
33
33
  "@akemi-mio/evolution-strategy": "^0.1.0",
34
34
  "@akemi-mio/evolution-safety": "^0.1.0",
35
35
  "@akemi-mio/evolution-scheduler": "^0.1.0",
36
- "@akemi-mio/observer": "^0.1.3",
37
- "@akemi-mio/insight": "^0.1.0"
36
+ "@akemi-mio/observer": "^0.1.4",
37
+ "@akemi-mio/insight": "^0.1.1"
38
38
  },
39
39
  "main": "index.js",
40
40
  "directories": {
@@ -4,6 +4,29 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const crypto = require('crypto')
6
6
 
7
+ // Cache validity key for a JSONL file: a (size, mtimeMs) pair, the same
8
+ // guard server/insight-store.js uses (commit 0ba7fa8). Every getter below used
9
+ // to latch its parsed array forever, so a long-running MCP process answered
10
+ // `mio.creativity.list` from the copy it loaded at startup and a
11
+ // `mio creativity generate` in another process stayed invisible until restart.
12
+ function fileSignature(filePath) {
13
+ try {
14
+ const st = fs.statSync(filePath)
15
+ return `${st.size}:${st.mtimeMs}`
16
+ } catch (_) {
17
+ return 'missing'
18
+ }
19
+ }
20
+
21
+ // One clamp for every LLM score: coerce, reject NaN/Infinity (a model that
22
+ // returns "high" used to store NaN and poison the sum gate), then bound to
23
+ // 0-100. Missing values fall back so old records keep a neutral score.
24
+ function clamp100(value, fallback = 50) {
25
+ const n = Number(value)
26
+ if (!Number.isFinite(n)) return fallback
27
+ return Math.min(100, Math.max(0, n))
28
+ }
29
+
7
30
  // ═══════════════════════════════════════════════
8
31
  // JSONL Store — ideas, combos, experiments
9
32
  // ═══════════════════════════════════════════════
@@ -17,6 +40,9 @@ class CreativityStore {
17
40
  this._hypotheses = null
18
41
  this._combos = null
19
42
  this._experiments = null
43
+ this._hypothesesSig = null
44
+ this._combosSig = null
45
+ this._experimentsSig = null
20
46
  }
21
47
 
22
48
  _ensureDir() {
@@ -42,8 +68,10 @@ class CreativityStore {
42
68
  }
43
69
 
44
70
  getHypotheses(opts = {}) {
45
- if (this._hypotheses === null) {
71
+ const sig = fileSignature(this.hypothesesPath)
72
+ if (this._hypotheses === null || this._hypothesesSig !== sig) {
46
73
  this._hypotheses = this._readJsonl(this.hypothesesPath)
74
+ this._hypothesesSig = sig
47
75
  }
48
76
  let list = this._hypotheses
49
77
  if (opts.status) list = list.filter(h => h.status === opts.status)
@@ -53,6 +81,7 @@ class CreativityStore {
53
81
 
54
82
  addHypothesis(h) {
55
83
  this._appendJsonl(this.hypothesesPath, h)
84
+ this._hypothesesSig = fileSignature(this.hypothesesPath)
56
85
  if (this._hypotheses) this._hypotheses.push(h)
57
86
  }
58
87
 
@@ -63,30 +92,37 @@ class CreativityStore {
63
92
  Object.assign(all[idx], patch)
64
93
  this._hypotheses = all
65
94
  this._rewriteFile(this.hypothesesPath, all)
95
+ this._hypothesesSig = fileSignature(this.hypothesesPath)
66
96
  return true
67
97
  }
68
98
 
69
99
  getCombos(limit) {
70
- if (this._combos === null) {
100
+ const sig = fileSignature(this.combosPath)
101
+ if (this._combos === null || this._combosSig !== sig) {
71
102
  this._combos = this._readJsonl(this.combosPath)
103
+ this._combosSig = sig
72
104
  }
73
105
  return limit ? this._combos.slice(-limit) : this._combos
74
106
  }
75
107
 
76
108
  addCombo(c) {
77
109
  this._appendJsonl(this.combosPath, c)
110
+ this._combosSig = fileSignature(this.combosPath)
78
111
  if (this._combos) this._combos.push(c)
79
112
  }
80
113
 
81
114
  getExperiments() {
82
- if (this._experiments === null) {
115
+ const sig = fileSignature(this.experimentsPath)
116
+ if (this._experiments === null || this._experimentsSig !== sig) {
83
117
  this._experiments = this._readJsonl(this.experimentsPath)
118
+ this._experimentsSig = sig
84
119
  }
85
120
  return this._experiments
86
121
  }
87
122
 
88
123
  addExperiment(e) {
89
124
  this._appendJsonl(this.experimentsPath, e)
125
+ this._experimentsSig = fileSignature(this.experimentsPath)
90
126
  if (this._experiments) this._experiments.push(e)
91
127
  }
92
128
 
@@ -178,6 +214,30 @@ function evaluateNovelty(idea, recent, rejected) {
178
214
  // Creativity Engine — main orchestrator
179
215
  // ═══════════════════════════════════════════════
180
216
 
217
+ // Concept sources drawn from stored insights, backing
218
+ // `mio.creativity.generate { fromInsights: true }` / `mio creativity generate
219
+ // --from-insights`. The *caller* supplies the insight records because only the
220
+ // entry point knows which store is authoritative (MIO_HOME for the CLI, the
221
+ // MCP server's MIO_DATA_DIR); this only maps them onto the { name, content,
222
+ // type, weight } shape the concept mixer pairs up. Highest score first, capped
223
+ // so one busy insight run cannot turn into dozens of LLM calls.
224
+ function sourcesFromInsights(insights, limit = 10) {
225
+ return (Array.isArray(insights) ? insights : [])
226
+ .filter(i => i && (i.title || i.description || i.content))
227
+ .slice()
228
+ .sort((a, b) => (b.score || 0) - (a.score || 0))
229
+ .slice(0, limit)
230
+ .map(i => ({
231
+ name: String(i.title || i.id || 'insight').replace(/\s+/g, ' ').slice(0, 60),
232
+ content: [
233
+ i.description || i.content || i.title || '',
234
+ Array.isArray(i.evidence) && i.evidence.length > 0 ? `Evidence: ${i.evidence.join('; ')}` : '',
235
+ ].filter(Boolean).join('\n'),
236
+ type: 'insight',
237
+ weight: 0.7,
238
+ }))
239
+ }
240
+
181
241
  const STRATEGY_CYCLE = ['explore', 'signal', 'stable']
182
242
 
183
243
  class CreativityEngine {
@@ -221,56 +281,84 @@ class CreativityEngine {
221
281
 
222
282
  // Generate hypotheses via LLM
223
283
  const ideas = []
284
+ // A failed pair used to vanish: `catch {}` swallowed the exception and a
285
+ // chatJson `{ error }` payload fell through the `if (result.data)` guard,
286
+ // so the caller saw `ideas: []` with no explanation of whether the LLM was
287
+ // down, the JSON was unparseable, or every pair really was a duplicate.
288
+ const errors = []
224
289
  for (const [a, b] of pairs) {
225
290
  const prompt = this._buildGenerationPrompt(a, b, strat, recent.slice(-10), rejected.slice(-5))
226
291
  try {
227
292
  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.`,
293
+ system: [
294
+ 'You are a creative hypothesis generator. Generate ONE novel hypothesis by combining the given concepts.',
295
+ 'Reasoning rules:',
296
+ '1. First state the synergy mechanism: WHY these two concepts combine (what shared structure or complementary gap), not just THAT they pair.',
297
+ '2. Then give a causal chain: premise -> mechanism -> outcome. Each step must follow from the previous one; no unexplained jumps.',
298
+ '3. Be concrete: name the component, the change, and the observable effect. Vague claims ("improve efficiency", "be better") score 0 on logic.',
299
+ '4. State the strongest counter-risk honestly; an idea with no real risk is not being analyzed.',
300
+ '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).',
301
+ ].join('\n'),
229
302
  temperature: strat === 'explore' ? 0.8 : 0.4,
303
+ maxTokens: 900,
230
304
  })
231
305
 
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
- }
306
+ if (!result || !result.data) {
307
+ errors.push({
308
+ pair: [a.name, b.name],
309
+ error: (result && result.error) || 'LLM returned no data',
310
+ })
311
+ continue
312
+ }
256
313
 
257
- this.store.addHypothesis(h)
258
- ideas.push(h)
314
+ const h = {
315
+ id: crypto.randomUUID(),
316
+ title: String(result.data.title || 'Untitled').slice(0, 60),
317
+ idea: result.data.idea || '',
318
+ expectedBenefit: result.data.expectedBenefit || '',
319
+ risk: result.data.risk || '',
320
+ novelty: clamp100(result.data.novelty || 50),
321
+ feasibility: clamp100(result.data.feasibility || 50),
322
+ impact: clamp100(result.data.impact || 50),
323
+ // logic is the fourth scoring dimension: a high-novelty idea with a
324
+ // broken causal chain must be visibly weaker than one that reasons
325
+ // cleanly. Defaults to 50 so records written before it existed keep
326
+ // a neutral value instead of NaN.
327
+ logic: clamp100(result.data.logic ?? 50),
328
+ sourceLabels: [a.name, b.name],
329
+ status: 'active',
330
+ createdAt: Date.now(),
331
+ strategy: strat,
332
+ }
259
333
 
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
- })
334
+ // Novelty check
335
+ const verdict = evaluateNovelty(h, recent, rejected)
336
+ if (verdict.shouldReject) {
337
+ h.status = 'rejected'
338
+ h.rejectionReason = verdict.rejectReason
339
+ } else {
340
+ h.novelty = verdict.adjustedNovelty
267
341
  }
342
+
343
+ this.store.addHypothesis(h)
344
+ ideas.push(h)
345
+
346
+ // Record combo
347
+ this.store.addCombo({
348
+ id: crypto.randomUUID(),
349
+ sources: [a.name, b.name],
350
+ description: h.title,
351
+ createdAt: Date.now(),
352
+ })
268
353
  } catch (err) {
269
- // LLM call failed, skip this pair
354
+ errors.push({
355
+ pair: [a.name, b.name],
356
+ error: String((err && err.message) || err),
357
+ })
270
358
  }
271
359
  }
272
360
 
273
- return { ideas, strategy: strat, pairsAttempted: pairs.length }
361
+ return { ideas, strategy: strat, pairsAttempted: pairs.length, errors }
274
362
  }
275
363
 
276
364
  async ferment(limit = 5) {
@@ -283,48 +371,105 @@ class CreativityEngine {
283
371
  }
284
372
 
285
373
  const results = []
374
+ // Same reason as generate(): a failing review used to be indistinguishable
375
+ // from a hypothesis that simply had nothing new to say.
376
+ const errors = []
286
377
  for (const h of fermentable) {
287
378
  const prompt = `Review and refine this hypothesis. Current state:
288
379
  Title: ${h.title}
289
380
  Idea: ${h.idea}
290
- Novelty: ${h.novelty}, Feasibility: ${h.feasibility}, Impact: ${h.impact}
381
+ Novelty: ${h.novelty}, Feasibility: ${h.feasibility}, Impact: ${h.impact}, Logic: ${h.logic ?? '(not scored)'}
291
382
 
292
- Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impact, verdict (promote/keep/reject/merge), reason.`
383
+ Review checklist (apply in order):
384
+ 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.
385
+ 2. Evidence: is each claim traceable to the given idea text, or is it speculation presented as fact? Flag speculation.
386
+ 3. Falsifiability: can you state how this idea would prove wrong? If not, say so in reason.
387
+ 4. Concrete over vague: reject filler claims ("improve efficiency") by rewriting them with the specific component and effect.
388
+
389
+ Verdict rules:
390
+ - promote: the idea is mature AND the refined scores justify landing (novelty + feasibility + impact > 200 with logic >= 60).
391
+ - merge: only when this idea is clearly complementary to another stored one; set mergeWithId to that hypothesis id.
392
+ - reject: the causal chain cannot be repaired, or the premise lost its supporting signals.
393
+ - keep: everything else.
394
+
395
+ 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
396
 
294
397
  try {
295
398
  const result = await this.chatJson(prompt, {
296
- system: 'You are a creative hypothesis reviewer. Output JSON with refined scores and a verdict.',
399
+ system: 'You are a critical creative hypothesis reviewer. Reward tight causal reasoning, penalize vagueness. Output JSON only.',
297
400
  temperature: 0.3,
401
+ maxTokens: 900,
298
402
  })
299
403
 
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
- }
404
+ if (!result || !result.data) {
405
+ errors.push({
406
+ id: h.id,
407
+ title: h.title,
408
+ error: (result && result.error) || 'LLM returned no data',
409
+ })
410
+ continue
411
+ }
412
+
413
+ const patch = {
414
+ title: String(result.data.title || h.title).slice(0, 60),
415
+ idea: result.data.idea || h.idea,
416
+ expectedBenefit: result.data.expectedBenefit || h.expectedBenefit,
417
+ risk: result.data.risk || h.risk,
418
+ novelty: clamp100(result.data.novelty || h.novelty),
419
+ feasibility: clamp100(result.data.feasibility || h.feasibility),
420
+ impact: clamp100(result.data.impact || h.impact),
421
+ logic: clamp100(result.data.logic ?? h.logic ?? 50),
422
+ fermentedAt: Date.now(),
423
+ fermentCount: (h.fermentCount || 0) + 1,
424
+ }
312
425
 
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'
426
+ let verdict = result.data.verdict
427
+ if (verdict === 'reject') {
428
+ patch.status = 'rejected'
429
+ } else if (verdict === 'merge') {
430
+ // merge used to fall through silently: the verdict was echoed back
431
+ // in the results but nothing was merged. Now it behaves like
432
+ // IdeaFermentationEngine -- the reviewed idea is folded into the
433
+ // target and marked merged, or downgraded to keep when the target
434
+ // id is unknown (an unresolvable merge is not a merge).
435
+ const target = result.data.mergeWithId
436
+ ? this.store.getHypotheses().find((x) => x.id === result.data.mergeWithId)
437
+ : null
438
+ if (target && target.id !== h.id) {
439
+ target.idea = [target.idea, patch.idea].filter(Boolean).join('\n---\n')
440
+ target.fermentedAt = patch.fermentedAt
441
+ target.fermentCount = (target.fermentCount || 0) + 1
442
+ this.store.updateHypothesis(target.id, {
443
+ idea: target.idea,
444
+ fermentedAt: target.fermentedAt,
445
+ fermentCount: target.fermentCount,
446
+ })
447
+ patch.status = 'merged'
448
+ patch.mergedInto = target.id
449
+ } else {
450
+ // An unresolvable merge target is not a merge; report it honestly
451
+ // as keep so the caller never sees a merge that did not happen.
452
+ verdict = 'keep'
317
453
  }
454
+ }
318
455
 
319
- this.store.updateHypothesis(h.id, patch)
320
- results.push({ id: h.id, title: h.title, verdict: result.data.verdict, reason: result.data.reason })
456
+ // The gate is unconditional: patch.logic already folds in the
457
+ // review's score, the stored score, or 50. A reviewer that omits
458
+ // the field falls back to the stored/neutral value — omitting logic
459
+ // must not waive the gate it exists to enforce.
460
+ const logicOk = Number.isFinite(patch.logic) && patch.logic >= 60
461
+ if (verdict === 'promote' && (patch.novelty + patch.feasibility + patch.impact) > 200 && logicOk) {
462
+ patch.status = 'validated'
321
463
  }
464
+
465
+ this.store.updateHypothesis(h.id, patch)
466
+ results.push({ id: h.id, title: h.title, verdict, reason: result.data.reason })
322
467
  } catch (err) {
323
- // skip
468
+ errors.push({ id: h.id, title: h.title, error: String((err && err.message) || err) })
324
469
  }
325
470
  }
326
471
 
327
- return { fermented: results.length, results }
472
+ return { fermented: results.length, results, errors }
328
473
  }
329
474
 
330
475
  status() {
@@ -332,25 +477,36 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
332
477
  const active = this.store.getHypotheses({ status: 'active' })
333
478
  const validated = this.store.getHypotheses({ status: 'validated' })
334
479
  const rejected = this.store.getHypotheses({ status: 'rejected' })
480
+ // draft is a real state -- ferment() reviews draft + active -- so hiding it
481
+ // here made `status` and `list --status draft` disagree.
482
+ const draft = this.store.getHypotheses({ status: 'draft' })
335
483
 
336
484
  return {
337
485
  ...count,
338
486
  active: active.length,
339
487
  validated: validated.length,
340
488
  rejected: rejected.length,
489
+ draft: draft.length,
341
490
  recentIdeas: active.slice(-5).map(h => ({
342
491
  id: h.id,
343
492
  title: h.title,
344
493
  novelty: h.novelty,
345
494
  feasibility: h.feasibility,
346
495
  impact: h.impact,
496
+ // Undefined keys vanish over JSON (the CLI path); keep them out here
497
+ // too so the engine and the CLI round-trip deep-equal.
498
+ ...(h.logic !== undefined ? { logic: h.logic } : {}),
347
499
  score: h.novelty + h.feasibility + h.impact,
348
500
  })),
349
501
  }
350
502
  }
351
503
 
504
+ // The MCP schema and the CLI usage text both promise a default of 20; the
505
+ // implementation returned the whole store, so a large one flooded the tool
506
+ // result. `limit: 0` (and only 0) opts back into "everything".
352
507
  list(opts = {}) {
353
- return this.store.getHypotheses(opts).map(h => ({
508
+ const limit = opts.limit === undefined ? 20 : opts.limit
509
+ return this.store.getHypotheses({ ...opts, limit }).map(h => ({
354
510
  id: h.id,
355
511
  title: h.title,
356
512
  idea: h.idea,
@@ -358,18 +514,24 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
358
514
  novelty: h.novelty,
359
515
  feasibility: h.feasibility,
360
516
  impact: h.impact,
517
+ ...(h.logic !== undefined ? { logic: h.logic } : {}),
361
518
  score: h.novelty + h.feasibility + h.impact,
362
519
  sourceLabels: h.sourceLabels,
363
520
  createdAt: h.createdAt,
364
521
  fermentedAt: h.fermentedAt,
365
522
  fermentCount: h.fermentCount,
523
+ ...(h.mergedInto !== undefined ? { mergedInto: h.mergedInto } : {}),
366
524
  }))
367
525
  }
368
526
 
369
527
  _buildGenerationPrompt(a, b, strategy, recentHypotheses, rejectedHypotheses) {
528
+ // Long sources used to be pasted unbounded, crowding the instructions out
529
+ // of the context window; 600 chars keeps the pairing signal without the
530
+ // flood.
531
+ const clip = (s, n = 600) => (s.length > n ? s.slice(0, n) + '…' : s)
370
532
  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`
533
+ prompt += `Concept A: "${a.name}" — ${clip(a.content)}\n`
534
+ prompt += `Concept B: "${b.name}" — ${clip(b.content)}\n\n`
373
535
  prompt += `Strategy: ${strategy}\n`
374
536
 
375
537
  if (strategy === 'explore') {
@@ -380,6 +542,15 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
380
542
  prompt += `Stable combination. Conservative refinement of existing ideas.\n`
381
543
  }
382
544
 
545
+ // The logical-quality bar, stated where the model composes rather than
546
+ // only in the system prompt: synergy mechanism, explicit causal chain,
547
+ // and no claims that cannot name their mechanism.
548
+ prompt += `\nQuality requirements:\n`
549
+ prompt += `- The idea must explain the synergy mechanism: WHY these two concepts combine.\n`
550
+ prompt += `- Write the proposal as a causal chain (premise -> mechanism -> outcome), each step following from the last.\n`
551
+ prompt += `- Be specific: component, change, observable effect. No bare claims like "improve efficiency".\n`
552
+ prompt += `- The title must be self-contained (a reader sees only the title in lists).\n`
553
+
383
554
  if (recentHypotheses.length > 0) {
384
555
  prompt += `\nRecent ideas (avoid duplicates):\n`
385
556
  for (const h of recentHypotheses) {
@@ -394,9 +565,9 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty, feasibility, impa
394
565
  }
395
566
  }
396
567
 
397
- prompt += `\nOutput JSON: {"title": "...", "idea": "...", "expectedBenefit": "...", "risk": "...", "novelty": 0-100, "feasibility": 0-100, "impact": 0-100}`
568
+ prompt += `\nOutput JSON: {"title": "...", "idea": "...", "expectedBenefit": "...", "risk": "...", "novelty": 0-100, "feasibility": 0-100, "impact": 0-100, "logic": 0-100}`
398
569
  return prompt
399
570
  }
400
571
  }
401
572
 
402
- module.exports = { CreativityEngine, CreativityStore }
573
+ module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights }
@@ -72,13 +72,16 @@ function createEvolutionCutoverTools({ dataDir, appendJsonl, readJsonl, projectN
72
72
  const project = projectFromArgs(args)
73
73
  const shadowRuns = readJsonl(shadowPath).filter((record) => record.project === project)
74
74
  const dualWriteRuns = readJsonl(dualWritePath).filter((record) => record.project === project)
75
+ // Apply documented defaults (schema says minShadowRuns: 5, maxMismatchRate: 0)
76
+ const minShadowRuns = Number.isFinite(args.minShadowRuns) ? args.minShadowRuns : 5
77
+ const maxMismatchRate = Number.isFinite(args.maxMismatchRate) ? args.maxMismatchRate : 0
75
78
  return {
76
79
  project,
77
80
  ...assessCutoverReadiness({
78
81
  shadowRuns,
79
82
  dualWriteRuns,
80
- minShadowRuns: Number.isFinite(args.minShadowRuns) ? args.minShadowRuns : undefined,
81
- maxMismatchRate: Number.isFinite(args.maxMismatchRate) ? args.maxMismatchRate : undefined,
83
+ minShadowRuns,
84
+ maxMismatchRate,
82
85
  }),
83
86
  }
84
87
  }
@@ -52,10 +52,12 @@ function isInsightAvailable() {
52
52
  return Boolean(InsightStore)
53
53
  }
54
54
 
55
- // Score at or above which an unreported insight is counted as high-value in the
56
- // status summary.
57
- const INSIGHT_HIGH_VALUE_SCORE = 0.7
58
- const INSIGHT_HIGH_VALUE_LIMIT = 50
55
+ // "High value" means score >= 50 (0-100 scale) AND confidence >= 0.7 (0-1).
56
+ // These used to be named INSIGHT_HIGH_VALUE_SCORE = 0.7 / ..._LIMIT = 50 and
57
+ // were passed in (LIMIT, SCORE) order -- correct only because the constants
58
+ // happened to occupy each other's slots. Named for what they are now.
59
+ const INSIGHT_HIGH_VALUE_SCORE_THRESHOLD = 50
60
+ const INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD = 0.7
59
61
 
60
62
  function createInsightStore(options = {}) {
61
63
  const dataDir = options.dataDir
@@ -94,7 +96,10 @@ function createInsightStore(options = {}) {
94
96
  total: all.length,
95
97
  unreported: unreported.length,
96
98
  reported: all.length - unreported.length,
97
- highValue: s.getHighValueUnreported(INSIGHT_HIGH_VALUE_LIMIT, INSIGHT_HIGH_VALUE_SCORE).length,
99
+ highValue: s.getHighValueUnreported(
100
+ INSIGHT_HIGH_VALUE_SCORE_THRESHOLD,
101
+ INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD,
102
+ ).length,
98
103
  }
99
104
  }
100
105
 
@@ -125,7 +130,11 @@ function createInsightStore(options = {}) {
125
130
  createdAt: m.createdAt || m.timestamp || Date.now(),
126
131
  })),
127
132
  summaries: args.summaries || [],
128
- interactionCount: 0,
133
+ // 桌面端有真实交互流;CLI 默认没有。调用方可显式传入
134
+ // interactionCount/eventCount(如 MCP 侧按 trace 统计),
135
+ // 不传时 Scorer 会按记忆新鲜度给 confidence 兜底(旧逻辑
136
+ // 恒 0.2 会把 highValue 永久压成 0)。
137
+ interactionCount: Number(args.interactionCount) || 0,
129
138
  plans: (args.plans || []).map((p) => ({
130
139
  title: p.title || p.name || '',
131
140
  status: p.status || 'unknown',
@@ -133,13 +142,30 @@ function createInsightStore(options = {}) {
133
142
  steps: p.steps || [],
134
143
  createdAt: p.createdAt || Date.now(),
135
144
  })),
136
- eventCount: 0,
145
+ eventCount: Number(args.eventCount) || 0,
137
146
  }
138
147
  const insights = await generator.generate(ctx)
139
148
  if (InsightStore && storePath) {
140
- requireStore().addMany(insights)
149
+ // addMany is a pure push: every `mio insight generate` run used to
150
+ // append another copy of the same detection. Drop titles the store
151
+ // already holds (reported or not) so re-running is idempotent.
152
+ const s = requireStore()
153
+ const seen = new Set(s.getAll().map((i) => String(i.title || '').trim().toLowerCase()))
154
+ // Dedup against the store AND within the batch -- a generator that emits
155
+ // the same detection twice in one run must not survive just because the
156
+ // second copy was not there when the set was built.
157
+ const fresh = []
158
+ for (const i of insights || []) {
159
+ if (!i) continue
160
+ const key = i.title ? String(i.title).trim().toLowerCase() : null
161
+ if (key !== null && seen.has(key)) continue
162
+ if (key !== null) seen.add(key)
163
+ fresh.push(i)
164
+ }
165
+ if (fresh.length > 0) s.addMany(fresh)
166
+ return { generated: fresh.length, duplicates: (insights || []).length - fresh.length, insights: fresh }
141
167
  }
142
- return { generated: insights.length, insights }
168
+ return { generated: (insights || []).length, insights }
143
169
  }
144
170
 
145
171
  return {
@@ -154,6 +180,6 @@ function createInsightStore(options = {}) {
154
180
  module.exports = {
155
181
  createInsightStore,
156
182
  isInsightAvailable,
157
- INSIGHT_HIGH_VALUE_SCORE,
158
- INSIGHT_HIGH_VALUE_LIMIT,
183
+ INSIGHT_HIGH_VALUE_SCORE_THRESHOLD,
184
+ INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD,
159
185
  }
@@ -27,14 +27,13 @@ const path = require('path')
27
27
  const DEFAULT_API_URL = 'https://opencode.ai/zen/go/v1/chat/completions'
28
28
  const DEFAULT_MODEL = 'deepseek-v4-flash'
29
29
 
30
- // Resolve the directory holding config.json. The CLI and the MCP server use
31
- // different data dirs by design (MIO_HOME vs MIO_DATA_DIR||cwd/.mio-intelligence),
32
- // so we honour whichever is set and fall back to the CLI default. No third
33
- // convention is introduced here on purpose.
30
+ // Resolve the directory holding config.json. The CLI uses MIO_HOME
31
+ // (~/.mio-intelligence by default). The MCP server uses MIO_DATA_DIR ||
32
+ // cwd/.mio-intelligence. This module serves the CLI-facing `llm-config`/
33
+ // `config llm` commands, so we follow the CLI convention: MIO_HOME first.
34
34
  function homeDir() {
35
35
  return (
36
36
  process.env.MIO_HOME ||
37
- process.env.MIO_DATA_DIR ||
38
37
  path.join(os.homedir(), '.mio-intelligence')
39
38
  )
40
39
  }
@@ -118,6 +117,13 @@ async function chatJson(userText, opts = {}) {
118
117
  const system = opts.system || 'You are a creative AI assistant. Output JSON.'
119
118
  const temperature = opts.temperature ?? 0.3
120
119
 
120
+ // response_format must match the prompt's top-level shape. Prompts that
121
+ // demand a top-level JSON array (InsightPrompt) can be rejected outright
122
+ // (HTTP 400) by OpenAI-compatible gateways that enforce json_object, so
123
+ // array callers omit the field and rely on prompt + fence-stripping parse.
124
+ const wantsArray = opts.responseFormat === 'array'
125
+ const responseFormat = wantsArray ? undefined : { type: 'json_object' }
126
+
121
127
  try {
122
128
  const resp = await fetch(apiUrl, {
123
129
  method: 'POST',
@@ -128,11 +134,12 @@ async function chatJson(userText, opts = {}) {
128
134
  body: JSON.stringify({
129
135
  model,
130
136
  temperature,
137
+ ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
131
138
  messages: [
132
139
  { role: 'system', content: system },
133
140
  { role: 'user', content: userText },
134
141
  ],
135
- response_format: { type: 'json_object' },
142
+ ...(responseFormat ? { response_format: responseFormat } : {}),
136
143
  }),
137
144
  signal: AbortSignal.timeout(opts.timeoutMs || 30000),
138
145
  })
@@ -140,10 +147,24 @@ async function chatJson(userText, opts = {}) {
140
147
  if (!resp.ok) return { error: `HTTP ${resp.status}` }
141
148
  const json = await resp.json()
142
149
  const text = json.choices?.[0]?.message?.content || ''
150
+
151
+ // Parse failures used to return { data: rawText }, so a prose answer
152
+ // masqueraded as a parsed object: callers testing truthy `result.data`
153
+ // stored an untitled, score-less record (and polluted the novelty
154
+ // dedup pool). A model that answered with anything but JSON is an
155
+ // error, not a degraded success. One retry with a code fence stripped
156
+ // covers models that wrap JSON despite response_format.
143
157
  try {
144
158
  return { data: JSON.parse(text) }
145
- } catch {
146
- return { data: text }
159
+ } catch (_) {}
160
+ const stripped = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')
161
+ try {
162
+ return { data: JSON.parse(stripped) }
163
+ } catch (_) {
164
+ return {
165
+ error: 'model returned unparseable JSON',
166
+ raw: String(text).slice(0, 300),
167
+ }
147
168
  }
148
169
  } catch (err) {
149
170
  return { error: String(err) }