clawmatrix 0.4.2 → 0.5.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.
@@ -2,8 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
3
  import type { HandoffManager } from "./handoff.ts";
4
4
  import type { ClawMatrixConfig } from "./config.ts";
5
- import type { SatelliteContext, IngestedEvent } from "./types.ts";
5
+ import type { SatelliteContext, IngestedEvent, KanbanNotifyFrame, CardStage, CardPriority, CardAnnotation } from "./types.ts";
6
6
  import type { HealthTracker } from "./health-tracker.ts";
7
+ import type { AutomationManager } from "./automation.ts";
8
+ import type { KanbanManager } from "./kanban.ts";
9
+ import type { KnowledgeSync } from "./knowledge-sync.ts";
10
+ import { nanoid } from "nanoid";
7
11
  import { timingSafeEqual } from "./auth.ts";
8
12
  import { readBody } from "./http-utils.ts";
9
13
 
@@ -15,10 +19,17 @@ const LOGIN_RATE_MAX = 10; // max attempts per window per IP
15
19
  const MAX_INGESTED_EVENTS = 500; // ring buffer for ingested events
16
20
  const INGESTED_EVENT_TTL = 86400_000; // 24 hours
17
21
  const SATELLITE_TOOL_TIMEOUT = 120_000; // 2 min timeout for satellite tool requests
22
+ const CLUSTER_TOOLS = [
23
+ "cluster_handoff", "cluster_handoff_reply", "cluster_send", "cluster_peers",
24
+ "cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
25
+ "cluster_batch", "cluster_tool", "cluster_terminal", "cluster_transfer",
26
+ "cluster_events", "cluster_diagnostic", "cluster_acp", "cluster_kanban",
27
+ "cluster_notify", "cluster_query",
28
+ ];
18
29
 
19
30
  interface SatelliteEvent {
20
31
  ts: number;
21
- type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested";
32
+ type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested" | "kanban";
22
33
  data: Record<string, unknown>;
23
34
  }
24
35
 
@@ -33,7 +44,7 @@ interface PendingSatelliteTool {
33
44
  timer: ReturnType<typeof setTimeout>;
34
45
  }
35
46
 
