mio-agent-runtime 0.3.4 → 0.3.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 +93 -79
- package/bin/mio.js +3 -1
- package/observe/observer.js +339 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,79 +1,93 @@
|
|
|
1
|
-
# mio-agent-runtime
|
|
2
|
-
|
|
3
|
-
Mio Agent Runtime 的可发布 CLI 包。
|
|
4
|
-
|
|
5
|
-
安装一次,所有 Agent 自动接入 Mio。
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install -g mio-agent-runtime
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
mio
|
|
21
|
-
mio
|
|
22
|
-
mio
|
|
23
|
-
mio
|
|
24
|
-
mio
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
WorkBuddy
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
1
|
+
# mio-agent-runtime
|
|
2
|
+
|
|
3
|
+
Mio Agent Runtime 的可发布 CLI 包。
|
|
4
|
+
|
|
5
|
+
安装一次,所有 Agent 自动接入 Mio。
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g mio-agent-runtime
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> 新机器 / 升级:请安装 `>=0.3.4`(Codex 被动观察)或 `>=0.3.5`
|
|
14
|
+
> (Codex + OpenCode 被动观察)。旧版本只有主动 MCP 规则注入,
|
|
15
|
+
> 没有「无感存/取」。安装后执行一次 `mio init && mio install <host>`。
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
mio init Initialize MIO_HOME
|
|
21
|
+
mio mcp Start Mio MCP server (stdio)
|
|
22
|
+
mio install <host> Install Mio into a host (codex|opencode|workbuddy)
|
|
23
|
+
mio status Show runtime and adapter status
|
|
24
|
+
mio agents List installed agents
|
|
25
|
+
mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes (foreground)
|
|
26
|
+
mio observe --start|--stop|--status|--once Manage the background observer daemon
|
|
27
|
+
mio --json status Machine-readable status
|
|
28
|
+
mio --json agents Machine-readable agents
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Host notes
|
|
32
|
+
|
|
33
|
+
- OpenCode uses `~/.config/opencode/opencode.json` for MCP registration and
|
|
34
|
+
`~/.config/opencode/AGENTS.md` for global instructions. `mio install opencode`
|
|
35
|
+
writes both files and injects Mio usage rules into `AGENTS.md` so OpenCode
|
|
36
|
+
calls Mio memory/observer/policy automatically. If OpenCode is already
|
|
37
|
+
running, restart it once so it reloads both files. `mio observe` also
|
|
38
|
+
passively tails the OpenCode session database (`opencode db`) without
|
|
39
|
+
requiring the model to call Mio tools.
|
|
40
|
+
- WorkBuddy uses `~/.workbuddy/mcp.json` and requires each custom MCP server to
|
|
41
|
+
be approved in `~/.workbuddy/mcp-approvals.json`. `mio install workbuddy`
|
|
42
|
+
writes both files and injects Mio usage rules into
|
|
43
|
+
`~/.workbuddy/CODEBUDDY.md` (user-level memory rules, auto-loaded by
|
|
44
|
+
WorkBuddy into every session; the CLI hardcodes the CodeBuddy product name, so the file is CODEBUDDY.md not WORKBUDDY.md) plus `<workspace>/AGENTS.md` (project-level).
|
|
45
|
+
If WorkBuddy is already running, restart it once so it reloads the approval
|
|
46
|
+
and memory rule files.
|
|
47
|
+
|
|
48
|
+
## Passive observer (`mio observe`)
|
|
49
|
+
|
|
50
|
+
WorkBuddy 的默认模型不一定会主动调用 Mio MCP 工具,即使规则已注入
|
|
51
|
+
(`CODEBUDDY.md` / `AGENTS.md`)。`mio observe` 是一个不依赖模型自觉的
|
|
52
|
+
兜底:它持续监听 `~/.workbuddy/projects/**/*.jsonl` 会话记录,把每个任务的
|
|
53
|
+
结果(`task_outcome`)、工具调用错误(`error`)以及完成任务的摘要自动写入
|
|
54
|
+
`MIO_HOME/traces.jsonl` 和 `MIO_HOME/memory.jsonl`(与 MCP 服务同一套
|
|
55
|
+
schema,`source=workbuddy-observer`)。
|
|
56
|
+
|
|
57
|
+
- `mio install <host>` 会自动执行一次回填并启动后台观察器。
|
|
58
|
+
|
|
59
|
+
- 除了写入 `MIO_HOME`,观察器还会把任务摘要**写回 WorkBuddy 自己的记忆文件**
|
|
60
|
+
(`<workspace>/.workbuddy/memory/YYYY-MM-DD.md`,标为 `## Mio 自动摘要`)。
|
|
61
|
+
WorkBuddy 的系统提示要求模型每次会话维护并阅读这些文件,所以下一次会话
|
|
62
|
+
WorkBuddy 会**被动取到** Mio 记录的内容——单 Agent 的「存 + 取」闭环不依赖
|
|
63
|
+
模型调用 Mio 工具。
|
|
64
|
+
- 已处理的文件偏移记录在 `MIO_HOME/observe-state.json`,不会重复写入。
|
|
65
|
+
- 只回填最近 24h 内更新的会话,避免把历史记录灌入记忆库。
|
|
66
|
+
|
|
67
|
+
OpenCode 会话不依赖模型自觉:`mio observe` 通过 `opencode db` 增量读取
|
|
68
|
+
`~/.local/share/opencode/opencode.db`(Windows 下该文件被 OpenCode 独占锁定,
|
|
69
|
+
必须经 opencode 自带 CLI 读取),按 `part` 表的 rowid 做增量游标,只传输
|
|
70
|
+
`text`/`tool` 两种 part,把每个用户回合的 `task_outcome` 与工具错误写入
|
|
71
|
+
`MIO_HOME/traces.jsonl` / `memory.jsonl`(`source=opencode-observer`),并同样
|
|
72
|
+
把摘要写回 `<workspace>/AGENTS.md` 的 `MIO_CONTEXT` 块,供 OpenCode 下一会话
|
|
73
|
+
被动取用。首次启动只回填最近 24h 的 part,避免历史会话灌入记忆库。
|
|
74
|
+
|
|
75
|
+
## MIO_HOME
|
|
76
|
+
|
|
77
|
+
默认 `~/.mio-intelligence`,可通过环境变量 `MIO_HOME` 覆盖。
|
|
78
|
+
|
|
79
|
+
## Package layout
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
packages/mio-cli
|
|
83
|
+
├── bin/mio.js
|
|
84
|
+
├── adapters/
|
|
85
|
+
│ ├── codex.js
|
|
86
|
+
│ ├── opencode.js
|
|
87
|
+
│ └── workbuddy.js
|
|
88
|
+
├── observe/
|
|
89
|
+
│ └── observer.js
|
|
90
|
+
├── server/
|
|
91
|
+
│ └── mio-intelligence-mcp/index.js
|
|
92
|
+
└── package.json
|
|
93
|
+
```
|
package/bin/mio.js
CHANGED
|
@@ -68,6 +68,7 @@ function statusCommand(useJson) {
|
|
|
68
68
|
codexInstalled: installed.codex,
|
|
69
69
|
opencodeInstalled: installed.opencode,
|
|
70
70
|
workbuddyInstalled: installed.workbuddy,
|
|
71
|
+
opencodeObserverReady: fs.existsSync(observer.opencodeDbPath()),
|
|
71
72
|
observerRunning: observer.isRunning(MIO_HOME),
|
|
72
73
|
observerPid: observer.isRunning(MIO_HOME) ? observer.readPid(MIO_HOME) : null,
|
|
73
74
|
}
|
|
@@ -78,7 +79,8 @@ function statusCommand(useJson) {
|
|
|
78
79
|
console.log(`MIO_HOME: ${MIO_HOME}`)
|
|
79
80
|
console.log(`MCP Server: ${SERVER_SCRIPT}`)
|
|
80
81
|
console.log(`Codex MCP: ${installed.codex ? 'installed' : 'not installed'}`)
|
|
81
|
-
console.log(`OpenCode MCP: ${installed.opencode ? 'installed' : 'not installed'}`)
|
|
82
|
+
console.log(`OpenCode MCP: ${installed.opencode ? 'installed' : 'not installed'}`)
|
|
83
|
+
console.log(`OpenCode observer: ${fs.existsSync(observer.opencodeDbPath()) ? 'ready' : 'no database'}`)
|
|
82
84
|
console.log(`WorkBuddy MCP: ${installed.workbuddy ? 'installed' : 'not installed'}`)
|
|
83
85
|
console.log(`Observer: ${observer.isRunning(MIO_HOME) ? 'running (pid ' + observer.readPid(MIO_HOME) + ')' : 'not running'}`)
|
|
84
86
|
}
|
package/observe/observer.js
CHANGED
|
@@ -5,7 +5,7 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const os = require('os')
|
|
7
7
|
const crypto = require('crypto')
|
|
8
|
-
const { spawn } = require('child_process')
|
|
8
|
+
const { spawn, spawnSync } = require('child_process')
|
|
9
9
|
|
|
10
10
|
const INTERVAL_MS = 3000
|
|
11
11
|
// A WorkBuddy turn is considered finished when its transcript file has not
|
|
@@ -78,6 +78,11 @@ function loadState(home) {
|
|
|
78
78
|
codexFiles: parsed.codexFiles || {},
|
|
79
79
|
codexBuffers: parsed.codexBuffers || {},
|
|
80
80
|
codexTurns: parsed.codexTurns || {},
|
|
81
|
+
ocSeeded: parsed.ocSeeded || false,
|
|
82
|
+
ocLastRowid: parsed.ocLastRowid || 0,
|
|
83
|
+
ocDbMtime: parsed.ocDbMtime || null,
|
|
84
|
+
ocPending: parsed.ocPending || false,
|
|
85
|
+
ocTurns: parsed.ocTurns || {},
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
} catch (_) {}
|
|
@@ -90,6 +95,11 @@ function loadState(home) {
|
|
|
90
95
|
codexFiles: {},
|
|
91
96
|
codexBuffers: {},
|
|
92
97
|
codexTurns: {},
|
|
98
|
+
ocSeeded: false,
|
|
99
|
+
ocLastRowid: 0,
|
|
100
|
+
ocDbMtime: null,
|
|
101
|
+
ocPending: false,
|
|
102
|
+
ocTurns: {},
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
|
|
@@ -552,6 +562,327 @@ function runCodexCycle(home, state, sinks, opts) {
|
|
|
552
562
|
return out
|
|
553
563
|
}
|
|
554
564
|
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// OpenCode session observation (passive, via `opencode db`)
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
// OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
|
|
570
|
+
// On Windows the db file is held with an exclusive lock while OpenCode runs,
|
|
571
|
+
// so we cannot open it with node:sqlite directly. Instead we shell out to the
|
|
572
|
+
// `opencode db` CLI, which opens the same database through the app itself and
|
|
573
|
+
// works even while OpenCode is running. We tail `part` rows by rowid so each
|
|
574
|
+
// session is scanned incrementally and only text/tool parts are transferred.
|
|
575
|
+
|
|
576
|
+
function opencodeDataDir() {
|
|
577
|
+
return (
|
|
578
|
+
process.env.OPENCODE_DATA_DIR ||
|
|
579
|
+
path.join(os.homedir(), '.local', 'share', 'opencode')
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function opencodeDbPath() {
|
|
584
|
+
return path.join(opencodeDataDir(), 'opencode.db')
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function findOpencodeBin() {
|
|
588
|
+
if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
|
|
589
|
+
return process.env.OPENCODE_BIN
|
|
590
|
+
}
|
|
591
|
+
const exeDir = path.dirname(process.execPath)
|
|
592
|
+
const candidates = [
|
|
593
|
+
path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
594
|
+
path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
595
|
+
path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
596
|
+
]
|
|
597
|
+
for (const c of candidates) {
|
|
598
|
+
const resolved = path.resolve(c)
|
|
599
|
+
if (fs.existsSync(resolved)) return resolved
|
|
600
|
+
}
|
|
601
|
+
return null
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function runOpencodeQuery(sql) {
|
|
605
|
+
const bin = findOpencodeBin()
|
|
606
|
+
const args = ['db', sql, '--format', 'json']
|
|
607
|
+
let res
|
|
608
|
+
try {
|
|
609
|
+
res = bin
|
|
610
|
+
? spawnSync(bin, args, { encoding: 'utf8', timeout: 90000, windowsHide: true, maxBuffer: 256 * 1024 * 1024 })
|
|
611
|
+
: spawnSync('opencode', args, { encoding: 'utf8', timeout: 90000, shell: true, maxBuffer: 256 * 1024 * 1024 })
|
|
612
|
+
} catch (_) {
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
if (res.error || res.status !== 0 || !res.stdout) return null
|
|
616
|
+
try {
|
|
617
|
+
return JSON.parse(res.stdout)
|
|
618
|
+
} catch (_) {
|
|
619
|
+
return null
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
|
|
624
|
+
// keep the transferred payload small.
|
|
625
|
+
const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
|
|
626
|
+
|
|
627
|
+
function createOpencodeTurn(meta) {
|
|
628
|
+
meta = meta || {}
|
|
629
|
+
return {
|
|
630
|
+
id: null,
|
|
631
|
+
ts: null,
|
|
632
|
+
sessionId: meta.sessionId || null,
|
|
633
|
+
cwd: meta.directory || null,
|
|
634
|
+
project: meta.directory ? path.basename(meta.directory) : 'unknown',
|
|
635
|
+
agent: meta.agent || null,
|
|
636
|
+
title: meta.title || null,
|
|
637
|
+
lastMid: null,
|
|
638
|
+
lastText: null,
|
|
639
|
+
toolCount: 0,
|
|
640
|
+
errors: [],
|
|
641
|
+
pendingTerminal: null,
|
|
642
|
+
done: false,
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function opencodeOutcomeFor(finish) {
|
|
647
|
+
if (finish === 'error') return 'failure'
|
|
648
|
+
if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
|
|
649
|
+
return 'success'
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function finalizeOpencodeTurn(turn, outcome) {
|
|
653
|
+
if (!turn.id) return null
|
|
654
|
+
const finalized = {
|
|
655
|
+
id: turn.id,
|
|
656
|
+
ts: turn.ts,
|
|
657
|
+
sessionId: turn.sessionId,
|
|
658
|
+
cwd: turn.cwd,
|
|
659
|
+
project: turn.project,
|
|
660
|
+
agent: turn.agent,
|
|
661
|
+
title: turn.title,
|
|
662
|
+
lastText: turn.lastText,
|
|
663
|
+
toolCount: turn.toolCount,
|
|
664
|
+
errors: turn.errors.slice(),
|
|
665
|
+
outcome: outcome,
|
|
666
|
+
}
|
|
667
|
+
turn.done = true
|
|
668
|
+
return finalized
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function applyOpencodeAssistantPart(turn, pd, md, row) {
|
|
672
|
+
if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
|
|
673
|
+
turn.lastText = pd.text
|
|
674
|
+
} else if (pd.type === 'tool') {
|
|
675
|
+
turn.toolCount += 1
|
|
676
|
+
const status = pd.state && pd.state.status
|
|
677
|
+
if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
|
|
678
|
+
turn.errors.push({ tool: pd.tool || 'unknown', status: status })
|
|
679
|
+
if (turn.errors.length > 20) turn.errors.shift()
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (md.finish && md.finish !== 'tool-calls') {
|
|
683
|
+
turn.pendingTerminal = md.finish
|
|
684
|
+
}
|
|
685
|
+
turn.lastMid = row.mid
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function feedOpencodePart(turn, row) {
|
|
689
|
+
let pd
|
|
690
|
+
let md
|
|
691
|
+
try {
|
|
692
|
+
pd = JSON.parse(row.pd)
|
|
693
|
+
} catch (_) {
|
|
694
|
+
pd = null
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
md = JSON.parse(row.md)
|
|
698
|
+
} catch (_) {
|
|
699
|
+
md = null
|
|
700
|
+
}
|
|
701
|
+
if (!pd || !md) return { next: turn }
|
|
702
|
+
|
|
703
|
+
// A user message starts a new turn; any unfinished previous turn is aborted.
|
|
704
|
+
if (md.role === 'user') {
|
|
705
|
+
let finalized = null
|
|
706
|
+
if (turn.id && !turn.done) {
|
|
707
|
+
finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
|
|
708
|
+
}
|
|
709
|
+
const next = createOpencodeTurn({
|
|
710
|
+
sessionId: row.sid,
|
|
711
|
+
directory: row.sdir,
|
|
712
|
+
title: row.stitle,
|
|
713
|
+
agent: row.sagent,
|
|
714
|
+
})
|
|
715
|
+
next.id = row.mid
|
|
716
|
+
next.ts = Number(row.t) || Date.now()
|
|
717
|
+
next.lastMid = row.mid
|
|
718
|
+
return { finalized, next }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (turn.done) return { next: turn }
|
|
722
|
+
|
|
723
|
+
// Message boundary after a terminal finish closes the previous turn.
|
|
724
|
+
if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
|
|
725
|
+
const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
|
|
726
|
+
const next = createOpencodeTurn({
|
|
727
|
+
sessionId: row.sid,
|
|
728
|
+
directory: row.sdir,
|
|
729
|
+
title: row.stitle,
|
|
730
|
+
agent: row.sagent,
|
|
731
|
+
})
|
|
732
|
+
applyOpencodeAssistantPart(next, pd, md, row)
|
|
733
|
+
return { finalized, next }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
applyOpencodeAssistantPart(turn, pd, md, row)
|
|
737
|
+
return { next: turn }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function emitOpencodeOutcome(turn, sinks, state) {
|
|
741
|
+
if (!turn.id) return false
|
|
742
|
+
const summary = cleanText(turn.lastText)
|
|
743
|
+
if (!summary) return false
|
|
744
|
+
const outcome = turn.outcome
|
|
745
|
+
const turnToken = String(turn.id).slice(0, 8)
|
|
746
|
+
const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
|
|
747
|
+
|
|
748
|
+
sinks.trace({
|
|
749
|
+
trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
|
|
750
|
+
event_type: 'task_outcome',
|
|
751
|
+
outcome: outcome,
|
|
752
|
+
payload: {
|
|
753
|
+
host: 'opencode',
|
|
754
|
+
project: turn.project,
|
|
755
|
+
sessionId: turn.sessionId,
|
|
756
|
+
turnId: turn.id,
|
|
757
|
+
title: turn.title,
|
|
758
|
+
agent: turn.agent,
|
|
759
|
+
toolCalls: turn.toolCount,
|
|
760
|
+
errors: turn.errors.length,
|
|
761
|
+
summary: summary.slice(0, 300),
|
|
762
|
+
source: 'opencode-observer',
|
|
763
|
+
},
|
|
764
|
+
agent: 'opencode',
|
|
765
|
+
host: 'opencode',
|
|
766
|
+
project: turn.project,
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
if (outcome === 'success' && summary.length >= 10) {
|
|
770
|
+
sinks.memory({
|
|
771
|
+
kind: 'note',
|
|
772
|
+
content: '[opencode] 任务完成: ' + summary.slice(0, 160),
|
|
773
|
+
tags: ['task-outcome', 'opencode', turn.project],
|
|
774
|
+
project: turn.project,
|
|
775
|
+
source: 'opencode-observer',
|
|
776
|
+
})
|
|
777
|
+
var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
|
|
778
|
+
updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (const err of turn.errors) {
|
|
782
|
+
sinks.trace({
|
|
783
|
+
trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
|
|
784
|
+
event_type: 'error',
|
|
785
|
+
outcome: 'error',
|
|
786
|
+
payload: {
|
|
787
|
+
host: 'opencode',
|
|
788
|
+
project: turn.project,
|
|
789
|
+
sessionId: turn.sessionId,
|
|
790
|
+
turnId: turn.id,
|
|
791
|
+
tool: err.tool,
|
|
792
|
+
status: err.status,
|
|
793
|
+
},
|
|
794
|
+
agent: 'opencode',
|
|
795
|
+
host: 'opencode',
|
|
796
|
+
project: turn.project,
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
return true
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function runOpencodeCycle(home, state, sinks, opts) {
|
|
803
|
+
const out = { files: 0, lines: 0, outcomes: 0 }
|
|
804
|
+
const db = opencodeDbPath()
|
|
805
|
+
if (!fs.existsSync(db)) return out
|
|
806
|
+
let st
|
|
807
|
+
try {
|
|
808
|
+
st = fs.statSync(db)
|
|
809
|
+
} catch (_) {
|
|
810
|
+
return out
|
|
811
|
+
}
|
|
812
|
+
out.files = 1
|
|
813
|
+
const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
|
|
814
|
+
|
|
815
|
+
// First run: seed the rowid cursor to the first part written within the
|
|
816
|
+
// backfill window so ancient history never floods MIO_HOME.
|
|
817
|
+
if (!state.ocSeeded) {
|
|
818
|
+
const ts = Date.now() - maxAgeMs
|
|
819
|
+
const first = runOpencodeQuery('SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1')
|
|
820
|
+
let rid = 0
|
|
821
|
+
if (Array.isArray(first) && first.length > 0) {
|
|
822
|
+
rid = Number(first[0].rowid) || 0
|
|
823
|
+
} else {
|
|
824
|
+
const maxRow = runOpencodeQuery('SELECT COALESCE(MAX(rowid), 0) AS rid FROM part')
|
|
825
|
+
if (Array.isArray(maxRow) && maxRow.length > 0) rid = Number(maxRow[0].rid) || 0
|
|
826
|
+
}
|
|
827
|
+
state.ocLastRowid = rid
|
|
828
|
+
state.ocSeeded = true
|
|
829
|
+
state.ocDbMtime = st.mtimeMs
|
|
830
|
+
state.ocPending = true
|
|
831
|
+
return out
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
|
|
835
|
+
if (!changed && !state.ocPending) return out
|
|
836
|
+
|
|
837
|
+
const limit = 1500
|
|
838
|
+
const sql =
|
|
839
|
+
'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
|
|
840
|
+
'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
|
|
841
|
+
'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
|
|
842
|
+
'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
|
|
843
|
+
' ORDER BY p.rowid LIMIT ' + limit
|
|
844
|
+
const rows = runOpencodeQuery(sql)
|
|
845
|
+
if (!Array.isArray(rows)) return out
|
|
846
|
+
state.ocDbMtime = st.mtimeMs
|
|
847
|
+
if (rows.length === 0) {
|
|
848
|
+
state.ocPending = false
|
|
849
|
+
return out
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let lastRid = state.ocLastRowid
|
|
853
|
+
for (const row of rows) {
|
|
854
|
+
const rid = Number(row.rid)
|
|
855
|
+
if (rid > lastRid) lastRid = rid
|
|
856
|
+
out.lines += 1
|
|
857
|
+
const sid = row.sid
|
|
858
|
+
let turn = state.ocTurns[sid]
|
|
859
|
+
if (!turn) {
|
|
860
|
+
turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
|
|
861
|
+
state.ocTurns[sid] = turn
|
|
862
|
+
}
|
|
863
|
+
const result = feedOpencodePart(turn, row)
|
|
864
|
+
if (result.finalized) {
|
|
865
|
+
if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
|
|
866
|
+
}
|
|
867
|
+
state.ocTurns[sid] = result.next
|
|
868
|
+
}
|
|
869
|
+
state.ocLastRowid = lastRid
|
|
870
|
+
state.ocPending = rows.length >= limit
|
|
871
|
+
|
|
872
|
+
// Caught up: any turn holding a terminal finish is complete.
|
|
873
|
+
if (!state.ocPending) {
|
|
874
|
+
for (const sid of Object.keys(state.ocTurns)) {
|
|
875
|
+
const turn = state.ocTurns[sid]
|
|
876
|
+
if (turn && turn.id && turn.pendingTerminal && !turn.done) {
|
|
877
|
+
const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
|
|
878
|
+
if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
|
|
879
|
+
state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return out
|
|
884
|
+
}
|
|
885
|
+
|
|
555
886
|
// ---------------------------------------------------------------------------
|
|
556
887
|
// cycle
|
|
557
888
|
// ---------------------------------------------------------------------------
|
|
@@ -634,6 +965,11 @@ function runCycle(home, state, sinks, opts) {
|
|
|
634
965
|
out.lines += codexRes.lines
|
|
635
966
|
out.outcomes += codexRes.outcomes
|
|
636
967
|
|
|
968
|
+
const ocRes = runOpencodeCycle(home, state, sinks, opts)
|
|
969
|
+
out.files += ocRes.files
|
|
970
|
+
out.lines += ocRes.lines
|
|
971
|
+
out.outcomes += ocRes.outcomes
|
|
972
|
+
|
|
637
973
|
saveState(home, state)
|
|
638
974
|
return out
|
|
639
975
|
}
|
|
@@ -721,6 +1057,8 @@ module.exports = {
|
|
|
721
1057
|
saveState,
|
|
722
1058
|
updateProjectContext,
|
|
723
1059
|
runCycle,
|
|
1060
|
+
opencodeDbPath,
|
|
1061
|
+
runOpencodeCycle,
|
|
724
1062
|
createSinks,
|
|
725
1063
|
startForeground,
|
|
726
1064
|
startBackground,
|