mio-agent-runtime 0.5.23 → 0.7.0

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.
@@ -0,0 +1,228 @@
1
+ 'use strict'
2
+
3
+ // Shared observer research-pipeline store: the single implementation behind the
4
+ // mio.observer.* MCP tools and the `mio observer` CLI command. Keeping it here
5
+ // rather than inlined in the MCP server means both entry points read the same
6
+ // files the same way and cannot drift -- same rationale as memory-store.js,
7
+ // experience-store.js and policy-store.js.
8
+ //
9
+ // Default base directory matches the MCP server: <cwd>/.local/observer. This is
10
+ // deliberately project-local (unlike the MIO_HOME-backed stores) because the
11
+ // research pipeline writes into the project it observes; both the CLI flag
12
+ // --base-dir and the MCP argument baseDir override it.
13
+
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+
17
+ // Observer research pipeline (optional -- installed via @akemi-mio/observer).
18
+ // Loaded lazily at module level so the CLI can report availability without the
19
+ // MCP server having to pass its own handles down.
20
+ let ObserverStore = null
21
+ let ObserverService = null
22
+ try {
23
+ const obs = require('@akemi-mio/observer')
24
+ ObserverStore = obs.ObserverStore
25
+ ObserverService = obs.ObserverService
26
+ } catch {}
27
+
28
+ // Subdirectories that make up the pipeline. `observerStatus` counts files in
29
+ // each, so adding a stage here also makes it show up in the status output.
30
+ const OBSERVER_SUBDIRS = [
31
+ 'observations',
32
+ 'trends',
33
+ 'topics',
34
+ 'research',
35
+ 'insights',
36
+ 'world_model',
37
+ 'essays',
38
+ ]
39
+
40
+ function isObserverAvailable() {
41
+ return Boolean(ObserverStore)
42
+ }
43
+
44
+ function readJsonSafe(filePath) {
45
+ if (!fs.existsSync(filePath)) return null
46
+ try {
47
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function readJsonlSafe(filePath) {
54
+ if (!fs.existsSync(filePath)) return []
55
+ try {
56
+ return fs
57
+ .readFileSync(filePath, 'utf8')
58
+ .trim()
59
+ .split('\n')
60
+ .filter(Boolean)
61
+ .map((l) => {
62
+ try {
63
+ return JSON.parse(l)
64
+ } catch {
65
+ return null
66
+ }
67
+ })
68
+ .filter(Boolean)
69
+ } catch {
70
+ return []
71
+ }
72
+ }
73
+
74
+ // Reads a directory of per-item JSON files, newest file name first.
75
+ function readJsonDir(dirPath, limit) {
76
+ if (!fs.existsSync(dirPath)) return []
77
+ const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.json')).sort().reverse()
78
+ return files.slice(0, limit).map((f) => readJsonSafe(path.join(dirPath, f))).filter(Boolean)
79
+ }
80
+
81
+ function createObserverStore(options = {}) {
82
+ const defaultBaseDir = options.baseDir || path.join(process.cwd(), '.local', 'observer')
83
+
84
+ // Per-call args win over the store default, matching the MCP handler contract
85
+ // where the caller may pass baseDir on any tool call.
86
+ function baseDirOf(args = {}) {
87
+ return args.baseDir || defaultBaseDir
88
+ }
89
+
90
+ function status(args = {}) {
91
+ const baseDir = baseDirOf(args)
92
+ const result = {}
93
+ for (const d of OBSERVER_SUBDIRS) {
94
+ const dir = path.join(baseDir, d)
95
+ result[d] = fs.existsSync(dir)
96
+ ? fs.readdirSync(dir).filter((f) => !f.startsWith('.')).length
97
+ : 0
98
+ }
99
+ return result
100
+ }
101
+
102
+ function worldModel(args = {}) {
103
+ const baseDir = baseDirOf(args)
104
+ const wmDir = path.join(baseDir, 'world_model')
105
+ return {
106
+ entities: readJsonSafe(path.join(wmDir, 'entities.json')) || [],
107
+ events: readJsonSafe(path.join(wmDir, 'events.json')) || [],
108
+ trends: readJsonSafe(path.join(wmDir, 'trends.json')) || [],
109
+ narratives: readJsonSafe(path.join(wmDir, 'narratives.json')) || [],
110
+ }
111
+ }
112
+
113
+ function trends(args = {}) {
114
+ const baseDir = baseDirOf(args)
115
+ const trendsDir = path.join(baseDir, 'trends')
116
+ if (args.date) {
117
+ const report = readJsonSafe(path.join(trendsDir, `${args.date}.json`))
118
+ return report ? [report] : []
119
+ }
120
+ return readJsonDir(trendsDir, args.limit || 7)
121
+ }
122
+
123
+ function research(args = {}) {
124
+ return readJsonDir(path.join(baseDirOf(args), 'research'), args.limit || 10)
125
+ }
126
+
127
+ function insights(args = {}) {
128
+ return readJsonDir(path.join(baseDirOf(args), 'insights'), args.limit || 20)
129
+ }
130
+
131
+ function essays(args = {}) {
132
+ const baseDir = baseDirOf(args)
133
+ const type = args.type || 'published'
134
+ const essaysDir = path.join(baseDir, 'essays', type)
135
+ if (!fs.existsSync(essaysDir)) return []
136
+ const files = fs.readdirSync(essaysDir).filter((f) => f.endsWith('.md')).sort().reverse()
137
+ return files.slice(0, args.limit || 10).map((f) => {
138
+ const full = path.join(essaysDir, f)
139
+ return {
140
+ file: f,
141
+ type,
142
+ content: fs.readFileSync(full, 'utf8'),
143
+ created: fs.statSync(full).birthtime.toISOString(),
144
+ }
145
+ })
146
+ }
147
+
148
+ // Daily summaries for the last N days, used by the DAG view.
149
+ function dag(args = {}) {
150
+ const baseDir = baseDirOf(args)
151
+ const days = args.days || 7
152
+ const summaries = []
153
+ const todayDate = new Date()
154
+ for (let i = 0; i < days; i++) {
155
+ const d = new Date(todayDate)
156
+ d.setDate(d.getDate() - i)
157
+ const dateStr = d.toISOString().slice(0, 10)
158
+ const items = readJsonlSafe(path.join(baseDir, 'summaries', `${dateStr}.jsonl`))
159
+ if (items.length) summaries.push({ date: dateStr, summaries: items })
160
+ }
161
+ return { summaries, summaryCount: summaries.reduce((a, s) => a + s.summaries.length, 0) }
162
+ }
163
+
164
+ // ObserverService.collectBySource and FermentationEngine.ferment are both
165
+ // async. They must be awaited here: a Promise is `typeof 'object'` with no
166
+ // own enumerable keys, so the previous synchronous version of collect()
167
+ // happily "iterated" one and pushed nothing -- every call reported
168
+ // `collected: 0` no matter how much the collectors actually returned.
169
+ async function collect(args = {}) {
170
+ if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
171
+ const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
172
+ const sources = args.sources || ['bilibili', 'hackernews', 'github', 'douyin', 'rss']
173
+ const allObs = []
174
+ for (const src of sources) {
175
+ try {
176
+ const collectFn = service.collectBySource || service.collect
177
+ if (!collectFn) {
178
+ throw new Error('ObserverService exposes neither collectBySource nor collect')
179
+ }
180
+ const result = await collectFn.call(service, [src], args.keywords || [], args.limit || 20)
181
+ if (Array.isArray(result)) allObs.push(...result)
182
+ else if (result && typeof result === 'object') {
183
+ // Record<sourceName, Observation[]>: keep every list, in key order.
184
+ for (const items of Object.values(result)) {
185
+ if (Array.isArray(items)) allObs.push(...items)
186
+ }
187
+ }
188
+ } catch (e) {
189
+ allObs.push({ source: src, error: e.message })
190
+ }
191
+ }
192
+ return { collected: allObs.length, observations: allObs }
193
+ }
194
+
195
+ async function ferment(args = {}) {
196
+ if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
197
+ const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
198
+ const session = args.session || 'afternoon'
199
+ try {
200
+ const fermentation = service.getFermentation()
201
+ if (fermentation && fermentation.ferment) return await fermentation.ferment(session)
202
+ return { status: 'fermentation engine available but no ferment method' }
203
+ } catch (e) {
204
+ return { error: e.message }
205
+ }
206
+ }
207
+
208
+ return {
209
+ baseDir: defaultBaseDir,
210
+ status,
211
+ worldModel,
212
+ trends,
213
+ research,
214
+ insights,
215
+ essays,
216
+ dag,
217
+ collect,
218
+ ferment,
219
+ }
220
+ }
221
+
222
+ module.exports = {
223
+ createObserverStore,
224
+ isObserverAvailable,
225
+ readJsonSafe,
226
+ readJsonlSafe,
227
+ OBSERVER_SUBDIRS,
228
+ }
@@ -0,0 +1,263 @@
1
+ 'use strict'
2
+
3
+ // Shared policy store: the single implementation of mio.policy.check, consumed
4
+ // by both the MCP server (index.js) and the CLI (`mio policy check`). Keeping it
5
+ // here rather than inlined in the MCP server means both entry points score
6
+ // risk identically and cannot drift -- same rationale as memory-store.js and
7
+ // experience-store.js.
8
+ //
9
+ // Unlike those stores, the CLI deliberately points this one at the *global*
10
+ // MIO_HOME rather than the per-project directory the MCP server uses, so that
11
+ // `mio policy check` sees the same history as `mio recall` / `mio traces`.
12
+
13
+ const path = require('path')
14
+
15
+ const { readJsonl } = require('./memory-store.js')
16
+
17
+ // Outcomes that count against an action. `retry` is included because a retried
18
+ // action is evidence the first attempt did not simply work.
19
+ const FAILURE_OUTCOMES = new Set(['failure', 'error', 'aborted', 'retry'])
20
+
21
+ function isFailureOutcome(value) {
22
+ return FAILURE_OUTCOMES.has(String(value || '').toLowerCase())
23
+ }
24
+
25
+ function summarizeTrace(event) {
26
+ const payload = event.payload
27
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
28
+ if (typeof payload.summary === 'string' && payload.summary.trim()) {
29
+ return payload.summary.trim().slice(0, 160)
30
+ }
31
+ if (typeof payload.tool === 'string' && payload.tool.trim()) {
32
+ return `tool: ${payload.tool.trim().slice(0, 120)}`
33
+ }
34
+ if (typeof payload.error === 'string' && payload.error.trim()) {
35
+ return payload.error.trim().slice(0, 160)
36
+ }
37
+ }
38
+ return String(event.event_type || 'trace').slice(0, 120)
39
+ }
40
+
41
+ function buildPolicyGuidance({ total, failures, risk, failureExamples, relatedMemories }) {
42
+ const guidance = {
43
+ level: 'none',
44
+ rationale: null,
45
+ saferAlternatives: [],
46
+ verificationSteps: [],
47
+ avoid: [],
48
+ hardGate: false,
49
+ }
50
+ if (total === 0) {
51
+ guidance.rationale =
52
+ 'No historical evidence for this action yet. Treat as normal risk and record the outcome.'
53
+ return guidance
54
+ }
55
+ const verifiedMemories = relatedMemories.filter((record) => record.evidence)
56
+ const hasVerified = verifiedMemories.length > 0
57
+ if (risk >= 0.4 && failures >= 2) {
58
+ guidance.level = hasVerified ? 'actionable' : 'advisory'
59
+ guidance.rationale =
60
+ 'Repeated historical failures. Prefer an alternative approach and add rollback/verification.'
61
+ guidance.avoid = failureExamples
62
+ .map((example) => example.summary)
63
+ .filter(Boolean)
64
+ .slice(0, 3)
65
+ } else if (risk >= 0.2 || hasVerified) {
66
+ guidance.level = hasVerified ? 'actionable' : 'advisory'
67
+ guidance.rationale = hasVerified
68
+ ? 'Verified experience exists for this action; apply it before proceeding.'
69
+ : 'Moderate historical risk. Add verification before proceeding.'
70
+ } else {
71
+ guidance.level = 'advisory'
72
+ guidance.rationale = 'Low historical risk. Proceed with normal checks and record the outcome.'
73
+ }
74
+ if (hasVerified) {
75
+ guidance.saferAlternatives = verifiedMemories.slice(0, 3).map((record) => ({
76
+ memoryId: record.id,
77
+ reuseCount: record.evidence.reuseCount,
78
+ content: String(record.content || '').slice(0, 300),
79
+ }))
80
+ }
81
+ guidance.verificationSteps = [
82
+ 'Add an explicit verification step after execution (dry-run, rollback plan, or assertion).',
83
+ 'Record the outcome with mio.observer.ingest so the evidence base improves.',
84
+ ]
85
+ return guidance
86
+ }
87
+
88
+ // Risk score thresholds. `unknown` (no history) is deliberately distinct from
89
+ // `low` (history exists and is clean) -- they call for different behavior.
90
+ const POLICY_RISK_HIGH = 0.4
91
+ const POLICY_RISK_MODERATE = 0.2
92
+
93
+ // A token appearing in more than this share of the trace corpus carries no
94
+ // discriminative power -- it is a structural JSON key (`task`, `outcome`,
95
+ // `summary`, `project`, `host`, `sessionid`...) rather than a description of
96
+ // what the action does. Measured on a real 920-trace corpus, those keys sit
97
+ // above 85%, while meaningful verbs sit far lower.
98
+ const POLICY_GENERIC_TOKEN_SHARE = 0.5
99
+
100
+ // Below this many matched traces the failure ratio is too noisy to call a
101
+ // level, so it is reported but flagged.
102
+ const POLICY_MIN_SAMPLE = 5
103
+
104
+ // Tokens that appear in most traces regardless of subject. Matching on these is
105
+ // what makes `mio policy check task` return the entire corpus, so the caller is
106
+ // warned rather than shown a confident-looking but meaningless risk level.
107
+ function collectGenericTokens(traces, tokenize) {
108
+ const total = traces.length
109
+ const generic = new Set()
110
+ if (total === 0) return generic
111
+ const counts = new Map()
112
+ for (const event of traces) {
113
+ const seen = new Set(
114
+ tokenize(`${event.event_type || ''} ${JSON.stringify(event.payload || {})}`)
115
+ )
116
+ for (const token of seen) counts.set(token, (counts.get(token) || 0) + 1)
117
+ }
118
+ for (const [token, count] of counts) {
119
+ if (count / total >= POLICY_GENERIC_TOKEN_SHARE) generic.add(token)
120
+ }
121
+ return generic
122
+ }
123
+
124
+ function createPolicyStore(options = {}) {
125
+ const dataDir = options.dataDir
126
+ const projectName = options.projectName || (() => null)
127
+ const memoryStore = options.memoryStore
128
+ // scoreRecord / tokenize / loadEvidenceWeights all live in the memory store
129
+ // and must match the ranking used by mio.memory.query.
130
+ const tokenize = options.tokenize || memoryStore.tokenize
131
+ const scoreRecord = options.scoreRecord || memoryStore.scoreRecord
132
+ const loadEvidenceWeights = options.loadEvidenceWeights || memoryStore.loadEvidenceWeights
133
+
134
+ const tracePath = options.tracePath || path.join(dataDir, 'traces.jsonl')
135
+ const memoryPath = options.memoryPath || path.join(dataDir, 'memory.jsonl')
136
+
137
+ function policyCheck(args = {}) {
138
+ const action = String(args.action || '').trim()
139
+ if (!action) throw new Error('policy.check requires action')
140
+ const project = args.project || projectName()
141
+ const actionTokens = tokenize(action)
142
+ const allTraces = readJsonl(tracePath)
143
+ const scopedTraces = allTraces.filter(
144
+ (event) => !(project && event.project && event.project !== project)
145
+ )
146
+ const traces = scopedTraces.filter((event) => {
147
+ const haystack = tokenize(`${event.event_type || ''} ${JSON.stringify(event.payload || {})}`)
148
+ return actionTokens.some((token) => haystack.includes(token))
149
+ })
150
+ const total = traces.length
151
+ const failures = traces.filter((event) => isFailureOutcome(event.outcome)).length
152
+ const risk = total > 0 ? failures / total : null
153
+ const riskLevel =
154
+ total === 0
155
+ ? 'unknown'
156
+ : risk >= POLICY_RISK_HIGH
157
+ ? 'high'
158
+ : risk >= POLICY_RISK_MODERATE
159
+ ? 'moderate'
160
+ : 'low'
161
+ const outcomeCounts = traces.reduce((counts, event) => {
162
+ const outcome = String(event.outcome || 'unknown').toLowerCase() || 'unknown'
163
+ counts[outcome] = (counts[outcome] || 0) + 1
164
+ return counts
165
+ }, {})
166
+ const failureExamples = traces
167
+ .filter((event) => isFailureOutcome(event.outcome))
168
+ .sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime())
169
+ .slice(0, 3)
170
+ .map((event) => ({
171
+ trace_id: event.trace_id || event.id || null,
172
+ event_type: event.event_type || null,
173
+ outcome: event.outcome || null,
174
+ timestamp: event.timestamp || null,
175
+ agent: event.agent || null,
176
+ summary: summarizeTrace(event),
177
+ }))
178
+ let suggestion
179
+ if (total === 0) {
180
+ suggestion = 'No history for this action. Treat as normal risk and record the outcome.'
181
+ } else if (risk >= POLICY_RISK_HIGH) {
182
+ suggestion = 'Historically risky. Prefer an alternative approach or add rollback/verification.'
183
+ } else if (risk >= POLICY_RISK_MODERATE) {
184
+ suggestion = 'Moderate risk. Add verification before proceeding.'
185
+ } else {
186
+ suggestion = 'Low historical risk. Proceed with normal checks.'
187
+ }
188
+ const evidence = loadEvidenceWeights()
189
+ const relatedMemories = readJsonl(memoryPath)
190
+ .filter((record) => scoreRecord(record, action, project, evidence) > 0)
191
+ .sort(
192
+ (a, b) => scoreRecord(b, action, project, evidence) - scoreRecord(a, action, project, evidence)
193
+ )
194
+ .slice(0, 3)
195
+ .map((record) => {
196
+ const ev = evidence.get(record.id)
197
+ return {
198
+ id: record.id,
199
+ timestamp: record.timestamp,
200
+ kind: record.kind,
201
+ content: String(record.content || '').slice(0, 300),
202
+ evidence: ev ? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount } : null,
203
+ }
204
+ })
205
+ const genericTokens = collectGenericTokens(scopedTraces, tokenize)
206
+ // Only flag when the match is *carried* by generic tokens: an action like
207
+ // "npm publish" also contains "npm", which is common but still meaningful.
208
+ const genericMatched = actionTokens.filter((token) => genericTokens.has(token))
209
+ const meaningfulTokens = actionTokens.filter((token) => !genericTokens.has(token))
210
+ const lowSignal =
211
+ actionTokens.length > 0 && meaningfulTokens.length === 0 && genericMatched.length > 0
212
+
213
+ return {
214
+ action,
215
+ project,
216
+ total,
217
+ failures,
218
+ risk: risk === null ? null : Number(risk.toFixed(3)),
219
+ riskLevel,
220
+ outcomeCounts,
221
+ failureExamples,
222
+ suggestion,
223
+ guidance: buildPolicyGuidance({
224
+ total,
225
+ failures,
226
+ risk,
227
+ failureExamples,
228
+ relatedMemories,
229
+ }),
230
+ related_memories: relatedMemories,
231
+ // Additive diagnostics. Existing consumers (mio.policy.check) ignore
232
+ // these; the CLI uses them to avoid presenting a confident risk level
233
+ // derived from tokens that match everything.
234
+ diagnostics: {
235
+ lowSignal,
236
+ genericTokens: genericMatched,
237
+ meaningfulTokens,
238
+ sampleSize: total,
239
+ lowSample: total > 0 && total < POLICY_MIN_SAMPLE,
240
+ },
241
+ }
242
+ }
243
+
244
+ return {
245
+ tracePath,
246
+ memoryPath,
247
+ policyCheck,
248
+ // pure helpers, exported for tests and other consumers
249
+ isFailureOutcome,
250
+ summarizeTrace,
251
+ buildPolicyGuidance,
252
+ }
253
+ }
254
+
255
+ module.exports = {
256
+ createPolicyStore,
257
+ FAILURE_OUTCOMES,
258
+ isFailureOutcome,
259
+ summarizeTrace,
260
+ buildPolicyGuidance,
261
+ POLICY_RISK_HIGH,
262
+ POLICY_RISK_MODERATE,
263
+ }
@@ -0,0 +1,66 @@
1
+ 'use strict'
2
+
3
+ // Shared query log: the single implementation behind queries.jsonl.
4
+ //
5
+ // Two consumers need it and cannot reach each other: memoryStore records a
6
+ // query during queryMemory (phase 0 auto-claim input), and taskStore reads the
7
+ // log to attribute a later task_outcome to that query. Passing one store into
8
+ // the other would be a cycle, so the log is its own module and both get an
9
+ // instance handed in.
10
+ //
11
+ // This replaces what used to be two copies of load/persist -- one inlined in
12
+ // mio-intelligence-mcp/index.js, one in task-store.js -- which had to be kept
13
+ // equivalent by hand.
14
+
15
+ const fs = require('fs')
16
+ const path = require('path')
17
+ const { readJsonl } = require('./memory-store.js')
18
+
19
+ const MAX_RECENT_QUERIES = 200
20
+
21
+ // Evaluated once at load, matching the previous inlined behaviour. Tests that
22
+ // need a different window should set MIO_REUSE_MATCH_WINDOW_MIN before require.
23
+ const REUSE_MATCH_WINDOW_MS = (() => {
24
+ const minutes = Number(process.env.MIO_REUSE_MATCH_WINDOW_MIN)
25
+ return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : 60 * 60 * 1000
26
+ })()
27
+
28
+ function createQueryLog(options = {}) {
29
+ const dataDir = options.dataDir
30
+ const queryPath = path.join(dataDir, 'queries.jsonl')
31
+
32
+ function load() {
33
+ const now = Date.now()
34
+ return readJsonl(queryPath)
35
+ .filter(
36
+ (entry) =>
37
+ entry &&
38
+ typeof entry.agent === 'string' &&
39
+ entry.agent &&
40
+ typeof entry.expiresAt === 'number' &&
41
+ entry.expiresAt > now
42
+ )
43
+ .slice(-MAX_RECENT_QUERIES)
44
+ }
45
+
46
+ function persist(entries) {
47
+ const limited = entries.slice(-MAX_RECENT_QUERIES)
48
+ fs.mkdirSync(dataDir, { recursive: true })
49
+ fs.writeFileSync(
50
+ queryPath,
51
+ limited.length > 0 ? limited.map((entry) => JSON.stringify(entry)).join('\n') + '\n' : '',
52
+ 'utf8'
53
+ )
54
+ return limited
55
+ }
56
+
57
+ function record(entry) {
58
+ const entries = load()
59
+ entries.push(entry)
60
+ return persist(entries)
61
+ }
62
+
63
+ return { queryPath, windowMs: REUSE_MATCH_WINDOW_MS, load, persist, record }
64
+ }
65
+
66
+ module.exports = { createQueryLog, REUSE_MATCH_WINDOW_MS, MAX_RECENT_QUERIES }