mio-agent-runtime 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -13
- package/adapters/hermes.js +59 -5
- package/adapters/host-utils.js +66 -61
- package/bin/mio.js +3279 -2943
- package/observe/observer.js +82 -12
- package/package.json +2 -2
- package/scripts/check-syntax.cjs +16 -2
- package/server/agent-store.js +7 -7
- package/server/digest.js +3 -18
- package/server/evaluation-store.js +244 -0
- package/server/evolution-report.js +5 -21
- package/server/experience-store.js +3 -3
- package/server/memory-store.js +119 -23
- package/server/mio-intelligence-mcp/index.js +37 -0
- package/server/mio-intelligence-mcp/phase0.js +4 -20
- package/server/observer-store.js +104 -2
- package/server/policy-store.js +3 -3
- package/server/query-log.js +3 -9
- package/server/retention.js +1 -9
- package/server/subscription-store.js +62 -8
- package/server/task-store.js +100 -10
|
@@ -9,10 +9,56 @@
|
|
|
9
9
|
// would mean one store with two different notions of "where the data lives".
|
|
10
10
|
//
|
|
11
11
|
// The digest is cursor-based: digest_state.json records, per subscription, the
|
|
12
|
-
//
|
|
12
|
+
// position of the newest event already delivered. Running it again therefore
|
|
13
13
|
// returns only what is new -- which is the point, but also why a digest is not
|
|
14
14
|
// idempotent and the cursor must be written even when nothing matched (otherwise
|
|
15
15
|
// a subscription created just now would replay the whole trace history).
|
|
16
|
+
//
|
|
17
|
+
// A cursor is `{ time, id }` and is an *exclusive* bound: an event is delivered
|
|
18
|
+
// iff it sorts strictly after it, with `id` breaking ties inside a millisecond.
|
|
19
|
+
// The tie-break is not cosmetic. Both timestamps in play come from the same wall
|
|
20
|
+
// clock -- task-store.js stamps the event, digest() stamps the cursor -- and the
|
|
21
|
+
// gap between them is one small writeFileSync, so comparing time alone silently
|
|
22
|
+
// swallowed every event that shared a millisecond with the cursor, permanently,
|
|
23
|
+
// because the cursor only moves forward. Measured 2026-09-22: that gap is ~2ms
|
|
24
|
+
// on a Defender-scanned Windows box but sub-millisecond on a CI runner, which is
|
|
25
|
+
// why `digest respects topic filter` passed 30/30 locally and flaked about half
|
|
26
|
+
// the time on CI. See __tests__/observer-digest-cursor.test.js.
|
|
27
|
+
//
|
|
28
|
+
// The `id` field has three states, and the difference matters:
|
|
29
|
+
// '' a wall-clock cursor: digest() found nothing to deliver, so nothing at
|
|
30
|
+
// that instant has been consumed. The empty string sorts below every
|
|
31
|
+
// real id, so same-millisecond events are still delivered.
|
|
32
|
+
// null a legacy cursor, written before the tie-break existed as a bare ISO
|
|
33
|
+
// string. `null` sorts above every real id, reproducing the old
|
|
34
|
+
// inclusive time comparison exactly -- so an upgrade never re-delivers.
|
|
35
|
+
// '<id>' an event cursor: the event with that id has been delivered.
|
|
36
|
+
const WALL_CURSOR_ID = ''
|
|
37
|
+
|
|
38
|
+
function readCursor(raw) {
|
|
39
|
+
if (typeof raw === 'string') {
|
|
40
|
+
const time = Date.parse(raw)
|
|
41
|
+
return Number.isFinite(time) ? { time, id: null } : null
|
|
42
|
+
}
|
|
43
|
+
if (raw && typeof raw === 'object' && typeof raw.time === 'string') {
|
|
44
|
+
const time = Date.parse(raw.time)
|
|
45
|
+
if (!Number.isFinite(time)) return null
|
|
46
|
+
return { time, id: typeof raw.id === 'string' ? raw.id : null }
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Exclusive: true when `event` sorts strictly after `cursor`.
|
|
52
|
+
function isNewEvent(eventTime, eventId, cursor) {
|
|
53
|
+
if (!cursor || cursor.time === null || eventTime === null) return true
|
|
54
|
+
if (eventTime !== cursor.time) return eventTime > cursor.time
|
|
55
|
+
// Same millisecond -- fall back to the id. Ids are `trace_<ms>_<hex>`, so the
|
|
56
|
+
// order inside a millisecond is by the random suffix: arbitrary, but stable,
|
|
57
|
+
// which is all "do not deliver twice" needs.
|
|
58
|
+
if (cursor.id === null) return false
|
|
59
|
+
if (typeof eventId !== 'string' || eventId === '') return true
|
|
60
|
+
return eventId > cursor.id
|
|
61
|
+
}
|
|
16
62
|
|
|
17
63
|
const fs = require('fs')
|
|
18
64
|
const path = require('path')
|
|
@@ -148,10 +194,10 @@ function createSubscriptionStore(options = {}) {
|
|
|
148
194
|
const matchedSubscriptionIds = new Set()
|
|
149
195
|
|
|
150
196
|
for (const subscription of subscriptions) {
|
|
151
|
-
const cursor = state[subscription.id]
|
|
152
|
-
const cursorTime = cursor ? Date.parse(cursor) : null
|
|
197
|
+
const cursor = readCursor(state[subscription.id])
|
|
153
198
|
const subscriptionEventTypes = subscription.eventTypes || []
|
|
154
199
|
let lastEventTime = null
|
|
200
|
+
let lastEventId = WALL_CURSOR_ID
|
|
155
201
|
for (const event of traces) {
|
|
156
202
|
if (subscription.project && event.project && event.project !== subscription.project) {
|
|
157
203
|
continue
|
|
@@ -168,9 +214,17 @@ function createSubscriptionStore(options = {}) {
|
|
|
168
214
|
if (!haystack.includes(subscription.topic.toLowerCase())) continue
|
|
169
215
|
}
|
|
170
216
|
const eventTime = event.timestamp ? Date.parse(event.timestamp) : null
|
|
171
|
-
if (
|
|
172
|
-
if (eventTime !== null
|
|
173
|
-
lastEventTime
|
|
217
|
+
if (!isNewEvent(eventTime, event.id, cursor)) continue
|
|
218
|
+
if (eventTime !== null) {
|
|
219
|
+
if (lastEventTime === null || eventTime > lastEventTime) {
|
|
220
|
+
lastEventTime = eventTime
|
|
221
|
+
lastEventId = typeof event.id === 'string' ? event.id : WALL_CURSOR_ID
|
|
222
|
+
} else if (eventTime === lastEventTime && typeof event.id === 'string' && event.id > lastEventId) {
|
|
223
|
+
// Several delivered events share the newest millisecond. The cursor
|
|
224
|
+
// has to sit on the highest id among them, otherwise the next digest
|
|
225
|
+
// would hand the lower ones out again.
|
|
226
|
+
lastEventId = event.id
|
|
227
|
+
}
|
|
174
228
|
}
|
|
175
229
|
if (seenIds.has(event.id)) continue
|
|
176
230
|
seenIds.add(event.id)
|
|
@@ -189,9 +243,9 @@ function createSubscriptionStore(options = {}) {
|
|
|
189
243
|
// Advance the cursor even when nothing matched: a brand-new subscription
|
|
190
244
|
// has no cursor, and without this it would replay the entire history.
|
|
191
245
|
if (lastEventTime !== null) {
|
|
192
|
-
state[subscription.id] = new Date(lastEventTime).toISOString()
|
|
246
|
+
state[subscription.id] = { time: new Date(lastEventTime).toISOString(), id: lastEventId }
|
|
193
247
|
} else if (cursor === null) {
|
|
194
|
-
state[subscription.id] = digestTime
|
|
248
|
+
state[subscription.id] = { time: digestTime, id: WALL_CURSOR_ID }
|
|
195
249
|
}
|
|
196
250
|
if (events.length >= limit) break
|
|
197
251
|
}
|
package/server/task-store.js
CHANGED
|
@@ -18,12 +18,32 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require('fs')
|
|
20
20
|
const path = require('path')
|
|
21
|
-
const { createId,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 =
|
|
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
|