mio-agent-runtime 0.13.5 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,7 @@ mio creativity status Show creativity hypothesis counts and recent top id
57
57
  mio creativity list List creativity hypotheses (--status active|validated|rejected|draft, --limit N)
58
58
  mio creativity generate Generate hypotheses from 2+ --source "name|content" (or --from-insights; calls an LLM)
59
59
  mio creativity ferment Review and refine active hypotheses (calls an LLM)
60
+ mio idea generate --goal "..." Grounded idea pipeline: auto sources + novelty gate, persisted (calls an LLM)
60
61
  mio insight status Insight counts: total, reported, unreported, high-value (needs @akemi-mio/insight)
61
62
  mio insight list List insights (--unreported, --min-score N, --detector X, --limit N)
62
63
  mio insight generate Generate insights from context (--memory "kind|content"/--summary "text"; calls an LLM)
@@ -101,7 +102,7 @@ mio --version Print the version and exit (-V)
101
102
 
102
103
  ## MCP 工具
103
104
 
104
- MCP 服务端在 5 大域共暴露 50 个工具:
105
+ MCP 服务端在 5 大域共暴露 51 个工具:
105
106
 
106
107
  ### 记忆(11)
107
108
  `mio.memory.query` · `mio.memory.record` · `mio.memory.archive` · `mio.memory.merge` · `mio.memory.migrate` · `mio.memory.analyze` · `mio.memory.forget` · `mio.experience.list` · `mio.experience.confirm` · `mio.experience.reuse` · `mio.policy.check`
@@ -114,15 +115,15 @@ MCP 服务端在 5 大域共暴露 50 个工具:
114
115
  ### 洞察自省(4)
115
116
  `mio.insight.status` · `mio.insight.list` · `mio.insight.mark_reported` · `mio.insight.generate`
116
117
 
117
- ### 创意引擎(4)
118
- `mio.creativity.generate` · `mio.creativity.ferment` · `mio.creativity.list` · `mio.creativity.status`
118
+ ### 创意引擎(5)
119
+ `mio.creativity.generate` · `mio.creativity.ferment` · `mio.creativity.list` · `mio.creativity.status` · `mio.idea.generate`
119
120
 
120
121
  ### 任务、Agent 与演化(16)
121
122
  `mio.task.route` · `mio.task.record_outcome` · `mio.agent.register` · `mio.agent.list` · `mio.agent.report` · `mio.agent.evaluation` · `mio.host.capabilities` · `mio.evolution.status` · `mio.evolution.report` · `mio.evolution.shadow.record` · `mio.evolution.dual_write.record` · `mio.evolution.cutover.readiness` · `mio.evolution.cutover.apply` · `mio.evolution.migration.plan` · `mio.evolution.authority.plan` · `mio.phase0.report`
122
123
 
123
124
  ## 包
124
125
 
125
- `mio-agent-runtime` 依赖发布在 `@akemi-mio` scope 下的 **9** 个包(声明于 `package.json`,均为 `0.1.0`)。它们仅在源码变更时才发布,所以普通的 CLI 发布不会重新发布这些包。
126
+ `mio-agent-runtime` 依赖发布在 `@akemi-mio` scope 下的 **10** 个包(声明于 `package.json`,均为 `0.1.0`)。它们仅在源码变更时才发布,所以普通的 CLI 发布不会重新发布这些包。
126
127
 
127
128
  | 依赖 | 说明 |
128
129
  |---|---|
@@ -135,6 +136,7 @@ MCP 服务端在 5 大域共暴露 50 个工具:
135
136
  | `@akemi-mio/evolution-scheduler` | 演化调度器 |
136
137
  | `@akemi-mio/observer` | ObserverService —— 多源数据采集、趋势分析、深度研究、世界模型、发酵、DAG 状态机、自演化引擎(唯一运行时依赖 `undici`,只用于走代理采集) |
137
138
  | `@akemi-mio/insight` | InsightService —— 基于 LLM 的洞察生成、冲突/漂移/重复/停滞目标/摩擦检测器、在场服务、洞察打分(零 npm 依赖) |
139
+ | `@akemi-mio/creativity` | IdeaGenerator / NoveltyScorer / SourceAggregator —— `mio idea generate` 与创意引擎的生成算法(依赖 `@akemi-mio/core`) |
138
140
 
139
141
  以下 `@akemi-mio` 包同样位于本 monorepo 中并独立发布,但**不是**本 CLI 的依赖——其源码从不 import 它们,因此未声明在 `package.json` 里:
140
142
 
@@ -146,9 +148,9 @@ MCP 服务端在 5 大域共暴露 50 个工具:
146
148
 
147
149
  它们可直接被其它 Node 项目消费。`mio-agent-runtime` 自身是 CLI + MCP server 包。
148
150
 
149
- ### 与宿主耦合的包(不发布)
151
+ ### 曾经不发布的两个包(现已发布)
150
152
 
151
- `@akemi-mio/core` 与 `@akemi-mio/creativity` 通过 `workspace:*` 链接在本 monorepo 内部消费,因此从源码构建,**不**发布到 npm。`packages/cli/` 已不存在。不要把这两个包加进下游 `package.json`——安装会失败。
153
+ `@akemi-mio/core` 与 `@akemi-mio/creativity` 自 `mio-agent-runtime@0.14.0` 起发布到 npm(`0.1.0`)。`@akemi-mio/creativity` 是本 CLI 的直接依赖,`@akemi-mio/core` 经由它传递引入;其它 Node 项目可直接安装这两个包。
152
154
 
153
155
  ## 宿主说明
154
156
 
@@ -414,13 +416,20 @@ mio creativity ferment --limit 3
414
416
 
415
417
  - `--source "名称|内容"` **至少两个**:引擎是把概念两两配对来产生新假设的,
416
418
  一个来源在构造上就不可能产出组合(此时**不会**调用 LLM,直接返回
417
- `need at least 2 sources`)。
419
+ `need at least 2 sources`)。显式来源不足 2 个时,本地自动来源(memory / traces / 已存假设;MCP 侧还含 insight 与 observer 趋势)会先把列表补齐到 2——显式 `--source` 永远排在前面且不被替换;补齐后仍不足 2 个才报上面的错。
418
420
  - **`--from-insights`(MCP 侧为 `fromInsights: true`)用已存洞察补足概念来源**:
419
421
  读取 `mio insight list` 能看到的同一份存储,按分数取前 10 条映射成
420
422
  `{ name, content, type: 'insight' }`,再与显式的 `--source` 合并。它只在
421
423
  `@akemi-mio/insight` 已安装且至少存有一条洞察时有意义,否则明确报错而不是
422
424
  静默当作「没有来源」。因为来源可由它提供,`mio.creativity.generate` 的
423
425
  `sources` 不再是 schema 必填项。
426
+ - **判重粒度是(素材对 × `--strategy`)。** 同一对素材在 `explore` / `signal` /
427
+ `stable` 下各算一次实验——「409 就换 strategy 重试」是有效的降级而不是空转;
428
+ 三种 strategy 都试过、且没有新素材时才真正耗尽,此时返回的 `reason` 会带上
429
+ strategy、来源数与候选对数(外加 `strategy` 与 `pairsAvailable: 0` 字段),
430
+ 调用方可以据此区分「换个 strategy 有用」与「该补新素材了」。素材身份取名称
431
+ 去空白后的**前 120 个字符**(`--source` 省略 `|` 时 name 会退化成整段描述,
432
+ 描述尾部微调不得绕过判重);没有 `strategy` 字段的旧记录仍挡住所有 strategy。
424
433
  - `ferment` 会复核 `active` / `draft` 假设并更新分数;`verdict=promote` 且总分 > 200 时
