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.
@@ -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 }