mio-agent-runtime 0.13.4 → 0.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/adapters/codex.js +121 -121
- package/adapters/hermes.js +182 -176
- package/adapters/opencode.js +203 -202
- package/adapters/workbuddy.js +197 -197
- package/bin/mio.js +93 -12
- package/observe/observer.js +1876 -1867
- package/package.json +3 -3
- package/server/creativity-engine.js +238 -67
- package/server/evolution-cutover.js +5 -2
- package/server/insight-store.js +37 -11
- package/server/llm-client.js +29 -8
- package/server/mio-intelligence-mcp/README.md +173 -173
- package/server/mio-intelligence-mcp/index.js +27 -6
- package/server/mio-intelligence-mcp/package.json +17 -17
- package/server/mio-intelligence-mcp/phase0.js +365 -365
- package/server/observer-store.js +12 -11
- package/server/task-store.js +475 -475
package/server/task-store.js
CHANGED
|
@@ -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 }
|