425
434
  升为 `validated`,`verdict=reject` 则标记为 `rejected`。
426
435
  - **失败的 LLM 调用会出现在结果里,不再被吞掉。** 返回值带 `errors`
@@ -428,10 +437,24 @@ mio creativity ferment --limit 3
428
437
  打印 `N pair(s) failed:`。此前 `catch {}` 让「端点挂了」与「这一对确实没有
429
438
  新意」长得一模一样——`ideas: []` / `Nothing fermented` 两种输出都无法区分。
430
439
 
440
+ ### 目标驱动的 idea 流水线(`mio idea generate`)
441
+
442
+ 一个 goal 直接产出可实验的假设(MCP 面为 `mio.idea.generate`),来源全程本地优先组装:
443
+
444
+ ```bash
445
+ mio idea generate --goal "降低 MCP 调用延迟" --context "近三天 p95 上升" --constraint "不引入新依赖"
446
+ mio idea generate --goal "扩大命令覆盖" --json
447
+ ```
448
+
449
+ - **来源顺序**:goal → context → constraints → 记忆 grounding(取前 3 条,`scope: all`)→ 本地自动来源(memory / traces / 已存假设,MCP 侧还含 insight 与 observer 趋势),按 `origin` 去重;凑不足 2 个时返回 `reason` 文案而不是报错。
450
+ - **每条 idea 都持久化**到 `<MIO_HOME>/creativity/creativity-hypotheses.jsonl`(`draft`,带 `provenance`:strategy / technique / relatedMemoryIds / 命中来源 / generatedAt),**绝不写入 Mio memory**。
451
+ - **novelty 门禁**:与已 `rejected` 假设近似重复的候选落盘为 `rejected` 并带 `rejectionReason`;与近期假设相似的只降分。拒绝的假设同样入库,供后续去重。
452
+ - `--num` 钳制在 1..3(默认 3);`--json` 返回完整结构 `{ ideas, generatedAt, groundedWith, persistedIds }`。
453
+
431
454
  #### LLM 配置
432
455
 
433
- 只有 4 条命令会调用大模型:`mio creativity generate` / `ferment`、
434
- `mio insight generate`、`mio observer ferment`。它们共用
456
+ 只有 5 条命令会调用大模型:`mio creativity generate` / `ferment`、
457
+ `mio idea generate`、`mio insight generate`、`mio observer ferment`。它们共用
435
458
  `server/llm-client.js` 这一份实现,配置也共用同一套解析顺序:
436
459
 
437
460
  ```
package/bin/mio.js CHANGED
@@ -24,6 +24,8 @@ const { createMemoryStore } = require('../server/memory-store.js')
24
24
  const { createExperienceStore } = require('../server/experience-store.js')
25
25
  const { createPolicyStore } = require('../server/policy-store.js')
26
26
  const { CreativityEngine, sourcesFromInsights } = require('../server/creativity-engine.js')
27
+ const { buildAutoSources, topUpSources } = require('../server/creativity-sources.js')
28
+ const { runIdeaGenerate, groundingFrom } = require('../server/idea-generate.js')
27
29
  const { createAgentStore } = require('../server/agent-store.js')
28
30
  const { createEvaluationStore } = require('../server/evaluation-store.js')
29
31
  const { createInsightStore, isInsightAvailable } = require('../server/insight-store.js')
@@ -2065,7 +2067,7 @@ function observerCommand(args, useJson) {
2065
2067
  function creativityUsage(write = console.log) {
2066
2068
  write(`Usage:
2067
2069
  mio creativity status Show hypothesis counts and recent top ideas
2068
- mio creativity list List hypotheses (--status active|validated|rejected|draft, --limit N)
2070
+ mio creativity list List hypotheses (--status active|validated|rejected|draft, --sort novelty, --limit N)
2069
2071
 
2070
2072
  mio creativity generate Generate hypotheses by combining sources (needs 2+ --source, calls an LLM)
2071
2073
  mio creativity ferment Review/refine active hypotheses (calls an LLM)
