@sesamespace/hivemind 0.2.0 → 0.3.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 (72) hide show
  1. package/PLANNING.md +383 -0
  2. package/TASKS.md +60 -0
  3. package/install.sh +187 -0
  4. package/npm-package.json +28 -0
  5. package/package.json +13 -20
  6. package/packages/cli/package.json +23 -0
  7. package/{dist/chunk-DVR2KBL7.js → packages/cli/src/commands/fleet.ts} +50 -30
  8. package/packages/cli/src/commands/init.ts +230 -0
  9. package/{dist/chunk-MBS5A6BZ.js → packages/cli/src/commands/service.ts} +51 -42
  10. package/{dist/chunk-RNK5Q5GR.js → packages/cli/src/commands/start.ts} +12 -14
  11. package/{dist/main.js → packages/cli/src/main.ts} +12 -18
  12. package/packages/cli/tsconfig.json +8 -0
  13. package/packages/memory/Cargo.lock +6480 -0
  14. package/packages/memory/Cargo.toml +21 -0
  15. package/packages/memory/src/context.rs +179 -0
  16. package/packages/memory/src/embeddings.rs +51 -0
  17. package/packages/memory/src/main.rs +626 -0
  18. package/packages/memory/src/promotion.rs +637 -0
  19. package/packages/memory/src/scoring.rs +131 -0
  20. package/packages/memory/src/store.rs +460 -0
  21. package/packages/memory/src/tasks.rs +321 -0
  22. package/packages/runtime/package.json +24 -0
  23. package/packages/runtime/src/__tests__/fleet-integration.test.ts +235 -0
  24. package/packages/runtime/src/__tests__/fleet.test.ts +207 -0
  25. package/packages/runtime/src/__tests__/integration.test.ts +434 -0
  26. package/packages/runtime/src/agent.ts +255 -0
  27. package/packages/runtime/src/config.ts +130 -0
  28. package/packages/runtime/src/context.ts +192 -0
  29. package/packages/runtime/src/fleet/fleet-manager.ts +399 -0
  30. package/packages/runtime/src/fleet/memory-sync.ts +362 -0
  31. package/packages/runtime/src/fleet/primary-client.ts +285 -0
  32. package/packages/runtime/src/fleet/worker-protocol.ts +158 -0
  33. package/packages/runtime/src/fleet/worker-server.ts +246 -0
  34. package/packages/runtime/src/index.ts +57 -0
  35. package/packages/runtime/src/llm-client.ts +65 -0
  36. package/packages/runtime/src/memory-client.ts +309 -0
  37. package/packages/runtime/src/pipeline.ts +151 -0
  38. package/packages/runtime/src/prompt.ts +173 -0
  39. package/packages/runtime/src/sesame.ts +174 -0
  40. package/{dist/start.js → packages/runtime/src/start.ts} +7 -9
  41. package/packages/runtime/src/task-engine.ts +113 -0
  42. package/packages/runtime/src/worker.ts +339 -0
  43. package/packages/runtime/tsconfig.json +8 -0
  44. package/pnpm-workspace.yaml +2 -0
  45. package/run-aidan.sh +23 -0
  46. package/scripts/bootstrap.sh +196 -0
  47. package/scripts/build-npm.sh +94 -0
  48. package/scripts/com.hivemind.agent.plist +44 -0
  49. package/scripts/com.hivemind.memory.plist +31 -0
  50. package/tsconfig.json +22 -0
  51. package/tsup.config.ts +28 -0
  52. package/dist/chunk-2I2O6X5D.js +0 -1408
  53. package/dist/chunk-2I2O6X5D.js.map +0 -1
  54. package/dist/chunk-DVR2KBL7.js.map +0 -1
  55. package/dist/chunk-MBS5A6BZ.js.map +0 -1
  56. package/dist/chunk-NVJ424TB.js +0 -731
  57. package/dist/chunk-NVJ424TB.js.map +0 -1
  58. package/dist/chunk-RNK5Q5GR.js.map +0 -1
  59. package/dist/chunk-XNOWVLXD.js +0 -160
  60. package/dist/chunk-XNOWVLXD.js.map +0 -1
  61. package/dist/commands/fleet.js +0 -9
  62. package/dist/commands/fleet.js.map +0 -1
  63. package/dist/commands/init.js +0 -7
  64. package/dist/commands/init.js.map +0 -1
  65. package/dist/commands/service.js +0 -7
  66. package/dist/commands/service.js.map +0 -1
  67. package/dist/commands/start.js +0 -9
  68. package/dist/commands/start.js.map +0 -1
  69. package/dist/index.js +0 -41
  70. package/dist/index.js.map +0 -1
  71. package/dist/main.js.map +0 -1
  72. package/dist/start.js.map +0 -1
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Cross-machine memory sync.
3
+ *
4
+ * Workers periodically push key L3 knowledge back to the Primary.
5
+ * Primary can push Global context updates (L3 + L2 episodes) to workers.
6
+ *
7
+ * Conflict resolution:
8
+ * - L3: last-write-wins (compared by updated_at timestamp)
9
+ * - L2: append-only (episodes are never overwritten, only added)
10
+ *
11
+ * Sync protocol:
12
+ * POST /sync/pull — Worker sends L3 knowledge to Primary
13
+ * POST /sync/push — Primary sends Global context updates to Worker
14
+ */
15
+
16
+ import type { MemoryClient, L3Entry, Episode } from "../memory-client.js";
17
+ import type { PrimaryClient } from "./primary-client.js";
18
+ import type { WorkerServer } from "./worker-server.js";
19
+ import type {
20
+ SyncPullRequest,
21
+ SyncPullResponse,
22
+ SyncPushRequest,
23
+ SyncPushResponse,
24
+ SyncL3Entry,
25
+ SyncL2Episode,
26
+ } from "./worker-protocol.js";
27
+ import { DEFAULT_SYNC_INTERVAL_MS, HEALTH_TIMEOUT_MS, PRIMARY_ROUTES } from "./worker-protocol.js";
28
+
29
+ // --- Helpers ---
30
+
31
+ function l3ToSync(entry: L3Entry): SyncL3Entry {
32
+ return {
33
+ id: entry.id,
34
+ source_episode_id: entry.source_episode_id,
35
+ context_name: entry.context_name,
36
+ content: entry.content,
37
+ promoted_at: entry.promoted_at,
38
+ access_count: entry.access_count,
39
+ connection_density: entry.connection_density,
40
+ updated_at: entry.promoted_at,
41
+ };
42
+ }
43
+
44
+ function episodeToSync(ep: Episode): SyncL2Episode {
45
+ return {
46
+ id: ep.id,
47
+ timestamp: ep.timestamp,
48
+ context_name: ep.context_name,
49
+ role: ep.role,
50
+ content: ep.content,
51
+ };
52
+ }
53
+
54
+ // --- MemorySync (Worker side) ---
55
+
56
+ export interface WorkerSyncOptions {
57
+ workerId: string;
58
+ primaryUrl: string;
59
+ memory: MemoryClient;
60
+ server: WorkerServer;
61
+ syncIntervalMs?: number;
62
+ }
63
+
64
+ /**
65
+ * Worker-side memory sync. Periodically sends L3 knowledge from assigned
66
+ * contexts back to the Primary, and accepts Global context pushes.
67
+ */
68
+ export class WorkerMemorySync {
69
+ private workerId: string;
70
+ private primaryUrl: string;
71
+ private memory: MemoryClient;
72
+ private syncTimer: ReturnType<typeof setInterval> | null = null;
73
+ private syncIntervalMs: number;
74
+ private knownL2Ids: Set<string> = new Set();
75
+ private knownL3Ids: Map<string, string> = new Map(); // id -> updated_at
76
+
77
+ constructor(opts: WorkerSyncOptions) {
78
+ this.workerId = opts.workerId;
79
+ this.primaryUrl = opts.primaryUrl;
80
+ this.memory = opts.memory;
81
+ this.syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
82
+
83
+ // Register handler for incoming Global context pushes from Primary
84
+ opts.server.onSyncPush((req) => this.handlePush(req));
85
+ }
86
+
87
+ /** Start periodic sync (Worker -> Primary). */
88
+ start(contextNames: () => string[]): void {
89
+ this.stop();
90
+ this.syncTimer = setInterval(() => {
91
+ this.pullTowardsPrimary(contextNames()).catch((err) => {
92
+ console.warn("[sync] Pull cycle failed:", (err as Error).message);
93
+ });
94
+ }, this.syncIntervalMs);
95
+ }
96
+
97
+ /** Stop periodic sync. */
98
+ stop(): void {
99
+ if (this.syncTimer) {
100
+ clearInterval(this.syncTimer);
101
+ this.syncTimer = null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Pull L3 knowledge from all assigned contexts and send to Primary.
107
+ * Called periodically by the sync timer.
108
+ */
109
+ async pullTowardsPrimary(contextNames: string[]): Promise<void> {
110
+ for (const contextName of contextNames) {
111
+ try {
112
+ const entries = await this.memory.getL3Knowledge(contextName);
113
+ if (entries.length === 0) continue;
114
+
115
+ const syncEntries = entries.map(l3ToSync);
116
+
117
+ const req: SyncPullRequest = {
118
+ worker_id: this.workerId,
119
+ context_name: contextName,
120
+ entries: syncEntries,
121
+ };
122
+
123
+ const controller = new AbortController();
124
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
125
+
126
+ const resp = await fetch(`${this.primaryUrl}${PRIMARY_ROUTES.syncPull}`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify(req),
130
+ signal: controller.signal,
131
+ });
132
+ clearTimeout(timeout);
133
+
134
+ if (resp.ok) {
135
+ const result = (await resp.json()) as SyncPullResponse;
136
+ if (result.accepted > 0) {
137
+ console.log(`[sync] Pushed ${result.accepted} L3 entries from "${contextName}" to Primary`);
138
+ }
139
+ }
140
+ } catch {
141
+ // Primary unreachable — will retry next interval
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Handle an incoming push of Global context updates from the Primary.
148
+ * L3: last-write-wins. L2: append-only.
149
+ */
150
+ async handlePush(req: SyncPushRequest): Promise<SyncPushResponse> {
151
+ let l3Accepted = 0;
152
+ let l2Appended = 0;
153
+
154
+ // Process L3 entries with last-write-wins
155
+ for (const entry of req.entries) {
156
+ const existing = this.knownL3Ids.get(entry.id);
157
+ if (existing && existing >= entry.updated_at) {
158
+ // Local version is same or newer — skip
159
+ continue;
160
+ }
161
+
162
+ // Store in local memory daemon (the memory daemon's L3 store handles upsert)
163
+ try {
164
+ // Store as an episode in the global context so the worker has access
165
+ await this.memory.storeEpisode({
166
+ context_name: entry.context_name,
167
+ role: "system",
168
+ content: entry.content,
169
+ });
170
+ this.knownL3Ids.set(entry.id, entry.updated_at);
171
+ l3Accepted++;
172
+ } catch {
173
+ // Non-fatal — entry will be retried on next push
174
+ }
175
+ }
176
+
177
+ // Process L2 episodes as append-only
178
+ for (const episode of req.episodes) {
179
+ if (this.knownL2Ids.has(episode.id)) {
180
+ continue; // Already stored
181
+ }
182
+
183
+ try {
184
+ await this.memory.storeEpisode({
185
+ context_name: episode.context_name,
186
+ role: episode.role,
187
+ content: episode.content,
188
+ });
189
+ this.knownL2Ids.add(episode.id);
190
+ l2Appended++;
191
+ } catch {
192
+ // Non-fatal
193
+ }
194
+ }
195
+
196
+ if (l3Accepted > 0 || l2Appended > 0) {
197
+ console.log(`[sync] Received push: ${l3Accepted} L3 accepted, ${l2Appended} L2 appended`);
198
+ }
199
+
200
+ return { l3_accepted: l3Accepted, l2_appended: l2Appended };
201
+ }
202
+
203
+ getSyncIntervalMs(): number {
204
+ return this.syncIntervalMs;
205
+ }
206
+ }
207
+
208
+ // --- PrimaryMemorySync ---
209
+
210
+ export interface PrimarySyncOptions {
211
+ primary: PrimaryClient;
212
+ memory: MemoryClient;
213
+ syncIntervalMs?: number;
214
+ }
215
+
216
+ /**
217
+ * Primary-side memory sync. Handles incoming L3 pulls from workers and
218
+ * can push Global context updates out to all workers.
219
+ */
220
+ export class PrimaryMemorySync {
221
+ private primary: PrimaryClient;
222
+ private memory: MemoryClient;
223
+ private syncIntervalMs: number;
224
+ private pushTimer: ReturnType<typeof setInterval> | null = null;
225
+ private l3Store: Map<string, SyncL3Entry> = new Map(); // id -> latest entry
226
+ private lastPushAt: string = new Date(0).toISOString();
227
+
228
+ constructor(opts: PrimarySyncOptions) {
229
+ this.primary = opts.primary;
230
+ this.memory = opts.memory;
231
+ this.syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
232
+
233
+ // Register handler for incoming L3 pulls from workers
234
+ this.primary.onSyncPull((req) => this.handlePull(req));
235
+ }
236
+
237
+ /**
238
+ * Handle an incoming pull — Worker sending L3 knowledge to Primary.
239
+ * Uses last-write-wins for conflict resolution.
240
+ */
241
+ async handlePull(req: SyncPullRequest): Promise<SyncPullResponse> {
242
+ let accepted = 0;
243
+ let rejected = 0;
244
+
245
+ for (const entry of req.entries) {
246
+ const existing = this.l3Store.get(entry.id);
247
+
248
+ if (existing && existing.updated_at >= entry.updated_at) {
249
+ // Primary has same or newer version — reject
250
+ rejected++;
251
+ continue;
252
+ }
253
+
254
+ // Accept the entry — store locally
255
+ this.l3Store.set(entry.id, entry);
256
+
257
+ // Persist to Primary's memory daemon
258
+ try {
259
+ await this.memory.storeEpisode({
260
+ context_name: entry.context_name,
261
+ role: "system",
262
+ content: entry.content,
263
+ });
264
+ } catch {
265
+ // Non-fatal — the in-memory store is the source of truth for sync
266
+ }
267
+
268
+ accepted++;
269
+ }
270
+
271
+ if (accepted > 0) {
272
+ console.log(
273
+ `[sync] Accepted ${accepted} L3 entries from worker "${req.worker_id}" context "${req.context_name}"`,
274
+ );
275
+ }
276
+
277
+ return { accepted, rejected };
278
+ }
279
+
280
+ /**
281
+ * Push Global context updates to all workers.
282
+ * Fetches current Global L3 knowledge and recent L2 episodes,
283
+ * then pushes to every registered worker.
284
+ */
285
+ async pushGlobalToAll(): Promise<Map<string, SyncPushResponse | null>> {
286
+ // Gather Global context L3 knowledge
287
+ let l3Entries: SyncL3Entry[] = [];
288
+ try {
289
+ const knowledge = await this.memory.getL3Knowledge("global");
290
+ l3Entries = knowledge.map(l3ToSync);
291
+ } catch {
292
+ // Memory daemon may not have global context yet
293
+ }
294
+
295
+ // Gather recent Global L2 episodes (append-only)
296
+ let l2Episodes: SyncL2Episode[] = [];
297
+ try {
298
+ const episodes = await this.memory.getContext("global");
299
+ // Only send episodes newer than last push
300
+ l2Episodes = episodes
301
+ .filter((ep) => ep.timestamp > this.lastPushAt)
302
+ .map(episodeToSync);
303
+ } catch {
304
+ // Memory daemon may not have global context yet
305
+ }
306
+
307
+ if (l3Entries.length === 0 && l2Episodes.length === 0) {
308
+ return new Map();
309
+ }
310
+
311
+ const payload: SyncPushRequest = {
312
+ entries: l3Entries,
313
+ episodes: l2Episodes,
314
+ };
315
+
316
+ this.lastPushAt = new Date().toISOString();
317
+
318
+ const results = await this.primary.pushSyncToAll(payload);
319
+
320
+ let totalL3 = 0;
321
+ let totalL2 = 0;
322
+ for (const resp of results.values()) {
323
+ if (resp) {
324
+ totalL3 += resp.l3_accepted;
325
+ totalL2 += resp.l2_appended;
326
+ }
327
+ }
328
+
329
+ if (totalL3 > 0 || totalL2 > 0) {
330
+ console.log(`[sync] Pushed Global updates: ${totalL3} L3, ${totalL2} L2 across ${results.size} workers`);
331
+ }
332
+
333
+ return results;
334
+ }
335
+
336
+ /** Start periodic Global push to all workers. */
337
+ startPushLoop(): void {
338
+ this.stopPushLoop();
339
+ this.pushTimer = setInterval(() => {
340
+ this.pushGlobalToAll().catch((err) => {
341
+ console.warn("[sync] Push cycle failed:", (err as Error).message);
342
+ });
343
+ }, this.syncIntervalMs);
344
+ }
345
+
346
+ /** Stop periodic push. */
347
+ stopPushLoop(): void {
348
+ if (this.pushTimer) {
349
+ clearInterval(this.pushTimer);
350
+ this.pushTimer = null;
351
+ }
352
+ }
353
+
354
+ /** Get all synced L3 entries the Primary has received. */
355
+ getSyncedEntries(): SyncL3Entry[] {
356
+ return Array.from(this.l3Store.values());
357
+ }
358
+
359
+ getSyncIntervalMs(): number {
360
+ return this.syncIntervalMs;
361
+ }
362
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Primary-side client for managing Workers.
3
+ *
4
+ * The Primary uses this to:
5
+ * - Track registered workers
6
+ * - Poll worker health
7
+ * - Assign/unassign contexts to workers
8
+ * - Collect status reports
9
+ */
10
+
11
+ import type {
12
+ WorkerInfo,
13
+ WorkerRegistrationRequest,
14
+ WorkerRegistrationResponse,
15
+ WorkerHealthResponse,
16
+ WorkerHealthStatus,
17
+ WorkerStatus,
18
+ WorkerStatusReport,
19
+ ContextAssignRequest,
20
+ ContextAssignResponse,
21
+ SyncPushRequest,
22
+ SyncPushResponse,
23
+ SyncPullRequest,
24
+ SyncPullResponse,
25
+ } from "./worker-protocol.js";
26
+
27
+ import {
28
+ PRIMARY_ROUTES,
29
+ WORKER_ROUTES,
30
+ DEFAULT_HEALTH_INTERVAL_MS,
31
+ HEALTH_TIMEOUT_MS,
32
+ } from "./worker-protocol.js";
33
+
34
+ export class PrimaryClient {
35
+ private workers: Map<string, WorkerInfo> = new Map();
36
+ private healthTimer: ReturnType<typeof setInterval> | null = null;
37
+ private nextId = 1;
38
+ private onSyncPullCallback: ((req: SyncPullRequest) => Promise<SyncPullResponse>) | null = null;
39
+
40
+ /** All registered workers. */
41
+ getWorkers(): WorkerInfo[] {
42
+ return Array.from(this.workers.values());
43
+ }
44
+
45
+ /** Get a single worker by ID. */
46
+ getWorker(workerId: string): WorkerInfo | undefined {
47
+ return this.workers.get(workerId);
48
+ }
49
+
50
+ /** Find which worker owns a context, if any. */
51
+ findWorkerForContext(contextName: string): WorkerInfo | undefined {
52
+ for (const w of this.workers.values()) {
53
+ if (w.assigned_contexts.includes(contextName)) return w;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ // --- Registration (called when Worker POSTs to Primary) ---
59
+
60
+ handleRegistration(req: WorkerRegistrationRequest): WorkerRegistrationResponse {
61
+ const id = `worker-${this.nextId++}`;
62
+ const now = new Date().toISOString();
63
+
64
+ const info: WorkerInfo = {
65
+ id,
66
+ url: req.url,
67
+ capabilities: req.capabilities,
68
+ assigned_contexts: [],
69
+ registered_at: now,
70
+ last_heartbeat: now,
71
+ };
72
+
73
+ this.workers.set(id, info);
74
+ return { worker_id: id, registered_at: now };
75
+ }
76
+
77
+ /** Remove a worker from the registry. */
78
+ deregister(workerId: string): boolean {
79
+ return this.workers.delete(workerId);
80
+ }
81
+
82
+ // --- Health Checking ---
83
+
84
+ /** Poll a single worker's health endpoint. */
85
+ async checkHealth(workerId: string): Promise<WorkerHealthResponse | null> {
86
+ const worker = this.workers.get(workerId);
87
+ if (!worker) return null;
88
+
89
+ try {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
92
+
93
+ const resp = await fetch(`${worker.url}${WORKER_ROUTES.health}`, {
94
+ signal: controller.signal,
95
+ });
96
+ clearTimeout(timeout);
97
+
98
+ if (!resp.ok) {
99
+ this.markUnreachable(workerId);
100
+ return null;
101
+ }
102
+
103
+ const health = (await resp.json()) as WorkerHealthResponse;
104
+ worker.last_heartbeat = new Date().toISOString();
105
+ return health;
106
+ } catch {
107
+ this.markUnreachable(workerId);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** Poll all workers and return their statuses. */
113
+ async checkAllHealth(): Promise<Map<string, WorkerHealthStatus>> {
114
+ const results = new Map<string, WorkerHealthStatus>();
115
+
116
+ const checks = Array.from(this.workers.keys()).map(async (id) => {
117
+ const health = await this.checkHealth(id);
118
+ results.set(id, health?.status ?? "unreachable");
119
+ });
120
+
121
+ await Promise.all(checks);
122
+ return results;
123
+ }
124
+
125
+ /** Start periodic health polling. */
126
+ startHealthPolling(intervalMs = DEFAULT_HEALTH_INTERVAL_MS): void {
127
+ this.stopHealthPolling();
128
+ this.healthTimer = setInterval(() => {
129
+ this.checkAllHealth().catch(() => {});
130
+ }, intervalMs);
131
+ }
132
+
133
+ /** Stop periodic health polling. */
134
+ stopHealthPolling(): void {
135
+ if (this.healthTimer) {
136
+ clearInterval(this.healthTimer);
137
+ this.healthTimer = null;
138
+ }
139
+ }
140
+
141
+ // --- Context Assignment ---
142
+
143
+ /** Assign a context to a worker. */
144
+ async assignContext(
145
+ workerId: string,
146
+ contextName: string,
147
+ contextDescription = "",
148
+ ): Promise<ContextAssignResponse> {
149
+ const worker = this.workers.get(workerId);
150
+ if (!worker) {
151
+ return { context_name: contextName, accepted: false, reason: "Worker not found" };
152
+ }
153
+
154
+ if (worker.assigned_contexts.length >= worker.capabilities.max_contexts) {
155
+ return { context_name: contextName, accepted: false, reason: "Worker at capacity" };
156
+ }
157
+
158
+ const body: ContextAssignRequest = {
159
+ context_name: contextName,
160
+ context_description: contextDescription,
161
+ };
162
+
163
+ try {
164
+ const resp = await fetch(`${worker.url}${WORKER_ROUTES.assign}`, {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify(body),
168
+ });
169
+
170
+ if (!resp.ok) {
171
+ const text = await resp.text();
172
+ return { context_name: contextName, accepted: false, reason: `Worker rejected: ${text}` };
173
+ }
174
+
175
+ const result = (await resp.json()) as ContextAssignResponse;
176
+
177
+ if (result.accepted) {
178
+ worker.assigned_contexts.push(contextName);
179
+ }
180
+
181
+ return result;
182
+ } catch (err) {
183
+ const msg = err instanceof Error ? err.message : String(err);
184
+ return { context_name: contextName, accepted: false, reason: `Request failed: ${msg}` };
185
+ }
186
+ }
187
+
188
+ /** Unassign a context from a worker. */
189
+ async unassignContext(workerId: string, contextName: string): Promise<boolean> {
190
+ const worker = this.workers.get(workerId);
191
+ if (!worker) return false;
192
+
193
+ try {
194
+ const resp = await fetch(`${worker.url}${WORKER_ROUTES.unassign(contextName)}`, {
195
+ method: "DELETE",
196
+ });
197
+
198
+ if (resp.ok) {
199
+ worker.assigned_contexts = worker.assigned_contexts.filter((c) => c !== contextName);
200
+ return true;
201
+ }
202
+ return false;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // --- Status Collection ---
209
+
210
+ /** Handle an incoming status report from a Worker. */
211
+ handleStatusReport(workerId: string, report: WorkerStatusReport): WorkerStatus | null {
212
+ const worker = this.workers.get(workerId);
213
+ if (!worker) return null;
214
+
215
+ worker.last_heartbeat = new Date().toISOString();
216
+
217
+ return {
218
+ worker_id: workerId,
219
+ activity: report.activity,
220
+ current_context: report.current_context,
221
+ current_task: report.current_task,
222
+ error: report.error,
223
+ reported_at: new Date().toISOString(),
224
+ };
225
+ }
226
+
227
+ // --- Memory Sync ---
228
+
229
+ /** Register a handler for incoming sync pull requests from Workers. */
230
+ onSyncPull(cb: (req: SyncPullRequest) => Promise<SyncPullResponse>): void {
231
+ this.onSyncPullCallback = cb;
232
+ }
233
+
234
+ /** Handle an incoming sync pull (Worker sends L3 knowledge to Primary). */
235
+ async handleSyncPull(req: SyncPullRequest): Promise<SyncPullResponse> {
236
+ if (!this.onSyncPullCallback) {
237
+ return { accepted: 0, rejected: req.entries.length };
238
+ }
239
+ return this.onSyncPullCallback(req);
240
+ }
241
+
242
+ /** Push Global context updates to a specific worker. */
243
+ async pushSyncToWorker(workerId: string, payload: SyncPushRequest): Promise<SyncPushResponse | null> {
244
+ const worker = this.workers.get(workerId);
245
+ if (!worker) return null;
246
+
247
+ try {
248
+ const controller = new AbortController();
249
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
250
+
251
+ const resp = await fetch(`${worker.url}${WORKER_ROUTES.syncPush}`, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify(payload),
255
+ signal: controller.signal,
256
+ });
257
+ clearTimeout(timeout);
258
+
259
+ if (!resp.ok) return null;
260
+ return (await resp.json()) as SyncPushResponse;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /** Push Global context updates to all registered workers. */
267
+ async pushSyncToAll(payload: SyncPushRequest): Promise<Map<string, SyncPushResponse | null>> {
268
+ const results = new Map<string, SyncPushResponse | null>();
269
+
270
+ const pushes = Array.from(this.workers.keys()).map(async (id) => {
271
+ const result = await this.pushSyncToWorker(id, payload);
272
+ results.set(id, result);
273
+ });
274
+
275
+ await Promise.all(pushes);
276
+ return results;
277
+ }
278
+
279
+ // --- Internal ---
280
+
281
+ private markUnreachable(workerId: string): void {
282
+ // Worker stays registered but health is tracked via last_heartbeat staleness.
283
+ // Fleet manager (Phase 3.3) will decide when to evict.
284
+ }
285
+ }