mio-agent-runtime 0.5.17 → 0.5.23

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,30 @@ function stateFile(home) {
65
65
  return path.join(home, 'observe-state.json')
66
66
  }
67
67
 
68
+ // Drop repeated lines from stored context lists, preserving the most recent
69
+ // occurrence order. Older builds could write the same line into every slot of
70
+ // a workspace block (a generated digest headline broadcast to all projects),
71
+ // so existing observe-state files still carry duplicates until they are
72
+ // rewritten. Normalising on load heals that data without a migration step.
73
+ function dedupeContexts(contexts) {
74
+ const out = {}
75
+ for (const [cwd, list] of Object.entries(contexts)) {
76
+ if (!Array.isArray(list)) {
77
+ out[cwd] = []
78
+ continue
79
+ }
80
+ const seen = new Set()
81
+ const deduped = []
82
+ for (const line of list) {
83
+ if (seen.has(line)) continue
84
+ seen.add(line)
85
+ deduped.push(line)
86
+ }
87
+ out[cwd] = deduped
88
+ }
89
+ return out
90
+ }
91
+
68
92
  function loadState(home) {
69
93
  try {
70
94
  const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
@@ -74,7 +98,7 @@ function loadState(home) {
74
98
  files: parsed.files || {},
75
99
  buffers: parsed.buffers || {},
76
100
  openTurns: parsed.openTurns || {},
77
- contexts: parsed.contexts || {},
101
+ contexts: dedupeContexts(parsed.contexts || {}),
78
102
  codexFiles: parsed.codexFiles || {},
79
103
  codexBuffers: parsed.codexBuffers || {},
80
104
  codexTurns: parsed.codexTurns || {},
@@ -86,6 +110,9 @@ function loadState(home) {
86
110
  hmSeeded: parsed.hmSeeded || false,
87
111
  hmLastMsgId: parsed.hmLastMsgId || 0,
88
112
  hmTurns: parsed.hmTurns || {},
113
+ clFiles: parsed.clFiles || {},
114
+ clBuffers: parsed.clBuffers || {},
115
+ clTurns: parsed.clTurns || {},
89
116
  }
90
117
  }
91
118
  } catch (_) {}
@@ -106,6 +133,9 @@ function loadState(home) {
106
133
  hmSeeded: false,
107
134
  hmLastMsgId: 0,
108
135
  hmTurns: {},
136
+ clFiles: {},
137
+ clBuffers: {},
138
+ clTurns: {},
109
139
  }
110
140
  }
111
141
 
@@ -282,10 +312,23 @@ const CONTEXT_MAX = 6
282
312
  // Maintain a compact 'recent Mio context' block inside <workspace>/AGENTS.md,
283
313
  // which WorkBuddy's MemoryLoader auto-injects into every session. This makes
284
314
  // retrieval guaranteed (not dependent on the model deciding to read memory).