@@ -2073,6 +2075,7 @@ function creativityUsage(write = console.log) {
2073
2075
  Options:
2074
2076
  --status name Filter list by status
2075
2077
  --limit N Max results for list / ferment (default 20 / 5); --limit 0 lists everything
2078
+ --sort novelty Rank list by relative noveltyScore (a dedup signal, not a quality score)
2076
2079
  --source "name|content" A concept source for generate; repeat 2+ times
2077
2080
  --from-insights Seed generate's sources from stored insights (needs @akemi-mio/insight)
2078
2081
  --strategy explore|signal|stable Generation strategy (auto-selected if omitted)
@@ -2081,6 +2084,7 @@ Options:
2081
2084
  Examples:
2082
2085
  mio creativity status
2083
2086
  mio creativity list --status rejected --limit 10
2087
+ mio creativity list --sort novelty
2084
2088
  mio creativity generate --source "auth|token rotation" --source "cache|write-through"
2085
2089
  mio creativity generate --from-insights --source "auth|token rotation"
2086
2090
 
@@ -2114,7 +2118,7 @@ function printCreativityList(items) {
2114
2118
  items.forEach((h, index) => {
2115
2119
  const when = h.createdAt ? new Date(h.createdAt).toISOString().slice(0, 19).replace('T', ' ') : ''
2116
2120
  const labels = Array.isArray(h.sourceLabels) && h.sourceLabels.length > 0 ? h.sourceLabels.join(' + ') : '—'
2117
- console.log(`${index + 1}. [${h.status}] ${h.title} (N=${h.novelty} F=${h.feasibility} I=${h.impact} score=${h.score})`)
2121
+ console.log(`${index + 1}. [${h.status}] ${h.title} (N=${h.novelty} F=${h.feasibility} I=${h.impact} score=${h.score}${h.noveltyScore !== undefined ? ` noveltyScore=${h.noveltyScore}` : ''})`)
2118
2122
  console.log(` ${labels} | ${when} | ${h.id}`)
2119
2123
  if (h.fermentCount) console.log(` fermented ${h.fermentCount}x`)
2120
2124
  })
@@ -2162,6 +2166,26 @@ function printCreativityErrors(errors, label) {
2162
2166
  }
2163
2167
  }
2164
2168
 
2169
+ // Auto sources for generate's top-up: the same local providers the MCP server
2170
+ // uses, assembled from the CLI's global MIO_HOME (spec Capability 2). Every
2171
+ // layer degrades to [] — a missing optional package or an unreadable store
2172
+ // must never fail `mio creativity generate`.
2173
+ function cliAutoSources() {
2174
+ let insights = []
2175
+ let trends = []
2176
+ try {
2177
+ if (isInsightAvailable()) insights = cliInsightStore().list({}) || []
2178
+ } catch (_) {}
2179
+ try {
2180
+ if (isObserverAvailable()) trends = cliObserverStore().trends({ limit: 3 }) || []
2181
+ } catch (_) {}
2182
+ try {
2183
+ return buildAutoSources({ dataDir: MIO_HOME, insights, trends })
2184
+ } catch (_) {
2185
+ return []
2186
+ }
2187
+ }
2188
+
2165
2189
  function creativityGenerateCommand(args, useJson) {
2166
2190
  const flags = args.slice(2)
2167
2191
  let sources = parseSources(flags)
@@ -2191,6 +2215,15 @@ function creativityGenerateCommand(args, useJson) {
2191
2215
  sources = sources.concat(seeds)
2192
2216
  }
2193
2217
 
2218
+ // Auto top-up (spec Capability 2, injection point 2): explicit --source
2219
+ // entries win; when fewer than two remain, local memory/traces/stored
2220
+ // hypotheses (plus insight/observer observations) fill the gap. If the
2221
+ // topped-up set is still short, the errors below keep their old wording.
2222
+ if (sources.length < 2) {
2223
+ const topped = topUpSources(sources, cliAutoSources())
2224
+ if (topped.length >= 2) sources = topped
2225
+ }
2226
+
2194
2227
  if (sources.length < 2) {
2195
2228
  if (flagPresent(flags, '--from-insights')) {
2196
2229
  console.error(`mio creativity generate needs at least 2 concept sources; --from-insights only supplied ${sources.length}.`)
@@ -2263,6 +2296,90 @@ function creativityFermentCommand(args, useJson) {
2263
2296
  })
2264
2297
  }
2265
2298
 
2299
+ function ideaUsage() {
2300
+ console.error('usage: mio idea generate --goal "one line" [--context "..."] [--constraint "..."] [--num N] [--json]')
2301
+ }
2302
+
2303
+ async function ideaGenerateCommand(args, useJson) {
2304
+ const flags = args.slice(2)
2305
+ const goal = (optionValue(flags, '--goal') || '').trim()
2306
+ if (!goal) {
2307
+ console.error('mio idea generate requires --goal "..."')
2308
+ ideaUsage()
2309
+ process.exitCode = 1
2310
+ return
2311
+ }
2312
+ const context = optionValue(flags, '--context') || ''
2313
+ const constraints = optionValues(flags, '--constraint')
2314
+ let numIdeas
2315
+ try {
2316
+ numIdeas = parseNumberOption(flags, '--num')
2317
+ } catch (error) {
2318
+ console.error(error.message || error)
2319
+ ideaUsage()
2320
+ process.exitCode = 1
2321
+ return
2322
+ }
2323
+
2324
+ // Grounding from the CLI's global MIO_HOME. A failed query degrades to "no
2325
+ // grounding" — never a hard error (spec Capability 1: local-first, tolerant).
2326
+ // The core logger echoes every event to stdout; in --json mode that would
2327
+ // corrupt the payload jsonOrText must own, so the echo is routed to stderr
2328
+ // while the pipeline runs (executed deviation — the plan assumed pure JSON).
2329
+ const echoLog = useJson ? console.log : null
2330
+ if (useJson) console.log = (...params) => console.error(...params)
2331
+
2332
+ let result
2333
+ try {
2334
+ let grounding = { sources: [], relatedMemoryIds: [], groundedWith: 0 }
2335
+ try {
2336
+ const qr = await cliMemoryStore().queryMemory({ query: goal, limit: 3, scope: 'all' })
2337
+ grounding = groundingFrom(qr)
2338
+ } catch (_) {}
2339
+
2340
+ if (!useJson) warnIfLlmUnconfigured()
2341
+
2342
+ result = await runIdeaGenerate({
2343
+ goal,
2344
+ context,
2345
+ constraints,
2346
+ numIdeas,
2347
+ chatJson,
2348
+ store: cliCreativityEngine().store,
2349
+ groundingSources: grounding.sources,
2350
+ relatedMemoryIds: grounding.relatedMemoryIds,
2351
+ autoSources: cliAutoSources(),
2352
+ })
2353
+ } catch (error) {
2354
+ console.error(error.message || error)
2355
+ process.exitCode = 1
2356
+ return
2357
+ } finally {
2358
+ if (useJson) console.log = echoLog
2359
+ }
2360
+
2361
+ if (useJson) return jsonOrText(result, true)
2362
+ if (result.reason) {
2363
+ console.log(`No ideas generated: ${result.reason}`)
2364
+ return
2365
+ }
2366
+ console.log(`Generated ${result.ideas.length} idea(s) (grounded with ${result.groundedWith} memories)`)
2367
+ result.ideas.forEach((idea) => {
2368
+ console.log(`- [${idea.hypothesis.status}] ${idea.title}`)
2369
+ console.log(` novelty=${idea.hypothesis.novelty} feasibility=${idea.hypothesis.feasibility} impact=${idea.hypothesis.impact} | ${idea.hypothesis.id}`)
2370
+ if (idea.provenance && idea.provenance.technique) console.log(` technique=${idea.provenance.technique}`)
2371
+ if (idea.hypothesis.rejectionReason) console.log(` rejected: ${idea.hypothesis.rejectionReason}`)
2372
+ })
2373
+ console.log(`Persisted ${result.persistedIds.length} idea(s)`)
2374
+ }
2375
+
2376
+ function ideaCommand(args, useJson) {
2377
+ const sub = args[1]
2378
+ if (sub === 'generate') return ideaGenerateCommand(args, useJson)
2379
+ ideaUsage()
2380
+ process.exitCode = 1
2381
+ }
2382
+
2266
2383
  function creativityCommand(args, useJson) {
2267
2384
  const sub = args[1]
2268
2385
 
@@ -2289,9 +2406,16 @@ function creativityCommand(args, useJson) {
2289
2406
  if (sub === 'status') {
2290
2407
  result = engine.status()
2291
2408
  } else {
2409
+ const sort = optionValue(flags, '--sort')
2410
+ if (sort !== undefined && sort !== 'novelty') {
2411
+ console.error(`mio creativity list: unknown --sort "${sort}" (supported: novelty)`)
2412
+ process.exitCode = 1
2413
+ return
2414
+ }
2292
2415
  result = engine.list({
2293
2416
  status: optionValue(flags, '--status'),
2294
2417
  limit: parseNumberOption(flags, '--limit'),
2418
+ sort,
2295
2419
  })
2296
2420
  }
2297
2421
  } catch (error) {
@@ -3369,9 +3493,10 @@ Usage:
3369
3493
  mio experience confirm --ids a,b Confirm auto-claimed reuse (bulk supported)
3370
3494
  mio experience reuse --source-agent A --target-agent B --experience-id X Record a reuse
3371
3495
  mio creativity status Show creativity hypothesis counts and recent top ideas
3372
- mio creativity list List creativity hypotheses (--status active|validated|rejected|draft, --limit N)
3496
+ mio creativity list List creativity hypotheses (--status active|validated|rejected|draft, --sort novelty, --limit N)
3373
3497
  mio creativity generate Generate hypotheses from 2+ --source "name|content" (or --from-insights; calls an LLM)
3374
3498
  mio creativity ferment Review and refine active hypotheses (calls an LLM)
3499
+ mio idea generate --goal "..." Grounded idea pipeline: auto sources, novelty gate, persisted (calls an LLM)
3375
3500
  mio insight status Insight counts: total, reported, unreported, high-value
3376
3501
  mio insight list List insights (--unreported, --min-score N, --detector X, --limit N)
3377
3502
  mio insight generate Generate insights from context (--memory "kind|content"/--summary "text"; calls an LLM)
@@ -3435,6 +3560,8 @@ async function main() {
3435
3560
  return policyCommand(args, useJson)
3436
3561
  case 'creativity':
3437
3562
  return creativityCommand(args, useJson)
3563
+ case 'idea':
3564
+ return ideaCommand(args, useJson)
3438
3565
  case 'insight':
3439
3566
  return insightCommand(args, useJson)
3440
3567
  case 'observer':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.13.5",
3
+ "version": "0.14.1",
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",
@@ -34,7 +35,8 @@
34
35
  "@akemi-mio/evolution-safety": "^0.1.0",
35
36
  "@akemi-mio/evolution-scheduler": "^0.1.0",
36
37
  "@akemi-mio/observer": "^0.1.4",
37
- "@akemi-mio/insight": "^0.1.1"
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,7 @@
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')
6
7
 
7
8
  // Cache validity key for a JSONL file: a (size, mtimeMs) pair, the same
8
9
  // guard server/insight-store.js uses (commit 0ba7fa8). Every getter below used
@@ -27,6 +28,13 @@ function clamp100(value, fallback = 50) {
27
28
  return Math.min(100, Math.max(0, n))
28
29
  }
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
+ }
37
+
30
38
  // ═══════════════════════════════════════════════
31
39
  // JSONL Store — ideas, combos, experiments
32
40
  // ═══════════════════════════════════════════════
@@ -154,14 +162,34 @@ function mulberry32(seed) {
154
162
  }
155
163
  }
156
164
 
157
- function mixConcepts(sources, exploredPairs, rng) {
165
+ // One concept-pair identity. `--source "name|content"` allows omitting the
166
+ // pipe, so `name` can degrade into the entire description (often >1k chars):
167
+ // with the raw name as identity, a tail edit mints a brand-new pair and
168
+ // silently bypasses dedup. Trim + first 120 chars is short enough to read in
169
+ // a key and long enough that genuine sources do not collide.
170
+ function pairSourceKey(name) {
171
+ return String(name == null ? '' : name).trim().slice(0, 120)
172
+ }
173
+
174
+ function mixConcepts(sources, exploredPairs, rng, strategy) {
158
175
  if (sources.length < 2) return []
176
+ // Dedup identity is (pair, strategy): `strategy` picks the prompt and the
177
+ // temperature, so the documented fallback "hit 409, retry with another
178
+ // strategy" is a genuinely different experiment. Without the suffix every
179
+ // retry recomputed the same key and returned the same deterministic
180
+ // `all pairs already explored` — three LLM-less no-ops in a row.
181
+ const strat = String(strategy || '')
159
182
  const pairs = []
160
183
  for (let i = 0; i < sources.length; i++) {
161
184
  for (let j = i + 1; j < sources.length; j++) {
162
- const key = `${sources[i].name}||${sources[j].name}`
163
- const reverseKey = `${sources[j].name}||${sources[i].name}`
185
+ const a = pairSourceKey(sources[i].name)
186
+ const b = pairSourceKey(sources[j].name)
187
+ const key = strat ? `${a}||${b}||${strat}` : `${a}||${b}`
188
+ const reverseKey = strat ? `${b}||${a}||${strat}` : `${b}||${a}`
164
189
  if (exploredPairs.has(key) || exploredPairs.has(reverseKey)) continue
190
+ // Records written without a strategy field carry the unsuffixed key;
191
+ // they must keep blocking every strategy (the old semantics).
192
+ if (strat && (exploredPairs.has(`${a}||${b}`) || exploredPairs.has(`${b}||${a}`))) continue
165
193
  pairs.push([sources[i], sources[j]])
166
194
  }
167
195
  }
@@ -173,43 +201,6 @@ function mixConcepts(sources, exploredPairs, rng) {
173
201
  return pairs.slice(0, Math.min(5, pairs.length))
174
202
  }
175
203
 
176
- // ═══════════════════════════════════════════════
177
- // Novelty Scorer — reject duplicates
178
- // ═══════════════════════════════════════════════
179
-
180
- function bigramSet(text) {
181
- const s = new Set()
182
- const lower = text.toLowerCase()
183
- for (let i = 0; i < lower.length - 1; i++) {
184
- s.add(lower.slice(i, i + 2))
185
- }
186
- return s
187
- }
188
-
189
- function jaccard(a, b) {
190
- if (a.size === 0 && b.size === 0) return 1
191
- let inter = 0
192
- for (const x of a) if (b.has(x)) inter++
193
- return inter / (a.size + b.size - inter)
194
- }
195
-
196
- function evaluateNovelty(idea, recent, rejected) {
197
- const ideaBigrams = bigramSet(idea.title + ' ' + idea.idea)
198
- for (const r of recent) {
199
- const sim = jaccard(ideaBigrams, bigramSet(r.title + ' ' + r.idea))
200
- if (sim > 0.7) {
201
- return { shouldReject: true, rejectReason: `too similar to recent: "${r.title}" (sim=${sim.toFixed(2)})`, adjustedNovelty: idea.novelty }
202
- }
203
- }
204
- for (const r of rejected) {
205
- const sim = jaccard(ideaBigrams, bigramSet(r.title + ' ' + r.idea))
206
- if (sim > 0.6) {
207
- return { shouldReject: true, rejectReason: `matches rejected: "${r.title}" (sim=${sim.toFixed(2)})`, adjustedNovelty: idea.novelty }
208
- }
209
- }
210
- return { shouldReject: false, adjustedNovelty: Math.min(100, idea.novelty + 5) }
211
- }
212
-
213
204
  // ═══════════════════════════════════════════════
214
205
  // Creativity Engine — main orchestrator
215
206
  // ═══════════════════════════════════════════════
@@ -234,12 +225,38 @@ function sourcesFromInsights(insights, limit = 10) {
234
225
  Array.isArray(i.evidence) && i.evidence.length > 0 ? `Evidence: ${i.evidence.join('; ')}` : '',
235
226
  ].filter(Boolean).join('\n'),
236
227
  type: 'insight',
237
- weight: 0.7,
228
+ // > 0.7: IdeaGenerator.applyTemperature keeps any weight>0.7 source unconditionally.
229
+ weight: 0.78,
230
+ origin: `insight:${i.id || i.title || 'unknown'}`,
231
+ ...(i.id ? { originId: i.id } : {}),
232
+ ...(isoOf(i.createdAt) ? { timestamp: isoOf(i.createdAt) } : {}),
238
233
  }))
239
234
  }
240
235
 
241
236
  const STRATEGY_CYCLE = ['explore', 'signal', 'stable']
242
237
 
238
+ // One row shape for every list variant — the fields JSON/CLI consumers already
239
+ // depend on. `noveltyScore` is added only by sort=novelty (relative ranking),
240
+ // never confused with the quality sum `score`.
241
+ function listRow(h) {
242
+ return {
243
+ id: h.id,
244
+ title: h.title,
245
+ idea: h.idea,
246
+ status: h.status,
247
+ novelty: h.novelty,
248
+ feasibility: h.feasibility,
249
+ impact: h.impact,
250
+ ...(h.logic !== undefined ? { logic: h.logic } : {}),
251
+ score: h.novelty + h.feasibility + h.impact,
252
+ sourceLabels: h.sourceLabels,
253
+ createdAt: h.createdAt,
254
+ fermentedAt: h.fermentedAt,
255
+ fermentCount: h.fermentCount,
256
+ ...(h.mergedInto !== undefined ? { mergedInto: h.mergedInto } : {}),
257
+ }
258
+ }
259
+
243
260
  class CreativityEngine {
244
261
  constructor(dataDir, chatJson) {
245
262
  this.store = new CreativityStore(dataDir)
@@ -269,14 +286,35 @@ class CreativityEngine {
269
286
  const strat = strategy || STRATEGY_CYCLE[this.strategyIndex % STRATEGY_CYCLE.length]
270
287
  this.strategyIndex++
271
288
 
272
- // Mix concepts
289
+ // Mix concepts. Keys carry the strategy each record was written with, so
290
+ // explore -> signal -> stable retries each get their own attempt while a
291
+ // record without a strategy stays unsuffixed and blocks every strategy.
273
292
  const recent = this.store.getHypotheses({ limit: 30 })
274
293
  const rejected = this.store.getHypotheses({ status: 'rejected', limit: 50 })
275
- const exploredPairs = new Set(recent.flatMap(h => (h.sourceLabels || []).map((l, i, a) => i > 0 ? `${a[i-1]}||${l}` : '')).filter(Boolean))
276
- const pairs = mixConcepts(sources, exploredPairs, this.rng.bind(this))
294
+ const exploredPairs = new Set(
295
+ recent.flatMap((h) =>
296
+ (h.sourceLabels || []).flatMap((l, i, a) => {
297
+ if (i === 0) return []
298
+ const x = pairSourceKey(a[i - 1])
299
+ const y = pairSourceKey(l)
300
+ return h.strategy ? [`${x}||${y}||${h.strategy}`] : [`${x}||${y}`]
301
+ })
302
+ )
303
+ )
304
+ const pairs = mixConcepts(sources, exploredPairs, this.rng.bind(this), strat)
277
305
 
278
306
  if (pairs.length === 0) {
279
- return { ideas: [], reason: 'all pairs already explored' }
307
+ // The bare string made a deterministic failure look like "this strategy
308
+ // did not work, maybe the next one will" — callers that degrade by
309
+ // switching strategy burned two LLM-less retries before learning the
310
+ // material pool itself was exhausted.
311
+ const candidatePairs = (sources.length * (sources.length - 1)) / 2
312
+ return {
313
+ ideas: [],
314
+ reason: `all pairs already explored for strategy "${strat}" (${sources.length} sources, ${candidatePairs} candidate pairs)`,
315
+ strategy: strat,
316
+ pairsAvailable: 0,
317
+ }
280
318
  }
281
319
 
282
320
  // Generate hypotheses via LLM
@@ -506,22 +544,19 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
506
544
  // result. `limit: 0` (and only 0) opts back into "everything".
507
545
  list(opts = {}) {
508
546
  const limit = opts.limit === undefined ? 20 : opts.limit
509
- return this.store.getHypotheses({ ...opts, limit }).map(h => ({
510
- id: h.id,
511
- title: h.title,
512
- idea: h.idea,
513
- status: h.status,
514
- novelty: h.novelty,
515
- feasibility: h.feasibility,
516
- impact: h.impact,
517
- ...(h.logic !== undefined ? { logic: h.logic } : {}),
518
- score: h.novelty + h.feasibility + h.impact,
519
- sourceLabels: h.sourceLabels,
520
- createdAt: h.createdAt,
521
- fermentedAt: h.fermentedAt,
522
- fermentCount: h.fermentCount,
523
- ...(h.mergedInto !== undefined ? { mergedInto: h.mergedInto } : {}),
524
- }))
547
+ if (opts.sort === 'novelty') {
548
+ // Relative novelty ranking (read-only, nothing written back). Pairwise
549
+ // Jaccard is O(n²) — bound candidates to the newest 200 (spec Risk 3).
550
+ const candidates = this.store.getHypotheses({ status: opts.status }).slice(-200)
551
+ const rows = candidates.map((h) => {
552
+ const others = candidates.filter((x) => x.id !== h.id)
553
+ const verdict = evaluateNovelty(h, others, [])
554
+ return { ...listRow(h), noveltyScore: verdict.adjustedNovelty }
555
+ })
556
+ rows.sort((a, b) => b.noveltyScore - a.noveltyScore || (b.createdAt || 0) - (a.createdAt || 0))
557
+ return limit === 0 ? rows : rows.slice(0, limit)
558
+ }
559
+ return this.store.getHypotheses({ status: opts.status, limit }).map(listRow)
525
560
  }
526
561
 
527
562
  _buildGenerationPrompt(a, b, strategy, recentHypotheses, rejectedHypotheses) {
@@ -570,4 +605,4 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
570
605
  }
571
606
  }
572
607
 
573
- module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights }
608
+ module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights, isoOf }
@@ -0,0 +1,186 @@
1
+ 'use strict'
2
+
3
+ // Source auto-aggregation for the creativity engine (spec Capability 2).
4
+ //
5
+ // Default providers read ONLY local MIO_HOME data — zero network. Insight and
6
+ // observer sources arrive as plain arrays from the caller, because only the
7
+ // entry point knows which store is authoritative (MIO_HOME for the CLI, the
8
+ // MCP server's MIO_DATA_DIR) and whether its optional package resolves.
9
+ // SourceAggregator already tolerates a throwing provider (per-provider
10
+ // try/catch with a WARN log), so providers here do not need extra guards.
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+ const { SourceAggregator } = require('@akemi-mio/creativity/SourceAggregator')
15
+ const { sourcesFromInsights, isoOf } = require('./creativity-engine.js')
16
+
17
+ function readJsonl(file) {
18
+ try {
19
+ return fs
20
+ .readFileSync(file, 'utf8')
21
+ .split('\n')
22
+ .filter(Boolean)
23
+ .map((line) => {
24
+ try { return JSON.parse(line) } catch { return null }
25
+ })
26
+ .filter(Boolean)
27
+ } catch (_) {
28
+ return []
29
+ }
30
+ }
31
+
32
+ // Every weight is > 0.7 on purpose: IdeaGenerator.applyTemperature keeps any
33
+ // source with weight > 0.7 unconditionally (temperature 0.5 middle branch), so
34
+ // the assembled set reaches the prompt intact — deterministic, no RNG survival.
35
+ const WEIGHTS = {
36
+ memory: 0.75,
37
+ traceOk: 0.74,
38
+ traceFail: 0.73,
39
+ hypothesisActive: 0.72,
40
+ hypothesisRejected: 0.72,
41
+ trend: 0.76,
42
+ }
43
+
44
+ function memoryProvider(dataDir) {
45
+ return {
46
+ domain: 'feedback',
47
+ name: 'memory',
48
+ collect() {
49
+ return readJsonl(path.join(dataDir, 'memory.jsonl'))
50
+ .filter((r) => r && r.archived !== true && (r.kind === 'decision' || r.kind === 'note'))
51
+ .slice(-6)
52
+ .map((r) => ({
53
+ name: `${r.kind}: ${String(r.content || '').slice(0, 40)}`,
54
+ content: String(r.content || ''),
55
+ type: 'feedback',
56
+ weight: WEIGHTS.memory,
57
+ origin: `memory:${r.id}`,
58
+ originId: r.id,
59
+ timestamp: isoOf(r.timestamp),
60
+ }))
61
+ },
62
+ }
63
+ }
64
+
65
+ function traceProviders(dataDir) {
66
+ const events = () => readJsonl(path.join(dataDir, 'traces.jsonl')).slice(-40)
67
+ const fmt = (r) => `${r.event_type}${r.outcome ? `/${r.outcome}` : ''}: ${JSON.stringify(r.payload || {}).slice(0, 300)}`
68
+ const stamp = (r) => isoOf(r.timestamp ?? r.ts)
69
+ return [
70
+ {
71
+ domain: 'behavior',
72
+ name: 'traces-success',
73
+ collect() {
74
+ return events()
75
+ .filter((r) => r.event_type === 'task_outcome' && r.outcome === 'success')
76
+ .slice(-3)
77
+ .map((r) => ({
78
+ name: `success: ${String((r.payload && (r.payload.task || r.payload.summary)) || r.trace_id || 'task').slice(0, 40)}`,
79
+ content: fmt(r),
80
+ type: 'behavior',
81
+ weight: WEIGHTS.traceOk,
82
+ origin: `trace:${r.trace_id}`,
83
+ originId: r.trace_id,
84
+ timestamp: stamp(r),
85
+ }))
86
+ },
87
+ },
88
+ {
89
+ domain: 'failure',
90
+ name: 'traces-failure',
91
+ collect() {
92
+ return events()
93
+ .filter(
94
+ (r) =>
95
+ (r.event_type === 'error' || r.event_type === 'retry') ||
96
+ (r.event_type === 'task_outcome' && (r.outcome === 'failure' || r.outcome === 'error' || r.outcome === 'aborted')),
97
+ )
98
+ .slice(-3)
99
+ .map((r) => ({
100
+ name: `failure: ${String(r.event_type)}${r.trace_id ? ` ${String(r.trace_id).slice(0, 12)}` : ''}`,
101
+ content: fmt(r),
102
+ type: 'failure',
103
+ weight: WEIGHTS.traceFail,
104
+ origin: `trace:${r.trace_id || 'unknown'}`,
105
+ originId: r.trace_id,
106
+ timestamp: stamp(r),
107
+ }))
108
+ },
109
+ },
110
+ ]
111
+ }
112
+
113
+ function hypothesesProvider(dataDir) {
114
+ return {
115
+ domain: 'module',
116
+ name: 'hypotheses',
117
+ collect() {
118
+ return readJsonl(path.join(dataDir, 'creativity', 'creativity-hypotheses.jsonl'))
119
+ .filter((h) => h && (h.status === 'active' || h.status === 'rejected'))
120
+ .slice(-6)
121
+ .map((h) => ({
122
+ name: String(h.title || h.id).slice(0, 60),
123
+ content: `idea: ${h.idea}\nrisk: ${h.risk || '-'}`,
124
+ type: h.status === 'rejected' ? 'failure' : 'knowledge',
125
+ weight: h.status === 'rejected' ? WEIGHTS.hypothesisRejected : WEIGHTS.hypothesisActive,
126
+ origin: `hypothesis:${h.id}`,
127
+ originId: h.id,
128
+ timestamp: isoOf(h.createdAt),
129
+ }))
130
+ },
131
+ }
132
+ }
133
+
134
+ // Observer trend reports → provocation sources. The report schema has drifted
135
+ // across observer versions, so fields are read defensively with fallbacks.
136
+ function trendSources(trends) {
137
+ return (Array.isArray(trends) ? trends : []).slice(0, 4).map((t, i) => {
138
+ const title = (t && (t.title || t.name || t.date)) || `trend-${i}`
139
+ const body =
140
+ (t && t.summary) ||
141
+ (Array.isArray(t && t.themes) ? t.themes.join('; ') : '') ||
142
+ JSON.stringify(t || {}).slice(0, 400)
143
+ const key = (t && (t.date || t.id)) || String(i)
144
+ const stamp = isoOf(t && (t.generatedAt || t.date))
145
+ return {
146
+ name: String(title).slice(0, 60),
147
+ content: String(body),
148
+ type: 'provocation',
149
+ weight: WEIGHTS.trend,
150
+ origin: `observer-trends:${key}`,
151
+ ...(t && t.id ? { originId: t.id } : {}),
152
+ ...(stamp ? { timestamp: stamp } : {}),
153
+ }
154
+ })
155
+ }
156
+
157
+ // Assemble every local (+ optionally insight/observer) domain through the
158
+ // package SourceAggregator so dedup, per-domain caps and failure tolerance
159
+ // behave identically for idea.generate and the generate top-up.
160
+ function buildAutoSources({ dataDir, insights = [], trends = [] }) {
161
+ const aggregator = new SourceAggregator(4, 1)
162
+ aggregator.addProvider(memoryProvider(dataDir))
163
+ for (const provider of traceProviders(dataDir)) aggregator.addProvider(provider)
164
+ aggregator.addProvider(hypothesesProvider(dataDir))
165
+ if (Array.isArray(insights) && insights.length > 0) {
166
+ aggregator.addProvider({ domain: 'observation', name: 'insight', collect: () => sourcesFromInsights(insights) })
167
+ }
168
+ const trendList = Array.isArray(trends) ? trends : []
169
+ if (trendList.length > 0) {
170
+ aggregator.addProvider({ domain: 'external', name: 'observer-trends', collect: () => trendSources(trendList) })
171
+ }
172
+ return aggregator.build()
173
+ }
174
+
175
+ // Explicit --source entries always win: auto sources only top a set up to the
176
+ // two entries the concept mixer needs; they never replace or reorder explicit
177
+ // ones (spec Capability 2, injection point 2).
178
+ function topUpSources(explicit, auto) {
179
+ const list = Array.isArray(explicit) ? explicit.filter(Boolean) : []
180
+ if (list.length >= 2) return list
181
+ const seen = new Set(list.map((s) => s.name))
182
+ const extra = (Array.isArray(auto) ? auto : []).filter((s) => s && !seen.has(s.name))
183
+ return [...list, ...extra]
184
+ }
185
+
186
+ module.exports = { buildAutoSources, topUpSources, trendSources, memoryProvider, traceProviders, hypothesesProvider }
@@ -0,0 +1,136 @@
1
+ 'use strict'
2
+
3
+ // `mio.idea.generate` — one-shot goal-driven idea pipeline (spec Capability 1).
4
+ // Shared by the CLI (`mio idea generate`) and the MCP server (tool
5
+ // `mio.idea.generate`): each entry point only assembles entry-specific inputs
6
+ // (which store is authoritative, where local data lives) and renders the
7
+ // response this module returns.
8
+ //
9
+ // Contract:
10
+ // - source order: goal → context → constraints → grounding → local auto,
11
+ // deduplicated by `origin` (fallback name), first occurrence wins — so a
12
+ // memory that arrives both as a grounding hit and through the local
13
+ // provider contributes exactly once, the grounding copy first;
14
+ // - pairing strategy: cross-type `explore` only when two types exist, else
15
+ // the `stable` fallback (an all-knowledge set explores into ZERO combos);
16
+ // - every candidate passes evaluateNovelty against stored hypotheses: a
17
+ // near-duplicate of a REJECTED one is persisted as rejected with a reason,
18
+ // a merely-similar recent one is down-scored; drafts AND rejects both land
19
+ // in the store so later runs can dedup against them;
20
+ // - ideas are never written to Mio memory — CreativityStore only.
21
+
22
+ const { IdeaGenerator } = require('@akemi-mio/creativity/IdeaGenerator')
23
+ const { evaluateNovelty } = require('@akemi-mio/creativity/NoveltyScorer')
24
+
25
+ // All > 0.7: IdeaGenerator.applyTemperature keeps such sources unconditionally
26
+ // (temperature 0.5 middle branch) — deterministic prompt, no RNG survival.
27
+ const WEIGHTS = { goal: 0.95, context: 0.9, constraint: 0.88, grounding: 0.75 }
28
+
29
+ // `queryMemory` result → grounding sources + ids. Tolerates null/empty
30
+ // (a thrown query is the caller's problem to catch; a miss is not).
31
+ function groundingFrom(queryResult) {
32
+ const results = Array.isArray(queryResult && queryResult.results) ? queryResult.results : []
33
+ const sources = results.map((r) => ({
34
+ name: `${r.kind || 'memory'}: ${String(r.content || '').slice(0, 40)}`,
35
+ content: String(r.content || ''),
36
+ type: 'feedback',
37
+ weight: WEIGHTS.grounding,
38
+ origin: `memory:${r.id}`,
39
+ originId: r.id,
40
+ ...(r.timestamp ? { timestamp: r.timestamp } : {}),
41
+ }))
42
+ return { sources, relatedMemoryIds: results.map((r) => r.id), groundedWith: results.length }
43
+ }
44
+
45
+ function namedSource(role, text, origin, weight) {
46
+ const body = String(text).trim()
47
+ return {
48
+ name: `${role}: ${body.slice(0, 40)}`,
49
+ content: body,
50
+ type: 'knowledge',
51
+ weight,
52
+ origin,
53
+ }
54
+ }
55
+
56
+ async function runIdeaGenerate(options) {
57
+ const {
58
+ goal,
59
+ context = '',
60
+ constraints = [],
61
+ numIdeas,
62
+ chatJson,
63
+ store,
64
+ groundingSources = [],
65
+ relatedMemoryIds = [],
66
+ autoSources = [],
67
+ } = options || {}
68
+
69
+ const ordered = []
70
+ if (typeof goal === 'string' && goal.trim()) ordered.push(namedSource('goal', goal, 'goal', WEIGHTS.goal))
71
+ if (typeof context === 'string' && context.trim()) ordered.push(namedSource('context', context, 'context', WEIGHTS.context))
72
+ if (Array.isArray(constraints)) {
73
+ constraints.forEach((c, i) => {
74
+ if (typeof c === 'string' && c.trim()) ordered.push(namedSource('constraint', c, `constraint:${i}`, WEIGHTS.constraint))
75
+ })
76
+ }
77
+ for (const s of [...groundingSources, ...autoSources]) {
78
+ if (s && typeof s === 'object' && s.name) ordered.push(s)
79
+ }
80
+
81
+ const seen = new Set()
82
+ const sources = []
83
+ for (const s of ordered) {
84
+ const key = s.origin || s.name
85
+ if (seen.has(key)) continue
86
+ seen.add(key)
87
+ sources.push(s)
88
+ }
89
+
90
+ if (sources.length < 2) {
91
+ return { reason: 'need at least 2 sources — add --context/--constraints, or run `mio remember` first' }
92
+ }
93
+
94
+ const pairingStrategy = new Set(sources.map((s) => s.type)).size >= 2 ? 'explore' : 'stable'
95
+ const requested = Number(numIdeas)
96
+ const maxIdeas = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 1), 3) : 3
97
+
98
+ const rotationSeed = store.getHypotheses().length
99
+ const generator = new IdeaGenerator(chatJson, 0.5, undefined, rotationSeed)
100
+ // method left undefined: HypothesisGenerator rotates techniques internally
101
+ // from rotationSeed; the chosen one comes back on hypothesis.technique.
102
+ const creativeIdeas = await generator.generateIdeas(sources, maxIdeas, pairingStrategy, [])
103
+
104
+ const generatedAt = new Date().toISOString()
105
+ const recent = store.getHypotheses().slice(-30)
106
+ const rejected = store.getHypotheses({ status: 'rejected' }).slice(-50)
107
+
108
+ const ideas = []
109
+ const persistedIds = []
110
+ for (const { hypothesis: h, experiment } of creativeIdeas) {
111
+ const verdict = evaluateNovelty(h, recent, rejected)
112
+ if (verdict.shouldReject) {
113
+ h.status = 'rejected'
114
+ h.rejectionReason = verdict.rejectReason
115
+ } else {
116
+ h.novelty = verdict.adjustedNovelty
117
+ }
118
+ const labels = new Set(Array.isArray(h.sourceLabels) ? h.sourceLabels : [])
119
+ h.provenance = {
120
+ strategy: pairingStrategy,
121
+ technique: h.technique,
122
+ relatedMemoryIds: [...relatedMemoryIds],
123
+ sources: sources
124
+ .filter((s) => labels.has(s.name))
125
+ .map((s) => ({ name: s.name, type: s.type, origin: s.origin, timestamp: s.timestamp })),
126
+ generatedAt,
127
+ }
128
+ store.addHypothesis(h)
129
+ persistedIds.push(h.id)
130
+ ideas.push({ title: h.title, hypothesis: h, experiment, novelty: h.novelty, provenance: h.provenance })
131
+ }
132
+
133
+ return { ideas, generatedAt, groundedWith: groundingSources.length, persistedIds }
134
+ }
135
+
136
+ module.exports = { runIdeaGenerate, groundingFrom }
@@ -21,6 +21,8 @@ const { createDigest } = require('../digest.js')
21
21
  const { createEvolutionReport } = require('../evolution-report.js')
