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