mio-agent-runtime 0.11.0 → 0.12.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.
@@ -18,12 +18,32 @@
18
18
 
19
19
  const fs = require('fs')
20
20
  const path = require('path')
21
- const { createId, readJsonl, appendJsonl, writeJsonl } = require('./memory-store.js')
21
+ const { createId, readJsonlCached, appendJsonl, writeJsonl } = require('./memory-store.js')
22
22
  const { REUSE_STATUS_FILTERS } = require('./experience-store.js')
23
23
  const { createQueryLog } = require('./query-log.js')
24
24
 
25
25
  const AGENT_HEALTH_FRESH_MS = 48 * 3600000
26
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
+
27
47
  function createTaskStore(options) {
28
48
  const dataDir = options.dataDir
29
49
  const projectName = options.projectName || (() => null)
@@ -92,13 +112,28 @@ function createTaskStore(options) {
92
112
  const scope = normalizeScope(args.scope)
93
113
  const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10)
94
114
 
95
- const memories = readJsonl(memoryPath).filter((record) => record.archived !== true)
115
+ const memories = readJsonlCached(memoryPath).filter((record) => record.archived !== true)
96
116
  const memoryById = new Map(memories.map((record) => [record.id, record]))
97
117
  const evidence = loadEvidenceWeights()
98
118
 
99
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)
100
135
  const routeByMemory = new Map()
101
- for (const record of readJsonl(experienceReusePath)) {
136
+ for (const record of reuseRecords) {
102
137
  if (!REUSE_STATUS_FILTERS.verified(record)) continue
103
138
  const memory = memoryById.get(record.experienceId)
104
139
  if (!memory || !matchesProjectScope(memory, project, scope)) continue
@@ -145,18 +180,65 @@ function createTaskStore(options) {
145
180
  },
146
181
  }))
147
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
+
148
222
  // Broader context: top related memories not already routed.
149
223
  const routedIds = new Set(routes.map((route) => route.experienceId))
150
224
  const scoreRelated = (record) =>
151
225
  scoreRecord(record, task, project, evidence) +
152
226
  (scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
153
- const relatedMemories = memories
154
- .filter((record) => !routedIds.has(record.id))
155
- .filter((record) => matchesProjectScope(record, project, scope))
156
- .filter((record) => scoreRelated(record) > 0)
157
- .sort((a, b) => scoreRelated(b) - scoreRelated(a))
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)
158
240
  .slice(0, Math.max(limit, 5))
159
- .map((record) => {
241
+ .map(({ record }) => {
160
242
  const ev = evidence.get(record.id)
161
243
  return {
162
244
  id: record.id,
@@ -196,6 +278,13 @@ function createTaskStore(options) {
196
278
  if (routes.length > 0) {
197
279
  suggestion =
198
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,...>".`
199
288
  } else if (relatedMemories.length > 0) {
200
289
  suggestion =
201
290
  'No verified experience matches yet. Consult the related memories, then record the outcome so this task can seed future routes.'
@@ -232,6 +321,7 @@ function createTaskStore(options) {
232
321
  routes,
233
322
  relatedMemories,
234
323
  agentHealth,
324
+ gatedBy,
235
325
  summary: {
236
326
  verifiedRoutes: routes.length,
237
327
  relatedMemories: relatedMemories.length,
@@ -331,7 +421,7 @@ function createTaskStore(options) {
331
421
 
332
422
  // 3. Update agent registry
333
423
  const now = new Date().toISOString()
334
- const agents = readJsonl(agentsPath)
424
+ const agents = readJsonlCached(agentsPath)
335
425
  const agent = agents.find((a) => a.agentId === agentId && a.project === project)
336
426
  if (agent) {
337
427
  agent.lastSeenAt = now