22
22
 
23
23
  const { CreativityEngine, sourcesFromInsights } = require('../creativity-engine.js')
24
+ const { buildAutoSources, topUpSources } = require('../creativity-sources.js')
25
+ const { runIdeaGenerate, groundingFrom } = require('../idea-generate.js')
24
26
  // The observer and insight engines are optional dependencies. Their stores own
25
27
  // the require and expose isObserverAvailable() / isInsightAvailable() so tool
26
28
  // registration can be gated on them, and both the MCP server and the CLI share
@@ -178,6 +180,16 @@ const memoryStore = createMemoryStore({
178
180
  verifiedFilter: REUSE_STATUS_FILTERS.verified,
179
181
  })
180
182
 
183
+ // Second memory-store instance WITHOUT the shared queryLog: idea.generate's
184
+ // grounding recall is tool-side context gathering, not a user query — it must
185
+ // not append to queries.jsonl (that log exists for recall-style queries).
186
+ const groundingMemoryStore = createMemoryStore({
187
+ dataDir,
188
+ projectName,
189
+ agentId: runtimeAgentId,
190
+ verifiedFilter: REUSE_STATUS_FILTERS.verified,
191
+ })
192
+
181
193
  const digestEngine = createDigest({ home: dataDir })
182
194
 
183
195
  const {
@@ -695,6 +707,7 @@ const TOOLS = [
695
707
  properties: {
696
708
  status: { type: 'string', description: 'Filter by status: active, validated, rejected, draft.' },
697
709
  limit: { type: 'number', description: 'Max results. Default 20.' },
710
+ sort: { type: 'string', description: "Sort order: 'novelty' ranks stored hypotheses by relative noveltyScore (a dedup signal, NOT a quality score), newest 200 candidates capped. Default: newest." },
698
711
  },
699
712
  },
700
713
  },
