mio-agent-runtime 0.5.17 → 0.5.22

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,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 }