mio-agent-runtime 0.14.0 → 0.15.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/README.md CHANGED
@@ -53,10 +53,11 @@ mio memory forget --ids a,b PERMANENTLY delete records (--yes required; writ
53
53
  mio memory merge --ids a,b Merge duplicate records into one survivor (--keep/--allow-divergent; --yes required)
54
54
  mio memory migrate --ids a,b --scope global|project Move records between the project and global layers
55
55
  mio policy check "<action>" Check the historical risk of an action before running it (--project; reads global MIO_HOME)
56
- mio creativity status Show creativity hypothesis counts and recent top ideas (reads global MIO_HOME)
56
+ mio creativity status Show creativity hypothesis counts, recent top ideas, adoption join (reads global MIO_HOME)
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 creativity adopt Record an adoption event for a stored hypothesis (writes traces only)
60
61
  mio idea generate --goal "..." Grounded idea pipeline: auto sources + novelty gate, persisted (calls an LLM)
61
62
  mio insight status Insight counts: total, reported, unreported, high-value (needs @akemi-mio/insight)
62
63
  mio insight list List insights (--unreported, --min-score N, --detector X, --limit N)
@@ -102,7 +103,7 @@ mio --version Print the version and exit (-V)
102
103
 
103
104
  ## MCP 工具
104
105
 
105
- MCP 服务端在 5 大域共暴露 51 个工具:
106
+ MCP 服务端在 5 大域共暴露 52 个工具:
106
107
 
107
108
  ### 记忆(11)
108
109
  `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`
@@ -115,8 +116,8 @@ MCP 服务端在 5 大域共暴露 51 个工具:
115
116
  ### 洞察自省(4)
116
117
  `mio.insight.status` · `mio.insight.list` · `mio.insight.mark_reported` · `mio.insight.generate`
117
118
 
118
- ### 创意引擎(5)
119
- `mio.creativity.generate` · `mio.creativity.ferment` · `mio.creativity.list` · `mio.creativity.status` · `mio.idea.generate`
119
+ ### 创意引擎(6)
120
+ `mio.creativity.generate` · `mio.creativity.ferment` · `mio.creativity.adopt` · `mio.creativity.list` · `mio.creativity.status` · `mio.idea.generate`
120
121
 
121
122
  ### 任务、Agent 与演化(16)
122
123
  `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`
@@ -389,6 +390,7 @@ mio creativity list --json
389
390
  Creativity engine:
390
391
  hypotheses: 5 combos: 3 experiments: 1
391
392
  active: 2 validated: 1 rejected: 1 draft: 1
393
+ adoption: 2 adopted (events=2, tags=2) — derived join, not a metric
392
394
 
393
395
  Recent top ideas:
394
396
  - Plugin architecture (novelty=80 feasibility=70 impact=90 score=240)
@@ -403,6 +405,32 @@ Recent top ideas:
403
405
  - **空存储是正常状态。** 引擎运行之前,`status` 报告全零计数,`list` 显示 "No hypotheses match." 两者都是有效输出,不是错误。
404
406
  - **缓存会在文件变化时失效。** 三个 JSONL 的读取按 `(size, mtime)` 校验,因此另一个进程写入后长驻的 MCP 服务端无需重启就能看到——与 `server/insight-store.js` 同一套守卫。
405
407
 
408
+ ### 采用证据链(adopt / status / list)
409
+
410
+ 假设的「采用」**不在存储里**,而是三轴分离的证据链——store 只管评审,采用是假设与外部世界的关系:
411
+
412
+ - **评审轴(`creativity-hypotheses.jsonl`)**:`draft → active → validated / rejected`,假说自身的生命周期,由 `generate` / `ferment` 推进。
413
+ - **声称轴(`traces.jsonl`)**:`mio.creativity.adopt`(MCP `mio.creativity.adopt`)追加 `creativity.adopt` 事件,`payload.hypothesisId` 即声称;只写事件、绝不碰 memory,校验失败零写入。
414
+ - **证据轴(`memory.jsonl`)**:`mio memory record ... --hypothesis-id <uuid>`(MCP 参数 `hypothesisId`)把 `hypothesis:<uuid>` 注入记录 tags;`mio memory query --tags hypothesis:<uuid>` 即按标签召回。`AGENTS.md` 规范:**采用假设做决策时 `memory.record` 必带 `hypothesisId`**——这是把决策与假设挂上钩的唯一写入口。
415
+
416
+ `status` / `list` 在**读侧**把两轴 join 成派生计数(`mio.creativity.status` 返回):
417
+
418
+ ```json
419
+ "adoption": {
420
+ "adopted": 2, "claimed": 3, "evidenced": 2,
421
+ "metric": false,
422
+ "note": "derived join of creativity.adopt events and hypothesis:<id> memory tags — informational; no rate or threshold by design",
423
+ "sources": {
424
+ "events": { "file": "traces.jsonl", "durable": true },
425
+ "memory": { "file": "memory.jsonl", "durable": true }
426
+ }
427
+ }
428
+ ```
429
+
430
+ - `claimed` / `evidenced` 是两轴各自的 **distinct id 原始计数**(可以含已不在存储中的悬空 id);`adopted` = 两集合并集与存储假设 id 的交集——只有它回答「**存下来的**假设里哪些被采用过」。原始与 join 并报,悬空证据因此可见而不是被静默吞掉。
431
+ - **`metric: false` 是承重字段**:这是信息性派生 join,**不是** ADR-017 评估指标——没有 adoption rate、没有阈值。`sources` 照 `routeAdoption` 的模式注明每个轴的背书文件与 durable 性,于是「0 adopted(真没人用)」与「证据文件没留存(不可测)」能区分开。
432
+ - `list` 每行带 `adopted: boolean`,文本模式在状态后显示 `[adopted]` 标记;JSON 输出即引擎结果,MCP 与 CLI 两侧天然一致。
433
+
406
434
  ### 生成与发酵(`generate` / `ferment`)
407
435
 
408
436
  这两个子命令会调用 LLM,`mio creativity` 同样支持它们——用的是**共享的 LLM 客户端**
@@ -423,6 +451,13 @@ mio creativity ferment --limit 3
423
451
  `@akemi-mio/insight` 已安装且至少存有一条洞察时有意义,否则明确报错而不是
424
452
  静默当作「没有来源」。因为来源可由它提供,`mio.creativity.generate` 的
425
453
  `sources` 不再是 schema 必填项。
454
+ - **判重粒度是(素材对 × `--strategy`)。** 同一对素材在 `explore` / `signal` /
455
+ `stable` 下各算一次实验——「409 就换 strategy 重试」是有效的降级而不是空转;
456
+ 三种 strategy 都试过、且没有新素材时才真正耗尽,此时返回的 `reason` 会带上
457
+ strategy、来源数与候选对数(外加 `strategy` 与 `pairsAvailable: 0` 字段),
458
+ 调用方可以据此区分「换个 strategy 有用」与「该补新素材了」。素材身份取名称
459
+ 去空白后的**前 120 个字符**(`--source` 省略 `|` 时 name 会退化成整段描述,
460
+ 描述尾部微调不得绕过判重);没有 `strategy` 字段的旧记录仍挡住所有 strategy。
426
461
  - `ferment` 会复核 `active` / `draft` 假设并更新分数;`verdict=promote` 且总分 > 200 时
427
462
  升为 `validated`,`verdict=reject` 则标记为 `rejected`。
428
463
  - **失败的 LLM 调用会出现在结果里,不再被吞掉。** 返回值带 `errors`
package/bin/mio.js CHANGED
@@ -23,7 +23,7 @@ const { createEvolutionCutoverTools } = require('../server/evolution-cutover.js'
23
23
  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
- const { CreativityEngine, sourcesFromInsights } = require('../server/creativity-engine.js')
26
+ const { CreativityEngine, sourcesFromInsights, adoptHypothesis } = require('../server/creativity-engine.js')
27
27
  const { buildAutoSources, topUpSources } = require('../server/creativity-sources.js')
28
28
  const { runIdeaGenerate, groundingFrom } = require('../server/idea-generate.js')
29
29
  const { createAgentStore } = require('../server/agent-store.js')
@@ -678,18 +678,22 @@ function pruneCommand(args, useJson) {
678
678
  function rememberCommand(args, useJson) {
679
679
  const content = args[1]
680
680
  if (!content) {
681
- console.error('Usage: mio remember "<content>" [--kind decision|context|problem|note] [--tags a,b] [--scope project|global] [--project name] [--json]')
681
+ console.error('Usage: mio remember "<content>" [--kind decision|context|problem|note] [--tags a,b] [--hypothesis-id id] [--scope project|global] [--project name] [--json]')
682
682
  process.exitCode = 1
683
683
  return
684
684
  }
685
685
  const flags = args.slice(2)
686
686
  const store = cliMemoryStore()
687
+ // optionValue returns the next token even if it is another flag; a bare
688
+ // trailing `--hypothesis-id` must not swallow `--json` as a "uuid".
689
+ const hypothesisId = optionValue(flags, '--hypothesis-id')
687
690
  let record
688
691
  try {
689
692
  record = store.recordMemory({
690
693
  content,
691
694
  kind: optionValue(flags, '--kind'),
692
695
  tags: splitTagsOption(flags, '--tags'),
696
+ hypothesisId: hypothesisId && !hypothesisId.startsWith('--') ? hypothesisId : undefined,
693
697
  scope: optionValue(flags, '--scope'),
694
698
  project: optionValue(flags, '--project'),
695
699
  source: 'cli',
@@ -2066,11 +2070,12 @@ function observerCommand(args, useJson) {
2066
2070
 
2067
2071
  function creativityUsage(write = console.log) {
2068
2072
  write(`Usage:
2069
- mio creativity status Show hypothesis counts and recent top ideas
2073
+ mio creativity status Show hypothesis counts, recent top ideas, adoption join
2070
2074
  mio creativity list List hypotheses (--status active|validated|rejected|draft, --sort novelty, --limit N)
2071
2075
 
2072
2076
  mio creativity generate Generate hypotheses by combining sources (needs 2+ --source, calls an LLM)
2073
2077
  mio creativity ferment Review/refine active hypotheses (calls an LLM)
2078
+ mio creativity adopt Record an adoption event for a stored hypothesis (writes traces only)
2074
2079
 
2075
2080
  Options:
2076
2081
  --status name Filter list by status
@@ -2098,6 +2103,8 @@ function printCreativityStatus(result) {
2098
2103
  console.log('Creativity engine:')
2099
2104
  console.log(` hypotheses: ${result.hypotheses} combos: ${result.combos} experiments: ${result.experiments}`)
2100
2105
  console.log(` active: ${result.active} validated: ${result.validated} rejected: ${result.rejected} draft: ${result.draft}`)
2106
+ const adoption = result.adoption || { adopted: 0, claimed: 0, evidenced: 0 }
2107
+ console.log(` adoption: ${adoption.adopted} adopted (events=${adoption.claimed} tags=${adoption.evidenced}) — derived join, not a metric`)
2101
2108
  if (result.recentIdeas.length === 0) {
2102
2109
  console.log('No hypotheses yet.')
2103
2110
  return
@@ -2118,7 +2125,7 @@ function printCreativityList(items) {
2118
2125
  items.forEach((h, index) => {
2119
2126
  const when = h.createdAt ? new Date(h.createdAt).toISOString().slice(0, 19).replace('T', ' ') : ''
2120
2127
  const labels = Array.isArray(h.sourceLabels) && h.sourceLabels.length > 0 ? h.sourceLabels.join(' + ') : '—'
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}` : ''})`)
2128
+ console.log(`${index + 1}. [${h.status}]${h.adopted ? ' [adopted]' : ''} ${h.title} (N=${h.novelty} F=${h.feasibility} I=${h.impact} score=${h.score}${h.noveltyScore !== undefined ? ` noveltyScore=${h.noveltyScore}` : ''})`)
2122
2129
  console.log(` ${labels} | ${when} | ${h.id}`)
2123
2130
  if (h.fermentCount) console.log(` fermented ${h.fermentCount}x`)
2124
2131
  })
@@ -2296,10 +2303,45 @@ function creativityFermentCommand(args, useJson) {
2296
2303
  })
2297
2304
  }
2298
2305
 
2306
+ // mio.creativity.adopt from the terminal: same shared adoptHypothesis the MCP
2307
+ // server calls, same enforcement (id must exist; --memory-id must already
2308
+ // carry the hypothesis:<id> tag), event written to MIO_HOME/traces.jsonl.
2309
+ function creativityAdoptCommand(args, useJson) {
2310
+ const flags = args.slice(2)
2311
+ const hypothesisId = flags[0] && !flags[0].startsWith('--') ? String(flags[0]).trim() : ''
2312
+ if (!hypothesisId) {
2313
+ console.error('mio creativity adopt requires <hypothesisId>')
2314
+ console.error('Usage: mio creativity adopt <hypothesisId> [--memory-id id] [--task-id id] [--note text] [--json]')
2315
+ process.exitCode = 1
2316
+ return
2317
+ }
2318
+
2319
+ let result
2320
+ try {
2321
+ result = adoptHypothesis(
2322
+ cliCreativityEngine(),
2323
+ {
2324
+ hypothesisId,
2325
+ memoryId: optionValue(flags, '--memory-id'),
2326
+ taskId: optionValue(flags, '--task-id'),
2327
+ note: optionValue(flags, '--note'),
2328
+ },
2329
+ cliTaskStore().ingestObservation,
2330
+ MIO_HOME
2331
+ )
2332
+ } catch (error) {
2333
+ console.error(error.message || error)
2334
+ process.exitCode = 1
2335
+ return
2336
+ }
2337
+ if (useJson) return jsonOrText(result, true)
2338
+ console.log(`Adopted hypothesis ${result.hypothesisId}`)
2339
+ console.log(` event: ${result.event && result.event.trace_id ? result.event.trace_id : 'creativity.adopt'}`)
2340
+ }
2341
+
2299
2342
  function ideaUsage() {
2300
2343
  console.error('usage: mio idea generate --goal "one line" [--context "..."] [--constraint "..."] [--num N] [--json]')
2301
2344
  }
2302
-
2303
2345
  async function ideaGenerateCommand(args, useJson) {
2304
2346
  const flags = args.slice(2)
2305
2347
  const goal = (optionValue(flags, '--goal') || '').trim()
@@ -2390,6 +2432,7 @@ function creativityCommand(args, useJson) {
2390
2432
  }
2391
2433
  if (sub === 'generate') return creativityGenerateCommand(args, useJson)
2392
2434
  if (sub === 'ferment') return creativityFermentCommand(args, useJson)
2435
+ if (sub === 'adopt') return creativityAdoptCommand(args, useJson)
2393
2436
 
2394
2437
  if (!['status', 'list'].includes(sub)) {
2395
2438
  console.error(`Unknown creativity subcommand: ${sub}`)
@@ -3492,10 +3535,11 @@ Usage:
3492
3535
  mio experience list List experience reuse (--status pending|confirmed|verified)
3493
3536
  mio experience confirm --ids a,b Confirm auto-claimed reuse (bulk supported)
3494
3537
  mio experience reuse --source-agent A --target-agent B --experience-id X Record a reuse
3495
- mio creativity status Show creativity hypothesis counts and recent top ideas
3538
+ mio creativity status Show creativity hypothesis counts, recent top ideas, adoption join
3496
3539
  mio creativity list List creativity hypotheses (--status active|validated|rejected|draft, --sort novelty, --limit N)
3497
3540
  mio creativity generate Generate hypotheses from 2+ --source "name|content" (or --from-insights; calls an LLM)
3498
3541
  mio creativity ferment Review and refine active hypotheses (calls an LLM)
3542
+ mio creativity adopt Record an adoption event for a stored hypothesis (writes traces only)
3499
3543
  mio idea generate --goal "..." Grounded idea pipeline: auto sources, novelty gate, persisted (calls an LLM)
3500
3544
  mio insight status Insight counts: total, reported, unreported, high-value
3501
3545
  mio insight list List insights (--unreported, --min-score N, --detector X, --limit N)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -34,7 +34,7 @@
34
34
  "@akemi-mio/evolution-strategy": "^0.1.0",
35
35
  "@akemi-mio/evolution-safety": "^0.1.0",
36
36
  "@akemi-mio/evolution-scheduler": "^0.1.0",
37
- "@akemi-mio/observer": "^0.1.4",
37
+ "@akemi-mio/observer": "^0.1.5",
38
38
  "@akemi-mio/insight": "^0.1.1",
39
39
  "@akemi-mio/creativity": "^0.1.0"
40
40
  },
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const crypto = require('crypto')
6
+ const { readJsonl } = require('./memory-store.js')
6
7
  const { evaluateNovelty } = require('@akemi-mio/creativity/NoveltyScorer')
7
8
 
8
9
  // Cache validity key for a JSONL file: a (size, mtimeMs) pair, the same
@@ -162,14 +163,34 @@ function mulberry32(seed) {
162
163
  }
163
164
  }
164
165
 
165
- function mixConcepts(sources, exploredPairs, rng) {
166
+ // One concept-pair identity. `--source "name|content"` allows omitting the
167
+ // pipe, so `name` can degrade into the entire description (often >1k chars):
168
+ // with the raw name as identity, a tail edit mints a brand-new pair and
169
+ // silently bypasses dedup. Trim + first 120 chars is short enough to read in
170
+ // a key and long enough that genuine sources do not collide.
171
+ function pairSourceKey(name) {
172
+ return String(name == null ? '' : name).trim().slice(0, 120)
173
+ }
174
+
175
+ function mixConcepts(sources, exploredPairs, rng, strategy) {
166
176
  if (sources.length < 2) return []
177
+ // Dedup identity is (pair, strategy): `strategy` picks the prompt and the
178
+ // temperature, so the documented fallback "hit 409, retry with another
179
+ // strategy" is a genuinely different experiment. Without the suffix every
180
+ // retry recomputed the same key and returned the same deterministic
181
+ // `all pairs already explored` — three LLM-less no-ops in a row.
182
+ const strat = String(strategy || '')
167
183
  const pairs = []
168
184
  for (let i = 0; i < sources.length; i++) {
169
185
  for (let j = i + 1; j < sources.length; j++) {
170
- const key = `${sources[i].name}||${sources[j].name}`
171
- const reverseKey = `${sources[j].name}||${sources[i].name}`
186
+ const a = pairSourceKey(sources[i].name)
187
+ const b = pairSourceKey(sources[j].name)
188
+ const key = strat ? `${a}||${b}||${strat}` : `${a}||${b}`
189
+ const reverseKey = strat ? `${b}||${a}||${strat}` : `${b}||${a}`
172
190
  if (exploredPairs.has(key) || exploredPairs.has(reverseKey)) continue
191
+ // Records written without a strategy field carry the unsuffixed key;
192
+ // they must keep blocking every strategy (the old semantics).
193
+ if (strat && (exploredPairs.has(`${a}||${b}`) || exploredPairs.has(`${b}||${a}`))) continue
173
194
  pairs.push([sources[i], sources[j]])
174
195
  }
175
196
  }
@@ -237,8 +258,55 @@ function listRow(h) {
237
258
  }
238
259
  }
239
260
 
261
+ // --- read-side adoption join (idea b19bada4, design discussion 1d2e22e4 D3) ---
262
+ // The store never gains an `adopted` status (D1/D4, see adoptHypothesis at the
263
+ // bottom of this file): adoption is joined at READ time from the two evidence
264
+ // axes the write side owns -- an append-only `creativity.adopt` trace event
265
+ // (payload.hypothesisId = the CLAIM, written by mio.creativity.adopt) and a
266
+ // `hypothesis:<id>` tag on a memory record (the EVIDENCE, injected by
267
+ // mio.memory.record's hypothesisId parameter). The engine sits in
268
+ // <dataRoot>/creativity on both hosts (MCP: path.join(dataDir, 'creativity');
269
+ // CLI: path.join(MIO_HOME, 'creativity')), so the two evidence files are one
270
+ // dirname up -- the same dataRoot convention adoptHypothesis documents.
271
+ // Missing files are fine: readJsonl returns [] for them.
272
+ //
273
+ // The counts are informational joins, NOT ADR-017 metrics: no rate and no
274
+ // threshold by design (D3 -- adoption rate was explicitly out of scope).
275
+ // `sources` follows the routeAdoption pattern (evaluation-store.js): names
276
+ // each axis' backing file and that it is durable, so a reader can tell
277
+ // "0 adopted" apart from "evidence not kept". claimed/evidenced are raw
278
+ // distinct ids (they can include ids no longer in the store); adopted is the
279
+ // inner join against stored hypotheses, the only count that answers "which
280
+ // surviving hypotheses were adopted".
281
+ const ADOPTION_TAG_PREFIX = 'hypothesis:'
282
+
283
+ const ADOPTION_SOURCES = Object.freeze({
284
+ events: { file: 'traces.jsonl', durable: true },
285
+ memory: { file: 'memory.jsonl', durable: true },
286
+ })
287
+
288
+ function readAdoptionEvidence(rootDir) {
289
+ const claimed = new Set()
290
+ for (const record of readJsonl(path.join(rootDir, 'traces.jsonl'))) {
291
+ if (!record || record.event_type !== 'creativity.adopt') continue
292
+ const id = record.payload && record.payload.hypothesisId
293
+ if (id) claimed.add(String(id))
294
+ }
295
+ const evidenced = new Set()
296
+ for (const record of readJsonl(path.join(rootDir, 'memory.jsonl'))) {
297
+ if (!record || !Array.isArray(record.tags)) continue
298
+ for (const tag of record.tags) {
299
+ if (typeof tag !== 'string' || !tag.startsWith(ADOPTION_TAG_PREFIX)) continue
300
+ const id = tag.slice(ADOPTION_TAG_PREFIX.length).trim()
301
+ if (id) evidenced.add(id)
302
+ }
303
+ }
304
+ return { claimed, evidenced }
305
+ }
306
+
240
307
  class CreativityEngine {
241
308
  constructor(dataDir, chatJson) {
309
+ this.dataDir = dataDir
242
310
  this.store = new CreativityStore(dataDir)
243
311
  this.chatJson = chatJson
244
312
  this.strategyIndex = 0
@@ -266,14 +334,35 @@ class CreativityEngine {
266
334
  const strat = strategy || STRATEGY_CYCLE[this.strategyIndex % STRATEGY_CYCLE.length]
267
335
  this.strategyIndex++
268
336
 
269
- // Mix concepts
337
+ // Mix concepts. Keys carry the strategy each record was written with, so
338
+ // explore -> signal -> stable retries each get their own attempt while a
339
+ // record without a strategy stays unsuffixed and blocks every strategy.
270
340
  const recent = this.store.getHypotheses({ limit: 30 })
271
341
  const rejected = this.store.getHypotheses({ status: 'rejected', limit: 50 })
272
- const exploredPairs = new Set(recent.flatMap(h => (h.sourceLabels || []).map((l, i, a) => i > 0 ? `${a[i-1]}||${l}` : '')).filter(Boolean))
273
- const pairs = mixConcepts(sources, exploredPairs, this.rng.bind(this))
342
+ const exploredPairs = new Set(
343
+ recent.flatMap((h) =>
344
+ (h.sourceLabels || []).flatMap((l, i, a) => {
345
+ if (i === 0) return []
346
+ const x = pairSourceKey(a[i - 1])
347
+ const y = pairSourceKey(l)
348
+ return h.strategy ? [`${x}||${y}||${h.strategy}`] : [`${x}||${y}`]
349
+ })
350
+ )
351
+ )
352
+ const pairs = mixConcepts(sources, exploredPairs, this.rng.bind(this), strat)
274
353
 
275
354
  if (pairs.length === 0) {
276
- return { ideas: [], reason: 'all pairs already explored' }
355
+ // The bare string made a deterministic failure look like "this strategy
356
+ // did not work, maybe the next one will" — callers that degrade by
357
+ // switching strategy burned two LLM-less retries before learning the
358
+ // material pool itself was exhausted.
359
+ const candidatePairs = (sources.length * (sources.length - 1)) / 2
360
+ return {
361
+ ideas: [],
362
+ reason: `all pairs already explored for strategy "${strat}" (${sources.length} sources, ${candidatePairs} candidate pairs)`,
363
+ strategy: strat,
364
+ pairsAvailable: 0,
365
+ }
277
366
  }
278
367
 
279
368
  // Generate hypotheses via LLM
@@ -469,6 +558,20 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
469
558
  return { fermented: results.length, results, errors }
470
559
  }
471
560
 
561
+ // Fresh per call: both evidence files are append-only and a long-lived MCP
562
+ // server must see new adopt events / memory tags without a restart (same
563
+ // rationale as the insight-store mtime cache -- minus the cache, since this
564
+ // is two small file reads on a read-only report path).
565
+ _adoption() {
566
+ const { claimed, evidenced } = readAdoptionEvidence(path.dirname(this.dataDir))
567
+ const storedIds = new Set(this.store.getHypotheses().map((h) => h.id))
568
+ const adoptedIds = new Set()
569
+ for (const id of [...claimed, ...evidenced]) {
570
+ if (storedIds.has(id)) adoptedIds.add(id)
571
+ }
572
+ return { claimed, evidenced, adoptedIds }
573
+ }
574
+
472
575
  status() {
473
576
  const count = this.store.count()
474
577
  const active = this.store.getHypotheses({ status: 'active' })
@@ -477,6 +580,7 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
477
580
  // draft is a real state -- ferment() reviews draft + active -- so hiding it
478
581
  // here made `status` and `list --status draft` disagree.
479
582
  const draft = this.store.getHypotheses({ status: 'draft' })
583
+ const adoption = this._adoption()
480
584
 
481
585
  return {
482
586
  ...count,
@@ -484,6 +588,19 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
484
588
  validated: validated.length,
485
589
  rejected: rejected.length,
486
590
  draft: draft.length,
591
+ // Derived join of the two evidence axes (see readAdoptionEvidence).
592
+ // metric:false is load-bearing: this is NOT an ADR-017 evaluation metric,
593
+ // so no rate and no threshold are reported -- adoption rate was explicitly
594
+ // out of scope (D3). sources names the backing files, routeAdoption style.
595
+ adoption: {
596
+ adopted: adoption.adoptedIds.size,
597
+ claimed: adoption.claimed.size,
598
+ evidenced: adoption.evidenced.size,
599
+ metric: false,
600
+ note:
601
+ 'derived join of creativity.adopt events and hypothesis:<id> memory tags — informational; no rate or threshold by design',
602
+ sources: ADOPTION_SOURCES,
603
+ },
487
604
  recentIdeas: active.slice(-5).map(h => ({
488
605
  id: h.id,
489
606
  title: h.title,
@@ -503,6 +620,11 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
503
620
  // result. `limit: 0` (and only 0) opts back into "everything".
504
621
  list(opts = {}) {
505
622
  const limit = opts.limit === undefined ? 20 : opts.limit
623
+ // Same read-side join as status(): every row carries `adopted` so the two
624
+ // surfaces cannot disagree (the CLI/MCP entry points already share this
625
+ // engine). listRow() stays the pure store-row shape; decoration is here.
626
+ const adoption = this._adoption()
627
+ const decorate = (row) => ({ ...row, adopted: adoption.adoptedIds.has(row.id) })
506
628
  if (opts.sort === 'novelty') {
507
629
  // Relative novelty ranking (read-only, nothing written back). Pairwise
508
630
  // Jaccard is O(n²) — bound candidates to the newest 200 (spec Risk 3).
@@ -513,9 +635,13 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
513
635
  return { ...listRow(h), noveltyScore: verdict.adjustedNovelty }
514
636
  })
515
637
  rows.sort((a, b) => b.noveltyScore - a.noveltyScore || (b.createdAt || 0) - (a.createdAt || 0))
516
- return limit === 0 ? rows : rows.slice(0, limit)
638
+ const decorated = rows.map(decorate)
639
+ return limit === 0 ? decorated : decorated.slice(0, limit)
517
640
  }
518
- return this.store.getHypotheses({ status: opts.status, limit }).map(listRow)
641
+ return this.store
642
+ .getHypotheses({ status: opts.status, limit })
643
+ .map(listRow)
644
+ .map(decorate)
519
645
  }
520
646
 
521
647
  _buildGenerationPrompt(a, b, strategy, recentHypotheses, rejectedHypotheses) {
@@ -564,4 +690,69 @@ Output JSON with: title, idea, expectedBenefit, risk, novelty (0-100), feasibili
564
690
  }
565
691
  }
566
692
 
567
- module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights, isoOf }
693
+ // Adoption evidence (idea b19bada4, design discussion 1d2e22e4 D1/D4): the
694
+ // store never gains an `adopted` status -- adoption is a relation between a
695
+ // stored hypothesis and the outside world, not an attribute of the hypothesis,
696
+ // so it lives as an append-only `creativity.adopt` trace event, optionally
697
+ // cross-checked against a memory record carrying the `hypothesis:<id>` tag.
698
+ // The host (MCP or CLI) passes its own ingestObservation so both entry points
699
+ // write byte-identical events; dataRoot is the directory holding memory.jsonl
700
+ // and traces.jsonl -- the engine sits in <dataRoot>/creativity on both hosts
701
+ // (see cliCreativityEngine in bin/mio.js). Every validation failure happens
702
+ // before ingest, so a rejected call never pollutes the event stream. This
703
+ // function writes traces only: it never touches memory.jsonl (the
704
+ // generate/ferment write nothing to Memory boundary extends here).
705
+ function adoptHypothesis(engine, args = {}, ingest, dataRoot) {
706
+ const hypothesisId = String(args.hypothesisId || '').trim()
707
+ if (!hypothesisId) {
708
+ throw new Error('mio.creativity.adopt requires hypothesisId: a stored hypothesis UUID')
709
+ }
710
+ if (!engine.store.getHypotheses().some((h) => h.id === hypothesisId)) {
711
+ throw new Error(`mio.creativity.adopt: no stored hypothesis with id "${hypothesisId}"`)
712
+ }
713
+ const memoryId = String(args.memoryId || '').trim()
714
+ if (memoryId) {
715
+ const memoryPath = path.join(dataRoot, 'memory.jsonl')
716
+ const records = fs.existsSync(memoryPath)
717
+ ? fs
718
+ .readFileSync(memoryPath, 'utf-8')
719
+ .split('\n')
720
+ .filter(Boolean)
721
+ .map((line) => {
722
+ try { return JSON.parse(line) } catch { return null }
723
+ })
724
+ .filter(Boolean)
725
+ : []
726
+ const record = records.find((r) => r && r.id === memoryId)
727
+ if (!record) {
728
+ throw new Error(`mio.creativity.adopt: no memory record with id "${memoryId}"`)
729
+ }
730
+ const tag = `hypothesis:${hypothesisId}`
731
+ if (!Array.isArray(record.tags) || !record.tags.includes(tag)) {
732
+ throw new Error(`mio.creativity.adopt: memory record "${memoryId}" is missing tag "${tag}"`)
733
+ }
734
+ }
735
+ if (typeof ingest !== 'function') {
736
+ throw new Error('mio.creativity.adopt: ingest is not available on this host')
737
+ }
738
+ const taskId = String(args.taskId || '').trim()
739
+ const note = String(args.note || '').trim()
740
+ const result = ingest({
741
+ trace_id: `creativity-adopt:${hypothesisId}:${Date.now()}`,
742
+ event_type: 'creativity.adopt',
743
+ outcome: 'success',
744
+ payload: {
745
+ hypothesisId,
746
+ ...(memoryId ? { memoryId } : {}),
747
+ ...(taskId ? { taskId } : {}),
748
+ ...(note ? { note } : {}),
749
+ },
750
+ })
751
+ return {
752
+ recorded: true,
753
+ hypothesisId,
754
+ event: (result && result.event) || result,
755
+ }
756
+ }
757
+
758
+ module.exports = { CreativityEngine, CreativityStore, sourcesFromInsights, isoOf, adoptHypothesis }
@@ -518,12 +518,23 @@ function createMemoryStore(options = {}) {
518
518
  const content = String(args.content || '').trim()
519
519
  if (!content) throw new Error('memory.record requires a non-empty content')
520
520
  const scope = normalizeScope(args.scope)
521
+ // Adoption evidence (idea b19bada4, task 2261a5f4): hypothesisId is pure
522
+ // sugar for the exact tag `mio.creativity.adopt` enforces — the record
523
+ // joins the creativity hypothesis through the same `hypothesis:<uuid>`
524
+ // tag the tags AND-filter already searches. Omitted, the record keeps
525
+ // the exact legacy shape: no extra field, tags byte-identical.
526
+ const hypothesisId = String(args.hypothesisId || '').trim()
527
+ const tags = normalizeTags(args.tags)
528
+ if (hypothesisId) {
529
+ const tag = `hypothesis:${hypothesisId}`
530
+ if (!tags.includes(tag)) tags.push(tag)
531
+ }
521
532
  const record = {
522
533
  id: createId('mem'),
523
534
  timestamp: new Date().toISOString(),
524
535
  kind: args.kind || 'note',
525
536
  content,
526
- tags: normalizeTags(args.tags),
537
+ tags,
527
538
  project: scope === 'global' ? null : args.project || projectName(),
528
539
  scope,
529
540
  source: args.source || agentId() || 'mcp',
@@ -20,7 +20,7 @@ const { createSubscriptionStore } = require('../subscription-store.js')
20
20
  const { createDigest } = require('../digest.js')
21
21
  const { createEvolutionReport } = require('../evolution-report.js')
22
22
 
23
- const { CreativityEngine, sourcesFromInsights } = require('../creativity-engine.js')
23
+ const { CreativityEngine, sourcesFromInsights, adoptHypothesis } = require('../creativity-engine.js')
24
24
  const { buildAutoSources, topUpSources } = require('../creativity-sources.js')
25
25
  const { runIdeaGenerate, groundingFrom } = require('../idea-generate.js')
26
26
  // The observer and insight engines are optional dependencies. Their stores own
@@ -351,6 +351,11 @@ const TOOLS = [
351
351
  content: { type: 'string', description: 'Memory content.' },
352
352
  kind: { type: 'string', description: 'Memory kind, such as decision, context, or problem.' },
353
353
  tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
354
+ hypothesisId: {
355
+ type: 'string',
356
+ description:
357
+ 'Optional creativity hypothesis UUID. Injects the tag "hypothesis:<id>" so this record joins that hypothesis (the tag mio.creativity.adopt enforces); search it back via memory.query tags.',
358
+ },
354
359
  project: { type: 'string', description: 'Project name. Defaults to current directory name.' },
355
360
  scope: { type: 'string', description: 'Record scope: project (default) or global.' },
356
361
  source: { type: 'string', description: 'Recording source. Defaults to mcp.' },
@@ -696,12 +701,12 @@ const TOOLS = [
696
701
  },
697
702
  {
698
703
  name: 'mio.creativity.status',
699
- description: 'Show creativity engine status: hypothesis counts, active/validated/rejected/draft, recent top ideas.',
704
+ description: 'Show creativity engine status: hypothesis counts, active/validated/rejected/draft, recent top ideas, plus the adoption evidence join — adopted/claimed/evidenced counts with sources (informational, NOT a metric; derived from creativity.adopt trace events and hypothesis:<id> memory tags).',
700
705
  inputSchema: { type: 'object', properties: {} },
701
706
  },
702
707
  {
703
708
  name: 'mio.creativity.list',
704
- description: 'List creativity hypotheses with optional status filter. Newest 20 by default; pass limit: 0 for everything.',
709
+ description: 'List creativity hypotheses with optional status filter. Newest 20 by default; pass limit: 0 for everything. Each row carries adopted: true when a creativity.adopt event or a hypothesis:<id> memory tag exists for its id (read-side evidence join, informational).',
705
710
  inputSchema: {
706
711
  type: 'object',
707
712
  properties: {
@@ -735,7 +740,7 @@ const TOOLS = [
735
740
  type: 'boolean',
736
741
  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.',
737
742
  },
738
- strategy: { type: 'string', description: 'Generation strategy: explore (cross-domain), signal (provocative), stable (conservative). Auto-selected if omitted.' },
743
+ 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.' },
739
744
  },
740
745
  },
741
746
  },
@@ -749,6 +754,21 @@ const TOOLS = [
749
754
  },
750
755
  },
751
756
  },
757
+ {
758
+ name: 'mio.creativity.adopt',
759
+ description:
760
+ 'Record that a stored creativity hypothesis was adopted: validates the hypothesis id, then appends a creativity.adopt trace event (who/when/what). Optionally enforces that the referenced memory record carries the hypothesis:<id> tag. Writes traces only — never Mio memory, never hypothesis status.',
761
+ inputSchema: {
762
+ type: 'object',
763
+ properties: {
764
+ hypothesisId: { type: 'string', description: 'UUID of a stored hypothesis (creativity-hypotheses.jsonl). Must exist or the call fails.' },
765
+ memoryId: { type: 'string', description: 'Optional memory record id. If given, the record must exist and carry the tag "hypothesis:<hypothesisId>" or the call fails.' },
766
+ taskId: { type: 'string', description: 'Optional task id this adoption feeds.' },
767
+ note: { type: 'string', description: 'Optional short rationale for the adoption.' },
768
+ },
769
+ required: ['hypothesisId'],
770
+ },
771
+ },
752
772
  {
753
773
  name: 'mio.idea.generate',
754
774
  description:
@@ -1116,6 +1136,8 @@ async function callTool(name, args = {}) {
1116
1136
  return creativityEngine.generate(generationSources(args), args.strategy)
1117
1137
  case 'mio.creativity.ferment':
1118
1138
  return creativityEngine.ferment(args.limit)
1139
+ case 'mio.creativity.adopt':
1140
+ return adoptHypothesis(creativityEngine, args, ingestObservation, dataDir)
1119
1141
  case 'mio.idea.generate': {
1120
1142
  const goal = typeof args.goal === 'string' ? args.goal.trim() : ''
1121
1143
  if (!goal) throw new Error('mio.idea.generate requires goal: a non-empty string')