@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.
- package/PLANNING.md +383 -0
- package/TASKS.md +60 -0
- package/install.sh +187 -0
- package/npm-package.json +28 -0
- package/package.json +13 -20
- package/packages/cli/package.json +23 -0
- package/{dist/chunk-DVR2KBL7.js → packages/cli/src/commands/fleet.ts} +50 -30
- package/packages/cli/src/commands/init.ts +230 -0
- package/{dist/chunk-MBS5A6BZ.js → packages/cli/src/commands/service.ts} +51 -42
- package/{dist/chunk-RNK5Q5GR.js → packages/cli/src/commands/start.ts} +12 -14
- package/{dist/main.js → packages/cli/src/main.ts} +12 -18
- package/packages/cli/tsconfig.json +8 -0
- package/packages/memory/Cargo.lock +6480 -0
- package/packages/memory/Cargo.toml +21 -0
- package/packages/memory/src/context.rs +179 -0
- package/packages/memory/src/embeddings.rs +51 -0
- package/packages/memory/src/main.rs +626 -0
- package/packages/memory/src/promotion.rs +637 -0
- package/packages/memory/src/scoring.rs +131 -0
- package/packages/memory/src/store.rs +460 -0
- package/packages/memory/src/tasks.rs +321 -0
- package/packages/runtime/package.json +24 -0
- package/packages/runtime/src/__tests__/fleet-integration.test.ts +235 -0
- package/packages/runtime/src/__tests__/fleet.test.ts +207 -0
- package/packages/runtime/src/__tests__/integration.test.ts +434 -0
- package/packages/runtime/src/agent.ts +255 -0
- package/packages/runtime/src/config.ts +130 -0
- package/packages/runtime/src/context.ts +192 -0
- package/packages/runtime/src/fleet/fleet-manager.ts +399 -0
- package/packages/runtime/src/fleet/memory-sync.ts +362 -0
- package/packages/runtime/src/fleet/primary-client.ts +285 -0
- package/packages/runtime/src/fleet/worker-protocol.ts +158 -0
- package/packages/runtime/src/fleet/worker-server.ts +246 -0
- package/packages/runtime/src/index.ts +57 -0
- package/packages/runtime/src/llm-client.ts +65 -0
- package/packages/runtime/src/memory-client.ts +309 -0
- package/packages/runtime/src/pipeline.ts +151 -0
- package/packages/runtime/src/prompt.ts +173 -0
- package/packages/runtime/src/sesame.ts +174 -0
- package/{dist/start.js → packages/runtime/src/start.ts} +7 -9
- package/packages/runtime/src/task-engine.ts +113 -0
- package/packages/runtime/src/worker.ts +339 -0
- package/packages/runtime/tsconfig.json +8 -0
- package/pnpm-workspace.yaml +2 -0
- package/run-aidan.sh +23 -0
- package/scripts/bootstrap.sh +196 -0
- package/scripts/build-npm.sh +94 -0
- package/scripts/com.hivemind.agent.plist +44 -0
- package/scripts/com.hivemind.memory.plist +31 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +28 -0
- package/dist/chunk-2I2O6X5D.js +0 -1408
- package/dist/chunk-2I2O6X5D.js.map +0 -1
- package/dist/chunk-DVR2KBL7.js.map +0 -1
- package/dist/chunk-MBS5A6BZ.js.map +0 -1
- package/dist/chunk-NVJ424TB.js +0 -731
- package/dist/chunk-NVJ424TB.js.map +0 -1
- package/dist/chunk-RNK5Q5GR.js.map +0 -1
- package/dist/chunk-XNOWVLXD.js +0 -160
- package/dist/chunk-XNOWVLXD.js.map +0 -1
- package/dist/commands/fleet.js +0 -9
- package/dist/commands/fleet.js.map +0 -1
- package/dist/commands/init.js +0 -7
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/service.js +0 -7
- package/dist/commands/service.js.map +0 -1
- package/dist/commands/start.js +0 -9
- package/dist/commands/start.js.map +0 -1
- package/dist/index.js +0 -41
- package/dist/index.js.map +0 -1
- package/dist/main.js.map +0 -1
- package/dist/start.js.map +0 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the worker protocol (Phase 3, Task 3.1).
|
|
3
|
+
*
|
|
4
|
+
* Spins up a real WorkerServer and exercises the PrimaryClient against it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, before, after } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { PrimaryClient } from "../fleet/primary-client.js";
|
|
10
|
+
import { WorkerServer } from "../fleet/worker-server.js";
|
|
11
|
+
import type {
|
|
12
|
+
WorkerRegistrationRequest,
|
|
13
|
+
WorkerHealthResponse,
|
|
14
|
+
WorkerStatusReport,
|
|
15
|
+
} from "../fleet/worker-protocol.js";
|
|
16
|
+
|
|
17
|
+
const TEST_PORT = 19876;
|
|
18
|
+
const WORKER_URL = `http://localhost:${TEST_PORT}`;
|
|
19
|
+
|
|
20
|
+
describe("Worker Protocol", () => {
|
|
21
|
+
let primary: PrimaryClient;
|
|
22
|
+
let worker: WorkerServer;
|
|
23
|
+
let workerId: string;
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
worker = new WorkerServer({
|
|
27
|
+
workerId: "test-worker-1",
|
|
28
|
+
port: TEST_PORT,
|
|
29
|
+
maxContexts: 2,
|
|
30
|
+
memoryDaemonUrl: "http://localhost:9999",
|
|
31
|
+
ollamaUrl: "http://localhost:11434",
|
|
32
|
+
});
|
|
33
|
+
await worker.start();
|
|
34
|
+
|
|
35
|
+
primary = new PrimaryClient();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(async () => {
|
|
39
|
+
primary.stopHealthPolling();
|
|
40
|
+
await worker.stop();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("Registration", () => {
|
|
44
|
+
it("should register a worker and return an ID", () => {
|
|
45
|
+
const req: WorkerRegistrationRequest = {
|
|
46
|
+
url: WORKER_URL,
|
|
47
|
+
capabilities: {
|
|
48
|
+
max_contexts: 2,
|
|
49
|
+
has_ollama: true,
|
|
50
|
+
has_memory_daemon: true,
|
|
51
|
+
available_models: ["nomic-embed-text"],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const resp = primary.handleRegistration(req);
|
|
56
|
+
workerId = resp.worker_id;
|
|
57
|
+
|
|
58
|
+
assert.ok(workerId.startsWith("worker-"));
|
|
59
|
+
assert.ok(resp.registered_at);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should list the registered worker", () => {
|
|
63
|
+
const workers = primary.getWorkers();
|
|
64
|
+
assert.equal(workers.length, 1);
|
|
65
|
+
assert.equal(workers[0].id, workerId);
|
|
66
|
+
assert.equal(workers[0].url, WORKER_URL);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should get a worker by ID", () => {
|
|
70
|
+
const w = primary.getWorker(workerId);
|
|
71
|
+
assert.ok(w);
|
|
72
|
+
assert.equal(w.id, workerId);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("Health Checks", () => {
|
|
77
|
+
it("should get a healthy response from worker", async () => {
|
|
78
|
+
const health = await primary.checkHealth(workerId);
|
|
79
|
+
assert.ok(health);
|
|
80
|
+
assert.equal(health.worker_id, "test-worker-1");
|
|
81
|
+
assert.equal(health.status, "healthy");
|
|
82
|
+
assert.ok(health.uptime_seconds >= 0);
|
|
83
|
+
assert.equal(health.memory_daemon_ok, true);
|
|
84
|
+
assert.equal(health.ollama_ok, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return null for unknown worker", async () => {
|
|
88
|
+
const health = await primary.checkHealth("nonexistent");
|
|
89
|
+
assert.equal(health, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should poll all workers", async () => {
|
|
93
|
+
const results = await primary.checkAllHealth();
|
|
94
|
+
assert.equal(results.size, 1);
|
|
95
|
+
assert.equal(results.get(workerId), "healthy");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("Context Assignment", () => {
|
|
100
|
+
it("should assign a context to a worker", async () => {
|
|
101
|
+
const resp = await primary.assignContext(workerId, "project-alpha", "Alpha project");
|
|
102
|
+
assert.equal(resp.accepted, true);
|
|
103
|
+
assert.equal(resp.context_name, "project-alpha");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should track the assigned context on primary", () => {
|
|
107
|
+
const w = primary.getWorker(workerId);
|
|
108
|
+
assert.ok(w);
|
|
109
|
+
assert.ok(w.assigned_contexts.includes("project-alpha"));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should find worker for context", () => {
|
|
113
|
+
const w = primary.findWorkerForContext("project-alpha");
|
|
114
|
+
assert.ok(w);
|
|
115
|
+
assert.equal(w.id, workerId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should return undefined for unassigned context", () => {
|
|
119
|
+
const w = primary.findWorkerForContext("nonexistent");
|
|
120
|
+
assert.equal(w, undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should track the assigned context on worker", () => {
|
|
124
|
+
const contexts = worker.getAssignedContexts();
|
|
125
|
+
assert.ok(contexts.includes("project-alpha"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should assign a second context", async () => {
|
|
129
|
+
const resp = await primary.assignContext(workerId, "project-beta", "Beta project");
|
|
130
|
+
assert.equal(resp.accepted, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should reject when at capacity", async () => {
|
|
134
|
+
const resp = await primary.assignContext(workerId, "project-gamma", "Too many");
|
|
135
|
+
assert.equal(resp.accepted, false);
|
|
136
|
+
assert.ok(resp.reason?.includes("capacity"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should unassign a context", async () => {
|
|
140
|
+
const ok = await primary.unassignContext(workerId, "project-beta");
|
|
141
|
+
assert.equal(ok, true);
|
|
142
|
+
|
|
143
|
+
const w = primary.getWorker(workerId);
|
|
144
|
+
assert.ok(w);
|
|
145
|
+
assert.ok(!w.assigned_contexts.includes("project-beta"));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("Status Reporting", () => {
|
|
150
|
+
it("should report idle status when no task", async () => {
|
|
151
|
+
const resp = await fetch(`${WORKER_URL}/status`);
|
|
152
|
+
const body = (await resp.json()) as WorkerStatusReport;
|
|
153
|
+
assert.equal(body.activity, "idle");
|
|
154
|
+
assert.equal(body.current_context, null);
|
|
155
|
+
assert.equal(body.current_task, null);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should reflect active context and task", async () => {
|
|
159
|
+
worker.setActiveContext("project-alpha");
|
|
160
|
+
worker.setCurrentTask("task-42");
|
|
161
|
+
|
|
162
|
+
const resp = await fetch(`${WORKER_URL}/status`);
|
|
163
|
+
const body = (await resp.json()) as WorkerStatusReport;
|
|
164
|
+
assert.equal(body.activity, "working");
|
|
165
|
+
assert.equal(body.current_context, "project-alpha");
|
|
166
|
+
assert.equal(body.current_task, "task-42");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should handle status report on primary side", () => {
|
|
170
|
+
const report: WorkerStatusReport = {
|
|
171
|
+
activity: "working",
|
|
172
|
+
current_context: "project-alpha",
|
|
173
|
+
current_task: "task-42",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const status = primary.handleStatusReport(workerId, report);
|
|
177
|
+
assert.ok(status);
|
|
178
|
+
assert.equal(status.worker_id, workerId);
|
|
179
|
+
assert.equal(status.activity, "working");
|
|
180
|
+
assert.equal(status.current_context, "project-alpha");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should return null for unknown worker status report", () => {
|
|
184
|
+
const report: WorkerStatusReport = {
|
|
185
|
+
activity: "idle",
|
|
186
|
+
current_context: null,
|
|
187
|
+
current_task: null,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const status = primary.handleStatusReport("nonexistent", report);
|
|
191
|
+
assert.equal(status, null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("Deregistration", () => {
|
|
196
|
+
it("should deregister a worker", () => {
|
|
197
|
+
const ok = primary.deregister(workerId);
|
|
198
|
+
assert.equal(ok, true);
|
|
199
|
+
assert.equal(primary.getWorkers().length, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should return false for unknown worker", () => {
|
|
203
|
+
const ok = primary.deregister("nonexistent");
|
|
204
|
+
assert.equal(ok, false);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { describe, it, before, after, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration tests for Hivemind memory architecture.
|
|
7
|
+
*
|
|
8
|
+
* These tests mock the memory daemon HTTP API to verify:
|
|
9
|
+
* - Context creation and isolation
|
|
10
|
+
* - Episode storage scoped to contexts
|
|
11
|
+
* - Search isolation (context A doesn't leak into B)
|
|
12
|
+
* - Cross-context search returns labeled results
|
|
13
|
+
* - Global context accessible from all contexts
|
|
14
|
+
* - Task engine CRUD
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// In-memory mock state
|
|
18
|
+
interface MockEpisode {
|
|
19
|
+
id: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
context_name: string;
|
|
22
|
+
role: string;
|
|
23
|
+
content: string;
|
|
24
|
+
access_count: number;
|
|
25
|
+
layer: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MockTask {
|
|
29
|
+
id: string;
|
|
30
|
+
context_name: string;
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
status: string;
|
|
34
|
+
blocked_by: string[];
|
|
35
|
+
created_at: string;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let episodes: MockEpisode[] = [];
|
|
40
|
+
let contexts: Map<string, { name: string; description: string; created_at: string }> = new Map();
|
|
41
|
+
let tasks: MockTask[] = [];
|
|
42
|
+
let nextId = 1;
|
|
43
|
+
|
|
44
|
+
function resetState() {
|
|
45
|
+
episodes = [];
|
|
46
|
+
contexts = new Map();
|
|
47
|
+
tasks = [];
|
|
48
|
+
nextId = 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseBody(req: IncomingMessage): Promise<any> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let data = "";
|
|
54
|
+
req.on("data", (chunk: Buffer) => (data += chunk));
|
|
55
|
+
req.on("end", () => resolve(data ? JSON.parse(data) : {}));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mockHandler(req: IncomingMessage, res: ServerResponse) {
|
|
60
|
+
const url = new URL(req.url!, `http://localhost`);
|
|
61
|
+
const path = url.pathname;
|
|
62
|
+
const method = req.method!;
|
|
63
|
+
|
|
64
|
+
res.setHeader("Content-Type", "application/json");
|
|
65
|
+
|
|
66
|
+
// Health check
|
|
67
|
+
if (path === "/health" && method === "GET") {
|
|
68
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// POST /episodes
|
|
73
|
+
if (path === "/episodes" && method === "POST") {
|
|
74
|
+
parseBody(req).then((body) => {
|
|
75
|
+
const ep: MockEpisode = {
|
|
76
|
+
id: `ep-${nextId++}`,
|
|
77
|
+
timestamp: new Date().toISOString(),
|
|
78
|
+
context_name: body.context_name || "global",
|
|
79
|
+
role: body.role,
|
|
80
|
+
content: body.content,
|
|
81
|
+
access_count: 0,
|
|
82
|
+
layer: "L2",
|
|
83
|
+
};
|
|
84
|
+
episodes.push(ep);
|
|
85
|
+
res.end(JSON.stringify(ep));
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// GET /search — scoped to context param
|
|
91
|
+
if (path === "/search" && method === "GET") {
|
|
92
|
+
const ctx = url.searchParams.get("context");
|
|
93
|
+
const q = url.searchParams.get("q") || "";
|
|
94
|
+
const limit = parseInt(url.searchParams.get("limit") || "10");
|
|
95
|
+
let results = episodes;
|
|
96
|
+
if (ctx) results = results.filter((e) => e.context_name === ctx || e.context_name === "global");
|
|
97
|
+
// Simple text matching for mock
|
|
98
|
+
if (q) results = results.filter((e) => e.content.toLowerCase().includes(q.toLowerCase()));
|
|
99
|
+
const scored = results.slice(0, limit).map((e, i) => ({
|
|
100
|
+
...e,
|
|
101
|
+
score: 1 - i * 0.1,
|
|
102
|
+
}));
|
|
103
|
+
res.end(JSON.stringify({ episodes: scored }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// GET /search/cross-context
|
|
108
|
+
if (path === "/search/cross-context" && method === "GET") {
|
|
109
|
+
const q = url.searchParams.get("q") || "";
|
|
110
|
+
const limit = parseInt(url.searchParams.get("limit") || "10");
|
|
111
|
+
const matched = episodes.filter((e) => e.content.toLowerCase().includes(q.toLowerCase()));
|
|
112
|
+
const byContext = new Map<string, any[]>();
|
|
113
|
+
for (const e of matched.slice(0, limit)) {
|
|
114
|
+
const list = byContext.get(e.context_name) || [];
|
|
115
|
+
list.push({ ...e, score: 0.9, source_context: e.context_name });
|
|
116
|
+
byContext.set(e.context_name, list);
|
|
117
|
+
}
|
|
118
|
+
const results = Array.from(byContext.entries()).map(([context, eps]) => ({
|
|
119
|
+
context,
|
|
120
|
+
episodes: eps,
|
|
121
|
+
}));
|
|
122
|
+
res.end(JSON.stringify({ results }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// POST /contexts
|
|
127
|
+
if (path === "/contexts" && method === "POST") {
|
|
128
|
+
parseBody(req).then((body) => {
|
|
129
|
+
contexts.set(body.name, {
|
|
130
|
+
name: body.name,
|
|
131
|
+
description: body.description || "",
|
|
132
|
+
created_at: new Date().toISOString(),
|
|
133
|
+
});
|
|
134
|
+
res.end(JSON.stringify({ ok: true }));
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// GET /contexts
|
|
140
|
+
if (path === "/contexts" && method === "GET") {
|
|
141
|
+
const list = Array.from(contexts.values()).map((c) => ({
|
|
142
|
+
...c,
|
|
143
|
+
episode_count: episodes.filter((e) => e.context_name === c.name).length,
|
|
144
|
+
}));
|
|
145
|
+
res.end(JSON.stringify({ contexts: list }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// GET /contexts/:name
|
|
150
|
+
const ctxMatch = path.match(/^\/contexts\/([^/]+)$/);
|
|
151
|
+
if (ctxMatch && method === "GET") {
|
|
152
|
+
const name = decodeURIComponent(ctxMatch[1]);
|
|
153
|
+
const eps = episodes.filter((e) => e.context_name === name);
|
|
154
|
+
res.end(JSON.stringify(eps));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// POST /tasks
|
|
159
|
+
if (path === "/tasks" && method === "POST") {
|
|
160
|
+
parseBody(req).then((body) => {
|
|
161
|
+
const task: MockTask = {
|
|
162
|
+
id: `task-${nextId++}`,
|
|
163
|
+
context_name: body.context_name,
|
|
164
|
+
title: body.title,
|
|
165
|
+
description: body.description || "",
|
|
166
|
+
status: body.status || "planned",
|
|
167
|
+
blocked_by: body.blocked_by || [],
|
|
168
|
+
created_at: new Date().toISOString(),
|
|
169
|
+
updated_at: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
tasks.push(task);
|
|
172
|
+
res.end(JSON.stringify(task));
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// GET /tasks
|
|
178
|
+
if (path === "/tasks" && method === "GET") {
|
|
179
|
+
const ctx = url.searchParams.get("context") || "";
|
|
180
|
+
const status = url.searchParams.get("status");
|
|
181
|
+
let result = tasks.filter((t) => t.context_name === ctx);
|
|
182
|
+
if (status) result = result.filter((t) => t.status === status);
|
|
183
|
+
res.end(JSON.stringify({ tasks: result }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// PATCH /tasks/:id
|
|
188
|
+
const taskMatch = path.match(/^\/tasks\/([^/]+)$/);
|
|
189
|
+
if (taskMatch && method === "PATCH") {
|
|
190
|
+
parseBody(req).then((body) => {
|
|
191
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
192
|
+
const task = tasks.find((t) => t.id === id);
|
|
193
|
+
if (!task) {
|
|
194
|
+
res.statusCode = 404;
|
|
195
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
Object.assign(task, body, { updated_at: new Date().toISOString() });
|
|
199
|
+
res.end(JSON.stringify(task));
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// GET /tasks/next
|
|
205
|
+
if (path === "/tasks/next" && method === "GET") {
|
|
206
|
+
const ctx = url.searchParams.get("context") || "";
|
|
207
|
+
const next = tasks.find(
|
|
208
|
+
(t) =>
|
|
209
|
+
t.context_name === ctx &&
|
|
210
|
+
t.status === "planned" &&
|
|
211
|
+
t.blocked_by.every((dep) => tasks.find((d) => d.id === dep)?.status === "complete"),
|
|
212
|
+
);
|
|
213
|
+
if (!next) {
|
|
214
|
+
res.statusCode = 404;
|
|
215
|
+
res.end(JSON.stringify({ error: "no tasks" }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
res.end(JSON.stringify(next));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
res.statusCode = 404;
|
|
223
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let server: ReturnType<typeof createServer>;
|
|
227
|
+
let port: number;
|
|
228
|
+
|
|
229
|
+
describe("Hivemind Memory Integration", () => {
|
|
230
|
+
before(async () => {
|
|
231
|
+
server = createServer(mockHandler);
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
server.listen(0, () => {
|
|
234
|
+
port = (server.address() as any).port;
|
|
235
|
+
resolve();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
after(() => {
|
|
241
|
+
server.close();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("Context Isolation", () => {
|
|
245
|
+
before(() => resetState());
|
|
246
|
+
|
|
247
|
+
it("should create two independent contexts", async () => {
|
|
248
|
+
const base = `http://localhost:${port}`;
|
|
249
|
+
|
|
250
|
+
// Create contexts
|
|
251
|
+
await fetch(`${base}/contexts`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: JSON.stringify({ name: "project-alpha", description: "Alpha project" }),
|
|
255
|
+
});
|
|
256
|
+
await fetch(`${base}/contexts`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: { "Content-Type": "application/json" },
|
|
259
|
+
body: JSON.stringify({ name: "project-beta", description: "Beta project" }),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const listResp = await fetch(`${base}/contexts`);
|
|
263
|
+
const { contexts: ctxList } = (await listResp.json()) as any;
|
|
264
|
+
assert.equal(ctxList.length, 2);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should store episodes in separate contexts", async () => {
|
|
268
|
+
const base = `http://localhost:${port}`;
|
|
269
|
+
|
|
270
|
+
await fetch(`${base}/episodes`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "Content-Type": "application/json" },
|
|
273
|
+
body: JSON.stringify({ context_name: "project-alpha", role: "user", content: "Alpha auth uses JWT tokens" }),
|
|
274
|
+
});
|
|
275
|
+
await fetch(`${base}/episodes`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "Content-Type": "application/json" },
|
|
278
|
+
body: JSON.stringify({ context_name: "project-beta", role: "user", content: "Beta uses cookie-based sessions" }),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const alphaEps = await (await fetch(`${base}/contexts/project-alpha`)).json() as any[];
|
|
282
|
+
const betaEps = await (await fetch(`${base}/contexts/project-beta`)).json() as any[];
|
|
283
|
+
|
|
284
|
+
assert.equal(alphaEps.length, 1);
|
|
285
|
+
assert.equal(betaEps.length, 1);
|
|
286
|
+
assert.ok(alphaEps[0].content.includes("JWT"));
|
|
287
|
+
assert.ok(betaEps[0].content.includes("cookie"));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should NOT return project-beta results when searching project-alpha", async () => {
|
|
291
|
+
const base = `http://localhost:${port}`;
|
|
292
|
+
|
|
293
|
+
const resp = await fetch(`${base}/search?q=cookie&context=project-alpha`);
|
|
294
|
+
const { episodes: results } = (await resp.json()) as any;
|
|
295
|
+
assert.equal(results.length, 0, "Alpha search should not find Beta's episodes");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should return results from both contexts in cross-context search", async () => {
|
|
299
|
+
const base = `http://localhost:${port}`;
|
|
300
|
+
|
|
301
|
+
// Store something searchable in both
|
|
302
|
+
await fetch(`${base}/episodes`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ context_name: "project-alpha", role: "assistant", content: "Authentication architecture decided" }),
|
|
306
|
+
});
|
|
307
|
+
await fetch(`${base}/episodes`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "Content-Type": "application/json" },
|
|
310
|
+
body: JSON.stringify({ context_name: "project-beta", role: "assistant", content: "Authentication middleware complete" }),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const resp = await fetch(`${base}/search/cross-context?q=authentication`);
|
|
314
|
+
const { results } = (await resp.json()) as any;
|
|
315
|
+
|
|
316
|
+
const contextNames = results.map((r: any) => r.context);
|
|
317
|
+
assert.ok(contextNames.includes("project-alpha"), "Should include alpha");
|
|
318
|
+
assert.ok(contextNames.includes("project-beta"), "Should include beta");
|
|
319
|
+
|
|
320
|
+
// Verify results are labeled by source
|
|
321
|
+
for (const r of results) {
|
|
322
|
+
assert.ok(r.context, "Each result group should have a context label");
|
|
323
|
+
for (const ep of r.episodes) {
|
|
324
|
+
assert.ok(ep.source_context, "Each episode should have source_context");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("Global Context Accessibility", () => {
|
|
331
|
+
before(() => resetState());
|
|
332
|
+
|
|
333
|
+
it("should make global episodes visible from any context search", async () => {
|
|
334
|
+
const base = `http://localhost:${port}`;
|
|
335
|
+
|
|
336
|
+
// Store a global episode
|
|
337
|
+
await fetch(`${base}/episodes`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "Content-Type": "application/json" },
|
|
340
|
+
body: JSON.stringify({ context_name: "global", role: "system", content: "Agent identity: I am Aidan Hivemind" }),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Search from a project context — global should be included
|
|
344
|
+
const resp = await fetch(`${base}/search?q=identity&context=project-x`);
|
|
345
|
+
const { episodes: results } = (await resp.json()) as any;
|
|
346
|
+
assert.ok(results.length > 0, "Global episode should be visible from project context");
|
|
347
|
+
assert.ok(results[0].content.includes("Aidan"));
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("Task Engine", () => {
|
|
352
|
+
before(() => resetState());
|
|
353
|
+
|
|
354
|
+
it("should create and list tasks per context", async () => {
|
|
355
|
+
const base = `http://localhost:${port}`;
|
|
356
|
+
|
|
357
|
+
await fetch(`${base}/tasks`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "Content-Type": "application/json" },
|
|
360
|
+
body: JSON.stringify({ context_name: "project-alpha", title: "Set up CI", description: "Configure GitHub Actions" }),
|
|
361
|
+
});
|
|
362
|
+
await fetch(`${base}/tasks`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/json" },
|
|
365
|
+
body: JSON.stringify({ context_name: "project-alpha", title: "Write README", description: "" }),
|
|
366
|
+
});
|
|
367
|
+
await fetch(`${base}/tasks`, {
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers: { "Content-Type": "application/json" },
|
|
370
|
+
body: JSON.stringify({ context_name: "project-beta", title: "Deploy staging", description: "" }),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const alphaResp = await fetch(`${base}/tasks?context=project-alpha`);
|
|
374
|
+
const { tasks: alphaTasks } = (await alphaResp.json()) as any;
|
|
375
|
+
assert.equal(alphaTasks.length, 2);
|
|
376
|
+
|
|
377
|
+
const betaResp = await fetch(`${base}/tasks?context=project-beta`);
|
|
378
|
+
const { tasks: betaTasks } = (await betaResp.json()) as any;
|
|
379
|
+
assert.equal(betaTasks.length, 1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("should update task status", async () => {
|
|
383
|
+
const base = `http://localhost:${port}`;
|
|
384
|
+
|
|
385
|
+
const listResp = await fetch(`${base}/tasks?context=project-alpha`);
|
|
386
|
+
const { tasks: list } = (await listResp.json()) as any;
|
|
387
|
+
const taskId = list[0].id;
|
|
388
|
+
|
|
389
|
+
const patchResp = await fetch(`${base}/tasks/${taskId}`, {
|
|
390
|
+
method: "PATCH",
|
|
391
|
+
headers: { "Content-Type": "application/json" },
|
|
392
|
+
body: JSON.stringify({ status: "complete" }),
|
|
393
|
+
});
|
|
394
|
+
const updated = await patchResp.json() as any;
|
|
395
|
+
assert.equal(updated.status, "complete");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should get next available task respecting dependencies", async () => {
|
|
399
|
+
const base = `http://localhost:${port}`;
|
|
400
|
+
resetState();
|
|
401
|
+
|
|
402
|
+
// Create task A (no deps)
|
|
403
|
+
const respA = await fetch(`${base}/tasks`, {
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: { "Content-Type": "application/json" },
|
|
406
|
+
body: JSON.stringify({ context_name: "proj", title: "Task A", description: "" }),
|
|
407
|
+
});
|
|
408
|
+
const taskA = await respA.json() as any;
|
|
409
|
+
|
|
410
|
+
// Create task B (blocked by A)
|
|
411
|
+
await fetch(`${base}/tasks`, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
headers: { "Content-Type": "application/json" },
|
|
414
|
+
body: JSON.stringify({ context_name: "proj", title: "Task B", description: "", blocked_by: [taskA.id] }),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Next should be A (B is blocked)
|
|
418
|
+
const nextResp = await fetch(`${base}/tasks/next?context=proj`);
|
|
419
|
+
const next = await nextResp.json() as any;
|
|
420
|
+
assert.equal(next.title, "Task A");
|
|
421
|
+
|
|
422
|
+
// Complete A, now B should be next
|
|
423
|
+
await fetch(`${base}/tasks/${taskA.id}`, {
|
|
424
|
+
method: "PATCH",
|
|
425
|
+
headers: { "Content-Type": "application/json" },
|
|
426
|
+
body: JSON.stringify({ status: "complete" }),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const nextResp2 = await fetch(`${base}/tasks/next?context=proj`);
|
|
430
|
+
const next2 = await nextResp2.json() as any;
|
|
431
|
+
assert.equal(next2.title, "Task B");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|