triflux 7.1.3 → 7.2.1

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.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +31 -31
  2. package/.claude-plugin/plugin.json +22 -23
  3. package/bin/triflux.mjs +18 -5
  4. package/hooks/keyword-rules.json +393 -361
  5. package/hub/bridge.mjs +799 -786
  6. package/hub/delegator/contracts.mjs +37 -38
  7. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  8. package/hub/delegator/service.mjs +307 -302
  9. package/hub/intent.mjs +108 -11
  10. package/hub/lib/process-utils.mjs +20 -0
  11. package/hub/pipe.mjs +589 -589
  12. package/hub/pipeline/gates/confidence.mjs +1 -1
  13. package/hub/pipeline/gates/selfcheck.mjs +2 -4
  14. package/hub/pipeline/state.mjs +191 -187
  15. package/hub/pipeline/transitions.mjs +124 -120
  16. package/hub/public/dashboard.html +355 -349
  17. package/hub/quality/deslop.mjs +5 -3
  18. package/hub/reflexion.mjs +5 -1
  19. package/hub/research.mjs +6 -1
  20. package/hub/router.mjs +791 -782
  21. package/hub/server.mjs +893 -822
  22. package/hub/store.mjs +807 -778
  23. package/hub/team/agent-map.json +10 -0
  24. package/hub/team/ansi.mjs +3 -4
  25. package/hub/team/cli/commands/control.mjs +43 -43
  26. package/hub/team/cli/commands/interrupt.mjs +36 -36
  27. package/hub/team/cli/commands/kill.mjs +3 -3
  28. package/hub/team/cli/commands/send.mjs +37 -37
  29. package/hub/team/cli/commands/start/index.mjs +18 -8
  30. package/hub/team/cli/commands/start/parse-args.mjs +3 -1
  31. package/hub/team/cli/commands/start/start-headless.mjs +4 -1
  32. package/hub/team/cli/commands/status.mjs +87 -87
  33. package/hub/team/cli/commands/stop.mjs +1 -1
  34. package/hub/team/cli/commands/task.mjs +1 -1
  35. package/hub/team/cli/index.mjs +41 -39
  36. package/hub/team/cli/manifest.mjs +29 -28
  37. package/hub/team/cli/services/hub-client.mjs +37 -0
  38. package/hub/team/cli/services/state-store.mjs +26 -12
  39. package/hub/team/dashboard.mjs +11 -4
  40. package/hub/team/handoff.mjs +12 -0
  41. package/hub/team/headless.mjs +202 -200
  42. package/hub/team/native-supervisor.mjs +386 -346
  43. package/hub/team/nativeProxy.mjs +680 -692
  44. package/hub/team/staleState.mjs +361 -369
  45. package/hub/team/tui-viewer.mjs +27 -3
  46. package/hub/team/tui.mjs +1 -0
  47. package/hub/token-mode.mjs +114 -24
  48. package/hub/workers/delegator-mcp.mjs +1059 -1057
  49. package/hud/colors.mjs +88 -0
  50. package/hud/constants.mjs +78 -0
  51. package/hud/hud-qos-status.mjs +206 -1872
  52. package/hud/providers/claude.mjs +309 -0
  53. package/hud/providers/codex.mjs +151 -0
  54. package/hud/providers/gemini.mjs +320 -0
  55. package/hud/renderers.mjs +424 -0
  56. package/hud/terminal.mjs +140 -0
  57. package/hud/utils.mjs +271 -0
  58. package/package.json +1 -2
  59. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  60. package/scripts/headless-guard-fast.sh +21 -0
  61. package/scripts/headless-guard.mjs +26 -6
  62. package/scripts/lib/keyword-rules.mjs +166 -168
  63. package/scripts/setup.mjs +720 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1663 -1650
  66. package/scripts/tmp-cleanup.mjs +74 -0
  67. package/skills/tfx-auto/SKILL.md +279 -278
  68. package/skills/tfx-auto-codex/SKILL.md +98 -77
  69. package/skills/tfx-codex/SKILL.md +65 -65
  70. package/skills/tfx-gemini/SKILL.md +83 -82
  71. package/skills/tfx-hub/SKILL.md +205 -136
  72. package/skills/tfx-multi/SKILL.md +11 -5
  73. package/.mcp.json +0 -8
