mio-agent-runtime 0.3.6 → 0.3.8

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.
@@ -16,8 +16,13 @@ const dataDir = path.resolve(
16
16
  const memoryPath = path.join(dataDir, 'memory.jsonl')
17
17
  const tracePath = path.join(dataDir, 'traces.jsonl')
18
18
  const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
19
+ const queryPath = path.join(dataDir, 'queries.jsonl')
20
+ const subscriptionPath = path.join(dataDir, 'subscriptions.jsonl')
21
+ const digestStatePath = path.join(dataDir, 'digest_state.json')
19
22
  fs.mkdirSync(dataDir, { recursive: true })
20
23
 
24
+ const { loadPhase0, summarizePhase0, renderPhase0Markdown } = require('./phase0')
25
+
21
26
  let runtimeContext = {}
22
27
  try {
23
28
  const rawContext = process.env.MIO_CONTEXT
@@ -35,6 +40,44 @@ function runtimeAgentId() {
35
40
  return runtimeContext.agentId || null
36
41
  }
37
42
 
43
+ // Phase 0 auto-claim: match memory.query results with a later task_outcome
44
+ // from the same agent+project, so reuse evidence does not depend on the
45
+ // agent self-reporting mio.experience.reuse.
46
+ const REUSE_MATCH_WINDOW_MS = (() => {
47
+ const minutes = Number(process.env.MIO_REUSE_MATCH_WINDOW_MIN)
48
+ return Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : 60 * 60 * 1000
49
+ })()
50
+ const MAX_RECENT_QUERIES = 200
51
+ let recentQueries = []
52
+
53
+ function loadRecentQueries() {
54
+ const now = Date.now()
55
+ const entries = readJsonl(queryPath)
56
+ .filter(
57
+ (entry) =>
58
+ entry &&
59
+ typeof entry.agent === 'string' &&
60
+ entry.agent &&
61
+ typeof entry.expiresAt === 'number' &&
62
+ entry.expiresAt > now
63
+ )
64
+ .slice(-MAX_RECENT_QUERIES)
65
+ recentQueries = entries
66
+ return entries
67
+ }
68
+
69
+ function persistRecentQueries(entries) {
70
+ const limited = entries.slice(-MAX_RECENT_QUERIES)
71
+ fs.mkdirSync(dataDir, { recursive: true })
72
+ fs.writeFileSync(
73
+ queryPath,
74
+ limited.length > 0 ? limited.map((entry) => JSON.stringify(entry)).join('\n') + '\n' : '',
75
+ 'utf8'
76
+ )
77
+ recentQueries = limited
78
+ return limited
79
+ }
80
+
38
81
  function createId(prefix) {
39
82
  return `${prefix}_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`
40
83
  }
@@ -43,6 +86,15 @@ function appendJsonl(file, value) {
43
86
  fs.appendFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
44
87
  }
45
88
 
89
+ function writeJsonl(file, values) {
90
+ fs.mkdirSync(path.dirname(file), { recursive: true })
91
+ fs.writeFileSync(
92
+ file,
93
+ values.length > 0 ? values.map((value) => JSON.stringify(value)).join('\n') + '\n' : '',
94
+ 'utf8'
95
+ )
96
+ }
97
+
46
98
  function readJsonl(file) {
47
99
  if (!fs.existsSync(file)) return []
48
100
  return fs
@@ -115,36 +167,158 @@ function tokenize(text) {
115
167
  return [...latin, ...cjk]
116
168
  }
117
169
 
118
- function scoreRecord(record, queryTokens, project) {
170
+ function normalizeScope(value) {
171
+ const s = String(value || 'project').trim().toLowerCase()
172
+ if (s === 'global' || s === 'all') return s
173
+ return 'project'
174
+ }
175
+
176
+ function isGlobalRecord(record) {
177
+ return String(record.scope || '').trim().toLowerCase() === 'global'
178
+ }
179
+
180
+ function matchesProjectScope(record, project, scope) {
181
+ const s = normalizeScope(scope)
182
+ if (s === 'global') return isGlobalRecord(record)
183
+ if (s === 'all') {
184
+ if (isGlobalRecord(record)) return true
185
+ return !project || record.project === project
186
+ }
187
+ // project (default): project-scoped records matching the project only
188
+ if (isGlobalRecord(record)) return false
189
+ return !project || record.project === project
190
+ }
191
+
192
+ function latinTokens(text) {
193
+ return String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []
194
+ }
195
+
196
+ function cjkBigrams(text) {
197
+ const chars = String(text || '').toLowerCase().match(/[\u4e00-\u9fff]/g) || []
198
+ const bigrams = new Set()
199
+ for (let i = 0; i + 1 < chars.length; i += 1) {
200
+ bigrams.add(`${chars[i]}${chars[i + 1]}`)
201
+ }
202
+ return bigrams
203
+ }
204
+
205
+ function scoreRecord(record, queryText, project, evidence) {
119
206
  if (project && record.project && record.project !== project) return 0
120
- const haystack = tokenize(
121
- `${record.content || ''} ${(record.tags || []).join(' ')} ${record.kind || ''}`
122
- )
207
+ const content = `${record.content || ''} ${(record.tags || []).join(' ')} ${record.kind || ''}`
208
+ const haystackTokens = tokenize(content)
209
+ const haystackLatin = latinTokens(content)
210
+ const queryTokens = tokenize(queryText)
123
211
  let score = 0
124
212
  for (const token of queryTokens) {
125
- if (haystack.includes(token)) score += 1
213
+ if (haystackTokens.includes(token)) score += 2
214
+ }
215
+ const queryLatin = queryTokens.filter((token) => !token.startsWith('cjk:'))
216
+ for (const token of queryLatin) {
217
+ if (
218
+ token.length >= 3 &&
219
+ haystackLatin.some((item) => item.startsWith(token) || token.startsWith(item))
220
+ ) {
221
+ score += 1
222
+ }
126
223
  }
127
- if (record.timestamp) {
224
+ const queryBigrams = cjkBigrams(queryText)
225
+ const haystackBigrams = cjkBigrams(content)
226
+ if (queryBigrams.size > 0 && haystackBigrams.size > 0) {
227
+ let matched = 0
228
+ for (const bigram of queryBigrams) {
229
+ if (haystackBigrams.has(bigram)) matched += 1
230
+ }
231
+ score += matched * 1.5
232
+ }
233
+ // Recency only boosts ranking among actual matches; it must not enable recall.
234
+ if (score > 0 && record.timestamp) {
128
235
  const ageDays = Math.max(0, (Date.now() - Date.parse(record.timestamp)) / 86400000)
129
236
  score += Math.max(0, 0.5 - ageDays * 0.05)
130
237
  }
238
+ // Evidence weighting (P2): memories successfully reused rank higher; never enables recall.
239
+ if (score > 0 && evidence && evidence.has(record.id)) {
240
+ const ev = evidence.get(record.id)
241
+ score += Math.min(2, ev.reuseCount * 0.6 + (ev.confirmedCount > 0 ? 1 : 0))
242
+ }
131
243
  return score
132
244
  }
133
245
 
246
+ function matchesMemoryFilters(record, kind, tags) {
247
+ if (kind) {
248
+ const recordKind = String(record.kind || '').trim().toLowerCase()
249
+ if (recordKind !== kind) return false
250
+ }
251
+ if (tags.length > 0) {
252
+ const recordTags = new Set(
253
+ (record.tags || []).map((tag) => String(tag).trim().toLowerCase()),
254
+ )
255
+ for (const tag of tags) {
256
+ if (!recordTags.has(tag)) return false
257
+ }
258
+ }
259
+ return true
260
+ }
261
+
134
262
  function queryMemory(args = {}) {
135
263
  const query = String(args.query || '')
136
264
  const project = args.project || projectName()
137
265
  const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 20)
138
- const tokens = tokenize(query)
266
+ const kind = args.kind ? String(args.kind).trim().toLowerCase() : null
267
+ const tags = Array.isArray(args.tags)
268
+ ? args.tags.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean)
269
+ : []
270
+ const scope = normalizeScope(args.scope)
271
+ const evidence = loadEvidenceWeights()
272
+ const scoreFor = (record) =>
273
+ scoreRecord(record, query, project, evidence) +
274
+ (scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
139
275
  const results = readJsonl(memoryPath)
140
- .filter((record) => scoreRecord(record, tokens, project) > 0)
276
+ .filter((record) => record.archived !== true)
277
+ .filter((record) => matchesMemoryFilters(record, kind, tags))
278
+ .filter((record) => matchesProjectScope(record, project, scope))
279
+ .filter((record) => scoreFor(record) > 0)
141
280
  .sort((a, b) => {
142
- return scoreRecord(b, tokens, project) - scoreRecord(a, tokens, project)
281
+ return scoreFor(b) - scoreFor(a)
143
282
  })
144
283
  .slice(0, limit)
284
+ .map((record) => {
285
+ const ev = evidence.get(record.id)
286
+ if (!ev) return record
287
+ return {
288
+ ...record,
289
+ evidence: {
290
+ reuseCount: ev.reuseCount,
291
+ confirmedCount: ev.confirmedCount,
292
+ lastReusedAt: ev.lastReusedAt,
293
+ },
294
+ }
295
+ })
296
+ const layerCounts = { project: 0, global: 0 }
297
+ for (const record of results) {
298
+ if (record.scope === 'global' || !record.project) layerCounts.global += 1
299
+ else layerCounts.project += 1
300
+ }
301
+ if (results.length > 0) {
302
+ const timestamp = Date.now()
303
+ const entries = loadRecentQueries()
304
+ entries.push({
305
+ agent: runtimeAgentId() || 'mcp',
306
+ project,
307
+ query,
308
+ resultIds: results.map((record) => record.id).filter(Boolean),
309
+ resultSources: results.map((record) => record.source || null),
310
+ timestamp,
311
+ expiresAt: timestamp + REUSE_MATCH_WINDOW_MS,
312
+ })
313
+ persistRecentQueries(entries)
314
+ }
145
315
  return {
146
316
  query,
147
317
  project,
318
+ scope,
319
+ layers: layerCounts,
320
+ kind: kind || null,
321
+ tags: tags,
148
322
  count: results.length,
149
323
  results,
150
324
  }
@@ -153,13 +327,15 @@ function queryMemory(args = {}) {
153
327
  function recordMemory(args = {}) {
154
328
  const content = String(args.content || '').trim()
155
329
  if (!content) throw new Error('memory.record requires a non-empty content')
330
+ const scope = normalizeScope(args.scope)
156
331
  const record = {
157
332
  id: createId('mem'),
158
333
  timestamp: new Date().toISOString(),
159
334
  kind: args.kind || 'note',
160
335
  content,
161
336
  tags: normalizeTags(args.tags),
162
- project: args.project || projectName(),
337
+ project: scope === 'global' ? null : args.project || projectName(),
338
+ scope,
163
339
  source: args.source || runtimeAgentId() || 'mcp',
164
340
  }
165
341
  appendJsonl(memoryPath, record)
@@ -183,9 +359,543 @@ function ingestObservation(args = {}) {
183
359
  project: args.project || projectName(),
184
360
  }
185
361
  appendJsonl(tracePath, event)
362
+ const autoClaims = autoClaimExperienceReuse(event)
363
+ if (autoClaims.length > 0) {
364
+ return { recorded: true, event, autoClaims }
365
+ }
186
366
  return { recorded: true, event }
187
367
  }
188
368
 
369
+ function autoClaimExperienceReuse(event) {
370
+ if (String(event.event_type || '').toLowerCase() !== 'task_outcome') return []
371
+ const targetAgent = String(event.agent || '').trim()
372
+ if (!targetAgent) return []
373
+ const now = Date.now()
374
+ const claims = []
375
+ const remaining = []
376
+ const entries = loadRecentQueries()
377
+ for (const entry of entries) {
378
+ const expired = now - entry.timestamp > REUSE_MATCH_WINDOW_MS
379
+ const matches =
380
+ entry.agent === targetAgent && (!event.project || entry.project === event.project)
381
+ if (expired || matches) {
382
+ if (matches && !expired) {
383
+ claims.push(buildAutoClaim(entry, event, targetAgent))
384
+ }
385
+ continue
386
+ }
387
+ remaining.push(entry)
388
+ }
389
+ persistRecentQueries(remaining)
390
+ for (const evidence of claims) {
391
+ appendJsonl(experienceReusePath, evidence)
392
+ }
393
+ return claims
394
+ }
395
+
396
+ function buildAutoClaim(entry, event, targetAgent) {
397
+ const crossSourceIndex = entry.resultSources.findIndex(
398
+ (source) => source && String(source).toLowerCase() !== targetAgent.toLowerCase()
399
+ )
400
+ const sourceAgent =
401
+ crossSourceIndex >= 0
402
+ ? String(entry.resultSources[crossSourceIndex])
403
+ : targetAgent
404
+ const experienceId =
405
+ entry.resultIds[crossSourceIndex >= 0 ? crossSourceIndex : 0] ||
406
+ entry.resultIds[0] ||
407
+ 'unknown'
408
+ const outcomeImproved = String(event.outcome || '').toLowerCase() === 'success'
409
+ return {
410
+ id: createId('xfer'),
411
+ timestamp: new Date().toISOString(),
412
+ sourceAgent,
413
+ targetAgent,
414
+ experienceId,
415
+ reuse: true,
416
+ behaviorChanged: false,
417
+ outcomeImproved,
418
+ project: entry.project,
419
+ source: 'auto_claim',
420
+ traceId: event.trace_id || null,
421
+ notes: `Auto-claimed: memory.query "${entry.query}" matched task_outcome ${event.outcome || 'unknown'} in trace ${event.trace_id || 'unknown'}.`,
422
+ }
423
+ }
424
+
425
+ const SUBSCRIPTION_DEFAULT_TTL_DAYS = 30
426
+ const MAX_SUBSCRIPTIONS_PER_AGENT = 20
427
+ const MAX_DIGEST_EVENTS = 100
428
+
429
+ function readDigestState() {
430
+ if (!fs.existsSync(digestStatePath)) return {}
431
+ try {
432
+ const parsed = JSON.parse(fs.readFileSync(digestStatePath, 'utf8'))
433
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
434
+ } catch (_) {
435
+ return {}
436
+ }
437
+ }
438
+
439
+ function writeDigestState(state) {
440
+ fs.mkdirSync(dataDir, { recursive: true })
441
+ fs.writeFileSync(digestStatePath, JSON.stringify(state), 'utf8')
442
+ }
443
+
444
+ function subscribeKey(agent, project, eventTypes, topic) {
445
+ return `${agent}|${project}|${eventTypes.join(',')}|${topic || ''}`
446
+ }
447
+
448
+ function loadActiveSubscriptions(agent) {
449
+ const now = Date.now()
450
+ return readJsonl(subscriptionPath)
451
+ .filter((subscription) => subscription && subscription.agent === agent)
452
+ .filter((subscription) => {
453
+ if (subscription.expiresAt) {
454
+ const expires = new Date(subscription.expiresAt).getTime()
455
+ if (Number.isFinite(expires) && expires <= now) return false
456
+ }
457
+ return true
458
+ })
459
+ }
460
+
461
+ function subscribeObserver(args = {}) {
462
+ const agent = runtimeAgentId() || 'mcp'
463
+ const project = args.project || projectName()
464
+ const eventTypes = Array.isArray(args.eventTypes)
465
+ ? args.eventTypes.map((value) => String(value).trim().toLowerCase()).filter(Boolean)
466
+ : []
467
+ const topic = args.topic ? String(args.topic).trim().slice(0, 120) : ''
468
+ const ttlDays = Number(args.ttlDays) || SUBSCRIPTION_DEFAULT_TTL_DAYS
469
+ const ttlMs = Math.max(1, ttlDays) * 24 * 60 * 60 * 1000
470
+
471
+ const subscriptions = readJsonl(subscriptionPath)
472
+ const key = subscribeKey(agent, project, eventTypes, topic)
473
+ let existing = subscriptions.find((subscription) => {
474
+ return (
475
+ subscription &&
476
+ subscribeKey(
477
+ subscription.agent,
478
+ subscription.project,
479
+ subscription.eventTypes || [],
480
+ subscription.topic || '',
481
+ ) === key
482
+ )
483
+ })
484
+ const now = new Date()
485
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString()
486
+ if (existing) {
487
+ existing.updatedAt = now.toISOString()
488
+ existing.expiresAt = expiresAt
489
+ } else {
490
+ existing = {
491
+ id: createId('sub'),
492
+ agent,
493
+ project,
494
+ eventTypes,
495
+ topic: topic || null,
496
+ createdAt: now.toISOString(),
497
+ updatedAt: now.toISOString(),
498
+ expiresAt,
499
+ }
500
+ subscriptions.push(existing)
501
+ }
502
+ const agentSubscriptions = subscriptions.filter((subscription) => subscription.agent === agent)
503
+ if (agentSubscriptions.length > MAX_SUBSCRIPTIONS_PER_AGENT) {
504
+ throw new Error(`Too many subscriptions for agent ${agent} (max ${MAX_SUBSCRIPTIONS_PER_AGENT})`)
505
+ }
506
+ writeJsonl(subscriptionPath, subscriptions)
507
+ return { subscribed: true, subscription: existing, project }
508
+ }
509
+
510
+ function observerDigest(args = {}) {
511
+ const agent = runtimeAgentId() || 'mcp'
512
+ const project = args.project || projectName()
513
+ const limit = Math.min(Math.max(Number(args.limit) || 20, 1), MAX_DIGEST_EVENTS)
514
+ const filterEventTypes = Array.isArray(args.eventTypes)
515
+ ? args.eventTypes.map((value) => String(value).trim().toLowerCase()).filter(Boolean)
516
+ : null
517
+
518
+ let subscriptions = loadActiveSubscriptions(agent)
519
+ if (args.project) {
520
+ subscriptions = subscriptions.filter((subscription) => subscription.project === project)
521
+ }
522
+ if (filterEventTypes) {
523
+ subscriptions = subscriptions.filter((subscription) => {
524
+ if ((subscription.eventTypes || []).length === 0) return true
525
+ return filterEventTypes.some((eventType) =>
526
+ (subscription.eventTypes || []).includes(eventType),
527
+ )
528
+ })
529
+ }
530
+
531
+ const state = readDigestState()
532
+ const traces = readJsonl(tracePath)
533
+ const digestTime = new Date().toISOString()
534
+ const events = []
535
+ const seenIds = new Set()
536
+ const matchedSubscriptionIds = new Set()
537
+
538
+ for (const subscription of subscriptions) {
539
+ const cursor = state[subscription.id] || null
540
+ const cursorTime = cursor ? Date.parse(cursor) : null
541
+ const subscriptionEventTypes = subscription.eventTypes || []
542
+ let lastEventTime = null
543
+ for (const event of traces) {
544
+ if (subscription.project && event.project && event.project !== subscription.project) {
545
+ continue
546
+ }
547
+ if (
548
+ subscriptionEventTypes.length > 0 &&
549
+ !subscriptionEventTypes.includes(String(event.event_type || '').toLowerCase())
550
+ ) {
551
+ continue
552
+ }
553
+ if (subscription.topic) {
554
+ const haystack = `${event.event_type || ''} ${JSON.stringify(event.payload || {})}`
555
+ .toLowerCase()
556
+ if (!haystack.includes(subscription.topic.toLowerCase())) continue
557
+ }
558
+ const eventTime = event.timestamp ? Date.parse(event.timestamp) : null
559
+ if (cursorTime !== null && eventTime !== null && eventTime <= cursorTime) continue
560
+ if (eventTime !== null && (lastEventTime === null || eventTime > lastEventTime)) {
561
+ lastEventTime = eventTime
562
+ }
563
+ if (seenIds.has(event.id)) continue
564
+ seenIds.add(event.id)
565
+ events.push({
566
+ id: event.id,
567
+ trace_id: event.trace_id || null,
568
+ event_type: event.event_type,
569
+ outcome: event.outcome || null,
570
+ timestamp: event.timestamp,
571
+ agent: event.agent || null,
572
+ payload: event.payload || null,
573
+ })
574
+ matchedSubscriptionIds.add(subscription.id)
575
+ if (events.length >= limit) break
576
+ }
577
+ if (lastEventTime !== null) {
578
+ state[subscription.id] = new Date(lastEventTime).toISOString()
579
+ } else if (cursor === null) {
580
+ state[subscription.id] = digestTime
581
+ }
582
+ if (events.length >= limit) break
583
+ }
584
+
585
+ writeDigestState(state)
586
+ return {
587
+ project,
588
+ agent,
589
+ subscriptionCount: subscriptions.length,
590
+ matchedSubscriptions: Array.from(matchedSubscriptionIds),
591
+ count: events.length,
592
+ events,
593
+ }
594
+ }
595
+
596
+ function contentTokens(value) {
597
+ const text = String(value || '').toLowerCase()
598
+ const latin = text.match(/[a-z0-9]+/g) || []
599
+ const chars = text.match(/[\u4e00-\u9fff]/g) || []
600
+ const bigrams = []
601
+ for (let i = 0; i < chars.length - 1; i++) {
602
+ bigrams.push(`${chars[i]}${chars[i + 1]}`)
603
+ }
604
+ return [...latin, ...bigrams]
605
+ }
606
+
607
+ function jaccardSimilarity(a, b) {
608
+ const setA = new Set(contentTokens(a))
609
+ const setB = new Set(contentTokens(b))
610
+ if (setA.size === 0 || setB.size === 0) return 0
611
+ let intersection = 0
612
+ for (const token of setA) {
613
+ if (setB.has(token)) intersection += 1
614
+ }
615
+ const union = setA.size + setB.size - intersection
616
+ return union === 0 ? 0 : intersection / union
617
+ }
618
+
619
+ function findDuplicateGroups(records, limit) {
620
+ const parent = new Map()
621
+ for (const record of records) parent.set(record.id, record.id)
622
+ const find = (id) => {
623
+ while (parent.get(id) !== id) {
624
+ parent.set(id, parent.get(parent.get(id)))
625
+ id = parent.get(id)
626
+ }
627
+ return id
628
+ }
629
+ const union = (a, b) => {
630
+ const ra = find(a)
631
+ const rb = find(b)
632
+ if (ra !== rb) parent.set(ra, rb)
633
+ }
634
+ for (let i = 0; i < records.length; i++) {
635
+ for (let j = i + 1; j < records.length; j++) {
636
+ if (
637
+ jaccardSimilarity(records[i].content, records[j].content) >= MEMORY_DUPLICATE_SIMILARITY
638
+ ) {
639
+ union(records[i].id, records[j].id)
640
+ }
641
+ }
642
+ }
643
+ const byRoot = new Map()
644
+ for (const record of records) {
645
+ const root = find(record.id)
646
+ if (!byRoot.has(root)) byRoot.set(root, [])
647
+ byRoot.get(root).push(record)
648
+ }
649
+ return Array.from(byRoot.values())
650
+ .filter((group) => group.length >= 2)
651
+ .sort((a, b) => b.length - a.length)
652
+ .slice(0, limit)
653
+ }
654
+
655
+ function analyzeMemory(args = {}) {
656
+ const project = args.project || projectName()
657
+ const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 20)
658
+ const records = readJsonl(memoryPath).filter(
659
+ (record) => !project || record.project === project || !record.project,
660
+ )
661
+ const archived = records.filter((record) => record.archived === true).length
662
+ const activeRecords = records.filter((record) => record.archived !== true)
663
+ const total = activeRecords.length
664
+ const layerCounts = { project: 0, global: 0 }
665
+ for (const record of activeRecords) {
666
+ if (record.scope === 'global' || !record.project) layerCounts.global += 1
667
+ else layerCounts.project += 1
668
+ }
669
+
670
+ const byKind = {}
671
+ const lowQuality = []
672
+ for (const record of activeRecords) {
673
+ const kind = String(record.kind || '').trim() || 'unknown'
674
+ byKind[kind] = (byKind[kind] || 0) + 1
675
+ const content = String(record.content || '').trim()
676
+ const issues = []
677
+ if (!content) issues.push('missing content')
678
+ else if (content.length < MEMORY_SHORT_CONTENT_MIN) issues.push('content too short')
679
+ if (!record.kind || !String(record.kind).trim()) issues.push('missing kind')
680
+ if (issues.length > 0) {
681
+ lowQuality.push({
682
+ id: record.id,
683
+ timestamp: record.timestamp,
684
+ kind: record.kind || null,
685
+ content: content.slice(0, 200) || null,
686
+ issues,
687
+ })
688
+ }
689
+ }
690
+
691
+ let duplicateGroups = []
692
+ let duplicatesSkipped = false
693
+ if (activeRecords.length > MAX_PAIRWISE_MEMORY) {
694
+ duplicatesSkipped = true
695
+ } else {
696
+ duplicateGroups = findDuplicateGroups(activeRecords, limit).map((group) => ({
697
+ size: group.length,
698
+ records: group.map((record) => ({
699
+ id: record.id,
700
+ timestamp: record.timestamp,
701
+ kind: record.kind || null,
702
+ content: String(record.content || '').slice(0, 200),
703
+ })),
704
+ }))
705
+ }
706
+
707
+ const issues = {
708
+ lowQuality: lowQuality.length,
709
+ duplicateGroups: duplicateGroups.length,
710
+ duplicatesSkipped,
711
+ }
712
+
713
+ const suggestions = []
714
+ if (duplicateGroups.length > 0) {
715
+ suggestions.push(
716
+ `Found ${duplicateGroups.length} duplicate group(s); consider archiving or merging duplicates to keep recall precise.`,
717
+ )
718
+ }
719
+ if (lowQuality.length > 0) {
720
+ suggestions.push(
721
+ `${lowQuality.length} low-quality record(s) (missing kind/content or too short); consider fixing or removing them.`,
722
+ )
723
+ }
724
+ if (total === 0) {
725
+ suggestions.push('No memory records for this project yet.')
726
+ } else if (duplicateGroups.length === 0 && lowQuality.length === 0) {
727
+ suggestions.push('Memory looks healthy; no dedup or quality fixes needed.')
728
+ }
729
+
730
+ return {
731
+ project: project || null,
732
+ total,
733
+ archived,
734
+ layers: layerCounts,
735
+ byKind,
736
+ issues,
737
+ duplicates: duplicateGroups,
738
+ lowQuality: lowQuality.slice(0, limit),
739
+ suggestions,
740
+ }
741
+ }
742
+
743
+ function migrateMemory(args = {}) {
744
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
745
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
746
+ if (ids.length === 0) throw new Error('memory.migrate requires ids (array of memory record ids)')
747
+ const targetScope = normalizeScope(args.scope)
748
+ if (targetScope === 'all') throw new Error('memory.migrate scope must be project or global')
749
+ const project = args.project || projectName()
750
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
751
+
752
+ const records = readJsonl(memoryPath)
753
+ const idSet = new Set(ids)
754
+ const updated = []
755
+ const unchanged = []
756
+ const notFound = [...ids]
757
+ for (const record of records) {
758
+ if (!idSet.has(record.id)) continue
759
+ const fromScope = record.scope || 'project'
760
+ const fromProject = record.project || null
761
+ if (fromScope === targetScope) {
762
+ unchanged.push(record.id)
763
+ } else {
764
+ record.scope = targetScope
765
+ record.project = targetScope === 'global' ? null : project
766
+ record.migratedAt = new Date().toISOString()
767
+ record.migratedFrom = fromScope === 'global' ? 'global' : fromProject || 'project'
768
+ if (reason) record.migrateReason = reason
769
+ updated.push(record.id)
770
+ }
771
+ const index = notFound.indexOf(record.id)
772
+ if (index >= 0) notFound.splice(index, 1)
773
+ }
774
+ writeJsonl(memoryPath, records)
775
+
776
+ return {
777
+ project: project || null,
778
+ scope: targetScope,
779
+ updated,
780
+ updatedCount: updated.length,
781
+ unchanged,
782
+ notFound,
783
+ }
784
+ }
785
+ function archiveMemory(args = {}) {
786
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
787
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
788
+ if (ids.length === 0) throw new Error('memory.archive requires ids (array of memory record ids)')
789
+ const project = args.project || projectName()
790
+ const restore = args.restore === true || args.restore === 'true'
791
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
792
+
793
+ const records = readJsonl(memoryPath)
794
+ const idSet = new Set(ids)
795
+ const archived = []
796
+ const notFound = [...ids]
797
+ for (const record of records) {
798
+ if (!idSet.has(record.id)) continue
799
+ if (project && record.project && record.project !== project) continue
800
+ if (restore) {
801
+ if (record.archived === true) {
802
+ delete record.archived
803
+ delete record.archivedAt
804
+ delete record.archiveReason
805
+ archived.push(record.id)
806
+ }
807
+ } else if (record.archived !== true) {
808
+ record.archived = true
809
+ record.archivedAt = new Date().toISOString()
810
+ if (reason) record.archiveReason = reason
811
+ archived.push(record.id)
812
+ }
813
+ const index = notFound.indexOf(record.id)
814
+ if (index >= 0) notFound.splice(index, 1)
815
+ }
816
+ writeJsonl(memoryPath, records)
817
+
818
+ return {
819
+ project: project || null,
820
+ restored: restore,
821
+ archived,
822
+ archivedCount: archived.length,
823
+ notFound,
824
+ }
825
+ }
826
+
827
+ const FAILURE_OUTCOMES = new Set(['failure', 'error', 'aborted', 'retry'])
828
+ const MEMORY_DUPLICATE_SIMILARITY = 0.75
829
+ const MEMORY_SHORT_CONTENT_MIN = 20
830
+ const MAX_PAIRWISE_MEMORY = 1500
831
+
832
+ function isFailureOutcome(value) {
833
+ return FAILURE_OUTCOMES.has(String(value || '').toLowerCase())
834
+ }
835
+
836
+ function summarizeTrace(event) {
837
+ const payload = event.payload
838
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
839
+ if (typeof payload.summary === 'string' && payload.summary.trim()) {
840
+ return payload.summary.trim().slice(0, 160)
841
+ }
842
+ if (typeof payload.tool === 'string' && payload.tool.trim()) {
843
+ return `tool: ${payload.tool.trim().slice(0, 120)}`
844
+ }
845
+ if (typeof payload.error === 'string' && payload.error.trim()) {
846
+ return payload.error.trim().slice(0, 160)
847
+ }
848
+ }
849
+ return String(event.event_type || 'trace').slice(0, 120)
850
+ }
851
+
852
+ function buildPolicyGuidance({ total, failures, risk, failureExamples, relatedMemories }) {
853
+ const guidance = {
854
+ level: 'none',
855
+ rationale: null,
856
+ saferAlternatives: [],
857
+ verificationSteps: [],
858
+ avoid: [],
859
+ hardGate: false,
860
+ }
861
+ if (total === 0) {
862
+ guidance.rationale =
863
+ 'No historical evidence for this action yet. Treat as normal risk and record the outcome.'
864
+ return guidance
865
+ }
866
+ const verifiedMemories = relatedMemories.filter((record) => record.evidence)
867
+ const hasVerified = verifiedMemories.length > 0
868
+ if (risk >= 0.4 && failures >= 2) {
869
+ guidance.level = hasVerified ? 'actionable' : 'advisory'
870
+ guidance.rationale =
871
+ 'Repeated historical failures. Prefer an alternative approach and add rollback/verification.'
872
+ guidance.avoid = failureExamples
873
+ .map((example) => example.summary)
874
+ .filter(Boolean)
875
+ .slice(0, 3)
876
+ } else if (risk >= 0.2 || hasVerified) {
877
+ guidance.level = hasVerified ? 'actionable' : 'advisory'
878
+ guidance.rationale = hasVerified
879
+ ? 'Verified experience exists for this action; apply it before proceeding.'
880
+ : 'Moderate historical risk. Add verification before proceeding.'
881
+ } else {
882
+ guidance.level = 'advisory'
883
+ guidance.rationale = 'Low historical risk. Proceed with normal checks and record the outcome.'
884
+ }
885
+ if (hasVerified) {
886
+ guidance.saferAlternatives = verifiedMemories.slice(0, 3).map((record) => ({
887
+ memoryId: record.id,
888
+ reuseCount: record.evidence.reuseCount,
889
+ content: String(record.content || '').slice(0, 300),
890
+ }))
891
+ }
892
+ guidance.verificationSteps = [
893
+ 'Add an explicit verification step after execution (dry-run, rollback plan, or assertion).',
894
+ 'Record the outcome with mio.observer.ingest so the evidence base improves.',
895
+ ]
896
+ return guidance
897
+ }
898
+
189
899
  function policyCheck(args = {}) {
190
900
  const action = String(args.action || '').trim()
191
901
  if (!action) throw new Error('policy.check requires action')
@@ -199,10 +909,26 @@ function policyCheck(args = {}) {
199
909
  return actionTokens.some((token) => haystack.includes(token))
200
910
  })
201
911
  const total = traces.length
202
- const failures = traces.filter((event) =>
203
- ['failure', 'error', 'aborted', 'retry'].includes(String(event.outcome || '').toLowerCase())
204
- ).length
912
+ const failures = traces.filter((event) => isFailureOutcome(event.outcome)).length
205
913
  const risk = total > 0 ? failures / total : null
914
+ const riskLevel = total === 0 ? 'unknown' : risk >= 0.4 ? 'high' : risk >= 0.2 ? 'moderate' : 'low'
915
+ const outcomeCounts = traces.reduce((counts, event) => {
916
+ const outcome = String(event.outcome || 'unknown').toLowerCase() || 'unknown'
917
+ counts[outcome] = (counts[outcome] || 0) + 1
918
+ return counts
919
+ }, {})
920
+ const failureExamples = traces
921
+ .filter((event) => isFailureOutcome(event.outcome))
922
+ .sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime())
923
+ .slice(0, 3)
924
+ .map((event) => ({
925
+ trace_id: event.trace_id || event.id || null,
926
+ event_type: event.event_type || null,
927
+ outcome: event.outcome || null,
928
+ timestamp: event.timestamp || null,
929
+ agent: event.agent || null,
930
+ summary: summarizeTrace(event),
931
+ }))
206
932
  let suggestion
207
933
  if (total === 0) {
208
934
  suggestion = 'No history for this action. Treat as normal risk and record the outcome.'
@@ -213,24 +939,41 @@ function policyCheck(args = {}) {
213
939
  } else {
214
940
  suggestion = 'Low historical risk. Proceed with normal checks.'
215
941
  }
942
+ const evidence = loadEvidenceWeights()
216
943
  const relatedMemories = readJsonl(memoryPath)
217
- .filter((record) => scoreRecord(record, actionTokens, project) > 0)
218
- .sort((a, b) => scoreRecord(b, actionTokens, project) - scoreRecord(a, actionTokens, project))
944
+ .filter((record) => scoreRecord(record, action, project, evidence) > 0)
945
+ .sort((a, b) => scoreRecord(b, action, project, evidence) - scoreRecord(a, action, project, evidence))
219
946
  .slice(0, 3)
220
- .map((record) => ({
221
- id: record.id,
222
- timestamp: record.timestamp,
223
- kind: record.kind,
224
- content: record.content,
225
- tags: record.tags,
226
- }))
947
+ .map((record) => {
948
+ const ev = evidence.get(record.id)
949
+ return {
950
+ id: record.id,
951
+ timestamp: record.timestamp,
952
+ kind: record.kind,
953
+ content: record.content,
954
+ tags: record.tags,
955
+ evidence: ev
956
+ ? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount }
957
+ : null,
958
+ }
959
+ })
227
960
  return {
228
961
  action,
229
962
  project,
230
963
  total,
231
964
  failures,
232
965
  risk: risk === null ? null : Number(risk.toFixed(3)),
966
+ riskLevel,
967
+ outcomeCounts,
968
+ failureExamples,
233
969
  suggestion,
970
+ guidance: buildPolicyGuidance({
971
+ total,
972
+ failures,
973
+ risk,
974
+ failureExamples,
975
+ relatedMemories,
976
+ }),
234
977
  related_memories: relatedMemories,
235
978
  }
236
979
  }
@@ -243,6 +986,7 @@ function recordExperienceReuse(args = {}) {
243
986
  if (!targetAgent) throw new Error('experience.reuse requires targetAgent')
244
987
  if (!experienceId) throw new Error('experience.reuse requires experienceId')
245
988
 
989
+ const project = args.project || projectName()
246
990
  const evidence = {
247
991
  id: createId('xfer'),
248
992
  timestamp: new Date().toISOString(),
@@ -252,11 +996,279 @@ function recordExperienceReuse(args = {}) {
252
996
  reuse: args.reuse === true || args.reuse === 'true',
253
997
  behaviorChanged: args.behaviorChanged === true || args.behaviorChanged === 'true',
254
998
  outcomeImproved: args.outcomeImproved === true || args.outcomeImproved === 'true',
255
- project: args.project || projectName(),
999
+ project,
1000
+ source: args.source || 'agent_report',
256
1001
  notes: args.notes || null,
257
1002
  }
258
1003
  appendJsonl(experienceReusePath, evidence)
259
- return evidence
1004
+
1005
+ const report = loadPhase0(dataDir, project)
1006
+ return {
1007
+ recorded: true,
1008
+ evidence,
1009
+ phase0: summarizePhase0(report),
1010
+ }
1011
+ }
1012
+
1013
+ function confirmExperienceReuse(args = {}) {
1014
+ const id = String(args.id || '').trim()
1015
+ if (!id) throw new Error('experience.confirm requires id of an auto-claimed reuse record')
1016
+ const records = readJsonl(experienceReusePath)
1017
+ const index = records.findIndex((record) => record && record.id === id)
1018
+ if (index < 0) {
1019
+ throw new Error(`No experience reuse record found with id ${id}`)
1020
+ }
1021
+ const record = records[index]
1022
+ if (record.source !== 'auto_claim') {
1023
+ throw new Error('experience.confirm only applies to source=auto_claim reuse records')
1024
+ }
1025
+ const outcomeImproved =
1026
+ args.outcomeImproved === undefined || args.outcomeImproved === null
1027
+ ? record.outcomeImproved === true
1028
+ : args.outcomeImproved === true || args.outcomeImproved === 'true'
1029
+ const updated = {
1030
+ ...record,
1031
+ behaviorChanged: true,
1032
+ outcomeImproved,
1033
+ confirmed: true,
1034
+ confirmedAt: new Date().toISOString(),
1035
+ confirmedBy: String(args.confirmedBy || 'agent').trim() || 'agent',
1036
+ notes: args.notes ? String(args.notes).trim() : record.notes || null,
1037
+ }
1038
+ records[index] = updated
1039
+ writeJsonl(experienceReusePath, records)
1040
+
1041
+ const report = loadPhase0(dataDir, updated.project)
1042
+ return {
1043
+ confirmed: true,
1044
+ evidence: updated,
1045
+ phase0: summarizePhase0(report),
1046
+ }
1047
+ }
1048
+
1049
+ const REUSE_STATUS_FILTERS = Object.freeze({
1050
+ pending: (record) => record.source === 'auto_claim' && record.confirmed !== true,
1051
+ confirmed: (record) => record.confirmed === true,
1052
+ verified: (record) =>
1053
+ (record.reuse === true || record.reuse === 'true') &&
1054
+ (record.behaviorChanged === true || record.behaviorChanged === 'true') &&
1055
+ (record.outcomeImproved === true || record.outcomeImproved === 'true'),
1056
+ auto_claim: (record) => record.source === 'auto_claim',
1057
+ agent_report: (record) => record.source !== 'auto_claim',
1058
+ })
1059
+
1060
+ function loadEvidenceWeights() {
1061
+ const weights = new Map()
1062
+ for (const record of readJsonl(experienceReusePath)) {
1063
+ if (!REUSE_STATUS_FILTERS.verified(record)) continue
1064
+ const id = record.experienceId
1065
+ if (!id) continue
1066
+ let entry = weights.get(id)
1067
+ if (!entry) {
1068
+ entry = { reuseCount: 0, confirmedCount: 0, lastReusedAt: null }
1069
+ weights.set(id, entry)
1070
+ }
1071
+ entry.reuseCount += 1
1072
+ if (record.confirmed === true) entry.confirmedCount += 1
1073
+ if (!entry.lastReusedAt || new Date(record.timestamp || 0) > new Date(entry.lastReusedAt)) {
1074
+ entry.lastReusedAt = record.timestamp || null
1075
+ }
1076
+ }
1077
+ return weights
1078
+ }
1079
+
1080
+ function listExperienceReuse(args = {}) {
1081
+ const project = args.project || projectName()
1082
+ const status = String(args.status || 'all').trim().toLowerCase()
1083
+ const targetAgent = String(args.targetAgent || '').trim().toLowerCase()
1084
+ const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100)
1085
+
1086
+ let records = readJsonl(experienceReusePath)
1087
+ if (project) records = records.filter((record) => record.project === project)
1088
+ if (targetAgent) {
1089
+ records = records.filter(
1090
+ (record) => String(record.targetAgent || '').toLowerCase() === targetAgent,
1091
+ )
1092
+ }
1093
+ const matcher = REUSE_STATUS_FILTERS[status]
1094
+ if (matcher) records = records.filter(matcher)
1095
+ records.sort((a, b) => {
1096
+ const at = new Date(a.timestamp || 0).getTime()
1097
+ const bt = new Date(b.timestamp || 0).getTime()
1098
+ return bt - at
1099
+ })
1100
+
1101
+ const visible = records.slice(0, limit).map((record) => ({
1102
+ id: record.id,
1103
+ source: record.source,
1104
+ sourceAgent: record.sourceAgent,
1105
+ targetAgent: record.targetAgent,
1106
+ experienceId: record.experienceId,
1107
+ reuse: record.reuse === true || record.reuse === 'true',
1108
+ behaviorChanged: record.behaviorChanged === true || record.behaviorChanged === 'true',
1109
+ outcomeImproved: record.outcomeImproved === true || record.outcomeImproved === 'true',
1110
+ confirmed: record.confirmed === true,
1111
+ confirmedBy: record.confirmedBy || null,
1112
+ traceId: record.traceId || null,
1113
+ timestamp: record.timestamp,
1114
+ project: record.project,
1115
+ notes:
1116
+ typeof record.notes === 'string' && record.notes ? record.notes.slice(0, 300) : null,
1117
+ }))
1118
+
1119
+ return {
1120
+ project: project || null,
1121
+ status,
1122
+ count: visible.length,
1123
+ total: records.length,
1124
+ records: visible,
1125
+ }
1126
+ }
1127
+
1128
+ function routeTask(args = {}) {
1129
+ const task = String(args.task || '').trim()
1130
+ if (!task) throw new Error('task.route requires a non-empty task')
1131
+ const project = args.project || projectName()
1132
+ const scope = normalizeScope(args.scope)
1133
+ const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10)
1134
+
1135
+ const memories = readJsonl(memoryPath).filter((record) => record.archived !== true)
1136
+ const memoryById = new Map(memories.map((record) => [record.id, record]))
1137
+ const evidence = loadEvidenceWeights()
1138
+
1139
+ // Match verified reuse experiences whose source memory is relevant to the task.
1140
+ const routeByMemory = new Map()
1141
+ for (const record of readJsonl(experienceReusePath)) {
1142
+ if (!REUSE_STATUS_FILTERS.verified(record)) continue
1143
+ const memory = memoryById.get(record.experienceId)
1144
+ if (!memory || !matchesProjectScope(memory, project, scope)) continue
1145
+ const score = scoreRecord(memory, task, project)
1146
+ if (score <= 0) continue
1147
+ let entry = routeByMemory.get(record.experienceId)
1148
+ if (!entry) {
1149
+ entry = {
1150
+ experienceId: record.experienceId,
1151
+ memory,
1152
+ weight: 0,
1153
+ confirmed: false,
1154
+ sourceAgents: new Set(),
1155
+ targetAgents: new Set(),
1156
+ reuseCount: 0,
1157
+ }
1158
+ routeByMemory.set(record.experienceId, entry)
1159
+ }
1160
+ entry.reuseCount += 1
1161
+ if (record.confirmed === true) entry.confirmed = true
1162
+ if (record.sourceAgent) entry.sourceAgents.add(record.sourceAgent)
1163
+ if (record.targetAgent) entry.targetAgents.add(record.targetAgent)
1164
+ entry.weight = Math.max(entry.weight, score + (entry.confirmed ? 2 : 1.5))
1165
+ }
1166
+
1167
+ const routes = [...routeByMemory.values()]
1168
+ .sort((a, b) => b.weight - a.weight)
1169
+ .slice(0, limit)
1170
+ .map((entry) => ({
1171
+ experienceId: entry.experienceId,
1172
+ reuseCount: entry.reuseCount,
1173
+ confirmed: entry.confirmed,
1174
+ sourceAgents: [...entry.sourceAgents].filter(Boolean),
1175
+ targetAgents: [...entry.targetAgents].filter(Boolean),
1176
+ score: Number(entry.weight.toFixed(2)),
1177
+ memory: {
1178
+ id: entry.memory.id,
1179
+ timestamp: entry.memory.timestamp,
1180
+ kind: entry.memory.kind,
1181
+ content: entry.memory.content,
1182
+ tags: entry.memory.tags || [],
1183
+ project: entry.memory.project || null,
1184
+ scope: entry.memory.scope || 'project',
1185
+ },
1186
+ }))
1187
+
1188
+ // Broader context: top related memories not already routed.
1189
+ const routedIds = new Set(routes.map((route) => route.experienceId))
1190
+ const scoreRelated = (record) =>
1191
+ scoreRecord(record, task, project, evidence) +
1192
+ (scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
1193
+ const relatedMemories = memories
1194
+ .filter((record) => !routedIds.has(record.id))
1195
+ .filter((record) => matchesProjectScope(record, project, scope))
1196
+ .filter((record) => scoreRelated(record) > 0)
1197
+ .sort((a, b) => scoreRelated(b) - scoreRelated(a))
1198
+ .slice(0, Math.max(limit, 5))
1199
+ .map((record) => {
1200
+ const ev = evidence.get(record.id)
1201
+ return {
1202
+ id: record.id,
1203
+ timestamp: record.timestamp,
1204
+ kind: record.kind,
1205
+ content: record.content,
1206
+ tags: record.tags || [],
1207
+ project: record.project || null,
1208
+ scope: record.scope || 'project',
1209
+ evidence: ev
1210
+ ? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount }
1211
+ : null,
1212
+ }
1213
+ })
1214
+
1215
+ // Feed the query log so a later task_outcome can auto-claim route-driven reuse.
1216
+ if (routes.length > 0 || relatedMemories.length > 0) {
1217
+ const now = Date.now()
1218
+ const entries = loadRecentQueries()
1219
+ entries.push({
1220
+ agent: runtimeAgentId() || 'mcp',
1221
+ project,
1222
+ query: task,
1223
+ resultIds: [...routedIds, ...relatedMemories.map((record) => record.id)].filter(Boolean),
1224
+ resultSources: [...routes.map(() => 'experience'), ...relatedMemories.map((record) => record.source || null)],
1225
+ timestamp: now,
1226
+ expiresAt: now + REUSE_MATCH_WINDOW_MS,
1227
+ })
1228
+ persistRecentQueries(entries)
1229
+ }
1230
+
1231
+ let suggestion
1232
+ if (routes.length > 0) {
1233
+ suggestion =
1234
+ `Route ${routes.length} verified experience(s) to this task; apply the top match first and record the outcome with mio.observer.ingest / mio.experience.reuse.`
1235
+ } else if (relatedMemories.length > 0) {
1236
+ suggestion =
1237
+ 'No verified experience matches yet. Consult the related memories, then record the outcome so this task can seed future routes.'
1238
+ } else {
1239
+ suggestion =
1240
+ 'No matching experience or memory found. Proceed with care and record the outcome to grow the experience base.'
1241
+ }
1242
+
1243
+ return {
1244
+ task,
1245
+ project,
1246
+ scope,
1247
+ count: routes.length,
1248
+ routes,
1249
+ relatedMemories,
1250
+ summary: {
1251
+ verifiedRoutes: routes.length,
1252
+ relatedMemories: relatedMemories.length,
1253
+ suggestion,
1254
+ },
1255
+ }
1256
+ }
1257
+ function phase0Report(args = {}) {
1258
+ const project = args.project || projectName()
1259
+ const report = loadPhase0(dataDir, project)
1260
+ const format = String(args.format || 'json').trim().toLowerCase()
1261
+ if (format === 'markdown') {
1262
+ return {
1263
+ project,
1264
+ format: 'markdown',
1265
+ markdown: renderPhase0Markdown(report),
1266
+ }
1267
+ }
1268
+ return {
1269
+ project,
1270
+ ...report,
1271
+ }
260
1272
  }
261
1273
 
262
1274
  const TOOLS = [
@@ -269,10 +1281,41 @@ const TOOLS = [
269
1281
  query: { type: 'string', description: 'Question or topic to recall.' },
270
1282
  project: { type: 'string', description: 'Project name filter. Defaults to current directory name.' },
271
1283
  limit: { type: 'number', description: 'Maximum results, between 1 and 20. Defaults to 5.' },
1284
+ kind: { type: 'string', description: 'Only return records with this kind (decision, context, problem, note).' },
1285
+ tags: { type: 'array', items: { type: 'string' }, description: 'Only return records containing all of these tags (AND).' },
1286
+ scope: { type: 'string', description: 'Search scope: project (default), global, or all.' },
272
1287
  },
273
1288
  required: ['query'],
274
1289
  },
275
1290
  },
