mcp-coordinator 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/dashboard/Dockerfile +19 -0
  4. package/dashboard/public/index.html +1178 -0
  5. package/dist/cli/config.d.ts +14 -0
  6. package/dist/cli/config.js +58 -0
  7. package/dist/cli/dashboard.d.ts +2 -0
  8. package/dist/cli/dashboard.js +14 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +13 -0
  11. package/dist/cli/server/index.d.ts +2 -0
  12. package/dist/cli/server/index.js +11 -0
  13. package/dist/cli/server/start.d.ts +2 -0
  14. package/dist/cli/server/start.js +57 -0
  15. package/dist/cli/server/status.d.ts +2 -0
  16. package/dist/cli/server/status.js +60 -0
  17. package/dist/cli/server/stop.d.ts +2 -0
  18. package/dist/cli/server/stop.js +59 -0
  19. package/dist/cli/version.d.ts +1 -0
  20. package/dist/cli/version.js +22 -0
  21. package/dist/src/agent-activity.d.ts +27 -0
  22. package/dist/src/agent-activity.js +70 -0
  23. package/dist/src/agent-registry.d.ts +10 -0
  24. package/dist/src/agent-registry.js +38 -0
  25. package/dist/src/auth.d.ts +22 -0
  26. package/dist/src/auth.js +91 -0
  27. package/dist/src/conflict-detector.d.ts +17 -0
  28. package/dist/src/conflict-detector.js +114 -0
  29. package/dist/src/consultation.d.ts +75 -0
  30. package/dist/src/consultation.js +332 -0
  31. package/dist/src/context-provider.d.ts +14 -0
  32. package/dist/src/context-provider.js +34 -0
  33. package/dist/src/database.d.ts +4 -0
  34. package/dist/src/database.js +194 -0
  35. package/dist/src/db-adapter.d.ts +15 -0
  36. package/dist/src/db-adapter.js +1 -0
  37. package/dist/src/dependency-map.d.ts +7 -0
  38. package/dist/src/dependency-map.js +76 -0
  39. package/dist/src/file-tracker.d.ts +21 -0
  40. package/dist/src/file-tracker.js +44 -0
  41. package/dist/src/impact-scorer.d.ts +31 -0
  42. package/dist/src/impact-scorer.js +112 -0
  43. package/dist/src/index.d.ts +2 -0
  44. package/dist/src/index.js +26 -0
  45. package/dist/src/introspection.d.ts +24 -0
  46. package/dist/src/introspection.js +28 -0
  47. package/dist/src/logger.d.ts +20 -0
  48. package/dist/src/logger.js +55 -0
  49. package/dist/src/mqtt-bridge.d.ts +40 -0
  50. package/dist/src/mqtt-bridge.js +173 -0
  51. package/dist/src/mqtt-broker.d.ts +23 -0
  52. package/dist/src/mqtt-broker.js +99 -0
  53. package/dist/src/plan-quality.d.ts +11 -0
  54. package/dist/src/plan-quality.js +30 -0
  55. package/dist/src/quota/credential-reader.d.ts +21 -0
  56. package/dist/src/quota/credential-reader.js +86 -0
  57. package/dist/src/quota/quota-cache.d.ts +93 -0
  58. package/dist/src/quota/quota-cache.js +177 -0
  59. package/dist/src/quota/quota.d.ts +47 -0
  60. package/dist/src/quota/quota.js +117 -0
  61. package/dist/src/serve-http.d.ts +5 -0
  62. package/dist/src/serve-http.js +775 -0
  63. package/dist/src/server-setup.d.ts +34 -0
  64. package/dist/src/server-setup.js +453 -0
  65. package/dist/src/sse-emitter.d.ts +10 -0
  66. package/dist/src/sse-emitter.js +35 -0
  67. package/dist/src/types.d.ts +121 -0
  68. package/dist/src/types.js +1 -0
  69. package/package.json +80 -0