package/hub/store.mjs CHANGED
@@ -1,778 +1,807 @@
1
- // hub/store.mjs — SQLite 감사 로그/메타데이터 저장소
2
- // 실시간 배달 큐는 router/pipe가 담당하고, SQLite는 재생/감사 용도로만 유지한다.
3
- import Database from 'better-sqlite3';
4
- import { readFileSync, mkdirSync } from 'node:fs';
5
- import { join, dirname } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import { randomBytes } from 'node:crypto';
8
-
9
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
- let _rndPool = Buffer.alloc(0), _rndOff = 0;
11
-
12
- function pooledRandom(n) {
13
- if (_rndOff + n > _rndPool.length) {
14
- _rndPool = randomBytes(256);
15
- _rndOff = 0;
16
- }
17
- const out = Buffer.from(_rndPool.subarray(_rndOff, _rndOff + n));
18
- _rndOff += n;
19
- return out;
20
- }
21
-
22
- /** UUIDv7 생성 (RFC 9562, 단조 증가 보장) */
23
- let _lastMs = 0n;
24
- let _seq = 0;
25
- export function uuidv7() {
26
- let now = BigInt(Date.now());
27
- if (now <= _lastMs) {
28
- _seq++;
29
- if (_seq > 0xfff) {
30
- now = _lastMs + 1n;
31
- _seq = 0;
32
- }
33
- } else {
34
- _seq = 0;
35
- }
36
- _lastMs = now;
37
- const buf = pooledRandom(16);
38
- buf[0] = Number((now >> 40n) & 0xffn);
39
- buf[1] = Number((now >> 32n) & 0xffn);
40
- buf[2] = Number((now >> 24n) & 0xffn);
41
- buf[3] = Number((now >> 16n) & 0xffn);
42
- buf[4] = Number((now >> 8n) & 0xffn);
43
- buf[5] = Number(now & 0xffn);
44
- buf[6] = ((_seq >> 8) & 0x0f) | 0x70;
45
- buf[7] = _seq & 0xff;
46
- buf[8] = (buf[8] & 0x3f) | 0x80;
47
- const h = buf.toString('hex');
48
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
49
- }
50
-
51
- function parseJson(str, fallback = null) {
52
- if (str == null) return fallback;
53
- try { return JSON.parse(str); } catch { return fallback; }
54
- }
55
-
56
- function parseAgentRow(row) {
57
- if (!row) return null;
58
- const { capabilities_json, topics_json, metadata_json, ...rest } = row;
59
- return {
60
- ...rest,
61
- capabilities: parseJson(capabilities_json, []),
62
- topics: parseJson(topics_json, []),
63
- metadata: parseJson(metadata_json, {}),
64
- };
65
- }
66
-
67
- function parseMessageRow(row) {
68
- if (!row) return null;
69
- const { payload_json, ...rest } = row;
70
- return { ...rest, payload: parseJson(payload_json, {}) };
71
- }
72
-
73
- function parseHumanRequestRow(row) {
74
- if (!row) return null;
75
- const { schema_json, response_json, ...rest } = row;
76
- return {
77
- ...rest,
78
- schema: parseJson(schema_json, {}),
79
- response: parseJson(response_json, null),
80
- };
81
- }
82
-
83
- function parseAssignRow(row) {
84
- if (!row) return null;
85
- const { payload_json, result_json, error_json, ...rest } = row;
86
- return {
87
- ...rest,
88
- payload: parseJson(payload_json, {}),
89
- result: parseJson(result_json, null),
90
- error: parseJson(error_json, null),
91
- };
92
- }
93
-
94
- function parseReflexionRow(row) {
95
- if (!row) return null;
96
- const { context_json, ...rest } = row;
97
- return { ...rest, context: parseJson(context_json, {}) };
98
- }
99
-
100
- /**
101
- * 저장소 생성
102
- * @param {string} dbPath
103
- */
104
- export function createStore(dbPath) {
105
- mkdirSync(dirname(dbPath), { recursive: true });
106
- const db = new Database(dbPath);
107
-
108
- db.pragma('journal_mode = WAL');
109
- db.pragma('synchronous = NORMAL');
110
- db.pragma('foreign_keys = ON');
111
- db.pragma('busy_timeout = 5000');
112
- db.pragma('wal_autocheckpoint = 1000');
113
-
114
- const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
115
- db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
116
- const SCHEMA_VERSION = '3';
117
- const curVer = (() => {
118
- try { return db.prepare("SELECT value FROM _meta WHERE key='schema_version'").pluck().get(); }
119
- catch { return null; }
120
- })();
121
- if (curVer !== SCHEMA_VERSION) {
122
- db.exec(schemaSQL);
123
- db.prepare("INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
124
- }
125
-
126
- const S = {
127
- upsertAgent: db.prepare(`
128
- INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
129
- VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
130
- ON CONFLICT(agent_id) DO UPDATE SET
131
- cli=excluded.cli,
132
- pid=excluded.pid,
133
- capabilities_json=excluded.capabilities_json,
134
- topics_json=excluded.topics_json,
135
- last_seen_ms=excluded.last_seen_ms,
136
- lease_expires_ms=excluded.lease_expires_ms,
137
- status=excluded.status,
138
- metadata_json=excluded.metadata_json`),
139
- getAgent: db.prepare('SELECT * FROM agents WHERE agent_id = ?'),
140
- setAgentTopics: db.prepare('UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?'),
141
- heartbeat: db.prepare("UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?"),
142
- setAgentStatus: db.prepare('UPDATE agents SET status=? WHERE agent_id=?'),
143
- onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
144
- allAgents: db.prepare('SELECT * FROM agents'),
145
- agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
146
- markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
147
- markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
148
-
149
- insertAuditMessage: db.prepare(`
150
- INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status)
151
- VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
152
- getMsg: db.prepare('SELECT * FROM messages WHERE id=?'),
153
- getResponse: db.prepare("SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1"),
154
- getMsgsByTrace: db.prepare('SELECT * FROM messages WHERE trace_id=? ORDER BY created_at_ms'),
155
- setMsgStatus: db.prepare('UPDATE messages SET status=? WHERE id=?'),
156
- recentAgentMessages: db.prepare(`
157
- SELECT * FROM messages
158
- WHERE to_agent=?
159
- ORDER BY created_at_ms DESC
160
- LIMIT ?`),
161
- recentAgentMessagesWithTopics: db.prepare(`
162
- SELECT * FROM messages
163
- WHERE to_agent=?
164
- OR (
165
- substr(to_agent, 1, 6)='topic:'
166
- AND topic IN (SELECT value FROM json_each(?))
167
- )
168
- ORDER BY created_at_ms DESC
169
- LIMIT ?`),
170
-
171
- insertHR: db.prepare(`
172
- INSERT INTO human_requests (request_id, requester_agent, kind, prompt, schema_json, state, deadline_ms, default_action, correlation_id, trace_id, response_json)
173
- VALUES (@request_id, @requester_agent, @kind, @prompt, @schema_json, @state, @deadline_ms, @default_action, @correlation_id, @trace_id, @response_json)`),
174
- getHR: db.prepare('SELECT * FROM human_requests WHERE request_id=?'),
175
- updateHR: db.prepare('UPDATE human_requests SET state=?, response_json=? WHERE request_id=?'),
176
- pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
177
- expireHR: db.prepare("UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?"),
178
-
179
- insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
180
- getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
181
-
182
- insertAssign: db.prepare(`
183
- INSERT INTO assign_jobs (
184
- job_id, supervisor_agent, worker_agent, topic, task, payload_json,
185
- status, attempt, retry_count, max_retries, priority, ttl_ms, timeout_ms, deadline_ms,
186
- trace_id, correlation_id, last_message_id, result_json, error_json,
187
- created_at_ms, updated_at_ms, started_at_ms, completed_at_ms, last_retry_at_ms
188
- ) VALUES (
189
- @job_id, @supervisor_agent, @worker_agent, @topic, @task, @payload_json,
190
- @status, @attempt, @retry_count, @max_retries, @priority, @ttl_ms, @timeout_ms, @deadline_ms,
191
- @trace_id, @correlation_id, @last_message_id, @result_json, @error_json,
192
- @created_at_ms, @updated_at_ms, @started_at_ms, @completed_at_ms, @last_retry_at_ms
193
- )`),
194
- getAssign: db.prepare('SELECT * FROM assign_jobs WHERE job_id = ?'),
195
- updateAssign: db.prepare(`
196
- UPDATE assign_jobs SET
197
- supervisor_agent=@supervisor_agent,
198
- worker_agent=@worker_agent,
199
- topic=@topic,
200
- task=@task,
201
- payload_json=@payload_json,
202
- status=@status,
203
- attempt=@attempt,
204
- retry_count=@retry_count,
205
- max_retries=@max_retries,
206
- priority=@priority,
207
- ttl_ms=@ttl_ms,
208
- timeout_ms=@timeout_ms,
209
- deadline_ms=@deadline_ms,
210
- trace_id=@trace_id,
211
- correlation_id=@correlation_id,
212
- last_message_id=@last_message_id,
213
- result_json=@result_json,
214
- error_json=@error_json,
215
- updated_at_ms=@updated_at_ms,
216
- started_at_ms=@started_at_ms,
217
- completed_at_ms=@completed_at_ms,
218
- last_retry_at_ms=@last_retry_at_ms
219
- WHERE job_id=@job_id`),
220
-
221
- findExpired: db.prepare("SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?"),
222
- urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7"),
223
- normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority < 7"),
224
- onlineCount: db.prepare("SELECT COUNT(*) as cnt FROM agents WHERE status='online'"),
225
- msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
226
- dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
227
- ackedRecent: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='acked' AND created_at_ms > ? - 300000"),
228
- assignCountByStatus: db.prepare('SELECT COUNT(*) as cnt FROM assign_jobs WHERE status = ?'),
229
- activeAssignCount: db.prepare("SELECT COUNT(*) as cnt FROM assign_jobs WHERE status IN ('queued','running')"),
230
-
231
- // reflexion
232
- insertReflexion: db.prepare(`
233
- INSERT INTO reflexion_entries (id, error_pattern, error_message, context_json, solution, solution_code, confidence, hit_count, success_count, last_hit_ms, created_at_ms, updated_at_ms)
234
- VALUES (@id, @error_pattern, @error_message, @context_json, @solution, @solution_code, @confidence, @hit_count, @success_count, @last_hit_ms, @created_at_ms, @updated_at_ms)`),
235
- getReflexionById: db.prepare('SELECT * FROM reflexion_entries WHERE id = ?'),
236
- findReflexionExact: db.prepare('SELECT * FROM reflexion_entries WHERE error_pattern = ? ORDER BY confidence DESC'),
237
- findReflexionLike: db.prepare("SELECT * FROM reflexion_entries WHERE error_pattern LIKE ? ESCAPE '\\' ORDER BY confidence DESC LIMIT 10"),
238
- updateReflexionHitSuccess: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, success_count = success_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
239
- updateReflexionHitOnly: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
240
- updateReflexionConfidence: db.prepare('UPDATE reflexion_entries SET confidence = ?, updated_at_ms = ? WHERE id = ?'),
241
- pruneReflexionEntries: db.prepare('DELETE FROM reflexion_entries WHERE updated_at_ms < ? AND confidence < ?'),
242
- };
243
-
244
- const assignStatusListeners = new Set();
245
-
246
- function buildAssignCallbackEvent(row) {
247
- return {
248
- job_id: row.job_id,
249
- status: row.status,
250
- result: row.result ?? row.error ?? null,
251
- timestamp: new Date(row.updated_at_ms || Date.now()).toISOString(),
252
- };
253
- }
254
-
255
- function notifyAssignStatusListeners(row) {
256
- const event = buildAssignCallbackEvent(row);
257
- for (const listener of Array.from(assignStatusListeners)) {
258
- try { listener(event, row); } catch {}
259
- }
260
- }
261
-
262
- function clampMaxMessages(value, fallback = 20) {
263
- const num = Number(value);
264
- if (!Number.isFinite(num)) return fallback;
265
- return Math.max(1, Math.min(Math.trunc(num), 100));
266
- }
267
-
268
- function clampPriority(value, fallback = 5) {
269
- const num = Number(value);
270
- if (!Number.isFinite(num)) return fallback;
271
- return Math.max(1, Math.min(Math.trunc(num), 9));
272
- }
273
-
274
- function clampDuration(value, fallback = 600000, min = 1000, max = 86400000) {
275
- const num = Number(value);
276
- if (!Number.isFinite(num)) return fallback;
277
- return Math.max(min, Math.min(Math.trunc(num), max));
278
- }
279
-
280
- const store = {
281
- db,
282
- uuidv7,
283
-
284
- close() {
285
- db.close();
286
- },
287
-
288
- registerAgent({ agent_id, cli, pid, capabilities = [], topics = [], heartbeat_ttl_ms = 30000, metadata = {} }) {
289
- const now = Date.now();
290
- const leaseExpires = now + heartbeat_ttl_ms;
291
- S.upsertAgent.run({
292
- agent_id,
293
- cli,
294
- pid: pid ?? null,
295
- capabilities_json: JSON.stringify(capabilities),
296
- topics_json: JSON.stringify(topics),
297
- last_seen_ms: now,
298
- lease_expires_ms: leaseExpires,
299
- status: 'online',
300
- metadata_json: JSON.stringify(metadata),
301
- });
302
- return { agent_id, lease_id: uuidv7(), lease_expires_ms: leaseExpires, server_time_ms: now };
303
- },
304
-
305
- getAgent(id) {
306
- return parseAgentRow(S.getAgent.get(id));
307
- },
308
-
309
- refreshLease(agentId, ttlMs = 30000) {
310
- const now = Date.now();
311
- S.heartbeat.run(now, now + ttlMs, agentId);
312
- return { agent_id: agentId, lease_expires_ms: now + ttlMs, server_time_ms: now };
313
- },
314
-
315
- updateAgentTopics(agentId, topics = []) {
316
- const now = Date.now();
317
- return S.setAgentTopics.run(JSON.stringify(topics), now, agentId).changes > 0;
318
- },
319
-
320
- listOnlineAgents() {
321
- return S.onlineAgents.all().map(parseAgentRow);
322
- },
323
-
324
- listAllAgents() {
325
- return S.allAgents.all().map(parseAgentRow);
326
- },
327
-
328
- getAgentsByTopic(topic) {
329
- return S.agentsByTopic.all(topic).map(parseAgentRow);
330
- },
331
-
332
- sweepStaleAgents() {
333
- const now = Date.now();
334
- return {
335
- stale: S.markStale.run(now).changes,
336
- offline: S.markOffline.run(now).changes,
337
- };
338
- },
339
-
340
- updateAgentStatus(agentId, status) {
341
- return S.setAgentStatus.run(status, agentId).changes > 0;
342
- },
343
-
344
- auditLog({ type, from, to, topic, priority = 5, ttl_ms = 300000, payload = {}, trace_id, correlation_id, status = 'queued' }) {
345
- const now = Date.now();
346
- const row = {
347
- id: uuidv7(),
348
- type,
349
- from_agent: from,
350
- to_agent: to,
351
- topic,
352
- priority,
353
- ttl_ms,
354
- created_at_ms: now,
355
- expires_at_ms: now + ttl_ms,
356
- correlation_id: correlation_id || uuidv7(),
357
- trace_id: trace_id || uuidv7(),
358
- payload_json: JSON.stringify(payload),
359
- status,
360
- };
361
- S.insertAuditMessage.run(row);
362
- return { ...row, payload };
363
- },
364
-
365
- // 하위 호환: 기존 enqueueMessage 호출은 auditLog로 위임한다.
366
- enqueueMessage(args) {
367
- return store.auditLog(args);
368
- },
369
-
370
- getMessage(id) {
371
- return parseMessageRow(S.getMsg.get(id));
372
- },
373
-
374
- getResponseByCorrelation(cid) {
375
- return parseMessageRow(S.getResponse.get(cid));
376
- },
377
-
378
- getMessagesByTrace(tid) {
379
- return S.getMsgsByTrace.all(tid).map(parseMessageRow);
380
- },
381
-
382
- updateMessageStatus(id, status) {
383
- return S.setMsgStatus.run(status, id).changes > 0;
384
- },
385
-
386
- getAuditMessagesForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
387
- const limit = clampMaxMessages(max_messages);
388
- const topics = Array.isArray(include_topics) && include_topics.length
389
- ? include_topics
390
- : (store.getAgent(agentId)?.topics || []);
391
-
392
- const rows = topics.length
393
- ? S.recentAgentMessagesWithTopics.all(agentId, JSON.stringify(topics), limit)
394
- : S.recentAgentMessages.all(agentId, limit);
395
-
396
- return rows.map(parseMessageRow);
397
- },
398
-
399
- // 하위 호환: 실시간 수신함 대신 감사 로그 재생 결과를 반환한다.
400
- deliverToAgent(messageId, agentId) {
401
- return !!store.getMessage(messageId) && !!agentId;
402
- },
403
-
404
- deliverToTopic(messageId, topic) {
405
- void messageId;
406
- return store.getAgentsByTopic(topic).length;
407
- },
408
-
409
- pollForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
410
- return store.getAuditMessagesForAgent(agentId, {
411
- max_messages,
412
- include_topics,
413
- });
414
- },
415
-
416
- ackMessages() {
417
- return 0;
418
- },
419
-
420
- insertHumanRequest({ requester_agent, kind, prompt, requested_schema = {}, deadline_ms, default_action, correlation_id, trace_id }) {
421
- const requestId = uuidv7();
422
- const now = Date.now();
423
- const deadlineAt = now + deadline_ms;
424
- S.insertHR.run({
425
- request_id: requestId,
426
- requester_agent,
427
- kind,
428
- prompt,
429
- schema_json: JSON.stringify(requested_schema),
430
- state: 'pending',
431
- deadline_ms: deadlineAt,
432
- default_action,
433
- correlation_id: correlation_id || uuidv7(),
434
- trace_id: trace_id || uuidv7(),
435
- response_json: null,
436
- });
437
- return { request_id: requestId, state: 'pending', deadline_ms: deadlineAt };
438
- },
439
-
440
- getHumanRequest(id) {
441
- return parseHumanRequestRow(S.getHR.get(id));
442
- },
443
-
444
- updateHumanRequest(id, state, resp = null) {
445
- return S.updateHR.run(state, resp ? JSON.stringify(resp) : null, id).changes > 0;
446
- },
447
-
448
- getPendingHumanRequests() {
449
- return S.pendingHR.all().map(parseHumanRequestRow);
450
- },
451
-
452
- expireHumanRequests() {
453
- return S.expireHR.run(Date.now()).changes;
454
- },
455
-
456
- moveToDeadLetter(messageId, reason, lastError = null) {
457
- db.transaction(() => {
458
- S.setMsgStatus.run('dead_letter', messageId);
459
- S.insertDL.run(messageId, reason, Date.now(), lastError);
460
- })();
461
- return true;
462
- },
463
-
464
- getDeadLetters(limit = 50) {
465
- return S.getDL.all(limit);
466
- },
467
-
468
- createAssign({
469
- job_id,
470
- supervisor_agent,
471
- worker_agent,
472
- topic = 'assign.job',
473
- task = '',
474
- payload = {},
475
- status = 'queued',
476
- attempt = 1,
477
- retry_count = 0,
478
- max_retries = 0,
479
- priority = 5,
480
- ttl_ms = 600000,
481
- timeout_ms = 600000,
482
- deadline_ms,
483
- trace_id,
484
- correlation_id,
485
- last_message_id = null,
486
- result = null,
487
- error = null,
488
- }) {
489
- const now = Date.now();
490
- const normalizedTimeout = clampDuration(timeout_ms, 600000);
491
- const row = {
492
- job_id: job_id || uuidv7(),
493
- supervisor_agent,
494
- worker_agent,
495
- topic: String(topic || 'assign.job'),
496
- task: String(task || ''),
497
- payload_json: JSON.stringify(payload || {}),
498
- status,
499
- attempt: Math.max(1, Number(attempt) || 1),
500
- retry_count: Math.max(0, Number(retry_count) || 0),
501
- max_retries: Math.max(0, Number(max_retries) || 0),
502
- priority: clampPriority(priority, 5),
503
- ttl_ms: clampDuration(ttl_ms, normalizedTimeout),
504
- timeout_ms: normalizedTimeout,
505
- deadline_ms: Number.isFinite(Number(deadline_ms))
506
- ? Math.trunc(Number(deadline_ms))
507
- : now + normalizedTimeout,
508
- trace_id: trace_id || uuidv7(),
509
- correlation_id: correlation_id || uuidv7(),
510
- last_message_id,
511
- result_json: result == null ? null : JSON.stringify(result),
512
- error_json: error == null ? null : JSON.stringify(error),
513
- created_at_ms: now,
514
- updated_at_ms: now,
515
- started_at_ms: status === 'running' ? now : null,
516
- completed_at_ms: ['succeeded', 'failed', 'timed_out'].includes(status) ? now : null,
517
- last_retry_at_ms: retry_count > 0 ? now : null,
518
- };
519
- S.insertAssign.run(row);
520
- const inserted = store.getAssign(row.job_id);
521
- notifyAssignStatusListeners(inserted);
522
- return inserted;
523
- },
524
-
525
- getAssign(jobId) {
526
- return parseAssignRow(S.getAssign.get(jobId));
527
- },
528
-
529
- updateAssignStatus(jobId, status, patch = {}) {
530
- const current = store.getAssign(jobId);
531
- if (!current) return null;
532
-
533
- const now = Date.now();
534
- const nextStatus = status || current.status;
535
- const isTerminal = ['succeeded', 'failed', 'timed_out'].includes(nextStatus);
536
- const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
537
- const nextRow = {
538
- job_id: current.job_id,
539
- supervisor_agent: patch.supervisor_agent ?? current.supervisor_agent,
540
- worker_agent: patch.worker_agent ?? current.worker_agent,
541
- topic: patch.topic ?? current.topic,
542
- task: patch.task ?? current.task,
543
- payload_json: JSON.stringify(patch.payload ?? current.payload ?? {}),
544
- status: nextStatus,
545
- attempt: Math.max(1, Number(patch.attempt ?? current.attempt) || current.attempt || 1),
546
- retry_count: Math.max(0, Number(patch.retry_count ?? current.retry_count) || 0),
547
- max_retries: Math.max(0, Number(patch.max_retries ?? current.max_retries) || 0),
548
- priority: clampPriority(patch.priority ?? current.priority, current.priority || 5),
549
- ttl_ms: clampDuration(patch.ttl_ms ?? current.ttl_ms, current.ttl_ms || nextTimeout),
550
- timeout_ms: nextTimeout,
551
- deadline_ms: (() => {
552
- if (Object.prototype.hasOwnProperty.call(patch, 'deadline_ms')) {
553
- return patch.deadline_ms == null ? null : Math.trunc(Number(patch.deadline_ms));
554
- }
555
- if (isTerminal) return null;
556
- if (nextStatus === 'running' && !current.deadline_ms) return now + nextTimeout;
557
- return current.deadline_ms;
558
- })(),
559
- trace_id: patch.trace_id ?? current.trace_id,
560
- correlation_id: patch.correlation_id ?? current.correlation_id,
561
- last_message_id: Object.prototype.hasOwnProperty.call(patch, 'last_message_id')
562
- ? patch.last_message_id
563
- : current.last_message_id,
564
- result_json: Object.prototype.hasOwnProperty.call(patch, 'result')
565
- ? (patch.result == null ? null : JSON.stringify(patch.result))
566
- : (current.result == null ? null : JSON.stringify(current.result)),
567
- error_json: Object.prototype.hasOwnProperty.call(patch, 'error')
568
- ? (patch.error == null ? null : JSON.stringify(patch.error))
569
- : (current.error == null ? null : JSON.stringify(current.error)),
570
- updated_at_ms: now,
571
- started_at_ms: Object.prototype.hasOwnProperty.call(patch, 'started_at_ms')
572
- ? patch.started_at_ms
573
- : (nextStatus === 'running' ? (current.started_at_ms || now) : current.started_at_ms),
574
- completed_at_ms: Object.prototype.hasOwnProperty.call(patch, 'completed_at_ms')
575
- ? patch.completed_at_ms
576
- : (isTerminal ? (current.completed_at_ms || now) : current.completed_at_ms),
577
- last_retry_at_ms: Object.prototype.hasOwnProperty.call(patch, 'last_retry_at_ms')
578
- ? patch.last_retry_at_ms
579
- : current.last_retry_at_ms,
580
- };
581
- S.updateAssign.run(nextRow);
582
- const updated = store.getAssign(jobId);
583
- if (updated && current.status !== updated.status) {
584
- notifyAssignStatusListeners(updated);
585
- }
586
- return updated;
587
- },
588
-
589
- listAssigns({
590
- supervisor_agent,
591
- worker_agent,
592
- status,
593
- statuses,
594
- trace_id,
595
- correlation_id,
596
- active_before_ms,
597
- limit = 50,
598
- } = {}) {
599
- const clauses = [];
600
- const values = [];
601
-
602
- if (supervisor_agent) {
603
- clauses.push('supervisor_agent = ?');
604
- values.push(supervisor_agent);
605
- }
606
- if (worker_agent) {
607
- clauses.push('worker_agent = ?');
608
- values.push(worker_agent);
609
- }
610
- if (trace_id) {
611
- clauses.push('trace_id = ?');
612
- values.push(trace_id);
613
- }
614
- if (correlation_id) {
615
- clauses.push('correlation_id = ?');
616
- values.push(correlation_id);
617
- }
618
-
619
- const statusList = Array.isArray(statuses) && statuses.length
620
- ? statuses
621
- : (status ? [status] : []);
622
- if (statusList.length) {
623
- clauses.push(`status IN (${statusList.map(() => '?').join(',')})`);
624
- values.push(...statusList);
625
- }
626
-
627
- if (Number.isFinite(Number(active_before_ms))) {
628
- clauses.push('deadline_ms IS NOT NULL AND deadline_ms <= ?');
629
- values.push(Math.trunc(Number(active_before_ms)));
630
- }
631
-
632
- const sql = `
633
- SELECT * FROM assign_jobs
634
- ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''}
635
- ORDER BY updated_at_ms DESC
636
- LIMIT ?`;
637
- values.push(clampMaxMessages(limit, 50));
638
- return db.prepare(sql).all(...values).map(parseAssignRow);
639
- },
640
-
641
- retryAssign(jobId, patch = {}) {
642
- const current = store.getAssign(jobId);
643
- if (!current) return null;
644
-
645
- const nextRetryCount = Math.max(0, Number(patch.retry_count ?? current.retry_count + 1) || 0);
646
- const nextAttempt = Math.max(current.attempt + 1, Number(patch.attempt ?? current.attempt + 1) || 1);
647
- const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
648
- return store.updateAssignStatus(jobId, 'queued', {
649
- retry_count: nextRetryCount,
650
- attempt: nextAttempt,
651
- timeout_ms: nextTimeout,
652
- ttl_ms: patch.ttl_ms ?? current.ttl_ms,
653
- deadline_ms: Date.now() + nextTimeout,
654
- completed_at_ms: null,
655
- started_at_ms: null,
656
- last_retry_at_ms: Date.now(),
657
- result: patch.result ?? null,
658
- error: Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : current.error,
659
- last_message_id: null,
660
- });
661
- },
662
-
663
- sweepExpired() {
664
- const now = Date.now();
665
- return db.transaction(() => {
666
- const expired = S.findExpired.all(now);
667
- for (const { id } of expired) {
668
- S.setMsgStatus.run('dead_letter', id);
669
- S.insertDL.run(id, 'ttl_expired', now, null);
670
- }
671
- const humanRequests = S.expireHR.run(now).changes;
672
- return { messages: expired.length, human_requests: humanRequests };
673
- })();
674
- },
675
-
676
- getQueueDepths() {
677
- return {
678
- urgent: S.urgentDepth.get().cnt,
679
- normal: S.normalDepth.get().cnt,
680
- dlq: S.dlqDepth.get().cnt,
681
- };
682
- },
683
-
684
- onAssignStatusChange(listener) {
685
- if (typeof listener !== 'function') {
686
- return () => {};
687
- }
688
- assignStatusListeners.add(listener);
689
- return () => {
690
- assignStatusListeners.delete(listener);
691
- };
692
- },
693
-
694
- getDeliveryStats() {
695
- return {
696
- total_deliveries: S.ackedRecent.get(Date.now()).cnt,
697
- avg_delivery_ms: 0,
698
- };
699
- },
700
-
701
- getHubStats() {
702
- return {
703
- online_agents: S.onlineCount.get().cnt,
704
- total_messages: S.msgCount.get().cnt,
705
- active_assign_jobs: S.activeAssignCount.get().cnt,
706
- ...store.getQueueDepths(),
707
- };
708
- },
709
-
710
- getAuditStats() {
711
- return {
712
- online_agents: S.onlineCount.get().cnt,
713
- total_messages: S.msgCount.get().cnt,
714
- dlq: S.dlqDepth.get().cnt,
715
- assign_queued: S.assignCountByStatus.get('queued').cnt,
716
- assign_running: S.assignCountByStatus.get('running').cnt,
717
- assign_failed: S.assignCountByStatus.get('failed').cnt,
718
- assign_timed_out: S.assignCountByStatus.get('timed_out').cnt,
719
- };
720
- },
721
-
722
- // --- Reflexion CRUD ---
723
-
724
- addReflexion({ error_pattern, error_message, context = {}, solution, solution_code = null }) {
725
- const now = Date.now();
726
- const id = uuidv7();
727
- S.insertReflexion.run({
728
- id,
729
- error_pattern,
730
- error_message,
731
- context_json: JSON.stringify(context),
732
- solution,
733
- solution_code,
734
- confidence: 0.5,
735
- hit_count: 1,
736
- success_count: 0,
737
- last_hit_ms: now,
738
- created_at_ms: now,
739
- updated_at_ms: now,
740
- });
741
- return store.getReflexion(id);
742
- },
743
-
744
- getReflexion(id) {
745
- return parseReflexionRow(S.getReflexionById.get(id));
746
- },
747
-
748
- findReflexion(errorPattern) {
749
- let rows = S.findReflexionExact.all(errorPattern);
750
- if (rows.length) return rows.map(parseReflexionRow);
751
- const escaped = errorPattern.replace(/[%_\\]/g, '\\$&');
752
- rows = S.findReflexionLike.all(`%${escaped.slice(0, 100)}%`);
753
- return rows.map(parseReflexionRow);
754
- },
755
-
756
- updateReflexionHit(id, success = false) {
757
- const now = Date.now();
758
- if (success) {
759
- S.updateReflexionHitSuccess.run(now, now, id);
760
- } else {
761
- S.updateReflexionHitOnly.run(now, now, id);
762
- }
763
- const entry = store.getReflexion(id);
764
- if (entry && entry.hit_count > 0) {
765
- const conf = entry.success_count / entry.hit_count;
766
- S.updateReflexionConfidence.run(Math.max(0, Math.min(1, conf)), now, id);
767
- }
768
- return store.getReflexion(id);
769
- },
770
-
771
- pruneReflexion(maxAge_ms = 30 * 24 * 3600 * 1000, minConfidence = 0.2) {
772
- const cutoff = Date.now() - maxAge_ms;
773
- return S.pruneReflexionEntries.run(cutoff, minConfidence).changes;
774
- },
775
- };
776
-
777
- return store;
778
- }
1
+ // hub/store.mjs — SQLite 감사 로그/메타데이터 저장소
2
+ // 실시간 배달 큐는 router/pipe가 담당하고, SQLite는 재생/감사 용도로만 유지한다.
3
+ import Database from 'better-sqlite3';
4
+ import { recalcConfidence } from './reflexion.mjs';
5
+ import { readFileSync, mkdirSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { randomBytes } from 'node:crypto';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ let _rndPool = Buffer.alloc(0), _rndOff = 0;
12
+
13
+ function pooledRandom(n) {
14
+ if (_rndOff + n > _rndPool.length) {
15
+ _rndPool = randomBytes(256);
16
+ _rndOff = 0;
17
+ }
18
+ const out = Buffer.from(_rndPool.subarray(_rndOff, _rndOff + n));
19
+ _rndOff += n;
20
+ return out;
21
+ }
22
+
23
+ /** UUIDv7 생성 (RFC 9562, 단조 증가 보장) */
24
+ let _lastMs = 0n;
25
+ let _seq = 0;
26
+ export function uuidv7() {
27
+ let now = BigInt(Date.now());
28
+ if (now <= _lastMs) {
29
+ _seq++;
30
+ // _seq > 0xfff (4095): 시퀀스 공간 소진 시 타임스탬프를 1ms 앞당겨 단조 증가를 보장.
31
+ // 고처리량 환경에서는 타임스탬프가 실제 벽시계보다 앞서 드리프트될 수 있음 (설계상 의도).
32
+ if (_seq > 0xfff) {
33
+ now = _lastMs + 1n;
34
+ _seq = 0;
35
+ }
36
+ } else {
37
+ _seq = 0;
38
+ }
39
+ _lastMs = now;
40
+ const buf = pooledRandom(16);
41
+ buf[0] = Number((now >> 40n) & 0xffn);
42
+ buf[1] = Number((now >> 32n) & 0xffn);
43
+ buf[2] = Number((now >> 24n) & 0xffn);
44
+ buf[3] = Number((now >> 16n) & 0xffn);
45
+ buf[4] = Number((now >> 8n) & 0xffn);
46
+ buf[5] = Number(now & 0xffn);
47
+ buf[6] = ((_seq >> 8) & 0x0f) | 0x70;
48
+ buf[7] = _seq & 0xff;
49
+ buf[8] = (buf[8] & 0x3f) | 0x80;
50
+ const h = buf.toString('hex');
51
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
52
+ }
53
+
54
+ function parseJson(str, fallback = null) {
55
+ if (str == null) return fallback;
56
+ try { return JSON.parse(str); } catch { return fallback; }
57
+ }
58
+
59
+ function parseAgentRow(row) {
60
+ if (!row) return null;
61
+ const { capabilities_json, topics_json, metadata_json, ...rest } = row;
62
+ return {
63
+ ...rest,
64
+ capabilities: parseJson(capabilities_json, []),
65
+ topics: parseJson(topics_json, []),
66
+ metadata: parseJson(metadata_json, {}),
67
+ };
68
+ }
69
+
70
+ function parseMessageRow(row) {
71
+ if (!row) return null;
72
+ const { payload_json, ...rest } = row;
73
+ return { ...rest, payload: parseJson(payload_json, {}) };
74
+ }
75
+
76
+ function parseHumanRequestRow(row) {
77
+ if (!row) return null;
78
+ const { schema_json, response_json, ...rest } = row;
79
+ return {
80
+ ...rest,
81
+ schema: parseJson(schema_json, {}),
82
+ response: parseJson(response_json, null),
83
+ };
84
+ }
85
+
86
+ function parseAssignRow(row) {
87
+ if (!row) return null;
88
+ const { payload_json, result_json, error_json, ...rest } = row;
89
+ return {
90
+ ...rest,
91
+ payload: parseJson(payload_json, {}),
92
+ result: parseJson(result_json, null),
93
+ error: parseJson(error_json, null),
94
+ };
95
+ }
96
+
97
+ function parseReflexionRow(row) {
98
+ if (!row) return null;
99
+ const { context_json, ...rest } = row;
100
+ return { ...rest, context: parseJson(context_json, {}) };
101
+ }
102
+
103
+ /**
104
+ * 저장소 생성
105
+ * @param {string} dbPath
106
+ */
107
+ export function createStore(dbPath) {
108
+ mkdirSync(dirname(dbPath), { recursive: true });
109
+ const db = new Database(dbPath);
110
+
111
+ db.pragma('journal_mode = WAL');
112
+ db.pragma('synchronous = NORMAL');
113
+ db.pragma('foreign_keys = ON');
114
+ db.pragma('busy_timeout = 5000');
115
+ db.pragma('wal_autocheckpoint = 1000');
116
+
117
+ const schemaSQL = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
118
+ db.exec("CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)");
119
+ const SCHEMA_VERSION = '3';
120
+ const curVer = (() => {
121
+ try { return db.prepare("SELECT value FROM _meta WHERE key='schema_version'").pluck().get(); }
122
+ catch { return null; }
123
+ })();
124
+ // 마이그레이션 전략: 스키마 버전이 다르면 schema.sql을 재실행한다.
125
+ // schema.sql은 CREATE TABLE IF NOT EXISTS 패턴을 사용하므로 멱등하게 적용된다.
126
+ // 비파괴적 컬럼 추가는 자동으로 처리되지만, 컬럼 제거/이름 변경은 수동 마이그레이션이 필요하다.
127
+ if (curVer !== SCHEMA_VERSION) {
128
+ if (curVer != null) {
129
+ // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
130
+ console.warn(`[store] schema version mismatch: found=${curVer} expected=${SCHEMA_VERSION}. Applying schema.sql (idempotent).`);
131
+ }
132
+ db.exec(schemaSQL);
133
+ db.prepare("INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
134
+ }
135
+
136
+ const S = {
137
+ upsertAgent: db.prepare(`
138
+ INSERT INTO agents (agent_id, cli, pid, capabilities_json, topics_json, last_seen_ms, lease_expires_ms, status, metadata_json)
139
+ VALUES (@agent_id, @cli, @pid, @capabilities_json, @topics_json, @last_seen_ms, @lease_expires_ms, @status, @metadata_json)
140
+ ON CONFLICT(agent_id) DO UPDATE SET
141
+ cli=excluded.cli,
142
+ pid=excluded.pid,
143
+ capabilities_json=excluded.capabilities_json,
144
+ topics_json=excluded.topics_json,
145
+ last_seen_ms=excluded.last_seen_ms,
146
+ lease_expires_ms=excluded.lease_expires_ms,
147
+ status=excluded.status,
148
+ metadata_json=excluded.metadata_json`),
149
+ getAgent: db.prepare('SELECT * FROM agents WHERE agent_id = ?'),
150
+ setAgentTopics: db.prepare('UPDATE agents SET topics_json=?, last_seen_ms=? WHERE agent_id=?'),
151
+ heartbeat: db.prepare("UPDATE agents SET last_seen_ms=?, lease_expires_ms=?, status='online' WHERE agent_id=?"),
152
+ setAgentStatus: db.prepare('UPDATE agents SET status=? WHERE agent_id=?'),
153
+ onlineAgents: db.prepare("SELECT * FROM agents WHERE status != 'offline'"),
154
+ allAgents: db.prepare('SELECT * FROM agents'),
155
+ agentsByTopic: db.prepare("SELECT a.* FROM agents a, json_each(a.topics_json) t WHERE t.value=? AND a.status != 'offline'"),
156
+ markStale: db.prepare("UPDATE agents SET status='stale' WHERE status='online' AND lease_expires_ms < ?"),
157
+ markOffline: db.prepare("UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000"),
158
+
159
+ insertAuditMessage: db.prepare(`
160
+ INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status)
161
+ VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
162
+ getMsg: db.prepare('SELECT * FROM messages WHERE id=?'),
163
+ getResponse: db.prepare("SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1"),
164
+ getMsgsByTrace: db.prepare('SELECT * FROM messages WHERE trace_id=? ORDER BY created_at_ms'),
165
+ setMsgStatus: db.prepare('UPDATE messages SET status=? WHERE id=?'),
166
+ recentAgentMessages: db.prepare(`
167
+ SELECT * FROM messages
168
+ WHERE to_agent=?
169
+ ORDER BY created_at_ms DESC
170
+ LIMIT ?`),
171
+ recentAgentMessagesWithTopics: db.prepare(`
172
+ SELECT * FROM messages
173
+ WHERE to_agent=?
174
+ OR (
175
+ substr(to_agent, 1, 6)='topic:'
176
+ AND topic IN (SELECT value FROM json_each(?))
177
+ )
178
+ ORDER BY created_at_ms DESC
179
+ LIMIT ?`),
180
+
181
+ insertHR: db.prepare(`
182
+ INSERT INTO human_requests (request_id, requester_agent, kind, prompt, schema_json, state, deadline_ms, default_action, correlation_id, trace_id, response_json)
183
+ VALUES (@request_id, @requester_agent, @kind, @prompt, @schema_json, @state, @deadline_ms, @default_action, @correlation_id, @trace_id, @response_json)`),
184
+ getHR: db.prepare('SELECT * FROM human_requests WHERE request_id=?'),
185
+ updateHR: db.prepare('UPDATE human_requests SET state=?, response_json=? WHERE request_id=?'),
186
+ pendingHR: db.prepare("SELECT * FROM human_requests WHERE state='pending'"),
187
+ expireHR: db.prepare("UPDATE human_requests SET state='timed_out' WHERE state='pending' AND deadline_ms < ?"),
188
+
189
+ insertDL: db.prepare('INSERT OR REPLACE INTO dead_letters (message_id, reason, failed_at_ms, last_error) VALUES (?,?,?,?)'),
190
+ getDL: db.prepare('SELECT * FROM dead_letters ORDER BY failed_at_ms DESC LIMIT ?'),
191
+
192
+ insertAssign: db.prepare(`
193
+ INSERT INTO assign_jobs (
194
+ job_id, supervisor_agent, worker_agent, topic, task, payload_json,
195
+ status, attempt, retry_count, max_retries, priority, ttl_ms, timeout_ms, deadline_ms,
196
+ trace_id, correlation_id, last_message_id, result_json, error_json,
197
+ created_at_ms, updated_at_ms, started_at_ms, completed_at_ms, last_retry_at_ms
198
+ ) VALUES (
199
+ @job_id, @supervisor_agent, @worker_agent, @topic, @task, @payload_json,
200
+ @status, @attempt, @retry_count, @max_retries, @priority, @ttl_ms, @timeout_ms, @deadline_ms,
201
+ @trace_id, @correlation_id, @last_message_id, @result_json, @error_json,
202
+ @created_at_ms, @updated_at_ms, @started_at_ms, @completed_at_ms, @last_retry_at_ms
203
+ )`),
204
+ getAssign: db.prepare('SELECT * FROM assign_jobs WHERE job_id = ?'),
205
+ updateAssign: db.prepare(`
206
+ UPDATE assign_jobs SET
207
+ supervisor_agent=@supervisor_agent,
208
+ worker_agent=@worker_agent,
209
+ topic=@topic,
210
+ task=@task,
211
+ payload_json=@payload_json,
212
+ status=@status,
213
+ attempt=@attempt,
214
+ retry_count=@retry_count,
215
+ max_retries=@max_retries,
216
+ priority=@priority,
217
+ ttl_ms=@ttl_ms,
218
+ timeout_ms=@timeout_ms,
219
+ deadline_ms=@deadline_ms,
220
+ trace_id=@trace_id,
221
+ correlation_id=@correlation_id,
222
+ last_message_id=@last_message_id,
223
+ result_json=@result_json,
224
+ error_json=@error_json,
225
+ updated_at_ms=@updated_at_ms,
226
+ started_at_ms=@started_at_ms,
227
+ completed_at_ms=@completed_at_ms,
228
+ last_retry_at_ms=@last_retry_at_ms
229
+ WHERE job_id=@job_id`),
230
+
231
+ findExpired: db.prepare("SELECT id FROM messages WHERE status='queued' AND expires_at_ms < ?"),
232
+ urgentDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority >= 7"),
233
+ normalDepth: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='queued' AND priority < 7"),
234
+ onlineCount: db.prepare("SELECT COUNT(*) as cnt FROM agents WHERE status='online'"),
235
+ msgCount: db.prepare('SELECT COUNT(*) as cnt FROM messages'),
236
+ dlqDepth: db.prepare('SELECT COUNT(*) as cnt FROM dead_letters'),
237
+ ackedRecent: db.prepare("SELECT COUNT(*) as cnt FROM messages WHERE status='acked' AND created_at_ms > ? - 300000"),
238
+ assignCountByStatus: db.prepare('SELECT COUNT(*) as cnt FROM assign_jobs WHERE status = ?'),
239
+ activeAssignCount: db.prepare("SELECT COUNT(*) as cnt FROM assign_jobs WHERE status IN ('queued','running')"),
240
+
241
+ // reflexion
242
+ insertReflexion: db.prepare(`
243
+ INSERT INTO reflexion_entries (id, error_pattern, error_message, context_json, solution, solution_code, confidence, hit_count, success_count, last_hit_ms, created_at_ms, updated_at_ms)
244
+ VALUES (@id, @error_pattern, @error_message, @context_json, @solution, @solution_code, @confidence, @hit_count, @success_count, @last_hit_ms, @created_at_ms, @updated_at_ms)`),
245
+ getReflexionById: db.prepare('SELECT * FROM reflexion_entries WHERE id = ?'),
246
+ findReflexionExact: db.prepare('SELECT * FROM reflexion_entries WHERE error_pattern = ? ORDER BY confidence DESC'),
247
+ findReflexionLike: db.prepare("SELECT * FROM reflexion_entries WHERE error_pattern LIKE ? ESCAPE '\\' ORDER BY confidence DESC LIMIT 10"),
248
+ updateReflexionHitSuccess: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, success_count = success_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
249
+ updateReflexionHitOnly: db.prepare('UPDATE reflexion_entries SET hit_count = hit_count + 1, last_hit_ms = ?, updated_at_ms = ? WHERE id = ?'),
250
+ updateReflexionConfidence: db.prepare('UPDATE reflexion_entries SET confidence = ?, updated_at_ms = ? WHERE id = ?'),
251
+ pruneReflexionEntries: db.prepare('DELETE FROM reflexion_entries WHERE updated_at_ms < ? AND confidence < ?'),
252
+ };
253
+
254
+ const assignStatusListeners = new Set();
255
+
256
+ function buildAssignCallbackEvent(row) {
257
+ return {
258
+ job_id: row.job_id,
259
+ status: row.status,
260
+ result: row.result ?? row.error ?? null,
261
+ timestamp: new Date(row.updated_at_ms || Date.now()).toISOString(),
262
+ };
263
+ }
264
+
265
+ function notifyAssignStatusListeners(row) {
266
+ const event = buildAssignCallbackEvent(row);
267
+ for (const listener of Array.from(assignStatusListeners)) {
268
+ try { listener(event, row); } catch {}
269
+ }
270
+ }
271
+
272
+ function clampMaxMessages(value, fallback = 20) {
273
+ const num = Number(value);
274
+ if (!Number.isFinite(num)) return fallback;
275
+ return Math.max(1, Math.min(Math.trunc(num), 100));
276
+ }
277
+
278
+ function clampPriority(value, fallback = 5) {
279
+ const num = Number(value);
280
+ if (!Number.isFinite(num)) return fallback;
281
+ return Math.max(1, Math.min(Math.trunc(num), 9));
282
+ }
283
+
284
+ function clampDuration(value, fallback = 600000, min = 1000, max = 86400000) {
285
+ const num = Number(value);
286
+ if (!Number.isFinite(num)) return fallback;
287
+ return Math.max(min, Math.min(Math.trunc(num), max));
288
+ }
289
+
290
+ const store = {
291
+ db,
292
+ uuidv7,
293
+
294
+ close() {
295
+ db.close();
296
+ },
297
+
298
+ registerAgent({ agent_id, cli, pid, capabilities = [], topics = [], heartbeat_ttl_ms = 30000, metadata = {} }) {
299
+ const now = Date.now();
300
+ const leaseExpires = now + heartbeat_ttl_ms;
301
+ S.upsertAgent.run({
302
+ agent_id,
303
+ cli,
304
+ pid: pid ?? null,
305
+ capabilities_json: JSON.stringify(capabilities),
306
+ topics_json: JSON.stringify(topics),
307
+ last_seen_ms: now,
308
+ lease_expires_ms: leaseExpires,
309
+ status: 'online',
310
+ metadata_json: JSON.stringify(metadata),
311
+ });
312
+ return { agent_id, lease_id: uuidv7(), lease_expires_ms: leaseExpires, server_time_ms: now };
313
+ },
314
+
315
+ getAgent(id) {
316
+ return parseAgentRow(S.getAgent.get(id));
317
+ },
318
+
319
+ refreshLease(agentId, ttlMs = 30000) {
320
+ const now = Date.now();
321
+ S.heartbeat.run(now, now + ttlMs, agentId);
322
+ return { agent_id: agentId, lease_expires_ms: now + ttlMs, server_time_ms: now };
323
+ },
324
+
325
+ updateAgentTopics(agentId, topics = []) {
326
+ const now = Date.now();
327
+ return S.setAgentTopics.run(JSON.stringify(topics), now, agentId).changes > 0;
328
+ },
329
+
330
+ listOnlineAgents() {
331
+ return S.onlineAgents.all().map(parseAgentRow);
332
+ },
333
+
334
+ listAllAgents() {
335
+ return S.allAgents.all().map(parseAgentRow);
336
+ },
337
+
338
+ getAgentsByTopic(topic) {
339
+ return S.agentsByTopic.all(topic).map(parseAgentRow);
340
+ },
341
+
342
+ sweepStaleAgents() {
343
+ const now = Date.now();
344
+ return {
345
+ stale: S.markStale.run(now).changes,
346
+ offline: S.markOffline.run(now).changes,
347
+ };
348
+ },
349
+
350
+ updateAgentStatus(agentId, status) {
351
+ return S.setAgentStatus.run(status, agentId).changes > 0;
352
+ },
353
+
354
+ auditLog({ type, from, to, topic, priority = 5, ttl_ms = 300000, payload = {}, trace_id, correlation_id, status = 'queued' }) {
355
+ const now = Date.now();
356
+ const row = {
357
+ id: uuidv7(),
358
+ type,
359
+ from_agent: from,
360
+ to_agent: to,
361
+ topic,
362
+ priority,
363
+ ttl_ms,
364
+ created_at_ms: now,
365
+ expires_at_ms: now + ttl_ms,
366
+ correlation_id: correlation_id || uuidv7(),
367
+ trace_id: trace_id || uuidv7(),
368
+ payload_json: JSON.stringify(payload),
369
+ status,
370
+ };
371
+ S.insertAuditMessage.run(row);
372
+ return { ...row, payload };
373
+ },
374
+
375
+ // 하위 호환: 기존 enqueueMessage 호출은 auditLog로 위임한다.
376
+ enqueueMessage(args) {
377
+ return store.auditLog(args);
378
+ },
379
+
380
+ getMessage(id) {
381
+ return parseMessageRow(S.getMsg.get(id));
382
+ },
383
+
384
+ getResponseByCorrelation(cid) {
385
+ return parseMessageRow(S.getResponse.get(cid));
386
+ },
387
+
388
+ getMessagesByTrace(tid) {
389
+ return S.getMsgsByTrace.all(tid).map(parseMessageRow);
390
+ },
391
+
392
+ updateMessageStatus(id, status) {
393
+ return S.setMsgStatus.run(status, id).changes > 0;
394
+ },
395
+
396
+ getAuditMessagesForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
397
+ const limit = clampMaxMessages(max_messages);
398
+ const topics = Array.isArray(include_topics) && include_topics.length
399
+ ? include_topics
400
+ : (store.getAgent(agentId)?.topics || []);
401
+
402
+ const rows = topics.length
403
+ ? S.recentAgentMessagesWithTopics.all(agentId, JSON.stringify(topics), limit)
404
+ : S.recentAgentMessages.all(agentId, limit);
405
+
406
+ return rows.map(parseMessageRow);
407
+ },
408
+
409
+ // 하위 호환: 실시간 수신함 대신 감사 로그 재생 결과를 반환한다.
410
+ deliverToAgent(messageId, agentId) {
411
+ return !!store.getMessage(messageId) && !!agentId;
412
+ },
413
+
414
+ deliverToTopic(messageId, topic) {
415
+ void messageId;
416
+ return store.getAgentsByTopic(topic).length;
417
+ },
418
+
419
+ pollForAgent(agentId, { max_messages = 20, include_topics = null } = {}) {
420
+ return store.getAuditMessagesForAgent(agentId, {
421
+ max_messages,
422
+ include_topics,
423
+ });
424
+ },
425
+
426
+ ackMessages() {
427
+ return 0;
428
+ },
429
+
430
+ insertHumanRequest({ requester_agent, kind, prompt, requested_schema = {}, deadline_ms, default_action, correlation_id, trace_id }) {
431
+ const requestId = uuidv7();
432
+ const now = Date.now();
433
+ const deadlineAt = now + deadline_ms;
434
+ S.insertHR.run({
435
+ request_id: requestId,
436
+ requester_agent,
437
+ kind,
438
+ prompt,
439
+ schema_json: JSON.stringify(requested_schema),
440
+ state: 'pending',
441
+ deadline_ms: deadlineAt,
442
+ default_action,
443
+ correlation_id: correlation_id || uuidv7(),
444
+ trace_id: trace_id || uuidv7(),
445
+ response_json: null,
446
+ });
447
+ return { request_id: requestId, state: 'pending', deadline_ms: deadlineAt };
448
+ },
449
+
450
+ getHumanRequest(id) {
451
+ return parseHumanRequestRow(S.getHR.get(id));
452
+ },
453
+
454
+ updateHumanRequest(id, state, resp = null) {
455
+ return S.updateHR.run(state, resp ? JSON.stringify(resp) : null, id).changes > 0;
456
+ },
457
+
458
+ getPendingHumanRequests() {
459
+ return S.pendingHR.all().map(parseHumanRequestRow);
460
+ },
461
+
462
+ expireHumanRequests() {
463
+ return S.expireHR.run(Date.now()).changes;
464
+ },
465
+
466
+ moveToDeadLetter(messageId, reason, lastError = null) {
467
+ db.transaction(() => {
468
+ S.setMsgStatus.run('dead_letter', messageId);
469
+ S.insertDL.run(messageId, reason, Date.now(), lastError);
470
+ })();
471
+ return true;
472
+ },
473
+
474
+ getDeadLetters(limit = 50) {
475
+ return S.getDL.all(limit);
476
+ },
477
+
478
+ createAssign({
479
+ job_id,
480
+ supervisor_agent,
481
+ worker_agent,
482
+ topic = 'assign.job',
483
+ task = '',
484
+ payload = {},
485
+ status = 'queued',
486
+ attempt = 1,
487
+ retry_count = 0,
488
+ max_retries = 0,
489
+ priority = 5,
490
+ ttl_ms = 600000,
491
+ timeout_ms = 600000,
492
+ deadline_ms,
493
+ trace_id,
494
+ correlation_id,
495
+ last_message_id = null,
496
+ result = null,
497
+ error = null,
498
+ }) {
499
+ const now = Date.now();
500
+ const normalizedTimeout = clampDuration(timeout_ms, 600000);
501
+ const row = {
502
+ job_id: job_id || uuidv7(),
503
+ supervisor_agent,
504
+ worker_agent,
505
+ topic: String(topic || 'assign.job'),
506
+ task: String(task || ''),
507
+ payload_json: JSON.stringify(payload || {}),
508
+ status,
509
+ attempt: Math.max(1, Number(attempt) || 1),
510
+ retry_count: Math.max(0, Number(retry_count) || 0),
511
+ max_retries: Math.max(0, Number(max_retries) || 0),
512
+ priority: clampPriority(priority, 5),
513
+ ttl_ms: clampDuration(ttl_ms, normalizedTimeout),
514
+ timeout_ms: normalizedTimeout,
515
+ deadline_ms: Number.isFinite(Number(deadline_ms))
516
+ ? Math.trunc(Number(deadline_ms))
517
+ : now + normalizedTimeout,
518
+ trace_id: trace_id || uuidv7(),
519
+ correlation_id: correlation_id || uuidv7(),
520
+ last_message_id,
521
+ result_json: result == null ? null : JSON.stringify(result),
522
+ error_json: error == null ? null : JSON.stringify(error),
523
+ created_at_ms: now,
524
+ updated_at_ms: now,
525
+ started_at_ms: status === 'running' ? now : null,
526
+ completed_at_ms: ['succeeded', 'failed', 'timed_out'].includes(status) ? now : null,
527
+ last_retry_at_ms: retry_count > 0 ? now : null,
528
+ };
529
+ S.insertAssign.run(row);
530
+ const inserted = store.getAssign(row.job_id);
531
+ notifyAssignStatusListeners(inserted);
532
+ return inserted;
533
+ },
534
+
535
+ getAssign(jobId) {
536
+ return parseAssignRow(S.getAssign.get(jobId));
537
+ },
538
+
539
+ updateAssignStatus(jobId, status, patch = {}) {
540
+ const current = store.getAssign(jobId);
541
+ if (!current) return null;
542
+
543
+ const now = Date.now();
544
+ const nextStatus = status || current.status;
545
+ const isTerminal = ['succeeded', 'failed', 'timed_out'].includes(nextStatus);
546
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
547
+ const nextRow = {
548
+ job_id: current.job_id,
549
+ supervisor_agent: patch.supervisor_agent ?? current.supervisor_agent,
550
+ worker_agent: patch.worker_agent ?? current.worker_agent,
551
+ topic: patch.topic ?? current.topic,
552
+ task: patch.task ?? current.task,
553
+ payload_json: JSON.stringify(patch.payload ?? current.payload ?? {}),
554
+ status: nextStatus,
555
+ attempt: Math.max(1, Number(patch.attempt ?? current.attempt) || current.attempt || 1),
556
+ retry_count: Math.max(0, Number(patch.retry_count ?? current.retry_count) || 0),
557
+ max_retries: Math.max(0, Number(patch.max_retries ?? current.max_retries) || 0),
558
+ priority: clampPriority(patch.priority ?? current.priority, current.priority || 5),
559
+ ttl_ms: clampDuration(patch.ttl_ms ?? current.ttl_ms, current.ttl_ms || nextTimeout),
560
+ timeout_ms: nextTimeout,
561
+ deadline_ms: (() => {
562
+ if (Object.prototype.hasOwnProperty.call(patch, 'deadline_ms')) {
563
+ return patch.deadline_ms == null ? null : Math.trunc(Number(patch.deadline_ms));
564
+ }
565
+ if (isTerminal) return null;
566
+ if (nextStatus === 'running' && !current.deadline_ms) return now + nextTimeout;
567
+ return current.deadline_ms;
568
+ })(),
569
+ trace_id: patch.trace_id ?? current.trace_id,
570
+ correlation_id: patch.correlation_id ?? current.correlation_id,
571
+ last_message_id: Object.prototype.hasOwnProperty.call(patch, 'last_message_id')
572
+ ? patch.last_message_id
573
+ : current.last_message_id,
574
+ result_json: Object.prototype.hasOwnProperty.call(patch, 'result')
575
+ ? (patch.result == null ? null : JSON.stringify(patch.result))
576
+ : (current.result == null ? null : JSON.stringify(current.result)),
577
+ error_json: Object.prototype.hasOwnProperty.call(patch, 'error')
578
+ ? (patch.error == null ? null : JSON.stringify(patch.error))
579
+ : (current.error == null ? null : JSON.stringify(current.error)),
580
+ updated_at_ms: now,
581
+ started_at_ms: Object.prototype.hasOwnProperty.call(patch, 'started_at_ms')
582
+ ? patch.started_at_ms
583
+ : (nextStatus === 'running' ? (current.started_at_ms || now) : current.started_at_ms),
584
+ completed_at_ms: Object.prototype.hasOwnProperty.call(patch, 'completed_at_ms')
585
+ ? patch.completed_at_ms
586
+ : (isTerminal ? (current.completed_at_ms || now) : current.completed_at_ms),
587
+ last_retry_at_ms: Object.prototype.hasOwnProperty.call(patch, 'last_retry_at_ms')
588
+ ? patch.last_retry_at_ms
589
+ : current.last_retry_at_ms,
590
+ };
591
+ S.updateAssign.run(nextRow);
592
+ const updated = store.getAssign(jobId);
593
+ if (updated && current.status !== updated.status) {
594
+ notifyAssignStatusListeners(updated);
595
+ }
596
+ return updated;
597
+ },
598
+
599
+ listAssigns({
600
+ supervisor_agent,
601
+ worker_agent,
602
+ status,
603
+ statuses,
604
+ trace_id,
605
+ correlation_id,
606
+ active_before_ms,
607
+ limit = 50,
608
+ } = {}) {
609
+ const clauses = [];
610
+ const values = [];
611
+
612
+ if (supervisor_agent) {
613
+ clauses.push('supervisor_agent = ?');
614
+ values.push(supervisor_agent);
615
+ }
616
+ if (worker_agent) {
617
+ clauses.push('worker_agent = ?');
618
+ values.push(worker_agent);
619
+ }
620
+ if (trace_id) {
621
+ clauses.push('trace_id = ?');
622
+ values.push(trace_id);
623
+ }
624
+ if (correlation_id) {
625
+ clauses.push('correlation_id = ?');
626
+ values.push(correlation_id);
627
+ }
628
+
629
+ const statusList = Array.isArray(statuses) && statuses.length
630
+ ? statuses
631
+ : (status ? [status] : []);
632
+ if (statusList.length) {
633
+ clauses.push(`status IN (${statusList.map(() => '?').join(',')})`);
634
+ values.push(...statusList);
635
+ }
636
+
637
+ if (Number.isFinite(Number(active_before_ms))) {
638
+ clauses.push('deadline_ms IS NOT NULL AND deadline_ms <= ?');
639
+ values.push(Math.trunc(Number(active_before_ms)));
640
+ }
641
+
642
+ // WHERE 절은 호출마다 달라지므로 prepared statement를 미리 캐시할 수 없다.
643
+ // db.prepare() 호출당 한 번 실행되며, better-sqlite3 내부에서 SQLite 구문 파싱을
644
+ // 수행한다. 필터 조합이 2^6 = 64가지이므로 정적 캐시 대신 동적 생성을 선택했다.
645
+ // 함수는 hot path(heartbeat/poll)가 아닌 관리/조회 경로에서만 호출되므로 허용한다.
646
+ const sql = `
647
+ SELECT * FROM assign_jobs
648
+ ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''}
649
+ ORDER BY updated_at_ms DESC
650
+ LIMIT ?`;
651
+ values.push(clampMaxMessages(limit, 50));
652
+ return db.prepare(sql).all(...values).map(parseAssignRow);
653
+ },
654
+
655
+ retryAssign(jobId, patch = {}) {
656
+ const current = store.getAssign(jobId);
657
+ if (!current) return null;
658
+
659
+ const nextRetryCount = Math.max(0, Number(patch.retry_count ?? current.retry_count + 1) || 0);
660
+ const nextAttempt = Math.max(current.attempt + 1, Number(patch.attempt ?? current.attempt + 1) || 1);
661
+ const nextTimeout = clampDuration(patch.timeout_ms ?? current.timeout_ms, current.timeout_ms);
662
+ return store.updateAssignStatus(jobId, 'queued', {
663
+ retry_count: nextRetryCount,
664
+ attempt: nextAttempt,
665
+ timeout_ms: nextTimeout,
666
+ ttl_ms: patch.ttl_ms ?? current.ttl_ms,
667
+ deadline_ms: Date.now() + nextTimeout,
668
+ completed_at_ms: null,
669
+ started_at_ms: null,
670
+ last_retry_at_ms: Date.now(),
671
+ result: patch.result ?? null,
672
+ error: Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : current.error,
673
+ last_message_id: null,
674
+ });
675
+ },
676
+
677
+ sweepExpired() {
678
+ const now = Date.now();
679
+ return db.transaction(() => {
680
+ const expired = S.findExpired.all(now);
681
+ for (const { id } of expired) {
682
+ S.setMsgStatus.run('dead_letter', id);
683
+ S.insertDL.run(id, 'ttl_expired', now, null);
684
+ }
685
+ const humanRequests = S.expireHR.run(now).changes;
686
+ return { messages: expired.length, human_requests: humanRequests };
687
+ })();
688
+ },
689
+
690
+ getQueueDepths() {
691
+ return {
692
+ urgent: S.urgentDepth.get().cnt,
693
+ normal: S.normalDepth.get().cnt,
694
+ dlq: S.dlqDepth.get().cnt,
695
+ };
696
+ },
697
+
698
+ onAssignStatusChange(listener) {
699
+ if (typeof listener !== 'function') {
700
+ return () => {};
701
+ }
702
+ assignStatusListeners.add(listener);
703
+ return () => {
704
+ assignStatusListeners.delete(listener);
705
+ };
706
+ },
707
+
708
+ getDeliveryStats() {
709
+ return {
710
+ total_deliveries: S.ackedRecent.get(Date.now()).cnt,
711
+ avg_delivery_ms: 0,
712
+ };
713
+ },
714
+
715
+ getHubStats() {
716
+ return {
717
+ online_agents: S.onlineCount.get().cnt,
718
+ total_messages: S.msgCount.get().cnt,
719
+ active_assign_jobs: S.activeAssignCount.get().cnt,
720
+ ...store.getQueueDepths(),
721
+ };
722
+ },
723
+
724
+ getAuditStats() {
725
+ return {
726
+ online_agents: S.onlineCount.get().cnt,
727
+ total_messages: S.msgCount.get().cnt,
728
+ dlq: S.dlqDepth.get().cnt,
729
+ assign_queued: S.assignCountByStatus.get('queued').cnt,
730
+ assign_running: S.assignCountByStatus.get('running').cnt,
731
+ assign_failed: S.assignCountByStatus.get('failed').cnt,
732
+ assign_timed_out: S.assignCountByStatus.get('timed_out').cnt,
733
+ };
734
+ },
735
+
736
+ // --- Reflexion CRUD ---
737
+
738
+ addReflexion({ error_pattern, error_message, context = {}, solution, solution_code = null }) {
739
+ const now = Date.now();
740
+ const id = uuidv7();
741
+ S.insertReflexion.run({
742
+ id,
743
+ error_pattern,
744
+ error_message,
745
+ context_json: JSON.stringify(context),
746
+ solution,
747
+ solution_code,
748
+ confidence: 0.5,
749
+ hit_count: 1,
750
+ success_count: 0,
751
+ last_hit_ms: now,
752
+ created_at_ms: now,
753
+ updated_at_ms: now,
754
+ });
755
+ return store.getReflexion(id);
756
+ },
757
+
758
+ getReflexion(id) {
759
+ return parseReflexionRow(S.getReflexionById.get(id));
760
+ },
761
+
762
+ findReflexion(errorPattern, context = {}) {
763
+ const ctxKeys = Object.keys(context).filter(k => context[k] != null);
764
+ const ctxWhere = ctxKeys.map(k => ` AND json_extract(context_json, '$.${k}') = ?`).join('');
765
+ const ctxVals = ctxKeys.map(k => context[k]);
766
+
767
+ if (ctxKeys.length === 0) {
768
+ let rows = S.findReflexionExact.all(errorPattern);
769
+ if (rows.length) return rows.map(parseReflexionRow);
770
+ const escaped = errorPattern.replace(/[%_\\]/g, '\\$&');
771
+ rows = S.findReflexionLike.all(`%${escaped.slice(0, 100)}%`);
772
+ return rows.map(parseReflexionRow);
773
+ }
774
+
775
+ const exactSql = `SELECT * FROM reflexion_entries WHERE error_pattern = ?${ctxWhere} ORDER BY confidence DESC`;
776
+ let rows = db.prepare(exactSql).all(errorPattern, ...ctxVals);
777
+ if (rows.length) return rows.map(parseReflexionRow);
778
+
779
+ const escaped = errorPattern.replace(/[%_\\]/g, '\\$&');
780
+ const likeSql = `SELECT * FROM reflexion_entries WHERE error_pattern LIKE ? ESCAPE '\\'${ctxWhere} ORDER BY confidence DESC LIMIT 10`;
781
+ rows = db.prepare(likeSql).all(`%${escaped.slice(0, 100)}%`, ...ctxVals);
782
+ return rows.map(parseReflexionRow);
783
+ },
784
+
785
+ updateReflexionHit(id, success = false) {
786
+ const now = Date.now();
787
+ if (success) {
788
+ S.updateReflexionHitSuccess.run(now, now, id);
789
+ } else {
790
+ S.updateReflexionHitOnly.run(now, now, id);
791
+ }
792
+ const entry = store.getReflexion(id);
793
+ if (entry && entry.hit_count > 0) {
794
+ const conf = recalcConfidence(entry);
795
+ S.updateReflexionConfidence.run(Math.max(0, Math.min(1, conf)), now, id);
796
+ }
797
+ return store.getReflexion(id);
798
+ },
799
+
800
+ pruneReflexion(maxAge_ms = 30 * 24 * 3600 * 1000, minConfidence = 0.2) {
801
+ const cutoff = Date.now() - maxAge_ms;
802
+ return S.pruneReflexionEntries.run(cutoff, minConfidence).changes;
803
+ },
804
+ };
805
+
806
+ return store;
807
+ }