1291
+ {
1292
+ name: 'mio.memory.archive',
1293
+ description: 'Archive (or restore) memory records by id. Archived records are excluded from memory.query and memory.analyze.',
1294
+ inputSchema: {
1295
+ type: 'object',
1296
+ properties: {
1297
+ ids: { type: 'array', items: { type: 'string' }, description: 'Memory record ids to archive or restore.' },
1298
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1299
+ restore: { type: 'boolean', description: 'When true, un-archive the given ids instead.' },
1300
+ reason: { type: 'string', description: 'Optional archive reason.' },
1301
+ },
1302
+ required: ['ids'],
1303
+ },
1304
+ },
1305
+ {
1306
+ name: 'mio.memory.migrate',
1307
+ description: 'Migrate memory records between project and global layers (P3). Moving to global makes the record reusable across projects (project set to null); moving to project restores project scoping.',
1308
+ inputSchema: {
1309
+ type: 'object',
1310
+ properties: {
1311
+ ids: { type: 'array', items: { type: 'string' }, description: 'Memory record ids to migrate.' },
1312
+ scope: { type: 'string', description: 'Target layer: global or project.' },
1313
+ project: { type: 'string', description: 'Target project for project scope. Defaults to current repository.' },
1314
+ reason: { type: 'string', description: 'Optional migration reason.' },
1315
+ },
1316
+ required: ['ids', 'scope'],
1317
+ },
1318
+ },
276
1319
  {
277
1320
  name: 'mio.memory.record',
278
1321
  description: 'Record a decision, project context, or historical problem into local Mio memory.',
@@ -283,6 +1326,7 @@ const TOOLS = [
283
1326
  kind: { type: 'string', description: 'Memory kind, such as decision, context, or problem.' },
284
1327
  tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
285
1328
  project: { type: 'string', description: 'Project name. Defaults to current directory name.' },
1329
+ scope: { type: 'string', description: 'Record scope: project (default) or global.' },
286
1330
  source: { type: 'string', description: 'Recording source. Defaults to mcp.' },
287
1331
  },
288
1332
  required: ['content'],
@@ -305,9 +1349,34 @@ const TOOLS = [
305
1349
  required: ['trace_id', 'event_type'],
306
1350
  },
307
1351
  },