@@ -0,0 +1,114 @@
1
+ import { silentLogger } from "./logger.js";
2
+ export class ConflictDetector {
3
+ consultation;
4
+ depMap;
5
+ fileTracker;
6
+ log;
7
+ constructor(consultation, depMap, fileTracker, logger) {
8
+ this.consultation = consultation;
9
+ this.depMap = depMap;
10
+ this.fileTracker = fileTracker;
11
+ this.log = logger || silentLogger;
12
+ }
13
+ detect(params) {
14
+ const conflicts = [];
15
+ // Include open, resolving, and recently resolved (auto-quorum) threads — exclude only cancelled
16
+ const allThreads = this.consultation.listThreads({});
17
+ const activeThreads = allThreads.filter((t) => t.status !== "cancelled");
18
+ for (const thread of activeThreads) {
19
+ if (thread.initiator_id === params.agent_id)
20
+ continue;
21
+ const threadModules = JSON.parse(thread.target_modules);
22
+ const threadFiles = JSON.parse(thread.target_files);
23
+ // 1. Module overlap
24
+ const moduleOverlap = params.target_modules.filter((m) => threadModules.includes(m));
25
+ if (moduleOverlap.length > 0) {
26
+ conflicts.push({
27
+ type: "module_overlap",
28
+ severity: "warning",
29
+ agent_id: thread.initiator_id,
30
+ agent_name: thread.subject,
31
+ description: `Module overlap on: ${moduleOverlap.join(", ")}`,
32
+ details: `Thread "${thread.subject}" (${thread.initiator_id}) targets same modules`,
33
+ });
34
+ }
35
+ // 2. File overlap
36
+ const fileOverlap = params.target_files.filter((f) => threadFiles.includes(f));
37
+ if (fileOverlap.length > 0) {
38
+ conflicts.push({
39
+ type: "file_overlap",
40
+ severity: "warning",
41
+ agent_id: thread.initiator_id,
42
+ agent_name: thread.subject,
43
+ description: `File overlap on: ${fileOverlap.join(", ")}`,
44
+ details: `Thread "${thread.subject}" (${thread.initiator_id}) targets same files`,
45
+ });
46
+ }
47
+ // 3. Dependency chain
48
+ for (const targetModule of params.target_modules) {
49
+ const info = this.depMap.getModuleInfo(targetModule);
50
+ if (!info)
51
+ continue;
52
+ for (const dep of info.depends_on) {
53
+ if (threadModules.includes(dep)) {
54
+ conflicts.push({
55
+ type: "dependency_chain",
56
+ severity: "info",
57
+ agent_id: thread.initiator_id,
58
+ agent_name: thread.subject,
59
+ description: `${targetModule} depends on ${dep} which is being modified`,
60
+ details: `Thread "${thread.subject}" modifies ${dep}, a dependency of ${targetModule}`,
61
+ });
62
+ }
63
+ }
64
+ // Reverse: someone depends on what we're modifying
65
+ const radius = this.depMap.getBlastRadius(targetModule);
66
+ this.log.debug({
67
+ module_id: targetModule,
68
+ direct_dependents: radius.direct_dependents,
69
+ indirect_dependents: radius.indirect_dependents,
70
+ }, "Blast radius calculated");
71
+ for (const dependent of [...radius.direct_dependents, ...radius.indirect_dependents]) {
72
+ if (threadModules.includes(dependent)) {
73
+ conflicts.push({
74
+ type: "dependency_chain",
75
+ severity: "info",
76
+ agent_id: thread.initiator_id,
77
+ agent_name: thread.subject,
78
+ description: `${dependent} depends on ${targetModule} which you are modifying`,
79
+ details: `Thread "${thread.subject}" works on ${dependent}, which depends on ${targetModule}`,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ // 4. Hot file overlap (from actual file activity, not just declared files)
86
+ for (const targetFile of params.target_files) {
87
+ const activity = this.fileTracker.checkFileConflict(targetFile, params.agent_id, 60);
88
+ if (activity.conflict) {
89
+ for (const otherAgent of activity.agents) {
90
+ // Avoid duplicating with file_overlap already detected
91
+ if (!conflicts.some(c => c.agent_id === otherAgent && c.type === "file_overlap")) {
92
+ conflicts.push({
93
+ type: "file_overlap",
94
+ severity: "warning",
95
+ agent_id: otherAgent,
96
+ agent_name: otherAgent,
97
+ description: `Hot file: ${targetFile} recently edited by ${otherAgent}`,
98
+ details: `File activity shows ${targetFile} was recently modified by ${otherAgent}`,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ if (conflicts.length > 0) {
105
+ this.log.warn({
106
+ agent_id: params.agent_id,
107
+ conflict_count: conflicts.length,
108
+ types: [...new Set(conflicts.map(c => c.type))],
109
+ modules: params.target_modules,
110
+ }, "Conflicts detected");
111
+ }
112
+ return conflicts;
113
+ }
114
+ }
@@ -0,0 +1,75 @@
1
+ import { type Logger } from "./logger.js";
2
+ import type { Thread, ThreadMessage, ActionSummary, MessageType, ResolutionType } from "./types.js";
3
+ export interface ResolutionEvent {
4
+ thread_id: string;
5
+ resolution_type: ResolutionType;
6
+ resolution_summary: string | null;
7
+ created_at: string;
8
+ resolved_at: string;
9
+ approved_by?: string;
10
+ approved_by_name?: string;
11
+ had_messages: boolean;
12
+ }
13
+ export declare class Consultation {
14
+ private onResolveCallback;
15
+ private log;
16
+ constructor(logger?: Logger);
17
+ onResolve(callback: (event: ResolutionEvent) => void): void;
18
+ emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
19
+ announceWork(params: {
20
+ agent_id: string;
21
+ subject: string;
22
+ plan?: string;
23
+ target_modules: string[];
24
+ target_files: string[];
25
+ depends_on_files?: string[];
26
+ exports_affected?: string[];
27
+ keep_open?: boolean;
28
+ assigned_to?: string | null;
29
+ }): Thread;
30
+ postToThread(params: {
31
+ thread_id: string;
32
+ agent_id: string;
33
+ agent_name?: string;
34
+ type: MessageType;
35
+ content: string;
36
+ context_snapshot?: string;
37
+ in_reply_to?: string;
38
+ }): ThreadMessage;
39
+ proposeResolution(threadId: string, agentId: string, summary: string): void;
40
+ approveResolution(threadId: string, agentId: string, agentName?: string): void;
41
+ contestResolution(threadId: string, agentId: string, reason: string): void;
42
+ cancelThread(threadId: string, agentId: string, reason?: string): void;
43
+ closeThread(threadId: string, agentId: string, summary: string): void;
44
+ handleAgentDeparture(agentId: string): void;
45
+ checkTimeouts(): void;
46
+ getThread(threadId: string): Thread | null;
47
+ getThreadWithMessages(threadId: string): {
48
+ thread: Thread;
49
+ messages: ThreadMessage[];
50
+ } | null;
51
+ listThreads(filters: {
52
+ status?: string;
53
+ agent_id?: string;
54
+ module?: string;
55
+ /**
56
+ * When set, only return threads that are claimable by this agent:
57
+ * assigned_to IS NULL (open pool — anyone can take it)
58
+ * OR assigned_to = agent_id (directed to me)
59
+ * Workers use this to filter out dispatches for other agents without
60
+ * parsing the thread list themselves.
61
+ */
62
+ assigned_to_me?: string;
63
+ }): Thread[];
64
+ getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
65
+ logActionSummary(params: {
66
+ session_id: string;
67
+ agent_id: string;
68
+ file_path?: string;
69
+ summary: string;
70
+ }): ActionSummary;
71
+ getActionSummaries(agentId: string, since?: string): ActionSummary[];
72
+ getActionSummariesBySession(sessionId: string): ActionSummary[];
73
+ private postResolutionMessage;
74
+ private allRespondentsApproved;
75
+ }
@@ -0,0 +1,332 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getDb } from "./database.js";
3
+ import { silentLogger } from "./logger.js";
4
+ export class Consultation {
5
+ onResolveCallback = null;
6
+ log;
7
+ constructor(logger) {
8
+ this.log = logger || silentLogger;
9
+ }
10
+ onResolve(callback) {
11
+ this.onResolveCallback = callback;
12
+ }
13
+ emitResolution(threadId, type, approvedBy, approvedByName) {
14
+ const db = getDb();
15
+ const thread = this.getThread(threadId);
16
+ if (!thread)
17
+ return;
18
+ const messageCount = db.prepare("SELECT COUNT(*) as count FROM thread_messages WHERE thread_id = ?").get(threadId).count;
19
+ const durationMs = thread ? Date.now() - new Date(thread.created_at).getTime() : undefined;
20
+ this.log.info({
21
+ thread_id: threadId,
22
+ resolution_type: type,
23
+ approved_by: approvedBy,
24
+ duration_ms: durationMs,
25
+ }, "Thread resolved");
26
+ if (this.onResolveCallback) {
27
+ this.onResolveCallback({
28
+ thread_id: threadId,
29
+ resolution_type: type,
30
+ resolution_summary: thread.resolution_summary,
31
+ created_at: thread.created_at,
32
+ resolved_at: thread.resolved_at || new Date().toISOString(),
33
+ approved_by: approvedBy,
34
+ approved_by_name: approvedByName,
35
+ had_messages: messageCount > 0,
36
+ });
37
+ }
38
+ }
39
+ announceWork(params) {
40
+ const db = getDb();
41
+ const id = randomUUID();
42
+ // Find expected respondents: online agents (not initiator) whose modules overlap
43
+ const onlineAgents = db
44
+ .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
45
+ .all(params.agent_id);
46
+ const respondents = onlineAgents.filter((agent) => {
47
+ const agentModules = JSON.parse(agent.modules);
48
+ return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
49
+ });
50
+ const respondentIds = respondents.map((r) => r.id);
51
+ // Directed dispatch skips module-based auto-resolve: if the thread is
52
+ // explicitly aimed at an agent, we keep it open for them regardless of
53
+ // what the module scorer finds.
54
+ const assignedTo = params.assigned_to ?? null;
55
+ const keepOpen = params.keep_open || assignedTo !== null;
56
+ const autoResolve = respondentIds.length === 0 && !keepOpen;
57
+ db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
59
+ this.log.info({
60
+ thread_id: id,
61
+ agent_id: params.agent_id,
62
+ subject: params.subject,
63
+ target_modules: params.target_modules,
64
+ auto_resolve: autoResolve,
65
+ respondent_count: respondentIds.length,
66
+ assigned_to: assignedTo,
67
+ }, "Thread opened");
68
+ return this.getThread(id);
69
+ }
70
+ postToThread(params) {
71
+ const db = getDb();
72
+ const thread = this.getThread(params.thread_id);
73
+ if (!thread)
74
+ throw new Error(`Thread ${params.thread_id} not found`);
75
+ // Cancelled threads are explicit aborts — reject posts.
76
+ // Resolved threads accept late posts (audit/enrichment) because the review
77
+ // phase races with auto-resolve: an ENRICHIT computed against an open
78
+ // thread may arrive milliseconds after the thread transitioned to resolved.
79
+ if (thread.status === "cancelled")
80
+ throw new Error(`Thread ${params.thread_id} is cancelled`);
81
+ const id = randomUUID();
82
+ // Simple token estimate: ~4 chars per token for English/French
83
+ const tokenEstimate = Math.ceil(params.content.length / 4);
84
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
86
+ this.log.debug({
87
+ thread_id: params.thread_id,
88
+ agent_id: params.agent_id,
89
+ type: params.type,
90
+ content_length: params.content.length,
91
+ }, "Message posted to thread");
92
+ return db.prepare("SELECT * FROM thread_messages WHERE id = ?").get(id);
93
+ }
94
+ proposeResolution(threadId, agentId, summary) {
95
+ const db = getDb();
96
+ const thread = this.getThread(threadId);
97
+ if (!thread)
98
+ throw new Error(`Thread ${threadId} not found`);
99
+ if (thread.initiator_id !== agentId && thread.claimed_by !== agentId)
100
+ throw new Error("Only the initiator or the claimant can propose a resolution");
101
+ db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ?").run(summary, threadId);
102
+ // Post resolution message
103
+ this.postResolutionMessage(threadId, agentId, "resolution", summary);
104
+ }
105
+ approveResolution(threadId, agentId, agentName) {
106
+ const db = getDb();
107
+ const thread = this.getThread(threadId);
108
+ if (!thread)
109
+ throw new Error(`Thread ${threadId} not found`);
110
+ if (thread.status !== "resolving")
111
+ throw new Error(`Thread is ${thread.status}, not resolving`);
112
+ // Post approve message
113
+ this.postResolutionMessage(threadId, agentId, "approve", "Approved");
114
+ this.log.debug({ thread_id: threadId, agent_id: agentId }, "Resolution approved");
115
+ // Check if all expected respondents have approved
116
+ if (this.allRespondentsApproved(threadId)) {
117
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
118
+ this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
119
+ }
120
+ }
121
+ contestResolution(threadId, agentId, reason) {
122
+ const db = getDb();
123
+ const thread = this.getThread(threadId);
124
+ if (!thread)
125
+ throw new Error(`Thread ${threadId} not found`);
126
+ if (thread.status !== "resolving")
127
+ throw new Error(`Thread is ${thread.status}, not resolving`);
128
+ // Post contest message
129
+ this.postResolutionMessage(threadId, agentId, "contest", reason);
130
+ this.log.debug({ thread_id: threadId, agent_id: agentId, reason }, "Resolution contested");
131
+ // Return to open with next round
132
+ const nextRound = thread.round + 1;
133
+ if (nextRound > thread.max_rounds) {
134
+ // Max rounds reached — force resolve
135
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
136
+ this.emitResolution(threadId, "max_rounds");
137
+ }
138
+ else {
139
+ db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ?").run(nextRound, threadId);
140
+ }
141
+ }
142
+ cancelThread(threadId, agentId, reason) {
143
+ const db = getDb();
144
+ const thread = this.getThread(threadId);
145
+ if (!thread)
146
+ throw new Error(`Thread ${threadId} not found`);
147
+ if (thread.initiator_id !== agentId)
148
+ throw new Error("Only the initiator can cancel");
149
+ db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
150
+ if (reason) {
151
+ this.postResolutionMessage(threadId, agentId, "context", `Cancelled: ${reason}`);
152
+ }
153
+ }
154
+ closeThread(threadId, agentId, summary) {
155
+ const db = getDb();
156
+ const thread = this.getThread(threadId);
157
+ if (!thread) {
158
+ throw new Error(`Thread ${threadId} not found`);
159
+ }
160
+ if (thread.initiator_id !== agentId) {
161
+ throw new Error(`Only the initiator (${thread.initiator_id}) may close thread ${threadId}, not ${agentId}`);
162
+ }
163
+ if (thread.status !== "open" && thread.status !== "resolving") {
164
+ throw new Error(`Cannot close thread ${threadId} in status '${thread.status}'`);
165
+ }
166
+ db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ?").run(summary, new Date().toISOString(), threadId);
167
+ this.emitResolution(threadId, "closed");
168
+ }
169
+ handleAgentDeparture(agentId) {
170
+ const db = getDb();
171
+ // Unclaim any tasks claimed by the departing agent
172
+ db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ? AND status = 'open'")
173
+ .run(agentId);
174
+ // Remove departed agent from expected_respondents of all open/resolving threads
175
+ const threads = db
176
+ .prepare("SELECT id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
177
+ .all();
178
+ for (const thread of threads) {
179
+ const respondents = JSON.parse(thread.expected_respondents || "[]");
180
+ const updated = respondents.filter((r) => r !== agentId);
181
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?").run(JSON.stringify(updated), thread.id);
182
+ // If resolving and all remaining approved, resolve
183
+ const t = this.getThread(thread.id);
184
+ if (t.status === "resolving" && this.allRespondentsApproved(thread.id)) {
185
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
186
+ this.emitResolution(thread.id, "agent_departure");
187
+ }
188
+ // If open and no respondents left, auto-resolve
189
+ if (t.status === "open" && updated.length === 0) {
190
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
191
+ this.emitResolution(thread.id, "agent_departure");
192
+ }
193
+ }
194
+ }
195
+ checkTimeouts() {
196
+ const db = getDb();
197
+ // Get threads that will be timed out (before updating them)
198
+ const timedOut = db.prepare(`
199
+ SELECT id FROM threads
200
+ WHERE status IN ('open', 'resolving')
201
+ AND timeout_seconds > 0
202
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
203
+ `).all();
204
+ if (timedOut.length === 0)
205
+ return;
206
+ db.prepare(`
207
+ UPDATE threads SET status = 'resolved',
208
+ resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
209
+ resolved_at = CURRENT_TIMESTAMP
210
+ WHERE status IN ('open', 'resolving')
211
+ AND timeout_seconds > 0
212
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
213
+ `).run();
214
+ this.log.info({ count: timedOut.length, thread_ids: timedOut.map(t => t.id) }, "Threads timed out");
215
+ for (const t of timedOut) {
216
+ this.emitResolution(t.id, "timeout");
217
+ }
218
+ }
219
+ getThread(threadId) {
220
+ this.checkTimeouts();
221
+ const db = getDb();
222
+ return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
223
+ }
224
+ getThreadWithMessages(threadId) {
225
+ const thread = this.getThread(threadId);
226
+ if (!thread)
227
+ return null;
228
+ const db = getDb();
229
+ const messages = db
230
+ .prepare("SELECT * FROM thread_messages WHERE thread_id = ? ORDER BY created_at")
231
+ .all(threadId);
232
+ return { thread, messages };
233
+ }
234
+ listThreads(filters) {
235
+ this.checkTimeouts();
236
+ const db = getDb();
237
+ let sql = "SELECT * FROM threads WHERE 1=1";
238
+ const params = [];
239
+ if (filters.status) {
240
+ sql += " AND status = ?";
241
+ params.push(filters.status);
242
+ }
243
+ if (filters.agent_id) {
244
+ sql += " AND initiator_id = ?";
245
+ params.push(filters.agent_id);
246
+ }
247
+ if (filters.module) {
248
+ // Exact match against each array element via json_each — LIKE on the
249
+ // serialized JSON caused false positives where "src/api" matched
250
+ // "src/api-gateway" too.
251
+ sql += " AND EXISTS (SELECT 1 FROM json_each(target_modules) WHERE value = ?)";
252
+ params.push(filters.module);
253
+ }
254
+ if (filters.assigned_to_me) {
255
+ sql += " AND (assigned_to IS NULL OR assigned_to = ?)";
256
+ params.push(filters.assigned_to_me);
257
+ }
258
+ sql += " ORDER BY created_at DESC";
259
+ return db.prepare(sql).all(...params);
260
+ }
261
+ getThreadUpdates(agentId, since) {
262
+ const db = getDb();
263
+ let sql = `SELECT tm.* FROM thread_messages tm
264
+ JOIN threads t ON tm.thread_id = t.id
265
+ WHERE t.status IN ('open', 'resolving')
266
+ AND tm.agent_id != ?`;
267
+ const params = [agentId];
268
+ if (since) {
269
+ sql += " AND tm.created_at >= ?";
270
+ // Normalize ANY parseable ISO/date string (including timezone offsets
271
+ // like "+05:00", "-0800", fractional seconds) to SQLite CURRENT_TIMESTAMP
272
+ // format "YYYY-MM-DD HH:MM:SS" in UTC. The old regex-based normalization
273
+ // only handled the `.\d+$` suffix, which left "+05:00" in place and
274
+ // broke the comparison.
275
+ const date = new Date(since);
276
+ const normalized = isNaN(date.getTime())
277
+ ? since
278
+ : date.toISOString().replace("T", " ").slice(0, 19);
279
+ params.push(normalized);
280
+ }
281
+ sql += " ORDER BY tm.created_at";
282
+ return db.prepare(sql).all(...params);
283
+ }
284
+ logActionSummary(params) {
285
+ const db = getDb();
286
+ const id = randomUUID();
287
+ db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
288
+ VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
289
+ return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
290
+ }
291
+ getActionSummaries(agentId, since) {
292
+ const db = getDb();
293
+ let sql = "SELECT * FROM action_summaries WHERE agent_id = ?";
294
+ const params = [agentId];
295
+ if (since) {
296
+ sql += " AND created_at > ?";
297
+ params.push(since);
298
+ }
299
+ sql += " ORDER BY created_at DESC";
300
+ return db.prepare(sql).all(...params);
301
+ }
302
+ getActionSummariesBySession(sessionId) {
303
+ const db = getDb();
304
+ return db
305
+ .prepare("SELECT * FROM action_summaries WHERE session_id = ? ORDER BY created_at")
306
+ .all(sessionId);
307
+ }
308
+ // ── Private helpers ──
309
+ postResolutionMessage(threadId, agentId, type, content) {
310
+ const db = getDb();
311
+ const thread = this.getThread(threadId);
312
+ const id = randomUUID();
313
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
314
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
315
+ }
316
+ allRespondentsApproved(threadId) {
317
+ const db = getDb();
318
+ const thread = this.getThread(threadId);
319
+ const expected = JSON.parse(thread.expected_respondents || "[]");
320
+ if (expected.length === 0)
321
+ return true;
322
+ // Only count approvals from the CURRENT round. A contested resolution
323
+ // increments the round, and prior-round approvals must be re-collected
324
+ // for the new proposal.
325
+ const approvals = db
326
+ .prepare(`SELECT DISTINCT agent_id FROM thread_messages
327
+ WHERE thread_id = ? AND type = 'approve' AND round = ?`)
328
+ .all(threadId, thread.round);
329
+ const approvedIds = new Set(approvals.map((a) => a.agent_id));
330
+ return expected.every((id) => approvedIds.has(id));
331
+ }
332
+ }
@@ -0,0 +1,14 @@
1
+ import type { AgentContext, ConsultationAnnounce } from "./types.js";
2
+ import type { AgentRegistry } from "./agent-registry.js";
3
+ import type { Consultation } from "./consultation.js";
4
+ import type { FileTracker } from "./file-tracker.js";
5
+ export interface ContextProvider {
6
+ getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
7
+ }
8
+ export declare class SummaryContextProvider implements ContextProvider {
9
+ private registry;
10
+ private consultation;
11
+ private fileTracker;
12
+ constructor(registry: AgentRegistry, consultation: Consultation, fileTracker: FileTracker);
13
+ getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
14
+ }
@@ -0,0 +1,34 @@
1
+ export class SummaryContextProvider {
2
+ registry;
3
+ consultation;
4
+ fileTracker;
5
+ constructor(registry, consultation, fileTracker) {
6
+ this.registry = registry;
7
+ this.consultation = consultation;
8
+ this.fileTracker = fileTracker;
9
+ }
10
+ getRelevantContext(agentId, query) {
11
+ const agent = this.registry.get(agentId);
12
+ if (!agent) {
13
+ return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
14
+ }
15
+ const agentModules = JSON.parse(agent.modules);
16
+ // Filter to only overlapping modules
17
+ const overlapping = agentModules.filter((am) => query.target_modules.some((tm) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
18
+ if (overlapping.length === 0) {
19
+ return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
20
+ }
21
+ // Get action summaries for this agent
22
+ const summaries = this.consultation.getActionSummaries(agentId);
23
+ // Get recent files from action summaries (agent writes these via MCP tool)
24
+ const recentFiles = summaries
25
+ .filter((s) => s.file_path)
26
+ .map((s) => s.file_path);
27
+ return {
28
+ agent_id: agentId,
29
+ modules: overlapping,
30
+ recent_files: [...new Set(recentFiles)],
31
+ action_summaries: summaries,
32
+ };
33
+ }
34
+ }
@@ -0,0 +1,4 @@
1
+ import type { DatabaseAdapter } from "./db-adapter.js";
2
+ export declare function initDatabase(dataDir: string): void;
3
+ export declare function getDb(): DatabaseAdapter;
4
+ export declare function closeDb(): void;