285
- function updateProjectContext(state, cwd, line) {
315
+ function updateProjectContext(state, cwd, line, targetFile) {
286
316
  if (!cwd || !line) return
287
- const target = path.join(cwd, 'AGENTS.md')
288
- const list = state.contexts[cwd] || []
317
+ const target = path.join(cwd, targetFile || 'AGENTS.md')
318
+ // Normalise the whole list before inserting, not just the incoming line.
319
+ // Callers may load state without going through loadState (the digest
320
+ // write-back reads the file directly), so stale duplicates have to be
321
+ // cleared here or they keep consuming slots and push real history out.
322
+ const list = []
323
+ const seen = new Set()
324
+ for (const item of state.contexts[cwd] || []) {
325
+ if (seen.has(item)) continue
326
+ seen.add(item)
327
+ list.push(item)
328
+ }
329
+ // Re-inserting an existing line moves it to the front instead of duplicating.
330
+ const existing = list.indexOf(line)
331
+ if (existing !== -1) list.splice(existing, 1)
289
332
  list.unshift(line)
290
333
  if (list.length > CONTEXT_MAX) list.pop()
291
334
  state.contexts[cwd] = list
@@ -1133,6 +1176,275 @@ function runHermesCycle(home, state, sinks, opts) {
1133
1176
  }
1134
1177
 
1135
1178
 
1179
+ // ---------------------------------------------------------------------------
1180
+ // Claude Code session observation (passive, transcript JSONL tailing)
1181
+ // ---------------------------------------------------------------------------
1182
+
1183
+ // Claude Code writes append-only JSONL transcripts under
1184
+ // <configDir>/projects/<munged-cwd>/<sessionId>.jsonl. Each line carries
1185
+ // {type, message:{role, content}, cwd, sessionId, timestamp, uuid, ...}.
1186
+ // Assistant content items are text/thinking/tool_use; tool results come back
1187
+ // as user lines whose content array holds tool_result items with is_error.
1188
+ // A user prompt line (string content or a text item) closes the previous
1189
+ // turn; sidechain lines (subagent transcripts) are skipped as noise.
1190
+
1191
+ function claudeConfigDir() {
1192
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1193
+ }
1194
+
1195
+ function claudeProjectsDir() {
1196
+ return path.join(claudeConfigDir(), 'projects')
1197
+ }
1198
+
1199
+ function listClaudeTranscripts() {
1200
+ const root = claudeProjectsDir()
1201
+ const out = []
1202
+ if (!fs.existsSync(root)) return out
1203
+ const walk = (dir) => {
1204
+ let entries
1205
+ try {
1206
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1207
+ } catch (_) {
1208
+ return
1209
+ }
1210
+ for (const entry of entries) {
1211
+ const full = path.join(dir, entry.name)
1212
+ if (entry.isDirectory()) walk(full)
1213
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
1214
+ }
1215
+ }
1216
+ walk(root)
1217
+ return out
1218
+ }
1219
+
1220
+ function createClaudeTurn(meta) {
1221
+ meta = meta || {}
1222
+ return {
1223
+ id: null,
1224
+ ts: null,
1225
+ sessionId: meta.sessionId || null,
1226
+ cwd: meta.cwd || null,
1227
+ project: meta.cwd ? path.basename(meta.cwd) : 'unknown',
1228
+ lastText: null,
1229
+ toolCount: 0,
1230
+ errors: [],
1231
+ apiError: false,
1232
+ }
1233
+ }
1234
+
1235
+ function claudeContentItems(obj) {
1236
+ const content = obj && obj.message && obj.message.content
1237
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
1238
+ return Array.isArray(content) ? content.filter(Boolean) : []
1239
+ }
1240
+
1241
+ function finishClaudeTurn(turn, outcome) {
1242
+ return {
1243
+ id: turn.id,
1244
+ ts: turn.ts,
1245
+ sessionId: turn.sessionId,
1246
+ cwd: turn.cwd,
1247
+ project: turn.project,
1248
+ lastText: turn.lastText,
1249
+ toolCount: turn.toolCount,
1250
+ errors: turn.errors.slice(),
1251
+ apiError: turn.apiError,
1252
+ outcome: outcome,
1253
+ }
1254
+ }
1255
+
1256
+ function feedClaudeLine(turn, obj) {
1257
+ if (!obj || typeof obj !== 'object') return { next: turn }
1258
+ // Subagent (sidechain) transcripts duplicate main-thread work; skip them.
1259
+ if (obj.isSidechain === true) return { next: turn }
1260
+ if (obj.sessionId) turn.sessionId = obj.sessionId
1261
+ if (obj.cwd) {
1262
+ turn.cwd = obj.cwd
1263
+ turn.project = path.basename(obj.cwd)
1264
+ }
1265
+
1266
+ if (obj.type === 'user') {
1267
+ const items = claudeContentItems(obj)
1268
+ let hasToolError = false
1269
+ for (const item of items) {
1270
+ if (item.type === 'tool_result' && item.is_error) {
1271
+ turn.errors.push({ toolUseId: item.tool_use_id || 'unknown' })
1272
+ hasToolError = true
1273
+ }
1274
+ }
1275
+ if (turn.errors.length > 20) turn.errors.splice(0, turn.errors.length - 20)
1276
+
1277
+ // tool_result-only user lines continue the current turn; a string content
1278
+ // or text item means a fresh prompt, which closes the previous turn.
1279
+ const isPrompt =
1280
+ typeof (obj.message && obj.message.content) === 'string' ||
1281
+ items.some((item) => item.type === 'text')
1282
+ if (isPrompt) {
1283
+ let finalized = null
1284
+ if (turn.id) {
1285
+ finalized = finishClaudeTurn(turn, turn.apiError ? 'failure' : turn.lastText ? 'success' : 'aborted')
1286
+ }
1287
+ const next = createClaudeTurn({ sessionId: turn.sessionId, cwd: turn.cwd })
1288
+ next.id = obj.uuid || null
1289
+ next.ts = obj.timestamp ? Date.parse(obj.timestamp) || Date.now() : Date.now()
1290
+ return { finalized, next }
1291
+ }
1292
+ return { next: turn }
1293
+ }
1294
+
1295
+ if (obj.type === 'assistant') {
1296
+ for (const item of claudeContentItems(obj)) {
1297
+ if (item.type === 'text' && typeof item.text === 'string' && item.text.trim()) {
1298
+ turn.lastText = item.text
1299
+ } else if (item.type === 'tool_use') {
1300
+ turn.toolCount += 1
1301
+ }
1302
+ }
1303
+ if (obj.isApiErrorMessage === true || obj.error) turn.apiError = true
1304
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN
1305
+ if (Number.isFinite(ts)) turn.ts = ts
1306
+ return { next: turn }
1307
+ }
1308
+
1309
+ return { next: turn }
1310
+ }
1311
+
1312
+ function emitClaudeOutcome(turn, sinks, state) {
1313
+ if (!turn.id) return false
1314
+ const summary = cleanText(turn.lastText)
1315
+ if (!summary && !turn.apiError) return false
1316
+ const outcome = turn.apiError && turn.outcome !== 'failure' ? 'failure' : turn.outcome
1317
+ const turnToken = String(turn.id).slice(0, 8)
1318
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1319
+
1320
+ sinks.trace({
1321
+ trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
1322
+ event_type: 'task_outcome',
1323
+ outcome: outcome,
1324
+ payload: {
1325
+ host: 'claude-code',
1326
+ project: turn.project,
1327
+ sessionId: turn.sessionId,
1328
+ turnId: turn.id,
1329
+ toolCalls: turn.toolCount,
1330
+ errors: turn.errors.length,
1331
+ summary: summary.slice(0, 300),
1332
+ source: 'claude-observer',
1333
+ },
1334
+ agent: 'claude-code',
1335
+ host: 'claude-code',
1336
+ project: turn.project,
1337
+ })
1338
+
1339
+ if (outcome === 'success' && summary.length >= 10) {
1340
+ sinks.memory({
1341
+ kind: 'note',
1342
+ content: '[claude-code] 任务完成: ' + summary.slice(0, 160),
1343
+ tags: ['task-outcome', 'claude-code', turn.project],
1344
+ project: turn.project,
1345
+ source: 'claude-observer',
1346
+ })
1347
+ // Claude Code auto-loads <workspace>/CLAUDE.md, so write the context
1348
+ // block there instead of AGENTS.md.
1349
+ if (turn.cwd) {
1350
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1351
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80), 'CLAUDE.md')
1352
+ }
1353
+ }
1354
+
1355
+ for (const err of turn.errors) {
1356
+ sinks.trace({
1357
+ trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.toolUseId || 'err').slice(0, 12),
1358
+ event_type: 'error',
1359
+ outcome: 'error',
1360
+ payload: {
1361
+ host: 'claude-code',
1362
+ project: turn.project,
1363
+ sessionId: turn.sessionId,
1364
+ turnId: turn.id,
1365
+ toolUseId: err.toolUseId,
1366
+ },
1367
+ agent: 'claude-code',
1368
+ host: 'claude-code',
1369
+ project: turn.project,
1370
+ })
1371
+ }
1372
+ return true
1373
+ }
1374
+
1375
+ function runClaudeCycle(home, state, sinks, opts) {
1376
+ const out = { files: 0, lines: 0, outcomes: 0 }
1377
+ const files = listClaudeTranscripts()
1378
+ const now = Date.now()
1379
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1380
+
1381
+ for (const file of files) {
1382
+ out.files += 1
1383
+ let st
1384
+ try {
1385
+ st = fs.statSync(file)
1386
+ } catch (_) {
1387
+ continue
1388
+ }
1389
+ const info = state.clFiles[file] || { size: 0 }
1390
+ if (!state.clFiles[file] && now - st.mtimeMs > maxAgeMs) continue
1391
+ const size = st.size
1392
+ if (size < info.size) {
1393
+ info.size = 0
1394
+ state.clBuffers[file] = ''
1395
+ }
1396
+
1397
+ let turn = state.clTurns[file] || createClaudeTurn()
1398
+
1399
+ if (size > info.size) {
1400
+ let chunk = ''
1401
+ try {
1402
+ const fd = fs.openSync(file, 'r')
1403
+ const buf = Buffer.alloc(size - info.size)
1404
+ fs.readSync(fd, buf, 0, buf.length, info.size)
1405
+ fs.closeSync(fd)
1406
+ chunk = buf.toString('utf8')
1407
+ } catch (_) {
1408
+ continue
1409
+ }
1410
+ const pending = (state.clBuffers[file] || '') + chunk
1411
+ const lines = pending.split(/\r?\n/)
1412
+ state.clBuffers[file] = lines.pop()
1413
+ for (const line of lines) {
1414
+ if (!line.trim()) continue
1415
+ out.lines += 1
1416
+ let obj
1417
+ try {
1418
+ obj = JSON.parse(line)
1419
+ } catch (_) {
1420
+ continue
1421
+ }
1422
+ const result = feedClaudeLine(turn, obj)
1423
+ turn = result.next
1424
+ if (result.finalized) {
1425
+ if (emitClaudeOutcome(result.finalized, sinks, state)) out.outcomes += 1
1426
+ }
1427
+ }
1428
+ state.clFiles[file] = { size: size, mtimeMs: st.mtimeMs }
1429
+ }
1430
+
1431
+ // Idle finalize: the session has answered but no new prompt arrived yet.
1432
+ if (turn.id && turn.lastText && now - st.mtimeMs > IDLE_MS) {
1433
+ if (emitClaudeOutcome(finishClaudeTurn(turn, turn.apiError ? 'failure' : 'success'), sinks, state)) {
1434
+ out.outcomes += 1
1435
+ }
1436
+ state.clTurns[file] = null
1437
+ } else if (turn.id) {
1438
+ state.clTurns[file] = turn
1439
+ } else {
1440
+ state.clTurns[file] = null
1441
+ }
1442
+ }
1443
+
1444
+ return out
1445
+ }
1446
+
1447
+
1136
1448
  // ---------------------------------------------------------------------------