@@ -722,7 +735,7 @@ const TOOLS = [
722
735
  type: 'boolean',
723
736
  description: 'Seed concept sources from stored insights (highest score first), merged with any explicit sources. Needs @akemi-mio/insight and at least one stored insight.',
724
737
  },
725
- strategy: { type: 'string', description: 'Generation strategy: explore (cross-domain), signal (provocative), stable (conservative). Auto-selected if omitted.' },
738
+ strategy: { type: 'string', description: 'Generation strategy: explore (cross-domain), signal (provocative), stable (conservative). Auto-selected if omitted. Pair dedup is per strategy: the same source pair may be tried once under each strategy.' },
726
739
  },
727
740
  },
728
741
  },
@@ -736,6 +749,21 @@ const TOOLS = [
736
749
  },
737
750
  },
738
751
  },
752
+ {
753
+ name: 'mio.idea.generate',
754
+ description:
755
+ 'Generate up to 3 concrete, experiment-backed hypotheses from one goal. Sources are assembled locally (goal, context/constraints, Mio memory grounding, local memory/traces/stored hypotheses) and every idea is persisted to the CreativityStore with provenance — Mio memory is never written. Returns { ideas, generatedAt, groundedWith, persistedIds }, or { reason } when fewer than two sources are available.',
756
+ inputSchema: {
757
+ type: 'object',
758
+ properties: {
759
+ goal: { type: 'string', description: 'The one-line goal the ideas should pursue (required).' },
760
+ context: { type: 'string', description: 'Optional free-form background.' },
761
+ constraints: { type: 'array', items: { type: 'string' }, description: 'Optional hard constraints.' },
762
+ numIdeas: { type: 'number', description: 'How many ideas to return; clamped to 1..3, default 3.' },
763
+ },
764
+ required: ['goal'],
765
+ },
766
+ },
739
767
 
