mio-agent-runtime 0.5.23 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +524 -85
- package/bin/mio.js +2861 -758
- package/observe/observer.js +73 -15
- package/package.json +45 -45
- package/scripts/check-syntax.cjs +72 -0
- package/server/agent-store.js +140 -0
- package/server/evolution-report.js +311 -0
- package/server/experience-store.js +214 -0
- package/server/insight-store.js +118 -0
- package/server/llm-client.js +161 -0
- package/server/memory-store.js +505 -3
- package/server/mio-intelligence-mcp/index.js +129 -1521
- package/server/observer-store.js +228 -0
- package/server/policy-store.js +263 -0
- package/server/query-log.js +66 -0
- package/server/subscription-store.js +213 -0
- package/server/task-store.js +385 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Shared subscription + digest store: the single implementation behind
|
|
4
|
+
// mio.observer.subscribe and mio.observer.digest.
|
|
5
|
+
//
|
|
6
|
+
// Kept separate from observer-store.js on purpose: that one is the research
|
|
7
|
+
// pipeline and is project-local (<cwd>/.local/observer), while subscriptions,
|
|
8
|
+
// traces and the digest cursor all live under the MIO_HOME data dir. Mixing them
|
|
9
|
+
// would mean one store with two different notions of "where the data lives".
|
|
10
|
+
//
|
|
11
|
+
// The digest is cursor-based: digest_state.json records, per subscription, the
|
|
12
|
+
// timestamp of the newest event already delivered. Running it again therefore
|
|
13
|
+
// returns only what is new -- which is the point, but also why a digest is not
|
|
14
|
+
// idempotent and the cursor must be written even when nothing matched (otherwise
|
|
15
|
+
// a subscription created just now would replay the whole trace history).
|
|
16
|
+
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
const { createId, readJsonl, writeJsonl } = require('./memory-store.js')
|
|
20
|
+
|
|
21
|
+
const SUBSCRIPTION_DEFAULT_TTL_DAYS = 30
|
|
22
|
+
const MAX_SUBSCRIPTIONS_PER_AGENT = 20
|
|
23
|
+
const MAX_DIGEST_EVENTS = 100
|
|
24
|
+
|
|
25
|
+
function subscribeKey(agent, project, eventTypes, topic) {
|
|
26
|
+
return `${agent}|${project}|${eventTypes.join(',')}|${topic || ''}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createSubscriptionStore(options = {}) {
|
|
30
|
+
const dataDir = options.dataDir
|
|
31
|
+
const projectName = options.projectName || (() => null)
|
|
32
|
+
// Named resolveAgentId so subscribe() can still use `agent` as a local.
|
|
33
|
+
const resolveAgentId = options.agentId || (() => 'mcp')
|
|
34
|
+
|
|
35
|
+
const subscriptionPath = path.join(dataDir, 'subscriptions.jsonl')
|
|
36
|
+
const tracePath = path.join(dataDir, 'traces.jsonl')
|
|
37
|
+
const digestStatePath = path.join(dataDir, 'digest_state.json')
|
|
38
|
+
|
|
39
|
+
function readDigestState() {
|
|
40
|
+
if (!fs.existsSync(digestStatePath)) return {}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(digestStatePath, 'utf8'))
|
|
43
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
44
|
+
} catch (_) {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeDigestState(state) {
|
|
50
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
51
|
+
fs.writeFileSync(digestStatePath, JSON.stringify(state), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadActiveSubscriptions(agent) {
|
|
55
|
+
const now = Date.now()
|
|
56
|
+
return readJsonl(subscriptionPath)
|
|
57
|
+
.filter((subscription) => subscription && subscription.agent === agent)
|
|
58
|
+
.filter((subscription) => {
|
|
59
|
+
if (subscription.expiresAt) {
|
|
60
|
+
const expires = new Date(subscription.expiresAt).getTime()
|
|
61
|
+
if (Number.isFinite(expires) && expires <= now) return false
|
|
62
|
+
}
|
|
63
|
+
return true
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subscribe(args = {}) {
|
|
68
|
+
const agent = resolveAgentId() || 'mcp'
|
|
69
|
+
const project = args.project || projectName()
|
|
70
|
+
const eventTypes = Array.isArray(args.eventTypes)
|
|
71
|
+
? args.eventTypes.map((value) => String(value).trim().toLowerCase()).filter(Boolean)
|
|
72
|
+
: []
|
|
73
|
+
const topic = args.topic ? String(args.topic).trim().slice(0, 120) : ''
|
|
74
|
+
const ttlDays = Number(args.ttlDays) || SUBSCRIPTION_DEFAULT_TTL_DAYS
|
|
75
|
+
const ttlMs = Math.max(1, ttlDays) * 24 * 60 * 60 * 1000
|
|
76
|
+
|
|
77
|
+
const subscriptions = readJsonl(subscriptionPath)
|
|
78
|
+
const key = subscribeKey(agent, project, eventTypes, topic)
|
|
79
|
+
let existing = subscriptions.find((subscription) => {
|
|
80
|
+
return (
|
|
81
|
+
subscription &&
|
|
82
|
+
subscribeKey(
|
|
83
|
+
subscription.agent,
|
|
84
|
+
subscription.project,
|
|
85
|
+
subscription.eventTypes || [],
|
|
86
|
+
subscription.topic || '',
|
|
87
|
+
) === key
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const expiresAt = new Date(now.getTime() + ttlMs).toISOString()
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.updatedAt = now.toISOString()
|
|
94
|
+
existing.expiresAt = expiresAt
|
|
95
|
+
} else {
|
|
96
|
+
existing = {
|
|
97
|
+
id: createId('sub'),
|
|
98
|
+
agent,
|
|
99
|
+
project,
|
|
100
|
+
eventTypes,
|
|
101
|
+
topic: topic || null,
|
|
102
|
+
createdAt: now.toISOString(),
|
|
103
|
+
updatedAt: now.toISOString(),
|
|
104
|
+
expiresAt,
|
|
105
|
+
}
|
|
106
|
+
subscriptions.push(existing)
|
|
107
|
+
}
|
|
108
|
+
const agentSubscriptions = subscriptions.filter(
|
|
109
|
+
(subscription) => subscription.agent === agent,
|
|
110
|
+
)
|
|
111
|
+
if (agentSubscriptions.length > MAX_SUBSCRIPTIONS_PER_AGENT) {
|
|
112
|
+
throw new Error(`Too many subscriptions for agent ${agent} (max ${MAX_SUBSCRIPTIONS_PER_AGENT})`)
|
|
113
|
+
}
|
|
114
|
+
writeJsonl(subscriptionPath, subscriptions)
|
|
115
|
+
return { subscribed: true, subscription: existing, project }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function digest(args = {}) {
|
|
119
|
+
const agent = resolveAgentId() || 'mcp'
|
|
120
|
+
const project = args.project || projectName()
|
|
121
|
+
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), MAX_DIGEST_EVENTS)
|
|
122
|
+
// An empty array means "no filter", not "match nothing" -- otherwise a
|
|
123
|
+
// caller that omits event types (or passes []) would silently exclude every
|
|
124
|
+
// subscription that declares any types at all.
|
|
125
|
+
const filterEventTypes =
|
|
126
|
+
Array.isArray(args.eventTypes) && args.eventTypes.length > 0
|
|
127
|
+
? args.eventTypes.map((value) => String(value).trim().toLowerCase()).filter(Boolean)
|
|
128
|
+
: null
|
|
129
|
+
|
|
130
|
+
let subscriptions = loadActiveSubscriptions(agent)
|
|
131
|
+
if (args.project) {
|
|
132
|
+
subscriptions = subscriptions.filter((subscription) => subscription.project === project)
|
|
133
|
+
}
|
|
134
|
+
if (filterEventTypes) {
|
|
135
|
+
subscriptions = subscriptions.filter((subscription) => {
|
|
136
|
+
if ((subscription.eventTypes || []).length === 0) return true
|
|
137
|
+
return filterEventTypes.some((eventType) =>
|
|
138
|
+
(subscription.eventTypes || []).includes(eventType),
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const state = readDigestState()
|
|
144
|
+
const traces = readJsonl(tracePath)
|
|
145
|
+
const digestTime = new Date().toISOString()
|
|
146
|
+
const events = []
|
|
147
|
+
const seenIds = new Set()
|
|
148
|
+
const matchedSubscriptionIds = new Set()
|
|
149
|
+
|
|
150
|
+
for (const subscription of subscriptions) {
|
|
151
|
+
const cursor = state[subscription.id] || null
|
|
152
|
+
const cursorTime = cursor ? Date.parse(cursor) : null
|
|
153
|
+
const subscriptionEventTypes = subscription.eventTypes || []
|
|
154
|
+
let lastEventTime = null
|
|
155
|
+
for (const event of traces) {
|
|
156
|
+
if (subscription.project && event.project && event.project !== subscription.project) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
subscriptionEventTypes.length > 0 &&
|
|
161
|
+
!subscriptionEventTypes.includes(String(event.event_type || '').toLowerCase())
|
|
162
|
+
) {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (subscription.topic) {
|
|
166
|
+
const haystack = `${event.event_type || ''} ${JSON.stringify(event.payload || {})}`
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
if (!haystack.includes(subscription.topic.toLowerCase())) continue
|
|
169
|
+
}
|
|
170
|
+
const eventTime = event.timestamp ? Date.parse(event.timestamp) : null
|
|
171
|
+
if (cursorTime !== null && eventTime !== null && eventTime <= cursorTime) continue
|
|
172
|
+
if (eventTime !== null && (lastEventTime === null || eventTime > lastEventTime)) {
|
|
173
|
+
lastEventTime = eventTime
|
|
174
|
+
}
|
|
175
|
+
if (seenIds.has(event.id)) continue
|
|
176
|
+
seenIds.add(event.id)
|
|
177
|
+
events.push({
|
|
178
|
+
id: event.id,
|
|
179
|
+
trace_id: event.trace_id || null,
|
|
180
|
+
event_type: event.event_type,
|
|
181
|
+
outcome: event.outcome || null,
|
|
182
|
+
timestamp: event.timestamp,
|
|
183
|
+
agent: event.agent || null,
|
|
184
|
+
payload: event.payload || null,
|
|
185
|
+
})
|
|
186
|
+
matchedSubscriptionIds.add(subscription.id)
|
|
187
|
+
if (events.length >= limit) break
|
|
188
|
+
}
|
|
189
|
+
// Advance the cursor even when nothing matched: a brand-new subscription
|
|
190
|
+
// has no cursor, and without this it would replay the entire history.
|
|
191
|
+
if (lastEventTime !== null) {
|
|
192
|
+
state[subscription.id] = new Date(lastEventTime).toISOString()
|
|
193
|
+
} else if (cursor === null) {
|
|
194
|
+
state[subscription.id] = digestTime
|
|
195
|
+
}
|
|
196
|
+
if (events.length >= limit) break
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
writeDigestState(state)
|
|
200
|
+
return {
|
|
201
|
+
project,
|
|
202
|
+
agent,
|
|
203
|
+
subscriptionCount: subscriptions.length,
|
|
204
|
+
matchedSubscriptions: Array.from(matchedSubscriptionIds),
|
|
205
|
+
count: events.length,
|
|
206
|
+
events,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { subscribe, digest, subscriptionPath, digestStatePath }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { createSubscriptionStore, subscribeKey }
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Shared task store: the single implementation behind mio.task.route, consumed
|
|
4
|
+
// by both the MCP server and the CLI (`mio task route`). It answers "which
|
|
5
|
+
// verified experiences apply to this kind of task, and which agents have
|
|
6
|
+
// actually succeeded at it" by cross-referencing memory + experience reuse,
|
|
7
|
+
// with an agent-health signal from the latest digest snapshot.
|
|
8
|
+
//
|
|
9
|
+
// Extracted from mio-intelligence-mcp/index.js so the terminal and the MCP tool
|
|
10
|
+
// can never route differently.
|
|
11
|
+
//
|
|
12
|
+
// One behavioural knob is deliberate: `persistQuery`. The MCP call feeds the
|
|
13
|
+
// query log so a later task_outcome can auto-claim route-driven reuse, which is
|
|
14
|
+
// load-bearing for the experience loop. A terminal `mio task route` is an
|
|
15
|
+
// inspection command and must NOT write to queries.jsonl on every invocation,
|
|
16
|
+
// so the CLI passes persistQuery: false. Default stays true so MCP behaviour is
|
|
17
|
+
// unchanged.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs')
|
|
20
|
+
const path = require('path')
|
|
21
|
+
const { createId, readJsonl, appendJsonl, writeJsonl } = require('./memory-store.js')
|
|
22
|
+
const { REUSE_STATUS_FILTERS } = require('./experience-store.js')
|
|
23
|
+
const { createQueryLog } = require('./query-log.js')
|
|
24
|
+
|
|
25
|
+
const AGENT_HEALTH_FRESH_MS = 48 * 3600000
|
|
26
|
+
|
|
27
|
+
function createTaskStore(options) {
|
|
28
|
+
const dataDir = options.dataDir
|
|
29
|
+
const projectName = options.projectName || (() => null)
|
|
30
|
+
const memoryStore = options.memoryStore
|
|
31
|
+
// Who is asking. The MCP passes the runtime agent id; the CLI passes 'cli'.
|
|
32
|
+
// Named resolveAgentId so recordTaskOutcome can still use `agentId` as a local.
|
|
33
|
+
const resolveAgentId = options.agentId || (() => 'mcp')
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
normalizeScope,
|
|
37
|
+
isGlobalRecord,
|
|
38
|
+
matchesProjectScope,
|
|
39
|
+
scoreRecord,
|
|
40
|
+
loadEvidenceWeights,
|
|
41
|
+
} = memoryStore
|
|
42
|
+
|
|
43
|
+
const memoryPath = path.join(dataDir, 'memory.jsonl')
|
|
44
|
+
const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
|
|
45
|
+
const tracePath = path.join(dataDir, 'traces.jsonl')
|
|
46
|
+
const agentsPath = path.join(dataDir, 'agents.jsonl')
|
|
47
|
+
|
|
48
|
+
function readAgentHealth() {
|
|
49
|
+
const file = path.join(dataDir, 'digest', 'latest.json')
|
|
50
|
+
if (!fs.existsSync(file)) return null
|
|
51
|
+
try {
|
|
52
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
53
|
+
const ts = Date.parse(snapshot.generatedAt || '')
|
|
54
|
+
const generatedAt = Number.isFinite(ts) ? snapshot.generatedAt : null
|
|
55
|
+
if (!Number.isFinite(ts) || Date.now() - ts > AGENT_HEALTH_FRESH_MS) {
|
|
56
|
+
return { stale: true, generatedAt, best: null, degraded: [], agents: [] }
|
|
57
|
+
}
|
|
58
|
+
const agents = Array.isArray(snapshot.agents) ? snapshot.agents : []
|
|
59
|
+
const reliable = agents.filter((a) => Number(a.total) >= 5)
|
|
60
|
+
const best =
|
|
61
|
+
reliable.length > 0
|
|
62
|
+
? reliable.reduce((a, b) => (b.successRate > a.successRate ? b : a))
|
|
63
|
+
: null
|
|
64
|
+
const degraded = reliable
|
|
65
|
+
.filter((a) => a.successRate < 50)
|
|
66
|
+
.map((a) => ({ agent: a.agent, successRate: a.successRate, total: a.total }))
|
|
67
|
+
return {
|
|
68
|
+
stale: false,
|
|
69
|
+
generatedAt,
|
|
70
|
+
periodDays: snapshot.periodDays || null,
|
|
71
|
+
best: best ? { agent: best.agent, successRate: best.successRate, total: best.total } : null,
|
|
72
|
+
degraded,
|
|
73
|
+
agents: agents.map((a) => ({ agent: a.agent, total: a.total, successRate: a.successRate })),
|
|
74
|
+
}
|
|
75
|
+
} catch (_) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// queries.jsonl is owned by the shared query log (see ../query-log.js). The
|
|
81
|
+
// MCP server and the CLI hand in the same instance the memory store uses, so
|
|
82
|
+
// there is exactly one reader/writer for this file.
|
|
83
|
+
const queryLog = options.queryLog || createQueryLog({ dataDir })
|
|
84
|
+
const loadRecentQueries = () => queryLog.load()
|
|
85
|
+
const persistRecentQueries = (entries) => queryLog.persist(entries)
|
|
86
|
+
const REUSE_MATCH_WINDOW_MS = queryLog.windowMs
|
|
87
|
+
|
|
88
|
+
function routeTask(args = {}) {
|
|
89
|
+
const task = String(args.task || '').trim()
|
|
90
|
+
if (!task) throw new Error('task.route requires a non-empty task')
|
|
91
|
+
const project = args.project || projectName()
|
|
92
|
+
const scope = normalizeScope(args.scope)
|
|
93
|
+
const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10)
|
|
94
|
+
|
|
95
|
+
const memories = readJsonl(memoryPath).filter((record) => record.archived !== true)
|
|
96
|
+
const memoryById = new Map(memories.map((record) => [record.id, record]))
|
|
97
|
+
const evidence = loadEvidenceWeights()
|
|
98
|
+
|
|
99
|
+
// Match verified reuse experiences whose source memory is relevant to the task.
|
|
100
|
+
const routeByMemory = new Map()
|
|
101
|
+
for (const record of readJsonl(experienceReusePath)) {
|
|
102
|
+
if (!REUSE_STATUS_FILTERS.verified(record)) continue
|
|
103
|
+
const memory = memoryById.get(record.experienceId)
|
|
104
|
+
if (!memory || !matchesProjectScope(memory, project, scope)) continue
|
|
105
|
+
const score = scoreRecord(memory, task, project)
|
|
106
|
+
if (score <= 0) continue
|
|
107
|
+
let entry = routeByMemory.get(record.experienceId)
|
|
108
|
+
if (!entry) {
|
|
109
|
+
entry = {
|
|
110
|
+
experienceId: record.experienceId,
|
|
111
|
+
memory,
|
|
112
|
+
weight: 0,
|
|
113
|
+
confirmed: false,
|
|
114
|
+
sourceAgents: new Set(),
|
|
115
|
+
targetAgents: new Set(),
|
|
116
|
+
reuseCount: 0,
|
|
117
|
+
}
|
|
118
|
+
routeByMemory.set(record.experienceId, entry)
|
|
119
|
+
}
|
|
120
|
+
entry.reuseCount += 1
|
|
121
|
+
if (record.confirmed === true) entry.confirmed = true
|
|
122
|
+
if (record.sourceAgent) entry.sourceAgents.add(record.sourceAgent)
|
|
123
|
+
if (record.targetAgent) entry.targetAgents.add(record.targetAgent)
|
|
124
|
+
entry.weight = Math.max(entry.weight, score + (entry.confirmed ? 2 : 1.5))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const routes = [...routeByMemory.values()]
|
|
128
|
+
.sort((a, b) => b.weight - a.weight)
|
|
129
|
+
.slice(0, limit)
|
|
130
|
+
.map((entry) => ({
|
|
131
|
+
experienceId: entry.experienceId,
|
|
132
|
+
reuseCount: entry.reuseCount,
|
|
133
|
+
confirmed: entry.confirmed,
|
|
134
|
+
sourceAgents: [...entry.sourceAgents].filter(Boolean),
|
|
135
|
+
targetAgents: [...entry.targetAgents].filter(Boolean),
|
|
136
|
+
score: Number(entry.weight.toFixed(2)),
|
|
137
|
+
memory: {
|
|
138
|
+
id: entry.memory.id,
|
|
139
|
+
timestamp: entry.memory.timestamp,
|
|
140
|
+
kind: entry.memory.kind,
|
|
141
|
+
content: entry.memory.content,
|
|
142
|
+
tags: entry.memory.tags || [],
|
|
143
|
+
project: entry.memory.project || null,
|
|
144
|
+
scope: entry.memory.scope || 'project',
|
|
145
|
+
},
|
|
146
|
+
}))
|
|
147
|
+
|
|
148
|
+
// Broader context: top related memories not already routed.
|
|
149
|
+
const routedIds = new Set(routes.map((route) => route.experienceId))
|
|
150
|
+
const scoreRelated = (record) =>
|
|
151
|
+
scoreRecord(record, task, project, evidence) +
|
|
152
|
+
(scope === 'all' && isGlobalRecord(record) ? 0.5 : 0)
|
|
153
|
+
const relatedMemories = memories
|
|
154
|
+
.filter((record) => !routedIds.has(record.id))
|
|
155
|
+
.filter((record) => matchesProjectScope(record, project, scope))
|
|
156
|
+
.filter((record) => scoreRelated(record) > 0)
|
|
157
|
+
.sort((a, b) => scoreRelated(b) - scoreRelated(a))
|
|
158
|
+
.slice(0, Math.max(limit, 5))
|
|
159
|
+
.map((record) => {
|
|
160
|
+
const ev = evidence.get(record.id)
|
|
161
|
+
return {
|
|
162
|
+
id: record.id,
|
|
163
|
+
timestamp: record.timestamp,
|
|
164
|
+
kind: record.kind,
|
|
165
|
+
content: record.content,
|
|
166
|
+
tags: record.tags || [],
|
|
167
|
+
project: record.project || null,
|
|
168
|
+
scope: record.scope || 'project',
|
|
169
|
+
evidence: ev
|
|
170
|
+
? { reuseCount: ev.reuseCount, confirmedCount: ev.confirmedCount }
|
|
171
|
+
: null,
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Feed the query log so a later task_outcome can auto-claim route-driven
|
|
176
|
+
// reuse. Skipped when persistQuery is false (the CLI, which is read-only).
|
|
177
|
+
if (args.persistQuery !== false && (routes.length > 0 || relatedMemories.length > 0)) {
|
|
178
|
+
const now = Date.now()
|
|
179
|
+
const entries = loadRecentQueries()
|
|
180
|
+
entries.push({
|
|
181
|
+
agent: resolveAgentId() || 'mcp',
|
|
182
|
+
project,
|
|
183
|
+
query: task,
|
|
184
|
+
resultIds: [...routedIds, ...relatedMemories.map((record) => record.id)].filter(Boolean),
|
|
185
|
+
resultSources: [
|
|
186
|
+
...routes.map(() => 'experience'),
|
|
187
|
+
...relatedMemories.map((record) => record.source || null),
|
|
188
|
+
],
|
|
189
|
+
timestamp: now,
|
|
190
|
+
expiresAt: now + REUSE_MATCH_WINDOW_MS,
|
|
191
|
+
})
|
|
192
|
+
persistRecentQueries(entries)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let suggestion
|
|
196
|
+
if (routes.length > 0) {
|
|
197
|
+
suggestion =
|
|
198
|
+
`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.`
|
|
199
|
+
} else if (relatedMemories.length > 0) {
|
|
200
|
+
suggestion =
|
|
201
|
+
'No verified experience matches yet. Consult the related memories, then record the outcome so this task can seed future routes.'
|
|
202
|
+
} else {
|
|
203
|
+
suggestion =
|
|
204
|
+
'No matching experience or memory found. Proceed with care and record the outcome to grow the experience base.'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Agent-health routing signal from the latest digest snapshot (value loop:
|
|
208
|
+
// collected task outcomes influence who should execute the next task).
|
|
209
|
+
const agentHealth = readAgentHealth()
|
|
210
|
+
let routingSignal = null
|
|
211
|
+
if (agentHealth && !agentHealth.stale && agentHealth.best) {
|
|
212
|
+
const parts = []
|
|
213
|
+
if (agentHealth.best.successRate >= 80) {
|
|
214
|
+
parts.push(
|
|
215
|
+
`prefer ${agentHealth.best.agent} (${agentHealth.best.successRate}% success over ${agentHealth.best.total} tasks)`,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
for (const bad of agentHealth.degraded) {
|
|
219
|
+
parts.push(`avoid ${bad.agent} (${bad.successRate}% success over ${bad.total} tasks)`)
|
|
220
|
+
}
|
|
221
|
+
if (parts.length > 0) {
|
|
222
|
+
routingSignal = `Agent health (last ${agentHealth.periodDays || '?'}d): ${parts.join('; ')}.`
|
|
223
|
+
suggestion = `${suggestion} ${routingSignal}`
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
task,
|
|
229
|
+
project,
|
|
230
|
+
scope,
|
|
231
|
+
count: routes.length,
|
|
232
|
+
routes,
|
|
233
|
+
relatedMemories,
|
|
234
|
+
agentHealth,
|
|
235
|
+
summary: {
|
|
236
|
+
verifiedRoutes: routes.length,
|
|
237
|
+
relatedMemories: relatedMemories.length,
|
|
238
|
+
routingSignal,
|
|
239
|
+
suggestion,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildAutoClaim(entry, event, targetAgent) {
|
|
245
|
+
const crossSourceIndex = entry.resultSources.findIndex(
|
|
246
|
+
(source) => source && String(source).toLowerCase() !== targetAgent.toLowerCase()
|
|
247
|
+
)
|
|
248
|
+
const sourceAgent =
|
|
249
|
+
crossSourceIndex >= 0 ? String(entry.resultSources[crossSourceIndex]) : targetAgent
|
|
250
|
+
const experienceId =
|
|
251
|
+
entry.resultIds[crossSourceIndex >= 0 ? crossSourceIndex : 0] ||
|
|
252
|
+
entry.resultIds[0] ||
|
|
253
|
+
'unknown'
|
|
254
|
+
const outcomeImproved = String(event.outcome || '').toLowerCase() === 'success'
|
|
255
|
+
return {
|
|
256
|
+
id: createId('xfer'),
|
|
257
|
+
timestamp: new Date().toISOString(),
|
|
258
|
+
sourceAgent,
|
|
259
|
+
targetAgent,
|
|
260
|
+
experienceId,
|
|
261
|
+
reuse: true,
|
|
262
|
+
behaviorChanged: false,
|
|
263
|
+
outcomeImproved,
|
|
264
|
+
project: entry.project,
|
|
265
|
+
source: 'auto_claim',
|
|
266
|
+
traceId: event.trace_id || null,
|
|
267
|
+
notes: `Auto-claimed: memory.query "${entry.query}" matched task_outcome ${event.outcome || 'unknown'} in trace ${event.trace_id || 'unknown'}.`,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Phase 0 auto-claim: a task_outcome arriving within the match window of a
|
|
272
|
+
// prior query is attributed to that query's results, so reuse evidence does
|
|
273
|
+
// not depend on the agent self-reporting mio.experience.reuse. Shared with
|
|
274
|
+
// observer.ingest, which is why it lives in the store rather than the MCP
|
|
275
|
+
// handler.
|
|
276
|
+
function autoClaimExperienceReuse(event) {
|
|
277
|
+
if (String(event.event_type || '').toLowerCase() !== 'task_outcome') return []
|
|
278
|
+
const targetAgent = String(event.agent || '').trim()
|
|
279
|
+
if (!targetAgent) return []
|
|
280
|
+
const now = Date.now()
|
|
281
|
+
const claims = []
|
|
282
|
+
const remaining = []
|
|
283
|
+
const entries = loadRecentQueries()
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
const expired = now - entry.timestamp > REUSE_MATCH_WINDOW_MS
|
|
286
|
+
const matches =
|
|
287
|
+
entry.agent === targetAgent && (!event.project || entry.project === event.project)
|
|
288
|
+
if (expired || matches) {
|
|
289
|
+
if (matches && !expired) {
|
|
290
|
+
claims.push(buildAutoClaim(entry, event, targetAgent))
|
|
291
|
+
}
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
remaining.push(entry)
|
|
295
|
+
}
|
|
296
|
+
persistRecentQueries(remaining)
|
|
297
|
+
for (const evidence of claims) {
|
|
298
|
+
appendJsonl(experienceReusePath, evidence)
|
|
299
|
+
}
|
|
300
|
+
return claims
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function recordTaskOutcome(args = {}) {
|
|
304
|
+
const outcome = String(args.outcome || '').trim().toLowerCase()
|
|
305
|
+
if (!['success', 'failure', 'aborted'].includes(outcome)) {
|
|
306
|
+
throw new Error('task.record_outcome requires outcome to be success, failure, or aborted')
|
|
307
|
+
}
|
|
308
|
+
const agentId = String(args.agentId || resolveAgentId() || 'unknown').trim()
|
|
309
|
+
const project = args.project || projectName()
|
|
310
|
+
const task = String(args.task || '').trim()
|
|
311
|
+
const summary = String(args.summary || '').trim()
|
|
312
|
+
const verification = String(args.verification || '').trim()
|
|
313
|
+
const traceId = String(args.traceId || '').trim() || createId('trace:' + agentId + ':' + project)
|
|
314
|
+
|
|
315
|
+
// 1. Record trace event
|
|
316
|
+
const event = {
|
|
317
|
+
id: createId('trace'),
|
|
318
|
+
timestamp: new Date().toISOString(),
|
|
319
|
+
trace_id: traceId,
|
|
320
|
+
event_type: 'task_outcome',
|
|
321
|
+
outcome,
|
|
322
|
+
payload: { task, summary, verification },
|
|
323
|
+
agent: agentId,
|
|
324
|
+
host: 'mcp',
|
|
325
|
+
project,
|
|
326
|
+
}
|
|
327
|
+
appendJsonl(tracePath, event)
|
|
328
|
+
|
|
329
|
+
// 2. Auto-claim experience reuse
|
|
330
|
+
const autoClaims = autoClaimExperienceReuse(event)
|
|
331
|
+
|
|
332
|
+
// 3. Update agent registry
|
|
333
|
+
const now = new Date().toISOString()
|
|
334
|
+
const agents = readJsonl(agentsPath)
|
|
335
|
+
const agent = agents.find((a) => a.agentId === agentId && a.project === project)
|
|
336
|
+
if (agent) {
|
|
337
|
+
agent.lastSeenAt = now
|
|
338
|
+
agent.taskCount = (agent.taskCount || 0) + 1
|
|
339
|
+
agent.successCount = (agent.successCount || 0) + (outcome === 'success' ? 1 : 0)
|
|
340
|
+
agent.failureCount = (agent.failureCount || 0) + (outcome === 'failure' ? 1 : 0)
|
|
341
|
+
writeJsonl(agentsPath, agents)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
recorded: true,
|
|
346
|
+
event: { id: event.id, trace_id: traceId, outcome, agent: agentId, project },
|
|
347
|
+
agentUpdated: !!agent,
|
|
348
|
+
autoClaims: autoClaims.length > 0 ? autoClaims : undefined,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function ingestObservation(args = {}) {
|
|
353
|
+
const traceId = String(args.trace_id || '').trim()
|
|
354
|
+
const eventType = String(args.event_type || '').trim()
|
|
355
|
+
if (!traceId) throw new Error('observer.ingest requires trace_id')
|
|
356
|
+
if (!eventType) throw new Error('observer.ingest requires event_type')
|
|
357
|
+
const event = {
|
|
358
|
+
id: createId('trace'),
|
|
359
|
+
timestamp: new Date().toISOString(),
|
|
360
|
+
trace_id: traceId,
|
|
361
|
+
event_type: eventType,
|
|
362
|
+
outcome: args.outcome || null,
|
|
363
|
+
payload: args.payload || {},
|
|
364
|
+
agent: args.agent || resolveAgentId(),
|
|
365
|
+
host: args.host || 'mcp',
|
|
366
|
+
project: args.project || projectName(),
|
|
367
|
+
}
|
|
368
|
+
appendJsonl(tracePath, event)
|
|
369
|
+
const autoClaims = autoClaimExperienceReuse(event)
|
|
370
|
+
if (autoClaims.length > 0) {
|
|
371
|
+
return { recorded: true, event, autoClaims }
|
|
372
|
+
}
|
|
373
|
+
return { recorded: true, event }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
routeTask,
|
|
378
|
+
readAgentHealth,
|
|
379
|
+
autoClaimExperienceReuse,
|
|
380
|
+
recordTaskOutcome,
|
|
381
|
+
ingestObservation,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = { createTaskStore }
|