1352
+ {
1353
+ name: 'mio.observer.subscribe',
1354
+ description: 'Subscribe to observer events (project, event types, optional topic). New matching events are delivered via mio.observer.digest.',
1355
+ inputSchema: {
1356
+ type: 'object',
1357
+ properties: {
1358
+ eventTypes: { type: 'array', items: { type: 'string' }, description: 'Event types to watch, e.g. error, retry, task_outcome. Empty means all.' },
1359
+ project: { type: 'string', description: 'Project name. Defaults to current repository.' },
1360
+ topic: { type: 'string', description: 'Optional keyword; only events whose payload mentions it match.' },
1361
+ ttlDays: { type: 'number', description: 'Subscription lifetime in days. Defaults to 30.' },
1362
+ },
1363
+ },
1364
+ },
1365
+ {
1366
+ name: 'mio.observer.digest',
1367
+ description: 'Return new observer events matching active subscriptions since the last digest, and advance the cursor.',
1368
+ inputSchema: {
1369
+ type: 'object',
1370
+ properties: {
1371
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1372
+ eventTypes: { type: 'array', items: { type: 'string' }, description: 'Optional extra event type filter.' },
1373
+ limit: { type: 'number', description: 'Maximum events to return, 1-100. Defaults to 20.' },
1374
+ },
1375
+ },
1376
+ },
308
1377
  {
309
1378
  name: 'mio.policy.check',
310
- description: 'Check historical risk for an action before executing it.',
1379
+ description: 'Check historical risk for an action before executing it, returning risk level, outcome counts, and recent failure examples.',
311
1380
  inputSchema: {
312
1381
  type: 'object',
313
1382
  properties: {
@@ -318,6 +1387,17 @@ const TOOLS = [
318
1387
  required: ['action'],
319
1388
  },
320
1389
  },
1390
+ {
1391
+ name: 'mio.memory.analyze',
1392
+ description: 'Analyze local Mio memory quality: kind distribution, duplicate groups, and low-quality records.',
1393
+ inputSchema: {
1394
+ type: 'object',
1395
+ properties: {
1396
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1397
+ limit: { type: 'number', description: 'Maximum duplicates/low-quality examples to return, 1-20. Defaults to 5.' },
1398
+ },
1399
+ },
1400
+ },
321
1401
  {
322
1402
  name: 'mio.experience.reuse',
323
1403
  description: 'Record Phase 0 evidence of cross-agent experience reuse.',
@@ -336,6 +1416,58 @@ const TOOLS = [
336
1416
  required: ['sourceAgent', 'targetAgent', 'experienceId', 'reuse', 'behaviorChanged', 'outcomeImproved'],
337
1417
  },
338
1418
  },
1419
+ {
1420
+ name: 'mio.experience.confirm',
1421
+ description: 'Confirm an auto-claimed reuse record as verified reuse evidence.',
1422
+ inputSchema: {
1423
+ type: 'object',
1424
+ properties: {
1425
+ id: { type: 'string', description: 'Id of the auto-claimed reuse record returned by mio.observer.ingest.' },
1426
+ confirmedBy: { type: 'string', description: 'Who confirmed the reuse: agent or human. Defaults to agent.' },
1427
+ outcomeImproved: { type: 'boolean', description: 'Override: true when the reused experience improved the task outcome. Defaults to the auto-claim value.' },
1428
+ notes: { type: 'string', description: 'Optional confirmation notes.' },
1429
+ },
1430
+ required: ['id'],
1431
+ },
1432
+ },
1433
+ {
1434
+ name: 'mio.experience.list',
1435
+ description: 'List Phase 0 experience reuse evidence, optionally filtered by status or target agent.',
1436
+ inputSchema: {
1437
+ type: 'object',
1438
+ properties: {
1439
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1440
+ status: { type: 'string', description: 'Filter: pending (auto-claim awaiting confirmation), confirmed, verified, auto_claim, agent_report, all. Defaults to all.' },
1441
+ targetAgent: { type: 'string', description: 'Filter records by target agent.' },
1442
+ limit: { type: 'number', description: 'Maximum records to return, 1-100. Defaults to 20.' },
1443
+ },
1444
+ },
1445
+ },
1446
+ {
1447
+ name: 'mio.phase0.report',
1448
+ description: 'Generate the current Phase 0 cross-agent validation report, as JSON or rendered Markdown.',
1449
+ inputSchema: {
1450
+ type: 'object',
1451
+ properties: {
1452
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1453
+ format: { type: 'string', description: 'Output format: json or markdown. Defaults to json.' },
1454
+ },
1455
+ },
1456
+ },
1457
+ {
1458
+ name: 'mio.task.route',
1459
+ description: 'Route an incoming task to the most relevant verified experiences and related memories, turning recall into decision influence (P1).',
1460
+ inputSchema: {
1461
+ type: 'object',
1462
+ properties: {
1463
+ task: { type: 'string', description: 'Task description to route, e.g. \'fix IPC communication bug\'.' },
1464
+ project: { type: 'string', description: 'Project name filter. Defaults to current repository.' },
1465
+ scope: { type: 'string', description: 'Search scope: project (default), global, or all.' },
1466
+ limit: { type: 'number', description: 'Maximum routes, between 1 and 10. Defaults to 5.' },
1467
+ },
1468
+ required: ['task'],
1469
+ },
1470
+ },
339
1471
  ]
340
1472
 
341
1473
  async function callTool(name, args = {}) {
@@ -344,12 +1476,30 @@ async function callTool(name, args = {}) {
344
1476
  return queryMemory(args)
345
1477
  case 'mio.memory.record':
346
1478
  return recordMemory(args)
1479
+ case 'mio.memory.archive':
1480
+ return archiveMemory(args)
1481
+ case 'mio.memory.migrate':
1482
+ return migrateMemory(args)
347
1483
  case 'mio.observer.ingest':
348
1484
  return ingestObservation(args)
1485
+ case 'mio.observer.subscribe':
1486
+ return subscribeObserver(args)
1487
+ case 'mio.observer.digest':
1488
+ return observerDigest(args)
349
1489
  case 'mio.policy.check':
350
1490
  return policyCheck(args)
1491
+ case 'mio.memory.analyze':
1492
+ return analyzeMemory(args)
351
1493
  case 'mio.experience.reuse':
352
1494
  return recordExperienceReuse(args)
1495
+ case 'mio.experience.confirm':
1496
+ return confirmExperienceReuse(args)
1497
+ case 'mio.experience.list':
1498
+ return listExperienceReuse(args)
1499
+ case 'mio.phase0.report':
1500
+ return phase0Report(args)
1501
+ case 'mio.task.route':
1502
+ return routeTask(args)
353
1503
  default:
354
1504
  throw new Error(`Unknown tool: ${name}`)
355
1505
  }
@@ -426,7 +1576,9 @@ rl.on('line', (line) => {
426
1576
  })
427
1577
 
428
1578
  rl.on('close', () => {
429
- process.exit(0)
1579
+ if (require.main === module) {
1580
+ process.exit(0)
1581
+ }
430
1582
  })
431
1583
 
432
- module.exports = { TOOLS, callTool, handleMessage, readJsonl, appendJsonl }
1584
+ module.exports = { TOOLS, callTool, handleMessage, readJsonl, appendJsonl, rl }