mio-agent-runtime 0.5.22 → 0.6.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.
@@ -65,6 +65,71 @@ function stateFile(home) {
65
65
  return path.join(home, 'observe-state.json')
66
66
  }
67
67
 
68
+ // Canonical form for a workspace path used as a context key.
69
+ //
70
+ // Context keys are taken verbatim from the `cwd` field of host transcripts, and
71
+ // hosts disagree on spelling: the same directory arrives as `D:\work\code\x`,
72
+ // `d:\work\code\x`, or `D:/work/code/x`. Left alone, one project splits into
73
+ // several buckets, each capped at CONTEXT_MAX, so its history is fragmented and
74
+ // entries are dropped early. Windows paths are case-insensitive, so normalise
75
+ // the drive letter to upper case and use backslashes consistently to match the
76
+ // style the file-observation keys already use.
77
+ function normalizeCwd(cwd) {
78
+ if (!cwd || typeof cwd !== 'string') return cwd
79
+ let out = cwd.replace(/\//g, '\\').replace(/\\+/g, '\\')
80
+ // Upper-case a single drive letter prefix ("c:\..." -> "C:\...").
81
+ if (/^[a-zA-Z]:\\/.test(out)) out = out[0].toUpperCase() + out.slice(1)
82
+ // Drop a trailing separator unless the path is just the drive root.
83
+ if (out.length > 3 && out.endsWith('\\')) out = out.slice(0, -1)
84
+ return out
85
+ }
86
+
87
+ // Parse the "YYYY/M/D HH:MM:SS | ..." stamp that context lines carry.
88
+ // Returns null when the line has no recognisable leading timestamp, so
89
+ // unparseable entries never get treated as the oldest by accident.
90
+ function contextTimestamp(line) {
91
+ const m = /^\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})/.exec(String(line || ''))
92
+ if (!m) return null
93
+ const ms = new Date(
94
+ Number(m[1]),
95
+ Number(m[2]) - 1,
96
+ Number(m[3]),
97
+ Number(m[4]),
98
+ Number(m[5]),
99
+ Number(m[6]),
100
+ ).getTime()
101
+ return Number.isFinite(ms) ? ms : null
102
+ }
103
+
104
+ // Drop repeated lines from stored context lists, preserving the most recent
105
+ // occurrence order. Older builds could write the same line into every slot of
106
+ // a workspace block (a generated digest headline broadcast to all projects),
107
+ // so existing observe-state files still carry duplicates until they are
108
+ // rewritten. Normalising on load heals that data without a migration step.
109
+ //
110
+ // Keys are also folded through normalizeCwd and merged, so buckets split by
111
+ // spelling differences collapse back into one on the next load.
112
+ function dedupeContexts(contexts) {
113
+ const out = {}
114
+ for (const [cwd, list] of Object.entries(contexts)) {
115
+ const key = normalizeCwd(cwd)
116
+ const existing = out[key] || []
117
+ if (!Array.isArray(list)) {
118
+ out[key] = existing
119
+ continue
120
+ }
121
+ const seen = new Set(existing)
122
+ const merged = existing.slice()
123
+ for (const line of list) {
124
+ if (seen.has(line)) continue
125
+ seen.add(line)
126
+ merged.push(line)
127
+ }
128
+ out[key] = merged
129
+ }
130
+ return out
131
+ }
132
+
68
133
  function loadState(home) {
69
134
  try {
70
135
  const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
@@ -74,7 +139,7 @@ function loadState(home) {
74
139
  files: parsed.files || {},
75
140
  buffers: parsed.buffers || {},
76
141
  openTurns: parsed.openTurns || {},
77
- contexts: parsed.contexts || {},
142
+ contexts: dedupeContexts(parsed.contexts || {}),
78
143
  codexFiles: parsed.codexFiles || {},
79
144
  codexBuffers: parsed.codexBuffers || {},
80
145
  codexTurns: parsed.codexTurns || {},
@@ -290,11 +355,39 @@ const CONTEXT_MAX = 6
290
355
  // retrieval guaranteed (not dependent on the model deciding to read memory).
291
356
  function updateProjectContext(state, cwd, line, targetFile) {
292
357
  if (!cwd || !line) return
358
+ const key = normalizeCwd(cwd)
293
359
  const target = path.join(cwd, targetFile || 'AGENTS.md')
294
- const list = state.contexts[cwd] || []
360
+ // Merge any buckets that differ only by spelling, so a project observed as
361
+ // both "D:\work\code\x" and "d:\work\code\x" converges into one list instead
362
+ // of two half-filled ones.
363
+ const list = []
364
+ const seen = new Set()
365
+ for (const [existingKey, items] of Object.entries(state.contexts || {})) {
366
+ if (normalizeCwd(existingKey) !== key) continue
367
+ for (const item of items || []) {
368
+ if (seen.has(item)) continue
369
+ seen.add(item)
370
+ list.push(item)
371
+ }
372
+ if (existingKey !== key) delete state.contexts[existingKey]
373
+ }
374
+ // Re-inserting an existing line moves it to the front instead of duplicating.
375
+ const existing = list.indexOf(line)
376
+ if (existing !== -1) list.splice(existing, 1)
295
377
  list.unshift(line)
296
- if (list.length > CONTEXT_MAX) list.pop()
297
- state.contexts[cwd] = list
378
+ // Once buckets merge, the list can exceed CONTEXT_MAX. Entries are stamped
379
+ // "YYYY/M/D HH:MM:SS | ...", so trim by that timestamp rather than by
380
+ // position -- merging appends whole buckets in object-key order, which says
381
+ // nothing about age, and popping blind could discard recent work.
382
+ if (list.length > CONTEXT_MAX) {
383
+ const stamped = list.filter((l) => contextTimestamp(l) !== null)
384
+ if (stamped.length > 1) {
385
+ list.sort((a, b) => contextTimestamp(b) - contextTimestamp(a))
386
+ }
387
+ list.length = CONTEXT_MAX
388
+ }
389
+ state.contexts = state.contexts || {}
390
+ state.contexts[key] = list
298
391
  try {
299
392
  let content = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
300
393
  const block = CONTEXT_BEGIN + '\n## Mio 最近上下文(自动注入)\n' + list.map(function (l) { return '- ' + l }).join('\n') + '\n' + CONTEXT_END
@@ -1591,6 +1684,9 @@ module.exports = {
1591
1684
  workbuddyConfigDir,
1592
1685
  loadState,
1593
1686
  saveState,
1687
+ dedupeContexts,
1688
+ normalizeCwd,
1689
+ contextTimestamp,
1594
1690
  updateProjectContext,
1595
1691
  runCycle,
1596
1692
  opencodeDbPath,
package/package.json CHANGED
@@ -1,38 +1,45 @@
1
- {
2
- "name": "mio-agent-runtime",
3
- "version": "0.5.22",
4
- "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
- "license": "MIT",
6
- "type": "commonjs",
7
- "bin": {
8
- "mio": "bin/mio.js"
9
- },
10
- "files": [
11
- "bin/",
12
- "server/",
13
- "adapters/",
14
- "observe/",
15
- "scripts/",
16
- "README.md",
17
- "!server/mio-intelligence-mcp/__tests__"
18
- ],
19
- "scripts": {
20
- "check": "node --check bin/mio.js && node --check adapters/host-utils.js && node --check adapters/codex.js && node --check adapters/opencode.js && node --check adapters/workbuddy.js && node --check adapters/hermes.js && node --check adapters/claude-code.js && node --check server/runtime-modules.js && node --check server/memory-store.js && node --check server/retention.js && node --check server/digest.js && node --check server/host-capabilities.js && node --check server/evolution-cutover.js && node --check server/mio-intelligence-mcp/index.js && node --check server/mio-intelligence-mcp/phase0.js && node --check observe/observer.js && node --check scripts/verify-packed-runtime.js",
21
- "prepack": "npm run check",
22
- "verify:pack-install": "node scripts/verify-packed-runtime.js"
23
- },
24
- "engines": {
25
- "node": ">=18"
26
- },
27
- "dependencies": {
28
- "@akemi-mio/runtime-contracts": "^0.1.0",
29
- "@akemi-mio/runtime-foundation": "^0.1.0",
30
- "@akemi-mio/experience-memory": "^0.1.0",
31
- "@akemi-mio/evolution-learning": "^0.1.0",
32
- "@akemi-mio/evolution-strategy": "^0.1.0",
33
- "@akemi-mio/evolution-safety": "^0.1.0",
34
- "@akemi-mio/evolution-scheduler": "^0.1.0",
35
- "@akemi-mio/observer": "^0.1.0",
36
- "@akemi-mio/insight": "^0.1.0"
37
- }
38
- }
1
+ {
2
+ "name": "mio-agent-runtime",
3
+ "version": "0.6.0",
4
+ "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "mio": "bin/mio.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "server/",
13
+ "adapters/",
14
+ "observe/",
15
+ "scripts/",
16
+ "README.md",
17
+ "!server/mio-intelligence-mcp/__tests__"
18
+ ],
19
+ "scripts": {
20
+ "check": "node scripts/check-syntax.cjs",
21
+ "test": "node --test \"__tests__/*.test.js\" \"server/mio-intelligence-mcp/__tests__/*.test.js\"",
22
+ "prepack": "npm run check",
23
+ "verify:pack-install": "node scripts/verify-packed-runtime.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "dependencies": {
29
+ "@akemi-mio/runtime-contracts": "^0.1.0",
30
+ "@akemi-mio/runtime-foundation": "^0.1.0",
31
+ "@akemi-mio/experience-memory": "^0.1.0",
32
+ "@akemi-mio/evolution-learning": "^0.1.0",
33
+ "@akemi-mio/evolution-strategy": "^0.1.0",
34
+ "@akemi-mio/evolution-safety": "^0.1.0",
35
+ "@akemi-mio/evolution-scheduler": "^0.1.0",
36
+ "@akemi-mio/observer": "^0.1.0",
37
+ "@akemi-mio/insight": "^0.1.0"
38
+ },
39
+ "main": "index.js",
40
+ "directories": {
41
+ "doc": "docs"
42
+ },
43
+ "keywords": [],
44
+ "author": ""
45
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // Syntax gate for mio-agent-runtime (`npm run check`, which prepack runs).
5
+ //
6
+ // This used to be a hand-written file list in package.json. That silently rotted:
7
+ // nine server modules -- including every store added later (policy, agent, task,
8
+ // query-log, llm-client, creativity-engine) -- were never syntax-checked, so a
9
+ // typo in any of them would have survived `npm run check` and only exploded at
10
+ // require time, i.e. inside the published package.
11
+ //
12
+ // So: discover files instead of listing them. New modules are covered
13
+ // automatically, and the gate can no longer fall behind the codebase.
14
+
15
+ const fs = require('fs')
16
+ const path = require('path')
17
+ const { spawnSync } = require('child_process')
18
+
19
+ const ROOT = path.resolve(__dirname, '..')
20
+
21
+ // Top-level dirs to walk. __tests__ is excluded: those are exercised by
22
+ // `node --test`, and a broken test file should fail the test run, not the
23
+ // package gate.
24
+ const INCLUDE_DIRS = ['bin', 'adapters', 'observe', 'server', 'scripts']
25
+ const EXCLUDE_DIRS = new Set(['node_modules', '__tests__', 'dist', 'out'])
26
+ const EXTENSIONS = new Set(['.js', '.cjs'])
27
+
28
+ function walk(dir, out = []) {
29
+ let entries
30
+ try {
31
+ entries = fs.readdirSync(dir, { withFileTypes: true })
32
+ } catch (_) {
33
+ return out
34
+ }
35
+ for (const entry of entries) {
36
+ if (entry.isDirectory()) {
37
+ if (EXCLUDE_DIRS.has(entry.name)) continue
38
+ walk(path.join(dir, entry.name), out)
39
+ } else if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name))) {
40
+ out.push(path.join(dir, entry.name))
41
+ }
42
+ }
43
+ return out
44
+ }
45
+
46
+ const files = INCLUDE_DIRS.flatMap((dir) => walk(path.join(ROOT, dir))).sort()
47
+
48
+ if (files.length === 0) {
49
+ console.error('check-syntax: no files discovered -- the gate is broken')
50
+ process.exit(1)
51
+ }
52
+
53
+ const failures = []
54
+ for (const file of files) {
55
+ const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' })
56
+ if (result.status !== 0) {
57
+ failures.push({ file, message: (result.stderr || '').trim().split('\n')[0] })
58
+ }
59
+ }
60
+
61
+ const rel = (f) => path.relative(ROOT, f).replace(/\\/g, '/')
62
+ console.log(`checked ${files.length} file(s)`)
63
+
64
+ if (failures.length > 0) {
65
+ for (const failure of failures) {
66
+ console.error(`FAIL ${rel(failure.file)}: ${failure.message}`)
67
+ }
68
+ console.error(`\n${failures.length} file(s) failed syntax check`)
69
+ process.exit(1)
70
+ }
71
+
72
+ console.log('all files parse')
@@ -76,12 +76,13 @@ function npmCommand(platform = process.platform) {
76
76
 
77
77
  function npmInvocation(args, env = process.env, platform = process.platform, node = process.execPath) {
78
78
  if (env.npm_execpath) {
79
- return { command: node, args: [env.npm_execpath, ...args] }
79
+ return { command: node, args: [env.npm_execpath, ...args], shell: false }
80
80
  }
81
- if (platform === 'win32') {
82
- return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'npm', ...args] }
83
- }
84
- return { command: npmCommand(platform), args }
81
+ // Fall back to the npm shim on PATH instead of routing through cmd.exe.
82
+ // cmd.exe is unavailable in some sandboxes, and a bare .cmd/.bat shim cannot
83
+ // be spawned directly (Node requires shell:true for those on Windows), so
84
+ // signal the shell requirement to the runner instead of guessing there.
85
+ return { command: npmCommand(platform), args: [...args], shell: platform === 'win32' }
85
86
  }
86
87
 
87
88
  function packageDirName(name) {
@@ -95,9 +96,12 @@ function tarballFileName(name, version) {
95
96
 
96
97
  function readPackageJson(workspaceRoot, name) {
97
98
  const packageDir = path.join(workspaceRoot, 'packages', packageDirName(name))
99
+ // Windows editors may write package.json with a UTF-8 BOM; JSON.parse
100
+ // rejects the leading U+FEFF, so strip it before parsing.
101
+ const raw = fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
98
102
  return {
99
103
  packageDir,
100
- manifest: JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')),
104
+ manifest: JSON.parse(raw),
101
105
  }
102
106
  }
103
107
 
@@ -211,6 +215,11 @@ function run(command, args, options = {}) {
211
215
  env: options.env || process.env,
212
216
  encoding: 'utf8',
213
217
  stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
218
+ // Windows .cmd/.bat shims (npm.cmd) cannot be spawned directly by
219
+ // execFileSync since Node's CVE-2024-27980 hardening; they need a shell.
220
+ // This only kicks in for the npm shim path — node-based invocations pass
221
+ // an absolute node executable and still run shell-free.
222
+ shell: options.shell === true,
214
223
  })
215
224
  return output ? output.trim() : ''
216
225
  }
@@ -296,14 +305,19 @@ function verifyPackedRuntimeInstall(input = {}) {
296
305
  run(pack.command, pack.args, {
297
306
  cwd: pkg.packageDir,
298
307
  stdio: ['ignore', 'pipe', 'pipe'],
308
+ shell: pack.shell,
299
309
  })
300
310
  packageArtifactEvidence(pkg)
301
311
  }
302
312
 
303
313
  const init = npmInvocation(['init', '-y'])
304
- run(init.command, init.args, { cwd: plan.installDir, stdio: ['ignore', 'pipe', 'pipe'] })
314
+ run(init.command, init.args, { cwd: plan.installDir, stdio: ['ignore', 'pipe', 'pipe'], shell: init.shell })
305
315
  const install = npmInvocation(plan.install.args)
306
- run(install.command, install.args, { cwd: plan.install.cwd, stdio: ['ignore', 'pipe', 'pipe'] })
316
+ run(install.command, install.args, {
317
+ cwd: plan.install.cwd,
318
+ stdio: ['ignore', 'pipe', 'pipe'],
319
+ shell: install.shell,
320
+ })
307
321
  const smokeEnv = {
308
322
  ...process.env,
309
323
  MIO_HOME: path.join(plan.installDir, '.mio-home'),
@@ -360,6 +374,7 @@ module.exports = {
360
374
  createVerifyPackedRuntimeOptions,
361
375
  createPackedRuntimeInstallPlan,
362
376
  packageArtifactEvidence,
377
+ readPackageJson,
363
378
  npmInvocation,
364
379
  npmCommand,
365
380
  parseVerifyPackedRuntimeArgs,
@@ -0,0 +1,140 @@
1
+ 'use strict'
2
+
3
+ // Shared agent store: the single implementation behind mio.agent.list /
4
+ // register / report, consumed by both the MCP server and the CLI
5
+ // (`mio agents list` / `mio agents report`). It owns agents.jsonl plus the
6
+ // read-only cross-references the report needs (traces, memory, experience
7
+ // reuse). Extracted from mio-intelligence-mcp/index.js so the terminal and the
8
+ // MCP tool can never report different agent telemetry.
9
+ //
10
+ // Two distinct "agent" notions live in this repo and the CLI must not confuse
11
+ // them: `mio agents` (no subcommand) lists installed host adapters from
12
+ // config.json, while this store reports *observed* agents recorded in
13
+ // agents.jsonl. `mio agents list` / `report` are about the latter.
14
+
15
+ const path = require('path')
16
+ const { createId, readJsonl, appendJsonl, writeJsonl } = require('./memory-store.js')
17
+
18
+ function createAgentStore(options) {
19
+ const dataDir = options.dataDir
20
+ const projectName = options.projectName || (() => null)
21
+
22
+ const agentsPath = path.join(dataDir, 'agents.jsonl')
23
+ const tracePath = path.join(dataDir, 'traces.jsonl')
24
+ const memoryPath = path.join(dataDir, 'memory.jsonl')
25
+ const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
26
+
27
+ function listAgents(args = {}) {
28
+ const project = args.project || null
29
+ let agents = readJsonl(agentsPath)
30
+ if (project) agents = agents.filter((a) => a.project === project)
31
+ return {
32
+ count: agents.length,
33
+ agents: agents.map((a) => ({
34
+ id: a.id,
35
+ agentId: a.agentId,
36
+ hostType: a.hostType,
37
+ capabilities: a.capabilities,
38
+ registeredAt: a.registeredAt,
39
+ lastSeenAt: a.lastSeenAt,
40
+ sessionCount: a.sessionCount,
41
+ taskCount: a.taskCount || 0,
42
+ successCount: a.successCount || 0,
43
+ failureCount: a.failureCount || 0,
44
+ project: a.project,
45
+ })),
46
+ }
47
+ }
48
+
49
+ function reportAgent(args = {}) {
50
+ const targetAgent = args.agentId || null
51
+ const project = args.project || projectName()
52
+ const agents = readJsonl(agentsPath)
53
+ const filtered = agents.filter(
54
+ (a) => (!targetAgent || a.agentId === targetAgent) && (!project || a.project === project)
55
+ )
56
+ const traces = readJsonl(tracePath)
57
+ const memories = readJsonl(memoryPath)
58
+ const reuses = readJsonl(experienceReusePath)
59
+ const reports = filtered.map((agent) => {
60
+ const agentTraces = traces.filter((t) => t.agent === agent.agentId)
61
+ const outcomes = agentTraces.filter((t) => t.event_type === 'task_outcome')
62
+ const successes = outcomes.filter((t) => t.outcome === 'success').length
63
+ const failures = outcomes.filter((t) => t.outcome === 'failure').length
64
+ const agentMemories = memories.filter((m) => m.source === agent.agentId)
65
+ const agentReuses = reuses.filter(
66
+ (r) => r.sourceAgent === agent.agentId || r.targetAgent === agent.agentId
67
+ )
68
+ const verified = agentReuses.filter(
69
+ (r) => r.confirmed && r.reuse && r.behaviorChanged && r.outcomeImproved
70
+ )
71
+ const regTasks = agent.taskCount || 0
72
+ const regSuccess = agent.successCount || 0
73
+ const regFail = agent.failureCount || 0
74
+ const taskTotal = regTasks > 0 ? regTasks : outcomes.length
75
+ const taskSuccess = regTasks > 0 ? regSuccess : successes
76
+ const taskFail = regTasks > 0 ? regFail : failures
77
+ return {
78
+ agentId: agent.agentId,
79
+ hostType: agent.hostType,
80
+ sessionCount: agent.sessionCount,
81
+ registeredAt: agent.registeredAt,
82
+ lastSeenAt: agent.lastSeenAt,
83
+ taskOutcomes: {
84
+ total: taskTotal,
85
+ success: taskSuccess,
86
+ failure: taskFail,
87
+ successRate: taskTotal > 0 ? Math.round((taskSuccess / taskTotal) * 100) : 0,
88
+ },
89
+ memories: agentMemories.length,
90
+ experienceReuses: { total: agentReuses.length, verified: verified.length },
91
+ active: Date.now() - new Date(agent.lastSeenAt).getTime() < 24 * 60 * 60 * 1000,
92
+ }
93
+ })
94
+ return { project, count: reports.length, reports }
95
+ }
96
+
97
+ function registerAgent(args = {}) {
98
+ const agentId = String(args.agentId || '').trim()
99
+ if (!agentId) throw new Error('agent.register requires a non-empty agentId')
100
+ const project = args.project || projectName()
101
+ const hostType = args.hostType || 'mcp'
102
+ const capabilities = Array.isArray(args.capabilities) ? args.capabilities : []
103
+ const now = new Date().toISOString()
104
+ const agents = readJsonl(agentsPath)
105
+ const existing = agents.find((a) => a.agentId === agentId && a.project === project)
106
+ let result
107
+ if (existing) {
108
+ existing.lastSeenAt = now
109
+ existing.sessionCount = (existing.sessionCount || 1) + 1
110
+ existing.hostType = hostType
111
+ if (capabilities.length > 0) existing.capabilities = capabilities
112
+ result = existing
113
+ } else {
114
+ result = {
115
+ id: createId('agent'),
116
+ agentId,
117
+ hostType,
118
+ capabilities,
119
+ registeredAt: now,
120
+ lastSeenAt: now,
121
+ sessionCount: 1,
122
+ project,
123
+ }
124
+ agents.push(result)
125
+ }
126
+ writeJsonl(agentsPath, agents)
127
+ return {
128
+ id: result.id,
129
+ agentId: result.agentId,
130
+ registered: !existing,
131
+ sessionCount: result.sessionCount,
132
+ hostType: result.hostType,
133
+ capabilities: result.capabilities,
134
+ }
135
+ }
136
+
137
+ return { listAgents, reportAgent, registerAgent }
138
+ }
139
+
140
+ module.exports = { createAgentStore }
package/server/digest.js CHANGED
@@ -186,6 +186,18 @@ function createDigest(options = {}) {
186
186
  suggestions,
187
187
  }
188
188
  report.markdown = renderMarkdown(report)
189
+
190
+ // Persist a machine-readable snapshot for consumers that should not
191
+ // recompute the aggregation — notably mio.task.route's agent-health signal.
192
+ try {
193
+ const digestDir = path.join(home, 'digest')
194
+ fs.mkdirSync(digestDir, { recursive: true })
195
+ const snapshot = { ...report }
196
+ delete snapshot.markdown
197
+ fs.writeFileSync(path.join(digestDir, 'latest.json'), JSON.stringify(snapshot) + '\n', 'utf8')
198
+ } catch (_) {
199
+ // best-effort persistence; the in-memory report is still returned
200
+ }
189
201
  return report
190
202
  }
191
203