mio-agent-runtime 0.13.4 → 0.13.5

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,1867 +1,1876 @@
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
- //
43
- // Hosts that munge a timestamped workspace path produce a trailing numeric
44
- // segment, e.g. "c-Users-me-WorkBuddy-2026-08-27-17-37-10" -> "10". Using
45
- // the seconds field as a project name funnels unrelated sessions into
46
- // colliding 2-digit buckets that recall never resolves to, so fall back to
47
- // the whole directory name in that case.
48
- //
49
- // ⚠️ The test must be /^\d+$/ (one backslash). An earlier revision wrote
50
- // /^\\d+$/ -- that matches a literal backslash followed by "d", so it never
51
- // fires and every numeric segment becomes the project (fixed 2026-09-20).
52
- // Keep the escaping as-is; do not "tidy" it into a double backslash.
53
- const parts = String(dirName || '').split('-').filter(Boolean)
54
- if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) {
55
- return dirName || 'unknown'
56
- }
57
- return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
58
- }
59
-
60
- function log(home, message) {
61
- const line = '[' + new Date().toISOString() + '] ' + message + '\n'
62
- try {
63
- fs.mkdirSync(path.join(home, 'logs'), { recursive: true })
64
- fs.appendFileSync(path.join(home, 'logs', 'observe.log'), line, 'utf8')
65
- } catch (_) {}
66
- if (process.stdout.isTTY || process.env.MIO_OBSERVE_VERBOSE) {
67
- process.stdout.write(line)
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // state
73
- // ---------------------------------------------------------------------------
74
-
75
- function stateFile(home) {
76
- return path.join(home, 'observe-state.json')
77
- }
78
-
79
- // Canonical form for a workspace path used as a context key.
80
- //
81
- // Context keys are taken verbatim from the `cwd` field of host transcripts, and
82
- // hosts disagree on spelling: the same directory arrives as `D:\work\code\x`,
83
- // `d:\work\code\x`, or `D:/work/code/x`. Left alone, one project splits into
84
- // several buckets, each capped at CONTEXT_MAX, so its history is fragmented and
85
- // entries are dropped early. Windows paths are case-insensitive, so normalise
86
- // the drive letter to upper case and use backslashes consistently to match the
87
- // style the file-observation keys already use.
88
- function normalizeCwd(cwd) {
89
- if (!cwd || typeof cwd !== 'string') return cwd
90
- let out = cwd.replace(/\//g, '\\').replace(/\\+/g, '\\')
91
- // Upper-case a single drive letter prefix ("c:\..." -> "C:\...").
92
- if (/^[a-zA-Z]:\\/.test(out)) out = out[0].toUpperCase() + out.slice(1)
93
- // Drop a trailing separator unless the path is just the drive root.
94
- if (out.length > 3 && out.endsWith('\\')) out = out.slice(0, -1)
95
- return out
96
- }
97
-
98
- // Parse the "YYYY/M/D HH:MM:SS | ..." stamp that context lines carry.
99
- // Returns null when the line has no recognisable leading timestamp, so
100
- // unparseable entries never get treated as the oldest by accident.
101
- function contextTimestamp(line) {
102
- const m = /^\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})/.exec(String(line || ''))
103
- if (!m) return null
104
- const ms = new Date(
105
- Number(m[1]),
106
- Number(m[2]) - 1,
107
- Number(m[3]),
108
- Number(m[4]),
109
- Number(m[5]),
110
- Number(m[6]),
111
- ).getTime()
112
- return Number.isFinite(ms) ? ms : null
113
- }
114
-
115
- // Drop repeated lines from stored context lists, preserving the most recent
116
- // occurrence order. Older builds could write the same line into every slot of
117
- // a workspace block (a generated digest headline broadcast to all projects),
118
- // so existing observe-state files still carry duplicates until they are
119
- // rewritten. Normalising on load heals that data without a migration step.
120
- //
121
- // Keys are also folded through normalizeCwd and merged, so buckets split by
122
- // spelling differences collapse back into one on the next load.
123
- function dedupeContexts(contexts) {
124
- const out = {}
125
- for (const [cwd, list] of Object.entries(contexts)) {
126
- const key = normalizeCwd(cwd)
127
- const existing = out[key] || []
128
- if (!Array.isArray(list)) {
129
- out[key] = existing
130
- continue
131
- }
132
- const seen = new Set(existing)
133
- const merged = existing.slice()
134
- for (const line of list) {
135
- if (seen.has(line)) continue
136
- seen.add(line)
137
- merged.push(line)
138
- }
139
- out[key] = merged
140
- }
141
- return out
142
- }
143
-
144
- function loadState(home) {
145
- try {
146
- const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
147
- if (parsed && typeof parsed === 'object') {
148
- return {
149
- version: 1,
150
- files: parsed.files || {},
151
- buffers: parsed.buffers || {},
152
- openTurns: parsed.openTurns || {},
153
- contexts: dedupeContexts(parsed.contexts || {}),
154
- codexFiles: parsed.codexFiles || {},
155
- codexBuffers: parsed.codexBuffers || {},
156
- codexTurns: parsed.codexTurns || {},
157
- ocSeeded: parsed.ocSeeded || false,
158
- ocLastRowid: parsed.ocLastRowid || 0,
159
- ocDbMtime: parsed.ocDbMtime || null,
160
- ocPending: parsed.ocPending || false,
161
- ocTurns: parsed.ocTurns || {},
162
- hmSeeded: parsed.hmSeeded || false,
163
- hmLastMsgId: parsed.hmLastMsgId || 0,
164
- hmTurns: parsed.hmTurns || {},
165
- clFiles: parsed.clFiles || {},
166
- clBuffers: parsed.clBuffers || {},
167
- clTurns: parsed.clTurns || {},
168
- }
169
- }
170
- } catch (_) {}
171
- return {
172
- version: 1,
173
- files: {},
174
- buffers: {},
175
- openTurns: {},
176
- contexts: {},
177
- codexFiles: {},
178
- codexBuffers: {},
179
- codexTurns: {},
180
- ocSeeded: false,
181
- ocLastRowid: 0,
182
- ocDbMtime: null,
183
- ocPending: false,
184
- ocTurns: {},
185
- hmSeeded: false,
186
- hmLastMsgId: 0,
187
- hmTurns: {},
188
- clFiles: {},
189
- clBuffers: {},
190
- clTurns: {},
191
- }
192
- }
193
-
194
- function saveState(home, state) {
195
- try {
196
- fs.mkdirSync(home, { recursive: true })
197
- fs.writeFileSync(stateFile(home), JSON.stringify(state) + '\n', 'utf8')
198
- } catch (_) {}
199
- }
200
-
201
- function listTranscripts() {
202
- const root = path.join(workbuddyConfigDir(), 'projects')
203
- const out = []
204
- if (!fs.existsSync(root)) return out
205
- const walk = (dir) => {
206
- let entries
207
- try {
208
- entries = fs.readdirSync(dir, { withFileTypes: true })
209
- } catch (_) {
210
- return
211
- }
212
- for (const entry of entries) {
213
- const full = path.join(dir, entry.name)
214
- if (entry.isDirectory()) walk(full)
215
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
216
- }
217
- }
218
- walk(root)
219
- return out
220
- }
221
-
222
- function codexConfigDir() {
223
- return process.env.CODEX_CONFIG_DIR || path.join(os.homedir(), '.codex')
224
- }
225
-
226
- function listCodexTranscripts() {
227
- const root = path.join(codexConfigDir(), 'sessions')
228
- const out = []
229
- if (!fs.existsSync(root)) return out
230
- const walk = (dir) => {
231
- let entries
232
- try {
233
- entries = fs.readdirSync(dir, { withFileTypes: true })
234
- } catch (_) {
235
- return
236
- }
237
- for (const entry of entries) {
238
- const full = path.join(dir, entry.name)
239
- if (entry.isDirectory()) walk(full)
240
- else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) out.push(full)
241
- }
242
- }
243
- walk(root)
244
- return out
245
- }
246
-
247
- function fileMeta(file) {
248
- const dirName = path.basename(path.dirname(file))
249
- return {
250
- sessionId: path.basename(file, '.jsonl'),
251
- project: projectFromDirName(dirName),
252
- }
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // sinks (write into MIO_HOME with the same schema as the MCP server)
257
- // ---------------------------------------------------------------------------
258
-
259
- function appendJsonl(file, value) {
260
- fs.mkdirSync(path.dirname(file), { recursive: true })
261
- fs.appendFileSync(file, JSON.stringify(value) + '\n', 'utf8')
262
- }
263
-
264
- function createSinks(home) {
265
- const tracePath = path.join(home, 'traces.jsonl')
266
- const memoryPath = path.join(home, 'memory.jsonl')
267
- const seenTraceIds = new Set()
268
- const seenMemoryContents = new Set()
269
-
270
- function trace(event) {
271
- const record = {
272
- id: createId('trace'),
273
- timestamp: new Date().toISOString(),
274
- trace_id: event.trace_id,
275
- event_type: event.event_type,
276
- outcome: event.outcome || null,
277
- payload: event.payload || {},
278
- agent: event.agent || 'workbuddy-observer',
279
- host: event.host || 'workbuddy-observer',
280
- project: event.project || 'unknown',
281
- }
282
- if (seenTraceIds.has(record.trace_id)) return
283
- seenTraceIds.add(record.trace_id)
284
- if (seenTraceIds.size > 20000) seenTraceIds.clear()
285
- appendJsonl(tracePath, record)
286
- }
287
-
288
- function memory(entry) {
289
- const key = entry.project + '|' + entry.content
290
- if (seenMemoryContents.has(key)) return
291
- seenMemoryContents.add(key)
292
- const record = {
293
- id: createId('mem'),
294
- timestamp: new Date().toISOString(),
295
- kind: entry.kind || 'note',
296
- content: entry.content,
297
- tags: Array.isArray(entry.tags) ? entry.tags : [],
298
- project: entry.project || 'unknown',
299
- source: entry.source || 'workbuddy-observer',
300
- }
301
- appendJsonl(memoryPath, record)
302
- }
303
-
304
- return { trace, memory }
305
- }
306
-
307
- // ---------------------------------------------------------------------------
308
- // WorkBuddy transcript turn machine
309
- // ---------------------------------------------------------------------------
310
-
311
- function createTurn(meta) {
312
- return {
313
- id: null,
314
- ts: null,
315
- project: meta.project,
316
- sessionId: meta.sessionId,
317
- cwd: meta.cwd || null,
318
- toolCount: 0,
319
- errors: [],
320
- lastAssistant: null,
321
- }
322
- }
323
-
324
- function feedWorkbuddyLine(turn, obj) {
325
- if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
326
- let finalized = null
327
- if (obj.cwd) turn.cwd = obj.cwd
328
- if (obj.type === 'message') {
329
- if (obj.role === 'user') {
330
- finalized = turn
331
- const next = createTurn(turn)
332
- next.id = obj.id
333
- next.ts = obj.timestamp
334
- next.cwd = obj.cwd || turn.cwd
335
- return { finalized, next: next }
336
- }
337
- if (obj.role === 'assistant') {
338
- const text = (Array.isArray(obj.content) ? obj.content : [])
339
- .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
340
- .filter(Boolean)
341
- .join('\n')
342
- turn.lastAssistant = {
343
- status: obj.status,
344
- text: text,
345
- error: (obj.providerData && obj.providerData.error) || null,
346
- }
347
- }
348
- } else if (obj.type === 'function_call') {
349
- turn.toolCount += 1
350
- } else if (obj.type === 'function_call_result') {
351
- const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
352
- if (status !== 'completed') {
353
- turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
354
- if (turn.errors.length > 20) turn.errors.shift()
355
- }
356
- }
357
- return { finalized: finalized, next: turn }
358
- }
359
-
360
- const CONTEXT_BEGIN = '<!-- MIO_CONTEXT_BEGIN -->'
361
- const CONTEXT_END = '<!-- MIO_CONTEXT_END -->'
362
- const CONTEXT_MAX = 6
363
-
364
- // Maintain a compact 'recent Mio context' block inside <workspace>/AGENTS.md,
365
- // which WorkBuddy's MemoryLoader auto-injects into every session. This makes
366
- // retrieval guaranteed (not dependent on the model deciding to read memory).
367
- function updateProjectContext(state, cwd, line, targetFile) {
368
- if (!cwd || !line) return
369
- const key = normalizeCwd(cwd)
370
- const target = path.join(cwd, targetFile || 'AGENTS.md')
371
- // Merge any buckets that differ only by spelling, so a project observed as
372
- // both "D:\work\code\x" and "d:\work\code\x" converges into one list instead
373
- // of two half-filled ones.
374
- const list = []
375
- const seen = new Set()
376
- for (const [existingKey, items] of Object.entries(state.contexts || {})) {
377
- if (normalizeCwd(existingKey) !== key) continue
378
- for (const item of items || []) {
379
- if (seen.has(item)) continue
380
- seen.add(item)
381
- list.push(item)
382
- }
383
- if (existingKey !== key) delete state.contexts[existingKey]
384
- }
385
- // Re-inserting an existing line moves it to the front instead of duplicating.
386
- const existing = list.indexOf(line)
387
- if (existing !== -1) list.splice(existing, 1)
388
- list.unshift(line)
389
- // Once buckets merge, the list can exceed CONTEXT_MAX. Entries are stamped
390
- // "YYYY/M/D HH:MM:SS | ...", so trim by that timestamp rather than by
391
- // position -- merging appends whole buckets in object-key order, which says
392
- // nothing about age, and popping blind could discard recent work.
393
- if (list.length > CONTEXT_MAX) {
394
- const stamped = list.filter((l) => contextTimestamp(l) !== null)
395
- if (stamped.length > 1) {
396
- list.sort((a, b) => contextTimestamp(b) - contextTimestamp(a))
397
- }
398
- list.length = CONTEXT_MAX
399
- }
400
- state.contexts = state.contexts || {}
401
- state.contexts[key] = list
402
- try {
403
- let content = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
404
- const block = CONTEXT_BEGIN + '\n## Mio 最近上下文(自动注入)\n' + list.map(function (l) { return '- ' + l }).join('\n') + '\n' + CONTEXT_END
405
- const start = content.indexOf(CONTEXT_BEGIN)
406
- if (start !== -1) {
407
- const end = content.indexOf(CONTEXT_END, start)
408
- content = end === -1 ? content.slice(0, start) : content.slice(0, start) + content.slice(end + CONTEXT_END.length)
409
- }
410
- content = content.trimEnd() + '\n\n' + block + '\n'
411
- fs.mkdirSync(path.dirname(target), { recursive: true })
412
- fs.writeFileSync(target, content, 'utf8')
413
- } catch (_) {}
414
- }
415
-
416
- function localDateKey(ts) {
417
- const d = new Date(Number(ts) || Date.now())
418
- const y = d.getFullYear()
419
- const m = String(d.getMonth() + 1).padStart(2, '0')
420
- const day = String(d.getDate()).padStart(2, '0')
421
- return y + '-' + m + '-' + day
422
- }
423
-
424
- // Write a compact Mio digest back into WorkBuddy's own memory file
425
- // (<workspace>/.workbuddy/memory/YYYY-MM-DD.md). WorkBuddy instructs its model
426
- // to maintain and read these files every session, so the digest is
427
- // 'retrieved' next session without any Mio tool call by the model.
428
- function writeWorkbuddyDigest(turn, outcome, summary) {
429
- if (!turn.cwd || !summary) return
430
- const dir = path.join(turn.cwd, '.workbuddy', 'memory')
431
- const file = path.join(dir, localDateKey(turn.ts) + '.md')
432
- try {
433
- fs.mkdirSync(dir, { recursive: true })
434
- const time = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
435
- const entry =
436
- '\n## Mio 自动摘要 (' + time + ')\n' +
437
- '- outcome: ' + outcome + ' | tools: ' + turn.toolCount + ' | ' + summary.slice(0, 160) + '\n'
438
- fs.appendFileSync(file, entry, 'utf8')
439
- } catch (_) {}
440
- }
441
-
442
- function emitWorkbuddyOutcome(turn, sinks, state) {
443
- const la = turn.lastAssistant
444
- if (!la) return false
445
- let outcome
446
- if (la.status === 'completed') outcome = 'success'
447
- else if (la.error) outcome = 'failure'
448
- else outcome = 'aborted'
449
-
450
- const summary = cleanText(la.text)
451
- const turnToken = String(turn.id || 'unknown').slice(0, 8)
452
- const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
453
-
454
- sinks.trace({
455
- trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
456
- event_type: 'task_outcome',
457
- outcome: outcome,
458
- payload: {
459
- host: 'workbuddy',
460
- project: turn.project,
461
- sessionId: turn.sessionId,
462
- turnId: turn.id,
463
- toolCalls: turn.toolCount,
464
- errors: turn.errors.length,
465
- summary: summary.slice(0, 300),
466
- },
467
- agent: 'workbuddy',
468
- host: 'workbuddy',
469
- project: turn.project,
470
- })
471
-
472
- // Low threshold: even a short but real completion summary must reach the
473
- // passive retrieval paths (memory + daily digest + AGENTS.md context block).
474
- // 40 was too strict and silently dropped short summaries from retrieval.
475
- if (outcome === 'success' && summary.length >= 10) {
476
- sinks.memory({
477
- kind: 'note',
478
- content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
479
- tags: ['task-outcome', 'workbuddy', turn.project],
480
- project: turn.project,
481
- source: 'workbuddy-observer',
482
- })
483
- writeWorkbuddyDigest(turn, outcome, summary)
484
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
485
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
486
- }
487
-
488
- for (const err of turn.errors) {
489
- sinks.trace({
490
- trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
491
- event_type: 'error',
492
- outcome: 'error',
493
- payload: {
494
- host: 'workbuddy',
495
- project: turn.project,
496
- sessionId: turn.sessionId,
497
- turnId: turn.id,
498
- tool: err.name,
499
- status: err.status,
500
- },
501
- agent: 'workbuddy',
502
- host: 'workbuddy',
503
- project: turn.project,
504
- })
505
- }
506
- return true
507
- }
508
-
509
- // ---------------------------------------------------------------------------
510
- // Codex session turn machine (passive observation)
511
- // ---------------------------------------------------------------------------
512
-
513
- // Codex rollout transcripts are append-only JSONL under
514
- // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The first session_meta line
515
- // carries the workspace cwd; task_started / task_complete / turn_aborted mark
516
- // turn boundaries, agent_message carries the running answer text.
517
- function createCodexTurn() {
518
- return {
519
- id: null,
520
- ts: null,
521
- sessionId: null,
522
- cwd: null,
523
- lastAgent: null,
524
- aborted: false,
525
- }
526
- }
527
-
528
- function feedCodexLine(turn, obj) {
529
- if (!obj || typeof obj !== 'object') return {}
530
- if (obj.type === 'session_meta') {
531
- const p = obj.payload || {}
532
- if (!turn.cwd && p.cwd) turn.cwd = p.cwd
533
- if (!turn.sessionId && (p.session_id || p.id)) turn.sessionId = p.session_id || p.id
534
- return {}
535
- }
536
- if (obj.type !== 'event_msg') return {}
537
- const p = obj.payload || {}
538
- if (p.type === 'task_started') {
539
- // A turn that started earlier but never completed is discarded.
540
- turn.id = p.turn_id || null
541
- turn.ts = p.started_at ? Number(p.started_at) * 1000 : null
542
- turn.lastAgent = null
543
- turn.aborted = false
544
- return {}
545
- }
546
- if (p.type === 'agent_message' && p.message) {
547
- turn.lastAgent = p.message
548
- return {}
549
- }
550
- if (p.type === 'task_complete') {
551
- const finalized = {
552
- id: turn.id,
553
- ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
554
- sessionId: turn.sessionId,
555
- cwd: turn.cwd,
556
- project: path.basename(turn.cwd || '') || 'unknown',
557
- lastAgent: p.last_agent_message || turn.lastAgent,
558
- aborted: false,
559
- }
560
- turn.id = null
561
- turn.lastAgent = null
562
- return { finalized: finalized }
563
- }
564
- if (p.type === 'turn_aborted') {
565
- const finalized = {
566
- id: turn.id,
567
- ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
568
- sessionId: turn.sessionId,
569
- cwd: turn.cwd,
570
- project: path.basename(turn.cwd || '') || 'unknown',
571
- lastAgent: turn.lastAgent,
572
- aborted: true,
573
- }
574
- turn.id = null
575
- turn.lastAgent = null
576
- return { finalized: finalized }
577
- }
578
- return {}
579
- }
580
-
581
- function emitCodexOutcome(turn, sinks, state) {
582
- if (!turn.lastAgent) return false
583
- const summary = cleanText(turn.lastAgent)
584
- if (!summary) return false
585
- const outcome = turn.aborted ? 'aborted' : 'success'
586
- const turnToken = String(turn.id || 'unknown').slice(0, 8)
587
-
588
- sinks.trace({
589
- trace_id: 'codex:' + (turn.sessionId || 'unknown') + ':' + turnToken,
590
- event_type: 'task_outcome',
591
- outcome: outcome,
592
- payload: {
593
- host: 'codex',
594
- project: turn.project,
595
- sessionId: turn.sessionId,
596
- turnId: turn.id,
597
- toolCalls: 0,
598
- errors: 0,
599
- summary: summary.slice(0, 300),
600
- source: 'codex-observer',
601
- },
602
- agent: 'codex',
603
- host: 'codex',
604
- project: turn.project,
605
- })
606
-
607
- if (outcome === 'success' && summary.length >= 10) {
608
- sinks.memory({
609
- kind: 'note',
610
- content: '[codex] 任务完成: ' + summary.slice(0, 160),
611
- tags: ['task-outcome', 'codex', turn.project],
612
- project: turn.project,
613
- source: 'codex-observer',
614
- })
615
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
616
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
617
- }
618
- return true
619
- }
620
-
621
- function runCodexCycle(home, state, sinks, opts) {
622
- const out = { files: 0, lines: 0, outcomes: 0 }
623
- const files = listCodexTranscripts()
624
- const now = Date.now()
625
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
626
-
627
- for (const file of files) {
628
- out.files += 1
629
- let st
630
- try {
631
- st = fs.statSync(file)
632
- } catch (_) {
633
- continue
634
- }
635
- const info = state.codexFiles[file] || { size: 0 }
636
- if (!state.codexFiles[file] && now - st.mtimeMs > maxAgeMs) continue
637
- const size = st.size
638
- if (size < info.size) {
639
- info.size = 0
640
- state.codexBuffers[file] = ''
641
- }
642
-
643
- if (!state.codexTurns[file]) state.codexTurns[file] = createCodexTurn()
644
- const turn = state.codexTurns[file]
645
-
646
- if (size > info.size) {
647
- let chunk = ''
648
- try {
649
- const fd = fs.openSync(file, 'r')
650
- const buf = Buffer.alloc(size - info.size)
651
- fs.readSync(fd, buf, 0, buf.length, info.size)
652
- fs.closeSync(fd)
653
- chunk = buf.toString('utf8')
654
- } catch (_) {
655
- continue
656
- }
657
- const pending = (state.codexBuffers[file] || '') + chunk
658
- const lines = pending.split(/\r?\n/)
659
- state.codexBuffers[file] = lines.pop()
660
- for (const line of lines) {
661
- if (!line.trim()) continue
662
- out.lines += 1
663
- let obj
664
- try {
665
- obj = JSON.parse(line)
666
- } catch (_) {
667
- continue
668
- }
669
- const result = feedCodexLine(turn, obj)
670
- if (result.finalized) {
671
- if (emitCodexOutcome(result.finalized, sinks, state)) out.outcomes += 1
672
- }
673
- }
674
- state.codexFiles[file] = { size: size, mtimeMs: st.mtimeMs }
675
- }
676
- }
677
-
678
- return out
679
- }
680
-
681
- // ---------------------------------------------------------------------------
682
- // OpenCode session observation (passive, via `opencode db`)
683
- // ---------------------------------------------------------------------------
684
-
685
- // OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
686
- // On Windows the db file is held with an exclusive lock while OpenCode runs,
687
- // so we cannot open it with node:sqlite directly. Instead we shell out to the
688
- // `opencode db` CLI, which opens the same database through the app itself and
689
- // works even while OpenCode is running. We tail `part` rows by rowid so each
690
- // session is scanned incrementally and only text/tool parts are transferred.
691
-
692
- function opencodeDataDir() {
693
- return (
694
- process.env.OPENCODE_DATA_DIR ||
695
- path.join(os.homedir(), '.local', 'share', 'opencode')
696
- )
697
- }
698
-
699
- function opencodeDbPath() {
700
- return path.join(opencodeDataDir(), 'opencode.db')
701
- }
702
-
703
- // Resolved once per observer process: the bin does not move mid-run, and
704
- // re-running `where`/`which` on every query doubled the subprocess count of
705
- // every cycle for zero information. Re-resolves only when the cached path
706
- // vanishes from disk. A null result is deliberately NOT cached so an opencode
707
- // installed mid-run is still picked up.
708
- let resolvedOpencodeBin
709
-
710
- function findOpencodeBin() {
711
- if (resolvedOpencodeBin && fs.existsSync(resolvedOpencodeBin)) {
712
- return resolvedOpencodeBin
713
- }
714
- if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
715
- resolvedOpencodeBin = process.env.OPENCODE_BIN
716
- return resolvedOpencodeBin
717
- }
718
- const exeDir = path.dirname(process.execPath)
719
- const candidates = [
720
- path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
721
- path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
722
- path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
723
- ]
724
- for (const c of candidates) {
725
- const resolved = path.resolve(c)
726
- if (fs.existsSync(resolved)) return resolved
727
- }
728
- // Resolve from PATH so the SQL is never passed through cmd.exe, where
729
- // '>' in the query is interpreted as output redirection and creates
730
- // stray files (e.g. "WHERE p.rowid > 0" -> a file named "0").
731
- try {
732
- const finder = process.platform === 'win32' ? 'where' : 'which'
733
- // stdio[0] must not be a pipe: a piped stdin makes spawnSync fail with
734
- // EBUSY before the child starts (status null, empty stderr), which would
735
- // read as "opencode is not on PATH". `where` reads no stdin.
736
- const res = spawnSync(finder, ['opencode'], {
737
- encoding: 'utf8',
738
- windowsHide: true,
739
- timeout: 15000,
740
- stdio: ['ignore', 'pipe', 'pipe'],
741
- })
742
- if (res.status === 0 && res.stdout) {
743
- const lines = res.stdout
744
- .split(/\r?\n/)
745
- .map((s) => s.trim())
746
- .filter(Boolean)
747
- // ⚠️ Known dead end on Windows, recorded by decision rather than "fixed"
748
- // (measured 2026-09-24). This filter prefers .cmd/.bat, but
749
- // runOpencodeQuery() spawns the result WITHOUT shell:true, and Node
750
- // refuses to spawn a .cmd/.bat without a shell:
751
- //
752
- // spawn opencode.cmd no shell status=null error=EINVAL
753
- // spawn opencode.cmd shell:true status=0 stdout="1.15.12"
754
- //
755
- // So when the only thing on PATH is an npm shim (e.g. `opencode.cmd`),
756
- // findOpencodeBin() returns a path that can never be executed, every
757
- // query below returns null, and the opencode observer silently ingests
758
- // nothing. On a machine where `where opencode` yields only `opencode`
759
- // and `opencode.cmd`, neither is spawnable without a shell.
760
- //
761
- // Adding shell:true is NOT the fix: cmd.exe would then parse '>' in the
762
- // SQL as output redirection, which is precisely what the comment above
763
- // this block exists to prevent. The two constraints really do conflict.
764
- //
765
- // Net behaviour is identical either way (null before and after), so it is
766
- // left alone. The bundled opencode-ai .exe path above is unaffected and
767
- // does work. If you change this, write the test that proves the payload
768
- // survives -- not just that status === 0.
769
- const preferred =
770
- process.platform === 'win32'
771
- ? lines.filter((p) => /\.(exe|cmd|bat)$/i.test(p))
772
- : lines
773
- const first = (preferred.length > 0 ? preferred : lines).find((p) => fs.existsSync(p))
774
- if (first) {
775
- resolvedOpencodeBin = first
776
- return first
777
- }
778
- }
779
- } catch (_) {}
780
- return null
781
- }
782
-
783
- function runOpencodeQuery(sql) {
784
- const bin = findOpencodeBin()
785
- // Never shell the SQL through cmd.exe: '>' in the query would be parsed
786
- // as output redirection, creating files named after the redirect target.
787
- if (!bin) return null
788
- const args = ['db', sql, '--format', 'json']
789
- let res
790
- try {
791
- // stdio[0] must not be a pipe: see findOpencodeBin above. `opencode db`
792
- // takes its SQL as an argument and reads no stdin.
793
- res = spawnSync(bin, args, {
794
- encoding: 'utf8',
795
- timeout: 90000,
796
- windowsHide: true,
797
- maxBuffer: 256 * 1024 * 1024,
798
- stdio: ['ignore', 'pipe', 'pipe'],
799
- })
800
- } catch (_) {
801
- return null
802
- }
803
- if (res.error || res.status !== 0 || !res.stdout) return null
804
- try {
805
- return JSON.parse(res.stdout)
806
- } catch (_) {
807
- return null
808
- }
809
- }
810
-
811
- // Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
812
- // keep the transferred payload small.
813
- const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
814
-
815
- function createOpencodeTurn(meta) {
816
- meta = meta || {}
817
- return {
818
- id: null,
819
- ts: null,
820
- sessionId: meta.sessionId || null,
821
- cwd: meta.directory || null,
822
- project: meta.directory ? path.basename(meta.directory) : 'unknown',
823
- agent: meta.agent || null,
824
- title: meta.title || null,
825
- lastMid: null,
826
- lastText: null,
827
- toolCount: 0,
828
- errors: [],
829
- pendingTerminal: null,
830
- done: false,
831
- }
832
- }
833
-
834
- function opencodeOutcomeFor(finish) {
835
- if (finish === 'error') return 'failure'
836
- if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
837
- return 'success'
838
- }
839
-
840
- function finalizeOpencodeTurn(turn, outcome) {
841
- if (!turn.id) return null
842
- const finalized = {
843
- id: turn.id,
844
- ts: turn.ts,
845
- sessionId: turn.sessionId,
846
- cwd: turn.cwd,
847
- project: turn.project,
848
- agent: turn.agent,
849
- title: turn.title,
850
- lastText: turn.lastText,
851
- toolCount: turn.toolCount,
852
- errors: turn.errors.slice(),
853
- outcome: outcome,
854
- }
855
- turn.done = true
856
- return finalized
857
- }
858
-
859
- function applyOpencodeAssistantPart(turn, pd, md, row) {
860
- if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
861
- turn.lastText = pd.text
862
- } else if (pd.type === 'tool') {
863
- turn.toolCount += 1
864
- const status = pd.state && pd.state.status
865
- if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
866
- turn.errors.push({ tool: pd.tool || 'unknown', status: status })
867
- if (turn.errors.length > 20) turn.errors.shift()
868
- }
869
- }
870
- if (md.finish && md.finish !== 'tool-calls') {
871
- turn.pendingTerminal = md.finish
872
- }
873
- turn.lastMid = row.mid
874
- }
875
-
876
- function feedOpencodePart(turn, row) {
877
- let pd
878
- let md
879
- try {
880
- pd = JSON.parse(row.pd)
881
- } catch (_) {
882
- pd = null
883
- }
884
- try {
885
- md = JSON.parse(row.md)
886
- } catch (_) {
887
- md = null
888
- }
889
- if (!pd || !md) return { next: turn }
890
-
891
- // A user message starts a new turn; any unfinished previous turn is aborted.
892
- if (md.role === 'user') {
893
- let finalized = null
894
- if (turn.id && !turn.done) {
895
- finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
896
- }
897
- const next = createOpencodeTurn({
898
- sessionId: row.sid,
899
- directory: row.sdir,
900
- title: row.stitle,
901
- agent: row.sagent,
902
- })
903
- next.id = row.mid
904
- next.ts = Number(row.t) || Date.now()
905
- next.lastMid = row.mid
906
- return { finalized, next }
907
- }
908
-
909
- if (turn.done) return { next: turn }
910
-
911
- // Message boundary after a terminal finish closes the previous turn.
912
- if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
913
- const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
914
- const next = createOpencodeTurn({
915
- sessionId: row.sid,
916
- directory: row.sdir,
917
- title: row.stitle,
918
- agent: row.sagent,
919
- })
920
- applyOpencodeAssistantPart(next, pd, md, row)
921
- return { finalized, next }
922
- }
923
-
924
- applyOpencodeAssistantPart(turn, pd, md, row)
925
- return { next: turn }
926
- }
927
-
928
- function emitOpencodeOutcome(turn, sinks, state) {
929
- if (!turn.id) return false
930
- const summary = cleanText(turn.lastText)
931
- if (!summary) return false
932
- const outcome = turn.outcome
933
- const turnToken = String(turn.id).slice(0, 8)
934
- const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
935
-
936
- sinks.trace({
937
- trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
938
- event_type: 'task_outcome',
939
- outcome: outcome,
940
- payload: {
941
- host: 'opencode',
942
- project: turn.project,
943
- sessionId: turn.sessionId,
944
- turnId: turn.id,
945
- title: turn.title,
946
- agent: turn.agent,
947
- toolCalls: turn.toolCount,
948
- errors: turn.errors.length,
949
- summary: summary.slice(0, 300),
950
- source: 'opencode-observer',
951
- },
952
- agent: 'opencode',
953
- host: 'opencode',
954
- project: turn.project,
955
- })
956
-
957
- if (outcome === 'success' && summary.length >= 10) {
958
- sinks.memory({
959
- kind: 'note',
960
- content: '[opencode] 任务完成: ' + summary.slice(0, 160),
961
- tags: ['task-outcome', 'opencode', turn.project],
962
- project: turn.project,
963
- source: 'opencode-observer',
964
- })
965
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
966
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
967
- }
968
-
969
- for (const err of turn.errors) {
970
- sinks.trace({
971
- trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
972
- event_type: 'error',
973
- outcome: 'error',
974
- payload: {
975
- host: 'opencode',
976
- project: turn.project,
977
- sessionId: turn.sessionId,
978
- turnId: turn.id,
979
- tool: err.tool,
980
- status: err.status,
981
- },
982
- agent: 'opencode',
983
- host: 'opencode',
984
- project: turn.project,
985
- })
986
- }
987
- return true
988
- }
989
-
990
- function runOpencodeCycle(home, state, sinks, opts) {
991
- const out = { files: 0, lines: 0, outcomes: 0 }
992
- const db = opencodeDbPath()
993
- if (!fs.existsSync(db)) return out
994
- let st
995
- try {
996
- st = fs.statSync(db)
997
- } catch (_) {
998
- return out
999
- }
1000
- out.files = 1
1001
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1002
-
1003
- // First run: seed the rowid cursor to the first part written within the
1004
- // backfill window so ancient history never floods MIO_HOME. One round trip:
1005
- // the scalar subselects return NULL for first_rid when nothing was written
1006
- // inside the window, which maps to the same "fall back to MAX(rowid)" branch
1007
- // the previous two-query version had.
1008
- if (!state.ocSeeded) {
1009
- const ts = Date.now() - maxAgeMs
1010
- const seed = runOpencodeQuery(
1011
- 'SELECT (SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1) AS first_rid, ' +
1012
- '(SELECT COALESCE(MAX(rowid), 0) FROM part) AS max_rid'
1013
- )
1014
- let rid = 0
1015
- if (Array.isArray(seed) && seed.length > 0) {
1016
- const firstRid = Number(seed[0].first_rid)
1017
- rid = Number.isFinite(firstRid) && firstRid > 0 ? firstRid : Number(seed[0].max_rid) || 0
1018
- }
1019
- state.ocLastRowid = rid
1020
- state.ocSeeded = true
1021
- state.ocDbMtime = st.mtimeMs
1022
- state.ocPending = true
1023
- return out
1024
- }
1025
-
1026
- const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
1027
- if (!changed && !state.ocPending) return out
1028
-
1029
- const limit = 1500
1030
- const sql =
1031
- 'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
1032
- 'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
1033
- 'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
1034
- 'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
1035
- ' ORDER BY p.rowid LIMIT ' + limit
1036
- const rows = runOpencodeQuery(sql)
1037
- if (!Array.isArray(rows)) return out
1038
- state.ocDbMtime = st.mtimeMs
1039
- if (rows.length === 0) {
1040
- state.ocPending = false
1041
- return out
1042
- }
1043
-
1044
- let lastRid = state.ocLastRowid
1045
- for (const row of rows) {
1046
- const rid = Number(row.rid)
1047
- if (rid > lastRid) lastRid = rid
1048
- out.lines += 1
1049
- const sid = row.sid
1050
- let turn = state.ocTurns[sid]
1051
- if (!turn) {
1052
- turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
1053
- state.ocTurns[sid] = turn
1054
- }
1055
- const result = feedOpencodePart(turn, row)
1056
- if (result.finalized) {
1057
- if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
1058
- }
1059
- state.ocTurns[sid] = result.next
1060
- }
1061
- state.ocLastRowid = lastRid
1062
- state.ocPending = rows.length >= limit
1063
-
1064
- // Caught up: any turn holding a terminal finish is complete.
1065
- if (!state.ocPending) {
1066
- for (const sid of Object.keys(state.ocTurns)) {
1067
- const turn = state.ocTurns[sid]
1068
- if (turn && turn.id && turn.pendingTerminal && !turn.done) {
1069
- const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
1070
- if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
1071
- state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
1072
- }
1073
- }
1074
- }
1075
- return out
1076
- }
1077
-
1078
- // ---------------------------------------------------------------------------
1079
- // Hermes session observation (passive, via its own SQLite state.db)
1080
- // ---------------------------------------------------------------------------
1081
-
1082
- // Hermes (Nous Research hermes-agent) keeps every conversation in
1083
- // <LocalAppData>/hermes/state.db. Unlike OpenCode the file is not exclusively
1084
- // locked, and `messages.id` is a monotonic AUTOINCREMENT cursor, so we can tail
1085
- // it read-only with node:sqlite (Node 22+). Each message is one row carrying
1086
- // role / content / finish_reason, so a user row opens a turn and an assistant
1087
- // row with a terminal finish_reason closes it immediately. Hermes already has
1088
- // native memory, so this observer only imports Hermes experience into the Mio
1089
- // ecosystem (traces + memory + AGENTS.md context when a git repo is present).
1090
-
1091
- let _hermesSqlite = null
1092
- let _hermesSqliteTried = false
1093
-
1094
- function hermesSqliteModule() {
1095
- if (_hermesSqliteTried) return _hermesSqlite
1096
- _hermesSqliteTried = true
1097
- try {
1098
- _hermesSqlite = require('node:sqlite')
1099
- } catch (_) {
1100
- _hermesSqlite = null
1101
- }
1102
- return _hermesSqlite
1103
- }
1104
-
1105
- function hermesDbPath() {
1106
- return (
1107
- process.env.HERMES_DATA_DIR ||
1108
- path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'state.db')
1109
- )
1110
- }
1111
-
1112
- function hermesProjectName(meta) {
1113
- if (meta.git) return path.basename(meta.git)
1114
- if (meta.cwd && meta.cwd !== os.homedir()) return path.basename(meta.cwd)
1115
- return 'hermes'
1116
- }
1117
-
1118
- function createHermesTurn(meta) {
1119
- meta = meta || {}
1120
- return {
1121
- id: null,
1122
- ts: null,
1123
- sessionId: meta.sessionId || null,
1124
- cwd: meta.git || null,
1125
- project: hermesProjectName({ git: meta.git, cwd: meta.projectHint }),
1126
- title: meta.title || null,
1127
- agent: meta.agent || null,
1128
- lastText: null,
1129
- toolCount: 0,
1130
- lastMid: null,
1131
- }
1132
- }
1133
-
1134
- function hermesOutcomeFor(finish) {
1135
- if (finish === 'error' || finish === 'failure') return 'failure'
1136
- if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
1137
- return 'success'
1138
- }
1139
-
1140
- function finalizeHermesTurn(turn, outcome) {
1141
- if (!turn.id) return null
1142
- const finalized = {
1143
- id: turn.id,
1144
- ts: turn.ts,
1145
- sessionId: turn.sessionId,
1146
- cwd: turn.cwd,
1147
- project: turn.project,
1148
- agent: turn.agent,
1149
- title: turn.title,
1150
- lastText: turn.lastText,
1151
- toolCount: turn.toolCount,
1152
- errors: [],
1153
- outcome: outcome,
1154
- }
1155
- return finalized
1156
- }
1157
-
1158
- function feedHermesRow(turn, row) {
1159
- const meta = { sessionId: row.sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd }
1160
-
1161
- if (row.role === 'user') {
1162
- let finalized = null
1163
- if (turn.id) finalized = finalizeHermesTurn(turn, 'aborted')
1164
- const next = createHermesTurn(meta)
1165
- next.id = Number(row.id)
1166
- next.ts = Math.floor(Number(row.t) * 1000) || Date.now()
1167
- next.lastMid = Number(row.id)
1168
- return { finalized, next }
1169
- }
1170
-
1171
- if (row.role === 'assistant') {
1172
- if (!turn.id) return { next: turn }
1173
- if (typeof row.content === 'string' && row.content.trim()) {
1174
- turn.lastText = row.content
1175
- }
1176
- if (row.tool_calls) {
1177
- try {
1178
- const calls = JSON.parse(row.tool_calls)
1179
- if (Array.isArray(calls)) turn.toolCount += calls.length
1180
- } catch (_) {}
1181
- }
1182
- if (row.finish_reason && row.finish_reason !== 'tool_calls') {
1183
- const finalized = finalizeHermesTurn(turn, hermesOutcomeFor(row.finish_reason))
1184
- return { finalized, next: createHermesTurn(meta) }
1185
- }
1186
- turn.lastMid = Number(row.id)
1187
- return { next: turn }
1188
- }
1189
-
1190
- return { next: turn }
1191
- }
1192
-
1193
- function emitHermesOutcome(turn, sinks, state) {
1194
- if (!turn.id) return false
1195
- const summary = cleanText(turn.lastText)
1196
- if (!summary) return false
1197
- const outcome = turn.outcome
1198
- const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1199
-
1200
- sinks.trace({
1201
- trace_id: 'hermes:' + (turn.sessionId || 'unknown') + ':' + String(turn.id) + ':' + tsToken,
1202
- event_type: 'task_outcome',
1203
- outcome: outcome,
1204
- payload: {
1205
- host: 'hermes',
1206
- project: turn.project,
1207
- sessionId: turn.sessionId,
1208
- turnId: turn.id,
1209
- title: turn.title,
1210
- agent: turn.agent,
1211
- toolCalls: turn.toolCount,
1212
- errors: 0,
1213
- summary: summary.slice(0, 300),
1214
- source: 'hermes-observer',
1215
- },
1216
- agent: 'hermes',
1217
- host: 'hermes',
1218
- project: turn.project,
1219
- })
1220
-
1221
- if (outcome === 'success' && summary.length >= 10) {
1222
- sinks.memory({
1223
- kind: 'note',
1224
- content: '[hermes] 任务完成: ' + summary.slice(0, 160),
1225
- tags: ['task-outcome', 'hermes', turn.project],
1226
- project: turn.project,
1227
- source: 'hermes-observer',
1228
- })
1229
- // Hermes reads AGENTS.md Context Files; write back only into a real repo
1230
- // workspace so we never pollute the user's home directory.
1231
- if (turn.cwd) {
1232
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1233
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
1234
- }
1235
- }
1236
- return true
1237
- }
1238
-
1239
- function runHermesCycle(home, state, sinks, opts) {
1240
- const out = { files: 0, lines: 0, outcomes: 0 }
1241
- const dbPath = hermesDbPath()
1242
- if (!fs.existsSync(dbPath)) return out
1243
- const sqlite = hermesSqliteModule()
1244
- if (!sqlite) return out
1245
- let db
1246
- try {
1247
- db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
1248
- } catch (_) {
1249
- return out
1250
- }
1251
- out.files = 1
1252
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1253
- try {
1254
- if (!state.hmSeeded) {
1255
- const cutoff = (Date.now() - maxAgeMs) / 1000
1256
- const first = db.prepare('SELECT id FROM messages WHERE active = 1 AND timestamp >= ? ORDER BY id LIMIT 1').get(cutoff)
1257
- if (first) {
1258
- state.hmLastMsgId = first.id
1259
- } else {
1260
- const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get()
1261
- state.hmLastMsgId = maxRow.id
1262
- }
1263
- state.hmSeeded = true
1264
- }
1265
-
1266
- const limit = 2000
1267
- const stmt = db.prepare(
1268
- 'SELECT m.id AS id, m.session_id AS sid, m.role AS role, m.content AS content, ' +
1269
- 'm.tool_name AS tool_name, m.tool_calls AS tool_calls, m.timestamp AS t, m.finish_reason AS finish_reason, ' +
1270
- 's.title AS stitle, s.cwd AS scwd, s.git_repo_root AS sgit, s.source AS ssource ' +
1271
- 'FROM messages m LEFT JOIN sessions s ON s.id = m.session_id ' +
1272
- 'WHERE m.id > ? AND m.active = 1 ORDER BY m.id LIMIT ' + limit
1273
- )
1274
- const rows = stmt.all(state.hmLastMsgId)
1275
- if (rows.length === 0) return out
1276
-
1277
- let lastId = state.hmLastMsgId
1278
- for (const row of rows) {
1279
- const rid = Number(row.id)
1280
- if (rid > lastId) lastId = rid
1281
- out.lines += 1
1282
- const sid = row.sid
1283
- let turn = state.hmTurns[sid]
1284
- if (!turn) {
1285
- turn = createHermesTurn({ sessionId: sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd })
1286
- state.hmTurns[sid] = turn
1287
- }
1288
- const result = feedHermesRow(turn, row)
1289
- if (result.finalized) {
1290
- if (emitHermesOutcome(result.finalized, sinks, state)) out.outcomes += 1
1291
- }
1292
- state.hmTurns[sid] = result.next
1293
- }
1294
- state.hmLastMsgId = lastId
1295
- } finally {
1296
- db.close()
1297
- }
1298
- return out
1299
- }
1300
-
1301
-
1302
- // ---------------------------------------------------------------------------
1303
- // Claude Code session observation (passive, transcript JSONL tailing)
1304
- // ---------------------------------------------------------------------------
1305
-
1306
- // Claude Code writes append-only JSONL transcripts under
1307
- // <configDir>/projects/<munged-cwd>/<sessionId>.jsonl. Each line carries
1308
- // {type, message:{role, content}, cwd, sessionId, timestamp, uuid, ...}.
1309
- // Assistant content items are text/thinking/tool_use; tool results come back
1310
- // as user lines whose content array holds tool_result items with is_error.
1311
- // A user prompt line (string content or a text item) closes the previous
1312
- // turn; sidechain lines (subagent transcripts) are skipped as noise.
1313
-
1314
- function claudeConfigDir() {
1315
- return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1316
- }
1317
-
1318
- function claudeProjectsDir() {
1319
- return path.join(claudeConfigDir(), 'projects')
1320
- }
1321
-
1322
- function listClaudeTranscripts() {
1323
- const root = claudeProjectsDir()
1324
- const out = []
1325
- if (!fs.existsSync(root)) return out
1326
- const walk = (dir) => {
1327
- let entries
1328
- try {
1329
- entries = fs.readdirSync(dir, { withFileTypes: true })
1330
- } catch (_) {
1331
- return
1332
- }
1333
- for (const entry of entries) {
1334
- const full = path.join(dir, entry.name)
1335
- if (entry.isDirectory()) walk(full)
1336
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
1337
- }
1338
- }
1339
- walk(root)
1340
- return out
1341
- }
1342
-
1343
- function createClaudeTurn(meta) {
1344
- meta = meta || {}
1345
- return {
1346
- id: null,
1347
- ts: null,
1348
- sessionId: meta.sessionId || null,
1349
- cwd: meta.cwd || null,
1350
- project: meta.cwd ? path.basename(meta.cwd) : 'unknown',
1351
- lastText: null,
1352
- toolCount: 0,
1353
- errors: [],
1354
- apiError: false,
1355
- }
1356
- }
1357
-
1358
- function claudeContentItems(obj) {
1359
- const content = obj && obj.message && obj.message.content
1360
- if (typeof content === 'string') return [{ type: 'text', text: content }]
1361
- return Array.isArray(content) ? content.filter(Boolean) : []
1362
- }
1363
-
1364
- function finishClaudeTurn(turn, outcome) {
1365
- return {
1366
- id: turn.id,
1367
- ts: turn.ts,
1368
- sessionId: turn.sessionId,
1369
- cwd: turn.cwd,
1370
- project: turn.project,
1371
- lastText: turn.lastText,
1372
- toolCount: turn.toolCount,
1373
- errors: turn.errors.slice(),
1374
- apiError: turn.apiError,
1375
- outcome: outcome,
1376
- }
1377
- }
1378
-
1379
- function feedClaudeLine(turn, obj) {
1380
- if (!obj || typeof obj !== 'object') return { next: turn }
1381
- // Subagent (sidechain) transcripts duplicate main-thread work; skip them.
1382
- if (obj.isSidechain === true) return { next: turn }
1383
- if (obj.sessionId) turn.sessionId = obj.sessionId
1384
- if (obj.cwd) {
1385
- turn.cwd = obj.cwd
1386
- turn.project = path.basename(obj.cwd)
1387
- }
1388
-
1389
- if (obj.type === 'user') {
1390
- const items = claudeContentItems(obj)
1391
- let hasToolError = false
1392
- for (const item of items) {
1393
- if (item.type === 'tool_result' && item.is_error) {
1394
- turn.errors.push({ toolUseId: item.tool_use_id || 'unknown' })
1395
- hasToolError = true
1396
- }
1397
- }
1398
- if (turn.errors.length > 20) turn.errors.splice(0, turn.errors.length - 20)
1399
-
1400
- // tool_result-only user lines continue the current turn; a string content
1401
- // or text item means a fresh prompt, which closes the previous turn.
1402
- const isPrompt =
1403
- typeof (obj.message && obj.message.content) === 'string' ||
1404
- items.some((item) => item.type === 'text')
1405
- if (isPrompt) {
1406
- let finalized = null
1407
- if (turn.id) {
1408
- finalized = finishClaudeTurn(turn, turn.apiError ? 'failure' : turn.lastText ? 'success' : 'aborted')
1409
- }
1410
- const next = createClaudeTurn({ sessionId: turn.sessionId, cwd: turn.cwd })
1411
- next.id = obj.uuid || null
1412
- next.ts = obj.timestamp ? Date.parse(obj.timestamp) || Date.now() : Date.now()
1413
- return { finalized, next }
1414
- }
1415
- return { next: turn }
1416
- }
1417
-
1418
- if (obj.type === 'assistant') {
1419
- for (const item of claudeContentItems(obj)) {
1420
- if (item.type === 'text' && typeof item.text === 'string' && item.text.trim()) {
1421
- turn.lastText = item.text
1422
- } else if (item.type === 'tool_use') {
1423
- turn.toolCount += 1
1424
- }
1425
- }
1426
- if (obj.isApiErrorMessage === true || obj.error) turn.apiError = true
1427
- const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN
1428
- if (Number.isFinite(ts)) turn.ts = ts
1429
- return { next: turn }
1430
- }
1431
-
1432
- return { next: turn }
1433
- }
1434
-
1435
- function emitClaudeOutcome(turn, sinks, state) {
1436
- if (!turn.id) return false
1437
- const summary = cleanText(turn.lastText)
1438
- if (!summary && !turn.apiError) return false
1439
- const outcome = turn.apiError && turn.outcome !== 'failure' ? 'failure' : turn.outcome
1440
- const turnToken = String(turn.id).slice(0, 8)
1441
- const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1442
-
1443
- sinks.trace({
1444
- trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
1445
- event_type: 'task_outcome',
1446
- outcome: outcome,
1447
- payload: {
1448
- host: 'claude-code',
1449
- project: turn.project,
1450
- sessionId: turn.sessionId,
1451
- turnId: turn.id,
1452
- toolCalls: turn.toolCount,
1453
- errors: turn.errors.length,
1454
- summary: summary.slice(0, 300),
1455
- source: 'claude-observer',
1456
- },
1457
- agent: 'claude-code',
1458
- host: 'claude-code',
1459
- project: turn.project,
1460
- })
1461
-
1462
- if (outcome === 'success' && summary.length >= 10) {
1463
- sinks.memory({
1464
- kind: 'note',
1465
- content: '[claude-code] 任务完成: ' + summary.slice(0, 160),
1466
- tags: ['task-outcome', 'claude-code', turn.project],
1467
- project: turn.project,
1468
- source: 'claude-observer',
1469
- })
1470
- // Claude Code auto-loads <workspace>/CLAUDE.md, so write the context
1471
- // block there instead of AGENTS.md.
1472
- if (turn.cwd) {
1473
- var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1474
- updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80), 'CLAUDE.md')
1475
- }
1476
- }
1477
-
1478
- for (const err of turn.errors) {
1479
- sinks.trace({
1480
- trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.toolUseId || 'err').slice(0, 12),
1481
- event_type: 'error',
1482
- outcome: 'error',
1483
- payload: {
1484
- host: 'claude-code',
1485
- project: turn.project,
1486
- sessionId: turn.sessionId,
1487
- turnId: turn.id,
1488
- toolUseId: err.toolUseId,
1489
- },
1490
- agent: 'claude-code',
1491
- host: 'claude-code',
1492
- project: turn.project,
1493
- })
1494
- }
1495
- return true
1496
- }
1497
-
1498
- function runClaudeCycle(home, state, sinks, opts) {
1499
- const out = { files: 0, lines: 0, outcomes: 0 }
1500
- const files = listClaudeTranscripts()
1501
- const now = Date.now()
1502
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1503
-
1504
- for (const file of files) {
1505
- out.files += 1
1506
- let st
1507
- try {
1508
- st = fs.statSync(file)
1509
- } catch (_) {
1510
- continue
1511
- }
1512
- const info = state.clFiles[file] || { size: 0 }
1513
- if (!state.clFiles[file] && now - st.mtimeMs > maxAgeMs) continue
1514
- const size = st.size
1515
- if (size < info.size) {
1516
- info.size = 0
1517
- state.clBuffers[file] = ''
1518
- }
1519
-
1520
- let turn = state.clTurns[file] || createClaudeTurn()
1521
-
1522
- if (size > info.size) {
1523
- let chunk = ''
1524
- try {
1525
- const fd = fs.openSync(file, 'r')
1526
- const buf = Buffer.alloc(size - info.size)
1527
- fs.readSync(fd, buf, 0, buf.length, info.size)
1528
- fs.closeSync(fd)
1529
- chunk = buf.toString('utf8')
1530
- } catch (_) {
1531
- continue
1532
- }
1533
- const pending = (state.clBuffers[file] || '') + chunk
1534
- const lines = pending.split(/\r?\n/)
1535
- state.clBuffers[file] = lines.pop()
1536
- for (const line of lines) {
1537
- if (!line.trim()) continue
1538
- out.lines += 1
1539
- let obj
1540
- try {
1541
- obj = JSON.parse(line)
1542
- } catch (_) {
1543
- continue
1544
- }
1545
- const result = feedClaudeLine(turn, obj)
1546
- turn = result.next
1547
- if (result.finalized) {
1548
- if (emitClaudeOutcome(result.finalized, sinks, state)) out.outcomes += 1
1549
- }
1550
- }
1551
- state.clFiles[file] = { size: size, mtimeMs: st.mtimeMs }
1552
- }
1553
-
1554
- // Idle finalize: the session has answered but no new prompt arrived yet.
1555
- if (turn.id && turn.lastText && now - st.mtimeMs > IDLE_MS) {
1556
- if (emitClaudeOutcome(finishClaudeTurn(turn, turn.apiError ? 'failure' : 'success'), sinks, state)) {
1557
- out.outcomes += 1
1558
- }
1559
- state.clTurns[file] = null
1560
- } else if (turn.id) {
1561
- state.clTurns[file] = turn
1562
- } else {
1563
- state.clTurns[file] = null
1564
- }
1565
- }
1566
-
1567
- return out
1568
- }
1569
-
1570
-
1571
- // ---------------------------------------------------------------------------
1572
- // cycle
1573
- // ---------------------------------------------------------------------------
1574
-
1575
- function runCycle(home, state, sinks, opts) {
1576
- const out = { files: 0, lines: 0, outcomes: 0 }
1577
- fs.mkdirSync(home, { recursive: true })
1578
- const files = listTranscripts()
1579
- const now = Date.now()
1580
- const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1581
-
1582
- for (const file of files) {
1583
- out.files += 1
1584
- let st
1585
- try {
1586
- st = fs.statSync(file)
1587
- } catch (_) {
1588
- continue
1589
- }
1590
- const info = state.files[file] || { size: 0 }
1591
- // Do not flood MIO_HOME with ancient history: only backfill files that are
1592
- // already tracked or that changed within the max age window.
1593
- if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
1594
- const size = st.size
1595
- if (size < info.size) {
1596
- // rotated/truncated -> restart from the beginning
1597
- info.size = 0
1598
- state.buffers[file] = ''
1599
- }
1600
-
1601
- const meta = fileMeta(file)
1602
- let turn = state.openTurns[file] || createTurn(meta)
1603
- turn.project = meta.project
1604
- turn.sessionId = meta.sessionId
1605
-
1606
- if (size > info.size) {
1607
- let chunk = ''
1608
- try {
1609
- const fd = fs.openSync(file, 'r')
1610
- const buf = Buffer.alloc(size - info.size)
1611
- fs.readSync(fd, buf, 0, buf.length, info.size)
1612
- fs.closeSync(fd)
1613
- chunk = buf.toString('utf8')
1614
- } catch (_) {
1615
- continue
1616
- }
1617
- const pending = (state.buffers[file] || '') + chunk
1618
- const lines = pending.split(/\r?\n/)
1619
- state.buffers[file] = lines.pop()
1620
- for (const line of lines) {
1621
- if (!line.trim()) continue
1622
- out.lines += 1
1623
- let obj
1624
- try {
1625
- obj = JSON.parse(line)
1626
- } catch (_) {
1627
- continue
1628
- }
1629
- const result = feedWorkbuddyLine(turn, obj)
1630
- if (result.finalized) {
1631
- if (emitWorkbuddyOutcome(result.finalized, sinks, state)) out.outcomes += 1
1632
- }
1633
- turn = result.next
1634
- }
1635
- state.files[file] = { size: size, mtimeMs: st.mtimeMs }
1636
- }
1637
-
1638
- if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
1639
- if (emitWorkbuddyOutcome(turn, sinks, state)) out.outcomes += 1
1640
- state.openTurns[file] = null
1641
- } else if (turn.id) {
1642
- state.openTurns[file] = turn
1643
- } else {
1644
- state.openTurns[file] = null
1645
- }
1646
- }
1647
-
1648
- const codexRes = runCodexCycle(home, state, sinks, opts)
1649
- out.files += codexRes.files
1650
- out.lines += codexRes.lines
1651
- out.outcomes += codexRes.outcomes
1652
-
1653
- const ocRes = runOpencodeCycle(home, state, sinks, opts)
1654
- out.files += ocRes.files
1655
- out.lines += ocRes.lines
1656
- out.outcomes += ocRes.outcomes
1657
-
1658
- const hmRes = runHermesCycle(home, state, sinks, opts)
1659
- out.files += hmRes.files
1660
- out.lines += hmRes.lines
1661
- out.outcomes += hmRes.outcomes
1662
-
1663
- const clRes = runClaudeCycle(home, state, sinks, opts)
1664
- out.files += clRes.files
1665
- out.lines += clRes.lines
1666
- out.outcomes += clRes.outcomes
1667
-
1668
- saveState(home, state)
1669
- return out
1670
- }
1671
-
1672
- function startForeground(home) {
1673
- const state = loadState(home)
1674
- const sinks = createSinks(home)
1675
- log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
1676
- const tick = () => {
1677
- try {
1678
- const res = runCycle(home, state, sinks)
1679
- if (res.lines > 0) {
1680
- log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
1681
- }
1682
- } catch (err) {
1683
- log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
1684
- }
1685
- }
1686
- tick()
1687
- setInterval(tick, INTERVAL_MS)
1688
- }
1689
-
1690
- // ---------------------------------------------------------------------------
1691
- // background daemon control
1692
- // ---------------------------------------------------------------------------
1693
-
1694
- function pidFile(home) {
1695
- return path.join(home, 'observe.pid')
1696
- }
1697
-
1698
- function researchPidFile(home) {
1699
- return path.join(home, 'research.pid')
1700
- }
1701
-
1702
- function researchLogFile(home) {
1703
- return path.join(home, 'research.log')
1704
- }
1705
-
1706
- function pidAlive(pid) {
1707
- if (!Number.isInteger(pid) || pid <= 0) return false
1708
- try {
1709
- process.kill(pid, 0)
1710
- return true
1711
- } catch (_) {
1712
- return false
1713
- }
1714
- }
1715
-
1716
- function readPid(home) {
1717
- return readPidFile(pidFile(home))
1718
- }
1719
-
1720
- function readResearchPid(home) {
1721
- return readPidFile(researchPidFile(home))
1722
- }
1723
-
1724
- function readPidFile(file) {
1725
- try {
1726
- const pid = Number(fs.readFileSync(file, 'utf8').trim())
1727
- return Number.isInteger(pid) && pid > 0 ? pid : null
1728
- } catch (_) {
1729
- return null
1730
- }
1731
- }
1732
-
1733
- function isRunning(home) {
1734
- return pidAlive(readPid(home))
1735
- }
1736
-
1737
- function isResearchRunning(home) {
1738
- return pidAlive(readResearchPid(home))
1739
- }
1740
-
1741
- function startBackground(home) {
1742
- if (isRunning(home)) {
1743
- return { running: true, started: false, pid: readPid(home) }
1744
- }
1745
- const script = path.join(__dirname, '..', 'bin', 'mio.js')
1746
- const child = spawn(process.execPath, [script, 'observe'], {
1747
- detached: true,
1748
- stdio: 'ignore',
1749
- windowsHide: true,
1750
- cwd: home,
1751
- })
1752
- child.unref()
1753
- fs.mkdirSync(home, { recursive: true })
1754
- fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
1755
- return { running: true, started: true, pid: child.pid }
1756
- }
1757
-
1758
- function stopBackground(home) {
1759
- const pid = readPid(home)
1760
- if (pid) {
1761
- try {
1762
- process.kill(pid)
1763
- } catch (_) {}
1764
- try {
1765
- fs.unlinkSync(pidFile(home))
1766
- } catch (_) {}
1767
- }
1768
- return { running: false, stopped: Boolean(pid), pid: pid || null }
1769
- }
1770
-
1771
- // ---------------------------------------------------------------------------
1772
- // research scheduler daemon (`mio observe --start --research`)
1773
- //
1774
- // D6: ObserverService's own scheduler (collector intervals + the daily
1775
- // pipeline tick) needed a process to live in. The transcript daemon could have
1776
- // hosted it in-process, but that daemon is spawned with cwd: home, so its
1777
- // `<cwd>/.local/observer` would resolve to MIO_HOME -- a second observation
1778
- // directory that `mio observer status` in the project would never see. Running
1779
- // `mio observer serve` as its own child with the *launcher's* cwd keeps one
1780
- // data directory per working directory, exactly like every other observer
1781
- // command, and keeps a crash in either half from taking down the other.
1782
- //
1783
- // spawn/kill are injectable so tests never signal a real pid.
1784
- // ---------------------------------------------------------------------------
1785
-
1786
- function startResearchBackground(home, opts = {}) {
1787
- const cwd = opts.cwd || process.cwd()
1788
- const spawnImpl = opts.spawn || spawn
1789
- if (isResearchRunning(home)) {
1790
- return { running: true, started: false, pid: readResearchPid(home), baseDir: path.join(cwd, '.local', 'observer') }
1791
- }
1792
- const script = path.join(__dirname, '..', 'bin', 'mio.js')
1793
- const baseDir = path.join(cwd, '.local', 'observer')
1794
- // The scheduler logs to stdout, so a detached child with stdio:'ignore' would
1795
- // be undiagnosable ("is it running? did it collect?"). Handing it an append
1796
- // fd instead of a pipe is deliberate: a pipe nobody reads fills up after
1797
- // ~64KB and the scheduler would block on its own log.
1798
- fs.mkdirSync(home, { recursive: true })
1799
- const logFd = fs.openSync(researchLogFile(home), 'a')
1800
- let child
1801
- try {
1802
- child = spawnImpl(
1803
- process.execPath,
1804
- [script, 'observer', 'serve', '--base-dir', baseDir],
1805
- {
1806
- detached: true,
1807
- stdio: ['ignore', logFd, logFd],
1808
- windowsHide: true,
1809
- cwd,
1810
- }
1811
- )
1812
- } finally {
1813
- fs.closeSync(logFd)
1814
- }
1815
- if (child.unref) child.unref()
1816
- fs.writeFileSync(researchPidFile(home), String(child.pid), 'utf8')
1817
- return { running: true, started: true, pid: child.pid, baseDir, logFile: researchLogFile(home) }
1818
- }
1819
-
1820
- function stopResearchBackground(home, killImpl) {
1821
- const kill = killImpl || process.kill
1822
- const pid = readResearchPid(home)
1823
- if (pid) {
1824
- try {
1825
- kill(pid)
1826
- } catch (_) {}
1827
- try {
1828
- fs.unlinkSync(researchPidFile(home))
1829
- } catch (_) {}
1830
- }
1831
- return { running: false, stopped: Boolean(pid), pid: pid || null }
1832
- }
1833
-
1834
- module.exports = {
1835
- INTERVAL_MS,
1836
- homeDir,
1837
- workbuddyConfigDir,
1838
- loadState,
1839
- saveState,
1840
- dedupeContexts,
1841
- normalizeCwd,
1842
- contextTimestamp,
1843
- updateProjectContext,
1844
- runCycle,
1845
- opencodeDbPath,
1846
- runOpencodeCycle,
1847
- runHermesCycle,
1848
- hermesDbPath,
1849
- claudeProjectsDir,
1850
- runClaudeCycle,
1851
- createClaudeTurn,
1852
- feedClaudeLine,
1853
- createSinks,
1854
- startForeground,
1855
- startBackground,
1856
- stopBackground,
1857
- isRunning,
1858
- readPid,
1859
- startResearchBackground,
1860
- stopResearchBackground,
1861
- isResearchRunning,
1862
- readResearchPid,
1863
- researchLogFile,
1864
- // exported for tests: the numeric-segment guard here was silently dead for
1865
- // several releases because nothing exercised it (see __tests__/observer-project.test.js)
1866
- projectFromDirName,
1867
- }
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
+ //
43
+ // Hosts that munge a timestamped workspace path produce a trailing numeric
44
+ // segment, e.g. "c-Users-me-WorkBuddy-2026-08-27-17-37-10" -> "10". Using
45
+ // the seconds field as a project name funnels unrelated sessions into
46
+ // colliding 2-digit buckets that recall never resolves to, so fall back to
47
+ // the whole directory name in that case.
48
+ //
49
+ // ⚠️ The test must be /^\d+$/ (one backslash). An earlier revision wrote
50
+ // /^\\d+$/ -- that matches a literal backslash followed by "d", so it never
51
+ // fires and every numeric segment becomes the project (fixed 2026-09-20).
52
+ // Keep the escaping as-is; do not "tidy" it into a double backslash.
53
+ const parts = String(dirName || '').split('-').filter(Boolean)
54
+ if (parts.length > 1 && /^\d+$/.test(parts[parts.length - 1])) {
55
+ return dirName || 'unknown'
56
+ }
57
+ return parts.length > 1 ? parts[parts.length - 1] : dirName || 'unknown'
58
+ }
59
+
60
+ function log(home, message) {
61
+ const line = '[' + new Date().toISOString() + '] ' + message + '\n'
62
+ try {
63
+ fs.mkdirSync(path.join(home, 'logs'), { recursive: true })
64
+ fs.appendFileSync(path.join(home, 'logs', 'observe.log'), line, 'utf8')
65
+ } catch (_) {}
66
+ if (process.stdout.isTTY || process.env.MIO_OBSERVE_VERBOSE) {
67
+ process.stdout.write(line)
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // state
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function stateFile(home) {
76
+ return path.join(home, 'observe-state.json')
77
+ }
78
+
79
+ // Canonical form for a workspace path used as a context key.
80
+ //
81
+ // Context keys are taken verbatim from the `cwd` field of host transcripts, and
82
+ // hosts disagree on spelling: the same directory arrives as `D:\work\code\x`,
83
+ // `d:\work\code\x`, or `D:/work/code/x`. Left alone, one project splits into
84
+ // several buckets, each capped at CONTEXT_MAX, so its history is fragmented and
85
+ // entries are dropped early. Windows paths are case-insensitive, so normalise
86
+ // the drive letter to upper case and use backslashes consistently to match the
87
+ // style the file-observation keys already use.
88
+ function normalizeCwd(cwd) {
89
+ if (!cwd || typeof cwd !== 'string') return cwd
90
+ let out = cwd.replace(/\//g, '\\').replace(/\\+/g, '\\')
91
+ // Upper-case a single drive letter prefix ("c:\..." -> "C:\...").
92
+ if (/^[a-zA-Z]:\\/.test(out)) out = out[0].toUpperCase() + out.slice(1)
93
+ // Drop a trailing separator unless the path is just the drive root.
94
+ if (out.length > 3 && out.endsWith('\\')) out = out.slice(0, -1)
95
+ return out
96
+ }
97
+
98
+ // Parse the "YYYY/M/D HH:MM:SS | ..." stamp that context lines carry.
99
+ // Returns null when the line has no recognisable leading timestamp, so
100
+ // unparseable entries never get treated as the oldest by accident.
101
+ function contextTimestamp(line) {
102
+ const m = /^\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})/.exec(String(line || ''))
103
+ if (!m) return null
104
+ const ms = new Date(
105
+ Number(m[1]),
106
+ Number(m[2]) - 1,
107
+ Number(m[3]),
108
+ Number(m[4]),
109
+ Number(m[5]),
110
+ Number(m[6]),
111
+ ).getTime()
112
+ return Number.isFinite(ms) ? ms : null
113
+ }
114
+
115
+ // Drop repeated lines from stored context lists, preserving the most recent
116
+ // occurrence order. Older builds could write the same line into every slot of
117
+ // a workspace block (a generated digest headline broadcast to all projects),
118
+ // so existing observe-state files still carry duplicates until they are
119
+ // rewritten. Normalising on load heals that data without a migration step.
120
+ //
121
+ // Keys are also folded through normalizeCwd and merged, so buckets split by
122
+ // spelling differences collapse back into one on the next load.
123
+ function dedupeContexts(contexts) {
124
+ const out = {}
125
+ for (const [cwd, list] of Object.entries(contexts)) {
126
+ const key = normalizeCwd(cwd)
127
+ const existing = out[key] || []
128
+ if (!Array.isArray(list)) {
129
+ out[key] = existing
130
+ continue
131
+ }
132
+ const seen = new Set(existing)
133
+ const merged = existing.slice()
134
+ for (const line of list) {
135
+ if (seen.has(line)) continue
136
+ seen.add(line)
137
+ merged.push(line)
138
+ }
139
+ out[key] = merged
140
+ }
141
+ return out
142
+ }
143
+
144
+ function loadState(home) {
145
+ try {
146
+ const parsed = JSON.parse(fs.readFileSync(stateFile(home), 'utf8'))
147
+ if (parsed && typeof parsed === 'object') {
148
+ return {
149
+ version: 1,
150
+ files: parsed.files || {},
151
+ buffers: parsed.buffers || {},
152
+ openTurns: parsed.openTurns || {},
153
+ contexts: dedupeContexts(parsed.contexts || {}),
154
+ codexFiles: parsed.codexFiles || {},
155
+ codexBuffers: parsed.codexBuffers || {},
156
+ codexTurns: parsed.codexTurns || {},
157
+ ocSeeded: parsed.ocSeeded || false,
158
+ ocLastRowid: parsed.ocLastRowid || 0,
159
+ ocDbMtime: parsed.ocDbMtime || null,
160
+ ocPending: parsed.ocPending || false,
161
+ ocTurns: parsed.ocTurns || {},
162
+ hmSeeded: parsed.hmSeeded || false,
163
+ hmLastMsgId: parsed.hmLastMsgId || 0,
164
+ hmTurns: parsed.hmTurns || {},
165
+ clFiles: parsed.clFiles || {},
166
+ clBuffers: parsed.clBuffers || {},
167
+ clTurns: parsed.clTurns || {},
168
+ }
169
+ }
170
+ } catch (_) {}
171
+ return {
172
+ version: 1,
173
+ files: {},
174
+ buffers: {},
175
+ openTurns: {},
176
+ contexts: {},
177
+ codexFiles: {},
178
+ codexBuffers: {},
179
+ codexTurns: {},
180
+ ocSeeded: false,
181
+ ocLastRowid: 0,
182
+ ocDbMtime: null,
183
+ ocPending: false,
184
+ ocTurns: {},
185
+ hmSeeded: false,
186
+ hmLastMsgId: 0,
187
+ hmTurns: {},
188
+ clFiles: {},
189
+ clBuffers: {},
190
+ clTurns: {},
191
+ }
192
+ }
193
+
194
+ function saveState(home, state) {
195
+ try {
196
+ fs.mkdirSync(home, { recursive: true })
197
+ fs.writeFileSync(stateFile(home), JSON.stringify(state) + '\n', 'utf8')
198
+ } catch (err) {
199
+ console.error('Failed to save observer state:', err.message || err)
200
+ throw err
201
+ }
202
+ }
203
+
204
+ function listTranscripts() {
205
+ const root = path.join(workbuddyConfigDir(), 'projects')
206
+ const out = []
207
+ if (!fs.existsSync(root)) return out
208
+ const walk = (dir) => {
209
+ let entries
210
+ try {
211
+ entries = fs.readdirSync(dir, { withFileTypes: true })
212
+ } catch (_) {
213
+ return
214
+ }
215
+ for (const entry of entries) {
216
+ const full = path.join(dir, entry.name)
217
+ if (entry.isDirectory()) walk(full)
218
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
219
+ }
220
+ }
221
+ walk(root)
222
+ return out
223
+ }
224
+
225
+ function codexConfigDir() {
226
+ return process.env.CODEX_CONFIG_DIR || path.join(os.homedir(), '.codex')
227
+ }
228
+
229
+ function listCodexTranscripts() {
230
+ const root = path.join(codexConfigDir(), 'sessions')
231
+ const out = []
232
+ if (!fs.existsSync(root)) return out
233
+ const walk = (dir) => {
234
+ let entries
235
+ try {
236
+ entries = fs.readdirSync(dir, { withFileTypes: true })
237
+ } catch (_) {
238
+ return
239
+ }
240
+ for (const entry of entries) {
241
+ const full = path.join(dir, entry.name)
242
+ if (entry.isDirectory()) walk(full)
243
+ else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) out.push(full)
244
+ }
245
+ }
246
+ walk(root)
247
+ return out
248
+ }
249
+
250
+ function fileMeta(file) {
251
+ const dirName = path.basename(path.dirname(file))
252
+ return {
253
+ sessionId: path.basename(file, '.jsonl'),
254
+ project: projectFromDirName(dirName),
255
+ }
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // sinks (write into MIO_HOME with the same schema as the MCP server)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function appendJsonl(file, value) {
263
+ fs.mkdirSync(path.dirname(file), { recursive: true })
264
+ fs.appendFileSync(file, JSON.stringify(value) + '\n', 'utf8')
265
+ }
266
+
267
+ function createSinks(home) {
268
+ const tracePath = path.join(home, 'traces.jsonl')
269
+ const memoryPath = path.join(home, 'memory.jsonl')
270
+ const seenTraceIds = new Set()
271
+ const seenMemoryContents = new Set()
272
+
273
+ function trace(event) {
274
+ const record = {
275
+ id: createId('trace'),
276
+ timestamp: new Date().toISOString(),
277
+ trace_id: event.trace_id,
278
+ event_type: event.event_type,
279
+ outcome: event.outcome || null,
280
+ payload: event.payload || {},
281
+ agent: event.agent || 'workbuddy-observer',
282
+ host: event.host || 'workbuddy-observer',
283
+ project: event.project || 'unknown',
284
+ }
285
+ if (seenTraceIds.has(record.trace_id)) return
286
+ seenTraceIds.add(record.trace_id)
287
+ if (seenTraceIds.size > 20000) seenTraceIds.clear()
288
+ appendJsonl(tracePath, record)
289
+ }
290
+
291
+ function memory(entry) {
292
+ const key = entry.project + '|' + entry.content
293
+ if (seenMemoryContents.has(key)) return
294
+ seenMemoryContents.add(key)
295
+ const record = {
296
+ id: createId('mem'),
297
+ timestamp: new Date().toISOString(),
298
+ kind: entry.kind || 'note',
299
+ content: entry.content,
300
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
301
+ project: entry.project || 'unknown',
302
+ source: entry.source || 'workbuddy-observer',
303
+ }
304
+ appendJsonl(memoryPath, record)
305
+ }
306
+
307
+ return { trace, memory }
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // WorkBuddy transcript turn machine
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function createTurn(meta) {
315
+ return {
316
+ id: null,
317
+ ts: null,
318
+ project: meta.project,
319
+ sessionId: meta.sessionId,
320
+ cwd: meta.cwd || null,
321
+ toolCount: 0,
322
+ errors: [],
323
+ lastAssistant: null,
324
+ }
325
+ }
326
+
327
+ function feedWorkbuddyLine(turn, obj) {
328
+ if (!obj || typeof obj !== 'object') return { finalized: null, next: turn }
329
+ let finalized = null
330
+ if (obj.cwd) turn.cwd = obj.cwd
331
+ if (obj.type === 'message') {
332
+ if (obj.role === 'user') {
333
+ finalized = turn
334
+ const next = createTurn(turn)
335
+ next.id = obj.id
336
+ next.ts = obj.timestamp
337
+ next.cwd = obj.cwd || turn.cwd
338
+ return { finalized, next: next }
339
+ }
340
+ if (obj.role === 'assistant') {
341
+ const text = (Array.isArray(obj.content) ? obj.content : [])
342
+ .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
343
+ .filter(Boolean)
344
+ .join('\n')
345
+ turn.lastAssistant = {
346
+ status: obj.status,
347
+ text: text,
348
+ error: (obj.providerData && obj.providerData.error) || null,
349
+ }
350
+ }
351
+ } else if (obj.type === 'function_call') {
352
+ turn.toolCount += 1
353
+ } else if (obj.type === 'function_call_result') {
354
+ const status = String(obj.status || (obj.is_error ? 'error' : 'completed'))
355
+ if (status !== 'completed') {
356
+ turn.errors.push({ callId: obj.callId, name: obj.name, status: status })
357
+ if (turn.errors.length > 20) turn.errors.shift()
358
+ }
359
+ }
360
+ return { finalized: finalized, next: turn }
361
+ }
362
+
363
+ const CONTEXT_BEGIN = '<!-- MIO_CONTEXT_BEGIN -->'
364
+ const CONTEXT_END = '<!-- MIO_CONTEXT_END -->'
365
+ const CONTEXT_MAX = 6
366
+
367
+ // Maintain a compact 'recent Mio context' block inside <workspace>/AGENTS.md,
368
+ // which WorkBuddy's MemoryLoader auto-injects into every session. This makes
369
+ // retrieval guaranteed (not dependent on the model deciding to read memory).
370
+ function updateProjectContext(state, cwd, line, targetFile) {
371
+ if (!cwd || !line) return
372
+ const key = normalizeCwd(cwd)
373
+ const target = path.join(cwd, targetFile || 'AGENTS.md')
374
+ // Merge any buckets that differ only by spelling, so a project observed as
375
+ // both "D:\work\code\x" and "d:\work\code\x" converges into one list instead
376
+ // of two half-filled ones.
377
+ const list = []
378
+ const seen = new Set()
379
+ for (const [existingKey, items] of Object.entries(state.contexts || {})) {
380
+ if (normalizeCwd(existingKey) !== key) continue
381
+ for (const item of items || []) {
382
+ if (seen.has(item)) continue
383
+ seen.add(item)
384
+ list.push(item)
385
+ }
386
+ if (existingKey !== key) delete state.contexts[existingKey]
387
+ }
388
+ // Re-inserting an existing line moves it to the front instead of duplicating.
389
+ const existing = list.indexOf(line)
390
+ if (existing !== -1) list.splice(existing, 1)
391
+ list.unshift(line)
392
+ // Once buckets merge, the list can exceed CONTEXT_MAX. Entries are stamped
393
+ // "YYYY/M/D HH:MM:SS | ...", so trim by that timestamp rather than by
394
+ // position -- merging appends whole buckets in object-key order, which says
395
+ // nothing about age, and popping blind could discard recent work.
396
+ if (list.length > CONTEXT_MAX) {
397
+ const stamped = list.filter((l) => contextTimestamp(l) !== null)
398
+ if (stamped.length > 1) {
399
+ list.sort((a, b) => contextTimestamp(b) - contextTimestamp(a))
400
+ }
401
+ list.length = CONTEXT_MAX
402
+ }
403
+ state.contexts = state.contexts || {}
404
+ state.contexts[key] = list
405
+ try {
406
+ let content = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
407
+ const block = CONTEXT_BEGIN + '\n## Mio 最近上下文(自动注入)\n' + list.map(function (l) { return '- ' + l }).join('\n') + '\n' + CONTEXT_END
408
+ const start = content.indexOf(CONTEXT_BEGIN)
409
+ if (start !== -1) {
410
+ const end = content.indexOf(CONTEXT_END, start)
411
+ content = end === -1 ? content.slice(0, start) : content.slice(0, start) + content.slice(end + CONTEXT_END.length)
412
+ }
413
+ content = content.trimEnd() + '\n\n' + block + '\n'
414
+ fs.mkdirSync(path.dirname(target), { recursive: true })
415
+ fs.writeFileSync(target, content, 'utf8')
416
+ } catch (err) {
417
+ console.error(`Failed to update ${path.basename(target)} for ${key}:`, err.message || err)
418
+ throw err
419
+ }
420
+ }
421
+
422
+ function localDateKey(ts) {
423
+ const d = new Date(Number(ts) || Date.now())
424
+ const y = d.getFullYear()
425
+ const m = String(d.getMonth() + 1).padStart(2, '0')
426
+ const day = String(d.getDate()).padStart(2, '0')
427
+ return y + '-' + m + '-' + day
428
+ }
429
+
430
+ // Write a compact Mio digest back into WorkBuddy's own memory file
431
+ // (<workspace>/.workbuddy/memory/YYYY-MM-DD.md). WorkBuddy instructs its model
432
+ // to maintain and read these files every session, so the digest is
433
+ // 'retrieved' next session without any Mio tool call by the model.
434
+ function writeWorkbuddyDigest(turn, outcome, summary) {
435
+ if (!turn.cwd || !summary) return
436
+ const dir = path.join(turn.cwd, '.workbuddy', 'memory')
437
+ const file = path.join(dir, localDateKey(turn.ts) + '.md')
438
+ try {
439
+ fs.mkdirSync(dir, { recursive: true })
440
+ const time = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
441
+ const entry =
442
+ '\n## Mio 自动摘要 (' + time + ')\n' +
443
+ '- outcome: ' + outcome + ' | tools: ' + turn.toolCount + ' | ' + summary.slice(0, 160) + '\n'
444
+ fs.appendFileSync(file, entry, 'utf8')
445
+ } catch (err) {
446
+ console.error('Failed to write WorkBuddy digest:', err.message || err)
447
+ throw err
448
+ }
449
+ }
450
+
451
+ function emitWorkbuddyOutcome(turn, sinks, state) {
452
+ const la = turn.lastAssistant
453
+ if (!la) return false
454
+ let outcome
455
+ if (la.status === 'completed') outcome = 'success'
456
+ else if (la.error) outcome = 'failure'
457
+ else outcome = 'aborted'
458
+
459
+ const summary = cleanText(la.text)
460
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
461
+ const tsToken = turn.ts ? Math.floor(Number(turn.ts) / 1000) : Math.floor(Date.now() / 1000)
462
+
463
+ sinks.trace({
464
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + tsToken,
465
+ event_type: 'task_outcome',
466
+ outcome: outcome,
467
+ payload: {
468
+ host: 'workbuddy',
469
+ project: turn.project,
470
+ sessionId: turn.sessionId,
471
+ turnId: turn.id,
472
+ toolCalls: turn.toolCount,
473
+ errors: turn.errors.length,
474
+ summary: summary.slice(0, 300),
475
+ },
476
+ agent: 'workbuddy',
477
+ host: 'workbuddy',
478
+ project: turn.project,
479
+ })
480
+
481
+ // Low threshold: even a short but real completion summary must reach the
482
+ // passive retrieval paths (memory + daily digest + AGENTS.md context block).
483
+ // 40 was too strict and silently dropped short summaries from retrieval.
484
+ if (outcome === 'success' && summary.length >= 10) {
485
+ sinks.memory({
486
+ kind: 'note',
487
+ content: '[workbuddy] 任务完成: ' + summary.slice(0, 160),
488
+ tags: ['task-outcome', 'workbuddy', turn.project],
489
+ project: turn.project,
490
+ source: 'workbuddy-observer',
491
+ })
492
+ writeWorkbuddyDigest(turn, outcome, summary)
493
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
494
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
495
+ }
496
+
497
+ for (const err of turn.errors) {
498
+ sinks.trace({
499
+ trace_id: 'workbuddy:' + turn.sessionId + ':' + turnToken + ':' + String(err.callId || 'err').slice(0, 12),
500
+ event_type: 'error',
501
+ outcome: 'error',
502
+ payload: {
503
+ host: 'workbuddy',
504
+ project: turn.project,
505
+ sessionId: turn.sessionId,
506
+ turnId: turn.id,
507
+ tool: err.name,
508
+ status: err.status,
509
+ },
510
+ agent: 'workbuddy',
511
+ host: 'workbuddy',
512
+ project: turn.project,
513
+ })
514
+ }
515
+ return true
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Codex session turn machine (passive observation)
520
+ // ---------------------------------------------------------------------------
521
+
522
+ // Codex rollout transcripts are append-only JSONL under
523
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The first session_meta line
524
+ // carries the workspace cwd; task_started / task_complete / turn_aborted mark
525
+ // turn boundaries, agent_message carries the running answer text.
526
+ function createCodexTurn() {
527
+ return {
528
+ id: null,
529
+ ts: null,
530
+ sessionId: null,
531
+ cwd: null,
532
+ lastAgent: null,
533
+ aborted: false,
534
+ }
535
+ }
536
+
537
+ function feedCodexLine(turn, obj) {
538
+ if (!obj || typeof obj !== 'object') return {}
539
+ if (obj.type === 'session_meta') {
540
+ const p = obj.payload || {}
541
+ if (!turn.cwd && p.cwd) turn.cwd = p.cwd
542
+ if (!turn.sessionId && (p.session_id || p.id)) turn.sessionId = p.session_id || p.id
543
+ return {}
544
+ }
545
+ if (obj.type !== 'event_msg') return {}
546
+ const p = obj.payload || {}
547
+ if (p.type === 'task_started') {
548
+ // A turn that started earlier but never completed is discarded.
549
+ turn.id = p.turn_id || null
550
+ turn.ts = p.started_at ? Number(p.started_at) * 1000 : null
551
+ turn.lastAgent = null
552
+ turn.aborted = false
553
+ return {}
554
+ }
555
+ if (p.type === 'agent_message' && p.message) {
556
+ turn.lastAgent = p.message
557
+ return {}
558
+ }
559
+ if (p.type === 'task_complete') {
560
+ const finalized = {
561
+ id: turn.id,
562
+ ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
563
+ sessionId: turn.sessionId,
564
+ cwd: turn.cwd,
565
+ project: path.basename(turn.cwd || '') || 'unknown',
566
+ lastAgent: p.last_agent_message || turn.lastAgent,
567
+ aborted: false,
568
+ }
569
+ turn.id = null
570
+ turn.lastAgent = null
571
+ return { finalized: finalized }
572
+ }
573
+ if (p.type === 'turn_aborted') {
574
+ const finalized = {
575
+ id: turn.id,
576
+ ts: p.completed_at ? Number(p.completed_at) * 1000 : turn.ts,
577
+ sessionId: turn.sessionId,
578
+ cwd: turn.cwd,
579
+ project: path.basename(turn.cwd || '') || 'unknown',
580
+ lastAgent: turn.lastAgent,
581
+ aborted: true,
582
+ }
583
+ turn.id = null
584
+ turn.lastAgent = null
585
+ return { finalized: finalized }
586
+ }
587
+ return {}
588
+ }
589
+
590
+ function emitCodexOutcome(turn, sinks, state) {
591
+ if (!turn.lastAgent) return false
592
+ const summary = cleanText(turn.lastAgent)
593
+ if (!summary) return false
594
+ const outcome = turn.aborted ? 'aborted' : 'success'
595
+ const turnToken = String(turn.id || 'unknown').slice(0, 8)
596
+
597
+ sinks.trace({
598
+ trace_id: 'codex:' + (turn.sessionId || 'unknown') + ':' + turnToken,
599
+ event_type: 'task_outcome',
600
+ outcome: outcome,
601
+ payload: {
602
+ host: 'codex',
603
+ project: turn.project,
604
+ sessionId: turn.sessionId,
605
+ turnId: turn.id,
606
+ toolCalls: 0,
607
+ errors: 0,
608
+ summary: summary.slice(0, 300),
609
+ source: 'codex-observer',
610
+ },
611
+ agent: 'codex',
612
+ host: 'codex',
613
+ project: turn.project,
614
+ })
615
+
616
+ if (outcome === 'success' && summary.length >= 10) {
617
+ sinks.memory({
618
+ kind: 'note',
619
+ content: '[codex] 任务完成: ' + summary.slice(0, 160),
620
+ tags: ['task-outcome', 'codex', turn.project],
621
+ project: turn.project,
622
+ source: 'codex-observer',
623
+ })
624
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
625
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
626
+ }
627
+ return true
628
+ }
629
+
630
+ function runCodexCycle(home, state, sinks, opts) {
631
+ const out = { files: 0, lines: 0, outcomes: 0 }
632
+ const files = listCodexTranscripts()
633
+ const now = Date.now()
634
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
635
+
636
+ for (const file of files) {
637
+ out.files += 1
638
+ let st
639
+ try {
640
+ st = fs.statSync(file)
641
+ } catch (_) {
642
+ continue
643
+ }
644
+ const info = state.codexFiles[file] || { size: 0 }
645
+ if (!state.codexFiles[file] && now - st.mtimeMs > maxAgeMs) continue
646
+ const size = st.size
647
+ if (size < info.size) {
648
+ info.size = 0
649
+ state.codexBuffers[file] = ''
650
+ }
651
+
652
+ if (!state.codexTurns[file]) state.codexTurns[file] = createCodexTurn()
653
+ const turn = state.codexTurns[file]
654
+
655
+ if (size > info.size) {
656
+ let chunk = ''
657
+ try {
658
+ const fd = fs.openSync(file, 'r')
659
+ const buf = Buffer.alloc(size - info.size)
660
+ fs.readSync(fd, buf, 0, buf.length, info.size)
661
+ fs.closeSync(fd)
662
+ chunk = buf.toString('utf8')
663
+ } catch (_) {
664
+ continue
665
+ }
666
+ const pending = (state.codexBuffers[file] || '') + chunk
667
+ const lines = pending.split(/\r?\n/)
668
+ state.codexBuffers[file] = lines.pop()
669
+ for (const line of lines) {
670
+ if (!line.trim()) continue
671
+ out.lines += 1
672
+ let obj
673
+ try {
674
+ obj = JSON.parse(line)
675
+ } catch (_) {
676
+ continue
677
+ }
678
+ const result = feedCodexLine(turn, obj)
679
+ if (result.finalized) {
680
+ if (emitCodexOutcome(result.finalized, sinks, state)) out.outcomes += 1
681
+ }
682
+ }
683
+ state.codexFiles[file] = { size: size, mtimeMs: st.mtimeMs }
684
+ }
685
+ }
686
+
687
+ return out
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // OpenCode session observation (passive, via `opencode db`)
692
+ // ---------------------------------------------------------------------------
693
+
694
+ // OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
695
+ // On Windows the db file is held with an exclusive lock while OpenCode runs,
696
+ // so we cannot open it with node:sqlite directly. Instead we shell out to the
697
+ // `opencode db` CLI, which opens the same database through the app itself and
698
+ // works even while OpenCode is running. We tail `part` rows by rowid so each
699
+ // session is scanned incrementally and only text/tool parts are transferred.
700
+
701
+ function opencodeDataDir() {
702
+ return (
703
+ process.env.OPENCODE_DATA_DIR ||
704
+ path.join(os.homedir(), '.local', 'share', 'opencode')
705
+ )
706
+ }
707
+
708
+ function opencodeDbPath() {
709
+ return path.join(opencodeDataDir(), 'opencode.db')
710
+ }
711
+
712
+ // Resolved once per observer process: the bin does not move mid-run, and
713
+ // re-running `where`/`which` on every query doubled the subprocess count of
714
+ // every cycle for zero information. Re-resolves only when the cached path
715
+ // vanishes from disk. A null result is deliberately NOT cached so an opencode
716
+ // installed mid-run is still picked up.
717
+ let resolvedOpencodeBin
718
+
719
+ function findOpencodeBin() {
720
+ if (resolvedOpencodeBin && fs.existsSync(resolvedOpencodeBin)) {
721
+ return resolvedOpencodeBin
722
+ }
723
+ if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
724
+ resolvedOpencodeBin = process.env.OPENCODE_BIN
725
+ return resolvedOpencodeBin
726
+ }
727
+ const exeDir = path.dirname(process.execPath)
728
+ const candidates = [
729
+ path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
730
+ path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
731
+ path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
732
+ ]
733
+ for (const c of candidates) {
734
+ const resolved = path.resolve(c)
735
+ if (fs.existsSync(resolved)) return resolved
736
+ }
737
+ // Resolve from PATH so the SQL is never passed through cmd.exe, where
738
+ // '>' in the query is interpreted as output redirection and creates
739
+ // stray files (e.g. "WHERE p.rowid > 0" -> a file named "0").
740
+ try {
741
+ const finder = process.platform === 'win32' ? 'where' : 'which'
742
+ // stdio[0] must not be a pipe: a piped stdin makes spawnSync fail with
743
+ // EBUSY before the child starts (status null, empty stderr), which would
744
+ // read as "opencode is not on PATH". `where` reads no stdin.
745
+ const res = spawnSync(finder, ['opencode'], {
746
+ encoding: 'utf8',
747
+ windowsHide: true,
748
+ timeout: 15000,
749
+ stdio: ['ignore', 'pipe', 'pipe'],
750
+ })
751
+ if (res.status === 0 && res.stdout) {
752
+ const lines = res.stdout
753
+ .split(/\r?\n/)
754
+ .map((s) => s.trim())
755
+ .filter(Boolean)
756
+ // ⚠️ Known dead end on Windows, recorded by decision rather than "fixed"
757
+ // (measured 2026-09-24). This filter prefers .cmd/.bat, but
758
+ // runOpencodeQuery() spawns the result WITHOUT shell:true, and Node
759
+ // refuses to spawn a .cmd/.bat without a shell:
760
+ //
761
+ // spawn opencode.cmd no shell status=null error=EINVAL
762
+ // spawn opencode.cmd shell:true status=0 stdout="1.15.12"
763
+ //
764
+ // So when the only thing on PATH is an npm shim (e.g. `opencode.cmd`),
765
+ // findOpencodeBin() returns a path that can never be executed, every
766
+ // query below returns null, and the opencode observer silently ingests
767
+ // nothing. On a machine where `where opencode` yields only `opencode`
768
+ // and `opencode.cmd`, neither is spawnable without a shell.
769
+ //
770
+ // Adding shell:true is NOT the fix: cmd.exe would then parse '>' in the
771
+ // SQL as output redirection, which is precisely what the comment above
772
+ // this block exists to prevent. The two constraints really do conflict.
773
+ //
774
+ // Net behaviour is identical either way (null before and after), so it is
775
+ // left alone. The bundled opencode-ai .exe path above is unaffected and
776
+ // does work. If you change this, write the test that proves the payload
777
+ // survives -- not just that status === 0.
778
+ const preferred =
779
+ process.platform === 'win32'
780
+ ? lines.filter((p) => /\.(exe|cmd|bat)$/i.test(p))
781
+ : lines
782
+ const first = (preferred.length > 0 ? preferred : lines).find((p) => fs.existsSync(p))
783
+ if (first) {
784
+ resolvedOpencodeBin = first
785
+ return first
786
+ }
787
+ }
788
+ } catch (_) {}
789
+ return null
790
+ }
791
+
792
+ function runOpencodeQuery(sql) {
793
+ const bin = findOpencodeBin()
794
+ // Never shell the SQL through cmd.exe: '>' in the query would be parsed
795
+ // as output redirection, creating files named after the redirect target.
796
+ if (!bin) return null
797
+ const args = ['db', sql, '--format', 'json']
798
+ let res
799
+ try {
800
+ // stdio[0] must not be a pipe: see findOpencodeBin above. `opencode db`
801
+ // takes its SQL as an argument and reads no stdin.
802
+ res = spawnSync(bin, args, {
803
+ encoding: 'utf8',
804
+ timeout: 90000,
805
+ windowsHide: true,
806
+ maxBuffer: 256 * 1024 * 1024,
807
+ stdio: ['ignore', 'pipe', 'pipe'],
808
+ })
809
+ } catch (_) {
810
+ return null
811
+ }
812
+ if (res.error || res.status !== 0 || !res.stdout) return null
813
+ try {
814
+ return JSON.parse(res.stdout)
815
+ } catch (_) {
816
+ return null
817
+ }
818
+ }
819
+
820
+ // Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
821
+ // keep the transferred payload small.
822
+ const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
823
+
824
+ function createOpencodeTurn(meta) {
825
+ meta = meta || {}
826
+ return {
827
+ id: null,
828
+ ts: null,
829
+ sessionId: meta.sessionId || null,
830
+ cwd: meta.directory || null,
831
+ project: meta.directory ? path.basename(meta.directory) : 'unknown',
832
+ agent: meta.agent || null,
833
+ title: meta.title || null,
834
+ lastMid: null,
835
+ lastText: null,
836
+ toolCount: 0,
837
+ errors: [],
838
+ pendingTerminal: null,
839
+ done: false,
840
+ }
841
+ }
842
+
843
+ function opencodeOutcomeFor(finish) {
844
+ if (finish === 'error') return 'failure'
845
+ if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
846
+ return 'success'
847
+ }
848
+
849
+ function finalizeOpencodeTurn(turn, outcome) {
850
+ if (!turn.id) return null
851
+ const finalized = {
852
+ id: turn.id,
853
+ ts: turn.ts,
854
+ sessionId: turn.sessionId,
855
+ cwd: turn.cwd,
856
+ project: turn.project,
857
+ agent: turn.agent,
858
+ title: turn.title,
859
+ lastText: turn.lastText,
860
+ toolCount: turn.toolCount,
861
+ errors: turn.errors.slice(),
862
+ outcome: outcome,
863
+ }
864
+ turn.done = true
865
+ return finalized
866
+ }
867
+
868
+ function applyOpencodeAssistantPart(turn, pd, md, row) {
869
+ if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
870
+ turn.lastText = pd.text
871
+ } else if (pd.type === 'tool') {
872
+ turn.toolCount += 1
873
+ const status = pd.state && pd.state.status
874
+ if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
875
+ turn.errors.push({ tool: pd.tool || 'unknown', status: status })
876
+ if (turn.errors.length > 20) turn.errors.shift()
877
+ }
878
+ }
879
+ if (md.finish && md.finish !== 'tool-calls') {
880
+ turn.pendingTerminal = md.finish
881
+ }
882
+ turn.lastMid = row.mid
883
+ }
884
+
885
+ function feedOpencodePart(turn, row) {
886
+ let pd
887
+ let md
888
+ try {
889
+ pd = JSON.parse(row.pd)
890
+ } catch (_) {
891
+ pd = null
892
+ }
893
+ try {
894
+ md = JSON.parse(row.md)
895
+ } catch (_) {
896
+ md = null
897
+ }
898
+ if (!pd || !md) return { next: turn }
899
+
900
+ // A user message starts a new turn; any unfinished previous turn is aborted.
901
+ if (md.role === 'user') {
902
+ let finalized = null
903
+ if (turn.id && !turn.done) {
904
+ finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
905
+ }
906
+ const next = createOpencodeTurn({
907
+ sessionId: row.sid,
908
+ directory: row.sdir,
909
+ title: row.stitle,
910
+ agent: row.sagent,
911
+ })
912
+ next.id = row.mid
913
+ next.ts = Number(row.t) || Date.now()
914
+ next.lastMid = row.mid
915
+ return { finalized, next }
916
+ }
917
+
918
+ if (turn.done) return { next: turn }
919
+
920
+ // Message boundary after a terminal finish closes the previous turn.
921
+ if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
922
+ const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
923
+ const next = createOpencodeTurn({
924
+ sessionId: row.sid,
925
+ directory: row.sdir,
926
+ title: row.stitle,
927
+ agent: row.sagent,
928
+ })
929
+ applyOpencodeAssistantPart(next, pd, md, row)
930
+ return { finalized, next }
931
+ }
932
+
933
+ applyOpencodeAssistantPart(turn, pd, md, row)
934
+ return { next: turn }
935
+ }
936
+
937
+ function emitOpencodeOutcome(turn, sinks, state) {
938
+ if (!turn.id) return false
939
+ const summary = cleanText(turn.lastText)
940
+ if (!summary) return false
941
+ const outcome = turn.outcome
942
+ const turnToken = String(turn.id).slice(0, 8)
943
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
944
+
945
+ sinks.trace({
946
+ trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
947
+ event_type: 'task_outcome',
948
+ outcome: outcome,
949
+ payload: {
950
+ host: 'opencode',
951
+ project: turn.project,
952
+ sessionId: turn.sessionId,
953
+ turnId: turn.id,
954
+ title: turn.title,
955
+ agent: turn.agent,
956
+ toolCalls: turn.toolCount,
957
+ errors: turn.errors.length,
958
+ summary: summary.slice(0, 300),
959
+ source: 'opencode-observer',
960
+ },
961
+ agent: 'opencode',
962
+ host: 'opencode',
963
+ project: turn.project,
964
+ })
965
+
966
+ if (outcome === 'success' && summary.length >= 10) {
967
+ sinks.memory({
968
+ kind: 'note',
969
+ content: '[opencode] 任务完成: ' + summary.slice(0, 160),
970
+ tags: ['task-outcome', 'opencode', turn.project],
971
+ project: turn.project,
972
+ source: 'opencode-observer',
973
+ })
974
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
975
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
976
+ }
977
+
978
+ for (const err of turn.errors) {
979
+ sinks.trace({
980
+ trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
981
+ event_type: 'error',
982
+ outcome: 'error',
983
+ payload: {
984
+ host: 'opencode',
985
+ project: turn.project,
986
+ sessionId: turn.sessionId,
987
+ turnId: turn.id,
988
+ tool: err.tool,
989
+ status: err.status,
990
+ },
991
+ agent: 'opencode',
992
+ host: 'opencode',
993
+ project: turn.project,
994
+ })
995
+ }
996
+ return true
997
+ }
998
+
999
+ function runOpencodeCycle(home, state, sinks, opts) {
1000
+ const out = { files: 0, lines: 0, outcomes: 0 }
1001
+ const db = opencodeDbPath()
1002
+ if (!fs.existsSync(db)) return out
1003
+ let st
1004
+ try {
1005
+ st = fs.statSync(db)
1006
+ } catch (_) {
1007
+ return out
1008
+ }
1009
+ out.files = 1
1010
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1011
+
1012
+ // First run: seed the rowid cursor to the first part written within the
1013
+ // backfill window so ancient history never floods MIO_HOME. One round trip:
1014
+ // the scalar subselects return NULL for first_rid when nothing was written
1015
+ // inside the window, which maps to the same "fall back to MAX(rowid)" branch
1016
+ // the previous two-query version had.
1017
+ if (!state.ocSeeded) {
1018
+ const ts = Date.now() - maxAgeMs
1019
+ const seed = runOpencodeQuery(
1020
+ 'SELECT (SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1) AS first_rid, ' +
1021
+ '(SELECT COALESCE(MAX(rowid), 0) FROM part) AS max_rid'
1022
+ )
1023
+ let rid = 0
1024
+ if (Array.isArray(seed) && seed.length > 0) {
1025
+ const firstRid = Number(seed[0].first_rid)
1026
+ rid = Number.isFinite(firstRid) && firstRid > 0 ? firstRid : Number(seed[0].max_rid) || 0
1027
+ }
1028
+ state.ocLastRowid = rid
1029
+ state.ocSeeded = true
1030
+ state.ocDbMtime = st.mtimeMs
1031
+ state.ocPending = true
1032
+ return out
1033
+ }
1034
+
1035
+ const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
1036
+ if (!changed && !state.ocPending) return out
1037
+
1038
+ const limit = 1500
1039
+ const sql =
1040
+ 'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
1041
+ 'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
1042
+ 'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
1043
+ 'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
1044
+ ' ORDER BY p.rowid LIMIT ' + limit
1045
+ const rows = runOpencodeQuery(sql)
1046
+ if (!Array.isArray(rows)) return out
1047
+ state.ocDbMtime = st.mtimeMs
1048
+ if (rows.length === 0) {
1049
+ state.ocPending = false
1050
+ return out
1051
+ }
1052
+
1053
+ let lastRid = state.ocLastRowid
1054
+ for (const row of rows) {
1055
+ const rid = Number(row.rid)
1056
+ if (rid > lastRid) lastRid = rid
1057
+ out.lines += 1
1058
+ const sid = row.sid
1059
+ let turn = state.ocTurns[sid]
1060
+ if (!turn) {
1061
+ turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
1062
+ state.ocTurns[sid] = turn
1063
+ }
1064
+ const result = feedOpencodePart(turn, row)
1065
+ if (result.finalized) {
1066
+ if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
1067
+ }
1068
+ state.ocTurns[sid] = result.next
1069
+ }
1070
+ state.ocLastRowid = lastRid
1071
+ state.ocPending = rows.length >= limit
1072
+
1073
+ // Caught up: any turn holding a terminal finish is complete.
1074
+ if (!state.ocPending) {
1075
+ for (const sid of Object.keys(state.ocTurns)) {
1076
+ const turn = state.ocTurns[sid]
1077
+ if (turn && turn.id && turn.pendingTerminal && !turn.done) {
1078
+ const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
1079
+ if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
1080
+ state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
1081
+ }
1082
+ }
1083
+ }
1084
+ return out
1085
+ }
1086
+
1087
+ // ---------------------------------------------------------------------------
1088
+ // Hermes session observation (passive, via its own SQLite state.db)
1089
+ // ---------------------------------------------------------------------------
1090
+
1091
+ // Hermes (Nous Research hermes-agent) keeps every conversation in
1092
+ // <LocalAppData>/hermes/state.db. Unlike OpenCode the file is not exclusively
1093
+ // locked, and `messages.id` is a monotonic AUTOINCREMENT cursor, so we can tail
1094
+ // it read-only with node:sqlite (Node 22+). Each message is one row carrying
1095
+ // role / content / finish_reason, so a user row opens a turn and an assistant
1096
+ // row with a terminal finish_reason closes it immediately. Hermes already has
1097
+ // native memory, so this observer only imports Hermes experience into the Mio
1098
+ // ecosystem (traces + memory + AGENTS.md context when a git repo is present).
1099
+
1100
+ let _hermesSqlite = null
1101
+ let _hermesSqliteTried = false
1102
+
1103
+ function hermesSqliteModule() {
1104
+ if (_hermesSqliteTried) return _hermesSqlite
1105
+ _hermesSqliteTried = true
1106
+ try {
1107
+ _hermesSqlite = require('node:sqlite')
1108
+ } catch (_) {
1109
+ _hermesSqlite = null
1110
+ }
1111
+ return _hermesSqlite
1112
+ }
1113
+
1114
+ function hermesDbPath() {
1115
+ return (
1116
+ process.env.HERMES_DATA_DIR ||
1117
+ path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'state.db')
1118
+ )
1119
+ }
1120
+
1121
+ function hermesProjectName(meta) {
1122
+ if (meta.git) return path.basename(meta.git)
1123
+ if (meta.cwd && meta.cwd !== os.homedir()) return path.basename(meta.cwd)
1124
+ return 'hermes'
1125
+ }
1126
+
1127
+ function createHermesTurn(meta) {
1128
+ meta = meta || {}
1129
+ return {
1130
+ id: null,
1131
+ ts: null,
1132
+ sessionId: meta.sessionId || null,
1133
+ cwd: meta.git || null,
1134
+ project: hermesProjectName({ git: meta.git, cwd: meta.projectHint }),
1135
+ title: meta.title || null,
1136
+ agent: meta.agent || null,
1137
+ lastText: null,
1138
+ toolCount: 0,
1139
+ lastMid: null,
1140
+ }
1141
+ }
1142
+
1143
+ function hermesOutcomeFor(finish) {
1144
+ if (finish === 'error' || finish === 'failure') return 'failure'
1145
+ if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
1146
+ return 'success'
1147
+ }
1148
+
1149
+ function finalizeHermesTurn(turn, outcome) {
1150
+ if (!turn.id) return null
1151
+ const finalized = {
1152
+ id: turn.id,
1153
+ ts: turn.ts,
1154
+ sessionId: turn.sessionId,
1155
+ cwd: turn.cwd,
1156
+ project: turn.project,
1157
+ agent: turn.agent,
1158
+ title: turn.title,
1159
+ lastText: turn.lastText,
1160
+ toolCount: turn.toolCount,
1161
+ errors: [],
1162
+ outcome: outcome,
1163
+ }
1164
+ return finalized
1165
+ }
1166
+
1167
+ function feedHermesRow(turn, row) {
1168
+ const meta = { sessionId: row.sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd }
1169
+
1170
+ if (row.role === 'user') {
1171
+ let finalized = null
1172
+ if (turn.id) finalized = finalizeHermesTurn(turn, 'aborted')
1173
+ const next = createHermesTurn(meta)
1174
+ next.id = Number(row.id)
1175
+ next.ts = Math.floor(Number(row.t) * 1000) || Date.now()
1176
+ next.lastMid = Number(row.id)
1177
+ return { finalized, next }
1178
+ }
1179
+
1180
+ if (row.role === 'assistant') {
1181
+ if (!turn.id) return { next: turn }
1182
+ if (typeof row.content === 'string' && row.content.trim()) {
1183
+ turn.lastText = row.content
1184
+ }
1185
+ if (row.tool_calls) {
1186
+ try {
1187
+ const calls = JSON.parse(row.tool_calls)
1188
+ if (Array.isArray(calls)) turn.toolCount += calls.length
1189
+ } catch (_) {}
1190
+ }
1191
+ if (row.finish_reason && row.finish_reason !== 'tool_calls') {
1192
+ const finalized = finalizeHermesTurn(turn, hermesOutcomeFor(row.finish_reason))
1193
+ return { finalized, next: createHermesTurn(meta) }
1194
+ }
1195
+ turn.lastMid = Number(row.id)
1196
+ return { next: turn }
1197
+ }
1198
+
1199
+ return { next: turn }
1200
+ }
1201
+
1202
+ function emitHermesOutcome(turn, sinks, state) {
1203
+ if (!turn.id) return false
1204
+ const summary = cleanText(turn.lastText)
1205
+ if (!summary) return false
1206
+ const outcome = turn.outcome
1207
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1208
+
1209
+ sinks.trace({
1210
+ trace_id: 'hermes:' + (turn.sessionId || 'unknown') + ':' + String(turn.id) + ':' + tsToken,
1211
+ event_type: 'task_outcome',
1212
+ outcome: outcome,
1213
+ payload: {
1214
+ host: 'hermes',
1215
+ project: turn.project,
1216
+ sessionId: turn.sessionId,
1217
+ turnId: turn.id,
1218
+ title: turn.title,
1219
+ agent: turn.agent,
1220
+ toolCalls: turn.toolCount,
1221
+ errors: 0,
1222
+ summary: summary.slice(0, 300),
1223
+ source: 'hermes-observer',
1224
+ },
1225
+ agent: 'hermes',
1226
+ host: 'hermes',
1227
+ project: turn.project,
1228
+ })
1229
+
1230
+ if (outcome === 'success' && summary.length >= 10) {
1231
+ sinks.memory({
1232
+ kind: 'note',
1233
+ content: '[hermes] 任务完成: ' + summary.slice(0, 160),
1234
+ tags: ['task-outcome', 'hermes', turn.project],
1235
+ project: turn.project,
1236
+ source: 'hermes-observer',
1237
+ })
1238
+ // Hermes reads AGENTS.md Context Files; write back only into a real repo
1239
+ // workspace so we never pollute the user's home directory.
1240
+ if (turn.cwd) {
1241
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1242
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
1243
+ }
1244
+ }
1245
+ return true
1246
+ }
1247
+
1248
+ function runHermesCycle(home, state, sinks, opts) {
1249
+ const out = { files: 0, lines: 0, outcomes: 0 }
1250
+ const dbPath = hermesDbPath()
1251
+ if (!fs.existsSync(dbPath)) return out
1252
+ const sqlite = hermesSqliteModule()
1253
+ if (!sqlite) return out
1254
+ let db
1255
+ try {
1256
+ db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
1257
+ } catch (_) {
1258
+ return out
1259
+ }
1260
+ out.files = 1
1261
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1262
+ try {
1263
+ if (!state.hmSeeded) {
1264
+ const cutoff = (Date.now() - maxAgeMs) / 1000
1265
+ const first = db.prepare('SELECT id FROM messages WHERE active = 1 AND timestamp >= ? ORDER BY id LIMIT 1').get(cutoff)
1266
+ if (first) {
1267
+ state.hmLastMsgId = first.id
1268
+ } else {
1269
+ const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get()
1270
+ state.hmLastMsgId = maxRow.id
1271
+ }
1272
+ state.hmSeeded = true
1273
+ }
1274
+
1275
+ const limit = 2000
1276
+ const stmt = db.prepare(
1277
+ 'SELECT m.id AS id, m.session_id AS sid, m.role AS role, m.content AS content, ' +
1278
+ 'm.tool_name AS tool_name, m.tool_calls AS tool_calls, m.timestamp AS t, m.finish_reason AS finish_reason, ' +
1279
+ 's.title AS stitle, s.cwd AS scwd, s.git_repo_root AS sgit, s.source AS ssource ' +
1280
+ 'FROM messages m LEFT JOIN sessions s ON s.id = m.session_id ' +
1281
+ 'WHERE m.id > ? AND m.active = 1 ORDER BY m.id LIMIT ' + limit
1282
+ )
1283
+ const rows = stmt.all(state.hmLastMsgId)
1284
+ if (rows.length === 0) return out
1285
+
1286
+ let lastId = state.hmLastMsgId
1287
+ for (const row of rows) {
1288
+ const rid = Number(row.id)
1289
+ if (rid > lastId) lastId = rid
1290
+ out.lines += 1
1291
+ const sid = row.sid
1292
+ let turn = state.hmTurns[sid]
1293
+ if (!turn) {
1294
+ turn = createHermesTurn({ sessionId: sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd })
1295
+ state.hmTurns[sid] = turn
1296
+ }
1297
+ const result = feedHermesRow(turn, row)
1298
+ if (result.finalized) {
1299
+ if (emitHermesOutcome(result.finalized, sinks, state)) out.outcomes += 1
1300
+ }
1301
+ state.hmTurns[sid] = result.next
1302
+ }
1303
+ state.hmLastMsgId = lastId
1304
+ } finally {
1305
+ db.close()
1306
+ }
1307
+ return out
1308
+ }
1309
+
1310
+
1311
+ // ---------------------------------------------------------------------------
1312
+ // Claude Code session observation (passive, transcript JSONL tailing)
1313
+ // ---------------------------------------------------------------------------
1314
+
1315
+ // Claude Code writes append-only JSONL transcripts under
1316
+ // <configDir>/projects/<munged-cwd>/<sessionId>.jsonl. Each line carries
1317
+ // {type, message:{role, content}, cwd, sessionId, timestamp, uuid, ...}.
1318
+ // Assistant content items are text/thinking/tool_use; tool results come back
1319
+ // as user lines whose content array holds tool_result items with is_error.
1320
+ // A user prompt line (string content or a text item) closes the previous
1321
+ // turn; sidechain lines (subagent transcripts) are skipped as noise.
1322
+
1323
+ function claudeConfigDir() {
1324
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
1325
+ }
1326
+
1327
+ function claudeProjectsDir() {
1328
+ return path.join(claudeConfigDir(), 'projects')
1329
+ }
1330
+
1331
+ function listClaudeTranscripts() {
1332
+ const root = claudeProjectsDir()
1333
+ const out = []
1334
+ if (!fs.existsSync(root)) return out
1335
+ const walk = (dir) => {
1336
+ let entries
1337
+ try {
1338
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1339
+ } catch (_) {
1340
+ return
1341
+ }
1342
+ for (const entry of entries) {
1343
+ const full = path.join(dir, entry.name)
1344
+ if (entry.isDirectory()) walk(full)
1345
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
1346
+ }
1347
+ }
1348
+ walk(root)
1349
+ return out
1350
+ }
1351
+
1352
+ function createClaudeTurn(meta) {
1353
+ meta = meta || {}
1354
+ return {
1355
+ id: null,
1356
+ ts: null,
1357
+ sessionId: meta.sessionId || null,
1358
+ cwd: meta.cwd || null,
1359
+ project: meta.cwd ? path.basename(meta.cwd) : 'unknown',
1360
+ lastText: null,
1361
+ toolCount: 0,
1362
+ errors: [],
1363
+ apiError: false,
1364
+ }
1365
+ }
1366
+
1367
+ function claudeContentItems(obj) {
1368
+ const content = obj && obj.message && obj.message.content
1369
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
1370
+ return Array.isArray(content) ? content.filter(Boolean) : []
1371
+ }
1372
+
1373
+ function finishClaudeTurn(turn, outcome) {
1374
+ return {
1375
+ id: turn.id,
1376
+ ts: turn.ts,
1377
+ sessionId: turn.sessionId,
1378
+ cwd: turn.cwd,
1379
+ project: turn.project,
1380
+ lastText: turn.lastText,
1381
+ toolCount: turn.toolCount,
1382
+ errors: turn.errors.slice(),
1383
+ apiError: turn.apiError,
1384
+ outcome: outcome,
1385
+ }
1386
+ }
1387
+
1388
+ function feedClaudeLine(turn, obj) {
1389
+ if (!obj || typeof obj !== 'object') return { next: turn }
1390
+ // Subagent (sidechain) transcripts duplicate main-thread work; skip them.
1391
+ if (obj.isSidechain === true) return { next: turn }
1392
+ if (obj.sessionId) turn.sessionId = obj.sessionId
1393
+ if (obj.cwd) {
1394
+ turn.cwd = obj.cwd
1395
+ turn.project = path.basename(obj.cwd)
1396
+ }
1397
+
1398
+ if (obj.type === 'user') {
1399
+ const items = claudeContentItems(obj)
1400
+ let hasToolError = false
1401
+ for (const item of items) {
1402
+ if (item.type === 'tool_result' && item.is_error) {
1403
+ turn.errors.push({ toolUseId: item.tool_use_id || 'unknown' })
1404
+ hasToolError = true
1405
+ }
1406
+ }
1407
+ if (turn.errors.length > 20) turn.errors.splice(0, turn.errors.length - 20)
1408
+
1409
+ // tool_result-only user lines continue the current turn; a string content
1410
+ // or text item means a fresh prompt, which closes the previous turn.
1411
+ const isPrompt =
1412
+ typeof (obj.message && obj.message.content) === 'string' ||
1413
+ items.some((item) => item.type === 'text')
1414
+ if (isPrompt) {
1415
+ let finalized = null
1416
+ if (turn.id) {
1417
+ finalized = finishClaudeTurn(turn, turn.apiError ? 'failure' : turn.lastText ? 'success' : 'aborted')
1418
+ }
1419
+ const next = createClaudeTurn({ sessionId: turn.sessionId, cwd: turn.cwd })
1420
+ next.id = obj.uuid || null
1421
+ next.ts = obj.timestamp ? Date.parse(obj.timestamp) || Date.now() : Date.now()
1422
+ return { finalized, next }
1423
+ }
1424
+ return { next: turn }
1425
+ }
1426
+
1427
+ if (obj.type === 'assistant') {
1428
+ for (const item of claudeContentItems(obj)) {
1429
+ if (item.type === 'text' && typeof item.text === 'string' && item.text.trim()) {
1430
+ turn.lastText = item.text
1431
+ } else if (item.type === 'tool_use') {
1432
+ turn.toolCount += 1
1433
+ }
1434
+ }
1435
+ if (obj.isApiErrorMessage === true || obj.error) turn.apiError = true
1436
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN
1437
+ if (Number.isFinite(ts)) turn.ts = ts
1438
+ return { next: turn }
1439
+ }
1440
+
1441
+ return { next: turn }
1442
+ }
1443
+
1444
+ function emitClaudeOutcome(turn, sinks, state) {
1445
+ if (!turn.id) return false
1446
+ const summary = cleanText(turn.lastText)
1447
+ if (!summary && !turn.apiError) return false
1448
+ const outcome = turn.apiError && turn.outcome !== 'failure' ? 'failure' : turn.outcome
1449
+ const turnToken = String(turn.id).slice(0, 8)
1450
+ const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
1451
+
1452
+ sinks.trace({
1453
+ trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
1454
+ event_type: 'task_outcome',
1455
+ outcome: outcome,
1456
+ payload: {
1457
+ host: 'claude-code',
1458
+ project: turn.project,
1459
+ sessionId: turn.sessionId,
1460
+ turnId: turn.id,
1461
+ toolCalls: turn.toolCount,
1462
+ errors: turn.errors.length,
1463
+ summary: summary.slice(0, 300),
1464
+ source: 'claude-observer',
1465
+ },
1466
+ agent: 'claude-code',
1467
+ host: 'claude-code',
1468
+ project: turn.project,
1469
+ })
1470
+
1471
+ if (outcome === 'success' && summary.length >= 10) {
1472
+ sinks.memory({
1473
+ kind: 'note',
1474
+ content: '[claude-code] 任务完成: ' + summary.slice(0, 160),
1475
+ tags: ['task-outcome', 'claude-code', turn.project],
1476
+ project: turn.project,
1477
+ source: 'claude-observer',
1478
+ })
1479
+ // Claude Code auto-loads <workspace>/CLAUDE.md, so write the context
1480
+ // block there instead of AGENTS.md.
1481
+ if (turn.cwd) {
1482
+ var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
1483
+ updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80), 'CLAUDE.md')
1484
+ }
1485
+ }
1486
+
1487
+ for (const err of turn.errors) {
1488
+ sinks.trace({
1489
+ trace_id: 'claude:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.toolUseId || 'err').slice(0, 12),
1490
+ event_type: 'error',
1491
+ outcome: 'error',
1492
+ payload: {
1493
+ host: 'claude-code',
1494
+ project: turn.project,
1495
+ sessionId: turn.sessionId,
1496
+ turnId: turn.id,
1497
+ toolUseId: err.toolUseId,
1498
+ },
1499
+ agent: 'claude-code',
1500
+ host: 'claude-code',
1501
+ project: turn.project,
1502
+ })
1503
+ }
1504
+ return true
1505
+ }
1506
+
1507
+ function runClaudeCycle(home, state, sinks, opts) {
1508
+ const out = { files: 0, lines: 0, outcomes: 0 }
1509
+ const files = listClaudeTranscripts()
1510
+ const now = Date.now()
1511
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1512
+
1513
+ for (const file of files) {
1514
+ out.files += 1
1515
+ let st
1516
+ try {
1517
+ st = fs.statSync(file)
1518
+ } catch (_) {
1519
+ continue
1520
+ }
1521
+ const info = state.clFiles[file] || { size: 0 }
1522
+ if (!state.clFiles[file] && now - st.mtimeMs > maxAgeMs) continue
1523
+ const size = st.size
1524
+ if (size < info.size) {
1525
+ info.size = 0
1526
+ state.clBuffers[file] = ''
1527
+ }
1528
+
1529
+ let turn = state.clTurns[file] || createClaudeTurn()
1530
+
1531
+ if (size > info.size) {
1532
+ let chunk = ''
1533
+ try {
1534
+ const fd = fs.openSync(file, 'r')
1535
+ const buf = Buffer.alloc(size - info.size)
1536
+ fs.readSync(fd, buf, 0, buf.length, info.size)
1537
+ fs.closeSync(fd)
1538
+ chunk = buf.toString('utf8')
1539
+ } catch (_) {
1540
+ continue
1541
+ }
1542
+ const pending = (state.clBuffers[file] || '') + chunk
1543
+ const lines = pending.split(/\r?\n/)
1544
+ state.clBuffers[file] = lines.pop()
1545
+ for (const line of lines) {
1546
+ if (!line.trim()) continue
1547
+ out.lines += 1
1548
+ let obj
1549
+ try {
1550
+ obj = JSON.parse(line)
1551
+ } catch (_) {
1552
+ continue
1553
+ }
1554
+ const result = feedClaudeLine(turn, obj)
1555
+ turn = result.next
1556
+ if (result.finalized) {
1557
+ if (emitClaudeOutcome(result.finalized, sinks, state)) out.outcomes += 1
1558
+ }
1559
+ }
1560
+ state.clFiles[file] = { size: size, mtimeMs: st.mtimeMs }
1561
+ }
1562
+
1563
+ // Idle finalize: the session has answered but no new prompt arrived yet.
1564
+ if (turn.id && turn.lastText && now - st.mtimeMs > IDLE_MS) {
1565
+ if (emitClaudeOutcome(finishClaudeTurn(turn, turn.apiError ? 'failure' : 'success'), sinks, state)) {
1566
+ out.outcomes += 1
1567
+ }
1568
+ state.clTurns[file] = null
1569
+ } else if (turn.id) {
1570
+ state.clTurns[file] = turn
1571
+ } else {
1572
+ state.clTurns[file] = null
1573
+ }
1574
+ }
1575
+
1576
+ return out
1577
+ }
1578
+
1579
+
1580
+ // ---------------------------------------------------------------------------
1581
+ // cycle
1582
+ // ---------------------------------------------------------------------------
1583
+
1584
+ function runCycle(home, state, sinks, opts) {
1585
+ const out = { files: 0, lines: 0, outcomes: 0 }
1586
+ fs.mkdirSync(home, { recursive: true })
1587
+ const files = listTranscripts()
1588
+ const now = Date.now()
1589
+ const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
1590
+
1591
+ for (const file of files) {
1592
+ out.files += 1
1593
+ let st
1594
+ try {
1595
+ st = fs.statSync(file)
1596
+ } catch (_) {
1597
+ continue
1598
+ }
1599
+ const info = state.files[file] || { size: 0 }
1600
+ // Do not flood MIO_HOME with ancient history: only backfill files that are
1601
+ // already tracked or that changed within the max age window.
1602
+ if (!state.files[file] && now - st.mtimeMs > maxAgeMs) continue
1603
+ const size = st.size
1604
+ if (size < info.size) {
1605
+ // rotated/truncated -> restart from the beginning
1606
+ info.size = 0
1607
+ state.buffers[file] = ''
1608
+ }
1609
+
1610
+ const meta = fileMeta(file)
1611
+ let turn = state.openTurns[file] || createTurn(meta)
1612
+ turn.project = meta.project
1613
+ turn.sessionId = meta.sessionId
1614
+
1615
+ if (size > info.size) {
1616
+ let chunk = ''
1617
+ try {
1618
+ const fd = fs.openSync(file, 'r')
1619
+ const buf = Buffer.alloc(size - info.size)
1620
+ fs.readSync(fd, buf, 0, buf.length, info.size)
1621
+ fs.closeSync(fd)
1622
+ chunk = buf.toString('utf8')
1623
+ } catch (_) {
1624
+ continue
1625
+ }
1626
+ const pending = (state.buffers[file] || '') + chunk
1627
+ const lines = pending.split(/\r?\n/)
1628
+ state.buffers[file] = lines.pop()
1629
+ for (const line of lines) {
1630
+ if (!line.trim()) continue
1631
+ out.lines += 1
1632
+ let obj
1633
+ try {
1634
+ obj = JSON.parse(line)
1635
+ } catch (_) {
1636
+ continue
1637
+ }
1638
+ const result = feedWorkbuddyLine(turn, obj)
1639
+ if (result.finalized) {
1640
+ if (emitWorkbuddyOutcome(result.finalized, sinks, state)) out.outcomes += 1
1641
+ }
1642
+ turn = result.next
1643
+ }
1644
+ state.files[file] = { size: size, mtimeMs: st.mtimeMs }
1645
+ }
1646
+
1647
+ if (turn.lastAssistant && now - st.mtimeMs > IDLE_MS) {
1648
+ if (emitWorkbuddyOutcome(turn, sinks, state)) out.outcomes += 1
1649
+ state.openTurns[file] = null
1650
+ } else if (turn.id) {
1651
+ state.openTurns[file] = turn
1652
+ } else {
1653
+ state.openTurns[file] = null
1654
+ }
1655
+ }
1656
+
1657
+ const codexRes = runCodexCycle(home, state, sinks, opts)
1658
+ out.files += codexRes.files
1659
+ out.lines += codexRes.lines
1660
+ out.outcomes += codexRes.outcomes
1661
+
1662
+ const ocRes = runOpencodeCycle(home, state, sinks, opts)
1663
+ out.files += ocRes.files
1664
+ out.lines += ocRes.lines
1665
+ out.outcomes += ocRes.outcomes
1666
+
1667
+ const hmRes = runHermesCycle(home, state, sinks, opts)
1668
+ out.files += hmRes.files
1669
+ out.lines += hmRes.lines
1670
+ out.outcomes += hmRes.outcomes
1671
+
1672
+ const clRes = runClaudeCycle(home, state, sinks, opts)
1673
+ out.files += clRes.files
1674
+ out.lines += clRes.lines
1675
+ out.outcomes += clRes.outcomes
1676
+
1677
+ saveState(home, state)
1678
+ return out
1679
+ }
1680
+
1681
+ function startForeground(home) {
1682
+ const state = loadState(home)
1683
+ const sinks = createSinks(home)
1684
+ log(home, 'observe started (home=' + home + ', interval=' + INTERVAL_MS + 'ms)')
1685
+ const tick = () => {
1686
+ try {
1687
+ const res = runCycle(home, state, sinks)
1688
+ if (res.lines > 0) {
1689
+ log(home, 'cycle: files=' + res.files + ' lines=' + res.lines + ' outcomes=' + res.outcomes)
1690
+ }
1691
+ } catch (err) {
1692
+ log(home, 'cycle error: ' + (err && err.stack ? err.stack : String(err)))
1693
+ }
1694
+ }
1695
+ tick()
1696
+ setInterval(tick, INTERVAL_MS)
1697
+ }
1698
+
1699
+ // ---------------------------------------------------------------------------
1700
+ // background daemon control
1701
+ // ---------------------------------------------------------------------------
1702
+
1703
+ function pidFile(home) {
1704
+ return path.join(home, 'observe.pid')
1705
+ }
1706
+
1707
+ function researchPidFile(home) {
1708
+ return path.join(home, 'research.pid')
1709
+ }
1710
+
1711
+ function researchLogFile(home) {
1712
+ return path.join(home, 'research.log')
1713
+ }
1714
+
1715
+ function pidAlive(pid) {
1716
+ if (!Number.isInteger(pid) || pid <= 0) return false
1717
+ try {
1718
+ process.kill(pid, 0)
1719
+ return true
1720
+ } catch (_) {
1721
+ return false
1722
+ }
1723
+ }
1724
+
1725
+ function readPid(home) {
1726
+ return readPidFile(pidFile(home))
1727
+ }
1728
+
1729
+ function readResearchPid(home) {
1730
+ return readPidFile(researchPidFile(home))
1731
+ }
1732
+
1733
+ function readPidFile(file) {
1734
+ try {
1735
+ const pid = Number(fs.readFileSync(file, 'utf8').trim())
1736
+ return Number.isInteger(pid) && pid > 0 ? pid : null
1737
+ } catch (_) {
1738
+ return null
1739
+ }
1740
+ }
1741
+
1742
+ function isRunning(home) {
1743
+ return pidAlive(readPid(home))
1744
+ }
1745
+
1746
+ function isResearchRunning(home) {
1747
+ return pidAlive(readResearchPid(home))
1748
+ }
1749
+
1750
+ function startBackground(home) {
1751
+ if (isRunning(home)) {
1752
+ return { running: true, started: false, pid: readPid(home) }
1753
+ }
1754
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
1755
+ const child = spawn(process.execPath, [script, 'observe'], {
1756
+ detached: true,
1757
+ stdio: 'ignore',
1758
+ windowsHide: true,
1759
+ cwd: home,
1760
+ })
1761
+ child.unref()
1762
+ fs.mkdirSync(home, { recursive: true })
1763
+ fs.writeFileSync(pidFile(home), String(child.pid), 'utf8')
1764
+ return { running: true, started: true, pid: child.pid }
1765
+ }
1766
+
1767
+ function stopBackground(home) {
1768
+ const pid = readPid(home)
1769
+ if (pid) {
1770
+ try {
1771
+ process.kill(pid)
1772
+ } catch (_) {}
1773
+ try {
1774
+ fs.unlinkSync(pidFile(home))
1775
+ } catch (_) {}
1776
+ }
1777
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
1778
+ }
1779
+
1780
+ // ---------------------------------------------------------------------------
1781
+ // research scheduler daemon (`mio observe --start --research`)
1782
+ //
1783
+ // D6: ObserverService's own scheduler (collector intervals + the daily
1784
+ // pipeline tick) needed a process to live in. The transcript daemon could have
1785
+ // hosted it in-process, but that daemon is spawned with cwd: home, so its
1786
+ // `<cwd>/.local/observer` would resolve to MIO_HOME -- a second observation
1787
+ // directory that `mio observer status` in the project would never see. Running
1788
+ // `mio observer serve` as its own child with the *launcher's* cwd keeps one
1789
+ // data directory per working directory, exactly like every other observer
1790
+ // command, and keeps a crash in either half from taking down the other.
1791
+ //
1792
+ // spawn/kill are injectable so tests never signal a real pid.
1793
+ // ---------------------------------------------------------------------------
1794
+
1795
+ function startResearchBackground(home, opts = {}) {
1796
+ const cwd = opts.cwd || process.cwd()
1797
+ const spawnImpl = opts.spawn || spawn
1798
+ if (isResearchRunning(home)) {
1799
+ return { running: true, started: false, pid: readResearchPid(home), baseDir: path.join(cwd, '.local', 'observer') }
1800
+ }
1801
+ const script = path.join(__dirname, '..', 'bin', 'mio.js')
1802
+ const baseDir = path.join(cwd, '.local', 'observer')
1803
+ // The scheduler logs to stdout, so a detached child with stdio:'ignore' would
1804
+ // be undiagnosable ("is it running? did it collect?"). Handing it an append
1805
+ // fd instead of a pipe is deliberate: a pipe nobody reads fills up after
1806
+ // ~64KB and the scheduler would block on its own log.
1807
+ fs.mkdirSync(home, { recursive: true })
1808
+ const logFd = fs.openSync(researchLogFile(home), 'a')
1809
+ let child
1810
+ try {
1811
+ child = spawnImpl(
1812
+ process.execPath,
1813
+ [script, 'observer', 'serve', '--base-dir', baseDir],
1814
+ {
1815
+ detached: true,
1816
+ stdio: ['ignore', logFd, logFd],
1817
+ windowsHide: true,
1818
+ cwd,
1819
+ }
1820
+ )
1821
+ } finally {
1822
+ fs.closeSync(logFd)
1823
+ }
1824
+ if (child.unref) child.unref()
1825
+ fs.writeFileSync(researchPidFile(home), String(child.pid), 'utf8')
1826
+ return { running: true, started: true, pid: child.pid, baseDir, logFile: researchLogFile(home) }
1827
+ }
1828
+
1829
+ function stopResearchBackground(home, killImpl) {
1830
+ const kill = killImpl || process.kill
1831
+ const pid = readResearchPid(home)
1832
+ if (pid) {
1833
+ try {
1834
+ kill(pid)
1835
+ } catch (_) {}
1836
+ try {
1837
+ fs.unlinkSync(researchPidFile(home))
1838
+ } catch (_) {}
1839
+ }
1840
+ return { running: false, stopped: Boolean(pid), pid: pid || null }
1841
+ }
1842
+
1843
+ module.exports = {
1844
+ INTERVAL_MS,
1845
+ homeDir,
1846
+ workbuddyConfigDir,
1847
+ loadState,
1848
+ saveState,
1849
+ dedupeContexts,
1850
+ normalizeCwd,
1851
+ contextTimestamp,
1852
+ updateProjectContext,
1853
+ runCycle,
1854
+ opencodeDbPath,
1855
+ runOpencodeCycle,
1856
+ runHermesCycle,
1857
+ hermesDbPath,
1858
+ claudeProjectsDir,
1859
+ runClaudeCycle,
1860
+ createClaudeTurn,
1861
+ feedClaudeLine,
1862
+ createSinks,
1863
+ startForeground,
1864
+ startBackground,
1865
+ stopBackground,
1866
+ isRunning,
1867
+ readPid,
1868
+ startResearchBackground,
1869
+ stopResearchBackground,
1870
+ isResearchRunning,
1871
+ readResearchPid,
1872
+ researchLogFile,
1873
+ // exported for tests: the numeric-segment guard here was silently dead for
1874
+ // several releases because nothing exercised it (see __tests__/observer-project.test.js)
1875
+ projectFromDirName,
1876
+ }