mio-agent-runtime 0.3.4 → 0.3.6
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 +102 -79
- package/adapters/hermes.js +90 -0
- package/bin/mio.js +255 -247
- package/observe/observer.js +576 -1
- package/package.json +2 -2
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,14 @@ 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 || {},
|
|
86
|
+
hmSeeded: parsed.hmSeeded || false,
|
|
87
|
+
hmLastMsgId: parsed.hmLastMsgId || 0,
|
|
88
|
+
hmTurns: parsed.hmTurns || {},
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
91
|
} catch (_) {}
|
|
@@ -90,6 +98,14 @@ function loadState(home) {
|
|
|
90
98
|
codexFiles: {},
|
|
91
99
|
codexBuffers: {},
|
|
92
100
|
codexTurns: {},
|
|
101
|
+
ocSeeded: false,
|
|
102
|
+
ocLastRowid: 0,
|
|
103
|
+
ocDbMtime: null,
|
|
104
|
+
ocPending: false,
|
|
105
|
+
ocTurns: {},
|
|
106
|
+
hmSeeded: false,
|
|
107
|
+
hmLastMsgId: 0,
|
|
108
|
+
hmTurns: {},
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
|
|
@@ -552,6 +568,551 @@ function runCodexCycle(home, state, sinks, opts) {
|
|
|
552
568
|
return out
|
|
553
569
|
}
|
|
554
570
|
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// OpenCode session observation (passive, via `opencode db`)
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
// OpenCode stores everything in ~/.local/share/opencode/opencode.db (SQLite).
|
|
576
|
+
// On Windows the db file is held with an exclusive lock while OpenCode runs,
|
|
577
|
+
// so we cannot open it with node:sqlite directly. Instead we shell out to the
|
|
578
|
+
// `opencode db` CLI, which opens the same database through the app itself and
|
|
579
|
+
// works even while OpenCode is running. We tail `part` rows by rowid so each
|
|
580
|
+
// session is scanned incrementally and only text/tool parts are transferred.
|
|
581
|
+
|
|
582
|
+
function opencodeDataDir() {
|
|
583
|
+
return (
|
|
584
|
+
process.env.OPENCODE_DATA_DIR ||
|
|
585
|
+
path.join(os.homedir(), '.local', 'share', 'opencode')
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function opencodeDbPath() {
|
|
590
|
+
return path.join(opencodeDataDir(), 'opencode.db')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function findOpencodeBin() {
|
|
594
|
+
if (process.env.OPENCODE_BIN && fs.existsSync(process.env.OPENCODE_BIN)) {
|
|
595
|
+
return process.env.OPENCODE_BIN
|
|
596
|
+
}
|
|
597
|
+
const exeDir = path.dirname(process.execPath)
|
|
598
|
+
const candidates = [
|
|
599
|
+
path.join(exeDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
600
|
+
path.join(exeDir, '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
601
|
+
path.join(exeDir, '..', '..', 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
602
|
+
]
|
|
603
|
+
for (const c of candidates) {
|
|
604
|
+
const resolved = path.resolve(c)
|
|
605
|
+
if (fs.existsSync(resolved)) return resolved
|
|
606
|
+
}
|
|
607
|
+
return null
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function runOpencodeQuery(sql) {
|
|
611
|
+
const bin = findOpencodeBin()
|
|
612
|
+
const args = ['db', sql, '--format', 'json']
|
|
613
|
+
let res
|
|
614
|
+
try {
|
|
615
|
+
res = bin
|
|
616
|
+
? spawnSync(bin, args, { encoding: 'utf8', timeout: 90000, windowsHide: true, maxBuffer: 256 * 1024 * 1024 })
|
|
617
|
+
: spawnSync('opencode', args, { encoding: 'utf8', timeout: 90000, shell: true, maxBuffer: 256 * 1024 * 1024 })
|
|
618
|
+
} catch (_) {
|
|
619
|
+
return null
|
|
620
|
+
}
|
|
621
|
+
if (res.error || res.status !== 0 || !res.stdout) return null
|
|
622
|
+
try {
|
|
623
|
+
return JSON.parse(res.stdout)
|
|
624
|
+
} catch (_) {
|
|
625
|
+
return null
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Only transfer text and tool parts; skip reasoning/step bookkeeping rows to
|
|
630
|
+
// keep the transferred payload small.
|
|
631
|
+
const OPENCODE_PART_FILTER = " AND (p.data LIKE '%\"type\":\"text\"%' OR p.data LIKE '%\"type\":\"tool\"%')"
|
|
632
|
+
|
|
633
|
+
function createOpencodeTurn(meta) {
|
|
634
|
+
meta = meta || {}
|
|
635
|
+
return {
|
|
636
|
+
id: null,
|
|
637
|
+
ts: null,
|
|
638
|
+
sessionId: meta.sessionId || null,
|
|
639
|
+
cwd: meta.directory || null,
|
|
640
|
+
project: meta.directory ? path.basename(meta.directory) : 'unknown',
|
|
641
|
+
agent: meta.agent || null,
|
|
642
|
+
title: meta.title || null,
|
|
643
|
+
lastMid: null,
|
|
644
|
+
lastText: null,
|
|
645
|
+
toolCount: 0,
|
|
646
|
+
errors: [],
|
|
647
|
+
pendingTerminal: null,
|
|
648
|
+
done: false,
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function opencodeOutcomeFor(finish) {
|
|
653
|
+
if (finish === 'error') return 'failure'
|
|
654
|
+
if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
|
|
655
|
+
return 'success'
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function finalizeOpencodeTurn(turn, outcome) {
|
|
659
|
+
if (!turn.id) return null
|
|
660
|
+
const finalized = {
|
|
661
|
+
id: turn.id,
|
|
662
|
+
ts: turn.ts,
|
|
663
|
+
sessionId: turn.sessionId,
|
|
664
|
+
cwd: turn.cwd,
|
|
665
|
+
project: turn.project,
|
|
666
|
+
agent: turn.agent,
|
|
667
|
+
title: turn.title,
|
|
668
|
+
lastText: turn.lastText,
|
|
669
|
+
toolCount: turn.toolCount,
|
|
670
|
+
errors: turn.errors.slice(),
|
|
671
|
+
outcome: outcome,
|
|
672
|
+
}
|
|
673
|
+
turn.done = true
|
|
674
|
+
return finalized
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function applyOpencodeAssistantPart(turn, pd, md, row) {
|
|
678
|
+
if (pd.type === 'text' && typeof pd.text === 'string' && pd.text.trim()) {
|
|
679
|
+
turn.lastText = pd.text
|
|
680
|
+
} else if (pd.type === 'tool') {
|
|
681
|
+
turn.toolCount += 1
|
|
682
|
+
const status = pd.state && pd.state.status
|
|
683
|
+
if (status && status !== 'completed' && status !== 'ok' && status !== 'success' && status !== 'running') {
|
|
684
|
+
turn.errors.push({ tool: pd.tool || 'unknown', status: status })
|
|
685
|
+
if (turn.errors.length > 20) turn.errors.shift()
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (md.finish && md.finish !== 'tool-calls') {
|
|
689
|
+
turn.pendingTerminal = md.finish
|
|
690
|
+
}
|
|
691
|
+
turn.lastMid = row.mid
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function feedOpencodePart(turn, row) {
|
|
695
|
+
let pd
|
|
696
|
+
let md
|
|
697
|
+
try {
|
|
698
|
+
pd = JSON.parse(row.pd)
|
|
699
|
+
} catch (_) {
|
|
700
|
+
pd = null
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
md = JSON.parse(row.md)
|
|
704
|
+
} catch (_) {
|
|
705
|
+
md = null
|
|
706
|
+
}
|
|
707
|
+
if (!pd || !md) return { next: turn }
|
|
708
|
+
|
|
709
|
+
// A user message starts a new turn; any unfinished previous turn is aborted.
|
|
710
|
+
if (md.role === 'user') {
|
|
711
|
+
let finalized = null
|
|
712
|
+
if (turn.id && !turn.done) {
|
|
713
|
+
finalized = finalizeOpencodeTurn(turn, turn.pendingTerminal ? opencodeOutcomeFor(turn.pendingTerminal) : 'aborted')
|
|
714
|
+
}
|
|
715
|
+
const next = createOpencodeTurn({
|
|
716
|
+
sessionId: row.sid,
|
|
717
|
+
directory: row.sdir,
|
|
718
|
+
title: row.stitle,
|
|
719
|
+
agent: row.sagent,
|
|
720
|
+
})
|
|
721
|
+
next.id = row.mid
|
|
722
|
+
next.ts = Number(row.t) || Date.now()
|
|
723
|
+
next.lastMid = row.mid
|
|
724
|
+
return { finalized, next }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (turn.done) return { next: turn }
|
|
728
|
+
|
|
729
|
+
// Message boundary after a terminal finish closes the previous turn.
|
|
730
|
+
if (turn.lastMid && row.mid !== turn.lastMid && turn.pendingTerminal) {
|
|
731
|
+
const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
|
|
732
|
+
const next = createOpencodeTurn({
|
|
733
|
+
sessionId: row.sid,
|
|
734
|
+
directory: row.sdir,
|
|
735
|
+
title: row.stitle,
|
|
736
|
+
agent: row.sagent,
|
|
737
|
+
})
|
|
738
|
+
applyOpencodeAssistantPart(next, pd, md, row)
|
|
739
|
+
return { finalized, next }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
applyOpencodeAssistantPart(turn, pd, md, row)
|
|
743
|
+
return { next: turn }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function emitOpencodeOutcome(turn, sinks, state) {
|
|
747
|
+
if (!turn.id) return false
|
|
748
|
+
const summary = cleanText(turn.lastText)
|
|
749
|
+
if (!summary) return false
|
|
750
|
+
const outcome = turn.outcome
|
|
751
|
+
const turnToken = String(turn.id).slice(0, 8)
|
|
752
|
+
const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
|
|
753
|
+
|
|
754
|
+
sinks.trace({
|
|
755
|
+
trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + tsToken,
|
|
756
|
+
event_type: 'task_outcome',
|
|
757
|
+
outcome: outcome,
|
|
758
|
+
payload: {
|
|
759
|
+
host: 'opencode',
|
|
760
|
+
project: turn.project,
|
|
761
|
+
sessionId: turn.sessionId,
|
|
762
|
+
turnId: turn.id,
|
|
763
|
+
title: turn.title,
|
|
764
|
+
agent: turn.agent,
|
|
765
|
+
toolCalls: turn.toolCount,
|
|
766
|
+
errors: turn.errors.length,
|
|
767
|
+
summary: summary.slice(0, 300),
|
|
768
|
+
source: 'opencode-observer',
|
|
769
|
+
},
|
|
770
|
+
agent: 'opencode',
|
|
771
|
+
host: 'opencode',
|
|
772
|
+
project: turn.project,
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
if (outcome === 'success' && summary.length >= 10) {
|
|
776
|
+
sinks.memory({
|
|
777
|
+
kind: 'note',
|
|
778
|
+
content: '[opencode] 任务完成: ' + summary.slice(0, 160),
|
|
779
|
+
tags: ['task-outcome', 'opencode', turn.project],
|
|
780
|
+
project: turn.project,
|
|
781
|
+
source: 'opencode-observer',
|
|
782
|
+
})
|
|
783
|
+
var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
|
|
784
|
+
updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
for (const err of turn.errors) {
|
|
788
|
+
sinks.trace({
|
|
789
|
+
trace_id: 'opencode:' + (turn.sessionId || 'unknown') + ':' + turnToken + ':' + String(err.tool || 'err').slice(0, 12),
|
|
790
|
+
event_type: 'error',
|
|
791
|
+
outcome: 'error',
|
|
792
|
+
payload: {
|
|
793
|
+
host: 'opencode',
|
|
794
|
+
project: turn.project,
|
|
795
|
+
sessionId: turn.sessionId,
|
|
796
|
+
turnId: turn.id,
|
|
797
|
+
tool: err.tool,
|
|
798
|
+
status: err.status,
|
|
799
|
+
},
|
|
800
|
+
agent: 'opencode',
|
|
801
|
+
host: 'opencode',
|
|
802
|
+
project: turn.project,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
return true
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function runOpencodeCycle(home, state, sinks, opts) {
|
|
809
|
+
const out = { files: 0, lines: 0, outcomes: 0 }
|
|
810
|
+
const db = opencodeDbPath()
|
|
811
|
+
if (!fs.existsSync(db)) return out
|
|
812
|
+
let st
|
|
813
|
+
try {
|
|
814
|
+
st = fs.statSync(db)
|
|
815
|
+
} catch (_) {
|
|
816
|
+
return out
|
|
817
|
+
}
|
|
818
|
+
out.files = 1
|
|
819
|
+
const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
|
|
820
|
+
|
|
821
|
+
// First run: seed the rowid cursor to the first part written within the
|
|
822
|
+
// backfill window so ancient history never floods MIO_HOME.
|
|
823
|
+
if (!state.ocSeeded) {
|
|
824
|
+
const ts = Date.now() - maxAgeMs
|
|
825
|
+
const first = runOpencodeQuery('SELECT rowid FROM part WHERE time_created >= ' + ts + ' ORDER BY rowid LIMIT 1')
|
|
826
|
+
let rid = 0
|
|
827
|
+
if (Array.isArray(first) && first.length > 0) {
|
|
828
|
+
rid = Number(first[0].rowid) || 0
|
|
829
|
+
} else {
|
|
830
|
+
const maxRow = runOpencodeQuery('SELECT COALESCE(MAX(rowid), 0) AS rid FROM part')
|
|
831
|
+
if (Array.isArray(maxRow) && maxRow.length > 0) rid = Number(maxRow[0].rid) || 0
|
|
832
|
+
}
|
|
833
|
+
state.ocLastRowid = rid
|
|
834
|
+
state.ocSeeded = true
|
|
835
|
+
state.ocDbMtime = st.mtimeMs
|
|
836
|
+
state.ocPending = true
|
|
837
|
+
return out
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const changed = state.ocDbMtime === null || state.ocDbMtime !== st.mtimeMs
|
|
841
|
+
if (!changed && !state.ocPending) return out
|
|
842
|
+
|
|
843
|
+
const limit = 1500
|
|
844
|
+
const sql =
|
|
845
|
+
'SELECT p.rowid AS rid, p.session_id AS sid, p.message_id AS mid, p.time_created AS t, ' +
|
|
846
|
+
'p.data AS pd, m.data AS md, s.directory AS sdir, s.title AS stitle, s.agent AS sagent ' +
|
|
847
|
+
'FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = p.session_id ' +
|
|
848
|
+
'WHERE p.rowid > ' + state.ocLastRowid + OPENCODE_PART_FILTER +
|
|
849
|
+
' ORDER BY p.rowid LIMIT ' + limit
|
|
850
|
+
const rows = runOpencodeQuery(sql)
|
|
851
|
+
if (!Array.isArray(rows)) return out
|
|
852
|
+
state.ocDbMtime = st.mtimeMs
|
|
853
|
+
if (rows.length === 0) {
|
|
854
|
+
state.ocPending = false
|
|
855
|
+
return out
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
let lastRid = state.ocLastRowid
|
|
859
|
+
for (const row of rows) {
|
|
860
|
+
const rid = Number(row.rid)
|
|
861
|
+
if (rid > lastRid) lastRid = rid
|
|
862
|
+
out.lines += 1
|
|
863
|
+
const sid = row.sid
|
|
864
|
+
let turn = state.ocTurns[sid]
|
|
865
|
+
if (!turn) {
|
|
866
|
+
turn = createOpencodeTurn({ sessionId: sid, directory: row.sdir, title: row.stitle, agent: row.sagent })
|
|
867
|
+
state.ocTurns[sid] = turn
|
|
868
|
+
}
|
|
869
|
+
const result = feedOpencodePart(turn, row)
|
|
870
|
+
if (result.finalized) {
|
|
871
|
+
if (emitOpencodeOutcome(result.finalized, sinks, state)) out.outcomes += 1
|
|
872
|
+
}
|
|
873
|
+
state.ocTurns[sid] = result.next
|
|
874
|
+
}
|
|
875
|
+
state.ocLastRowid = lastRid
|
|
876
|
+
state.ocPending = rows.length >= limit
|
|
877
|
+
|
|
878
|
+
// Caught up: any turn holding a terminal finish is complete.
|
|
879
|
+
if (!state.ocPending) {
|
|
880
|
+
for (const sid of Object.keys(state.ocTurns)) {
|
|
881
|
+
const turn = state.ocTurns[sid]
|
|
882
|
+
if (turn && turn.id && turn.pendingTerminal && !turn.done) {
|
|
883
|
+
const finalized = finalizeOpencodeTurn(turn, opencodeOutcomeFor(turn.pendingTerminal))
|
|
884
|
+
if (finalized && emitOpencodeOutcome(finalized, sinks, state)) out.outcomes += 1
|
|
885
|
+
state.ocTurns[sid] = createOpencodeTurn({ sessionId: sid, directory: turn.cwd, title: turn.title, agent: turn.agent })
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return out
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Hermes session observation (passive, via its own SQLite state.db)
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
// Hermes (Nous Research hermes-agent) keeps every conversation in
|
|
897
|
+
// <LocalAppData>/hermes/state.db. Unlike OpenCode the file is not exclusively
|
|
898
|
+
// locked, and `messages.id` is a monotonic AUTOINCREMENT cursor, so we can tail
|
|
899
|
+
// it read-only with node:sqlite (Node 22+). Each message is one row carrying
|
|
900
|
+
// role / content / finish_reason, so a user row opens a turn and an assistant
|
|
901
|
+
// row with a terminal finish_reason closes it immediately. Hermes already has
|
|
902
|
+
// native memory, so this observer only imports Hermes experience into the Mio
|
|
903
|
+
// ecosystem (traces + memory + AGENTS.md context when a git repo is present).
|
|
904
|
+
|
|
905
|
+
let _hermesSqlite = null
|
|
906
|
+
let _hermesSqliteTried = false
|
|
907
|
+
|
|
908
|
+
function hermesSqliteModule() {
|
|
909
|
+
if (_hermesSqliteTried) return _hermesSqlite
|
|
910
|
+
_hermesSqliteTried = true
|
|
911
|
+
try {
|
|
912
|
+
_hermesSqlite = require('node:sqlite')
|
|
913
|
+
} catch (_) {
|
|
914
|
+
_hermesSqlite = null
|
|
915
|
+
}
|
|
916
|
+
return _hermesSqlite
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function hermesDbPath() {
|
|
920
|
+
return (
|
|
921
|
+
process.env.HERMES_DATA_DIR ||
|
|
922
|
+
path.join(os.homedir(), 'AppData', 'Local', 'hermes', 'state.db')
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function hermesProjectName(meta) {
|
|
927
|
+
if (meta.git) return path.basename(meta.git)
|
|
928
|
+
if (meta.cwd && meta.cwd !== os.homedir()) return path.basename(meta.cwd)
|
|
929
|
+
return 'hermes'
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function createHermesTurn(meta) {
|
|
933
|
+
meta = meta || {}
|
|
934
|
+
return {
|
|
935
|
+
id: null,
|
|
936
|
+
ts: null,
|
|
937
|
+
sessionId: meta.sessionId || null,
|
|
938
|
+
cwd: meta.git || null,
|
|
939
|
+
project: hermesProjectName({ git: meta.git, cwd: meta.projectHint }),
|
|
940
|
+
title: meta.title || null,
|
|
941
|
+
agent: meta.agent || null,
|
|
942
|
+
lastText: null,
|
|
943
|
+
toolCount: 0,
|
|
944
|
+
lastMid: null,
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function hermesOutcomeFor(finish) {
|
|
949
|
+
if (finish === 'error' || finish === 'failure') return 'failure'
|
|
950
|
+
if (finish === 'cancel' || finish === 'aborted' || finish === 'interrupted') return 'aborted'
|
|
951
|
+
return 'success'
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function finalizeHermesTurn(turn, outcome) {
|
|
955
|
+
if (!turn.id) return null
|
|
956
|
+
const finalized = {
|
|
957
|
+
id: turn.id,
|
|
958
|
+
ts: turn.ts,
|
|
959
|
+
sessionId: turn.sessionId,
|
|
960
|
+
cwd: turn.cwd,
|
|
961
|
+
project: turn.project,
|
|
962
|
+
agent: turn.agent,
|
|
963
|
+
title: turn.title,
|
|
964
|
+
lastText: turn.lastText,
|
|
965
|
+
toolCount: turn.toolCount,
|
|
966
|
+
errors: [],
|
|
967
|
+
outcome: outcome,
|
|
968
|
+
}
|
|
969
|
+
return finalized
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function feedHermesRow(turn, row) {
|
|
973
|
+
const meta = { sessionId: row.sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd }
|
|
974
|
+
|
|
975
|
+
if (row.role === 'user') {
|
|
976
|
+
let finalized = null
|
|
977
|
+
if (turn.id) finalized = finalizeHermesTurn(turn, 'aborted')
|
|
978
|
+
const next = createHermesTurn(meta)
|
|
979
|
+
next.id = Number(row.id)
|
|
980
|
+
next.ts = Math.floor(Number(row.t) * 1000) || Date.now()
|
|
981
|
+
next.lastMid = Number(row.id)
|
|
982
|
+
return { finalized, next }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (row.role === 'assistant') {
|
|
986
|
+
if (!turn.id) return { next: turn }
|
|
987
|
+
if (typeof row.content === 'string' && row.content.trim()) {
|
|
988
|
+
turn.lastText = row.content
|
|
989
|
+
}
|
|
990
|
+
if (row.tool_calls) {
|
|
991
|
+
try {
|
|
992
|
+
const calls = JSON.parse(row.tool_calls)
|
|
993
|
+
if (Array.isArray(calls)) turn.toolCount += calls.length
|
|
994
|
+
} catch (_) {}
|
|
995
|
+
}
|
|
996
|
+
if (row.finish_reason && row.finish_reason !== 'tool_calls') {
|
|
997
|
+
const finalized = finalizeHermesTurn(turn, hermesOutcomeFor(row.finish_reason))
|
|
998
|
+
return { finalized, next: createHermesTurn(meta) }
|
|
999
|
+
}
|
|
1000
|
+
turn.lastMid = Number(row.id)
|
|
1001
|
+
return { next: turn }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { next: turn }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function emitHermesOutcome(turn, sinks, state) {
|
|
1008
|
+
if (!turn.id) return false
|
|
1009
|
+
const summary = cleanText(turn.lastText)
|
|
1010
|
+
if (!summary) return false
|
|
1011
|
+
const outcome = turn.outcome
|
|
1012
|
+
const tsToken = turn.ts ? Math.floor(turn.ts / 1000) : Math.floor(Date.now() / 1000)
|
|
1013
|
+
|
|
1014
|
+
sinks.trace({
|
|
1015
|
+
trace_id: 'hermes:' + (turn.sessionId || 'unknown') + ':' + String(turn.id) + ':' + tsToken,
|
|
1016
|
+
event_type: 'task_outcome',
|
|
1017
|
+
outcome: outcome,
|
|
1018
|
+
payload: {
|
|
1019
|
+
host: 'hermes',
|
|
1020
|
+
project: turn.project,
|
|
1021
|
+
sessionId: turn.sessionId,
|
|
1022
|
+
turnId: turn.id,
|
|
1023
|
+
title: turn.title,
|
|
1024
|
+
agent: turn.agent,
|
|
1025
|
+
toolCalls: turn.toolCount,
|
|
1026
|
+
errors: 0,
|
|
1027
|
+
summary: summary.slice(0, 300),
|
|
1028
|
+
source: 'hermes-observer',
|
|
1029
|
+
},
|
|
1030
|
+
agent: 'hermes',
|
|
1031
|
+
host: 'hermes',
|
|
1032
|
+
project: turn.project,
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
if (outcome === 'success' && summary.length >= 10) {
|
|
1036
|
+
sinks.memory({
|
|
1037
|
+
kind: 'note',
|
|
1038
|
+
content: '[hermes] 任务完成: ' + summary.slice(0, 160),
|
|
1039
|
+
tags: ['task-outcome', 'hermes', turn.project],
|
|
1040
|
+
project: turn.project,
|
|
1041
|
+
source: 'hermes-observer',
|
|
1042
|
+
})
|
|
1043
|
+
// Hermes reads AGENTS.md Context Files; write back only into a real repo
|
|
1044
|
+
// workspace so we never pollute the user's home directory.
|
|
1045
|
+
if (turn.cwd) {
|
|
1046
|
+
var ctxTime = new Date(Number(turn.ts) || Date.now()).toLocaleString('zh-CN', { hour12: false })
|
|
1047
|
+
updateProjectContext(state, turn.cwd, ctxTime + ' | ' + outcome + ' | ' + summary.slice(0, 80))
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return true
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function runHermesCycle(home, state, sinks, opts) {
|
|
1054
|
+
const out = { files: 0, lines: 0, outcomes: 0 }
|
|
1055
|
+
const dbPath = hermesDbPath()
|
|
1056
|
+
if (!fs.existsSync(dbPath)) return out
|
|
1057
|
+
const sqlite = hermesSqliteModule()
|
|
1058
|
+
if (!sqlite) return out
|
|
1059
|
+
let db
|
|
1060
|
+
try {
|
|
1061
|
+
db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
|
|
1062
|
+
} catch (_) {
|
|
1063
|
+
return out
|
|
1064
|
+
}
|
|
1065
|
+
out.files = 1
|
|
1066
|
+
const maxAgeMs = (opts && opts.maxAgeMs) || 24 * 60 * 60 * 1000
|
|
1067
|
+
try {
|
|
1068
|
+
if (!state.hmSeeded) {
|
|
1069
|
+
const cutoff = (Date.now() - maxAgeMs) / 1000
|
|
1070
|
+
const first = db.prepare('SELECT id FROM messages WHERE active = 1 AND timestamp >= ? ORDER BY id LIMIT 1').get(cutoff)
|
|
1071
|
+
if (first) {
|
|
1072
|
+
state.hmLastMsgId = first.id
|
|
1073
|
+
} else {
|
|
1074
|
+
const maxRow = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages').get()
|
|
1075
|
+
state.hmLastMsgId = maxRow.id
|
|
1076
|
+
}
|
|
1077
|
+
state.hmSeeded = true
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const limit = 2000
|
|
1081
|
+
const stmt = db.prepare(
|
|
1082
|
+
'SELECT m.id AS id, m.session_id AS sid, m.role AS role, m.content AS content, ' +
|
|
1083
|
+
'm.tool_name AS tool_name, m.tool_calls AS tool_calls, m.timestamp AS t, m.finish_reason AS finish_reason, ' +
|
|
1084
|
+
's.title AS stitle, s.cwd AS scwd, s.git_repo_root AS sgit, s.source AS ssource ' +
|
|
1085
|
+
'FROM messages m LEFT JOIN sessions s ON s.id = m.session_id ' +
|
|
1086
|
+
'WHERE m.id > ? AND m.active = 1 ORDER BY m.id LIMIT ' + limit
|
|
1087
|
+
)
|
|
1088
|
+
const rows = stmt.all(state.hmLastMsgId)
|
|
1089
|
+
if (rows.length === 0) return out
|
|
1090
|
+
|
|
1091
|
+
let lastId = state.hmLastMsgId
|
|
1092
|
+
for (const row of rows) {
|
|
1093
|
+
const rid = Number(row.id)
|
|
1094
|
+
if (rid > lastId) lastId = rid
|
|
1095
|
+
out.lines += 1
|
|
1096
|
+
const sid = row.sid
|
|
1097
|
+
let turn = state.hmTurns[sid]
|
|
1098
|
+
if (!turn) {
|
|
1099
|
+
turn = createHermesTurn({ sessionId: sid, title: row.stitle, git: row.sgit, agent: row.ssource, projectHint: row.scwd })
|
|
1100
|
+
state.hmTurns[sid] = turn
|
|
1101
|
+
}
|
|
1102
|
+
const result = feedHermesRow(turn, row)
|
|
1103
|
+
if (result.finalized) {
|
|
1104
|
+
if (emitHermesOutcome(result.finalized, sinks, state)) out.outcomes += 1
|
|
1105
|
+
}
|
|
1106
|
+
state.hmTurns[sid] = result.next
|
|
1107
|
+
}
|
|
1108
|
+
state.hmLastMsgId = lastId
|
|
1109
|
+
} finally {
|
|
1110
|
+
db.close()
|
|
1111
|
+
}
|
|
1112
|
+
return out
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
555
1116
|
// ---------------------------------------------------------------------------
|
|
556
1117
|
// cycle
|
|
557
1118
|
// ---------------------------------------------------------------------------
|
|
@@ -634,6 +1195,16 @@ function runCycle(home, state, sinks, opts) {
|
|
|
634
1195
|
out.lines += codexRes.lines
|
|
635
1196
|
out.outcomes += codexRes.outcomes
|
|
636
1197
|
|
|
1198
|
+
const ocRes = runOpencodeCycle(home, state, sinks, opts)
|
|
1199
|
+
out.files += ocRes.files
|
|
1200
|
+
out.lines += ocRes.lines
|
|
1201
|
+
out.outcomes += ocRes.outcomes
|
|
1202
|
+
|
|
1203
|
+
const hmRes = runHermesCycle(home, state, sinks, opts)
|
|
1204
|
+
out.files += hmRes.files
|
|
1205
|
+
out.lines += hmRes.lines
|
|
1206
|
+
out.outcomes += hmRes.outcomes
|
|
1207
|
+
|
|
637
1208
|
saveState(home, state)
|
|
638
1209
|
return out
|
|
639
1210
|
}
|
|
@@ -721,6 +1292,10 @@ module.exports = {
|
|
|
721
1292
|
saveState,
|
|
722
1293
|
updateProjectContext,
|
|
723
1294
|
runCycle,
|
|
1295
|
+
opencodeDbPath,
|
|
1296
|
+
runOpencodeCycle,
|
|
1297
|
+
runHermesCycle,
|
|
1298
|
+
hermesDbPath,
|
|
724
1299
|
createSinks,
|
|
725
1300
|
startForeground,
|
|
726
1301
|
startBackground,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mio-agent-runtime",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Mio Agent Runtime installer and MCP intelligence layer",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"README.md"
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
|
-
"check": "node --check bin/mio.js && node --check adapters/codex.js && node --check adapters/opencode.js && node --check adapters/workbuddy.js && node --check server/mio-intelligence-mcp/index.js && node --check observe/observer.js",
|
|
18
|
+
"check": "node --check bin/mio.js && node --check adapters/codex.js && node --check adapters/opencode.js && node --check adapters/workbuddy.js && node --check adapters/hermes.js && node --check server/mio-intelligence-mcp/index.js && node --check observe/observer.js",
|
|
19
19
|
"prepack": "npm run check"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|