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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dashboard/Dockerfile +19 -0
- package/dashboard/public/index.html +1178 -0
- package/dist/cli/config.d.ts +14 -0
- package/dist/cli/config.js +58 -0
- package/dist/cli/dashboard.d.ts +2 -0
- package/dist/cli/dashboard.js +14 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/server/index.d.ts +2 -0
- package/dist/cli/server/index.js +11 -0
- package/dist/cli/server/start.d.ts +2 -0
- package/dist/cli/server/start.js +57 -0
- package/dist/cli/server/status.d.ts +2 -0
- package/dist/cli/server/status.js +60 -0
- package/dist/cli/server/stop.d.ts +2 -0
- package/dist/cli/server/stop.js +59 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/src/agent-activity.d.ts +27 -0
- package/dist/src/agent-activity.js +70 -0
- package/dist/src/agent-registry.d.ts +10 -0
- package/dist/src/agent-registry.js +38 -0
- package/dist/src/auth.d.ts +22 -0
- package/dist/src/auth.js +91 -0
- package/dist/src/conflict-detector.d.ts +17 -0
- package/dist/src/conflict-detector.js +114 -0
- package/dist/src/consultation.d.ts +75 -0
- package/dist/src/consultation.js +332 -0
- package/dist/src/context-provider.d.ts +14 -0
- package/dist/src/context-provider.js +34 -0
- package/dist/src/database.d.ts +4 -0
- package/dist/src/database.js +194 -0
- package/dist/src/db-adapter.d.ts +15 -0
- package/dist/src/db-adapter.js +1 -0
- package/dist/src/dependency-map.d.ts +7 -0
- package/dist/src/dependency-map.js +76 -0
- package/dist/src/file-tracker.d.ts +21 -0
- package/dist/src/file-tracker.js +44 -0
- package/dist/src/impact-scorer.d.ts +31 -0
- package/dist/src/impact-scorer.js +112 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +26 -0
- package/dist/src/introspection.d.ts +24 -0
- package/dist/src/introspection.js +28 -0
- package/dist/src/logger.d.ts +20 -0
- package/dist/src/logger.js +55 -0
- package/dist/src/mqtt-bridge.d.ts +40 -0
- package/dist/src/mqtt-bridge.js +173 -0
- package/dist/src/mqtt-broker.d.ts +23 -0
- package/dist/src/mqtt-broker.js +99 -0
- package/dist/src/plan-quality.d.ts +11 -0
- package/dist/src/plan-quality.js +30 -0
- package/dist/src/quota/credential-reader.d.ts +21 -0
- package/dist/src/quota/credential-reader.js +86 -0
- package/dist/src/quota/quota-cache.d.ts +93 -0
- package/dist/src/quota/quota-cache.js +177 -0
- package/dist/src/quota/quota.d.ts +47 -0
- package/dist/src/quota/quota.js +117 -0
- package/dist/src/serve-http.d.ts +5 -0
- package/dist/src/serve-http.js +775 -0
- package/dist/src/server-setup.d.ts +34 -0
- package/dist/src/server-setup.js +453 -0
- package/dist/src/sse-emitter.d.ts +10 -0
- package/dist/src/sse-emitter.js +35 -0
- package/dist/src/types.d.ts +121 -0
- package/dist/src/types.js +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { mkdirSync } from "fs";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
let db;
|
|
6
|
+
const SCHEMA = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
name TEXT NOT NULL,
|
|
10
|
+
modules TEXT DEFAULT '[]',
|
|
11
|
+
status TEXT DEFAULT 'offline',
|
|
12
|
+
registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
initiator_id TEXT NOT NULL,
|
|
19
|
+
subject TEXT NOT NULL,
|
|
20
|
+
plan TEXT,
|
|
21
|
+
target_modules TEXT DEFAULT '[]',
|
|
22
|
+
target_files TEXT DEFAULT '[]',
|
|
23
|
+
status TEXT DEFAULT 'open',
|
|
24
|
+
resolution_summary TEXT,
|
|
25
|
+
conflicts TEXT,
|
|
26
|
+
round INTEGER DEFAULT 1,
|
|
27
|
+
max_rounds INTEGER DEFAULT 4,
|
|
28
|
+
timeout_seconds INTEGER DEFAULT 600,
|
|
29
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
30
|
+
resolved_at TEXT,
|
|
31
|
+
expected_respondents TEXT,
|
|
32
|
+
depends_on_files TEXT,
|
|
33
|
+
exports_affected TEXT,
|
|
34
|
+
claimed_by TEXT,
|
|
35
|
+
claimed_at TEXT,
|
|
36
|
+
FOREIGN KEY (initiator_id) REFERENCES agents(id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS thread_messages (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
thread_id TEXT NOT NULL,
|
|
42
|
+
agent_id TEXT NOT NULL,
|
|
43
|
+
agent_name TEXT,
|
|
44
|
+
type TEXT NOT NULL,
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
context_snapshot TEXT,
|
|
47
|
+
in_reply_to TEXT,
|
|
48
|
+
round INTEGER NOT NULL,
|
|
49
|
+
token_estimate INTEGER DEFAULT 0,
|
|
50
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
51
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id),
|
|
52
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS action_summaries (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
session_id TEXT NOT NULL,
|
|
58
|
+
agent_id TEXT NOT NULL,
|
|
59
|
+
file_path TEXT,
|
|
60
|
+
summary TEXT NOT NULL,
|
|
61
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
62
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
type TEXT NOT NULL,
|
|
68
|
+
payload TEXT NOT NULL,
|
|
69
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS dependency_map (
|
|
73
|
+
module_id TEXT PRIMARY KEY,
|
|
74
|
+
depends_on TEXT DEFAULT '[]',
|
|
75
|
+
exports TEXT DEFAULT '[]',
|
|
76
|
+
owners TEXT DEFAULT '[]'
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS file_activity (
|
|
80
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
+
session_id TEXT NOT NULL,
|
|
82
|
+
agent_id TEXT NOT NULL,
|
|
83
|
+
agent_name TEXT,
|
|
84
|
+
tool_name TEXT NOT NULL,
|
|
85
|
+
file_path TEXT NOT NULL,
|
|
86
|
+
module TEXT,
|
|
87
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS introspections (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
thread_id TEXT NOT NULL,
|
|
103
|
+
agent_id TEXT NOT NULL,
|
|
104
|
+
score INTEGER NOT NULL,
|
|
105
|
+
reasons TEXT,
|
|
106
|
+
status TEXT DEFAULT 'pending',
|
|
107
|
+
response TEXT,
|
|
108
|
+
concerned INTEGER DEFAULT 0,
|
|
109
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
110
|
+
responded_at TEXT,
|
|
111
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id),
|
|
112
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS agent_activity_status (
|
|
119
|
+
agent_id TEXT PRIMARY KEY,
|
|
120
|
+
activity_status TEXT DEFAULT 'idle',
|
|
121
|
+
current_file TEXT,
|
|
122
|
+
current_thread TEXT,
|
|
123
|
+
last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
124
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE TABLE IF NOT EXISTS revoked_agents (
|
|
128
|
+
agent_id TEXT PRIMARY KEY,
|
|
129
|
+
revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
130
|
+
revoked_by TEXT NOT NULL
|
|
131
|
+
);
|
|
132
|
+
`;
|
|
133
|
+
function createBetterSqlite3(dataDir) {
|
|
134
|
+
mkdirSync(dataDir, { recursive: true });
|
|
135
|
+
const Database = require("better-sqlite3");
|
|
136
|
+
const dbPath = path.join(dataDir, "coordinator.db");
|
|
137
|
+
const raw = new Database(dbPath);
|
|
138
|
+
raw.pragma("journal_mode = WAL");
|
|
139
|
+
raw.pragma("busy_timeout = 5000");
|
|
140
|
+
raw.pragma("foreign_keys = ON");
|
|
141
|
+
return raw;
|
|
142
|
+
}
|
|
143
|
+
function createBunSqlite(dataDir) {
|
|
144
|
+
mkdirSync(dataDir, { recursive: true });
|
|
145
|
+
const { Database } = require("bun:sqlite");
|
|
146
|
+
const dbPath = path.join(dataDir, "coordinator.db");
|
|
147
|
+
const raw = new Database(dbPath);
|
|
148
|
+
raw.exec("PRAGMA journal_mode = WAL");
|
|
149
|
+
raw.exec("PRAGMA busy_timeout = 5000");
|
|
150
|
+
raw.exec("PRAGMA foreign_keys = ON");
|
|
151
|
+
return raw;
|
|
152
|
+
}
|
|
153
|
+
export function initDatabase(dataDir) {
|
|
154
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
155
|
+
db = createBunSqlite(dataDir);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
db = createBetterSqlite3(dataDir);
|
|
159
|
+
}
|
|
160
|
+
db.exec(SCHEMA);
|
|
161
|
+
// Migrations for existing databases — columns may already exist
|
|
162
|
+
try {
|
|
163
|
+
db.exec("ALTER TABLE threads ADD COLUMN claimed_by TEXT");
|
|
164
|
+
}
|
|
165
|
+
catch { /* already exists */ }
|
|
166
|
+
try {
|
|
167
|
+
db.exec("ALTER TABLE threads ADD COLUMN claimed_at TEXT");
|
|
168
|
+
}
|
|
169
|
+
catch { /* already exists */ }
|
|
170
|
+
// F4: track unclaim count to poison threads that no agent manages to complete.
|
|
171
|
+
// Without this, an aborted task bounces back into the pool indefinitely and
|
|
172
|
+
// gets re-claimed by the same (or next) agent in a tight failure loop.
|
|
173
|
+
try {
|
|
174
|
+
db.exec("ALTER TABLE threads ADD COLUMN unclaim_count INTEGER DEFAULT 0");
|
|
175
|
+
}
|
|
176
|
+
catch { /* already exists */ }
|
|
177
|
+
// Directed-dispatch: a thread with `assigned_to` set is claimable only by
|
|
178
|
+
// that specific agent. NULL = anyone can claim (backwards compat with
|
|
179
|
+
// existing work-stealing). Used by lead/worker presets and sequential
|
|
180
|
+
// pipelines that need explicit hand-offs instead of first-come claims.
|
|
181
|
+
try {
|
|
182
|
+
db.exec("ALTER TABLE threads ADD COLUMN assigned_to TEXT");
|
|
183
|
+
}
|
|
184
|
+
catch { /* already exists */ }
|
|
185
|
+
}
|
|
186
|
+
export function getDb() {
|
|
187
|
+
if (!db)
|
|
188
|
+
throw new Error("Database not initialized. Call initDatabase first.");
|
|
189
|
+
return db;
|
|
190
|
+
}
|
|
191
|
+
export function closeDb() {
|
|
192
|
+
if (db)
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface RunResult {
|
|
2
|
+
changes: number;
|
|
3
|
+
lastInsertRowid: number;
|
|
4
|
+
}
|
|
5
|
+
export interface Statement {
|
|
6
|
+
run(...params: unknown[]): RunResult;
|
|
7
|
+
get(...params: unknown[]): unknown;
|
|
8
|
+
all(...params: unknown[]): unknown[];
|
|
9
|
+
}
|
|
10
|
+
export interface DatabaseAdapter {
|
|
11
|
+
prepare(sql: string): Statement;
|
|
12
|
+
exec(sql: string): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
transaction<T>(fn: () => T): () => T;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ModuleInfo, DependencyMap, BlastRadius } from "./types.js";
|
|
2
|
+
export declare class DependencyMapper {
|
|
3
|
+
getMap(): DependencyMap;
|
|
4
|
+
setMap(map: DependencyMap): void;
|
|
5
|
+
getModuleInfo(moduleId: string): ModuleInfo | null;
|
|
6
|
+
getBlastRadius(moduleId: string): BlastRadius;
|
|
7
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
export class DependencyMapper {
|
|
3
|
+
getMap() {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
const rows = db.prepare("SELECT * FROM dependency_map").all();
|
|
6
|
+
const map = {};
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
map[row.module_id] = {
|
|
9
|
+
module_id: row.module_id,
|
|
10
|
+
depends_on: JSON.parse(row.depends_on),
|
|
11
|
+
exports: JSON.parse(row.exports),
|
|
12
|
+
owners: JSON.parse(row.owners),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
setMap(map) {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
|
|
20
|
+
VALUES (?, ?, ?, ?)
|
|
21
|
+
ON CONFLICT(module_id) DO UPDATE SET
|
|
22
|
+
depends_on = excluded.depends_on, exports = excluded.exports, owners = excluded.owners`);
|
|
23
|
+
const tx = db.transaction(() => {
|
|
24
|
+
for (const [id, info] of Object.entries(map)) {
|
|
25
|
+
stmt.run(id, JSON.stringify(info.depends_on), JSON.stringify(info.exports), JSON.stringify(info.owners));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
tx();
|
|
29
|
+
}
|
|
30
|
+
getModuleInfo(moduleId) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const row = db.prepare("SELECT * FROM dependency_map WHERE module_id = ?").get(moduleId);
|
|
33
|
+
if (!row)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
module_id: row.module_id,
|
|
37
|
+
depends_on: JSON.parse(row.depends_on),
|
|
38
|
+
exports: JSON.parse(row.exports),
|
|
39
|
+
owners: JSON.parse(row.owners),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
getBlastRadius(moduleId) {
|
|
43
|
+
const map = this.getMap();
|
|
44
|
+
const direct = [];
|
|
45
|
+
const indirect = [];
|
|
46
|
+
const visited = new Set();
|
|
47
|
+
for (const [id, info] of Object.entries(map)) {
|
|
48
|
+
if (info.depends_on.includes(moduleId))
|
|
49
|
+
direct.push(id);
|
|
50
|
+
}
|
|
51
|
+
const queue = [...direct];
|
|
52
|
+
visited.add(moduleId);
|
|
53
|
+
for (const d of direct)
|
|
54
|
+
visited.add(d);
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const current = queue.shift();
|
|
57
|
+
for (const [id, info] of Object.entries(map)) {
|
|
58
|
+
if (!visited.has(id) && info.depends_on.includes(current)) {
|
|
59
|
+
indirect.push(id);
|
|
60
|
+
visited.add(id);
|
|
61
|
+
queue.push(id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const moduleInfo = map[moduleId];
|
|
66
|
+
const affectedExports = moduleInfo ? moduleInfo.exports : [];
|
|
67
|
+
const activeThreadsInRadius = [];
|
|
68
|
+
return {
|
|
69
|
+
module_id: moduleId,
|
|
70
|
+
direct_dependents: direct,
|
|
71
|
+
indirect_dependents: indirect,
|
|
72
|
+
affected_exports: affectedExports,
|
|
73
|
+
active_threads_in_radius: activeThreadsInRadius,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FileActivity } from "./types.js";
|
|
2
|
+
export declare class FileTracker {
|
|
3
|
+
log(params: {
|
|
4
|
+
session_id: string;
|
|
5
|
+
agent_id: string;
|
|
6
|
+
agent_name?: string;
|
|
7
|
+
tool_name: string;
|
|
8
|
+
file_path: string;
|
|
9
|
+
}): void;
|
|
10
|
+
getBySession(sessionId: string): FileActivity[];
|
|
11
|
+
getHotFiles(sinceMinutes?: number): {
|
|
12
|
+
file_path: string;
|
|
13
|
+
agent_count: number;
|
|
14
|
+
agents: string[];
|
|
15
|
+
}[];
|
|
16
|
+
checkFileConflict(filePath: string, agentId: string, withinMinutes?: number): {
|
|
17
|
+
conflict: boolean;
|
|
18
|
+
agents: string[];
|
|
19
|
+
};
|
|
20
|
+
fileToModule(filePath: string): string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
export class FileTracker {
|
|
3
|
+
log(params) {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
const module = this.fileToModule(params.file_path);
|
|
6
|
+
db.prepare(`INSERT INTO file_activity (session_id, agent_id, agent_name, tool_name, file_path, module)
|
|
7
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module);
|
|
8
|
+
}
|
|
9
|
+
getBySession(sessionId) {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
return db.prepare("SELECT * FROM file_activity WHERE session_id = ? ORDER BY created_at").all(sessionId);
|
|
12
|
+
}
|
|
13
|
+
getHotFiles(sinceMinutes = 30) {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
|
|
16
|
+
FROM file_activity
|
|
17
|
+
WHERE created_at > datetime('now', '-' || ? || ' minutes')
|
|
18
|
+
GROUP BY file_path
|
|
19
|
+
HAVING COUNT(DISTINCT agent_id) > 1
|
|
20
|
+
ORDER BY agent_count DESC`).all(sinceMinutes);
|
|
21
|
+
return rows.map((r) => ({
|
|
22
|
+
file_path: r.file_path,
|
|
23
|
+
agent_count: r.agent_count,
|
|
24
|
+
agents: r.agents.split(","),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
checkFileConflict(filePath, agentId, withinMinutes = 30) {
|
|
28
|
+
const db = getDb();
|
|
29
|
+
const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
|
|
30
|
+
WHERE file_path = ? AND agent_id != ?
|
|
31
|
+
AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
|
|
32
|
+
return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
|
|
33
|
+
}
|
|
34
|
+
fileToModule(filePath) {
|
|
35
|
+
// Strip leading / so "/server/src/x.ts" and "server/src/x.ts" produce the
|
|
36
|
+
// same module name. Without this, split("/") on an absolute path yields
|
|
37
|
+
// ["", "server", "src", ...] and slice(0,2) gives "/server" instead of "server/src".
|
|
38
|
+
const normalized = filePath.replace(/^\/+/, "");
|
|
39
|
+
const parts = normalized.split("/");
|
|
40
|
+
if (parts.length < 2 || parts[0] === "")
|
|
41
|
+
return "";
|
|
42
|
+
return parts.slice(0, 2).join("/");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentRegistry } from "./agent-registry.js";
|
|
2
|
+
import type { FileTracker } from "./file-tracker.js";
|
|
3
|
+
import type { Consultation } from "./consultation.js";
|
|
4
|
+
export interface ImpactScore {
|
|
5
|
+
agent_id: string;
|
|
6
|
+
agent_name: string;
|
|
7
|
+
score: number;
|
|
8
|
+
reasons: string[];
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CategorizedImpact {
|
|
12
|
+
concerned: ImpactScore[];
|
|
13
|
+
gray_zone: ImpactScore[];
|
|
14
|
+
pass: ImpactScore[];
|
|
15
|
+
}
|
|
16
|
+
interface AnnounceParams {
|
|
17
|
+
agent_id: string;
|
|
18
|
+
target_modules: string[];
|
|
19
|
+
target_files: string[];
|
|
20
|
+
depends_on_files?: string[];
|
|
21
|
+
exports_affected?: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare class ImpactScorer {
|
|
24
|
+
private registry;
|
|
25
|
+
private fileTracker;
|
|
26
|
+
private consultation?;
|
|
27
|
+
constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined);
|
|
28
|
+
score(params: AnnounceParams): ImpactScore[];
|
|
29
|
+
categorize(params: AnnounceParams): CategorizedImpact;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export class ImpactScorer {
|
|
2
|
+
registry;
|
|
3
|
+
fileTracker;
|
|
4
|
+
consultation;
|
|
5
|
+
constructor(registry, fileTracker, consultation) {
|
|
6
|
+
this.registry = registry;
|
|
7
|
+
this.fileTracker = fileTracker;
|
|
8
|
+
this.consultation = consultation;
|
|
9
|
+
}
|
|
10
|
+
score(params) {
|
|
11
|
+
const onlineAgents = this.registry
|
|
12
|
+
.listOnline()
|
|
13
|
+
.filter((a) => a.id !== params.agent_id);
|
|
14
|
+
return onlineAgents.map((agent) => {
|
|
15
|
+
const agentModules = JSON.parse(agent.modules);
|
|
16
|
+
const reasons = [];
|
|
17
|
+
let maxScore = 0;
|
|
18
|
+
// Layer 0: Announced intent overlap (checks active threads from this agent).
|
|
19
|
+
// Resolved threads older than LAYER_0_RESOLVED_WINDOW_MS are excluded —
|
|
20
|
+
// yesterday's resolved work shouldn't trigger today's scoring.
|
|
21
|
+
if (this.consultation) {
|
|
22
|
+
const LAYER_0_RESOLVED_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
// SQLite datetime('now') returns UTC without a TZ suffix. new Date(str)
|
|
25
|
+
// parses it as local time by default — causing a local-offset skew.
|
|
26
|
+
// Normalize by treating the space as T and appending Z.
|
|
27
|
+
const parseSqliteUtc = (s) => {
|
|
28
|
+
const iso = /[Tt]/.test(s) ? s : s.replace(" ", "T");
|
|
29
|
+
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + "Z").getTime();
|
|
30
|
+
};
|
|
31
|
+
const activeThreads = [
|
|
32
|
+
...this.consultation.listThreads({ status: "open" }),
|
|
33
|
+
...this.consultation.listThreads({ status: "resolving" }),
|
|
34
|
+
...this.consultation
|
|
35
|
+
.listThreads({ status: "resolved" })
|
|
36
|
+
.filter((t) => {
|
|
37
|
+
if (!t.resolved_at)
|
|
38
|
+
return true;
|
|
39
|
+
const resolvedAt = parseSqliteUtc(t.resolved_at);
|
|
40
|
+
return !isNaN(resolvedAt) && now - resolvedAt <= LAYER_0_RESOLVED_WINDOW_MS;
|
|
41
|
+
}),
|
|
42
|
+
].filter((t) => t.initiator_id === agent.id);
|
|
43
|
+
for (const thread of activeThreads) {
|
|
44
|
+
const threadFiles = JSON.parse(thread.target_files || "[]");
|
|
45
|
+
const threadDeps = JSON.parse(thread.depends_on_files || "[]");
|
|
46
|
+
// 0a: My target_files ∩ their target_files → score 100
|
|
47
|
+
const fileOverlap = params.target_files.filter((f) => threadFiles.includes(f));
|
|
48
|
+
if (fileOverlap.length > 0) {
|
|
49
|
+
maxScore = Math.max(maxScore, 100);
|
|
50
|
+
reasons.push(`announced same file: ${fileOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
|
|
51
|
+
}
|
|
52
|
+
// 0b: My depends_on ∩ their target_files → score 80 (they modify what I depend on)
|
|
53
|
+
if (params.depends_on_files) {
|
|
54
|
+
const depOverlap = params.depends_on_files.filter((f) => threadFiles.includes(f));
|
|
55
|
+
if (depOverlap.length > 0) {
|
|
56
|
+
maxScore = Math.max(maxScore, 80);
|
|
57
|
+
reasons.push(`modifies my dependency: ${depOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 0c: My target_files ∩ their depends_on → score 80 (I modify what they depend on)
|
|
61
|
+
const reverseDepOverlap = params.target_files.filter((f) => threadDeps.includes(f));
|
|
62
|
+
if (reverseDepOverlap.length > 0) {
|
|
63
|
+
maxScore = Math.max(maxScore, 80);
|
|
64
|
+
reasons.push(`they depend on my target: ${reverseDepOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Layer 1: Same file recently modified (score 100)
|
|
69
|
+
for (const targetFile of params.target_files) {
|
|
70
|
+
const conflict = this.fileTracker.checkFileConflict(targetFile, params.agent_id, 60);
|
|
71
|
+
if (conflict.agents.includes(agent.id)) {
|
|
72
|
+
maxScore = Math.max(maxScore, 100);
|
|
73
|
+
reasons.push(`same file: ${targetFile}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Layer 2: Depends-on file recently modified (score 80)
|
|
77
|
+
if (params.depends_on_files) {
|
|
78
|
+
for (const depFile of params.depends_on_files) {
|
|
79
|
+
const conflict = this.fileTracker.checkFileConflict(depFile, params.agent_id, 60);
|
|
80
|
+
if (conflict.agents.includes(agent.id)) {
|
|
81
|
+
maxScore = Math.max(maxScore, 80);
|
|
82
|
+
reasons.push(`depends on: ${depFile}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Layer 3: Module overlap (score 30)
|
|
87
|
+
const overlapping = agentModules.filter((am) => params.target_modules.some((tm) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
|
|
88
|
+
if (overlapping.length > 0) {
|
|
89
|
+
maxScore = Math.max(maxScore, 30);
|
|
90
|
+
reasons.push(`module overlap: ${overlapping.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
// Layer 4 (future): Git co-change analysis
|
|
93
|
+
// Score 60 for >50% co-change ratio, 40 for >20%
|
|
94
|
+
// Requires git history analysis — not implemented in v3 prototype
|
|
95
|
+
return {
|
|
96
|
+
agent_id: agent.id,
|
|
97
|
+
agent_name: agent.name,
|
|
98
|
+
score: maxScore,
|
|
99
|
+
reasons,
|
|
100
|
+
reason: reasons[0] || "no link detected",
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
categorize(params) {
|
|
105
|
+
const scores = this.score(params);
|
|
106
|
+
return {
|
|
107
|
+
concerned: scores.filter((s) => s.score >= 90),
|
|
108
|
+
gray_zone: scores.filter((s) => s.score >= 30 && s.score < 90),
|
|
109
|
+
pass: scores.filter((s) => s.score < 30),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import { createServices, createMcpServer } from "./server-setup.js";
|
|
4
|
+
// Re-export the public package surface for npm consumers
|
|
5
|
+
export { startServer } from "./serve-http.js";
|
|
6
|
+
export { createServices, createMcpServer } from "./server-setup.js";
|
|
7
|
+
// STDIO entry: only run when invoked directly (not when imported).
|
|
8
|
+
// Uses pathToFileURL for cross-platform correctness (handles Windows drive letters
|
|
9
|
+
// + the file:///C:/... vs file://C:/... slash-count mismatch). Guards against
|
|
10
|
+
// process.argv[1] being undefined (REPL, some bundlers).
|
|
11
|
+
const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
12
|
+
if (isMainModule) {
|
|
13
|
+
const DATA_DIR = process.env.COORDINATOR_DATA_DIR || "./data";
|
|
14
|
+
async function main() {
|
|
15
|
+
const services = createServices({ dataDir: DATA_DIR });
|
|
16
|
+
const log = services.logger;
|
|
17
|
+
const server = createMcpServer(services);
|
|
18
|
+
const transport = new StdioServerTransport();
|
|
19
|
+
await server.connect(transport);
|
|
20
|
+
log.info("mcp-coordinator running on stdio (no MQTT broker in stdio mode)");
|
|
21
|
+
}
|
|
22
|
+
main().catch((err) => {
|
|
23
|
+
console.error("FATAL:", err);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface IntrospectionRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
thread_id: string;
|
|
4
|
+
agent_id: string;
|
|
5
|
+
score: number;
|
|
6
|
+
reasons: string | null;
|
|
7
|
+
status: "pending" | "concerned" | "not_concerned";
|
|
8
|
+
response: string | null;
|
|
9
|
+
concerned: number;
|
|
10
|
+
created_at: string;
|
|
11
|
+
responded_at: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare class IntrospectionManager {
|
|
14
|
+
create(params: {
|
|
15
|
+
thread_id: string;
|
|
16
|
+
agent_id: string;
|
|
17
|
+
score: number;
|
|
18
|
+
reasons: string[];
|
|
19
|
+
}): IntrospectionRecord;
|
|
20
|
+
respond(id: string, concerned: boolean, response: string): IntrospectionRecord;
|
|
21
|
+
get(id: string): IntrospectionRecord | null;
|
|
22
|
+
getPending(agentId: string): IntrospectionRecord[];
|
|
23
|
+
getByThread(threadId: string): IntrospectionRecord[];
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getDb } from "./database.js";
|
|
3
|
+
export class IntrospectionManager {
|
|
4
|
+
create(params) {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const id = randomUUID();
|
|
7
|
+
db.prepare(`INSERT INTO introspections (id, thread_id, agent_id, score, reasons)
|
|
8
|
+
VALUES (?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.score, JSON.stringify(params.reasons));
|
|
9
|
+
return this.get(id);
|
|
10
|
+
}
|
|
11
|
+
respond(id, concerned, response) {
|
|
12
|
+
const db = getDb();
|
|
13
|
+
db.prepare(`UPDATE introspections SET status = ?, concerned = ?, response = ?, responded_at = ? WHERE id = ?`).run(concerned ? "concerned" : "not_concerned", concerned ? 1 : 0, response, new Date().toISOString(), id);
|
|
14
|
+
return this.get(id);
|
|
15
|
+
}
|
|
16
|
+
get(id) {
|
|
17
|
+
const db = getDb();
|
|
18
|
+
return db.prepare("SELECT * FROM introspections WHERE id = ?").get(id) || null;
|
|
19
|
+
}
|
|
20
|
+
getPending(agentId) {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
return db.prepare("SELECT * FROM introspections WHERE agent_id = ? AND status = 'pending' ORDER BY created_at").all(agentId);
|
|
23
|
+
}
|
|
24
|
+
getByThread(threadId) {
|
|
25
|
+
const db = getDb();
|
|
26
|
+
return db.prepare("SELECT * FROM introspections WHERE thread_id = ? ORDER BY created_at").all(threadId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Logger {
|
|
2
|
+
level: string;
|
|
3
|
+
info(obj: unknown, msg?: string): void;
|
|
4
|
+
info(msg: string): void;
|
|
5
|
+
warn(obj: unknown, msg?: string): void;
|
|
6
|
+
warn(msg: string): void;
|
|
7
|
+
error(obj: unknown, msg?: string): void;
|
|
8
|
+
error(msg: string): void;
|
|
9
|
+
fatal(obj: unknown, msg?: string): void;
|
|
10
|
+
fatal(msg: string): void;
|
|
11
|
+
debug(obj: unknown, msg?: string): void;
|
|
12
|
+
debug(msg: string): void;
|
|
13
|
+
child(bindings: Record<string, unknown>): Logger;
|
|
14
|
+
}
|
|
15
|
+
export interface LoggerOptions {
|
|
16
|
+
level?: string;
|
|
17
|
+
pretty?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function createLogger(options?: LoggerOptions): Logger;
|
|
20
|
+
export declare const silentLogger: Logger;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Simple console-based logger (works everywhere, no native deps)
|
|
2
|
+
function createConsoleLogger(level, bindings = {}) {
|
|
3
|
+
const levels = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50, silent: 100 };
|
|
4
|
+
const threshold = levels[level] ?? 20;
|
|
5
|
+
function log(lvl, num, args) {
|
|
6
|
+
if (num < threshold)
|
|
7
|
+
return;
|
|
8
|
+
const ts = new Date().toISOString();
|
|
9
|
+
const prefix = Object.keys(bindings).length > 0
|
|
10
|
+
? `[${Object.values(bindings).join(":")}]`
|
|
11
|
+
: "";
|
|
12
|
+
const obj = typeof args[0] === "object" && args[0] !== null ? args[0] : {};
|
|
13
|
+
const msg = typeof args[0] === "string" ? args[0] : args[1] ?? "";
|
|
14
|
+
const data = typeof args[0] === "object" ? { ...bindings, ...obj } : bindings;
|
|
15
|
+
if (lvl === "error" || lvl === "fatal") {
|
|
16
|
+
console.error(JSON.stringify({ level: num, time: ts, ...data, msg }));
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
console.log(JSON.stringify({ level: num, time: ts, ...data, msg }));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
level,
|
|
24
|
+
info: (...args) => log("info", 20, args),
|
|
25
|
+
warn: (...args) => log("warn", 30, args),
|
|
26
|
+
error: (...args) => log("error", 40, args),
|
|
27
|
+
fatal: (...args) => log("fatal", 50, args),
|
|
28
|
+
debug: (...args) => log("debug", 10, args),
|
|
29
|
+
child: (b) => createConsoleLogger(level, { ...bindings, ...b }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Pino-based logger (dev mode, richer output)
|
|
33
|
+
function createPinoLogger(level) {
|
|
34
|
+
const pino = require("pino");
|
|
35
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
36
|
+
const transport = isDev
|
|
37
|
+
? { target: "pino-pretty", options: { colorize: true, translateTime: "SYS:HH:mm:ss" } }
|
|
38
|
+
: undefined;
|
|
39
|
+
return pino({ level, transport });
|
|
40
|
+
}
|
|
41
|
+
export function createLogger(options) {
|
|
42
|
+
const level = options?.level || process.env.LOG_LEVEL || "info";
|
|
43
|
+
// Use console logger in Bun compiled binary (pino uses thread-stream which breaks)
|
|
44
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
45
|
+
return createConsoleLogger(level);
|
|
46
|
+
}
|
|
47
|
+
// Use pino in Node.js (dev/test)
|
|
48
|
+
try {
|
|
49
|
+
return createPinoLogger(level);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return createConsoleLogger(level);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const silentLogger = createConsoleLogger("silent");
|