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
package/observe/observer.js
CHANGED
|
@@ -39,8 +39,19 @@ function cleanText(text) {
|
|
|
39
39
|
function projectFromDirName(dirName) {
|
|
40
40
|
// WorkBuddy project dirs encode the workspace path with dashes, e.g.
|
|
41
41
|
// "d-work-code-douyin_store" -> "douyin_store"
|
|
42
|
+
//
|
|
43
|
+
// Hosts that munge a timestamped workspace path produce a trailing numeric
|
|
44
|
+
// segment, e.g. "c-Users-me-WorkBuddy-2026-08-27-17-37-10" -> "10". Using
|
|
45
|
+
// the seconds field as a project name funnels unrelated sessions into
|
|
46
|
+
// colliding 2-digit buckets that recall never resolves to, so fall back to
|
|
47
|
+
// the whole directory name in that case.
|
|
48
|
+
//
|
|
49
|
+
// ⚠️ The test must be /^\d+$/ (one backslash). An earlier revision wrote
|
|
50
|
+
// /^\\d+$/ -- that matches a literal backslash followed by "d", so it never
|
|
51
|
+
// fires and every numeric segment becomes the project (fixed 2026-09-20).
|
|
52
|
+
// Keep the escaping as-is; do not "tidy" it into a double backslash.
|
|
42
53
|
const parts = String(dirName || '').split('-').filter(Boolean)
|
|
43
|
-
if (parts.length > 1 &&
|
|
54
|
+
if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) {
|
|
44
55
|
return dirName || 'unknown'
|
|
45
56
|
}
|
|
46
57
|
return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
|
|
@@ -689,9 +700,20 @@ function opencodeDbPath() {
|
|
|
689
700
|
return path.join(opencodeDataDir(), 'opencode.db')
|
|
690
701
|
}
|
|
691
702
|
|
|
703
|
+
// Resolved once per observer process: the bin does not move mid-run, and
|
|
704
|
+
// re-running `where`/`which` on every query doubled the subprocess count of
|
|
705
|
+
// every cycle for zero information. Re-resolves only when the cached path
|
|
706
|
+
// vanishes from disk. A null result is deliberately NOT cached so an opencode
|
|
707
|
+
// installed mid-run is still picked up.
|
|
708
|
+
let resolvedOpencodeBin
|
|
709
|
+
|
|
692
710
|
function findOpencodeBin() {
|
|
711
|
+
if (resolvedOpencodeBin && fs.existsSync(resolvedOpencodeBin)) {
|
|
712
|
+
return resolvedOpencodeBin
|
|
713
|
+
}
|
|
693
714
|
if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
|
|
694
|
-
|
|
715
|
+
resolvedOpencodeBin = process.env.OPENCODE_BIN
|
|
716
|
+
return resolvedOpencodeBin
|
|
695
717
|
}
|
|
696
718
|
const exeDir = path.dirname(process.execPath)
|
|
697
719
|
const candidates = [
|
|
@@ -708,18 +730,51 @@ function findOpencodeBin() {
|
|
|
708
730
|
// stray files (e.g. "WHERE p.rowid > 0" -> a file named "0").
|
|
709
731
|
try {
|
|
710
732
|
const finder = process.platform === 'win32' ? 'where' : 'which'
|
|
711
|
-
|
|
733
|
+
// stdio[0] must not be a pipe: a piped stdin makes spawnSync fail with
|
|
734
|
+
// EBUSY before the child starts (status null, empty stderr), which would
|
|
735
|
+
// read as "opencode is not on PATH". `where` reads no stdin.
|
|
736
|
+
const res = spawnSync(finder, ['opencode'], {
|
|
737
|
+
encoding: 'utf8',
|
|
738
|
+
windowsHide: true,
|
|
739
|
+
timeout: 15000,
|
|
740
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
741
|
+
})
|
|
712
742
|
if (res.status === 0 && res.stdout) {
|
|
713
743
|
const lines = res.stdout
|
|
714
744
|
.split(/\r?\n/)
|
|
715
745
|
.map((s) => s.trim())
|
|
716
746
|
.filter(Boolean)
|
|
747
|
+
// ⚠️ Known dead end on Windows, recorded by decision rather than "fixed"
|
|
748
|
+
// (measured 2026-09-24). This filter prefers .cmd/.bat, but
|
|
749
|
+
// runOpencodeQuery() spawns the result WITHOUT shell:true, and Node
|
|
750
|
+
// refuses to spawn a .cmd/.bat without a shell:
|
|
751
|
+
//
|
|
752
|
+
// spawn opencode.cmd no shell status=null error=EINVAL
|
|
753
|
+
// spawn opencode.cmd shell:true status=0 stdout="1.15.12"
|
|
754
|
+
//
|
|
755
|
+
// So when the only thing on PATH is an npm shim (e.g. `opencode.cmd`),
|
|
756
|
+
// findOpencodeBin() returns a path that can never be executed, every
|
|
757
|
+
// query below returns null, and the opencode observer silently ingests
|
|
758
|
+
// nothing. On a machine where `where opencode` yields only `opencode`
|
|
759
|
+
// and `opencode.cmd`, neither is spawnable without a shell.
|
|
760
|
+
//
|
|
761
|
+
// Adding shell:true is NOT the fix: cmd.exe would then parse '>' in the
|
|
762
|
+
// SQL as output redirection, which is precisely what the comment above
|
|
763
|
+
// this block exists to prevent. The two constraints really do conflict.
|
|
764
|
+
//
|
|
765
|
+
// Net behaviour is identical either way (null before and after), so it is
|
|
766
|
+
// left alone. The bundled opencode-ai .exe path above is unaffected and
|
|
767
|
+
// does work. If you change this, write the test that proves the payload
|
|
768
|
+
// survives -- not just that status === 0.
|
|
717
769
|
const preferred =
|
|
718
770
|
process.platform === 'win32'
|
|
719
771
|
? lines.filter((p) => /\.(exe|cmd|bat)$/i.test(p))
|
|
720
772
|
: lines
|
|
721
773
|
const first = (preferred.length > 0 ? preferred : lines).find((p) => fs.existsSync(p))
|
|
722
|
-
if (first)
|
|
774
|
+
if (first) {
|
|
775
|
+
resolvedOpencodeBin = first
|
|
776
|
+
return first
|
|
777
|
+
}
|
|
723
778
|
}
|
|
724
779
|
} catch (_) {}
|
|
725
780
|
return null
|
|
@@ -733,7 +788,15 @@ function runOpencodeQuery(sql) {
|
|
|
733
788
|
const args = ['db', sql, '--format', 'json']
|
|
734
789
|
let res
|
|
735
790
|
try {
|
|
736
|
-
|
|
791
|
+
// stdio[0] must not be a pipe: see findOpencodeBin above. `opencode db`
|
|
792
|
+
// takes its SQL as an argument and reads no stdin.
|
|
793
|
+
res = spawnSync(bin, args, {
|
|
794
|
+
encoding: 'utf8',
|
|
795
|
+
timeout: 90000,
|
|
796
|
+
windowsHide: true,
|
|
797
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
798
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
799
|
+
})
|
|
737
800
|
} catch (_) {
|
|
738
801
|
return null
|
|
739
802
|
}
|
|
@@ -938,16 +1001,20 @@ function runOpencodeCycle(home, state, sinks, opts) {
|
|
|
938
1001
|
const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
|
|
939
1002
|
|
|
940
1003
|
// First run: seed the rowid cursor to the first part written within the
|
|
941
|
-
// backfill window so ancient history never floods MIO_HOME.
|
|
1004
|
+
// backfill window so ancient history never floods MIO_HOME. One round trip:
|
|
1005
|
+
// the scalar subselects return NULL for first_rid when nothing was written
|
|
1006
|
+
// inside the window, which maps to the same "fall back to MAX(rowid)" branch
|
|
1007
|
+
// the previous two-query version had.
|
|
942
1008
|
if (!state.ocSeeded) {
|
|
943
1009
|
const ts = Date.now() - maxAgeMs
|
|
944
|
-
const
|
|
1010
|
+
const seed = runOpencodeQuery(
|
|
1011
|
+
'SELECT (SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1) AS first_rid, ' +
|
|
1012
|
+
'(SELECT COALESCE(MAX(rowid), 0) FROM part) AS max_rid'
|
|
1013
|
+
)
|
|
945
1014
|
let rid = 0
|
|
946
|
-
if (Array.isArray(
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
const maxRow = runOpencodeQuery('SELECT COALESCE(MAX(rowid), 0) AS rid FROM part')
|
|
950
|
-
if (Array.isArray(maxRow) && maxRow.length > 0) rid = Number(maxRow[0].rid) || 0
|
|
1015
|
+
if (Array.isArray(seed) && seed.length > 0) {
|
|
1016
|
+
const firstRid = Number(seed[0].first_rid)
|
|
1017
|
+
rid = Number.isFinite(firstRid) && firstRid > 0 ? firstRid : Number(seed[0].max_rid) || 0
|
|
951
1018
|
}
|
|
952
1019
|
state.ocLastRowid = rid
|
|
953
1020
|
state.ocSeeded = true
|
|
@@ -1703,4 +1770,7 @@ module.exports = {
|
|
|
1703
1770
|
stopBackground,
|
|
1704
1771
|
isRunning,
|
|
1705
1772
|
readPid,
|
|
1773
|
+
// exported for tests: the numeric-segment guard here was silently dead for
|
|
1774
|
+
// several releases because nothing exercised it (see __tests__/observer-project.test.js)
|
|
1775
|
+
projectFromDirName,
|
|
1706
1776
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mio-agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Mio Agent Runtime installer and MCP intelligence layer",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@akemi-mio/evolution-strategy": "^0.1.0",
|
|
34
34
|
"@akemi-mio/evolution-safety": "^0.1.0",
|
|
35
35
|
"@akemi-mio/evolution-scheduler": "^0.1.0",
|
|
36
|
-
"@akemi-mio/observer": "^0.1.
|
|
36
|
+
"@akemi-mio/observer": "^0.1.1",
|
|
37
37
|
"@akemi-mio/insight": "^0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"main": "index.js",
|
package/scripts/check-syntax.cjs
CHANGED
|
@@ -52,8 +52,22 @@ if (files.length === 0) {
|
|
|
52
52
|
|
|
53
53
|
const failures = []
|
|
54
54
|
for (const file of files) {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// stdio[0] must not be a pipe. On Windows a piped stdin makes spawnSync fail
|
|
56
|
+
// with EBUSY before the child starts: status is null and stderr is empty, so
|
|
57
|
+
// every file reads as a syntax error and this gate is red 100% of the time.
|
|
58
|
+
// `node --check` never reads stdin, so 'ignore' costs nothing.
|
|
59
|
+
const result = spawnSync(process.execPath, ['--check', file], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
})
|
|
63
|
+
// "the checker did not run" and "the file does not parse" must stay separate:
|
|
64
|
+
// collapsing them is how a dead gate gets mistaken for a broken codebase.
|
|
65
|
+
if (result.error) {
|
|
66
|
+
failures.push({
|
|
67
|
+
file,
|
|
68
|
+
message: `could not run the syntax checker: ${result.error.code || result.error.message}`,
|
|
69
|
+
})
|
|
70
|
+
} else if (result.status !== 0) {
|
|
57
71
|
failures.push({ file, message: (result.stderr || '').trim().split('\n')[0] })
|
|
58
72
|
}
|
|
59
73
|
}
|
package/server/agent-store.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// agents.jsonl. `mio agents list` / `report` are about the latter.
|
|
14
14
|
|
|
15
15
|
const path = require('path')
|
|
16
|
-
const { createId,
|
|
16
|
+
const { createId, readJsonlCached, appendJsonl, writeJsonl } = require('./memory-store.js')
|
|
17
17
|
|
|
18
18
|
function createAgentStore(options) {
|
|
19
19
|
const dataDir = options.dataDir
|
|
@@ -26,7 +26,7 @@ function createAgentStore(options) {
|
|
|
26
26
|
|
|
27
27
|
function listAgents(args = {}) {
|
|
28
28
|
const project = args.project || null
|
|
29
|
-
let agents =
|
|
29
|
+
let agents = readJsonlCached(agentsPath)
|
|
30
30
|
if (project) agents = agents.filter((a) => a.project === project)
|
|
31
31
|
return {
|
|
32
32
|
count: agents.length,
|
|
@@ -49,13 +49,13 @@ function createAgentStore(options) {
|
|
|
49
49
|
function reportAgent(args = {}) {
|
|
50
50
|
const targetAgent = args.agentId || null
|
|
51
51
|
const project = args.project || projectName()
|
|
52
|
-
const agents =
|
|
52
|
+
const agents = readJsonlCached(agentsPath)
|
|
53
53
|
const filtered = agents.filter(
|
|
54
54
|
(a) => (!targetAgent || a.agentId === targetAgent) && (!project || a.project === project)
|
|
55
55
|
)
|
|
56
|
-
const traces =
|
|
57
|
-
const memories =
|
|
58
|
-
const reuses =
|
|
56
|
+
const traces = readJsonlCached(tracePath)
|
|
57
|
+
const memories = readJsonlCached(memoryPath)
|
|
58
|
+
const reuses = readJsonlCached(experienceReusePath)
|
|
59
59
|
const reports = filtered.map((agent) => {
|
|
60
60
|
const agentTraces = traces.filter((t) => t.agent === agent.agentId)
|
|
61
61
|
const outcomes = agentTraces.filter((t) => t.event_type === 'task_outcome')
|
|
@@ -101,7 +101,7 @@ function createAgentStore(options) {
|
|
|
101
101
|
const hostType = args.hostType || 'mcp'
|
|
102
102
|
const capabilities = Array.isArray(args.capabilities) ? args.capabilities : []
|
|
103
103
|
const now = new Date().toISOString()
|
|
104
|
-
const agents =
|
|
104
|
+
const agents = readJsonlCached(agentsPath)
|
|
105
105
|
const existing = agents.find((a) => a.agentId === agentId && a.project === project)
|
|
106
106
|
let result
|
|
107
107
|
if (existing) {
|
package/server/digest.js
CHANGED
|
@@ -7,22 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const fs = require('fs')
|
|
9
9
|
const path = require('path')
|
|
10
|
-
|
|
11
|
-
function readJsonl(file) {
|
|
12
|
-
if (!fs.existsSync(file)) return []
|
|
13
|
-
return fs
|
|
14
|
-
.readFileSync(file, 'utf8')
|
|
15
|
-
.split(/\r?\n/)
|
|
16
|
-
.filter(Boolean)
|
|
17
|
-
.map((line) => {
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(line)
|
|
20
|
-
} catch (_) {
|
|
21
|
-
return null
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
}
|
|
10
|
+
const { readJsonlCached } = require('./memory-store.js')
|
|
26
11
|
|
|
27
12
|
function isVerifiedReuse(record) {
|
|
28
13
|
return (
|
|
@@ -42,7 +27,7 @@ function createDigest(options = {}) {
|
|
|
42
27
|
const atTime = now()
|
|
43
28
|
const cutoff = atTime - days * 86400000
|
|
44
29
|
|
|
45
|
-
const traces =
|
|
30
|
+
const traces = readJsonlCached(path.join(home, 'traces.jsonl')).filter((trace) => {
|
|
46
31
|
if (!trace || typeof trace !== 'object') return false
|
|
47
32
|
const ts = Date.parse(trace.timestamp || '')
|
|
48
33
|
if (!(Number.isFinite(ts) && ts >= cutoff)) return false
|
|
@@ -126,7 +111,7 @@ function createDigest(options = {}) {
|
|
|
126
111
|
.sort((a, b) => b.tasks - a.tasks)
|
|
127
112
|
|
|
128
113
|
// --- reuse evidence ---
|
|
129
|
-
const reuseRecords =
|
|
114
|
+
const reuseRecords = readJsonlCached(path.join(home, 'experience_reuse.jsonl')).filter((record) => {
|
|
130
115
|
if (!record || typeof record !== 'object') return false
|
|
131
116
|
if (project && record.project !== project) return false
|
|
132
117
|
const ts = Date.parse(record.timestamp || '')
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// ADR-017 evaluation-period metrics: the numbers that decide whether the Agent
|
|
4
|
+
// Control Plane keeps expanding (`agent.register` / `agent.list` /
|
|
5
|
+
// `agent.report` are already authorised) or the whole direction gets rolled
|
|
6
|
+
// back. The ADR names four, and until now none of them had an implementation:
|
|
7
|
+
// a grep for adoptionRate / behaviorChangeRate / recallQuality / routingAccuracy
|
|
8
|
+
// across the CLI returned zero hits. The evaluation period was therefore
|
|
9
|
+
// running on a checklist a human had to tally by hand, which is exactly the
|
|
10
|
+
// kind of thing that silently stops happening.
|
|
11
|
+
//
|
|
12
|
+
// The four metrics, verbatim from the ADR, and how each is computed here:
|
|
13
|
+
//
|
|
14
|
+
// 1. 路由采纳率 (route adoption rate)
|
|
15
|
+
// "task.route 命中且被采纳的次数 / 调用次数"
|
|
16
|
+
// Denominator: `queries.jsonl` entries that carried at least one route
|
|
17
|
+
// result (i.e. routes.length > 0 -- a "hit"). Numerator: those that can be
|
|
18
|
+
// linked to a reuse record. A hit that nobody acts on is not adoption, so
|
|
19
|
+
// only routes with a correlating reuse count.
|
|
20
|
+
//
|
|
21
|
+
// 2. 行为改变率 (behavior change rate)
|
|
22
|
+
// "confirmed(behaviorChanged=true)数量变化"
|
|
23
|
+
// Numerator: reuse records with behaviorChanged=true and confirmed=true.
|
|
24
|
+
// Denominator: all reuse records. Kept separate from `verified` (which
|
|
25
|
+
// additionally requires outcomeImproved) because the ADR asks about the
|
|
26
|
+
// behaviour change specifically, not its benefit.
|
|
27
|
+
//
|
|
28
|
+
// 3. 召回质量 (recall quality)
|
|
29
|
+
// "scope=all 与混合检索是否减少无效探索"
|
|
30
|
+
// Proxied by query yield: of the queries that returned results, how many
|
|
31
|
+
// led to a confirmed reuse. A high-miss recall path shows up as a low
|
|
32
|
+
// yield and an `emptyQueryRate`, which is the "无效探索" the ADR asks about.
|
|
33
|
+
//
|
|
34
|
+
// 4. 数据卫生 (data hygiene)
|
|
35
|
+
// "pending auto-claim 噪声占比是否随确认纪律下降"
|
|
36
|
+
// `pendingAutoClaims / total` -- the share of reuse records that are
|
|
37
|
+
// unconfirmed auto-claims. These are invisible dead weight: they never
|
|
38
|
+
// feed ranking or routing, so a rising ratio means the confirmation
|
|
39
|
+
// discipline is slipping.
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ TWO OF THESE METRICS READ A BUFFER THAT IS EMPTY BY DESIGN.
|
|
42
|
+
//
|
|
43
|
+
// Metrics 1 and 3 are derived from `queries.jsonl`, which is not an evaluation
|
|
44
|
+
// log: it is the auto-claim correlation buffer (see ../query-log.js), holding
|
|
45
|
+
// at most 200 entries and pruning anything past a 1-hour `expiresAt` window
|
|
46
|
+
// (retention.js lists it under EXPIRY_BASED_FILES). Measured against the real
|
|
47
|
+
// dataset on 2026-09-20: queries.jsonl was 0 bytes while traces.jsonl held 764
|
|
48
|
+
// task_outcomes and experience_reuse.jsonl held 21 records. So in practice
|
|
49
|
+
// these two metrics report "no data" almost always -- not because routing is
|
|
50
|
+
// unused, but because its evidence deliberately does not survive.
|
|
51
|
+
//
|
|
52
|
+
// That is a real limitation of the metric definitions, not of the data, and it
|
|
53
|
+
// is reported rather than hidden: `sources` below names each metric's backing
|
|
54
|
+
// file and whether that file is durable, so a reader can tell "route adoption
|
|
55
|
+
// is 0%" from "route adoption cannot be measured from what we keep".
|
|
56
|
+
//
|
|
57
|
+
// Everything is read-only. Each metric reports its own denominator so a caller
|
|
58
|
+
// can tell "0%" from "no data at all" -- reporting a confident 0% on zero
|
|
59
|
+
// samples is the failure mode this repo keeps rediscovering.
|
|
60
|
+
|
|
61
|
+
const path = require('path')
|
|
62
|
+
const { readJsonl } = require('./memory-store.js')
|
|
63
|
+
const { REUSE_STATUS_FILTERS } = require('./experience-store.js')
|
|
64
|
+
const { REUSE_MATCH_WINDOW_MS } = require('./query-log.js')
|
|
65
|
+
|
|
66
|
+
// Reported so the client can say "this number is windowed to the last N
|
|
67
|
+
// minutes". Read from the query log itself rather than hardcoded, so a change
|
|
68
|
+
// to the auto-claim window cannot leave this description stale.
|
|
69
|
+
const QUERY_BUFFER_MINUTES = Math.round(REUSE_MATCH_WINDOW_MS / 60000)
|
|
70
|
+
|
|
71
|
+
// The ADR frames the whole evaluation period as a comparison against the Phase 0
|
|
72
|
+
// baseline, and states it in its own line:
|
|
73
|
+
//
|
|
74
|
+
// "对照 Phase 0 基线 2026-08-17:hosts 3/2、tasks 176/20、verified 6/5、
|
|
75
|
+
// improved 30/1"
|
|
76
|
+
//
|
|
77
|
+
// Each pair is `observed / threshold`, which the Phase 0 implementation
|
|
78
|
+
// confirms: phase0.js declares thresholds {minHosts:2, minTaskOutcomes:20,
|
|
79
|
+
// minVerifiedReuse:5, minImprovementEvidence:1} and renders them as
|
|
80
|
+
// `current/required` (phase0.js:313). So `verified 6/5` means verified=6
|
|
81
|
+
// against a required 5 -- passing, with one to spare.
|
|
82
|
+
//
|
|
83
|
+
// Without this the metrics report bare numbers with no reference point, and a
|
|
84
|
+
// regression like verified 6 -> 0 reads as an unremarkable "0" instead of a
|
|
85
|
+
// gate that used to pass and no longer does. Measured 2026-09-20: verified is
|
|
86
|
+
// in fact 0.
|
|
87
|
+
//
|
|
88
|
+
// The keys match phase0's `criteria[].key` values so the two reports can be
|
|
89
|
+
// read side by side.
|
|
90
|
+
const PHASE0_BASELINE = Object.freeze({
|
|
91
|
+
date: '2026-08-17',
|
|
92
|
+
source: 'docs/adr-017-mio-agent-control-plane.md',
|
|
93
|
+
values: Object.freeze({
|
|
94
|
+
hosts: Object.freeze({ observed: 3, required: 2 }),
|
|
95
|
+
tasks: Object.freeze({ observed: 176, required: 20 }),
|
|
96
|
+
verified_reuse: Object.freeze({ observed: 6, required: 5 }),
|
|
97
|
+
measurable_improvement: Object.freeze({ observed: 30, required: 1 }),
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function ratio(numerator, denominator) {
|
|
102
|
+
return denominator > 0 ? Math.round((numerator / denominator) * 100) : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createEvaluationStore(options = {}) {
|
|
106
|
+
const dataDir = options.dataDir
|
|
107
|
+
const projectName = options.projectName || (() => null)
|
|
108
|
+
|
|
109
|
+
const queryPath = path.join(dataDir, 'queries.jsonl')
|
|
110
|
+
const reusePath = path.join(dataDir, 'experience_reuse.jsonl')
|
|
111
|
+
const tracePath = path.join(dataDir, 'traces.jsonl')
|
|
112
|
+
const agentsPath = path.join(dataDir, 'agents.jsonl')
|
|
113
|
+
|
|
114
|
+
// The query log prunes itself by expiresAt, but for an evaluation window we
|
|
115
|
+
// want the historical record, so read the file directly rather than through
|
|
116
|
+
// query-log.js (whose `load()` deliberately drops expired entries).
|
|
117
|
+
function readQueries(project) {
|
|
118
|
+
const entries = readJsonl(queryPath).filter((entry) => entry && typeof entry === 'object')
|
|
119
|
+
if (!project) return entries
|
|
120
|
+
return entries.filter((entry) => !entry.project || entry.project === project)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function evaluate(args = {}) {
|
|
124
|
+
const project = args.project || projectName()
|
|
125
|
+
const since = args.since ? Date.parse(args.since) : null
|
|
126
|
+
const inWindow = (record) => {
|
|
127
|
+
if (!Number.isFinite(since)) return true
|
|
128
|
+
const ts = Date.parse(record.timestamp || '')
|
|
129
|
+
return Number.isFinite(ts) && ts >= since
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const queries = readQueries(project).filter(inWindow)
|
|
133
|
+
const reuses = readJsonl(reusePath).filter(
|
|
134
|
+
(r) => r && inWindow(r) && (!project || !r.project || r.project === project)
|
|
135
|
+
)
|
|
136
|
+
const traces = readJsonl(tracePath).filter(
|
|
137
|
+
(t) => t && inWindow(t) && (!project || !t.project || t.project === project)
|
|
138
|
+
)
|
|
139
|
+
const agents = readJsonl(agentsPath).filter(
|
|
140
|
+
(a) => a && (!project || !a.project || a.project === project)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// ---- 1. route adoption rate -------------------------------------------
|
|
144
|
+
// A query entry is a "route hit" when it came back with at least one result
|
|
145
|
+
// id to act on. `resultSources` marks which of those came from a verified
|
|
146
|
+
// experience rather than a plain memory.
|
|
147
|
+
const routeHits = queries.filter(
|
|
148
|
+
(q) => Array.isArray(q.resultIds) && q.resultIds.length > 0
|
|
149
|
+
)
|
|
150
|
+
const experienceHits = queries.filter(
|
|
151
|
+
(q) => Array.isArray(q.resultSources) && q.resultSources.some((s) => s === 'experience')
|
|
152
|
+
)
|
|
153
|
+
// Adoption: a route hit whose experience was subsequently reused. Correlate
|
|
154
|
+
// on experienceId, which both the route result and the reuse record carry.
|
|
155
|
+
const reusedIds = new Set(reuses.map((r) => r.experienceId).filter(Boolean))
|
|
156
|
+
const adopted = experienceHits.filter((q) =>
|
|
157
|
+
(q.resultIds || []).some((id) => reusedIds.has(id))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// ---- 2. behaviour change rate -----------------------------------------
|
|
161
|
+
const behaviorChanged = reuses.filter(
|
|
162
|
+
(r) => r.confirmed === true && (r.behaviorChanged === true || r.behaviorChanged === 'true')
|
|
163
|
+
)
|
|
164
|
+
const verified = reuses.filter(REUSE_STATUS_FILTERS.verified)
|
|
165
|
+
|
|
166
|
+
// ---- 3. recall quality -------------------------------------------------
|
|
167
|
+
const withResults = queries.filter((q) => (q.resultIds || []).length > 0)
|
|
168
|
+
const linkedToReuse = withResults.filter((q) =>
|
|
169
|
+
(q.resultIds || []).some((id) => reusedIds.has(id))
|
|
170
|
+
)
|
|
171
|
+
const emptyQueries = queries.filter((q) => (q.resultIds || []).length === 0)
|
|
172
|
+
|
|
173
|
+
// ---- 4. data hygiene ---------------------------------------------------
|
|
174
|
+
const pendingAutoClaims = reuses.filter(REUSE_STATUS_FILTERS.pending)
|
|
175
|
+
|
|
176
|
+
const outcomes = traces.filter((t) => t.event_type === 'task_outcome')
|
|
177
|
+
const successes = outcomes.filter((t) => t.outcome === 'success').length
|
|
178
|
+
const failures = outcomes.filter((t) => t.outcome === 'failure').length
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
project,
|
|
182
|
+
since: args.since || null,
|
|
183
|
+
// Which file each metric is derived from, and whether that file survives.
|
|
184
|
+
// Metrics 1 and 3 depend on a 1-hour buffer, so their denominators decay
|
|
185
|
+
// to zero on their own; without this a reader cannot tell an unused
|
|
186
|
+
// feature from an unmeasurable one.
|
|
187
|
+
sources: {
|
|
188
|
+
routeAdoption: { file: 'queries.jsonl', durable: false, windowMinutes: QUERY_BUFFER_MINUTES },
|
|
189
|
+
behaviorChange: { file: 'experience_reuse.jsonl', durable: true },
|
|
190
|
+
recallQuality: { file: 'queries.jsonl', durable: false, windowMinutes: QUERY_BUFFER_MINUTES },
|
|
191
|
+
dataHygiene: { file: 'experience_reuse.jsonl', durable: true },
|
|
192
|
+
},
|
|
193
|
+
// The Phase 0 reference point the ADR asks these numbers to be read
|
|
194
|
+
// against. Reported verbatim, with no interpretation folded in -- a
|
|
195
|
+
// caller compares `behaviorChange.verified` to
|
|
196
|
+
// `baseline.values.verified_reuse.observed` itself. Deliberately NOT
|
|
197
|
+
// merged into the metrics above: the baseline was measured on different
|
|
198
|
+
// record shapes, so silently differencing them would be another confident
|
|
199
|
+
// number with a shaky basis.
|
|
200
|
+
baseline: PHASE0_BASELINE,
|
|
201
|
+
sample: {
|
|
202
|
+
queries: queries.length,
|
|
203
|
+
routeHits: routeHits.length,
|
|
204
|
+
reuses: reuses.length,
|
|
205
|
+
traces: traces.length,
|
|
206
|
+
agents: agents.length,
|
|
207
|
+
taskOutcomes: outcomes.length,
|
|
208
|
+
},
|
|
209
|
+
routeAdoption: {
|
|
210
|
+
routeHits: experienceHits.length,
|
|
211
|
+
adopted: adopted.length,
|
|
212
|
+
rate: ratio(adopted.length, experienceHits.length),
|
|
213
|
+
},
|
|
214
|
+
behaviorChange: {
|
|
215
|
+
confirmed: behaviorChanged.length,
|
|
216
|
+
verified: verified.length,
|
|
217
|
+
total: reuses.length,
|
|
218
|
+
rate: ratio(behaviorChanged.length, reuses.length),
|
|
219
|
+
},
|
|
220
|
+
recallQuality: {
|
|
221
|
+
queriesWithResults: withResults.length,
|
|
222
|
+
queriesLinkedToReuse: linkedToReuse.length,
|
|
223
|
+
emptyQueries: emptyQueries.length,
|
|
224
|
+
emptyQueryRate: ratio(emptyQueries.length, queries.length),
|
|
225
|
+
yield: ratio(linkedToReuse.length, withResults.length),
|
|
226
|
+
},
|
|
227
|
+
dataHygiene: {
|
|
228
|
+
pendingAutoClaims: pendingAutoClaims.length,
|
|
229
|
+
total: reuses.length,
|
|
230
|
+
pendingRatio: ratio(pendingAutoClaims.length, reuses.length),
|
|
231
|
+
},
|
|
232
|
+
taskOutcomes: {
|
|
233
|
+
total: outcomes.length,
|
|
234
|
+
success: successes,
|
|
235
|
+
failure: failures,
|
|
236
|
+
successRate: ratio(successes, outcomes.length),
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { evaluate }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { createEvaluationStore }
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// its data directory from MIO_DATA_DIR while the CLI uses MIO_HOME, and each has
|
|
8
8
|
// its own projectName() -- so this module owns no path or environment policy.
|
|
9
9
|
|
|
10
|
-
const fs = require('fs')
|
|
11
10
|
const path = require('path')
|
|
11
|
+
const { readJsonlCached } = require('./memory-store.js')
|
|
12
12
|
|
|
13
13
|
const REPORT_FILES = {
|
|
14
14
|
memory: 'memory.jsonl',
|
|
@@ -17,22 +17,6 @@ const REPORT_FILES = {
|
|
|
17
17
|
agent: 'agents.jsonl',
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function readJsonl(file) {
|
|
21
|
-
if (!fs.existsSync(file)) return []
|
|
22
|
-
return fs
|
|
23
|
-
.readFileSync(file, 'utf8')
|
|
24
|
-
.split(/\r?\n/)
|
|
25
|
-
.filter(Boolean)
|
|
26
|
-
.map((line) => {
|
|
27
|
-
try {
|
|
28
|
-
return JSON.parse(line)
|
|
29
|
-
} catch (_) {
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
.filter(Boolean)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
20
|
const PERIOD_MS = {
|
|
37
21
|
'24h': 86400000,
|
|
38
22
|
'7d': 604800000,
|
|
@@ -65,10 +49,10 @@ function createEvolutionReport({ dataDir, projectName }) {
|
|
|
65
49
|
const periodMs = PERIOD_MS[period] === undefined ? Infinity : PERIOD_MS[period]
|
|
66
50
|
const cutoff = periodMs === Infinity ? 0 : now - periodMs
|
|
67
51
|
|
|
68
|
-
const memories =
|
|
69
|
-
const traces =
|
|
70
|
-
const reuses =
|
|
71
|
-
const agents =
|
|
52
|
+
const memories = readJsonlCached(files.memory).filter((m) => !project || m.project === project || !m.project)
|
|
53
|
+
const traces = readJsonlCached(files.trace).filter((t) => !project || t.project === project)
|
|
54
|
+
const reuses = readJsonlCached(files.reuse).filter((r) => !project || r.project === project)
|
|
55
|
+
const agents = readJsonlCached(files.agent).filter((a) => !project || a.project === project)
|
|
72
56
|
|
|
73
57
|
const filteredTraces = cutoff ? traces.filter((t) => new Date(t.timestamp).getTime() >= cutoff) : traces
|
|
74
58
|
const filteredMemories = cutoff ? memories.filter((m) => new Date(m.timestamp).getTime() >= cutoff) : memories
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// unconfirmed auto-claims are otherwise invisible dead weight.
|
|
13
13
|
|
|
14
14
|
const path = require('path')
|
|
15
|
-
const { createId,
|
|
15
|
+
const { createId, readJsonlCached, appendJsonl, writeJsonl } = require('./memory-store.js')
|
|
16
16
|
|
|
17
17
|
const REUSE_STATUS_FILTERS = Object.freeze({
|
|
18
18
|
pending: (record) => record.source === 'auto_claim' && record.confirmed !== true,
|
|
@@ -64,7 +64,7 @@ function createExperienceStore(options = {}) {
|
|
|
64
64
|
const targetAgent = String(args.targetAgent || '').trim().toLowerCase()
|
|
65
65
|
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100)
|
|
66
66
|
|
|
67
|
-
let records =
|
|
67
|
+
let records = readJsonlCached(experiencePath)
|
|
68
68
|
if (project) records = records.filter((record) => record.project === project)
|
|
69
69
|
if (targetAgent) {
|
|
70
70
|
records = records.filter(
|
|
@@ -116,7 +116,7 @@ function createExperienceStore(options = {}) {
|
|
|
116
116
|
const confirmedBy = String(args.confirmedBy || 'agent').trim() || 'agent'
|
|
117
117
|
const notes = args.notes ? String(args.notes).trim() : null
|
|
118
118
|
|
|
119
|
-
const records =
|
|
119
|
+
const records = readJsonlCached(experiencePath)
|
|
120
120
|
const confirmedIds = []
|
|
121
121
|
const confirmedRecords = []
|
|
122
122
|
const notFound = []
|