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.
- package/README.md +19 -5
- package/adapters/codex.js +121 -121
- package/adapters/hermes.js +182 -176
- package/adapters/opencode.js +203 -202
- package/adapters/workbuddy.js +197 -197
- package/bin/mio.js +93 -12
- package/observe/observer.js +1876 -1867
- package/package.json +3 -3
- package/server/creativity-engine.js +238 -67
- package/server/evolution-cutover.js +5 -2
- package/server/insight-store.js +37 -11
- package/server/llm-client.js +29 -8
- package/server/mio-intelligence-mcp/README.md +173 -173
- package/server/mio-intelligence-mcp/index.js +27 -6
- package/server/mio-intelligence-mcp/package.json +17 -17
- package/server/mio-intelligence-mcp/phase0.js +365 -365
- package/server/observer-store.js +12 -11
- package/server/task-store.js +475 -475
package/observe/observer.js
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (obj
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
next
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
turn.
|
|
542
|
-
turn.
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
turn.
|
|
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
|
-
|
|
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
|
-
out
|
|
1001
|
-
const
|
|
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
|
-
const
|
|
1056
|
-
if (
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
state.ocTurns[sid]
|
|
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
|
-
|
|
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
|
-
return
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
if (
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
if (row.
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
const
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
state.hmTurns[sid]
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (obj
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
if (
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
state.
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
out.
|
|
1660
|
-
out.
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
out.
|
|
1665
|
-
out.
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
function
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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
|
+
}
|