1137
1449
  // cycle
1138
1450
  // ---------------------------------------------------------------------------
@@ -1225,6 +1537,11 @@ function runCycle(home, state, sinks, opts) {
1225
1537
  out.lines += hmRes.lines
1226
1538
  out.outcomes += hmRes.outcomes
1227
1539
 
1540
+ const clRes = runClaudeCycle(home, state, sinks, opts)
1541
+ out.files += clRes.files
1542
+ out.lines += clRes.lines
1543
+ out.outcomes += clRes.outcomes
1544
+
1228
1545
  saveState(home, state)
1229
1546
  return out
1230
1547
  }
@@ -1311,12 +1628,17 @@ module.exports = {
1311
1628
  workbuddyConfigDir,
1312
1629
  loadState,
1313
1630
  saveState,
1631
+ dedupeContexts,
1314
1632
  updateProjectContext,
1315
1633
  runCycle,
1316
1634
  opencodeDbPath,
1317
1635
  runOpencodeCycle,
1318
1636
  runHermesCycle,
1319
1637
  hermesDbPath,
1638
+ claudeProjectsDir,
1639
+ runClaudeCycle,
1640
+ createClaudeTurn,
1641
+ feedClaudeLine,
1320
1642
  createSinks,
1321
1643
  startForeground,
1322
1644
  startBackground,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mio-agent-runtime",
3
- "version": "0.5.17",
3
+ "version": "0.5.23",
4
4
  "description": "Mio Agent Runtime installer and MCP intelligence layer",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -17,7 +17,8 @@
17
17
  "!server/mio-intelligence-mcp/__tests__"
18
18
  ],
19
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 server/runtime-modules.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",
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
+ "test": "node --test \"__tests__/*.test.js\" \"server/mio-intelligence-mcp/__tests__/*.test.js\"",
21
22
  "prepack": "npm run check",
22
23
  "verify:pack-install": "node scripts/verify-packed-runtime.js"
23
24
  },
@@ -34,5 +35,11 @@
34
35
  "@akemi-mio/evolution-scheduler": "^0.1.0",
35
36
  "@akemi-mio/observer": "^0.1.0",
36
37
  "@akemi-mio/insight": "^0.1.0"
37
- }
38
+ },
39
+ "main": "index.js",
40
+ "directories": {
41
+ "doc": "docs"
42
+ },
43
+ "keywords": [],
44
+ "author": ""
38
45
  }
@@ -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,