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.
@@ -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 && /^\\d+$/.test(parts[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
- return process.env.OPENCODE_BIN
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
- const res = spawnSync(finder, ['opencode'], { encoding: 'utf8', windowsHide: true, timeout: 15000 })
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) return 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
- res = spawnSync(bin, args, { encoding: 'utf8', timeout: 90000, windowsHide: true, maxBuffer: 256 * 1024 * 1024 })
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 first = runOpencodeQuery('SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1')
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(first) && first.length > 0) {
947
- rid = Number(first[0].rowid) || 0
948
- } else {
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.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -52,8 +52,22 @@ if (files.length === 0) {
52
52
 
53
53
  const failures = []
54
54
  for (const file of files) {
55
- const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' })
56
- if (result.status !== 0) {
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
  }
@@ -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, readJsonl, appendJsonl, writeJsonl } = require('./memory-store.js')
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 = readJsonl(agentsPath)
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 = readJsonl(agentsPath)
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 = readJsonl(tracePath)
57
- const memories = readJsonl(memoryPath)
58
- const reuses = readJsonl(experienceReusePath)
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 = readJsonl(agentsPath)
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 = readJsonl(path.join(home, 'traces.jsonl')).filter((trace) => {
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 = readJsonl(path.join(home, 'experience_reuse.jsonl')).filter((record) => {
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 = readJsonl(files.memory).filter((m) => !project || m.project === project || !m.project)
69
- const traces = readJsonl(files.trace).filter((t) => !project || t.project === project)
70
- const reuses = readJsonl(files.reuse).filter((r) => !project || r.project === project)
71
- const agents = readJsonl(files.agent).filter((a) => !project || a.project === project)
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, readJsonl, appendJsonl, writeJsonl } = require('./memory-store.js')
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 = readJsonl(experiencePath)
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 = readJsonl(experiencePath)
119
+ const records = readJsonlCached(experiencePath)
120
120
  const confirmedIds = []
121
121
  const confirmedRecords = []
122
122
  const notFound = []