mio-agent-runtime 0.5.14 → 0.5.16

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.
@@ -1,1335 +1,1326 @@
1
- #!/usr/bin/env node
2
- 'use strict'
3
-
4
- const fs = require('fs')
5
- const path = require('path')
6
- const os = require('os')
7
- const crypto = require('crypto')
8
- const { spawn, spawnSync } = require('child_process')
9
-
10
- const INTERVAL_MS = 3000
11
- // A WorkBuddy turn is considered finished when its transcript file has not
12
- // been appended for this long AND an assistant answer is present.
13
- const IDLE_MS = 15000
14
-
15
- function homeDir() {
16
- return process.env.MIO_HOME || path.join(os.homedir(), '.mio-intelligence')
17
- }
18
-
19
- function workbuddyConfigDir() {
20
- return (
21
- process.env.WORKBUDDY_CONFIG_DIR ||
22
- process.env.CODEBUDDY_CONFIG_DIR ||
23
- path.join(os.homedir(), '.workbuddy')
24
- )
25
- }
26
-
27
- function createId(prefix) {
28
- return prefix + '_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex')
29
- }
30
-
31
- function cleanText(text) {
32
- return String(text || '')
33
- .replace(/\r/g, '')
34
- .replace(/#{2,}|\*{2,}|`{1,}|_{2,}|~{2,}|-{2,}/g, ' ')
35
- .replace(/\s{2,}/g, ' ')
36
- .trim()
37
- }
38
-
39
- function projectFromDirName(dirName) {
40
- // WorkBuddy project dirs encode the workspace path with dashes, e.g.
41
- // "d-work-code-douyin_store" -> "douyin_store"
42
- const parts = String(dirName || '').split('-').filter(Boolean)
43
- if (parts.length > 1 && /^\\d+$/.test(parts[parts.length - 1])) {
44
- return dirName || 'unknown'
45
- }
46
- return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
47
- }
48
-
49
- function log(home, message) {
50
- const line = '[' + new Date().toISOString() + '] ' + message + '\n'
51
- try {
52
- fs.mkdirSync(path.join(home, 'logs'), { recursive: true })
53
- fs.appendFileSync(path.join(home, 'logs', 'observe.log'), line, 'utf8')
54
- } catch (_) {}
55
- if (process.stdout.isTTY || process.env.MIO_OBSERVE_VERBOSE) {
56
- process.stdout.write(line)
57
- }
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // state
62
- // ---------------------------------------------------------------------------
63
-
64
- function stateFile(home) {
65
- return path.join(home, 'observe-state.json')
66
- }
67
-
68
- function loadState(home) {
69
- try {
70
- const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
71
- if (parsed && typeof parsed === 'object') {
72
- return {
73
- version: 1,
74
- files: parsed.files || {},
75
- buffers: parsed.buffers || {},
76
- openTurns: parsed.openTurns || {},
77
- contexts: parsed.contexts || {},
78
- codexFiles: parsed.codexFiles || {},
79
- codexBuffers: parsed.codexBuffers || {},
80
- codexTurns: parsed.codexTurns || {},
81
- ocSeeded: parsed.ocSeeded || false,
82
- ocLastRowid: parsed.ocLastRowid || 0,
83
- ocDbMtime: parsed.ocDbMtime || null,
84
- ocPending: parsed.ocPending || false,
85
- ocTurns: parsed.ocTurns || {},
86
- hmSeeded: parsed.hmSeeded || false,
87
- hmLastMsgId: parsed.hmLastMsgId || 0,
88
- hmTurns: parsed.hmTurns || {},
89
- }
90
- }
91
- } catch (_) {}
92
- return {
93
- version: 1,
94
- files: {},
95
- buffers: {},
96
- openTurns: {},
97
- contexts: {},
98
- codexFiles: {},
99
- codexBuffers: {},
100
- codexTurns: {},
101
- ocSeeded: false,
102
- ocLastRowid: 0,
103
- ocDbMtime: null,
104
- ocPending: false,
105
- ocTurns: {},
106
- hmSeeded: false,
107
- hmLastMsgId: 0,
108
- hmTurns: {},
109
- }
110
- }
111
-
112
- function saveState(home, state) {
113
- try {
114
- fs.mkdirSync(home, { recursive: true })
115
- fs.writeFileSync(stateFile(home), JSON.stringify(state) + '\n', 'utf8')
116
- } catch (_) {}
117
- }
118
-
119
- function listTranscripts() {
120
- const root = path.join(workbuddyConfigDir(), 'projects')
121
- const out = []
122
- if (!fs.existsSync(root)) return out
123
- const walk = (dir) => {
124
- let entries
125
- try {
126
- entries = fs.readdirSync(dir, { withFileTypes: true })
127
- } catch (_) {
128
- return
129
- }
130
- for (const entry of entries) {
131
- const full = path.join(dir, entry.name)
132
- if (entry.isDirectory()) walk(full)
133
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
134
- }
135
- }
136
- walk(root)
137
- return out
138
- }
139
-
140
- function codexConfigDir() {
141
- return process.env.CODEX_CONFIG_DIR || path.join(os.homedir(), '.codex')
142
- }
143
-
144
- function listCodexTranscripts() {
145
- const root = path.join(codexConfigDir(), 'sessions')
146
- const out = []
147
- if (!fs.existsSync(root)) return out
148
- const walk = (dir) => {
149
- let entries
150
- try {
151
- entries = fs.readdirSync(dir, { withFileTypes: true })
152
- } catch (_) {
153
- return
154
- }
155
- for (const entry of entries) {
156
- const full = path.join(dir, entry.name)
157
- if (entry.isDirectory()) walk(full)
158
- else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) out.push(full)
159
- }
160
- }
161
- walk(root)
162
- return out
163
- }
164
-
165
- function fileMeta(file) {
166
- const dirName = path.basename(path.dirname(file))
167
- return {
168
- sessionId: path.basename(file, '.jsonl'),
169
- project: projectFromDirName(dirName),
170
- }
171
- }
172
-
173
- // ---------------------------------------------------------------------------
174
- // sinks (write into MIO_HOME with the same schema as the MCP server)
175
- // ---------------------------------------------------------------------------
176
-
177
- function appendJsonl(file, value) {
178
- fs.mkdirSync(path.dirname(file), { recursive: true })
179
- fs.appendFileSync(file, JSON.stringify(value) + '\n', 'utf8')
180
- }
181
-
182
- function createSinks(home) {
183
- const tracePath = path.join(home, 'traces.jsonl')
184
- const memoryPath = path.join(home, 'memory.jsonl')
185
- const seenTraceIds = new Set()
186
- const seenMemoryContents = new Set()
187
-
188
- function trace(event) {
189
- const record = {
190
- id: createId('trace'),
191
- timestamp: new Date().toISOString(),
192
- trace_id: event.trace_id,
193
- event_type: event.event_type,
194
- outcome: event.outcome || null,
195
- payload: event.payload || {},
196
- agent: event.agent || 'workbuddy-observer',
197
- host: event.host || 'workbuddy-observer',
198
- project: event.project || 'unknown',
199
- }
200
- if (seenTraceIds.has(record.trace_id)) return
201
- seenTraceIds.add(record.trace_id)
202
- if (seenTraceIds.size > 20000) seenTraceIds.clear()
203
- appendJsonl(tracePath, record)
204
- }
205
-
206
- function memory(entry) {
207
- const key = entry.project + '|' + entry.content
208
- if (seenMemoryContents.has(key)) return
209
- seenMemoryContents.add(key)
210
- const record = {
211
- id: createId('mem'),
212
- timestamp: new Date().toISOString(),
213
- kind: entry.kind || 'note',
214
- content: entry.content,
215
- tags: Array.isArray(entry.tags) ? entry.tags : [],
216
- project: entry.project || 'unknown',
217
- source: entry.source || 'workbuddy-observer',
218
- }
219
- appendJsonl(memoryPath, record)
220
- }
221
-
222
- return { trace, memory }
223
- }
224
-
225
- // ---------------------------------------------------------------------------
226
- // WorkBuddy transcript turn machine
227
- // ---------------------------------------------------------------------------
228
-
229
- function createTurn(meta) {
230
- return {
231
- id: null,
232
- ts: null,
233
- project: meta.project,
234
- sessionId: meta.sessionId,
235
- cwd: meta.cwd || null,
236
- toolCount: 0,
237
- errors: [],
238
- lastAssistant: null,
239
- }
240
- }
241
-
242
- function feedWorkbuddyLine(turn, obj) {
243
- if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
244
- let finalized = null
245
- if (obj.cwd) turn.cwd = obj.cwd
246
- if (obj.type === 'message') {
247
- if (obj.role === 'user') {
248
- finalized = turn
249
- const next = createTurn(turn)
250
- next.id = obj.id
251
- next.ts = obj.timestamp
252
- next.cwd = obj.cwd || turn.cwd
253
- return { finalized, next: next }
254
- }
255
- if (obj.role === 'assistant') {
256
- const text = (Array.isArray(obj.content) ? obj.content : [])
257
- .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
258
- .filter(Boolean)
259
- .join('\n')
260
- turn.lastAssistant = {
261
- status: obj.status,
262
- text: text,
263
- error: (obj.providerData && obj.providerData.error) || null,
264
- }
265
- }
266
- } else if (obj.type === 'function_call') {
267
- turn.toolCount += 1
268
- } else if (obj.type === 'function_call_result') {
269
- const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
270
- if (status !== 'completed') {
271
- turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
272
- if (turn.errors.length > 20) turn.errors.shift()
273
- }
274
- }
275
- return { finalized: finalized, next: turn }
276
- }
277
-
278
- const CONTEXT_BEGIN = '<!-- MIO_CONTEXT_BEGIN -->'
279
- const CONTEXT_END = '<!-- MIO_CONTEXT_END -->'
280
- const CONTEXT_MAX = 6
281
-
282
- // Maintain a compact 'recent Mio context' block inside <workspace>/AGENTS.md,
283
- // which WorkBuddy's MemoryLoader auto-injects into every session. This makes
284
- // retrieval guaranteed (not dependent on the model deciding to read memory).
285
- function updateProjectContext(state, cwd, line) {
286
- if (!cwd || !line) return
287
- const target = path.join(cwd, 'AGENTS.md')
288
- const list = state.contexts[cwd] || []
289
- list.unshift(line)
290
- if (list.length > CONTEXT_MAX) list.pop()
291
- state.contexts[cwd] = list
292
- try {
293
- let content = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
294
- const block = CONTEXT_BEGIN + '\n## Mio 最近上下文(自动注入)\n' + list.map(function (l) { return '- ' + l }).join('\n') + '\n' + CONTEXT_END
295
- const start = content.indexOf(CONTEXT_BEGIN)
296
- if (start !== -1) {
297
- const end = content.indexOf(CONTEXT_END, start)
298
- content = end === -1 ? content.slice(0, start) : content.slice(0, start) + content.slice(end + CONTEXT_END.length)
299
- }
300
- content = content.trimEnd() + '\n\n' + block + '\n'
301
- fs.mkdirSync(path.dirname(target), { recursive: true })
302
- fs.writeFileSync(target, content, 'utf8')
303
- log(homeDir(), 'write-back OK: MIO_CONTEXT -> ' + target)
304
- } catch (e) {
305
- log(homeDir(), 'write-back FAIL: MIO_CONTEXT -> ' + target + ': ' + (e && e.message || e))
306
- }
307
- }
308
-
309
- function localDateKey(ts) {
310
- const d = new Date(Number(ts) || Date.now())
311
- const y = d.getFullYear()
312
- const m = String(d.getMonth() + 1).padStart(2, '0')
313
- const day = String(d.getDate()).padStart(2, '0')
314
- return y + '-' + m + '-' + day
315
- }
316
-
317
- // Write a compact Mio digest back into WorkBuddy's own memory file
318
- // (<workspace>/.workbuddy/memory/YYYY-MM-DD.md). WorkBuddy instructs its model
319
- // to maintain and read these files every session, so the digest is
320
- // 'retrieved' next session without any Mio tool call by the model.
321
- function writeWorkbuddyDigest(turn, outcome, summary) {
322
- if (!turn.cwd || !summary) return
323
- const dir = path.join(turn.cwd, '.workbuddy', 'memory')
324
- const file = path.join(dir, localDateKey(turn.ts) + '.md')
325
- try {
326
- fs.mkdirSync(dir, { recursive: true })
327
- const time = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
328
- const entry =
329
- '\n## Mio 自动摘要 (' + time + ')\n' +
330
- '- outcome: ' + outcome + ' | tools: ' + turn.toolCount + ' | ' + summary.slice(0, 160) + '\n'
331
- fs.appendFileSync(file, entry, 'utf8')
332
- log(homeDir(), 'write-back OK: workbuddy digest -> ' + file)
333
- } catch (e) {
334
- log(homeDir(), 'write-back FAIL: workbuddy digest -> ' + file + ': ' + (e && e.message || e))
335
- }
336
- }
337
-
338
- function emitWorkbuddyOutcome(turn, sinks, state) {
339
- const la = turn.lastAssistant
340
- if (!la) return false
341
- let outcome
342
- if (la.status === 'completed') outcome = 'success'
343
- else if (la.error) outcome = 'failure'
344
- else outcome = 'aborted'
345
-
346
- const summary = cleanText(la.text)
347
- const turnToken = String(turn.id || 'unknown').slice(0, 8)
348
- const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
349
-
350
- sinks.trace({
351
- trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
352
- event_type: 'task_outcome',
353
- outcome: outcome,
354
- payload: {
355
- host: 'workbuddy',
356
- project: turn.project,
357
- sessionId: turn.sessionId,
358
- turnId: turn.id,
359
- toolCalls: turn.toolCount,
360
- errors: turn.errors.length,
361
- summary: summary.slice(0, 300),
362
- },
363
- agent: 'workbuddy',
364
- host: 'workbuddy',
365
- project: turn.project,
366
- })
367
-
368
- // Low threshold: even a short but real completion summary must reach the
369
- // passive retrieval paths (memory + daily digest + AGENTS.md context block).
370
- // 40 was too strict and silently dropped short summaries from retrieval.
371
- if (outcome === 'success' && summary.length >= 10) {
372
- sinks.memory({
373
- kind: 'note',
374
- content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
375
- tags: ['task-outcome', 'workbuddy', turn.project],
376
- project: turn.project,
377
- source: 'workbuddy-observer',
378
- })
379
- log(homeDir(), '[workbuddy] write-back: ' + memLabel + ' -> memory + ' + (turn.cwd || '?') + '/AGENTS.md')
380
- writeWorkbuddyDigest(turn, outcome, summary)
381
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
382
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
383
- }
384
-
385
- for (const err of turn.errors) {
386
- sinks.trace({
387
- trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
388
- event_type: 'error',
389
- outcome: 'error',
390
- payload: {
391
- host: 'workbuddy',
392
- project: turn.project,
393
- sessionId: turn.sessionId,
394
- turnId: turn.id,
395
- tool: err.name,
396
- status: err.status,
397
- },
398
- agent: 'workbuddy',
399
- host: 'workbuddy',
400
- project: turn.project,
401
- })
402
- }
403
- return true
404
- }
405
-
406
- // ---------------------------------------------------------------------------
407
- // Codex session turn machine (passive observation)
408
- // ---------------------------------------------------------------------------
409
-
410
- // Codex rollout transcripts are append-only JSONL under
411
- // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The first session_meta line
412
- // carries the workspace cwd; task_started / task_complete / turn_aborted mark
413
- // turn boundaries, agent_message carries the running answer text.
414
- function createCodexTurn() {
415
- return {
416
- id: null,
417
- ts: null,
418
- sessionId: null,
419
- cwd: null,
420
- lastAgent: null,
421
- aborted: false,
422
- }
423
- }
424
-
425
- function feedCodexLine(turn, obj) {
426
- if (!obj || typeof obj !== 'object') return {}
427
- if (obj.type === 'session_meta') {
428
- const p = obj.payload || {}
429
- if (!turn.cwd && p.cwd) turn.cwd = p.cwd
430
- if (!turn.sessionId && (p.session_id || p.id)) turn.sessionId = p.session_id || p.id
431
- return {}
432
- }
433
- if (obj.type !== 'event_msg') return {}
434
- const p = obj.payload || {}
435
- if (p.type === 'task_started') {
436
- // A turn that started earlier but never completed is discarded.
437
- turn.id = p.turn_id || null
438
- turn.ts = p.started_at ? Number(p.started_at) * 1000 : null
439
- turn.lastAgent = null
440
- turn.aborted = false
441
- return {}
442
- }
443
- if (p.type === 'agent_message' && p.message) {
444
- turn.lastAgent = p.message
445
- return {}
446
- }
447
- if (p.type === 'task_complete') {
448
- const finalized = {
449
- id: turn.id,
450
- ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
451
- sessionId: turn.sessionId,
452
- cwd: turn.cwd,
453
- project: path.basename(turn.cwd || '') || 'unknown',
454
- lastAgent: p.last_agent_message || turn.lastAgent,
455
- aborted: false,
456
- }
457
- turn.id = null
458
- turn.lastAgent = null
459
- return { finalized: finalized }
460
- }
461
- if (p.type === 'turn_aborted') {
462
- const finalized = {
463
- id: turn.id,
464
- ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
465
- sessionId: turn.sessionId,
466
- cwd: turn.cwd,
467
- project: path.basename(turn.cwd || '') || 'unknown',
468
- lastAgent: turn.lastAgent,
469
- aborted: true,
470
- }
471
- turn.id = null
472
- turn.lastAgent = null
473
- return { finalized: finalized }
474
- }
475
- return {}
476
- }
477
-
478
- function emitCodexOutcome(turn, sinks, state) {
479
- if (!turn.lastAgent) return false
480
- const summary = cleanText(turn.lastAgent)
481
- if (!summary) return false
482
- const outcome = turn.aborted ? 'aborted' : 'success'
483
- const turnToken = String(turn.id || 'unknown').slice(0, 8)
484
-
485
- sinks.trace({
486
- trace_id: 'codex:' + (turn.sessionId || 'unknown') + ':' + turnToken,
487
- event_type: 'task_outcome',
488
- outcome: outcome,
489
- payload: {
490
- host: 'codex',
491
- project: turn.project,
492
- sessionId: turn.sessionId,
493
- turnId: turn.id,
494
- toolCalls: 0,
495
- errors: 0,
496
- summary: summary.slice(0, 300),
497
- source: 'codex-observer',
498
- },
499
- agent: 'codex',
500
- host: 'codex',
501
- project: turn.project,
502
- })
503
-
504
- if (outcome === 'success' && summary.length >= 10) {
505
- sinks.memory({
506
- kind: 'note',
507
- content: '[codex] 任务完成: ' + summary.slice(0, 160),
508
- tags: ['task-outcome', 'codex', turn.project],
509
- project: turn.project,
510
- source: 'codex-observer',
511
- })
512
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
513
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
514
- }
515
- return true
516
- }
517
-
518
- function runCodexCycle(home, state, sinks, opts) {
519
- const out = { files: 0, lines: 0, outcomes: 0 }
520
- const files = listCodexTranscripts()
521
- const now = Date.now()
522
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
523
-
524
- for (const file of files) {
525
- out.files += 1
526
- let st
527
- try {
528
- st = fs.statSync(file)
529
- } catch (_) {
530
- continue
531
- }
532
- const info = state.codexFiles[file] || { size: 0 }
533
- if (!state.codexFiles[file] && now - st.mtimeMs > maxAgeMs) continue
534
- const size = st.size
535
- if (size < info.size) {
536
- info.size = 0
537
- state.codexBuffers[file] = ''
538
- }
539
-
540
- if (!state.codexTurns[file]) state.codexTurns[file] = createCodexTurn()
541
- const turn = state.codexTurns[file]
542
-
543
- if (size > info.size) {
544
- let chunk = ''
545
- try {
546
- const fd = fs.openSync(file, 'r')
547
- const buf = Buffer.alloc(size - info.size)
548
- fs.readSync(fd, buf, 0, buf.length, info.size)
549
- fs.closeSync(fd)
550
- chunk = buf.toString('utf8')
551
- } catch (_) {
552
- continue
553
- }
554
- const pending = (state.codexBuffers[file] || '') + chunk
555
- const lines = pending.split(/\r?\n/)
556
- state.codexBuffers[file] = lines.pop()
557
- for (const line of lines) {
558
- if (!line.trim()) continue
559
- out.lines += 1
560
- let obj
561
- try {
562
- obj = JSON.parse(line)
563
- } catch (_) {
564
- continue
565
- }
566
- const result = feedCodexLine(turn, obj)
567
- if (result.finalized) {
568
- if (emitCodexOutcome(result.finalized, sinks, state)) out.outcomes += 1
569
- }
570
- }
571
- state.codexFiles[file] = { size: size, mtimeMs: st.mtimeMs }
572
- }
573
- }
574
-
575
- return out
576
- }
577
-
578
- // ---------------------------------------------------------------------------
579
- // OpenCode session observation (passive, via `opencode db`)
580
- // ---------------------------------------------------------------------------
581
-
582
- // OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
583
- // On Windows the db file is held with an exclusive lock while OpenCode runs,
584
- // so we cannot open it with node:sqlite directly. Instead we shell out to the
585
- // `opencode db` CLI, which opens the same database through the app itself and
586
- // works even while OpenCode is running. We tail `part` rows by rowid so each
587
- // session is scanned incrementally and only text/tool parts are transferred.
588
-
589
- function opencodeDataDir() {
590
- return (
591
- process.env.OPENCODE_DATA_DIR ||
592
- path.join(os.homedir(), '.local', 'share', 'opencode')
593
- )
594
- }
595
-
596
- function opencodeDbPath() {
597
- return path.join(opencodeDataDir(), 'opencode.db')
598
- }
599
-
600
- function findOpencodeBin() {
601
- if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
602
- return process.env.OPENCODE_BIN
603
- }
604
- const exeDir = path.dirname(process.execPath)
605
- const candidates = [
606
- path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
607
- path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
608
- path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
609
- ]
610
- for (const c of candidates) {
611
- const resolved = path.resolve(c)
612
- if (fs.existsSync(resolved)) return resolved
613
- }
614
- // Resolve from PATH so the SQL is never passed through cmd.exe, where
615
- // '>' in the query is interpreted as output redirection and creates
616
- // stray files (e.g. "WHERE p.rowid > 0" -> a file named "0").
617
- try {
618
- const finder = process.platform === 'win32' ? 'where' : 'which'
619
- const res = spawnSync(finder, ['opencode'], { encoding: 'utf8', windowsHide: true, timeout: 15000 })
620
- if (res.status === 0 && res.stdout) {
621
- const lines = res.stdout
622
- .split(/\r?\n/)
623
- .map((s) => s.trim())
624
- .filter(Boolean)
625
- const preferred =
626
- process.platform === 'win32'
627
- ? lines.filter((p) => /\.(exe|cmd|bat)$/i.test(p))
628
- : lines
629
- const first = (preferred.length > 0 ? preferred : lines).find((p) => fs.existsSync(p))
630
- if (first) return first
631
- }
632
- } catch (_) {}
633
- return null
634
- }
635
-
636
- function runOpencodeQuery(sql) {
637
- const bin = findOpencodeBin()
638
- // Never shell the SQL through cmd.exe: '>' in the query would be parsed
639
- // as output redirection, creating files named after the redirect target.
640
- if (!bin) return null
641
- const args = ['db', sql, '--format', 'json']
642
- let res
643
- try {
644
- res = spawnSync(bin, args, { encoding: 'utf8', timeout: 90000, windowsHide: true, maxBuffer: 256 * 1024 * 1024 })
645
- } catch (_) {
646
- return null
647
- }
648
- if (res.error || res.status !== 0 || !res.stdout) return null
649
- try {
650
- return JSON.parse(res.stdout)
651
- } catch (_) {
652
- return null
653
- }
654
- }
655
-
656
- // Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
657
- // keep the transferred payload small.
658
- const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
659
-
660
- function createOpencodeTurn(meta) {
661
- meta = meta || {}
662
- return {
663
- id: null,
664
- ts: null,
665
- sessionId: meta.sessionId || null,
666
- cwd: meta.directory || null,
667
- project: meta.directory ? path.basename(meta.directory) : 'unknown',
668
- agent: meta.agent || null,
669
- title: meta.title || null,
670
- lastMid: null,
671
- lastText: null,
672
- toolCount: 0,
673
- errors: [],
674
- pendingTerminal: null,
675
- done: false,
676
- }
677
- }
678
-
679
- function opencodeOutcomeFor(finish) {
680
- if (finish === 'error') return 'failure'
681
- if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
682
- return 'success'
683
- }
684
-
685
- function finalizeOpencodeTurn(turn, outcome) {
686
- if (!turn.id) return null
687
- const finalized = {
688
- id: turn.id,
689
- ts: turn.ts,
690
- sessionId: turn.sessionId,
691
- cwd: turn.cwd,
692
- project: turn.project,
693
- agent: turn.agent,
694
- title: turn.title,
695
- lastText: turn.lastText,
696
- toolCount: turn.toolCount,
697
- errors: turn.errors.slice(),
698
- outcome: outcome,
699
- }
700
- turn.done = true
701
- return finalized
702
- }
703
-
704
- function applyOpencodeAssistantPart(turn, pd, md, row) {
705
- if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
706
- turn.lastText = pd.text
707
- } else if (pd.type === 'tool') {
708
- turn.toolCount += 1
709
- const status = pd.state && pd.state.status
710
- if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
711
- turn.errors.push({ tool: pd.tool || 'unknown', status: status })
712
- if (turn.errors.length > 20) turn.errors.shift()
713
- }
714
- }
715
- if (md.finish && md.finish !== 'tool-calls') {
716
- turn.pendingTerminal = md.finish
717
- }
718
- turn.lastMid = row.mid
719
- }
720
-
721
- function feedOpencodePart(turn, row) {
722
- let pd
723
- let md
724
- try {
725
- pd = JSON.parse(row.pd)
726
- } catch (_) {
727
- pd = null
728
- }
729
- try {
730
- md = JSON.parse(row.md)
731
- } catch (_) {
732
- md = null
733
- }
734
- if (!pd || !md) return { next: turn }
735
-
736
- // A user message starts a new turn; any unfinished previous turn is aborted.
737
- if (md.role === 'user') {
738
- let finalized = null
739
- if (turn.id && !turn.done) {
740
- finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
741
- }
742
- const next = createOpencodeTurn({
743
- sessionId: row.sid,
744
- directory: row.sdir,
745
- title: row.stitle,
746
- agent: row.sagent,
747
- })
748
- next.id = row.mid
749
- next.ts = Number(row.t) || Date.now()
750
- next.lastMid = row.mid
751
- return { finalized, next }
752
- }
753
-
754
- if (turn.done) return { next: turn }
755
-
756
- // Message boundary after a terminal finish closes the previous turn.
757
- if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
758
- const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
759
- const next = createOpencodeTurn({
760
- sessionId: row.sid,
761
- directory: row.sdir,
762
- title: row.stitle,
763
- agent: row.sagent,
764
- })
765
- applyOpencodeAssistantPart(next, pd, md, row)
766
- return { finalized, next }
767
- }
768
-
769
- applyOpencodeAssistantPart(turn, pd, md, row)
770
- return { next: turn }
771
- }
772
-
773
- function emitOpencodeOutcome(turn, sinks, state) {
774
- if (!turn.id) return false
775
- const summary = cleanText(turn.lastText)
776
- if (!summary) return false
777
- const outcome = turn.outcome
778
- const turnToken = String(turn.id).slice(0, 8)
779
- const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
780
-
781
- sinks.trace({
782
- trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
783
- event_type: 'task_outcome',
784
- outcome: outcome,
785
- payload: {
786
- host: 'opencode',
787
- project: turn.project,
788
- sessionId: turn.sessionId,
789
- turnId: turn.id,
790
- title: turn.title,
791
- agent: turn.agent,
792
- toolCalls: turn.toolCount,
793
- errors: turn.errors.length,
794
- summary: summary.slice(0, 300),
795
- source: 'opencode-observer',
796
- },
797
- agent: 'opencode',
798
- host: 'opencode',
799
- project: turn.project,
800
- })
801
-
802
- if (outcome === 'success' && summary.length >= 10) {
803
- sinks.memory({
804
- kind: 'note',
805
- content: '[opencode] 任务完成: ' + summary.slice(0, 160),
806
- tags: ['task-outcome', 'opencode', turn.project],
807
- project: turn.project,
808
- source: 'opencode-observer',
809
- })
810
- log(homeDir(), '[opencode] write-back: ' + memLabel + ' -> memory + ' + (turn.cwd || '?') + '/AGENTS.md')
811
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
812
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
813
- }
814
-
815
- for (const err of turn.errors) {
816
- sinks.trace({
817
- trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
818
- event_type: 'error',
819
- outcome: 'error',
820
- payload: {
821
- host: 'opencode',
822
- project: turn.project,
823
- sessionId: turn.sessionId,
824
- turnId: turn.id,
825
- tool: err.tool,
826
- status: err.status,
827
- },
828
- agent: 'opencode',
829
- host: 'opencode',
830
- project: turn.project,
831
- })
832
- }
833
- return true
834
- }
835
-
836
- function runOpencodeCycle(home, state, sinks, opts) {
837
- const out = { files: 0, lines: 0, outcomes: 0 }
838
- const db = opencodeDbPath()
839
- if (!fs.existsSync(db)) return out
840
- let st
841
- try {
842
- st = fs.statSync(db)
843
- } catch (_) {
844
- return out
845
- }
846
- out.files = 1
847
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
848
-
849
- // First run: seed the rowid cursor to the first part written within the
850
- // backfill window so ancient history never floods MIO_HOME.
851
- if (!state.ocSeeded) {
852
- const ts = Date.now() - maxAgeMs
853
- const first = runOpencodeQuery('SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1')
854
- let rid = 0
855
- if (Array.isArray(first) && first.length > 0) {
856
- rid = Number(first[0].rowid) || 0
857
- } else {
858
- const maxRow = runOpencodeQuery('SELECT COALESCE(MAX(rowid), 0) AS rid FROM part')
859
- if (Array.isArray(maxRow) && maxRow.length > 0) rid = Number(maxRow[0].rid) || 0
860
- }
861
- state.ocLastRowid = rid
862
- state.ocSeeded = true
863
- state.ocDbMtime = st.mtimeMs
864
- state.ocPending = true
865
- return out
866
- }
867
-
868
- const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
869
- if (!changed && !state.ocPending) return out
870
-
871
- const limit = 1500
872
- const sql =
873
- 'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
874
- 'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
875
- 'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
876
- 'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
877
- ' ORDER BY p.rowid LIMIT ' + limit
878
- const rows = runOpencodeQuery(sql)
879
- if (!Array.isArray(rows)) return out
880
- state.ocDbMtime = st.mtimeMs
881
- if (rows.length === 0) {
882
- state.ocPending = false
883
- return out
884
- }
885
-
886
- let lastRid = state.ocLastRowid
887
- for (const row of rows) {
888
- const rid = Number(row.rid)
889
- if (rid > lastRid) lastRid = rid
890
- out.lines += 1
891
- const sid = row.sid
892
- let turn = state.ocTurns[sid]
893
- if (!turn) {
894
- turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
895
- state.ocTurns[sid] = turn
896
- }
897
- const result = feedOpencodePart(turn, row)
898
- if (result.finalized) {
899
- if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
900
- }
901
- state.ocTurns[sid] = result.next
902
- }
903
- state.ocLastRowid = lastRid
904
- state.ocPending = rows.length >= limit
905
-
906
- // Caught up: any turn holding a terminal finish is complete.
907
- if (!state.ocPending) {
908
- for (const sid of Object.keys(state.ocTurns)) {
909
- const turn = state.ocTurns[sid]
910
- if (turn && turn.id && turn.pendingTerminal && !turn.done) {
911
- const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
912
- if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
913
- state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
914
- }
915
- }
916
- }
917
- return out
918
- }
919
-
920
- // ---------------------------------------------------------------------------
921
- // Hermes session observation (passive, via its own SQLite state.db)
922
- // ---------------------------------------------------------------------------
923
-
924
- // Hermes (Nous Research hermes-agent) keeps every conversation in
925
- // <LocalAppData>/hermes/state.db. Unlike OpenCode the file is not exclusively
926
- // locked, and `messages.id` is a monotonic AUTOINCREMENT cursor, so we can tail
927
- // it read-only with node:sqlite (Node 22+). Each message is one row carrying
928
- // role / content / finish_reason, so a user row opens a turn and an assistant
929
- // row with a terminal finish_reason closes it immediately. Hermes already has
930
- // native memory, so this observer only imports Hermes experience into the Mio
931
- // ecosystem (traces + memory + AGENTS.md context when a git repo is present).
932
-
933
- let _hermesSqlite = null
934
- let _hermesSqliteTried = false
935
-
936
- function hermesSqliteModule() {
937
- if (_hermesSqliteTried) return _hermesSqlite
938
- _hermesSqliteTried = true
939
- try {
940
- _hermesSqlite = require('node:sqlite')
941
- } catch (_) {
942
- _hermesSqlite = null
943
- }
944
- return _hermesSqlite
945
- }
946
-
947
- function hermesDbPath() {
948
- return (
949
- process.env.HERMES_DATA_DIR ||
950
- path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'state.db')
951
- )
952
- }
953
-
954
- function hermesProjectName(meta) {
955
- if (meta.git) return path.basename(meta.git)
956
- if (meta.cwd && meta.cwd !== os.homedir()) return path.basename(meta.cwd)
957
- return 'hermes'
958
- }
959
-
960
- function createHermesTurn(meta) {
961
- meta = meta || {}
962
- return {
963
- id: null,
964
- ts: null,
965
- sessionId: meta.sessionId || null,
966
- cwd: meta.git || null,
967
- project: hermesProjectName({ git: meta.git, cwd: meta.projectHint }),
968
- title: meta.title || null,
969
- agent: meta.agent || null,
970
- lastText: null,
971
- toolCount: 0,
972
- lastMid: null,
973
- }
974
- }
975
-
976
- function hermesOutcomeFor(finish) {
977
- if (finish === 'error' || finish === 'failure') return 'failure'
978
- if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
979
- return 'success'
980
- }
981
-
982
- function finalizeHermesTurn(turn, outcome) {
983
- if (!turn.id) return null
984
- const finalized = {
985
- id: turn.id,
986
- ts: turn.ts,
987
- sessionId: turn.sessionId,
988
- cwd: turn.cwd,
989
- project: turn.project,
990
- agent: turn.agent,
991
- title: turn.title,
992
- lastText: turn.lastText,
993
- toolCount: turn.toolCount,
994
- errors: [],
995
- outcome: outcome,
996
- }
997
- return finalized
998
- }
999
-
1000
- function feedHermesRow(turn, row) {
1001
- const meta = { sessionId: row.sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd }
1002
-
1003
- if (row.role === 'user') {
1004
- let finalized = null
1005
- if (turn.id) finalized = finalizeHermesTurn(turn, 'aborted')
1006
- const next = createHermesTurn(meta)
1007
- next.id = Number(row.id)
1008
- next.ts = Math.floor(Number(row.t) * 1000) || Date.now()
1009
- next.lastMid = Number(row.id)
1010
- return { finalized, next }
1011
- }
1012
-
1013
- if (row.role === 'assistant') {
1014
- if (!turn.id) return { next: turn }
1015
- if (typeof row.content === 'string' && row.content.trim()) {
1016
- turn.lastText = row.content
1017
- }
1018
- if (row.tool_calls) {
1019
- try {
1020
- const calls = JSON.parse(row.tool_calls)
1021
- if (Array.isArray(calls)) turn.toolCount += calls.length
1022
- } catch (_) {}
1023
- }
1024
- if (row.finish_reason && row.finish_reason !== 'tool_calls') {
1025
- const finalized = finalizeHermesTurn(turn, hermesOutcomeFor(row.finish_reason))
1026
- return { finalized, next: createHermesTurn(meta) }
1027
- }
1028
- turn.lastMid = Number(row.id)
1029
- return { next: turn }
1030
- }
1031
-
1032
- return { next: turn }
1033
- }
1034
-
1035
- function emitHermesOutcome(turn, sinks, state) {
1036
- if (!turn.id) return false
1037
- const summary = cleanText(turn.lastText)
1038
- if (!summary) return false
1039
- const outcome = turn.outcome
1040
- const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1041
-
1042
- sinks.trace({
1043
- trace_id: 'hermes:' + (turn.sessionId || 'unknown') + ':' + String(turn.id) + ':' + tsToken,
1044
- event_type: 'task_outcome',
1045
- outcome: outcome,
1046
- payload: {
1047
- host: 'hermes',
1048
- project: turn.project,
1049
- sessionId: turn.sessionId,
1050
- turnId: turn.id,
1051
- title: turn.title,
1052
- agent: turn.agent,
1053
- toolCalls: turn.toolCount,
1054
- errors: 0,
1055
- summary: summary.slice(0, 300),
1056
- source: 'hermes-observer',
1057
- },
1058
- agent: 'hermes',
1059
- host: 'hermes',
1060
- project: turn.project,
1061
- })
1062
-
1063
- if (outcome === 'success' && summary.length >= 10) {
1064
- sinks.memory({
1065
- kind: 'note',
1066
- content: '[hermes] 任务完成: ' + summary.slice(0, 160),
1067
- tags: ['task-outcome', 'hermes', turn.project],
1068
- project: turn.project,
1069
- source: 'hermes-observer',
1070
- })
1071
- log(homeDir(), '[hermes] write-back: ' + memLabel + ' -> memory')
1072
- // Hermes reads AGENTS.md Context Files; write back only into a real repo
1073
- // workspace so we never pollute the user's home directory.
1074
- if (turn.cwd) {
1075
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1076
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
1077
- }
1078
- }
1079
- return true
1080
- }
1081
-
1082
- function runHermesCycle(home, state, sinks, opts) {
1083
- const out = { files: 0, lines: 0, outcomes: 0 }
1084
- const dbPath = hermesDbPath()
1085
- if (!fs.existsSync(dbPath)) return out
1086
- const sqlite = hermesSqliteModule()
1087
- if (!sqlite) return out
1088
- let db
1089
- try {
1090
- db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
1091
- } catch (_) {
1092
- return out
1093
- }
1094
- out.files = 1
1095
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1096
- try {
1097
- if (!state.hmSeeded) {
1098
- const cutoff = (Date.now() - maxAgeMs) / 1000
1099
- const first = db.prepare('SELECT id FROM messages WHERE active = 1 AND timestamp >= ? ORDER BY id LIMIT 1').get(cutoff)
1100
- if (first) {
1101
- state.hmLastMsgId = first.id
1102
- } else {
1103
- const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get()
1104
- state.hmLastMsgId = maxRow.id
1105
- }
1106
- state.hmSeeded = true
1107
- }
1108
-
1109
- const limit = 2000
1110
- const stmt = db.prepare(
1111
- 'SELECT m.id AS id, m.session_id AS sid, m.role AS role, m.content AS content, ' +
1112
- 'm.tool_name AS tool_name, m.tool_calls AS tool_calls, m.timestamp AS t, m.finish_reason AS finish_reason, ' +
1113
- 's.title AS stitle, s.cwd AS scwd, s.git_repo_root AS sgit, s.source AS ssource ' +
1114
- 'FROM messages m LEFT JOIN sessions s ON s.id = m.session_id ' +
1115
- 'WHERE m.id > ? AND m.active = 1 ORDER BY m.id LIMIT ' + limit
1116
- )
1117
- const rows = stmt.all(state.hmLastMsgId)
1118
- if (rows.length === 0) return out
1119
-
1120
- let lastId = state.hmLastMsgId
1121
- for (const row of rows) {
1122
- const rid = Number(row.id)
1123
- if (rid > lastId) lastId = rid
1124
- out.lines += 1
1125
- const sid = row.sid
1126
- let turn = state.hmTurns[sid]
1127
- if (!turn) {
1128
- turn = createHermesTurn({ sessionId: sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd })
1129
- state.hmTurns[sid] = turn
1130
- }
1131
- const result = feedHermesRow(turn, row)
1132
- if (result.finalized) {
1133
- if (emitHermesOutcome(result.finalized, sinks, state)) out.outcomes += 1
1134
- }
1135
- state.hmTurns[sid] = result.next
1136
- }
1137
- state.hmLastMsgId = lastId
1138
- } finally {
1139
- db.close()
1140
- }
1141
- return out
1142
- }
1143
-
1144
-
1145
- // ---------------------------------------------------------------------------
1146
- // cycle
1147
- // ---------------------------------------------------------------------------
1148
-
1149
- function runCycle(home, state, sinks, opts) {
1150
- const out = { files: 0, lines: 0, outcomes: 0 }
1151
- fs.mkdirSync(home, { recursive: true })
1152
- const files = listTranscripts()
1153
- const now = Date.now()
1154
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1155
-
1156
- for (const file of files) {
1157
- out.files += 1
1158
- let st
1159
- try {
1160
- st = fs.statSync(file)
1161
- } catch (_) {
1162
- continue
1163
- }
1164
- const info = state.files[file] || { size: 0 }
1165
- // Do not flood MIO_HOME with ancient history: only backfill files that are
1166
- // already tracked or that changed within the max age window.
1167
- if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
1168
- const size = st.size
1169
- if (size < info.size) {
1170
- // rotated/truncated -> restart from the beginning
1171
- info.size = 0
1172
- state.buffers[file] = ''
1173
- }
1174
-
1175
- const meta = fileMeta(file)
1176
- let turn = state.openTurns[file] || createTurn(meta)
1177
- turn.project = meta.project
1178
- turn.sessionId = meta.sessionId
1179
-
1180
- if (size > info.size) {
1181
- let chunk = ''
1182
- try {
1183
- const fd = fs.openSync(file, 'r')
1184
- const buf = Buffer.alloc(size - info.size)
1185
- fs.readSync(fd, buf, 0, buf.length, info.size)
1186
- fs.closeSync(fd)
1187
- chunk = buf.toString('utf8')
1188
- } catch (_) {
1189
- continue
1190
- }
1191
- const pending = (state.buffers[file] || '') + chunk
1192
- const lines = pending.split(/\r?\n/)
1193
- state.buffers[file] = lines.pop()
1194
- for (const line of lines) {
1195
- if (!line.trim()) continue
1196
- out.lines += 1
1197
- let obj
1198
- try {
1199
- obj = JSON.parse(line)
1200
- } catch (_) {
1201
- continue
1202
- }
1203
- const result = feedWorkbuddyLine(turn, obj)
1204
- if (result.finalized) {
1205
- if (emitWorkbuddyOutcome(result.finalized, sinks, state)) out.outcomes += 1
1206
- }
1207
- turn = result.next
1208
- }
1209
- state.files[file] = { size: size, mtimeMs: st.mtimeMs }
1210
- }
1211
-
1212
- if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
1213
- if (emitWorkbuddyOutcome(turn, sinks, state)) out.outcomes += 1
1214
- state.openTurns[file] = null
1215
- } else if (turn.id) {
1216
- state.openTurns[file] = turn
1217
- } else {
1218
- state.openTurns[file] = null
1219
- }
1220
- }
1221
-
1222
- const codexRes = runCodexCycle(home, state, sinks, opts)
1223
- out.files += codexRes.files
1224
- out.lines += codexRes.lines
1225
- out.outcomes += codexRes.outcomes
1226
-
1227
- const ocRes = runOpencodeCycle(home, state, sinks, opts)
1228
- out.files += ocRes.files
1229
- out.lines += ocRes.lines
1230
- out.outcomes += ocRes.outcomes
1231
-
1232
- const hmRes = runHermesCycle(home, state, sinks, opts)
1233
- out.files += hmRes.files
1234
- out.lines += hmRes.lines
1235
- out.outcomes += hmRes.outcomes
1236
-
1237
- saveState(home, state)
1238
- return out
1239
- }
1240
-
1241
- function startForeground(home) {
1242
- const state = loadState(home)
1243
- const sinks = createSinks(home)
1244
- log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
1245
- const tick = () => {
1246
- try {
1247
- const res = runCycle(home, state, sinks)
1248
- if (res.lines > 0) {
1249
- log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
1250
- }
1251
- } catch (err) {
1252
- log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
1253
- }
1254
- }
1255
- tick()
1256
- setInterval(tick, INTERVAL_MS)
1257
- }
1258
-
1259
- // ---------------------------------------------------------------------------
1260
- // background daemon control
1261
- // ---------------------------------------------------------------------------
1262
-
1263
- function pidFile(home) {
1264
- return path.join(home, 'observe.pid')
1265
- }
1266
-
1267
- function readPid(home) {
1268
- try {
1269
- const pid = Number(fs.readFileSync(pidFile(home), 'utf8').trim())
1270
- return Number.isInteger(pid) && pid > 0 ? pid : null
1271
- } catch (_) {
1272
- return null
1273
- }
1274
- }
1275
-
1276
- function isRunning(home) {
1277
- const pid = readPid(home)
1278
- if (!pid) return false
1279
- try {
1280
- process.kill(pid, 0)
1281
- return true
1282
- } catch (_) {
1283
- return false
1284
- }
1285
- }
1286
-
1287
- function startBackground(home) {
1288
- if (isRunning(home)) {
1289
- return { running: true, started: false, pid: readPid(home) }
1290
- }
1291
- const script = path.join(__dirname, '..', 'bin', 'mio.js')
1292
- const child = spawn(process.execPath, [script, 'observe'], {
1293
- detached: true,
1294
- stdio: 'ignore',
1295
- windowsHide: true,
1296
- cwd: home,
1297
- })
1298
- child.unref()
1299
- fs.mkdirSync(home, { recursive: true })
1300
- fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
1301
- return { running: true, started: true, pid: child.pid }
1302
- }
1303
-
1304
- function stopBackground(home) {
1305
- const pid = readPid(home)
1306
- if (pid) {
1307
- try {
1308
- process.kill(pid)
1309
- } catch (_) {}
1310
- try {
1311
- fs.unlinkSync(pidFile(home))
1312
- } catch (_) {}
1313
- }
1314
- return { running: false, stopped: Boolean(pid), pid: pid || null }
1315
- }
1316
-
1317
- module.exports = {
1318
- INTERVAL_MS,
1319
- homeDir,
1320
- workbuddyConfigDir,
1321
- loadState,
1322
- saveState,
1323
- updateProjectContext,
1324
- runCycle,
1325
- opencodeDbPath,
1326
- runOpencodeCycle,
1327
- runHermesCycle,
1328
- hermesDbPath,
1329
- createSinks,
1330
- startForeground,
1331
- startBackground,
1332
- stopBackground,
1333
- isRunning,
1334
- readPid,
1335
- }
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const os = require('os')
7
+ const crypto = require('crypto')
8
+ const { spawn, spawnSync } = require('child_process')
9
+
10
+ const INTERVAL_MS = 3000
11
+ // A WorkBuddy turn is considered finished when its transcript file has not
12
+ // been appended for this long AND an assistant answer is present.
13
+ const IDLE_MS = 15000
14
+
15
+ function homeDir() {
16
+ return process.env.MIO_HOME || path.join(os.homedir(), '.mio-intelligence')
17
+ }
18
+
19
+ function workbuddyConfigDir() {
20
+ return (
21
+ process.env.WORKBUDDY_CONFIG_DIR ||
22
+ process.env.CODEBUDDY_CONFIG_DIR ||
23
+ path.join(os.homedir(), '.workbuddy')
24
+ )
25
+ }
26
+
27
+ function createId(prefix) {
28
+ return prefix + '_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex')
29
+ }
30
+
31
+ function cleanText(text) {
32
+ return String(text || '')
33
+ .replace(/\r/g, '')
34
+ .replace(/#{2,}|\*{2,}|`{1,}|_{2,}|~{2,}|-{2,}/g, ' ')
35
+ .replace(/\s{2,}/g, ' ')
36
+ .trim()
37
+ }
38
+
39
+ function projectFromDirName(dirName) {
40
+ // WorkBuddy project dirs encode the workspace path with dashes, e.g.
41
+ // "d-work-code-douyin_store" -> "douyin_store"
42
+ const parts = String(dirName || '').split('-').filter(Boolean)
43
+ if (parts.length > 1 && /^\\d+$/.test(parts[parts.length - 1])) {
44
+ return dirName || 'unknown'
45
+ }
46
+ return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
47
+ }
48
+
49
+ function log(home, message) {
50
+ const line = '[' + new Date().toISOString() + '] ' + message + '\n'
51
+ try {
52
+ fs.mkdirSync(path.join(home, 'logs'), { recursive: true })
53
+ fs.appendFileSync(path.join(home, 'logs', 'observe.log'), line, 'utf8')
54
+ } catch (_) {}
55
+ if (process.stdout.isTTY || process.env.MIO_OBSERVE_VERBOSE) {
56
+ process.stdout.write(line)
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // state
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function stateFile(home) {
65
+ return path.join(home, 'observe-state.json')
66
+ }
67
+
68
+ function loadState(home) {
69
+ try {
70
+ const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
71
+ if (parsed && typeof parsed === 'object') {
72
+ return {
73
+ version: 1,
74
+ files: parsed.files || {},
75
+ buffers: parsed.buffers || {},
76
+ openTurns: parsed.openTurns || {},
77
+ contexts: parsed.contexts || {},
78
+ codexFiles: parsed.codexFiles || {},
79
+ codexBuffers: parsed.codexBuffers || {},
80
+ codexTurns: parsed.codexTurns || {},
81
+ ocSeeded: parsed.ocSeeded || false,
82
+ ocLastRowid: parsed.ocLastRowid || 0,
83
+ ocDbMtime: parsed.ocDbMtime || null,
84
+ ocPending: parsed.ocPending || false,
85
+ ocTurns: parsed.ocTurns || {},
86
+ hmSeeded: parsed.hmSeeded || false,
87
+ hmLastMsgId: parsed.hmLastMsgId || 0,
88
+ hmTurns: parsed.hmTurns || {},
89
+ }
90
+ }
91
+ } catch (_) {}
92
+ return {
93
+ version: 1,
94
+ files: {},
95
+ buffers: {},
96
+ openTurns: {},
97
+ contexts: {},
98
+ codexFiles: {},
99
+ codexBuffers: {},
100
+ codexTurns: {},
101
+ ocSeeded: false,
102
+ ocLastRowid: 0,
103
+ ocDbMtime: null,
104
+ ocPending: false,
105
+ ocTurns: {},
106
+ hmSeeded: false,
107
+ hmLastMsgId: 0,
108
+ hmTurns: {},
109
+ }
110
+ }
111
+
112
+ function saveState(home, state) {
113
+ try {
114
+ fs.mkdirSync(home, { recursive: true })
115
+ fs.writeFileSync(stateFile(home), JSON.stringify(state) + '\n', 'utf8')
116
+ } catch (_) {}
117
+ }
118
+
119
+ function listTranscripts() {
120
+ const root = path.join(workbuddyConfigDir(), 'projects')
121
+ const out = []
122
+ if (!fs.existsSync(root)) return out
123
+ const walk = (dir) => {
124
+ let entries
125
+ try {
126
+ entries = fs.readdirSync(dir, { withFileTypes: true })
127
+ } catch (_) {
128
+ return
129
+ }
130
+ for (const entry of entries) {
131
+ const full = path.join(dir, entry.name)
132
+ if (entry.isDirectory()) walk(full)
133
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
134
+ }
135
+ }
136
+ walk(root)
137
+ return out
138
+ }
139
+
140
+ function codexConfigDir() {
141
+ return process.env.CODEX_CONFIG_DIR || path.join(os.homedir(), '.codex')
142
+ }
143
+
144
+ function listCodexTranscripts() {
145
+ const root = path.join(codexConfigDir(), 'sessions')
146
+ const out = []
147
+ if (!fs.existsSync(root)) return out
148
+ const walk = (dir) => {
149
+ let entries
150
+ try {
151
+ entries = fs.readdirSync(dir, { withFileTypes: true })
152
+ } catch (_) {
153
+ return
154
+ }
155
+ for (const entry of entries) {
156
+ const full = path.join(dir, entry.name)
157
+ if (entry.isDirectory()) walk(full)
158
+ else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) out.push(full)
159
+ }
160
+ }
161
+ walk(root)
162
+ return out
163
+ }
164
+
165
+ function fileMeta(file) {
166
+ const dirName = path.basename(path.dirname(file))
167
+ return {
168
+ sessionId: path.basename(file, '.jsonl'),
169
+ project: projectFromDirName(dirName),
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // sinks (write into MIO_HOME with the same schema as the MCP server)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ function appendJsonl(file, value) {
178
+ fs.mkdirSync(path.dirname(file), { recursive: true })
179
+ fs.appendFileSync(file, JSON.stringify(value) + '\n', 'utf8')
180
+ }
181
+
182
+ function createSinks(home) {
183
+ const tracePath = path.join(home, 'traces.jsonl')
184
+ const memoryPath = path.join(home, 'memory.jsonl')
185
+ const seenTraceIds = new Set()
186
+ const seenMemoryContents = new Set()
187
+
188
+ function trace(event) {
189
+ const record = {
190
+ id: createId('trace'),
191
+ timestamp: new Date().toISOString(),
192
+ trace_id: event.trace_id,
193
+ event_type: event.event_type,
194
+ outcome: event.outcome || null,
195
+ payload: event.payload || {},
196
+ agent: event.agent || 'workbuddy-observer',
197
+ host: event.host || 'workbuddy-observer',
198
+ project: event.project || 'unknown',
199
+ }
200
+ if (seenTraceIds.has(record.trace_id)) return
201
+ seenTraceIds.add(record.trace_id)
202
+ if (seenTraceIds.size > 20000) seenTraceIds.clear()
203
+ appendJsonl(tracePath, record)
204
+ }
205
+
206
+ function memory(entry) {
207
+ const key = entry.project + '|' + entry.content
208
+ if (seenMemoryContents.has(key)) return
209
+ seenMemoryContents.add(key)
210
+ const record = {
211
+ id: createId('mem'),
212
+ timestamp: new Date().toISOString(),
213
+ kind: entry.kind || 'note',
214
+ content: entry.content,
215
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
216
+ project: entry.project || 'unknown',
217
+ source: entry.source || 'workbuddy-observer',
218
+ }
219
+ appendJsonl(memoryPath, record)
220
+ }
221
+
222
+ return { trace, memory }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // WorkBuddy transcript turn machine
227
+ // ---------------------------------------------------------------------------
228
+
229
+ function createTurn(meta) {
230
+ return {
231
+ id: null,
232
+ ts: null,
233
+ project: meta.project,
234
+ sessionId: meta.sessionId,
235
+ cwd: meta.cwd || null,
236
+ toolCount: 0,
237
+ errors: [],
238
+ lastAssistant: null,
239
+ }
240
+ }
241
+
242
+ function feedWorkbuddyLine(turn, obj) {
243
+ if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
244
+ let finalized = null
245
+ if (obj.cwd) turn.cwd = obj.cwd
246
+ if (obj.type === 'message') {
247
+ if (obj.role === 'user') {
248
+ finalized = turn
249
+ const next = createTurn(turn)
250
+ next.id = obj.id
251
+ next.ts = obj.timestamp
252
+ next.cwd = obj.cwd || turn.cwd
253
+ return { finalized, next: next }
254
+ }
255
+ if (obj.role === 'assistant') {
256
+ const text = (Array.isArray(obj.content) ? obj.content : [])
257
+ .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
258
+ .filter(Boolean)
259
+ .join('\n')
260
+ turn.lastAssistant = {
261
+ status: obj.status,
262
+ text: text,
263
+ error: (obj.providerData && obj.providerData.error) || null,
264
+ }
265
+ }
266
+ } else if (obj.type === 'function_call') {
267
+ turn.toolCount += 1
268
+ } else if (obj.type === 'function_call_result') {
269
+ const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
270
+ if (status !== 'completed') {
271
+ turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
272
+ if (turn.errors.length > 20) turn.errors.shift()
273
+ }
274
+ }
275
+ return { finalized: finalized, next: turn }
276
+ }
277
+
278
+ const CONTEXT_BEGIN = '<!-- MIO_CONTEXT_BEGIN -->'
279
+ const CONTEXT_END = '<!-- MIO_CONTEXT_END -->'
280
+ const CONTEXT_MAX = 6
281
+
282
+ // Maintain a compact 'recent Mio context' block inside <workspace>/AGENTS.md,
283
+ // which WorkBuddy's MemoryLoader auto-injects into every session. This makes
284
+ // retrieval guaranteed (not dependent on the model deciding to read memory).
285
+ function updateProjectContext(state, cwd, line) {
286
+ if (!cwd || !line) return
287
+ const target = path.join(cwd, 'AGENTS.md')
288
+ const list = state.contexts[cwd] || []
289
+ list.unshift(line)
290
+ if (list.length > CONTEXT_MAX) list.pop()
291
+ state.contexts[cwd] = list
292
+ try {
293
+ let content = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
294
+ const block = CONTEXT_BEGIN + '\n## Mio 最近上下文(自动注入)\n' + list.map(function (l) { return '- ' + l }).join('\n') + '\n' + CONTEXT_END
295
+ const start = content.indexOf(CONTEXT_BEGIN)
296
+ if (start !== -1) {
297
+ const end = content.indexOf(CONTEXT_END, start)
298
+ content = end === -1 ? content.slice(0, start) : content.slice(0, start) + content.slice(end + CONTEXT_END.length)
299
+ }
300
+ content = content.trimEnd() + '\n\n' + block + '\n'
301
+ fs.mkdirSync(path.dirname(target), { recursive: true })
302
+ fs.writeFileSync(target, content, 'utf8')
303
+ } catch (_) {}
304
+ }
305
+
306
+ function localDateKey(ts) {
307
+ const d = new Date(Number(ts) || Date.now())
308
+ const y = d.getFullYear()
309
+ const m = String(d.getMonth() + 1).padStart(2, '0')
310
+ const day = String(d.getDate()).padStart(2, '0')
311
+ return y + '-' + m + '-' + day
312
+ }
313
+
314
+ // Write a compact Mio digest back into WorkBuddy's own memory file
315
+ // (<workspace>/.workbuddy/memory/YYYY-MM-DD.md). WorkBuddy instructs its model
316
+ // to maintain and read these files every session, so the digest is
317
+ // 'retrieved' next session without any Mio tool call by the model.
318
+ function writeWorkbuddyDigest(turn, outcome, summary) {
319
+ if (!turn.cwd || !summary) return
320
+ const dir = path.join(turn.cwd, '.workbuddy', 'memory')
321
+ const file = path.join(dir, localDateKey(turn.ts) + '.md')
322
+ try {
323
+ fs.mkdirSync(dir, { recursive: true })
324
+ const time = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
325
+ const entry =
326
+ '\n## Mio 自动摘要 (' + time + ')\n' +
327
+ '- outcome: ' + outcome + ' | tools: ' + turn.toolCount + ' | ' + summary.slice(0, 160) + '\n'
328
+ fs.appendFileSync(file, entry, 'utf8')
329
+ } catch (_) {}
330
+ }
331
+
332
+ function emitWorkbuddyOutcome(turn, sinks, state) {
333
+ const la = turn.lastAssistant
334
+ if (!la) return false
335
+ let outcome
336
+ if (la.status === 'completed') outcome = 'success'
337
+ else if (la.error) outcome = 'failure'
338
+ else outcome = 'aborted'
339
+
340
+ const summary = cleanText(la.text)
341
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
342
+ const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
343
+
344
+ sinks.trace({
345
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
346
+ event_type: 'task_outcome',
347
+ outcome: outcome,
348
+ payload: {
349
+ host: 'workbuddy',
350
+ project: turn.project,
351
+ sessionId: turn.sessionId,
352
+ turnId: turn.id,
353
+ toolCalls: turn.toolCount,
354
+ errors: turn.errors.length,
355
+ summary: summary.slice(0, 300),
356
+ },
357
+ agent: 'workbuddy',
358
+ host: 'workbuddy',
359
+ project: turn.project,
360
+ })
361
+
362
+ // Low threshold: even a short but real completion summary must reach the
363
+ // passive retrieval paths (memory + daily digest + AGENTS.md context block).
364
+ // 40 was too strict and silently dropped short summaries from retrieval.
365
+ if (outcome === 'success' && summary.length >= 10) {
366
+ sinks.memory({
367
+ kind: 'note',
368
+ content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
369
+ tags: ['task-outcome', 'workbuddy', turn.project],
370
+ project: turn.project,
371
+ source: 'workbuddy-observer',
372
+ })
373
+ writeWorkbuddyDigest(turn, outcome, summary)
374
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
375
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
376
+ }
377
+
378
+ for (const err of turn.errors) {
379
+ sinks.trace({
380
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
381
+ event_type: 'error',
382
+ outcome: 'error',
383
+ payload: {
384
+ host: 'workbuddy',
385
+ project: turn.project,
386
+ sessionId: turn.sessionId,
387
+ turnId: turn.id,
388
+ tool: err.name,
389
+ status: err.status,
390
+ },
391
+ agent: 'workbuddy',
392
+ host: 'workbuddy',
393
+ project: turn.project,
394
+ })
395
+ }
396
+ return true
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Codex session turn machine (passive observation)
401
+ // ---------------------------------------------------------------------------
402
+
403
+ // Codex rollout transcripts are append-only JSONL under
404
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The first session_meta line
405
+ // carries the workspace cwd; task_started / task_complete / turn_aborted mark
406
+ // turn boundaries, agent_message carries the running answer text.
407
+ function createCodexTurn() {
408
+ return {
409
+ id: null,
410
+ ts: null,
411
+ sessionId: null,
412
+ cwd: null,
413
+ lastAgent: null,
414
+ aborted: false,
415
+ }
416
+ }
417
+
418
+ function feedCodexLine(turn, obj) {
419
+ if (!obj || typeof obj !== 'object') return {}
420
+ if (obj.type === 'session_meta') {
421
+ const p = obj.payload || {}
422
+ if (!turn.cwd && p.cwd) turn.cwd = p.cwd
423
+ if (!turn.sessionId && (p.session_id || p.id)) turn.sessionId = p.session_id || p.id
424
+ return {}
425
+ }
426
+ if (obj.type !== 'event_msg') return {}
427
+ const p = obj.payload || {}
428
+ if (p.type === 'task_started') {
429
+ // A turn that started earlier but never completed is discarded.
430
+ turn.id = p.turn_id || null
431
+ turn.ts = p.started_at ? Number(p.started_at) * 1000 : null
432
+ turn.lastAgent = null
433
+ turn.aborted = false
434
+ return {}
435
+ }
436
+ if (p.type === 'agent_message' && p.message) {
437
+ turn.lastAgent = p.message
438
+ return {}
439
+ }
440
+ if (p.type === 'task_complete') {
441
+ const finalized = {
442
+ id: turn.id,
443
+ ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
444
+ sessionId: turn.sessionId,
445
+ cwd: turn.cwd,
446
+ project: path.basename(turn.cwd || '') || 'unknown',
447
+ lastAgent: p.last_agent_message || turn.lastAgent,
448
+ aborted: false,
449
+ }
450
+ turn.id = null
451
+ turn.lastAgent = null
452
+ return { finalized: finalized }
453
+ }
454
+ if (p.type === 'turn_aborted') {
455
+ const finalized = {
456
+ id: turn.id,
457
+ ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
458
+ sessionId: turn.sessionId,
459
+ cwd: turn.cwd,
460
+ project: path.basename(turn.cwd || '') || 'unknown',
461
+ lastAgent: turn.lastAgent,
462
+ aborted: true,
463
+ }
464
+ turn.id = null
465
+ turn.lastAgent = null
466
+ return { finalized: finalized }
467
+ }
468
+ return {}
469
+ }
470
+
471
+ function emitCodexOutcome(turn, sinks, state) {
472
+ if (!turn.lastAgent) return false
473
+ const summary = cleanText(turn.lastAgent)
474
+ if (!summary) return false
475
+ const outcome = turn.aborted ? 'aborted' : 'success'
476
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
477
+
478
+ sinks.trace({
479
+ trace_id: 'codex:' + (turn.sessionId || 'unknown') + ':' + turnToken,
480
+ event_type: 'task_outcome',
481
+ outcome: outcome,
482
+ payload: {
483
+ host: 'codex',
484
+ project: turn.project,
485
+ sessionId: turn.sessionId,
486
+ turnId: turn.id,
487
+ toolCalls: 0,
488
+ errors: 0,
489
+ summary: summary.slice(0, 300),
490
+ source: 'codex-observer',
491
+ },
492
+ agent: 'codex',
493
+ host: 'codex',
494
+ project: turn.project,
495
+ })
496
+
497
+ if (outcome === 'success' && summary.length >= 10) {
498
+ sinks.memory({
499
+ kind: 'note',
500
+ content: '[codex] 任务完成: ' + summary.slice(0, 160),
501
+ tags: ['task-outcome', 'codex', turn.project],
502
+ project: turn.project,
503
+ source: 'codex-observer',
504
+ })
505
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
506
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
507
+ }
508
+ return true
509
+ }
510
+
511
+ function runCodexCycle(home, state, sinks, opts) {
512
+ const out = { files: 0, lines: 0, outcomes: 0 }
513
+ const files = listCodexTranscripts()
514
+ const now = Date.now()
515
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
516
+
517
+ for (const file of files) {
518
+ out.files += 1
519
+ let st
520
+ try {
521
+ st = fs.statSync(file)
522
+ } catch (_) {
523
+ continue
524
+ }
525
+ const info = state.codexFiles[file] || { size: 0 }
526
+ if (!state.codexFiles[file] && now - st.mtimeMs > maxAgeMs) continue
527
+ const size = st.size
528
+ if (size < info.size) {
529
+ info.size = 0
530
+ state.codexBuffers[file] = ''
531
+ }
532
+
533
+ if (!state.codexTurns[file]) state.codexTurns[file] = createCodexTurn()
534
+ const turn = state.codexTurns[file]
535
+
536
+ if (size > info.size) {
537
+ let chunk = ''
538
+ try {
539
+ const fd = fs.openSync(file, 'r')
540
+ const buf = Buffer.alloc(size - info.size)
541
+ fs.readSync(fd, buf, 0, buf.length, info.size)
542
+ fs.closeSync(fd)
543
+ chunk = buf.toString('utf8')
544
+ } catch (_) {
545
+ continue
546
+ }
547
+ const pending = (state.codexBuffers[file] || '') + chunk
548
+ const lines = pending.split(/\r?\n/)
549
+ state.codexBuffers[file] = lines.pop()
550
+ for (const line of lines) {
551
+ if (!line.trim()) continue
552
+ out.lines += 1
553
+ let obj
554
+ try {
555
+ obj = JSON.parse(line)
556
+ } catch (_) {
557
+ continue
558
+ }
559
+ const result = feedCodexLine(turn, obj)
560
+ if (result.finalized) {
561
+ if (emitCodexOutcome(result.finalized, sinks, state)) out.outcomes += 1
562
+ }
563
+ }
564
+ state.codexFiles[file] = { size: size, mtimeMs: st.mtimeMs }
565
+ }
566
+ }
567
+
568
+ return out
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // OpenCode session observation (passive, via `opencode db`)
573
+ // ---------------------------------------------------------------------------
574
+
575
+ // OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
576
+ // On Windows the db file is held with an exclusive lock while OpenCode runs,
577
+ // so we cannot open it with node:sqlite directly. Instead we shell out to the
578
+ // `opencode db` CLI, which opens the same database through the app itself and
579
+ // works even while OpenCode is running. We tail `part` rows by rowid so each
580
+ // session is scanned incrementally and only text/tool parts are transferred.
581
+
582
+ function opencodeDataDir() {
583
+ return (
584
+ process.env.OPENCODE_DATA_DIR ||
585
+ path.join(os.homedir(), '.local', 'share', 'opencode')
586
+ )
587
+ }
588
+
589
+ function opencodeDbPath() {
590
+ return path.join(opencodeDataDir(), 'opencode.db')
591
+ }
592
+
593
+ function findOpencodeBin() {
594
+ if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
595
+ return process.env.OPENCODE_BIN
596
+ }
597
+ const exeDir = path.dirname(process.execPath)
598
+ const candidates = [
599
+ path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
600
+ path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
601
+ path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
602
+ ]
603
+ for (const c of candidates) {
604
+ const resolved = path.resolve(c)
605
+ if (fs.existsSync(resolved)) return resolved
606
+ }
607
+ // Resolve from PATH so the SQL is never passed through cmd.exe, where
608
+ // '>' in the query is interpreted as output redirection and creates
609
+ // stray files (e.g. "WHERE p.rowid > 0" -> a file named "0").
610
+ try {
611
+ const finder = process.platform === 'win32' ? 'where' : 'which'
612
+ const res = spawnSync(finder, ['opencode'], { encoding: 'utf8', windowsHide: true, timeout: 15000 })
613
+ if (res.status === 0 && res.stdout) {
614
+ const lines = res.stdout
615
+ .split(/\r?\n/)
616
+ .map((s) => s.trim())
617
+ .filter(Boolean)
618
+ const preferred =
619
+ process.platform === 'win32'
620
+ ? lines.filter((p) => /\.(exe|cmd|bat)$/i.test(p))
621
+ : lines
622
+ const first = (preferred.length > 0 ? preferred : lines).find((p) => fs.existsSync(p))
623
+ if (first) return first
624
+ }
625
+ } catch (_) {}
626
+ return null
627
+ }
628
+
629
+ function runOpencodeQuery(sql) {
630
+ const bin = findOpencodeBin()
631
+ // Never shell the SQL through cmd.exe: '>' in the query would be parsed
632
+ // as output redirection, creating files named after the redirect target.
633
+ if (!bin) return null
634
+ const args = ['db', sql, '--format', 'json']
635
+ let res
636
+ try {
637
+ res = spawnSync(bin, args, { encoding: 'utf8', timeout: 90000, windowsHide: true, maxBuffer: 256 * 1024 * 1024 })
638
+ } catch (_) {
639
+ return null
640
+ }
641
+ if (res.error || res.status !== 0 || !res.stdout) return null
642
+ try {
643
+ return JSON.parse(res.stdout)
644
+ } catch (_) {
645
+ return null
646
+ }
647
+ }
648
+
649
+ // Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
650
+ // keep the transferred payload small.
651
+ const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
652
+
653
+ function createOpencodeTurn(meta) {
654
+ meta = meta || {}
655
+ return {
656
+ id: null,
657
+ ts: null,
658
+ sessionId: meta.sessionId || null,
659
+ cwd: meta.directory || null,
660
+ project: meta.directory ? path.basename(meta.directory) : 'unknown',
661
+ agent: meta.agent || null,
662
+ title: meta.title || null,
663
+ lastMid: null,
664
+ lastText: null,
665
+ toolCount: 0,
666
+ errors: [],
667
+ pendingTerminal: null,
668
+ done: false,
669
+ }
670
+ }
671
+
672
+ function opencodeOutcomeFor(finish) {
673
+ if (finish === 'error') return 'failure'
674
+ if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
675
+ return 'success'
676
+ }
677
+
678
+ function finalizeOpencodeTurn(turn, outcome) {
679
+ if (!turn.id) return null
680
+ const finalized = {
681
+ id: turn.id,
682
+ ts: turn.ts,
683
+ sessionId: turn.sessionId,
684
+ cwd: turn.cwd,
685
+ project: turn.project,
686
+ agent: turn.agent,
687
+ title: turn.title,
688
+ lastText: turn.lastText,
689
+ toolCount: turn.toolCount,
690
+ errors: turn.errors.slice(),
691
+ outcome: outcome,
692
+ }
693
+ turn.done = true
694
+ return finalized
695
+ }
696
+
697
+ function applyOpencodeAssistantPart(turn, pd, md, row) {
698
+ if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
699
+ turn.lastText = pd.text
700
+ } else if (pd.type === 'tool') {
701
+ turn.toolCount += 1
702
+ const status = pd.state && pd.state.status
703
+ if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
704
+ turn.errors.push({ tool: pd.tool || 'unknown', status: status })
705
+ if (turn.errors.length > 20) turn.errors.shift()
706
+ }
707
+ }
708
+ if (md.finish && md.finish !== 'tool-calls') {
709
+ turn.pendingTerminal = md.finish
710
+ }
711
+ turn.lastMid = row.mid
712
+ }
713
+
714
+ function feedOpencodePart(turn, row) {
715
+ let pd
716
+ let md
717
+ try {
718
+ pd = JSON.parse(row.pd)
719
+ } catch (_) {
720
+ pd = null
721
+ }
722
+ try {
723
+ md = JSON.parse(row.md)
724
+ } catch (_) {
725
+ md = null
726
+ }
727
+ if (!pd || !md) return { next: turn }
728
+
729
+ // A user message starts a new turn; any unfinished previous turn is aborted.
730
+ if (md.role === 'user') {
731
+ let finalized = null
732
+ if (turn.id && !turn.done) {
733
+ finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
734
+ }
735
+ const next = createOpencodeTurn({
736
+ sessionId: row.sid,
737
+ directory: row.sdir,
738
+ title: row.stitle,
739
+ agent: row.sagent,
740
+ })
741
+ next.id = row.mid
742
+ next.ts = Number(row.t) || Date.now()
743
+ next.lastMid = row.mid
744
+ return { finalized, next }
745
+ }
746
+
747
+ if (turn.done) return { next: turn }
748
+
749
+ // Message boundary after a terminal finish closes the previous turn.
750
+ if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
751
+ const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
752
+ const next = createOpencodeTurn({
753
+ sessionId: row.sid,
754
+ directory: row.sdir,
755
+ title: row.stitle,
756
+ agent: row.sagent,
757
+ })
758
+ applyOpencodeAssistantPart(next, pd, md, row)
759
+ return { finalized, next }
760
+ }
761
+
762
+ applyOpencodeAssistantPart(turn, pd, md, row)
763
+ return { next: turn }
764
+ }
765
+
766
+ function emitOpencodeOutcome(turn, sinks, state) {
767
+ if (!turn.id) return false
768
+ const summary = cleanText(turn.lastText)
769
+ if (!summary) return false
770
+ const outcome = turn.outcome
771
+ const turnToken = String(turn.id).slice(0, 8)
772
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
773
+
774
+ sinks.trace({
775
+ trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
776
+ event_type: 'task_outcome',
777
+ outcome: outcome,
778
+ payload: {
779
+ host: 'opencode',
780
+ project: turn.project,
781
+ sessionId: turn.sessionId,
782
+ turnId: turn.id,
783
+ title: turn.title,
784
+ agent: turn.agent,
785
+ toolCalls: turn.toolCount,
786
+ errors: turn.errors.length,
787
+ summary: summary.slice(0, 300),
788
+ source: 'opencode-observer',
789
+ },
790
+ agent: 'opencode',
791
+ host: 'opencode',
792
+ project: turn.project,
793
+ })
794
+
795
+ if (outcome === 'success' && summary.length >= 10) {
796
+ sinks.memory({
797
+ kind: 'note',
798
+ content: '[opencode] 任务完成: ' + summary.slice(0, 160),
799
+ tags: ['task-outcome', 'opencode', turn.project],
800
+ project: turn.project,
801
+ source: 'opencode-observer',
802
+ })
803
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
804
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
805
+ }
806
+
807
+ for (const err of turn.errors) {
808
+ sinks.trace({
809
+ trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
810
+ event_type: 'error',
811
+ outcome: 'error',
812
+ payload: {
813
+ host: 'opencode',
814
+ project: turn.project,
815
+ sessionId: turn.sessionId,
816
+ turnId: turn.id,
817
+ tool: err.tool,
818
+ status: err.status,
819
+ },
820
+ agent: 'opencode',
821
+ host: 'opencode',
822
+ project: turn.project,
823
+ })
824
+ }
825
+ return true
826
+ }
827
+
828
+ function runOpencodeCycle(home, state, sinks, opts) {
829
+ const out = { files: 0, lines: 0, outcomes: 0 }
830
+ const db = opencodeDbPath()
831
+ if (!fs.existsSync(db)) return out
832
+ let st
833
+ try {
834
+ st = fs.statSync(db)
835
+ } catch (_) {
836
+ return out
837
+ }
838
+ out.files = 1
839
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
840
+
841
+ // First run: seed the rowid cursor to the first part written within the
842
+ // backfill window so ancient history never floods MIO_HOME.
843
+ if (!state.ocSeeded) {
844
+ const ts = Date.now() - maxAgeMs
845
+ const first = runOpencodeQuery('SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1')
846
+ let rid = 0
847
+ if (Array.isArray(first) && first.length > 0) {
848
+ rid = Number(first[0].rowid) || 0
849
+ } else {
850
+ const maxRow = runOpencodeQuery('SELECT COALESCE(MAX(rowid), 0) AS rid FROM part')
851
+ if (Array.isArray(maxRow) && maxRow.length > 0) rid = Number(maxRow[0].rid) || 0
852
+ }
853
+ state.ocLastRowid = rid
854
+ state.ocSeeded = true
855
+ state.ocDbMtime = st.mtimeMs
856
+ state.ocPending = true
857
+ return out
858
+ }
859
+
860
+ const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
861
+ if (!changed && !state.ocPending) return out
862
+
863
+ const limit = 1500
864
+ const sql =
865
+ 'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
866
+ 'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
867
+ 'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
868
+ 'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
869
+ ' ORDER BY p.rowid LIMIT ' + limit
870
+ const rows = runOpencodeQuery(sql)
871
+ if (!Array.isArray(rows)) return out
872
+ state.ocDbMtime = st.mtimeMs
873
+ if (rows.length === 0) {
874
+ state.ocPending = false
875
+ return out
876
+ }
877
+
878
+ let lastRid = state.ocLastRowid
879
+ for (const row of rows) {
880
+ const rid = Number(row.rid)
881
+ if (rid > lastRid) lastRid = rid
882
+ out.lines += 1
883
+ const sid = row.sid
884
+ let turn = state.ocTurns[sid]
885
+ if (!turn) {
886
+ turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
887
+ state.ocTurns[sid] = turn
888
+ }
889
+ const result = feedOpencodePart(turn, row)
890
+ if (result.finalized) {
891
+ if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
892
+ }
893
+ state.ocTurns[sid] = result.next
894
+ }
895
+ state.ocLastRowid = lastRid
896
+ state.ocPending = rows.length >= limit
897
+
898
+ // Caught up: any turn holding a terminal finish is complete.
899
+ if (!state.ocPending) {
900
+ for (const sid of Object.keys(state.ocTurns)) {
901
+ const turn = state.ocTurns[sid]
902
+ if (turn && turn.id && turn.pendingTerminal && !turn.done) {
903
+ const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
904
+ if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
905
+ state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
906
+ }
907
+ }
908
+ }
909
+ return out
910
+ }
911
+
912
+ // ---------------------------------------------------------------------------
913
+ // Hermes session observation (passive, via its own SQLite state.db)
914
+ // ---------------------------------------------------------------------------
915
+
916
+ // Hermes (Nous Research hermes-agent) keeps every conversation in
917
+ // <LocalAppData>/hermes/state.db. Unlike OpenCode the file is not exclusively
918
+ // locked, and `messages.id` is a monotonic AUTOINCREMENT cursor, so we can tail
919
+ // it read-only with node:sqlite (Node 22+). Each message is one row carrying
920
+ // role / content / finish_reason, so a user row opens a turn and an assistant
921
+ // row with a terminal finish_reason closes it immediately. Hermes already has
922
+ // native memory, so this observer only imports Hermes experience into the Mio
923
+ // ecosystem (traces + memory + AGENTS.md context when a git repo is present).
924
+
925
+ let _hermesSqlite = null
926
+ let _hermesSqliteTried = false
927
+
928
+ function hermesSqliteModule() {
929
+ if (_hermesSqliteTried) return _hermesSqlite
930
+ _hermesSqliteTried = true
931
+ try {
932
+ _hermesSqlite = require('node:sqlite')
933
+ } catch (_) {
934
+ _hermesSqlite = null
935
+ }
936
+ return _hermesSqlite
937
+ }
938
+
939
+ function hermesDbPath() {
940
+ return (
941
+ process.env.HERMES_DATA_DIR ||
942
+ path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'state.db')
943
+ )
944
+ }
945
+
946
+ function hermesProjectName(meta) {
947
+ if (meta.git) return path.basename(meta.git)
948
+ if (meta.cwd && meta.cwd !== os.homedir()) return path.basename(meta.cwd)
949
+ return 'hermes'
950
+ }
951
+
952
+ function createHermesTurn(meta) {
953
+ meta = meta || {}
954
+ return {
955
+ id: null,
956
+ ts: null,
957
+ sessionId: meta.sessionId || null,
958
+ cwd: meta.git || null,
959
+ project: hermesProjectName({ git: meta.git, cwd: meta.projectHint }),
960
+ title: meta.title || null,
961
+ agent: meta.agent || null,
962
+ lastText: null,
963
+ toolCount: 0,
964
+ lastMid: null,
965
+ }
966
+ }
967
+
968
+ function hermesOutcomeFor(finish) {
969
+ if (finish === 'error' || finish === 'failure') return 'failure'
970
+ if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
971
+ return 'success'
972
+ }
973
+
974
+ function finalizeHermesTurn(turn, outcome) {
975
+ if (!turn.id) return null
976
+ const finalized = {
977
+ id: turn.id,
978
+ ts: turn.ts,
979
+ sessionId: turn.sessionId,
980
+ cwd: turn.cwd,
981
+ project: turn.project,
982
+ agent: turn.agent,
983
+ title: turn.title,
984
+ lastText: turn.lastText,
985
+ toolCount: turn.toolCount,
986
+ errors: [],
987
+ outcome: outcome,
988
+ }
989
+ return finalized
990
+ }
991
+
992
+ function feedHermesRow(turn, row) {
993
+ const meta = { sessionId: row.sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd }
994
+
995
+ if (row.role === 'user') {
996
+ let finalized = null
997
+ if (turn.id) finalized = finalizeHermesTurn(turn, 'aborted')
998
+ const next = createHermesTurn(meta)
999
+ next.id = Number(row.id)
1000
+ next.ts = Math.floor(Number(row.t) * 1000) || Date.now()
1001
+ next.lastMid = Number(row.id)
1002
+ return { finalized, next }
1003
+ }
1004
+
1005
+ if (row.role === 'assistant') {
1006
+ if (!turn.id) return { next: turn }
1007
+ if (typeof row.content === 'string' && row.content.trim()) {
1008
+ turn.lastText = row.content
1009
+ }
1010
+ if (row.tool_calls) {
1011
+ try {
1012
+ const calls = JSON.parse(row.tool_calls)
1013
+ if (Array.isArray(calls)) turn.toolCount += calls.length
1014
+ } catch (_) {}
1015
+ }
1016
+ if (row.finish_reason && row.finish_reason !== 'tool_calls') {
1017
+ const finalized = finalizeHermesTurn(turn, hermesOutcomeFor(row.finish_reason))
1018
+ return { finalized, next: createHermesTurn(meta) }
1019
+ }
1020
+ turn.lastMid = Number(row.id)
1021
+ return { next: turn }
1022
+ }
1023
+
1024
+ return { next: turn }
1025
+ }
1026
+
1027
+ function emitHermesOutcome(turn, sinks, state) {
1028
+ if (!turn.id) return false
1029
+ const summary = cleanText(turn.lastText)
1030
+ if (!summary) return false
1031
+ const outcome = turn.outcome
1032
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1033
+
1034
+ sinks.trace({
1035
+ trace_id: 'hermes:' + (turn.sessionId || 'unknown') + ':' + String(turn.id) + ':' + tsToken,
1036
+ event_type: 'task_outcome',
1037
+ outcome: outcome,
1038
+ payload: {
1039
+ host: 'hermes',
1040
+ project: turn.project,
1041
+ sessionId: turn.sessionId,
1042
+ turnId: turn.id,
1043
+ title: turn.title,
1044
+ agent: turn.agent,
1045
+ toolCalls: turn.toolCount,
1046
+ errors: 0,
1047
+ summary: summary.slice(0, 300),
1048
+ source: 'hermes-observer',
1049
+ },
1050
+ agent: 'hermes',
1051
+ host: 'hermes',
1052
+ project: turn.project,
1053
+ })
1054
+
1055
+ if (outcome === 'success' && summary.length >= 10) {
1056
+ sinks.memory({
1057
+ kind: 'note',
1058
+ content: '[hermes] 任务完成: ' + summary.slice(0, 160),
1059
+ tags: ['task-outcome', 'hermes', turn.project],
1060
+ project: turn.project,
1061
+ source: 'hermes-observer',
1062
+ })
1063
+ // Hermes reads AGENTS.md Context Files; write back only into a real repo
1064
+ // workspace so we never pollute the user's home directory.
1065
+ if (turn.cwd) {
1066
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1067
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
1068
+ }
1069
+ }
1070
+ return true
1071
+ }
1072
+
1073
+ function runHermesCycle(home, state, sinks, opts) {
1074
+ const out = { files: 0, lines: 0, outcomes: 0 }
1075
+ const dbPath = hermesDbPath()
1076
+ if (!fs.existsSync(dbPath)) return out
1077
+ const sqlite = hermesSqliteModule()
1078
+ if (!sqlite) return out
1079
+ let db
1080
+ try {
1081
+ db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
1082
+ } catch (_) {
1083
+ return out
1084
+ }
1085
+ out.files = 1
1086
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1087
+ try {
1088
+ if (!state.hmSeeded) {
1089
+ const cutoff = (Date.now() - maxAgeMs) / 1000
1090
+ const first = db.prepare('SELECT id FROM messages WHERE active = 1 AND timestamp >= ? ORDER BY id LIMIT 1').get(cutoff)
1091
+ if (first) {
1092
+ state.hmLastMsgId = first.id
1093
+ } else {
1094
+ const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get()
1095
+ state.hmLastMsgId = maxRow.id
1096
+ }
1097
+ state.hmSeeded = true
1098
+ }
1099
+
1100
+ const limit = 2000
1101
+ const stmt = db.prepare(
1102
+ 'SELECT m.id AS id, m.session_id AS sid, m.role AS role, m.content AS content, ' +
1103
+ 'm.tool_name AS tool_name, m.tool_calls AS tool_calls, m.timestamp AS t, m.finish_reason AS finish_reason, ' +
1104
+ 's.title AS stitle, s.cwd AS scwd, s.git_repo_root AS sgit, s.source AS ssource ' +
1105
+ 'FROM messages m LEFT JOIN sessions s ON s.id = m.session_id ' +
1106
+ 'WHERE m.id > ? AND m.active = 1 ORDER BY m.id LIMIT ' + limit
1107
+ )
1108
+ const rows = stmt.all(state.hmLastMsgId)
1109
+ if (rows.length === 0) return out
1110
+
1111
+ let lastId = state.hmLastMsgId
1112
+ for (const row of rows) {
1113
+ const rid = Number(row.id)
1114
+ if (rid > lastId) lastId = rid
1115
+ out.lines += 1
1116
+ const sid = row.sid
1117
+ let turn = state.hmTurns[sid]
1118
+ if (!turn) {
1119
+ turn = createHermesTurn({ sessionId: sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd })
1120
+ state.hmTurns[sid] = turn
1121
+ }
1122
+ const result = feedHermesRow(turn, row)
1123
+ if (result.finalized) {
1124
+ if (emitHermesOutcome(result.finalized, sinks, state)) out.outcomes += 1
1125
+ }
1126
+ state.hmTurns[sid] = result.next
1127
+ }
1128
+ state.hmLastMsgId = lastId
1129
+ } finally {
1130
+ db.close()
1131
+ }
1132
+ return out
1133
+ }
1134
+
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // cycle
1138
+ // ---------------------------------------------------------------------------
1139
+
1140
+ function runCycle(home, state, sinks, opts) {
1141
+ const out = { files: 0, lines: 0, outcomes: 0 }
1142
+ fs.mkdirSync(home, { recursive: true })
1143
+ const files = listTranscripts()
1144
+ const now = Date.now()
1145
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1146
+
1147
+ for (const file of files) {
1148
+ out.files += 1
1149
+ let st
1150
+ try {
1151
+ st = fs.statSync(file)
1152
+ } catch (_) {
1153
+ continue
1154
+ }
1155
+ const info = state.files[file] || { size: 0 }
1156
+ // Do not flood MIO_HOME with ancient history: only backfill files that are
1157
+ // already tracked or that changed within the max age window.
1158
+ if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
1159
+ const size = st.size
1160
+ if (size < info.size) {
1161
+ // rotated/truncated -> restart from the beginning
1162
+ info.size = 0
1163
+ state.buffers[file] = ''
1164
+ }
1165
+
1166
+ const meta = fileMeta(file)
1167
+ let turn = state.openTurns[file] || createTurn(meta)
1168
+ turn.project = meta.project
1169
+ turn.sessionId = meta.sessionId
1170
+
1171
+ if (size > info.size) {
1172
+ let chunk = ''
1173
+ try {
1174
+ const fd = fs.openSync(file, 'r')
1175
+ const buf = Buffer.alloc(size - info.size)
1176
+ fs.readSync(fd, buf, 0, buf.length, info.size)
1177
+ fs.closeSync(fd)
1178
+ chunk = buf.toString('utf8')
1179
+ } catch (_) {
1180
+ continue
1181
+ }
1182
+ const pending = (state.buffers[file] || '') + chunk
1183
+ const lines = pending.split(/\r?\n/)
1184
+ state.buffers[file] = lines.pop()
1185
+ for (const line of lines) {
1186
+ if (!line.trim()) continue
1187
+ out.lines += 1
1188
+ let obj
1189
+ try {
1190
+ obj = JSON.parse(line)
1191
+ } catch (_) {
1192
+ continue
1193
+ }
1194
+ const result = feedWorkbuddyLine(turn, obj)
1195
+ if (result.finalized) {
1196
+ if (emitWorkbuddyOutcome(result.finalized, sinks, state)) out.outcomes += 1
1197
+ }
1198
+ turn = result.next
1199
+ }
1200
+ state.files[file] = { size: size, mtimeMs: st.mtimeMs }
1201
+ }
1202
+
1203
+ if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
1204
+ if (emitWorkbuddyOutcome(turn, sinks, state)) out.outcomes += 1
1205
+ state.openTurns[file] = null
1206
+ } else if (turn.id) {
1207
+ state.openTurns[file] = turn
1208
+ } else {
1209
+ state.openTurns[file] = null
1210
+ }
1211
+ }
1212
+
1213
+ const codexRes = runCodexCycle(home, state, sinks, opts)
1214
+ out.files += codexRes.files
1215
+ out.lines += codexRes.lines
1216
+ out.outcomes += codexRes.outcomes
1217
+
1218
+ const ocRes = runOpencodeCycle(home, state, sinks, opts)
1219
+ out.files += ocRes.files
1220
+ out.lines += ocRes.lines
1221
+ out.outcomes += ocRes.outcomes
1222
+
1223
+ const hmRes = runHermesCycle(home, state, sinks, opts)
1224
+ out.files += hmRes.files
1225
+ out.lines += hmRes.lines
1226
+ out.outcomes += hmRes.outcomes
1227
+
1228
+ saveState(home, state)
1229
+ return out
1230
+ }
1231
+
1232
+ function startForeground(home) {
1233
+ const state = loadState(home)
1234
+ const sinks = createSinks(home)
1235
+ log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
1236
+ const tick = () => {
1237
+ try {
1238
+ const res = runCycle(home, state, sinks)
1239
+ if (res.lines > 0) {
1240
+ log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
1241
+ }
1242
+ } catch (err) {
1243
+ log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
1244
+ }
1245
+ }
1246
+ tick()
1247
+ setInterval(tick, INTERVAL_MS)
1248
+ }
1249
+
1250
+ // ---------------------------------------------------------------------------
1251
+ // background daemon control
1252
+ // ---------------------------------------------------------------------------
1253
+
1254
+ function pidFile(home) {
1255
+ return path.join(home, 'observe.pid')
1256
+ }
1257
+
1258
+ function readPid(home) {
1259
+ try {
1260
+ const pid = Number(fs.readFileSync(pidFile(home), 'utf8').trim())
1261
+ return Number.isInteger(pid) && pid > 0 ? pid : null
1262
+ } catch (_) {
1263
+ return null
1264
+ }
1265
+ }
1266
+
1267
+ function isRunning(home) {
1268
+ const pid = readPid(home)
1269
+ if (!pid) return false
1270
+ try {
1271
+ process.kill(pid, 0)
1272
+ return true
1273
+ } catch (_) {
1274
+ return false
1275
+ }
1276
+ }
1277
+
1278
+ function startBackground(home) {
1279
+ if (isRunning(home)) {
1280
+ return { running: true, started: false, pid: readPid(home) }
1281
+ }
1282
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
1283
+ const child = spawn(process.execPath, [script, 'observe'], {
1284
+ detached: true,
1285
+ stdio: 'ignore',
1286
+ windowsHide: true,
1287
+ cwd: home,
1288
+ })
1289
+ child.unref()
1290
+ fs.mkdirSync(home, { recursive: true })
1291
+ fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
1292
+ return { running: true, started: true, pid: child.pid }
1293
+ }
1294
+
1295
+ function stopBackground(home) {
1296
+ const pid = readPid(home)
1297
+ if (pid) {
1298
+ try {
1299
+ process.kill(pid)
1300
+ } catch (_) {}
1301
+ try {
1302
+ fs.unlinkSync(pidFile(home))
1303
+ } catch (_) {}
1304
+ }
1305
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
1306
+ }
1307
+
1308
+ module.exports = {
1309
+ INTERVAL_MS,
1310
+ homeDir,
1311
+ workbuddyConfigDir,
1312
+ loadState,
1313
+ saveState,
1314
+ updateProjectContext,
1315
+ runCycle,
1316
+ opencodeDbPath,
1317
+ runOpencodeCycle,
1318
+ runHermesCycle,
1319
+ hermesDbPath,
1320
+ createSinks,
1321
+ startForeground,
1322
+ startBackground,
1323
+ stopBackground,
1324
+ isRunning,
1325
+ readPid,
1326
+ }