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.
@@ -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.4",
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": {