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.
@@ -1,475 +1,475 @@
1
- 'use strict'
2
-
3
- // Shared task store: the single implementation behind mio.task.route, consumed
4
- // by both the MCP server and the CLI (`mio task route`). It answers "which
5
- // verified experiences apply to this kind of task, and which agents have
6
- // actually succeeded at it" by cross-referencing memory + experience reuse,
7
- // with an agent-health signal from the latest digest snapshot.
8
- //
9
- // Extracted from mio-intelligence-mcp/index.js so the terminal and the MCP tool
10
- // can never route differently.
11
- //
12
- // One behavioural knob is deliberate: `persistQuery`. The MCP call feeds the
13
- // query log so a later task_outcome can auto-claim route-driven reuse, which is
14
- // load-bearing for the experience loop. A terminal `mio task route` is an
15
- // inspection command and must NOT write to queries.jsonl on every invocation,
16
- // so the CLI passes persistQuery: false. Default stays true so MCP behaviour is
17
- // unchanged.
18
-
19
- const fs = require('fs')
20
- const path = require('path')
21
- const { createId, readJsonlCached, appendJsonl, writeJsonl } = require('./memory-store.js')
22
- const { REUSE_STATUS_FILTERS } = require('./experience-store.js')
23
- const { createQueryLog } = require('./query-log.js')
24
-
25
- const AGENT_HEALTH_FRESH_MS = 48 * 3600000
26
-
27
- // Token overlap helpers for the routing-gate diagnostic below. Kept local and
28
- // deliberately stricter than memory-store's scoreRecord: that scorer also
29
- // counts prefix matches, which are right for fuzzy recall but would make this
30
- // diagnostic fire on unrelated records. These require exact token equality.
31
- function tokenizeForOverlap(text) {
32
- const normalized = String(text || '').toLowerCase()
33
- const latin = normalized.match(/[a-z0-9]+/g) || []
34
- const cjk = (normalized.match(/[\u4e00-\u9fff]/g) || []).map((char) => `cjk:${char}`)
35
- return [...latin, ...cjk]
36
- }
37
-
38
- function cjkBigramsForOverlap(text) {
39
- const chars = String(text || '').toLowerCase().match(/[\u4e00-\u9fff]/g) || []
40
- const bigrams = new Set()
41
- for (let i = 0; i + 1 < chars.length; i += 1) {
42
- bigrams.add(`${chars[i]}${chars[i + 1]}`)
43
- }
44
- return bigrams
45
- }
46
-
47
- function createTaskStore(options) {
48
- const dataDir = options.dataDir
49
- const projectName = options.projectName || (() => null)
50
- const memoryStore = options.memoryStore
51
- // Who is asking. The MCP passes the runtime agent id; the CLI passes 'cli'.
52
- // Named resolveAgentId so recordTaskOutcome can still use `agentId` as a local.
53
- const resolveAgentId = options.agentId || (() => 'mcp')
54
-
55
- const {
56
- normalizeScope,
57
- isGlobalRecord,
58
- matchesProjectScope,
59
- scoreRecord,
60
- loadEvidenceWeights,
61
- } = memoryStore
62
-
63
- const memoryPath = path.join(dataDir, 'memory.jsonl')
64
- const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
65
- const tracePath = path.join(dataDir, 'traces.jsonl')
66
- const agentsPath = path.join(dataDir, 'agents.jsonl')
67
-
68
- function readAgentHealth() {
69
- const file = path.join(dataDir, 'digest', 'latest.json')
70
- if (!fs.existsSync(file)) return null
71
- try {
72
- const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
73
- const ts = Date.parse(snapshot.generatedAt || '')
74
- const generatedAt = Number.isFinite(ts) ? snapshot.generatedAt : null
75
- if (!Number.isFinite(ts) || Date.now() - ts > AGENT_HEALTH_FRESH_MS) {
76
- return { stale: true, generatedAt, best: null, degraded: [], agents: [] }
77
- }
78
- const agents = Array.isArray(snapshot.agents) ? snapshot.agents : []
79
- const reliable = agents.filter((a) => Number(a.total) >= 5)
80
- const best =
81
- reliable.length > 0
82
- ? reliable.reduce((a, b) => (b.successRate > a.successRate ? b : a))
83
- : null
84
- const degraded = reliable
85
- .filter((a) => a.successRate < 50)
86
- .map((a) => ({ agent: a.agent, successRate: a.successRate, total: a.total }))
87
- return {
88
- stale: false,
89
- generatedAt,
90
- periodDays: snapshot.periodDays || null,
91
- best: best ? { agent: best.agent, successRate: best.successRate, total: best.total } : null,
92
- degraded,
93
- agents: agents.map((a) => ({ agent: a.agent, total: a.total, successRate: a.successRate })),
94
- }
95
- } catch (_) {
96
- return null
97
- }
98
- }
99
-
100
- // queries.jsonl is owned by the shared query log (see ../query-log.js). The
101
- // MCP server and the CLI hand in the same instance the memory store uses, so
102
- // there is exactly one reader/writer for this file.
103
- const queryLog = options.queryLog || createQueryLog({ dataDir })
104
- const loadRecentQueries = () => queryLog.load()
105
- const persistRecentQueries = (entries) => queryLog.persist(entries)
106
- const REUSE_MATCH_WINDOW_MS = queryLog.windowMs
107
-
108
- function routeTask(args = {}) {
109
- const task = String(args.task || '').trim()
110
- if (!task) throw new Error('task.route requires a non-empty task')
111
- const project = args.project || projectName()
112
- const scope = normalizeScope(args.scope)
113
- const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10)
114
-
115
- const memories = readJsonlCached(memoryPath).filter((record) => record.archived !== true)
116
- const memoryById = new Map(memories.map((record) => [record.id, record]))
117
- const evidence = loadEvidenceWeights()
118
-
119
- // Match verified reuse experiences whose source memory is relevant to the task.
120
- //
121
- // ⚠️ The `verified` predicate (experience-store.js) is
122
- // `reuse && behaviorChanged && outcomeImproved` -- note it does NOT include
123
- // `confirmed`. It has a failure mode that looks exactly like "no match": an
124
- // auto-claim is written with behaviorChanged=false (task-store.js
125
- // buildAutoClaim), and nothing flips it to true except
126
- // `mio.experience.confirm` (experience-store.js:146). So if nothing was ever
127
- // confirmed, verified is empty and routing returns nothing for every task,
128
- // forever. Measured on the real dataset 2026-09-20: 21 reuse records,
129
- // behaviorChanged=false in all of them, verified == 0, and `task.route`
130
- // could not return a single hit on a task that plainly had matching
131
- // experience. Confirming three records made the same route return 1. So
132
- // `count` is not a routing-quality signal until confirmations exist --
133
- // `gatedBy` below reports that distinction.
134
- const reuseRecords = readJsonlCached(experienceReusePath)
135
- const routeByMemory = new Map()
136
- for (const record of reuseRecords) {
137
- if (!REUSE_STATUS_FILTERS.verified(record)) continue
138
- const memory = memoryById.get(record.experienceId)
139
- if (!memory || !matchesProjectScope(memory, project, scope)) continue
140
- const score = scoreRecord(memory, task, project)
141
- if (score <= 0) continue
142
- let entry = routeByMemory.get(record.experienceId)
143
- if (!entry) {
144
- entry = {
145
- experienceId: record.experienceId,
146
- memory,
147
- weight: 0,
148
- confirmed: false,
149
- sourceAgents: new Set(),
150
- targetAgents: new Set(),
151
- reuseCount: 0,
152
- }
153
- routeByMemory.set(record.experienceId, entry)
154
- }
155
- entry.reuseCount += 1
156
- if (record.confirmed === true) entry.confirmed = true
157
- if (record.sourceAgent) entry.sourceAgents.add(record.sourceAgent)
158
- if (record.targetAgent) entry.targetAgents.add(record.targetAgent)
159
- entry.weight = Math.max(entry.weight, score + (entry.confirmed ? 2 : 1.5))
160
- }
161
-
162
- const routes = [...routeByMemory.values()]
163
- .sort((a, b) => b.weight - a.weight)
164
- .slice(0, limit)
165
- .map((entry) => ({
166
- experienceId: entry.experienceId,
167
- reuseCount: entry.reuseCount,
168
- confirmed: entry.confirmed,
169
- sourceAgents: [...entry.sourceAgents].filter(Boolean),
170
- targetAgents: [...entry.targetAgents].filter(Boolean),
171
- score: Number(entry.weight.toFixed(2)),
172
- memory: {
173
- id: entry.memory.id,
174
- timestamp: entry.memory.timestamp,
175
- kind: entry.memory.kind,
176
- content: entry.memory.content,
177
- tags: entry.memory.tags || [],
178
- project: entry.memory.project || null,
179
- scope: entry.memory.scope || 'project',
180
- },
181
- }))
182
-
183
- // Why routing came back empty, when it did. `no-match` means the strict
184
- // filter was satisfied by nothing relevant; `unconfirmed` means relevant
185
- // experience exists but was never confirmed, so the filter excluded it. The
186
- // two look identical in `count` but need opposite responses -- the first is
187
- // a recall problem, the second is a one-command fix.
188
- //
189
- // "Relevant" here deliberately does NOT reuse scoreRecord: that scorer's
190
- // prefix rule is symmetric (`token.startsWith(item) || item.startsWith(token)`),
191
- // so a query token merely starting with any short haystack token counts --
192
- // "nonsense" matches a record containing "no read path". Fine for fuzzy
193
- // recall, useless as a diagnostic: measured on the real dataset, a nonsense
194
- // task scored 10 unrelated records as "relevant". Require a real shared
195
- // token (or CJK bigram) instead, so the count only moves for genuine overlap.
196
- let gatedBy = null
197
- if (routes.length === 0) {
198
- const queryTokens = new Set(tokenizeForOverlap(task))
199
- const bigrams = cjkBigramsForOverlap(task)
200
- const overlaps = (memory) => {
201
- const content = `${memory.content || ''} ${(memory.tags || []).join(' ')} ${memory.kind || ''}`
202
- if (tokenizeForOverlap(content).some((token) => queryTokens.has(token))) return true
203
- if (bigrams.size === 0) return false
204
- const haystack = cjkBigramsForOverlap(content)
205
- for (const bigram of bigrams) if (haystack.has(bigram)) return true
206
- return false
207
- }
208
- const relevantUnconfirmed = reuseRecords.filter((record) => {
209
- if (!REUSE_STATUS_FILTERS.pending(record)) return false
210
- const memory = memoryById.get(record.experienceId)
211
- if (!memory || !matchesProjectScope(memory, project, scope)) return false
212
- return overlaps(memory)
213
- }).length
214
- const totalVerified = reuseRecords.filter(REUSE_STATUS_FILTERS.verified).length
215
- gatedBy = {
216
- reason: relevantUnconfirmed > 0 ? 'unconfirmed' : 'no-match',
217
- verifiedTotal: totalVerified,
218
- relevantUnconfirmed,
219
- }
220
- }
221
-
222
- // Broader context: top related memories not already routed.
223
- const routedIds = new Set(routes.map((route) => route.experienceId))
224
- const scoreRelated = (record) =>
225
- scoreRecord(record, task, project, evidence) +
226
- (scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
227
- // Same shape as memory-store.queryMemory: score once, then sort the scored
228
- // pairs. Scoring inside the comparator re-tokenized every candidate on every
229
- // comparison, which is what made mio.task.route the slowest tool call
230
- // (~100 ms median on a 1094-record store).
231
- const scoredRelated = []
232
- for (const record of memories) {
233
- if (routedIds.has(record.id)) continue
234
- if (!matchesProjectScope(record, project, scope)) continue
235
- const score = scoreRelated(record)
236
- if (score > 0) scoredRelated.push({ record, score })
237
- }
238
- const relatedMemories = scoredRelated
239
- .sort((a, b) => b.score - a.score)
240
- .slice(0, Math.max(limit, 5))
241
- .map(({ record }) => {
242
- const ev = evidence.get(record.id)
243
- return {
244
- id: record.id,
245
- timestamp: record.timestamp,
246
- kind: record.kind,
247
- content: record.content,
248
- tags: record.tags || [],
249
- project: record.project || null,
250
- scope: record.scope || 'project',
251
- evidence: ev
252
- ? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount }
253
- : null,
254
- }
255
- })
256
-
257
- // Feed the query log so a later task_outcome can auto-claim route-driven
258
- // reuse. Skipped when persistQuery is false (the CLI, which is read-only).
259
- if (args.persistQuery !== false && (routes.length > 0 || relatedMemories.length > 0)) {
260
- const now = Date.now()
261
- const entries = loadRecentQueries()
262
- entries.push({
263
- agent: resolveAgentId() || 'mcp',
264
- project,
265
- query: task,
266
- resultIds: [...routedIds, ...relatedMemories.map((record) => record.id)].filter(Boolean),
267
- resultSources: [
268
- ...routes.map(() => 'experience'),
269
- ...relatedMemories.map((record) => record.source || null),
270
- ],
271
- timestamp: now,
272
- expiresAt: now + REUSE_MATCH_WINDOW_MS,
273
- })
274
- persistRecentQueries(entries)
275
- }
276
-
277
- let suggestion
278
- if (routes.length > 0) {
279
- suggestion =
280
- `Route ${routes.length} verified experience(s) to this task; apply the top match first and record the outcome with mio.observer.ingest / mio.experience.reuse.`
281
- } else if (gatedBy && gatedBy.reason === 'unconfirmed') {
282
- // Distinct from "no match": the experience is there and relevant, it just
283
- // was never confirmed. `verified` requires behaviorChanged, which only
284
- // `experience.confirm` sets on an auto-claim, so an unconfirmed backlog
285
- // silently empties the routing set. Point at the fix, not at recall.
286
- suggestion =
287
- `${gatedBy.relevantUnconfirmed} relevant experience(s) match this task but are unconfirmed, so routing skipped them (only verified experience routes, and confirming is what marks behaviorChanged). Run "mio status" to see the backlog, then "mio experience confirm --ids <id,...>".`
288
- } else if (relatedMemories.length > 0) {
289
- suggestion =
290
- 'No verified experience matches yet. Consult the related memories, then record the outcome so this task can seed future routes.'
291
- } else {
292
- suggestion =
293
- 'No matching experience or memory found. Proceed with care and record the outcome to grow the experience base.'
294
- }
295
-
296
- // Agent-health routing signal from the latest digest snapshot (value loop:
297
- // collected task outcomes influence who should execute the next task).
298
- const agentHealth = readAgentHealth()
299
- let routingSignal = null
300
- if (agentHealth && !agentHealth.stale && agentHealth.best) {
301
- const parts = []
302
- if (agentHealth.best.successRate >= 80) {
303
- parts.push(
304
- `prefer ${agentHealth.best.agent} (${agentHealth.best.successRate}% success over ${agentHealth.best.total} tasks)`,
305
- )
306
- }
307
- for (const bad of agentHealth.degraded) {
308
- parts.push(`avoid ${bad.agent} (${bad.successRate}% success over ${bad.total} tasks)`)
309
- }
310
- if (parts.length > 0) {
311
- routingSignal = `Agent health (last ${agentHealth.periodDays || '?'}d): ${parts.join('; ')}.`
312
- suggestion = `${suggestion} ${routingSignal}`
313
- }
314
- }
315
-
316
- return {
317
- task,
318
- project,
319
- scope,
320
- count: routes.length,
321
- routes,
322
- relatedMemories,
323
- agentHealth,
324
- gatedBy,
325
- summary: {
326
- verifiedRoutes: routes.length,
327
- relatedMemories: relatedMemories.length,
328
- routingSignal,
329
- suggestion,
330
- },
331
- }
332
- }
333
-
334
- function buildAutoClaim(entry, event, targetAgent) {
335
- const crossSourceIndex = entry.resultSources.findIndex(
336
- (source) => source && String(source).toLowerCase() !== targetAgent.toLowerCase()
337
- )
338
- const sourceAgent =
339
- crossSourceIndex >= 0 ? String(entry.resultSources[crossSourceIndex]) : targetAgent
340
- const experienceId =
341
- entry.resultIds[crossSourceIndex >= 0 ? crossSourceIndex : 0] ||
342
- entry.resultIds[0] ||
343
- 'unknown'
344
- const outcomeImproved = String(event.outcome || '').toLowerCase() === 'success'
345
- return {
346
- id: createId('xfer'),
347
- timestamp: new Date().toISOString(),
348
- sourceAgent,
349
- targetAgent,
350
- experienceId,
351
- reuse: true,
352
- behaviorChanged: false,
353
- outcomeImproved,
354
- project: entry.project,
355
- source: 'auto_claim',
356
- traceId: event.trace_id || null,
357
- notes: `Auto-claimed: memory.query "${entry.query}" matched task_outcome ${event.outcome || 'unknown'} in trace ${event.trace_id || 'unknown'}.`,
358
- }
359
- }
360
-
361
- // Phase 0 auto-claim: a task_outcome arriving within the match window of a
362
- // prior query is attributed to that query's results, so reuse evidence does
363
- // not depend on the agent self-reporting mio.experience.reuse. Shared with
364
- // observer.ingest, which is why it lives in the store rather than the MCP
365
- // handler.
366
- function autoClaimExperienceReuse(event) {
367
- if (String(event.event_type || '').toLowerCase() !== 'task_outcome') return []
368
- const targetAgent = String(event.agent || '').trim()
369
- if (!targetAgent) return []
370
- const now = Date.now()
371
- const claims = []
372
- const remaining = []
373
- const entries = loadRecentQueries()
374
- for (const entry of entries) {
375
- const expired = now - entry.timestamp > REUSE_MATCH_WINDOW_MS
376
- const matches =
377
- entry.agent === targetAgent && (!event.project || entry.project === event.project)
378
- if (expired || matches) {
379
- if (matches && !expired) {
380
- claims.push(buildAutoClaim(entry, event, targetAgent))
381
- }
382
- continue
383
- }
384
- remaining.push(entry)
385
- }
386
- persistRecentQueries(remaining)
387
- for (const evidence of claims) {
388
- appendJsonl(experienceReusePath, evidence)
389
- }
390
- return claims
391
- }
392
-
393
- function recordTaskOutcome(args = {}) {
394
- const outcome = String(args.outcome || '').trim().toLowerCase()
395
- if (!['success', 'failure', 'aborted'].includes(outcome)) {
396
- throw new Error('task.record_outcome requires outcome to be success, failure, or aborted')
397
- }
398
- const agentId = String(args.agentId || resolveAgentId() || 'unknown').trim()
399
- const project = args.project || projectName()
400
- const task = String(args.task || '').trim()
401
- const summary = String(args.summary || '').trim()
402
- const verification = String(args.verification || '').trim()
403
- const traceId = String(args.traceId || '').trim() || createId('trace:' + agentId + ':' + project)
404
-
405
- // 1. Record trace event
406
- const event = {
407
- id: createId('trace'),
408
- timestamp: new Date().toISOString(),
409
- trace_id: traceId,
410
- event_type: 'task_outcome',
411
- outcome,
412
- payload: { task, summary, verification },
413
- agent: agentId,
414
- host: 'mcp',
415
- project,
416
- }
417
- appendJsonl(tracePath, event)
418
-
419
- // 2. Auto-claim experience reuse
420
- const autoClaims = autoClaimExperienceReuse(event)
421
-
422
- // 3. Update agent registry
423
- const now = new Date().toISOString()
424
- const agents = readJsonlCached(agentsPath)
425
- const agent = agents.find((a) => a.agentId === agentId && a.project === project)
426
- if (agent) {
427
- agent.lastSeenAt = now
428
- agent.taskCount = (agent.taskCount || 0) + 1
429
- agent.successCount = (agent.successCount || 0) + (outcome === 'success' ? 1 : 0)
430
- agent.failureCount = (agent.failureCount || 0) + (outcome === 'failure' ? 1 : 0)
431
- writeJsonl(agentsPath, agents)
432
- }
433
-
434
- return {
435
- recorded: true,
436
- event: { id: event.id, trace_id: traceId, outcome, agent: agentId, project },
437
- agentUpdated: !!agent,
438
- autoClaims: autoClaims.length > 0 ? autoClaims : undefined,
439
- }
440
- }
441
-
442
- function ingestObservation(args = {}) {
443
- const traceId = String(args.trace_id || '').trim()
444
- const eventType = String(args.event_type || '').trim()
445
- if (!traceId) throw new Error('observer.ingest requires trace_id')
446
- if (!eventType) throw new Error('observer.ingest requires event_type')
447
- const event = {
448
- id: createId('trace'),
449
- timestamp: new Date().toISOString(),
450
- trace_id: traceId,
451
- event_type: eventType,
452
- outcome: args.outcome || null,
453
- payload: args.payload || {},
454
- agent: args.agent || resolveAgentId(),
455
- host: args.host || 'mcp',
456
- project: args.project || projectName(),
457
- }
458
- appendJsonl(tracePath, event)
459
- const autoClaims = autoClaimExperienceReuse(event)
460
- if (autoClaims.length > 0) {
461
- return { recorded: true, event, autoClaims }
462
- }
463
- return { recorded: true, event }
464
- }
465
-
466
- return {
467
- routeTask,
468
- readAgentHealth,
469
- autoClaimExperienceReuse,
470
- recordTaskOutcome,
471
- ingestObservation,
472
- }
473
- }
474
-
475
- module.exports = { createTaskStore }
1
+ 'use strict'
2
+
3
+ // Shared task store: the single implementation behind mio.task.route, consumed
4
+ // by both the MCP server and the CLI (`mio task route`). It answers "which
5
+ // verified experiences apply to this kind of task, and which agents have
6
+ // actually succeeded at it" by cross-referencing memory + experience reuse,
7
+ // with an agent-health signal from the latest digest snapshot.
8
+ //
9
+ // Extracted from mio-intelligence-mcp/index.js so the terminal and the MCP tool
10
+ // can never route differently.
11
+ //
12
+ // One behavioural knob is deliberate: `persistQuery`. The MCP call feeds the
13
+ // query log so a later task_outcome can auto-claim route-driven reuse, which is
14
+ // load-bearing for the experience loop. A terminal `mio task route` is an
15
+ // inspection command and must NOT write to queries.jsonl on every invocation,
16
+ // so the CLI passes persistQuery: false. Default stays true so MCP behaviour is
17
+ // unchanged.
18
+
19
+ const fs = require('fs')
20
+ const path = require('path')
21
+ const { createId, readJsonlCached, appendJsonl, writeJsonl } = require('./memory-store.js')
22
+ const { REUSE_STATUS_FILTERS } = require('./experience-store.js')
23
+ const { createQueryLog } = require('./query-log.js')
24
+
25
+ const AGENT_HEALTH_FRESH_MS = 48 * 3600000
26
+
27
+ // Token overlap helpers for the routing-gate diagnostic below. Kept local and
28
+ // deliberately stricter than memory-store's scoreRecord: that scorer also
29
+ // counts prefix matches, which are right for fuzzy recall but would make this
30
+ // diagnostic fire on unrelated records. These require exact token equality.
31
+ function tokenizeForOverlap(text) {
32
+ const normalized = String(text || '').toLowerCase()
33
+ const latin = normalized.match(/[a-z0-9]+/g) || []
34
+ const cjk = (normalized.match(/[\u4e00-\u9fff]/g) || []).map((char) => `cjk:${char}`)
35
+ return [...latin, ...cjk]
36
+ }
37
+
38
+ function cjkBigramsForOverlap(text) {
39
+ const chars = String(text || '').toLowerCase().match(/[\u4e00-\u9fff]/g) || []
40
+ const bigrams = new Set()
41
+ for (let i = 0; i + 1 < chars.length; i += 1) {
42
+ bigrams.add(`${chars[i]}${chars[i + 1]}`)
43
+ }
44
+ return bigrams
45
+ }
46
+
47
+ function createTaskStore(options) {
48
+ const dataDir = options.dataDir
49
+ const projectName = options.projectName || (() => null)
50
+ const memoryStore = options.memoryStore
51
+ // Who is asking. The MCP passes the runtime agent id; the CLI passes 'cli'.
52
+ // Named resolveAgentId so recordTaskOutcome can still use `agentId` as a local.
53
+ const resolveAgentId = options.agentId || (() => 'mcp')
54
+
55
+ const {
56
+ normalizeScope,
57
+ isGlobalRecord,
58
+ matchesProjectScope,
59
+ scoreRecord,
60
+ loadEvidenceWeights,
61
+ } = memoryStore
62
+
63
+ const memoryPath = path.join(dataDir, 'memory.jsonl')
64
+ const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
65
+ const tracePath = path.join(dataDir, 'traces.jsonl')
66
+ const agentsPath = path.join(dataDir, 'agents.jsonl')
67
+
68
+ function readAgentHealth() {
69
+ const file = path.join(dataDir, 'digest', 'latest.json')
70
+ if (!fs.existsSync(file)) return null
71
+ try {
72
+ const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
73
+ const ts = Date.parse(snapshot.generatedAt || '')
74
+ const generatedAt = Number.isFinite(ts) ? snapshot.generatedAt : null
75
+ if (!Number.isFinite(ts) || Date.now() - ts > AGENT_HEALTH_FRESH_MS) {
76
+ return { stale: true, generatedAt, best: null, degraded: [], agents: [] }
77
+ }
78
+ const agents = Array.isArray(snapshot.agents) ? snapshot.agents : []
79
+ const reliable = agents.filter((a) => Number(a.total) >= 5)
80
+ const best =
81
+ reliable.length > 0
82
+ ? reliable.reduce((a, b) => (b.successRate > a.successRate ? b : a))
83
+ : null
84
+ const degraded = reliable
85
+ .filter((a) => a.successRate < 50)
86
+ .map((a) => ({ agent: a.agent, successRate: a.successRate, total: a.total }))
87
+ return {
88
+ stale: false,
89
+ generatedAt,
90
+ periodDays: snapshot.periodDays || null,
91
+ best: best ? { agent: best.agent, successRate: best.successRate, total: best.total } : null,
92
+ degraded,
93
+ agents: agents.map((a) => ({ agent: a.agent, total: a.total, successRate: a.successRate })),
94
+ }
95
+ } catch (_) {
96
+ return null
97
+ }
98
+ }
99
+
100
+ // queries.jsonl is owned by the shared query log (see ../query-log.js). The
101
+ // MCP server and the CLI hand in the same instance the memory store uses, so
102
+ // there is exactly one reader/writer for this file.
103
+ const queryLog = options.queryLog || createQueryLog({ dataDir })
104
+ const loadRecentQueries = () => queryLog.load()
105
+ const persistRecentQueries = (entries) => queryLog.persist(entries)
106
+ const REUSE_MATCH_WINDOW_MS = queryLog.windowMs
107
+
108
+ function routeTask(args = {}) {
109
+ const task = String(args.task || '').trim()
110
+ if (!task) throw new Error('task.route requires a non-empty task')
111
+ const project = args.project || projectName()
112
+ const scope = normalizeScope(args.scope)
113
+ const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10)
114
+
115
+ const memories = readJsonlCached(memoryPath).filter((record) => record.archived !== true)
116
+ const memoryById = new Map(memories.map((record) => [record.id, record]))
117
+ const evidence = loadEvidenceWeights()
118
+
119
+ // Match verified reuse experiences whose source memory is relevant to the task.
120
+ //
121
+ // ⚠️ The `verified` predicate (experience-store.js) is
122
+ // `reuse && behaviorChanged && outcomeImproved` -- note it does NOT include
123
+ // `confirmed`. It has a failure mode that looks exactly like "no match": an
124
+ // auto-claim is written with behaviorChanged=false (task-store.js
125
+ // buildAutoClaim), and nothing flips it to true except
126
+ // `mio.experience.confirm` (experience-store.js:146). So if nothing was ever
127
+ // confirmed, verified is empty and routing returns nothing for every task,
128
+ // forever. Measured on the real dataset 2026-09-20: 21 reuse records,
129
+ // behaviorChanged=false in all of them, verified == 0, and `task.route`
130
+ // could not return a single hit on a task that plainly had matching
131
+ // experience. Confirming three records made the same route return 1. So
132
+ // `count` is not a routing-quality signal until confirmations exist --
133
+ // `gatedBy` below reports that distinction.
134
+ const reuseRecords = readJsonlCached(experienceReusePath)
135
+ const routeByMemory = new Map()
136
+ for (const record of reuseRecords) {
137
+ if (!REUSE_STATUS_FILTERS.verified(record)) continue
138
+ const memory = memoryById.get(record.experienceId)
139
+ if (!memory || !matchesProjectScope(memory, project, scope)) continue
140
+ const score = scoreRecord(memory, task, project)
141
+ if (score <= 0) continue
142
+ let entry = routeByMemory.get(record.experienceId)
143
+ if (!entry) {
144
+ entry = {
145
+ experienceId: record.experienceId,
146
+ memory,
147
+ weight: 0,
148
+ confirmed: false,
149
+ sourceAgents: new Set(),
150
+ targetAgents: new Set(),
151
+ reuseCount: 0,
152
+ }
153
+ routeByMemory.set(record.experienceId, entry)
154
+ }
155
+ entry.reuseCount += 1
156
+ if (record.confirmed === true) entry.confirmed = true
157
+ if (record.sourceAgent) entry.sourceAgents.add(record.sourceAgent)
158
+ if (record.targetAgent) entry.targetAgents.add(record.targetAgent)
159
+ entry.weight = Math.max(entry.weight, score + (entry.confirmed ? 2 : 1.5))
160
+ }
161
+
162
+ const routes = [...routeByMemory.values()]
163
+ .sort((a, b) => b.weight - a.weight)
164
+ .slice(0, limit)
165
+ .map((entry) => ({
166
+ experienceId: entry.experienceId,
167
+ reuseCount: entry.reuseCount,
168
+ confirmed: entry.confirmed,
169
+ sourceAgents: [...entry.sourceAgents].filter(Boolean),
170
+ targetAgents: [...entry.targetAgents].filter(Boolean),
171
+ score: Number(entry.weight.toFixed(2)),
172
+ memory: {
173
+ id: entry.memory.id,
174
+ timestamp: entry.memory.timestamp,
175
+ kind: entry.memory.kind,
176
+ content: entry.memory.content,
177
+ tags: entry.memory.tags || [],
178
+ project: entry.memory.project || null,
179
+ scope: entry.memory.scope || 'project',
180
+ },
181
+ }))
182
+
183
+ // Why routing came back empty, when it did. `no-match` means the strict
184
+ // filter was satisfied by nothing relevant; `unconfirmed` means relevant
185
+ // experience exists but was never confirmed, so the filter excluded it. The
186
+ // two look identical in `count` but need opposite responses -- the first is
187
+ // a recall problem, the second is a one-command fix.
188
+ //
189
+ // "Relevant" here deliberately does NOT reuse scoreRecord: that scorer's
190
+ // prefix rule is symmetric (`token.startsWith(item) || item.startsWith(token)`),
191
+ // so a query token merely starting with any short haystack token counts --
192
+ // "nonsense" matches a record containing "no read path". Fine for fuzzy
193
+ // recall, useless as a diagnostic: measured on the real dataset, a nonsense
194
+ // task scored 10 unrelated records as "relevant". Require a real shared
195
+ // token (or CJK bigram) instead, so the count only moves for genuine overlap.
196
+ let gatedBy = null
197
+ if (routes.length === 0) {
198
+ const queryTokens = new Set(tokenizeForOverlap(task))
199
+ const bigrams = cjkBigramsForOverlap(task)
200
+ const overlaps = (memory) => {
201
+ const content = `${memory.content || ''} ${(memory.tags || []).join(' ')} ${memory.kind || ''}`
202
+ if (tokenizeForOverlap(content).some((token) => queryTokens.has(token))) return true
203
+ if (bigrams.size === 0) return false
204
+ const haystack = cjkBigramsForOverlap(content)
205
+ for (const bigram of bigrams) if (haystack.has(bigram)) return true
206
+ return false
207
+ }
208
+ const relevantUnconfirmed = reuseRecords.filter((record) => {
209
+ if (!REUSE_STATUS_FILTERS.pending(record)) return false
210
+ const memory = memoryById.get(record.experienceId)
211
+ if (!memory || !matchesProjectScope(memory, project, scope)) return false
212
+ return overlaps(memory)
213
+ }).length
214
+ const totalVerified = reuseRecords.filter(REUSE_STATUS_FILTERS.verified).length
215
+ gatedBy = {
216
+ reason: relevantUnconfirmed > 0 ? 'unconfirmed' : 'no-match',
217
+ verifiedTotal: totalVerified,
218
+ relevantUnconfirmed,
219
+ }
220
+ }
221
+
222
+ // Broader context: top related memories not already routed.
223
+ const routedIds = new Set(routes.map((route) => route.experienceId))
224
+ const scoreRelated = (record) =>
225
+ scoreRecord(record, task, project, evidence) +
226
+ (scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
227
+ // Same shape as memory-store.queryMemory: score once, then sort the scored
228
+ // pairs. Scoring inside the comparator re-tokenized every candidate on every
229
+ // comparison, which is what made mio.task.route the slowest tool call
230
+ // (~100 ms median on a 1094-record store).
231
+ const scoredRelated = []
232
+ for (const record of memories) {
233
+ if (routedIds.has(record.id)) continue
234
+ if (!matchesProjectScope(record, project, scope)) continue
235
+ const score = scoreRelated(record)
236
+ if (score > 0) scoredRelated.push({ record, score })
237
+ }
238
+ const relatedMemories = scoredRelated
239
+ .sort((a, b) => b.score - a.score)
240
+ .slice(0, Math.max(limit, 5))
241
+ .map(({ record }) => {
242
+ const ev = evidence.get(record.id)
243
+ return {
244
+ id: record.id,
245
+ timestamp: record.timestamp,
246
+ kind: record.kind,
247
+ content: record.content,
248
+ tags: record.tags || [],
249
+ project: record.project || null,
250
+ scope: record.scope || 'project',
251
+ evidence: ev
252
+ ? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount }
253
+ : null,
254
+ }
255
+ })
256
+
257
+ // Feed the query log so a later task_outcome can auto-claim route-driven
258
+ // reuse. Skipped when persistQuery is false (the CLI, which is read-only).
259
+ if (args.persistQuery !== false && (routes.length > 0 || relatedMemories.length > 0)) {
260
+ const now = Date.now()
261
+ const entries = loadRecentQueries()
262
+ entries.push({
263
+ agent: resolveAgentId() || 'mcp',
264
+ project,
265
+ query: task,
266
+ resultIds: [...routedIds, ...relatedMemories.map((record) => record.id)].filter(Boolean),
267
+ resultSources: [
268
+ ...routes.map(() => 'experience'),
269
+ ...relatedMemories.map((record) => record.source || null),
270
+ ],
271
+ timestamp: now,
272
+ expiresAt: now + REUSE_MATCH_WINDOW_MS,
273
+ })
274
+ persistRecentQueries(entries)
275
+ }
276
+
277
+ let suggestion
278
+ if (routes.length > 0) {
279
+ suggestion =
280
+ `Route ${routes.length} verified experience(s) to this task; apply the top match first and record the outcome with mio.observer.ingest / mio.experience.reuse.`
281
+ } else if (gatedBy && gatedBy.reason === 'unconfirmed') {
282
+ // Distinct from "no match": the experience is there and relevant, it just
283
+ // was never confirmed. `verified` requires behaviorChanged, which only
284
+ // `experience.confirm` sets on an auto-claim, so an unconfirmed backlog
285
+ // silently empties the routing set. Point at the fix, not at recall.
286
+ suggestion =
287
+ `${gatedBy.relevantUnconfirmed} relevant experience(s) match this task but are unconfirmed, so routing skipped them (only verified experience routes, and confirming is what marks behaviorChanged). Run "mio status" to see the backlog, then "mio experience confirm --ids <id,...>".`
288
+ } else if (relatedMemories.length > 0) {
289
+ suggestion =
290
+ 'No verified experience matches yet. Consult the related memories, then record the outcome so this task can seed future routes.'
291
+ } else {
292
+ suggestion =
293
+ 'No matching experience or memory found. Proceed with care and record the outcome to grow the experience base.'
294
+ }
295
+
296
+ // Agent-health routing signal from the latest digest snapshot (value loop:
297
+ // collected task outcomes influence who should execute the next task).
298
+ const agentHealth = readAgentHealth()
299
+ let routingSignal = null
300
+ if (agentHealth && !agentHealth.stale && agentHealth.best) {
301
+ const parts = []
302
+ if (agentHealth.best.successRate >= 80) {
303
+ parts.push(
304
+ `prefer ${agentHealth.best.agent} (${agentHealth.best.successRate}% success over ${agentHealth.best.total} tasks)`,
305
+ )
306
+ }
307
+ for (const bad of agentHealth.degraded) {
308
+ parts.push(`avoid ${bad.agent} (${bad.successRate}% success over ${bad.total} tasks)`)
309
+ }
310
+ if (parts.length > 0) {
311
+ routingSignal = `Agent health (last ${agentHealth.periodDays || '?'}d): ${parts.join('; ')}.`
312
+ suggestion = `${suggestion} ${routingSignal}`
313
+ }
314
+ }
315
+
316
+ return {
317
+ task,
318
+ project,
319
+ scope,
320
+ count: routes.length,
321
+ routes,
322
+ relatedMemories,
323
+ agentHealth,
324
+ gatedBy,
325
+ summary: {
326
+ verifiedRoutes: routes.length,
327
+ relatedMemories: relatedMemories.length,
328
+ routingSignal,
329
+ suggestion,
330
+ },
331
+ }
332
+ }
333
+
334
+ function buildAutoClaim(entry, event, targetAgent) {
335
+ const crossSourceIndex = entry.resultSources.findIndex(
336
+ (source) => source && String(source).toLowerCase() !== targetAgent.toLowerCase()
337
+ )
338
+ const sourceAgent =
339
+ crossSourceIndex >= 0 ? String(entry.resultSources[crossSourceIndex]) : targetAgent
340
+ const experienceId =
341
+ entry.resultIds[crossSourceIndex >= 0 ? crossSourceIndex : 0] ||
342
+ entry.resultIds[0] ||
343
+ 'unknown'
344
+ const outcomeImproved = String(event.outcome || '').toLowerCase() === 'success'
345
+ return {
346
+ id: createId('xfer'),
347
+ timestamp: new Date().toISOString(),
348
+ sourceAgent,
349
+ targetAgent,
350
+ experienceId,
351
+ reuse: true,
352
+ behaviorChanged: false,
353
+ outcomeImproved,
354
+ project: entry.project,
355
+ source: 'auto_claim',
356
+ traceId: event.trace_id || null,
357
+ notes: `Auto-claimed: memory.query "${entry.query}" matched task_outcome ${event.outcome || 'unknown'} in trace ${event.trace_id || 'unknown'}.`,
358
+ }
359
+ }
360
+
361
+ // Phase 0 auto-claim: a task_outcome arriving within the match window of a
362
+ // prior query is attributed to that query's results, so reuse evidence does
363
+ // not depend on the agent self-reporting mio.experience.reuse. Shared with
364
+ // observer.ingest, which is why it lives in the store rather than the MCP
365
+ // handler.
366
+ function autoClaimExperienceReuse(event) {
367
+ if (String(event.event_type || '').toLowerCase() !== 'task_outcome') return []
368
+ const targetAgent = String(event.agent || '').trim()
369
+ if (!targetAgent) return []
370
+ const now = Date.now()
371
+ const claims = []
372
+ const remaining = []
373
+ const entries = loadRecentQueries()
374
+ for (const entry of entries) {
375
+ const expired = now - entry.timestamp > REUSE_MATCH_WINDOW_MS
376
+ const matches =
377
+ entry.agent === targetAgent && (!event.project || entry.project === event.project)
378
+ if (expired || matches) {
379
+ if (matches && !expired) {
380
+ claims.push(buildAutoClaim(entry, event, targetAgent))
381
+ }
382
+ continue
383
+ }
384
+ remaining.push(entry)
385
+ }
386
+ persistRecentQueries(remaining)
387
+ for (const evidence of claims) {
388
+ appendJsonl(experienceReusePath, evidence)
389
+ }
390
+ return claims
391
+ }
392
+
393
+ function recordTaskOutcome(args = {}) {
394
+ const outcome = String(args.outcome || '').trim().toLowerCase()
395
+ if (!['success', 'failure', 'aborted'].includes(outcome)) {
396
+ throw new Error('task.record_outcome requires outcome to be success, failure, or aborted')
397
+ }
398
+ const agentId = String(args.agentId || resolveAgentId() || 'unknown').trim()
399
+ const project = args.project || projectName()
400
+ const task = String(args.task || '').trim()
401
+ const summary = String(args.summary || '').trim()
402
+ const verification = String(args.verification || '').trim()
403
+ const traceId = String(args.traceId || '').trim() || createId('trace:' + agentId + ':' + project)
404
+
405
+ // 1. Record trace event
406
+ const event = {
407
+ id: createId('trace'),
408
+ timestamp: new Date().toISOString(),
409
+ trace_id: traceId,
410
+ event_type: 'task_outcome',
411
+ outcome,
412
+ payload: { task, summary, verification },
413
+ agent: agentId,
414
+ host: 'mcp',
415
+ project,
416
+ }
417
+ appendJsonl(tracePath, event)
418
+
419
+ // 2. Auto-claim experience reuse
420
+ const autoClaims = autoClaimExperienceReuse(event)
421
+
422
+ // 3. Update agent registry
423
+ const now = new Date().toISOString()
424
+ const agents = readJsonlCached(agentsPath)
425
+ const agent = agents.find((a) => a.agentId === agentId && a.project === project)
426
+ if (agent) {
427
+ agent.lastSeenAt = now
428
+ agent.taskCount = (agent.taskCount || 0) + 1
429
+ agent.successCount = (agent.successCount || 0) + (outcome === 'success' ? 1 : 0)
430
+ agent.failureCount = (agent.failureCount || 0) + (outcome === 'failure' ? 1 : 0)
431
+ writeJsonl(agentsPath, agents)
432
+ }
433
+
434
+ return {
435
+ recorded: true,
436
+ event: { id: event.id, trace_id: traceId, outcome, agent: agentId, project },
437
+ agentUpdated: !!agent,
438
+ autoClaims: autoClaims.length > 0 ? autoClaims : undefined,
439
+ }
440
+ }
441
+
442
+ function ingestObservation(args = {}) {
443
+ const traceId = String(args.trace_id || '').trim()
444
+ const eventType = String(args.event_type || '').trim()
445
+ if (!traceId) throw new Error('observer.ingest requires trace_id')
446
+ if (!eventType) throw new Error('observer.ingest requires event_type')
447
+ const event = {
448
+ id: createId('trace'),
449
+ timestamp: new Date().toISOString(),
450
+ trace_id: traceId,
451
+ event_type: eventType,
452
+ outcome: args.outcome || null,
453
+ payload: args.payload || {},
454
+ agent: args.agent || resolveAgentId(),
455
+ host: args.host || 'mcp',
456
+ project: args.project || projectName(),
457
+ }
458
+ appendJsonl(tracePath, event)
459
+ const autoClaims = autoClaimExperienceReuse(event)
460
+ if (autoClaims.length > 0) {
461
+ return { recorded: true, event, autoClaims }
462
+ }
463
+ return { recorded: true, event }
464
+ }
465
+
466
+ return {
467
+ routeTask,
468
+ readAgentHealth,
469
+ autoClaimExperienceReuse,
470
+ recordTaskOutcome,
471
+ ingestObservation,
472
+ }
473
+ }
474
+
475
+ module.exports = { createTaskStore }