36
- export class WebHandler {
47
+ export class ApiHandler {
37
48
  private config: ClawMatrixConfig;
38
49
  private peerManager: PeerManager;
39
50
  private handoffManager: HandoffManager;
@@ -47,6 +58,10 @@ export class WebHandler {
47
58
  private loginAttempts = new Map<string, { count: number; resetAt: number }>(); // IP → rate limit
48
59
  private loginCleanupTimer: ReturnType<typeof setInterval> | null = null;
49
60
  private healthTracker: HealthTracker | null = null;
61
+ private automationManager: AutomationManager | null = null;
62
+ private kanbanManager: KanbanManager | null = null;
63
+ private knowledgeSync: KnowledgeSync | null = null;
64
+ private store: import("./store.ts").Store | null = null;
50
65
  private onPeerConnected: (nodeId: string) => void;
51
66
  private onPeerDisconnected: (nodeId: string) => void;
52
67
 
@@ -54,7 +69,7 @@ export class WebHandler {
54
69
  this.config = config;
55
70
  this.peerManager = peerManager;
56
71
  this.handoffManager = handoffManager;
57
- this.token = config.web!.token;
72
+ this.token = config.secret;
58
73
 
59
74
  // Periodically clean up stale login rate-limit entries
60
75
  this.loginCleanupTimer = setInterval(() => {
@@ -97,6 +112,28 @@ export class WebHandler {
97
112
  this.healthTracker = tracker;
98
113
  }
99
114
 
115
+ /** Set the automation manager for automation API. */
116
+ setAutomationManager(manager: AutomationManager) {
117
+ this.automationManager = manager;
118
+ }
119
+
120
+ /** Set the kanban manager for board API. */
121
+ setKanbanManager(manager: KanbanManager) {
122
+ this.kanbanManager = manager;
123
+ }
124
+
125
+ /** Set the knowledge sync manager for knowledge API. */
126
+ setKnowledgeSync(ks: KnowledgeSync) {
127
+ this.knowledgeSync = ks;
128
+ }
129
+
130
+ /** Set the SQLite store for persistent events. */
131
+ setStore(store: import("./store.ts").Store) {
132
+ this.store = store;
133
+ this.satelliteEvents = hydrateSatelliteEvents(store.querySatelliteEvents(0, MAX_EVENTS, true));
134
+ this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
135
+ }
136
+
100
137
  /** Clean up timers and pending requests on shutdown. */
101
138
  destroy() {
102
139
  // Remove event listeners to prevent post-destroy callbacks
@@ -123,11 +160,35 @@ export class WebHandler {
123
160
  this.satelliteWaiters.length = 0;
124
161
  }
125
162
 
163
+ /** Add CORS headers to allow cross-origin requests (Tauri WebView, browser satellite). */
164
+ private setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
165
+ const origin = req.headers.origin;
166
+ if (origin) {
167
+ res.setHeader("Access-Control-Allow-Origin", origin);
168
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
169
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
170
+ res.setHeader("Access-Control-Allow-Credentials", "true");
171
+ res.setHeader("Access-Control-Max-Age", "86400");
172
+ }
173
+ }
174
+
126
175
  /** Handle an HTTP request. Returns true if handled, false to fall through. */
127
176
  handle(req: IncomingMessage, res: ServerResponse): boolean {
128
177
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
129
178
  const path = url.pathname;
130
179
 
180
+ // CORS headers for all /api/ requests
181
+ if (path.startsWith("/api/")) {
182
+ this.setCorsHeaders(req, res);
183
+ }
184
+
185
+ // Handle CORS preflight (must be before auth check — preflight carries no credentials)
186
+ if (req.method === "OPTIONS") {
187
+ res.writeHead(204);
188
+ res.end();
189
+ return true;
190
+ }
191
+
131
192
  // Public routes (no auth)
132
193
  if (path === "/api/login" && req.method === "POST") {
133
194
  this.handleLogin(req, res);
@@ -180,6 +241,32 @@ export class WebHandler {
180
241
  return;
181
242
  }
182
243
 
244
+ if (path === "/api/automations/rules" && req.method === "GET") {
245
+ this.handleAutomationRulesGet(res);
246
+ return;
247
+ }
248
+
249
+ if (path === "/api/automations/rules" && req.method === "PUT") {
250
+ this.handleAutomationRulesPut(req, res);
251
+ return;
252
+ }
253
+
254
+ if (path === "/api/automations/history" && req.method === "GET") {
255
+ this.handleAutomationHistory(req, res);
256
+ return;
257
+ }
258
+
259
+ if (path === "/api/automations/run" && req.method === "POST") {
260
+ this.handleAutomationRun(req, res);
261
+ return;
262
+ }
263
+
264
+ const automationReplayMatch = path.match(/^\/api\/automations\/replay\/([^/]+)$/);
265
+ if (automationReplayMatch && req.method === "POST") {
266
+ this.handleAutomationReplay(decodeURIComponent(automationReplayMatch[1]!), res);
267
+ return;
268
+ }
269
+
183
270
  if (path === "/api/availability" && req.method === "GET") {
184
271
  this.handleAvailability(req, res);
185
272
  return;
@@ -200,6 +287,60 @@ export class WebHandler {
200
287
  return;
201
288
  }
202
289
 
290
+ // Knowledge sync API
291
+ if (path === "/api/knowledge/files" && req.method === "GET") {
292
+ this.handleKnowledgeFiles(res);
293
+ return;
294
+ }
295
+
296
+ if (path === "/api/knowledge/history" && req.method === "GET") {
297
+ this.handleKnowledgeHistory(req, res);
298
+ return;
299
+ }
300
+
301
+ // Board / Kanban API
302
+ if (path === "/api/board" && req.method === "GET") {
303
+ this.handleBoardSummary(res);
304
+ return;
305
+ }
306
+
307
+ if (path === "/api/board/cards" && req.method === "GET") {
308
+ this.handleBoardList(req, res);
309
+ return;
310
+ }
311
+
312
+ if (path === "/api/board/cards" && req.method === "POST") {
313
+ this.handleBoardCreate(req, res);
314
+ return;
315
+ }
316
+
317
+ // /api/board/cards/:id routes
318
+ const boardCardMatch = path.match(/^\/api\/board\/cards\/([^/]+)$/);
319
+ if (boardCardMatch) {
320
+ const cardId = decodeURIComponent(boardCardMatch[1]!);
321
+ if (req.method === "GET") {
322
+ this.handleBoardGet(cardId, res);
323
+ return;
324
+ }
325
+ if (req.method === "DELETE") {
326
+ this.handleBoardDelete(cardId, res);
327
+ return;
328
+ }
329
+ if (req.method === "PATCH") {
330
+ this.handleBoardUpdate(cardId, req, res);
331
+ return;
332
+ }
333
+ }
334
+
335
+ const boardActionMatch = path.match(/^\/api\/board\/cards\/([^/]+)\/(claim|move|annotate)$/);
336
+ if (boardActionMatch && req.method === "POST") {
337
+ const cardId = decodeURIComponent(boardActionMatch[1]!);
338
+ const action = boardActionMatch[2]!;
339
+ if (action === "claim") { this.handleBoardClaim(cardId, req, res); return; }
340
+ if (action === "move") { this.handleBoardMove(cardId, req, res); return; }
341
+ if (action === "annotate") { this.handleBoardAnnotate(cardId, req, res); return; }
342
+ }
343
+
203
344
  if (path === "/api/logout" && req.method === "POST") {
204
345
  res.writeHead(200, {
205
346
  "Content-Type": "application/json",
@@ -214,10 +355,12 @@ export class WebHandler {
214
355
  }
215
356
 
216
357
  private async checkAuth(req: IncomingMessage): Promise<boolean> {
217
- // Check Authorization header
358
+ // Check Authorization header (accept either web token or shared secret)
218
359
  const authHeader = req.headers.authorization;
219
- if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
220
- return true;
360
+ if (authHeader?.startsWith("Bearer ")) {
361
+ const bearer = authHeader.slice(7);
362
+ if (timingSafeEqual(bearer, this.token)) return true;
363
+ if (this.config.secret && timingSafeEqual(bearer, this.config.secret)) return true;
221
364
  }
222
365
 
223
366
  // Check cookie
@@ -312,17 +455,10 @@ export class WebHandler {
312
455
  deny: this.config.toolProxy.deny,
313
456
  } : undefined,
314
457
  clusterTools: [
315
- "cluster_handoff", "cluster_send", "cluster_peers",
316
- "cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
458
+ ...CLUSTER_TOOLS,
317
459
  ],
318
460
  };
319
461
 
320
- // All mesh peers share these cluster tools (they all run the ClawMatrix plugin)
321
- const CLUSTER_TOOLS = [
322
- "cluster_handoff", "cluster_send", "cluster_peers",
323
- "cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
324
- ];
325
-
326
462
  const peerNodes: Record<string, unknown>[] = peers.map((p) => ({
327
463
  nodeId: p.nodeId,
328
464
  agents: p.agents,
@@ -373,6 +509,7 @@ export class WebHandler {
373
509
  uptime: Math.floor((Date.now() - this.startTime) / 1000),
374
510
  listen: this.config.listen ? this.config.listenPort : false,
375
511
  proxyPort: this.config.proxyPort,
512
+ knowledgeEnabled: !!this.knowledgeSync,
376
513
  local: localNode,
377
514
  peers: peerNodes,
378
515
  }));
@@ -503,6 +640,15 @@ export class WebHandler {
503
640
  if (this.satelliteEvents.length > MAX_EVENTS) {
504
641
  this.satelliteEvents.splice(0, this.satelliteEvents.length - MAX_EVENTS);
505
642
  }
643
+ // Persist to SQLite
644
+ try {
645
+ this.store?.insertSatelliteEvent({
646
+ id: `sat-${event.ts}-${Math.random().toString(36).slice(2, 8)}`,
647
+ ts: event.ts,
648
+ type: event.type,
649
+ data: JSON.stringify(event.data),
650
+ });
651
+ } catch { /* non-fatal */ }
506
652
  // Wake up any long-polling clients immediately
507
653
  if (this.satelliteWaiters.length > 0) {
508
654
  this.flushSatelliteWaiters();
@@ -587,7 +733,10 @@ export class WebHandler {
587
733
  private sendSatellitePollResponse(res: ServerResponse, since: number, satelliteNodeId?: string) {
588
734
  this.syncPeerState();
589
735
  const peers = this.peerManager.router.getAllPeers();
590
- const events = this.satelliteEvents.filter(e => e.ts > since);
736
+ const events = this.store
737
+ ? hydrateSatelliteEvents(this.store.querySatelliteEvents(since, MAX_EVENTS))
738
+ : this.satelliteEvents.filter(e => e.ts > since);
739
+ const cursorTs = events.length > 0 ? events[events.length - 1]!.ts : Date.now();
591
740
 
592
741
  // Include pending tool requests for this satellite node
593
742
  let pendingTools: Array<{ id: string; tool: string; params: Record<string, unknown> }> | undefined;
@@ -627,7 +776,7 @@ export class WebHandler {
627
776
  peers: allPeers,
628
777
  events,
629
778
  pendingTools,
630
- ts: Date.now(),
779
+ ts: cursorTs,
631
780
  };
632
781
 
633
782
  try {
@@ -771,6 +920,109 @@ export class WebHandler {
771
920
  }
772
921
  }
773
922
 
923
+ // ── Automation API ─────────────────────────────────────────────
924
+
925
+ /** GET /api/automations/rules — list automation rules. */
926
+ private handleAutomationRulesGet(res: ServerResponse) {
927
+ if (!this.automationManager) {
928
+ res.writeHead(200, { "Content-Type": "application/json" });
929
+ res.end(JSON.stringify({ rules: [] }));
930
+ return;
931
+ }
932
+ res.writeHead(200, { "Content-Type": "application/json" });
933
+ res.end(JSON.stringify({ rules: this.automationManager.getRules() }));
934
+ }
935
+
936
+ /** PUT /api/automations/rules — save automation rules. */
937
+ private async handleAutomationRulesPut(req: IncomingMessage, res: ServerResponse) {
938
+ if (!this.automationManager) {
939
+ res.writeHead(503, { "Content-Type": "application/json" });
940
+ res.end(JSON.stringify({ error: "Automation manager not available" }));
941
+ return;
942
+ }
943
+ try {
944
+ const body = await readBody(req);
945
+ const { rules } = JSON.parse(body);
946
+ if (!Array.isArray(rules)) {
947
+ res.writeHead(400, { "Content-Type": "application/json" });
948
+ res.end(JSON.stringify({ error: "rules array required" }));
949
+ return;
950
+ }
951
+ await this.automationManager.saveRules(rules);
952
+ res.writeHead(200, { "Content-Type": "application/json" });
953
+ res.end(JSON.stringify({ ok: true, count: rules.length }));
954
+ } catch (err) {
955
+ res.writeHead(500, { "Content-Type": "application/json" });
956
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to save rules" }));
957
+ }
958
+ }
959
+
960
+ /** GET /api/automations/history?limit=<n> — execution history. */
961
+ private handleAutomationHistory(req: IncomingMessage, res: ServerResponse) {
962
+ if (!this.automationManager) {
963
+ res.writeHead(200, { "Content-Type": "application/json" });
964
+ res.end(JSON.stringify({ executions: [] }));
965
+ return;
966
+ }
967
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
968
+ const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 100);
969
+ res.writeHead(200, { "Content-Type": "application/json" });
970
+ res.end(JSON.stringify({ executions: this.automationManager.getExecutions(limit) }));
971
+ }
972
+
973
+ /** POST /api/automations/run — manually run a rule. */
974
+ private async handleAutomationRun(req: IncomingMessage, res: ServerResponse) {
975
+ if (!this.automationManager) {
976
+ res.writeHead(503, { "Content-Type": "application/json" });
977
+ res.end(JSON.stringify({ error: "Automation manager not available" }));
978
+ return;
979
+ }
980
+ try {
981
+ const body = await readBody(req);
982
+ const parsed = JSON.parse(body) as { ruleId?: string; event?: Record<string, unknown> };
983
+ if (!parsed.ruleId || typeof parsed.ruleId !== "string") {
984
+ res.writeHead(400, { "Content-Type": "application/json" });
985
+ res.end(JSON.stringify({ error: "ruleId required" }));
986
+ return;
987
+ }
988
+
989
+ const rawEvent = parsed.event;
990
+ const event = rawEvent
991
+ ? {
992
+ id: nanoid(),
993
+ source: typeof rawEvent.source === "string" ? rawEvent.source : "manual",
994
+ type: typeof rawEvent.type === "string" ? rawEvent.type : "manual.run",
995
+ data: (typeof rawEvent.data === "object" && rawEvent.data !== null ? rawEvent.data : {}) as Record<string, unknown>,
996
+ ts: Date.now(),
997
+ consumed: false,
998
+ }
999
+ : undefined;
1000
+ const execution = await this.automationManager.runRuleById(parsed.ruleId, event);
1001
+ res.writeHead(200, { "Content-Type": "application/json" });
1002
+ res.end(JSON.stringify({ ok: true, execution }));
1003
+ } catch (err) {
1004
+ res.writeHead(500, { "Content-Type": "application/json" });
1005
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to run automation" }));
1006
+ }
1007
+ }
1008
+
1009
+ /** POST /api/automations/replay/:id — rerun a historical execution. */
1010
+ private async handleAutomationReplay(executionId: string, res: ServerResponse) {
1011
+ if (!this.automationManager) {
1012
+ res.writeHead(503, { "Content-Type": "application/json" });
1013
+ res.end(JSON.stringify({ error: "Automation manager not available" }));
1014
+ return;
1015
+ }
1016
+ try {
1017
+ const execution = await this.automationManager.replayExecution(executionId);
1018
+ res.writeHead(200, { "Content-Type": "application/json" });
1019
+ res.end(JSON.stringify({ ok: true, execution }));
1020
+ } catch (err) {
1021
+ res.writeHead(500, { "Content-Type": "application/json" });
1022
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to replay automation" }));
1023
+ }
1024
+ }
1025
+
774
1026
  // ── Event ingestion (Shortcuts automations, etc.) ──────────────
775
1027
 
776
1028
  /** POST /api/events/ingest — receive events from external sources (e.g. Apple Shortcuts) */
@@ -786,7 +1038,7 @@ export class WebHandler {
786
1038
  for (const item of items) {
787
1039
  if (!item.type) continue;
788
1040
  const event: IngestedEvent = {
789
- id: crypto.randomUUID(),
1041
+ id: nanoid(),
790
1042
  source: String(item.source || "unknown"),
791
1043
  type: String(item.type),
792
1044
  data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
@@ -795,6 +1047,16 @@ export class WebHandler {
795
1047
  };
796
1048
  this.ingestedEvents.push(event);
797
1049
  ingested.push(event);
1050
+ // Persist to SQLite
1051
+ try {
1052
+ this.store?.insertIngestedEvent({
1053
+ id: event.id,
1054
+ ts: event.ts,
1055
+ type: event.type,
1056
+ source: event.source,
1057
+ data: JSON.stringify(event.data),
1058
+ });
1059
+ } catch { /* non-fatal */ }
798
1060
  }
799
1061
 
800
1062
  // Trim ring buffer
@@ -811,6 +1073,13 @@ export class WebHandler {
811
1073
  });
812
1074
  }
813
1075
 
1076
+ // Trigger automation rules for each ingested event
1077
+ if (this.automationManager) {
1078
+ for (const event of ingested) {
1079
+ this.automationManager.onEventIngested(event);
1080
+ }
1081
+ }
1082
+
814
1083
  res.writeHead(200, { "Content-Type": "application/json" });
815
1084
  res.end(JSON.stringify({ ok: true, count: ingested.length, ids: ingested.map(e => e.id) }));
816
1085
  } catch {
@@ -830,17 +1099,39 @@ export class WebHandler {
830
1099
 
831
1100
  this.evictStaleEvents();
832
1101
 
833
- let events = this.ingestedEvents;
834
- if (since > 0) events = events.filter(e => e.ts > since);
835
- if (type) events = events.filter(e => e.type === type);
836
- if (source) events = events.filter(e => e.source === source);
837
- if (unconsumed) events = events.filter(e => !e.consumed);
1102
+ const queryOpts = {
1103
+ since: since > 0 ? since : undefined,
1104
+ type,
1105
+ source,
1106
+ unconsumed,
1107
+ limit,
1108
+ latest: true,
1109
+ } as const;
1110
+
1111
+ let total: number | undefined;
1112
+ let events = this.store
1113
+ ? this.loadPersistedIngestedEvents(queryOpts)
1114
+ : this.ingestedEvents;
1115
+ if (!this.store) {
1116
+ if (since > 0) events = events.filter(e => e.ts > since);
1117
+ if (type) events = events.filter(e => e.type === type);
1118
+ if (source) events = events.filter(e => e.source === source);
1119
+ if (unconsumed) events = events.filter(e => !e.consumed);
1120
+ total = events.length;
1121
+ } else {
1122
+ total = this.store.countIngestedEvents({
1123
+ since: queryOpts.since,
1124
+ type,
1125
+ source,
1126
+ unconsumed,
1127
+ });
1128
+ }
838
1129
 
839
1130
  // Return newest first, limited
840
1131
  const result = events.slice(-limit).reverse();
841
1132
 
842
1133
  res.writeHead(200, { "Content-Type": "application/json" });
843
- res.end(JSON.stringify({ events: result, total: events.length }));
1134
+ res.end(JSON.stringify({ events: result, total }));
844
1135
  }
845
1136
 
846
1137
  /** POST /api/events/consume — mark events as consumed by id(s) */
@@ -862,6 +1153,10 @@ export class WebHandler {
862
1153
  consumed++;
863
1154
  }
864
1155
  }
1156
+ if (this.store) {
1157
+ consumed = this.store.consumeIngestedEvents(ids);
1158
+ this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
1159
+ }
865
1160
 
866
1161
  res.writeHead(200, { "Content-Type": "application/json" });
867
1162
  res.end(JSON.stringify({ ok: true, consumed }));
@@ -873,7 +1168,7 @@ export class WebHandler {
873
1168
 
874
1169
  /** Evict events older than TTL. Centralizes stale-event cleanup. */
875
1170
  private evictStaleEvents() {
876
- const cutoff = Date.now() - INGESTED_EVENT_TTL;
1171
+ const cutoff = this.getIngestedEventCutoff();
877
1172
  const firstValid = this.ingestedEvents.findIndex(e => e.ts > cutoff);
878
1173
  if (firstValid > 0) {
879
1174
  this.ingestedEvents.splice(0, firstValid);
@@ -885,12 +1180,25 @@ export class WebHandler {
885
1180
  /** Get unconsumed events (for agent context injection). */
886
1181
  getUnconsumedEvents(limit = 10): IngestedEvent[] {
887
1182
  this.evictStaleEvents();
1183
+ if (this.store) {
1184
+ return this.loadPersistedIngestedEvents({ unconsumed: true, limit, latest: true }).slice(-limit);
1185
+ }
888
1186
  return this.ingestedEvents.filter(e => !e.consumed).slice(-limit);
889
1187
  }
890
1188
 
891
1189
  /** Get all ingested events (for tool queries). */
892
1190
  queryEvents(opts?: { type?: string; source?: string; since?: number; unconsumed?: boolean; limit?: number }): IngestedEvent[] {
893
1191
  this.evictStaleEvents();
1192
+ if (this.store) {
1193
+ return this.loadPersistedIngestedEvents({
1194
+ since: opts?.since,
1195
+ type: opts?.type,
1196
+ source: opts?.source,
1197
+ unconsumed: opts?.unconsumed,
1198
+ limit: opts?.limit ?? 50,
1199
+ latest: true,
1200
+ }).reverse();
1201
+ }
894
1202
 
895
1203
  let events = this.ingestedEvents;
896
1204
  if (opts?.since) events = events.filter(e => e.ts > opts.since!);
@@ -902,6 +1210,11 @@ export class WebHandler {
902
1210
 
903
1211
  /** Mark events as consumed by id(s). */
904
1212
  consumeEvents(ids: string[]): number {
1213
+ if (this.store) {
1214
+ const consumed = this.store.consumeIngestedEvents(ids);
1215
+ this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
1216
+ return consumed;
1217
+ }
905
1218
  const idSet = new Set(ids);
906
1219
  let consumed = 0;
907
1220
  for (const event of this.ingestedEvents) {
@@ -913,6 +1226,287 @@ export class WebHandler {
913
1226
  return consumed;
914
1227
  }
915
1228
 
1229
+ private getIngestedEventCutoff(): number {
1230
+ return Date.now() - INGESTED_EVENT_TTL;
1231
+ }
1232
+
1233
+ private loadPersistedIngestedEvents(
1234
+ opts: { since?: number; type?: string; source?: string; unconsumed?: boolean; limit?: number; latest?: boolean },
1235
+ ): IngestedEvent[] {
1236
+ const cutoff = this.getIngestedEventCutoff();
1237
+ const since = opts.since == null ? cutoff : Math.max(opts.since, cutoff);
1238
+ return hydrateIngestedEvents(this.store!.queryIngestedEvents({
1239
+ ...opts,
1240
+ since,
1241
+ }));
1242
+ }
1243
+
1244
+ // ── Knowledge sync API handlers ─────────────────────────────────
1245
+
1246
+ /** GET /api/knowledge/files — list all synced files with metadata. */
1247
+ private handleKnowledgeFiles(res: ServerResponse) {
1248
+ if (!this.knowledgeSync) {
1249
+ res.writeHead(503, { "Content-Type": "application/json" });
1250
+ res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
1251
+ return;
1252
+ }
1253
+ const files = this.knowledgeSync.listSyncedFiles();
1254
+ res.writeHead(200, { "Content-Type": "application/json" });
1255
+ res.end(JSON.stringify({ success: true, files }));
1256
+ }
1257
+
1258
+ /** GET /api/knowledge/history?path=<relPath> — file change history from CRDT. */
1259
+ private handleKnowledgeHistory(req: IncomingMessage, res: ServerResponse) {
1260
+ if (!this.knowledgeSync) {
1261
+ res.writeHead(503, { "Content-Type": "application/json" });
1262
+ res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
1263
+ return;
1264
+ }
1265
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1266
+ const filePath = url.searchParams.get("path");
1267
+ if (!filePath) {
1268
+ res.writeHead(400, { "Content-Type": "application/json" });
1269
+ res.end(JSON.stringify({ error: "Missing required query param: path" }));
1270
+ return;
1271
+ }
1272
+ const rawHistory = this.knowledgeSync.getFileHistory(filePath);
1273
+ const history = rawHistory.map((entry) => {
1274
+ let nodeId: string | undefined;
1275
+ let agentId: string | undefined;
1276
+ try {
1277
+ const parsed = JSON.parse(entry.message);
1278
+ nodeId = parsed.nodeId;
1279
+ agentId = parsed.agentId;
1280
+ } catch {
1281
+ // message is not JSON — keep as-is
1282
+ }
1283
+ return {
1284
+ timestamp: entry.timestamp,
1285
+ message: entry.message,
1286
+ actor: entry.actor,
1287
+ ...(nodeId !== undefined ? { nodeId } : {}),
1288
+ ...(agentId !== undefined ? { agentId } : {}),
1289
+ };
1290
+ });
1291
+ res.writeHead(200, { "Content-Type": "application/json" });
1292
+ res.end(JSON.stringify({ success: true, path: filePath, history }));
1293
+ }
1294
+
1295
+ // ── Board / Kanban API handlers ─────────────────────────────────
1296
+
1297
+ private handleBoardSummary(res: ServerResponse) {
1298
+ if (!this.kanbanManager) {
1299
+ res.writeHead(503, { "Content-Type": "application/json" });
1300
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1301
+ return;
1302
+ }
1303
+ res.writeHead(200, { "Content-Type": "application/json" });
1304
+ res.end(JSON.stringify(this.kanbanManager.getSummary()));
1305
+ }
1306
+
1307
+ private handleBoardList(req: IncomingMessage, res: ServerResponse) {
1308
+ if (!this.kanbanManager) {
1309
+ res.writeHead(503, { "Content-Type": "application/json" });
1310
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1311
+ return;
1312
+ }
1313
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1314
+ const stage = url.searchParams.get("stage") as CardStage | null;
1315
+ const label = url.searchParams.get("label") || undefined;
1316
+ const assignedNode = url.searchParams.get("assignedNode") || undefined;
1317
+ const priority = url.searchParams.get("priority") as CardPriority | null;
1318
+
1319
+ const cards = this.kanbanManager.listCards({
1320
+ stage: stage || undefined,
1321
+ label,
1322
+ assignedNode,
1323
+ priority: priority || undefined,
1324
+ });
1325
+ res.writeHead(200, { "Content-Type": "application/json" });
1326
+ res.end(JSON.stringify({ cards }));
1327
+ }
1328
+
1329
+ private async handleBoardCreate(req: IncomingMessage, res: ServerResponse) {
1330
+ if (!this.kanbanManager) {
1331
+ res.writeHead(503, { "Content-Type": "application/json" });
1332
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1333
+ return;
1334
+ }
1335
+ try {
1336
+ const body = await readBody(req);
1337
+ const { title, description, priority, targetNode, targetAgent, cwd, labels } = JSON.parse(body);
1338
+ if (!title) {
1339
+ res.writeHead(400, { "Content-Type": "application/json" });
1340
+ res.end(JSON.stringify({ error: "title required" }));
1341
+ return;
1342
+ }
1343
+ const card = this.kanbanManager.createCard({ title, description, priority, targetNode, targetAgent, cwd, labels });
1344
+ res.writeHead(201, { "Content-Type": "application/json" });
1345
+ res.end(JSON.stringify(card));
1346
+ } catch {
1347
+ res.writeHead(400, { "Content-Type": "application/json" });
1348
+ res.end(JSON.stringify({ error: "Invalid request" }));
1349
+ }
1350
+ }
1351
+
1352
+ private handleBoardGet(cardId: string, res: ServerResponse) {
1353
+ if (!this.kanbanManager) {
1354
+ res.writeHead(503, { "Content-Type": "application/json" });
1355
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1356
+ return;
1357
+ }
1358
+ const card = this.kanbanManager.getCard(cardId);
1359
+ if (!card) {
1360
+ res.writeHead(404, { "Content-Type": "application/json" });
1361
+ res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
1362
+ return;
1363
+ }
1364
+ res.writeHead(200, { "Content-Type": "application/json" });
1365
+ res.end(JSON.stringify(card));
1366
+ }
1367
+
1368
+ private async handleBoardClaim(cardId: string, req: IncomingMessage, res: ServerResponse) {
1369
+ if (!this.kanbanManager) {
1370
+ res.writeHead(503, { "Content-Type": "application/json" });
1371
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1372
+ return;
1373
+ }
1374
+ try {
1375
+ const body = await readBody(req);
1376
+ const { agent } = body ? JSON.parse(body) : {} as { agent?: string };
1377
+ const card = this.kanbanManager.claimCard(
1378
+ cardId,
1379
+ this.config.nodeId,
1380
+ agent ?? "web",
1381
+ );
1382
+ if (!card) {
1383
+ res.writeHead(409, { "Content-Type": "application/json" });
1384
+ res.end(JSON.stringify({ error: `Cannot claim card ${cardId}` }));
1385
+ return;
1386
+ }
1387
+ res.writeHead(200, { "Content-Type": "application/json" });
1388
+ res.end(JSON.stringify(card));
1389
+ } catch {
1390
+ res.writeHead(400, { "Content-Type": "application/json" });
1391
+ res.end(JSON.stringify({ error: "Invalid request" }));
1392
+ }
1393
+ }
1394
+
1395
+ private async handleBoardMove(cardId: string, req: IncomingMessage, res: ServerResponse) {
1396
+ if (!this.kanbanManager) {
1397
+ res.writeHead(503, { "Content-Type": "application/json" });
1398
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1399
+ return;
1400
+ }
1401
+ try {
1402
+ const body = await readBody(req);
1403
+ const { stage } = JSON.parse(body) as { stage?: string };
1404
+ if (!stage) {
1405
+ res.writeHead(400, { "Content-Type": "application/json" });
1406
+ res.end(JSON.stringify({ error: "stage required" }));
1407
+ return;
1408
+ }
1409
+ const card = this.kanbanManager.moveCard(cardId, stage as CardStage);
1410
+ if (!card) {
1411
+ res.writeHead(409, { "Content-Type": "application/json" });
1412
+ res.end(JSON.stringify({ error: `Cannot move card ${cardId}` }));
1413
+ return;
1414
+ }
1415
+ res.writeHead(200, { "Content-Type": "application/json" });
1416
+ res.end(JSON.stringify(card));
1417
+ } catch {
1418
+ res.writeHead(400, { "Content-Type": "application/json" });
1419
+ res.end(JSON.stringify({ error: "Invalid request" }));
1420
+ }
1421
+ }
1422
+
1423
+ private async handleBoardAnnotate(cardId: string, req: IncomingMessage, res: ServerResponse) {
1424
+ if (!this.kanbanManager) {
1425
+ res.writeHead(503, { "Content-Type": "application/json" });
1426
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1427
+ return;
1428
+ }
1429
+ try {
1430
+ const body = await readBody(req);
1431
+ const { content, type: annotationType, agent } = JSON.parse(body) as {
1432
+ content?: string; type?: string; agent?: string;
1433
+ };
1434
+ if (!content) {
1435
+ res.writeHead(400, { "Content-Type": "application/json" });
1436
+ res.end(JSON.stringify({ error: "content required" }));
1437
+ return;
1438
+ }
1439
+ const card = this.kanbanManager.annotateCard(cardId, {
1440
+ nodeId: this.config.nodeId,
1441
+ agent: agent ?? "web",
1442
+ type: (annotationType ?? "note") as CardAnnotation["type"],
1443
+ content,
1444
+ });
1445
+ if (!card) {
1446
+ res.writeHead(404, { "Content-Type": "application/json" });
1447
+ res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
1448
+ return;
1449
+ }
1450
+ res.writeHead(200, { "Content-Type": "application/json" });
1451
+ res.end(JSON.stringify(card));
1452
+ } catch {
1453
+ res.writeHead(400, { "Content-Type": "application/json" });
1454
+ res.end(JSON.stringify({ error: "Invalid request" }));
1455
+ }
1456
+ }
1457
+
1458
+ private async handleBoardUpdate(cardId: string, req: IncomingMessage, res: ServerResponse) {
1459
+ if (!this.kanbanManager) {
1460
+ res.writeHead(503, { "Content-Type": "application/json" });
1461
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1462
+ return;
1463
+ }
1464
+ try {
1465
+ const body = await readBody(req);
1466
+ const updates = JSON.parse(body) as {
1467
+ title?: string; description?: string; priority?: string;
1468
+ targetNode?: string; targetAgent?: string; cwd?: string;
1469
+ labels?: string[]; handoffId?: string; acpSessionId?: string;
1470
+ };
1471
+ const card = this.kanbanManager.updateCard(cardId, updates as Parameters<typeof this.kanbanManager.updateCard>[1]);
1472
+ if (!card) {
1473
+ res.writeHead(404, { "Content-Type": "application/json" });
1474
+ res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
1475
+ return;
1476
+ }
1477
+ res.writeHead(200, { "Content-Type": "application/json" });
1478
+ res.end(JSON.stringify(card));
1479
+ } catch {
1480
+ res.writeHead(400, { "Content-Type": "application/json" });
1481
+ res.end(JSON.stringify({ error: "Invalid request" }));
1482
+ }
1483
+ }
1484
+
1485
+ private handleBoardDelete(cardId: string, res: ServerResponse) {
1486
+ if (!this.kanbanManager) {
1487
+ res.writeHead(503, { "Content-Type": "application/json" });
1488
+ res.end(JSON.stringify({ error: "Kanban not enabled" }));
1489
+ return;
1490
+ }
1491
+ const ok = this.kanbanManager.deleteCard(cardId);
1492
+ if (!ok) {
1493
+ res.writeHead(404, { "Content-Type": "application/json" });
1494
+ res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
1495
+ return;
1496
+ }
1497
+ res.writeHead(200, { "Content-Type": "application/json" });
1498
+ res.end(JSON.stringify({ deleted: true, cardId }));
1499
+ }
1500
+
1501
+ /** Push a kanban notification event into the satellite event stream. */
1502
+ pushKanbanEvent(payload: KanbanNotifyFrame["payload"]) {
1503
+ this.pushSatelliteEvent({
1504
+ ts: Date.now(),
1505
+ type: "kanban",
1506
+ data: { ...payload },
1507
+ });
1508
+ }
1509
+
916
1510
  /** Get all satellite contexts (for use by other components). */
917
1511
  getSatelliteContexts(): SatelliteContext[] {
918
1512
  // Filter out stale contexts (> 10 minutes)
@@ -929,3 +1523,30 @@ export class WebHandler {
929
1523
  }
930
1524
  }
931
1525
 
1526
+ function hydrateSatelliteEvents(rows: Array<{ ts: number; type: SatelliteEvent["type"]; data: string }>): SatelliteEvent[] {
1527
+ return rows.map((row) => ({
1528
+ ts: row.ts,
1529
+ type: row.type,
1530
+ data: parseJsonRecord(row.data),
1531
+ }));
1532
+ }
1533
+
1534
+ function hydrateIngestedEvents(rows: Array<{ id: string; ts: number; type: string; source: string; data: string; consumed: number }>): IngestedEvent[] {
1535
+ return rows.map((row) => ({
1536
+ id: row.id,
1537
+ ts: row.ts,
1538
+ type: row.type,
1539
+ source: row.source,
1540
+ data: parseJsonRecord(row.data),
1541
+ consumed: row.consumed !== 0,
1542
+ }));
1543
+ }
1544
+
1545
+ function parseJsonRecord(value: string): Record<string, unknown> {
1546
+ try {
1547
+ const parsed = JSON.parse(value);
1548
+ return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : { value: parsed };
1549
+ } catch {
1550
+ return { value };
1551
+ }
1552
+ }