mio-agent-runtime 0.5.17 → 0.5.23
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 +188 -107
- package/adapters/claude-code.js +141 -0
- package/bin/mio.js +526 -245
- package/observe/observer.js +326 -4
- package/package.json +10 -3
- package/scripts/verify-packed-runtime.js +23 -8
- package/server/digest.js +278 -0
- package/server/host-capabilities.js +5 -0
- package/server/memory-store.js +376 -0
- package/server/mio-intelligence-mcp/index.js +2383 -2471
- package/server/retention.js +150 -0
package/server/digest.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Digest: turn the accumulated MIO_HOME data (traces, memory, reuse evidence)
|
|
4
|
+
// into an actionable report. Deterministic aggregation only — no LLM. The
|
|
5
|
+
// report answers: what got done, where the errors are, which agent performs
|
|
6
|
+
// best, and what deserves attention next.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
|
|
11
|
+
function readJsonl(file) {
|
|
12
|
+
if (!fs.existsSync(file)) return []
|
|
13
|
+
return fs
|
|
14
|
+
.readFileSync(file, 'utf8')
|
|
15
|
+
.split(/\r?\n/)
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((line) => {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(line)
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isVerifiedReuse(record) {
|
|
28
|
+
return (
|
|
29
|
+
(record.reuse === true || record.reuse === 'true') &&
|
|
30
|
+
(record.behaviorChanged === true || record.behaviorChanged === 'true') &&
|
|
31
|
+
(record.outcomeImproved === true || record.outcomeImproved === 'true')
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createDigest(options = {}) {
|
|
36
|
+
const home = options.home
|
|
37
|
+
const now = options.now || (() => Date.now())
|
|
38
|
+
|
|
39
|
+
function generate(args = {}) {
|
|
40
|
+
const days = Math.max(1, Number(args.days) || 7)
|
|
41
|
+
const project = args.project ? String(args.project) : null
|
|
42
|
+
const atTime = now()
|
|
43
|
+
const cutoff = atTime - days * 86400000
|
|
44
|
+
|
|
45
|
+
const traces = readJsonl(path.join(home, 'traces.jsonl')).filter((trace) => {
|
|
46
|
+
if (!trace || typeof trace !== 'object') return false
|
|
47
|
+
const ts = Date.parse(trace.timestamp || '')
|
|
48
|
+
if (!(Number.isFinite(ts) && ts >= cutoff)) return false
|
|
49
|
+
if (project && trace.project !== project) return false
|
|
50
|
+
return true
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const outcomes = traces.filter((t) => String(t.event_type || '').toLowerCase() === 'task_outcome')
|
|
54
|
+
const errors = traces.filter((t) => String(t.event_type || '').toLowerCase() === 'error')
|
|
55
|
+
|
|
56
|
+
// --- per-agent performance ---
|
|
57
|
+
const agentMap = new Map()
|
|
58
|
+
for (const outcome of outcomes) {
|
|
59
|
+
const agent = String(outcome.agent || 'unknown')
|
|
60
|
+
let entry = agentMap.get(agent)
|
|
61
|
+
if (!entry) {
|
|
62
|
+
entry = { agent, total: 0, success: 0, failure: 0, aborted: 0 }
|
|
63
|
+
agentMap.set(agent, entry)
|
|
64
|
+
}
|
|
65
|
+
entry.total += 1
|
|
66
|
+
const oc = String(outcome.outcome || 'none').toLowerCase()
|
|
67
|
+
if (oc === 'success') entry.success += 1
|
|
68
|
+
else if (oc === 'failure' || oc === 'error') entry.failure += 1
|
|
69
|
+
else entry.aborted += 1
|
|
70
|
+
}
|
|
71
|
+
const agents = [...agentMap.values()]
|
|
72
|
+
.map((entry) => ({
|
|
73
|
+
...entry,
|
|
74
|
+
successRate: entry.total > 0 ? Math.round((entry.success / entry.total) * 100) : 0,
|
|
75
|
+
}))
|
|
76
|
+
.sort((a, b) => b.total - a.total)
|
|
77
|
+
|
|
78
|
+
// --- error hotspots ---
|
|
79
|
+
const errorByAgent = new Map()
|
|
80
|
+
const errorByTool = new Map()
|
|
81
|
+
for (const error of errors) {
|
|
82
|
+
const agent = String(error.agent || 'unknown')
|
|
83
|
+
errorByAgent.set(agent, (errorByAgent.get(agent) || 0) + 1)
|
|
84
|
+
const tool =
|
|
85
|
+
(error.payload && (error.payload.tool || error.payload.toolUseId || error.payload.status)) ||
|
|
86
|
+
'unknown'
|
|
87
|
+
const key = agent + ':' + String(tool)
|
|
88
|
+
errorByTool.set(key, (errorByTool.get(key) || 0) + 1)
|
|
89
|
+
}
|
|
90
|
+
const errorHotspots = {
|
|
91
|
+
total: errors.length,
|
|
92
|
+
byAgent: [...errorByAgent.entries()]
|
|
93
|
+
.map(([agent, count]) => ({ agent, count }))
|
|
94
|
+
.sort((a, b) => b.count - a.count),
|
|
95
|
+
byTool: [...errorByTool.entries()]
|
|
96
|
+
.map(([key, count]) => ({ key, count }))
|
|
97
|
+
.sort((a, b) => b.count - a.count)
|
|
98
|
+
.slice(0, 10),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- project activity ---
|
|
102
|
+
const projectMap = new Map()
|
|
103
|
+
for (const outcome of outcomes) {
|
|
104
|
+
const name = String(outcome.project || 'unknown')
|
|
105
|
+
let entry = projectMap.get(name)
|
|
106
|
+
if (!entry) {
|
|
107
|
+
entry = { project: name, tasks: 0, success: 0, lastAt: null, recent: [] }
|
|
108
|
+
projectMap.set(name, entry)
|
|
109
|
+
}
|
|
110
|
+
entry.tasks += 1
|
|
111
|
+
if (String(outcome.outcome || '').toLowerCase() === 'success') entry.success += 1
|
|
112
|
+
const ts = Date.parse(outcome.timestamp || '')
|
|
113
|
+
if (Number.isFinite(ts) && (!entry.lastAt || ts > entry.lastAt)) entry.lastAt = ts
|
|
114
|
+
const summary = outcome.payload && outcome.payload.summary
|
|
115
|
+
if (summary && entry.recent.length < 3) {
|
|
116
|
+
entry.recent.push({
|
|
117
|
+
at: outcome.timestamp,
|
|
118
|
+
agent: outcome.agent,
|
|
119
|
+
outcome: outcome.outcome,
|
|
120
|
+
summary: String(summary).slice(0, 160),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const projects = [...projectMap.values()]
|
|
125
|
+
.map((entry) => ({ ...entry, lastAt: entry.lastAt ? new Date(entry.lastAt).toISOString() : null }))
|
|
126
|
+
.sort((a, b) => b.tasks - a.tasks)
|
|
127
|
+
|
|
128
|
+
// --- reuse evidence ---
|
|
129
|
+
const reuseRecords = readJsonl(path.join(home, 'experience_reuse.jsonl')).filter((record) => {
|
|
130
|
+
if (!record || typeof record !== 'object') return false
|
|
131
|
+
if (project && record.project !== project) return false
|
|
132
|
+
const ts = Date.parse(record.timestamp || '')
|
|
133
|
+
return Number.isFinite(ts) && ts >= cutoff
|
|
134
|
+
})
|
|
135
|
+
const reuse = {
|
|
136
|
+
verified: reuseRecords.filter(isVerifiedReuse).length,
|
|
137
|
+
pendingAutoClaims: reuseRecords.filter(
|
|
138
|
+
(record) => record.source === 'auto_claim' && record.confirmed !== true
|
|
139
|
+
).length,
|
|
140
|
+
total: reuseRecords.length,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- rule-based suggestions (facts first, thresholds conservative) ---
|
|
144
|
+
const suggestions = []
|
|
145
|
+
const reliable = agents.filter((a) => a.total >= 5)
|
|
146
|
+
if (reliable.length >= 2) {
|
|
147
|
+
const best = reliable.reduce((a, b) => (b.successRate > a.successRate ? b : a))
|
|
148
|
+
const worst = reliable.reduce((a, b) => (b.successRate < a.successRate ? b : a))
|
|
149
|
+
if (best.successRate - worst.successRate >= 20) {
|
|
150
|
+
suggestions.push(
|
|
151
|
+
`路由信号: ${best.agent} 成功率 ${best.successRate}% vs ${worst.agent} ${worst.successRate}%(近 ${days} 天),同类任务优先路由给 ${best.agent}。`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (errorHotspots.total >= 5 && errorHotspots.byAgent.length > 0) {
|
|
156
|
+
const top = errorHotspots.byAgent[0]
|
|
157
|
+
suggestions.push(
|
|
158
|
+
`错误热点: ${top.agent} 近 ${days} 天产生 ${top.count} 条 error trace${errorHotspots.byTool.length > 0 ? `(高发: ${errorHotspots.byTool[0].key} x${errorHotspots.byTool[0].count})` : ''},优先排查。`
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
if (reuse.pendingAutoClaims >= 10) {
|
|
162
|
+
suggestions.push(
|
|
163
|
+
`待确认: ${reuse.pendingAutoClaims} 条 auto-claim 经验复用未确认,运行 mio.experience.confirm 提升证据质量。`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const report = {
|
|
168
|
+
generatedAt: new Date(atTime).toISOString(),
|
|
169
|
+
periodDays: days,
|
|
170
|
+
project: project || 'all',
|
|
171
|
+
overview: {
|
|
172
|
+
tasks: outcomes.length,
|
|
173
|
+
byOutcome: outcomes.reduce((acc, t) => {
|
|
174
|
+
const oc = String(t.outcome || 'none').toLowerCase()
|
|
175
|
+
acc[oc] = (acc[oc] || 0) + 1
|
|
176
|
+
return acc
|
|
177
|
+
}, {}),
|
|
178
|
+
errorTraces: errors.length,
|
|
179
|
+
activeAgents: agents.length,
|
|
180
|
+
activeProjects: projects.length,
|
|
181
|
+
},
|
|
182
|
+
agents,
|
|
183
|
+
errorHotspots,
|
|
184
|
+
projects,
|
|
185
|
+
reuse,
|
|
186
|
+
suggestions,
|
|
187
|
+
}
|
|
188
|
+
report.markdown = renderMarkdown(report)
|
|
189
|
+
|
|
190
|
+
// Persist a machine-readable snapshot for consumers that should not
|
|
191
|
+
// recompute the aggregation — notably mio.task.route's agent-health signal.
|
|
192
|
+
try {
|
|
193
|
+
const digestDir = path.join(home, 'digest')
|
|
194
|
+
fs.mkdirSync(digestDir, { recursive: true })
|
|
195
|
+
const snapshot = { ...report }
|
|
196
|
+
delete snapshot.markdown
|
|
197
|
+
fs.writeFileSync(path.join(digestDir, 'latest.json'), JSON.stringify(snapshot) + '\n', 'utf8')
|
|
198
|
+
} catch (_) {
|
|
199
|
+
// best-effort persistence; the in-memory report is still returned
|
|
200
|
+
}
|
|
201
|
+
return report
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderMarkdown(report) {
|
|
205
|
+
const lines = []
|
|
206
|
+
const from = new Date(Date.parse(report.generatedAt) - report.periodDays * 86400000)
|
|
207
|
+
.toISOString()
|
|
208
|
+
.slice(0, 10)
|
|
209
|
+
const to = report.generatedAt.slice(0, 10)
|
|
210
|
+
lines.push(`# Mio Digest (${from} ~ ${to}, project=${report.project})`)
|
|
211
|
+
lines.push('')
|
|
212
|
+
const ov = report.overview
|
|
213
|
+
const rate = ov.tasks > 0 ? Math.round(((ov.byOutcome.success || 0) / ov.tasks) * 100) : 0
|
|
214
|
+
lines.push(
|
|
215
|
+
`- 任务 ${ov.tasks} 条(success ${ov.byOutcome.success || 0} / failure ${ov.byOutcome.failure || 0} / aborted ${ov.byOutcome.aborted || 0},成功率 ${rate}%),error trace ${ov.errorTraces} 条`
|
|
216
|
+
)
|
|
217
|
+
lines.push(`- 活跃 agent ${ov.activeAgents} 个,活跃项目 ${ov.activeProjects} 个`)
|
|
218
|
+
lines.push('')
|
|
219
|
+
|
|
220
|
+
if (report.agents.length > 0) {
|
|
221
|
+
lines.push('## Agent 表现')
|
|
222
|
+
lines.push('')
|
|
223
|
+
lines.push('| agent | 任务 | 成功 | 失败 | 中止 | 成功率 |')
|
|
224
|
+
lines.push('|---|---|---|---|---|---|')
|
|
225
|
+
for (const a of report.agents) {
|
|
226
|
+
lines.push(`| ${a.agent} | ${a.total} | ${a.success} | ${a.failure} | ${a.aborted} | ${a.successRate}% |`)
|
|
227
|
+
}
|
|
228
|
+
lines.push('')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (report.projects.length > 0) {
|
|
232
|
+
lines.push('## 项目动态')
|
|
233
|
+
lines.push('')
|
|
234
|
+
for (const p of report.projects) {
|
|
235
|
+
const lastAt = p.lastAt ? p.lastAt.slice(0, 16).replace('T', ' ') : '-'
|
|
236
|
+
lines.push(`### ${p.project}(${p.tasks} 任务,最近 ${lastAt})`)
|
|
237
|
+
for (const task of p.recent) {
|
|
238
|
+
const at = task.at ? task.at.slice(5, 16).replace('T', ' ') : ''
|
|
239
|
+
lines.push(`- [${task.outcome}] (${at} ${task.agent}) ${task.summary}`)
|
|
240
|
+
}
|
|
241
|
+
lines.push('')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (report.errorHotspots.total > 0) {
|
|
246
|
+
lines.push('## 错误热点')
|
|
247
|
+
lines.push('')
|
|
248
|
+
for (const item of report.errorHotspots.byAgent) {
|
|
249
|
+
lines.push(`- ${item.agent}: ${item.count} 条`)
|
|
250
|
+
}
|
|
251
|
+
const tools = report.errorHotspots.byTool.slice(0, 5)
|
|
252
|
+
if (tools.length > 0) {
|
|
253
|
+
lines.push(`- 高发位置: ${tools.map((t) => `${t.key} x${t.count}`).join(', ')}`)
|
|
254
|
+
}
|
|
255
|
+
lines.push('')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
lines.push('## 经验复用')
|
|
259
|
+
lines.push('')
|
|
260
|
+
lines.push(`- verified ${report.reuse.verified} / pending auto-claim ${report.reuse.pendingAutoClaims} / total ${report.reuse.total}`)
|
|
261
|
+
lines.push('')
|
|
262
|
+
|
|
263
|
+
if (report.suggestions.length > 0) {
|
|
264
|
+
lines.push('## 可行动建议')
|
|
265
|
+
lines.push('')
|
|
266
|
+
for (const suggestion of report.suggestions) {
|
|
267
|
+
lines.push(`- ${suggestion}`)
|
|
268
|
+
}
|
|
269
|
+
lines.push('')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return lines.join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { generate, renderMarkdown }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { createDigest }
|
|
@@ -5,6 +5,7 @@ const adapters = {
|
|
|
5
5
|
opencode: require('../adapters/opencode.js'),
|
|
6
6
|
workbuddy: require('../adapters/workbuddy.js'),
|
|
7
7
|
hermes: require('../adapters/hermes.js'),
|
|
8
|
+
claude: require('../adapters/claude-code.js'),
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const HOST_CAPABILITIES = [
|
|
@@ -24,6 +25,10 @@ const HOST_CAPABILITIES = [
|
|
|
24
25
|
name: 'hermes',
|
|
25
26
|
capabilities: ['mcp-tools', 'memory', 'observer-ingest', 'observer-transcript', 'policy-check', 'experience-reuse', 'runtime-status'],
|
|
26
27
|
},
|
|
28
|
+
{
|
|
29
|
+
name: 'claude',
|
|
30
|
+
capabilities: ['mcp-tools', 'memory', 'observer-ingest', 'observer-transcript', 'policy-check', 'experience-reuse', 'runtime-status'],
|
|
31
|
+
},
|
|
27
32
|
]
|
|
28
33
|
|
|
29
34
|
function safeInstalled(adapter) {
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Shared memory store: the single implementation of memory.query / memory.record
|
|
4
|
+
// ranking and persistence, consumed by both the MCP server (index.js) and the
|
|
5
|
+
// CLI (`mio recall` / `mio remember`). Scoring must stay byte-compatible with
|
|
6
|
+
// the original inlined logic so MCP behavior does not change.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const crypto = require('crypto')
|
|
11
|
+
|
|
12
|
+
function createId(prefix) {
|
|
13
|
+
return `${prefix}_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readJsonl(file) {
|
|
17
|
+
if (!fs.existsSync(file)) return []
|
|
18
|
+
return fs
|
|
19
|
+
.readFileSync(file, 'utf8')
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((line) => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(line)
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function appendJsonl(file, value) {
|
|
33
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
34
|
+
fs.appendFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeTags(tags) {
|
|
38
|
+
if (Array.isArray(tags)) return tags.map((tag) => String(tag).trim()).filter(Boolean)
|
|
39
|
+
if (typeof tags === 'string') {
|
|
40
|
+
return tags
|
|
41
|
+
.split(',')
|
|
42
|
+
.map((tag) => tag.trim())
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
}
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tokenize(text) {
|
|
49
|
+
const normalized = String(text || '').toLowerCase()
|
|
50
|
+
const latin = normalized.match(/[a-z0-9]+/g) || []
|
|
51
|
+
const cjk = (normalized.match(/[\u4e00-\u9fff]/g) || []).map((char) => `cjk:${char}`)
|
|
52
|
+
return [...latin, ...cjk]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeScope(value) {
|
|
56
|
+
const s = String(value || 'project').trim().toLowerCase()
|
|
57
|
+
if (s === 'global' || s === 'all') return s
|
|
58
|
+
return 'project'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isGlobalRecord(record) {
|
|
62
|
+
return String(record.scope || '').trim().toLowerCase() === 'global'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesProjectScope(record, project, scope) {
|
|
66
|
+
const s = normalizeScope(scope)
|
|
67
|
+
if (s === 'global') return isGlobalRecord(record)
|
|
68
|
+
if (s === 'all') {
|
|
69
|
+
if (isGlobalRecord(record)) return true
|
|
70
|
+
return !project || record.project === project
|
|
71
|
+
}
|
|
72
|
+
// project (default): project-scoped records matching the project only
|
|
73
|
+
if (isGlobalRecord(record)) return false
|
|
74
|
+
return !project || record.project === project
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function latinTokens(text) {
|
|
78
|
+
return String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cjkBigrams(text) {
|
|
82
|
+
const chars = String(text || '').toLowerCase().match(/[\u4e00-\u9fff]/g) || []
|
|
83
|
+
const bigrams = new Set()
|
|
84
|
+
for (let i = 0; i + 1 < chars.length; i += 1) {
|
|
85
|
+
bigrams.add(`${chars[i]}${chars[i + 1]}`)
|
|
86
|
+
}
|
|
87
|
+
return bigrams
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scoreRecord(record, queryText, project, evidence) {
|
|
91
|
+
if (project && record.project && record.project !== project) return 0
|
|
92
|
+
const content = `${record.content || ''} ${(record.tags || []).join(' ')} ${record.kind || ''}`
|
|
93
|
+
const haystackTokens = tokenize(content)
|
|
94
|
+
const haystackLatin = latinTokens(content)
|
|
95
|
+
const queryTokens = tokenize(queryText)
|
|
96
|
+
let score = 0
|
|
97
|
+
for (const token of queryTokens) {
|
|
98
|
+
if (haystackTokens.includes(token)) score += 2
|
|
99
|
+
}
|
|
100
|
+
const queryLatin = queryTokens.filter((token) => !token.startsWith('cjk:'))
|
|
101
|
+
for (const token of queryLatin) {
|
|
102
|
+
if (
|
|
103
|
+
token.length >= 3 &&
|
|
104
|
+
haystackLatin.some((item) => item.startsWith(token) || token.startsWith(item))
|
|
105
|
+
) {
|
|
106
|
+
score += 1
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const queryBigrams = cjkBigrams(queryText)
|
|
110
|
+
const haystackBigrams = cjkBigrams(content)
|
|
111
|
+
if (queryBigrams.size > 0 && haystackBigrams.size > 0) {
|
|
112
|
+
let matched = 0
|
|
113
|
+
for (const bigram of queryBigrams) {
|
|
114
|
+
if (haystackBigrams.has(bigram)) matched += 1
|
|
115
|
+
}
|
|
116
|
+
score += matched * 1.5
|
|
117
|
+
}
|
|
118
|
+
// Recency only boosts ranking among actual matches; it must not enable recall.
|
|
119
|
+
if (score > 0 && record.timestamp) {
|
|
120
|
+
const ageDays = Math.max(0, (Date.now() - Date.parse(record.timestamp)) / 86400000)
|
|
121
|
+
score += Math.max(0, 0.5 - ageDays * 0.05)
|
|
122
|
+
}
|
|
123
|
+
// Evidence weighting (P2): memories successfully reused rank higher; never enables recall.
|
|
124
|
+
if (score > 0 && evidence && evidence.has(record.id)) {
|
|
125
|
+
const ev = evidence.get(record.id)
|
|
126
|
+
score += Math.min(2, ev.reuseCount * 0.6 + (ev.confirmedCount > 0 ? 1 : 0))
|
|
127
|
+
}
|
|
128
|
+
return score
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function matchesMemoryFilters(record, kind, tags) {
|
|
132
|
+
if (kind) {
|
|
133
|
+
const recordKind = String(record.kind || '').trim().toLowerCase()
|
|
134
|
+
if (recordKind !== kind) return false
|
|
135
|
+
}
|
|
136
|
+
if (tags.length > 0) {
|
|
137
|
+
const recordTags = new Set(
|
|
138
|
+
(record.tags || []).map((tag) => String(tag).trim().toLowerCase()),
|
|
139
|
+
)
|
|
140
|
+
for (const tag of tags) {
|
|
141
|
+
if (!recordTags.has(tag)) return false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function defaultVerifiedFilter(record) {
|
|
148
|
+
return (
|
|
149
|
+
(record.reuse === true || record.reuse === 'true') &&
|
|
150
|
+
(record.behaviorChanged === true || record.behaviorChanged === 'true') &&
|
|
151
|
+
(record.outcomeImproved === true || record.outcomeImproved === 'true')
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createMemoryStore(options = {}) {
|
|
156
|
+
const dataDir = options.dataDir
|
|
157
|
+
const projectName = options.projectName || (() => null)
|
|
158
|
+
const agentId = options.agentId || (() => null)
|
|
159
|
+
// Hook that persists a recent-query entry (phase 0 auto-claim input).
|
|
160
|
+
// Optional: the CLI passes null because no task_outcome can follow a
|
|
161
|
+
// terminal command, so recording a query there would only add noise.
|
|
162
|
+
const recordQuery = options.recordQuery || null
|
|
163
|
+
const verifiedFilter = options.verifiedFilter || defaultVerifiedFilter
|
|
164
|
+
|
|
165
|
+
const memoryPath = path.join(dataDir, 'memory.jsonl')
|
|
166
|
+
const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
|
|
167
|
+
const tracePath = path.join(dataDir, 'traces.jsonl')
|
|
168
|
+
|
|
169
|
+
function loadEvidenceWeights() {
|
|
170
|
+
const weights = new Map()
|
|
171
|
+
for (const record of readJsonl(experienceReusePath)) {
|
|
172
|
+
if (!verifiedFilter(record)) continue
|
|
173
|
+
const id = record.experienceId
|
|
174
|
+
if (!id) continue
|
|
175
|
+
let entry = weights.get(id)
|
|
176
|
+
if (!entry) {
|
|
177
|
+
entry = { reuseCount: 0, confirmedCount: 0, lastReusedAt: null }
|
|
178
|
+
weights.set(id, entry)
|
|
179
|
+
}
|
|
180
|
+
entry.reuseCount += 1
|
|
181
|
+
if (record.confirmed === true) entry.confirmedCount += 1
|
|
182
|
+
if (!entry.lastReusedAt || new Date(record.timestamp || 0) > new Date(entry.lastReusedAt)) {
|
|
183
|
+
entry.lastReusedAt = record.timestamp || null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return weights
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function queryMemory(args = {}) {
|
|
190
|
+
const query = String(args.query || '')
|
|
191
|
+
const project = args.project || projectName()
|
|
192
|
+
const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 20)
|
|
193
|
+
const kind = args.kind ? String(args.kind).trim().toLowerCase() : null
|
|
194
|
+
const tags = Array.isArray(args.tags)
|
|
195
|
+
? args.tags.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean)
|
|
196
|
+
: []
|
|
197
|
+
const scope = normalizeScope(args.scope)
|
|
198
|
+
const evidence = loadEvidenceWeights()
|
|
199
|
+
const scoreFor = (record) =>
|
|
200
|
+
scoreRecord(record, query, project, evidence) +
|
|
201
|
+
(scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
|
|
202
|
+
const results = readJsonl(memoryPath)
|
|
203
|
+
.filter((record) => record.archived !== true)
|
|
204
|
+
.filter((record) => matchesMemoryFilters(record, kind, tags))
|
|
205
|
+
.filter((record) => matchesProjectScope(record, project, scope))
|
|
206
|
+
.filter((record) => scoreFor(record) > 0)
|
|
207
|
+
.sort((a, b) => {
|
|
208
|
+
return scoreFor(b) - scoreFor(a)
|
|
209
|
+
})
|
|
210
|
+
.slice(0, limit)
|
|
211
|
+
.map((record) => {
|
|
212
|
+
const ev = evidence.get(record.id)
|
|
213
|
+
if (!ev) return record
|
|
214
|
+
return {
|
|
215
|
+
...record,
|
|
216
|
+
evidence: {
|
|
217
|
+
reuseCount: ev.reuseCount,
|
|
218
|
+
confirmedCount: ev.confirmedCount,
|
|
219
|
+
lastReusedAt: ev.lastReusedAt,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
const layerCounts = { project: 0, global: 0 }
|
|
224
|
+
for (const record of results) {
|
|
225
|
+
if (record.scope === 'global' || !record.project) layerCounts.global += 1
|
|
226
|
+
else layerCounts.project += 1
|
|
227
|
+
}
|
|
228
|
+
if (results.length > 0 && recordQuery) {
|
|
229
|
+
const timestamp = Date.now()
|
|
230
|
+
recordQuery({
|
|
231
|
+
agent: agentId() || 'mcp',
|
|
232
|
+
project,
|
|
233
|
+
query,
|
|
234
|
+
resultIds: results.map((record) => record.id).filter(Boolean),
|
|
235
|
+
resultSources: results.map((record) => record.source || null),
|
|
236
|
+
timestamp,
|
|
237
|
+
expiresAt: timestamp + (options.reuseMatchWindowMs || 60 * 60 * 1000),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
query,
|
|
242
|
+
project,
|
|
243
|
+
scope,
|
|
244
|
+
layers: layerCounts,
|
|
245
|
+
kind: kind || null,
|
|
246
|
+
tags: tags,
|
|
247
|
+
count: results.length,
|
|
248
|
+
results,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseTimeArg(value) {
|
|
253
|
+
if (value === undefined || value === null || value === '') return null
|
|
254
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
255
|
+
const asNumber = Number(value)
|
|
256
|
+
if (String(value).trim() !== '' && Number.isFinite(asNumber)) return asNumber
|
|
257
|
+
const parsed = Date.parse(String(value))
|
|
258
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Query the observer trace log (traces.jsonl): task outcomes, tool errors,
|
|
262
|
+
// and any event ingested via observer.ingest. Newest first. The summary
|
|
263
|
+
// counts cover the whole filtered set, not just the returned page.
|
|
264
|
+
function queryTraces(args = {}) {
|
|
265
|
+
// project default: current project; explicit null/'' disables the filter
|
|
266
|
+
const project =
|
|
267
|
+
args.project === undefined || args.project === null ? projectName() || null : String(args.project) || null
|
|
268
|
+
const eventType = args.event_type ? String(args.event_type).trim().toLowerCase() : null
|
|
269
|
+
const outcome = args.outcome ? String(args.outcome).trim().toLowerCase() : null
|
|
270
|
+
const agent = args.agent ? String(args.agent).trim() : null
|
|
271
|
+
const host = args.host ? String(args.host).trim() : null
|
|
272
|
+
const since = parseTimeArg(args.since)
|
|
273
|
+
const until = parseTimeArg(args.until)
|
|
274
|
+
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 200)
|
|
275
|
+
const includePayload = args.include_payload !== false
|
|
276
|
+
|
|
277
|
+
const all = readJsonl(tracePath)
|
|
278
|
+
const matched = all.filter((trace) => {
|
|
279
|
+
if (!trace || typeof trace !== 'object') return false
|
|
280
|
+
if (project && trace.project !== project) return false
|
|
281
|
+
if (eventType && String(trace.event_type || '').toLowerCase() !== eventType) return false
|
|
282
|
+
if (outcome && String(trace.outcome || '').toLowerCase() !== outcome) return false
|
|
283
|
+
if (agent && String(trace.agent || '') !== agent) return false
|
|
284
|
+
if (host && String(trace.host || '') !== host) return false
|
|
285
|
+
const ts = Date.parse(trace.timestamp || '')
|
|
286
|
+
if (since !== null && !(Number.isFinite(ts) && ts >= since)) return false
|
|
287
|
+
if (until !== null && !(Number.isFinite(ts) && ts <= until)) return false
|
|
288
|
+
return true
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const byEventType = {}
|
|
292
|
+
const byOutcome = {}
|
|
293
|
+
for (const trace of matched) {
|
|
294
|
+
const et = String(trace.event_type || 'unknown')
|
|
295
|
+
byEventType[et] = (byEventType[et] || 0) + 1
|
|
296
|
+
const oc = String(trace.outcome || 'none')
|
|
297
|
+
byOutcome[oc] = (byOutcome[oc] || 0) + 1
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const results = []
|
|
301
|
+
for (let i = matched.length - 1; i >= 0 && results.length < limit; i -= 1) {
|
|
302
|
+
const trace = matched[i]
|
|
303
|
+
results.push(
|
|
304
|
+
includePayload
|
|
305
|
+
? trace
|
|
306
|
+
: {
|
|
307
|
+
...trace,
|
|
308
|
+
payload: {
|
|
309
|
+
trace_id: trace.trace_id || null,
|
|
310
|
+
project: trace.project || null,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
project,
|
|
318
|
+
filters: {
|
|
319
|
+
event_type: eventType,
|
|
320
|
+
outcome: outcome,
|
|
321
|
+
agent: agent,
|
|
322
|
+
host: host,
|
|
323
|
+
since: since !== null ? new Date(since).toISOString() : null,
|
|
324
|
+
until: until !== null ? new Date(until).toISOString() : null,
|
|
325
|
+
},
|
|
326
|
+
total: all.length,
|
|
327
|
+
matched: matched.length,
|
|
328
|
+
summary: { byEventType, byOutcome },
|
|
329
|
+
count: results.length,
|
|
330
|
+
results,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function recordMemory(args = {}) {
|
|
335
|
+
const content = String(args.content || '').trim()
|
|
336
|
+
if (!content) throw new Error('memory.record requires a non-empty content')
|
|
337
|
+
const scope = normalizeScope(args.scope)
|
|
338
|
+
const record = {
|
|
339
|
+
id: createId('mem'),
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
kind: args.kind || 'note',
|
|
342
|
+
content,
|
|
343
|
+
tags: normalizeTags(args.tags),
|
|
344
|
+
project: scope === 'global' ? null : args.project || projectName(),
|
|
345
|
+
scope,
|
|
346
|
+
source: args.source || agentId() || 'mcp',
|
|
347
|
+
}
|
|
348
|
+
appendJsonl(memoryPath, record)
|
|
349
|
+
return record
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
memoryPath,
|
|
354
|
+
experienceReusePath,
|
|
355
|
+
tracePath,
|
|
356
|
+
queryMemory,
|
|
357
|
+
recordMemory,
|
|
358
|
+
queryTraces,
|
|
359
|
+
loadEvidenceWeights,
|
|
360
|
+
// pure helpers, exported for tests and other consumers
|
|
361
|
+
createId,
|
|
362
|
+
readJsonl,
|
|
363
|
+
appendJsonl,
|
|
364
|
+
normalizeTags,
|
|
365
|
+
tokenize,
|
|
366
|
+
normalizeScope,
|
|
367
|
+
isGlobalRecord,
|
|
368
|
+
matchesProjectScope,
|
|
369
|
+
latinTokens,
|
|
370
|
+
cjkBigrams,
|
|
371
|
+
scoreRecord,
|
|
372
|
+
matchesMemoryFilters,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = { createMemoryStore }
|