mio-agent-runtime 0.5.23 → 0.6.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.
- package/README.md +485 -85
- package/bin/mio.js +1882 -78
- package/observe/observer.js +73 -15
- package/package.json +45 -45
- package/scripts/check-syntax.cjs +72 -0
- package/server/agent-store.js +140 -0
- package/server/evolution-report.js +311 -0
- package/server/experience-store.js +214 -0
- package/server/insight-store.js +118 -0
- package/server/llm-client.js +69 -0
- package/server/memory-store.js +505 -3
- package/server/mio-intelligence-mcp/index.js +126 -1521
- package/server/observer-store.js +219 -0
- package/server/policy-store.js +263 -0
- package/server/query-log.js +66 -0
- package/server/subscription-store.js +213 -0
- package/server/task-store.js +385 -0
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
function collect(args = {}) {
|
|
165
|
+
if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
|
|
166
|
+
const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
|
|
167
|
+
const sources = args.sources || ['bilibili', 'hackernews', 'github', 'douyin', 'rss']
|
|
168
|
+
const allObs = []
|
|
169
|
+
for (const src of sources) {
|
|
170
|
+
try {
|
|
171
|
+
const collectFn = service.collectBySource ? service.collectBySource : service.collect
|
|
172
|
+
if (collectFn) {
|
|
173
|
+
const result = collectFn.call(service, [src], args.keywords || [], args.limit || 20)
|
|
174
|
+
if (Array.isArray(result)) allObs.push(...result)
|
|
175
|
+
else if (result && typeof result === 'object') {
|
|
176
|
+
for (const [, items] of Object.entries(result)) allObs.push(...items)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
allObs.push({ source: src, error: e.message })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { collected: allObs.length, observations: allObs }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ferment(args = {}) {
|
|
187
|
+
if (!ObserverService) throw new Error('@akemi-mio/observer not installed')
|
|
188
|
+
const service = new ObserverService({ baseDir: baseDirOf(args), collectorConfig: {} })
|
|
189
|
+
const session = args.session || 'afternoon'
|
|
190
|
+
try {
|
|
191
|
+
const fermentation = service.getFermentation()
|
|
192
|
+
if (fermentation && fermentation.ferment) return fermentation.ferment(session)
|
|
193
|
+
return { status: 'fermentation engine available but no ferment method' }
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { error: e.message }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
baseDir: defaultBaseDir,
|
|
201
|
+
status,
|
|
202
|
+
worldModel,
|
|
203
|
+
trends,
|
|
204
|
+
research,
|
|
205
|
+
insights,
|
|
206
|
+
essays,
|
|
207
|
+
dag,
|
|
208
|
+
collect,
|
|
209
|
+
ferment,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
createObserverStore,
|
|
215
|
+
isObserverAvailable,
|
|
216
|
+
readJsonSafe,
|
|
217
|
+
readJsonlSafe,
|
|
218
|
+
OBSERVER_SUBDIRS,
|
|
219
|
+
}
|
|
@@ -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 }
|