740
768
  // ═══ Observer Research Pipeline ═══
741
769
 
@@ -968,6 +996,25 @@ function observerDag(args = {}) {
968
996
  return observerStore.dag(args)
969
997
  }
970
998
 
999
+ // Local auto sources shared by generate's top-up and idea.generate — the same
1000
+ // providers the CLI assembles (spec Capability 2). Every layer degrades to []
1001
+ // so a missing optional package or an unreadable store never fails a tool call.
1002
+ function autoSources() {
1003
+ let insights = []
1004
+ let trends = []
1005
+ try {
1006
+ if (isInsightAvailable()) insights = insightStore.list({}) || []
1007
+ } catch (_) {}
1008
+ try {
1009
+ trends = observerStore.trends({ limit: 3 }) || []
1010
+ } catch (_) {}
1011
+ try {
1012
+ return buildAutoSources({ dataDir, insights, trends })
1013
+ } catch (_) {
1014
+ return []
1015
+ }
1016
+ }
1017
+
971
1018
  // `fromInsights` seeds generation from what the insight engine already noticed
972
1019
  // instead of making the caller hand-write concept sources. Only this entry
973
1020
  // point knows which store is authoritative, so the read happens here; the
@@ -975,15 +1022,24 @@ function observerDag(args = {}) {
975
1022
  // so the CLI's `--from-insights` lands on the same shapes.
976
1023
  function generationSources(args) {
977
1024
  const supplied = args.sources
978
- if (!args.fromInsights) return supplied
979
- if (!isInsightAvailable()) {
980
- throw new Error('mio.creativity.generate fromInsights requires the optional package @akemi-mio/insight')
981
- }
982
- const seeds = sourcesFromInsights(insightStore.list({}))
983
- if (seeds.length === 0) {
984
- throw new Error('mio.creativity.generate fromInsights: no stored insights yet (run `mio insight generate` first)')
1025
+ // Nothing to top up and no fromInsights: return as-is so the engine keeps
1026
+ // throwing its documented "requires sources" error for empty calls.
1027
+ if (!args.fromInsights && !(Array.isArray(supplied) && supplied.length)) return supplied
1028
+ let base = supplied
1029
+ if (args.fromInsights) {
1030
+ if (!isInsightAvailable()) {
1031
+ throw new Error('mio.creativity.generate fromInsights requires the optional package @akemi-mio/insight')
1032
+ }
1033
+ const seeds = sourcesFromInsights(insightStore.list({}))
1034
+ if (seeds.length === 0) {
1035
+ throw new Error('mio.creativity.generate fromInsights: no stored insights yet (run `mio insight generate` first)')
1036
+ }
1037
+ base = Array.isArray(supplied) ? supplied.concat(seeds) : seeds
985
1038
  }
986
- return Array.isArray(supplied) ? supplied.concat(seeds) : seeds
1039
+ // Explicit sources always win; local memory/traces/stored hypotheses only
1040
+ // top a short list up to the two entries the mixer needs (spec Capability 2,
1041
+ // injection point 2 — the MCP twin of `mio creativity generate`).
1042
+ return topUpSources(Array.isArray(base) ? base : [], autoSources())
987
1043
  }
988
1044
 
989
1045
  async function callTool(name, args = {}) {
@@ -1060,6 +1116,26 @@ async function callTool(name, args = {}) {
1060
1116
  return creativityEngine.generate(generationSources(args), args.strategy)
1061
1117
  case 'mio.creativity.ferment':
1062
1118
  return creativityEngine.ferment(args.limit)
1119
+ case 'mio.idea.generate': {
1120
+ const goal = typeof args.goal === 'string' ? args.goal.trim() : ''
1121
+ if (!goal) throw new Error('mio.idea.generate requires goal: a non-empty string')
1122
+ let grounding = { sources: [], relatedMemoryIds: [], groundedWith: 0 }
1123
+ try {
1124
+ const qr = await groundingMemoryStore.queryMemory({ query: goal, limit: 3, scope: 'all' })
1125
+ grounding = groundingFrom(qr)
1126
+ } catch (_) {}
1127
+ return runIdeaGenerate({
1128
+ goal,
1129
+ context: typeof args.context === 'string' ? args.context : '',
1130
+ constraints: Array.isArray(args.constraints) ? args.constraints.filter((c) => typeof c === 'string') : [],
1131
+ numIdeas: args.numIdeas,
1132
+ chatJson,
1133
+ store: creativityEngine.store,
1134
+ groundingSources: grounding.sources,
1135
+ relatedMemoryIds: grounding.relatedMemoryIds,
1136
+ autoSources: autoSources(),
1137
+ })
1138
+ }
1063
1139
  case 'mio.insight.status':
1064
1140
  return insightStatus()
1065
1141
  case 'mio.insight.list':