@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,255 @@
|
|
|
1
|
+
import type { HivemindConfig } from "./config.js";
|
|
2
|
+
import type { ChatMessage } from "./llm-client.js";
|
|
3
|
+
import type { L3Entry } from "./memory-client.js";
|
|
4
|
+
import { LLMClient } from "./llm-client.js";
|
|
5
|
+
import { MemoryClient } from "./memory-client.js";
|
|
6
|
+
import { ContextManager } from "./context.js";
|
|
7
|
+
import type { ContextSwitchResult } from "./context.js";
|
|
8
|
+
import { TaskEngine } from "./task-engine.js";
|
|
9
|
+
import { buildMessages, buildSystemPrompt } from "./prompt.js";
|
|
10
|
+
|
|
11
|
+
export interface AgentResponse {
|
|
12
|
+
content: string;
|
|
13
|
+
model: string;
|
|
14
|
+
context: string;
|
|
15
|
+
contextSwitched?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Agent {
|
|
19
|
+
private config: HivemindConfig;
|
|
20
|
+
private llm: LLMClient;
|
|
21
|
+
private memory: MemoryClient;
|
|
22
|
+
private contextManager: ContextManager;
|
|
23
|
+
// Per-context conversation histories
|
|
24
|
+
private conversationHistories: Map<string, ChatMessage[]> = new Map();
|
|
25
|
+
private messageCount = 0;
|
|
26
|
+
private readonly PROMOTION_INTERVAL = 10; // Run promotion every N messages
|
|
27
|
+
|
|
28
|
+
constructor(config: HivemindConfig, contextName = "global") {
|
|
29
|
+
this.config = config;
|
|
30
|
+
this.llm = new LLMClient(config.llm);
|
|
31
|
+
this.memory = new MemoryClient(config.memory);
|
|
32
|
+
this.contextManager = new ContextManager(this.memory);
|
|
33
|
+
|
|
34
|
+
if (contextName !== "global") {
|
|
35
|
+
this.contextManager.switchContext(contextName);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async processMessage(userMessage: string): Promise<AgentResponse> {
|
|
40
|
+
// Check for special commands first (cross-context, tasks)
|
|
41
|
+
const specialResult = await this.handleSpecialCommand(userMessage);
|
|
42
|
+
if (specialResult) return specialResult;
|
|
43
|
+
|
|
44
|
+
// Route message to correct context
|
|
45
|
+
const routing = this.contextManager.routeMessage(userMessage);
|
|
46
|
+
const contextName = routing.context;
|
|
47
|
+
|
|
48
|
+
// If an explicit context switch was requested, handle it
|
|
49
|
+
if (routing.switched) {
|
|
50
|
+
// Ensure context exists in daemon
|
|
51
|
+
try {
|
|
52
|
+
await this.memory.createContext(contextName);
|
|
53
|
+
} catch {
|
|
54
|
+
// Context may already exist
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get (or create) conversation history for this context
|
|
59
|
+
if (!this.conversationHistories.has(contextName)) {
|
|
60
|
+
this.conversationHistories.set(contextName, []);
|
|
61
|
+
}
|
|
62
|
+
const conversationHistory = this.conversationHistories.get(contextName)!;
|
|
63
|
+
|
|
64
|
+
// 1. Query memory for relevant episodes from active context + global
|
|
65
|
+
const relevantEpisodes = await this.memory
|
|
66
|
+
.search(userMessage, contextName, this.config.memory.top_k)
|
|
67
|
+
.catch((err) => {
|
|
68
|
+
console.error("Memory search failed, continuing without context:", err.message);
|
|
69
|
+
return [];
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Record access for retrieved episodes (for promotion engine)
|
|
73
|
+
if (relevantEpisodes.length > 0) {
|
|
74
|
+
const episodeIds = relevantEpisodes.map((e) => e.id);
|
|
75
|
+
this.memory.recordCoAccess(episodeIds).catch(() => {});
|
|
76
|
+
for (const ep of relevantEpisodes) {
|
|
77
|
+
this.memory.recordAccess(ep.id).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fetch L3 semantic knowledge for this context
|
|
82
|
+
const l3Knowledge = await this.memory
|
|
83
|
+
.getL3Knowledge(contextName)
|
|
84
|
+
.catch(() => [] as L3Entry[]);
|
|
85
|
+
|
|
86
|
+
// 2. Build prompt with identity + L2 memories + L3 knowledge + context info
|
|
87
|
+
const systemPrompt = buildSystemPrompt(this.config.agent, relevantEpisodes, contextName, l3Knowledge);
|
|
88
|
+
const messages = buildMessages(systemPrompt, conversationHistory, userMessage);
|
|
89
|
+
|
|
90
|
+
// 3. Call LLM
|
|
91
|
+
const response = await this.llm.chat(messages);
|
|
92
|
+
|
|
93
|
+
// 4. Update conversation history (L1 working memory, per-context)
|
|
94
|
+
conversationHistory.push(
|
|
95
|
+
{ role: "user", content: userMessage },
|
|
96
|
+
{ role: "assistant", content: response.content },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Keep working memory bounded (last 20 turns = 40 messages)
|
|
100
|
+
if (conversationHistory.length > 40) {
|
|
101
|
+
const trimmed = conversationHistory.slice(-40);
|
|
102
|
+
this.conversationHistories.set(contextName, trimmed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5. Store episodes in memory daemon (write-through to L2)
|
|
106
|
+
await this.storeEpisodes(contextName, userMessage, response.content);
|
|
107
|
+
|
|
108
|
+
// 6. Periodically trigger L2→L3 promotion (fire-and-forget)
|
|
109
|
+
this.messageCount++;
|
|
110
|
+
if (this.messageCount % this.PROMOTION_INTERVAL === 0) {
|
|
111
|
+
this.memory.runPromotion(contextName).catch((err) => {
|
|
112
|
+
console.error("Promotion run failed:", (err as Error).message);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: response.content,
|
|
118
|
+
model: response.model,
|
|
119
|
+
context: contextName,
|
|
120
|
+
contextSwitched: routing.switched,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async storeEpisodes(
|
|
125
|
+
contextName: string,
|
|
126
|
+
userMessage: string,
|
|
127
|
+
assistantResponse: string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
try {
|
|
130
|
+
await Promise.all([
|
|
131
|
+
this.memory.storeEpisode({
|
|
132
|
+
context_name: contextName,
|
|
133
|
+
role: "user",
|
|
134
|
+
content: userMessage,
|
|
135
|
+
}),
|
|
136
|
+
this.memory.storeEpisode({
|
|
137
|
+
context_name: contextName,
|
|
138
|
+
role: "assistant",
|
|
139
|
+
content: assistantResponse,
|
|
140
|
+
}),
|
|
141
|
+
]);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error("Failed to store episodes:", (err as Error).message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async handleSpecialCommand(message: string): Promise<AgentResponse | null> {
|
|
148
|
+
const activeCtx = this.contextManager.getActiveContext();
|
|
149
|
+
|
|
150
|
+
// --- Cross-context commands ---
|
|
151
|
+
|
|
152
|
+
// "search all: <query>" or "cross-context search: <query>"
|
|
153
|
+
const searchAllMatch = message.match(/^(?:search\s+all|cross-context\s+search)[:\s]+(.+)/i);
|
|
154
|
+
if (searchAllMatch) {
|
|
155
|
+
const query = searchAllMatch[1].trim();
|
|
156
|
+
try {
|
|
157
|
+
const results = await this.memory.searchCrossContext(query);
|
|
158
|
+
let response = "## Cross-Context Search Results\n\n";
|
|
159
|
+
if (results.length === 0) {
|
|
160
|
+
response += "No results found across any context.";
|
|
161
|
+
} else {
|
|
162
|
+
for (const group of results) {
|
|
163
|
+
response += `### Context: ${group.context}\n`;
|
|
164
|
+
for (const ep of group.episodes) {
|
|
165
|
+
response += `- [${ep.role}] ${ep.content.slice(0, 200)}${ep.content.length > 200 ? "..." : ""} (score: ${ep.score.toFixed(3)})\n`;
|
|
166
|
+
}
|
|
167
|
+
response += "\n";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { content: response, model: "system", context: activeCtx };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return { content: `Cross-context search failed: ${(err as Error).message}`, model: "system", context: activeCtx };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// "share <episode_id> with <context>"
|
|
177
|
+
const shareMatch = message.match(/^share\s+(\S+)\s+with\s+(\S+)/i);
|
|
178
|
+
if (shareMatch) {
|
|
179
|
+
const episodeId = shareMatch[1];
|
|
180
|
+
const targetContext = shareMatch[2];
|
|
181
|
+
try {
|
|
182
|
+
await this.memory.shareEpisode(episodeId, targetContext);
|
|
183
|
+
return { content: `Shared episode ${episodeId} with context "${targetContext}".`, model: "system", context: activeCtx };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { content: `Failed to share: ${(err as Error).message}`, model: "system", context: activeCtx };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Task commands ---
|
|
190
|
+
const taskCmd = TaskEngine.parseTaskCommand(message);
|
|
191
|
+
if (taskCmd) {
|
|
192
|
+
const engine = new TaskEngine({ contextName: activeCtx, memory: this.memory });
|
|
193
|
+
try {
|
|
194
|
+
switch (taskCmd.action) {
|
|
195
|
+
case "add": {
|
|
196
|
+
const task = await engine.addTask(taskCmd.title || "Untitled", taskCmd.description || "");
|
|
197
|
+
return { content: `Task created: [${task.id.slice(0, 8)}] ${task.title} (status: ${task.status})`, model: "system", context: activeCtx };
|
|
198
|
+
}
|
|
199
|
+
case "list": {
|
|
200
|
+
const tasks = await engine.listTasks(taskCmd.statusFilter);
|
|
201
|
+
if (tasks.length === 0) {
|
|
202
|
+
return { content: `No tasks${taskCmd.statusFilter ? ` with status "${taskCmd.statusFilter}"` : ""} in context "${activeCtx}".`, model: "system", context: activeCtx };
|
|
203
|
+
}
|
|
204
|
+
let response = `## Tasks in ${activeCtx}\n\n`;
|
|
205
|
+
for (const t of tasks) {
|
|
206
|
+
const blockedBy: string[] = Array.isArray(t.blocked_by) ? t.blocked_by : JSON.parse(String(t.blocked_by) || "[]");
|
|
207
|
+
const blockedStr = blockedBy.length > 0 ? ` (blocked by: ${blockedBy.map((b) => b.slice(0, 8)).join(", ")})` : "";
|
|
208
|
+
response += `- [${t.status}] ${t.id.slice(0, 8)}: ${t.title}${blockedStr}\n`;
|
|
209
|
+
}
|
|
210
|
+
return { content: response, model: "system", context: activeCtx };
|
|
211
|
+
}
|
|
212
|
+
case "start": {
|
|
213
|
+
const task = await engine.startTask(taskCmd.taskId!);
|
|
214
|
+
return { content: task ? `Task ${taskCmd.taskId!.slice(0, 8)} started.` : "Task not found.", model: "system", context: activeCtx };
|
|
215
|
+
}
|
|
216
|
+
case "complete": {
|
|
217
|
+
const task = await engine.completeTask(taskCmd.taskId!);
|
|
218
|
+
return { content: task ? `Task ${taskCmd.taskId!.slice(0, 8)} completed.` : "Task not found.", model: "system", context: activeCtx };
|
|
219
|
+
}
|
|
220
|
+
case "archive": {
|
|
221
|
+
const task = await engine.archiveTask(taskCmd.taskId!);
|
|
222
|
+
return { content: task ? `Task ${taskCmd.taskId!.slice(0, 8)} archived.` : "Task not found.", model: "system", context: activeCtx };
|
|
223
|
+
}
|
|
224
|
+
case "next": {
|
|
225
|
+
const task = await engine.pickAndStartNextTask();
|
|
226
|
+
if (task) {
|
|
227
|
+
return { content: `Picked up next task: [${task.id.slice(0, 8)}] ${task.title}`, model: "system", context: activeCtx };
|
|
228
|
+
}
|
|
229
|
+
return { content: "No available tasks to pick up.", model: "system", context: activeCtx };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return { content: `Task command failed: ${(err as Error).message}`, model: "system", context: activeCtx };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getMemoryClient(): MemoryClient {
|
|
241
|
+
return this.memory;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getContextManager(): ContextManager {
|
|
245
|
+
return this.contextManager;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
setContext(name: string): void {
|
|
249
|
+
this.contextManager.switchContext(name);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getActiveContext(): string {
|
|
253
|
+
return this.contextManager.getActiveContext();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve, dirname } from "path";
|
|
3
|
+
import { parse } from "@iarna/toml";
|
|
4
|
+
|
|
5
|
+
export interface AgentConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
personality: string;
|
|
8
|
+
team_charter?: string; // Path to team charter markdown file
|
|
9
|
+
workspace?: string; // Path to workspace directory containing .md identity files
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LLMConfig {
|
|
13
|
+
base_url: string;
|
|
14
|
+
model: string;
|
|
15
|
+
max_tokens: number;
|
|
16
|
+
temperature: number;
|
|
17
|
+
api_key?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MemoryConfig {
|
|
21
|
+
daemon_url: string;
|
|
22
|
+
top_k: number;
|
|
23
|
+
embedding_model: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OllamaConfig {
|
|
27
|
+
base_url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SesameConfig {
|
|
31
|
+
ws_url: string;
|
|
32
|
+
api_url: string;
|
|
33
|
+
api_key: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkerModeConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
primary_url: string;
|
|
39
|
+
worker_port: number;
|
|
40
|
+
worker_id: string;
|
|
41
|
+
max_contexts: number;
|
|
42
|
+
task_poll_interval_ms: number;
|
|
43
|
+
status_report_interval_ms: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HivemindConfig {
|
|
47
|
+
agent: AgentConfig;
|
|
48
|
+
llm: LLMConfig;
|
|
49
|
+
memory: MemoryConfig;
|
|
50
|
+
ollama: OllamaConfig;
|
|
51
|
+
sesame: SesameConfig;
|
|
52
|
+
worker?: WorkerModeConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultWorkerConfig(): WorkerModeConfig {
|
|
56
|
+
return {
|
|
57
|
+
enabled: false,
|
|
58
|
+
primary_url: "http://localhost:3000",
|
|
59
|
+
worker_port: 3100,
|
|
60
|
+
worker_id: `worker-${process.pid}`,
|
|
61
|
+
max_contexts: 4,
|
|
62
|
+
task_poll_interval_ms: 5_000,
|
|
63
|
+
status_report_interval_ms: 15_000,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deepMerge(target: any, source: any): any {
|
|
68
|
+
const result = { ...target };
|
|
69
|
+
for (const key of Object.keys(source)) {
|
|
70
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])
|
|
71
|
+
&& target[key] && typeof target[key] === "object") {
|
|
72
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
73
|
+
} else if (source[key] !== undefined && source[key] !== "") {
|
|
74
|
+
result[key] = source[key];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadConfig(path: string): HivemindConfig {
|
|
81
|
+
const raw = readFileSync(path, "utf-8");
|
|
82
|
+
let parsed = parse(raw) as unknown as HivemindConfig;
|
|
83
|
+
|
|
84
|
+
// Layer: merge local.toml if it exists alongside the config
|
|
85
|
+
const configDir = dirname(path);
|
|
86
|
+
const localPath = resolve(configDir, "local.toml");
|
|
87
|
+
if (existsSync(localPath)) {
|
|
88
|
+
const localRaw = readFileSync(localPath, "utf-8");
|
|
89
|
+
const localParsed = parse(localRaw) as Record<string, unknown>;
|
|
90
|
+
parsed = deepMerge(parsed, localParsed) as HivemindConfig;
|
|
91
|
+
console.log(`[config] Merged overrides from ${localPath}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Allow env overrides
|
|
95
|
+
if (process.env.AGENT_NAME) {
|
|
96
|
+
parsed.agent.name = process.env.AGENT_NAME;
|
|
97
|
+
}
|
|
98
|
+
if (process.env.LLM_API_KEY) {
|
|
99
|
+
parsed.llm.api_key = process.env.LLM_API_KEY;
|
|
100
|
+
}
|
|
101
|
+
if (process.env.LLM_BASE_URL) {
|
|
102
|
+
parsed.llm.base_url = process.env.LLM_BASE_URL;
|
|
103
|
+
}
|
|
104
|
+
if (process.env.SESAME_API_KEY) {
|
|
105
|
+
parsed.sesame.api_key = process.env.SESAME_API_KEY;
|
|
106
|
+
}
|
|
107
|
+
if (process.env.MEMORY_DAEMON_URL) {
|
|
108
|
+
parsed.memory.daemon_url = process.env.MEMORY_DAEMON_URL;
|
|
109
|
+
}
|
|
110
|
+
if (process.env.WORKER_PRIMARY_URL) {
|
|
111
|
+
if (!parsed.worker) parsed.worker = defaultWorkerConfig();
|
|
112
|
+
parsed.worker.primary_url = process.env.WORKER_PRIMARY_URL;
|
|
113
|
+
}
|
|
114
|
+
if (process.env.WORKER_PORT) {
|
|
115
|
+
if (!parsed.worker) parsed.worker = defaultWorkerConfig();
|
|
116
|
+
parsed.worker.worker_port = parseInt(process.env.WORKER_PORT, 10);
|
|
117
|
+
}
|
|
118
|
+
if (process.env.WORKER_ID) {
|
|
119
|
+
if (!parsed.worker) parsed.worker = defaultWorkerConfig();
|
|
120
|
+
parsed.worker.worker_id = process.env.WORKER_ID;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Resolve relative workspace path against config directory
|
|
124
|
+
if (parsed.agent.workspace && !parsed.agent.workspace.startsWith("/")) {
|
|
125
|
+
const configDir = dirname(path);
|
|
126
|
+
parsed.agent.workspace = resolve(configDir, "..", parsed.agent.workspace);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { MemoryClient } from "./memory-client.js";
|
|
2
|
+
|
|
3
|
+
export interface ContextMetadata {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
created_at: string;
|
|
7
|
+
last_active: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ContextSwitchResult {
|
|
11
|
+
previousContext: string | null;
|
|
12
|
+
activeContext: string;
|
|
13
|
+
isNew: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SWITCH_PATTERNS = [
|
|
17
|
+
/^switch\s+to\s+(\S+)/i,
|
|
18
|
+
/^context:\s*(\S+)/i,
|
|
19
|
+
/^@(\S+)\s/,
|
|
20
|
+
/^working\s+on\s+(\S+)/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export class ContextManager {
|
|
24
|
+
private contexts: Map<string, ContextMetadata> = new Map();
|
|
25
|
+
private activeContext: string = "global";
|
|
26
|
+
private memory: MemoryClient;
|
|
27
|
+
|
|
28
|
+
constructor(memory: MemoryClient) {
|
|
29
|
+
this.memory = memory;
|
|
30
|
+
// Global context always exists
|
|
31
|
+
this.contexts.set("global", {
|
|
32
|
+
name: "global",
|
|
33
|
+
description: "Global context — identity, preferences, cross-cutting knowledge",
|
|
34
|
+
created_at: new Date().toISOString(),
|
|
35
|
+
last_active: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async createContext(name: string, description = ""): Promise<ContextMetadata> {
|
|
40
|
+
if (this.contexts.has(name)) {
|
|
41
|
+
return this.contexts.get(name)!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const metadata: ContextMetadata = {
|
|
45
|
+
name,
|
|
46
|
+
description,
|
|
47
|
+
created_at: new Date().toISOString(),
|
|
48
|
+
last_active: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
this.contexts.set(name, metadata);
|
|
52
|
+
|
|
53
|
+
// Register context with memory daemon
|
|
54
|
+
try {
|
|
55
|
+
await this.memory.createContext(name, description);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`Failed to register context '${name}' with daemon:`, (err as Error).message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return metadata;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async deleteContext(name: string): Promise<boolean> {
|
|
64
|
+
if (name === "global") {
|
|
65
|
+
console.error("Cannot delete global context");
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!this.contexts.has(name)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.contexts.delete(name);
|
|
74
|
+
|
|
75
|
+
if (this.activeContext === name) {
|
|
76
|
+
this.activeContext = "global";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await this.memory.deleteContext(name);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`Failed to delete context '${name}' from daemon:`, (err as Error).message);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
listContexts(): ContextMetadata[] {
|
|
89
|
+
return Array.from(this.contexts.values());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getContext(name: string): ContextMetadata | undefined {
|
|
93
|
+
return this.contexts.get(name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getActiveContext(): string {
|
|
97
|
+
return this.activeContext;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
switchContext(name: string): ContextSwitchResult {
|
|
101
|
+
const previousContext = this.activeContext;
|
|
102
|
+
const isNew = !this.contexts.has(name);
|
|
103
|
+
|
|
104
|
+
if (isNew) {
|
|
105
|
+
this.contexts.set(name, {
|
|
106
|
+
name,
|
|
107
|
+
description: "",
|
|
108
|
+
created_at: new Date().toISOString(),
|
|
109
|
+
last_active: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.activeContext = name;
|
|
114
|
+
this.touchContext(name);
|
|
115
|
+
|
|
116
|
+
return { previousContext, activeContext: name, isNew };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a message and determine if it's a context switch command.
|
|
121
|
+
* Returns the context name if a switch is detected, null otherwise.
|
|
122
|
+
*/
|
|
123
|
+
parseContextSwitch(message: string): string | null {
|
|
124
|
+
for (const pattern of SWITCH_PATTERNS) {
|
|
125
|
+
const match = message.match(pattern);
|
|
126
|
+
if (match) {
|
|
127
|
+
return match[1].toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Infer context from message content by checking known context names.
|
|
135
|
+
* Returns the best matching context or current active context.
|
|
136
|
+
*/
|
|
137
|
+
inferContext(message: string): string {
|
|
138
|
+
const lower = message.toLowerCase();
|
|
139
|
+
for (const [name] of this.contexts) {
|
|
140
|
+
if (name === "global") continue;
|
|
141
|
+
if (lower.includes(name.toLowerCase())) {
|
|
142
|
+
return name;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return this.activeContext;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Route a message: check for explicit switch, then infer context.
|
|
150
|
+
* Returns the resolved context name and whether a switch happened.
|
|
151
|
+
*/
|
|
152
|
+
routeMessage(message: string): { context: string; switched: boolean; switchedTo?: string } {
|
|
153
|
+
// Check for explicit switch command
|
|
154
|
+
const switchTarget = this.parseContextSwitch(message);
|
|
155
|
+
if (switchTarget) {
|
|
156
|
+
const result = this.switchContext(switchTarget);
|
|
157
|
+
return { context: switchTarget, switched: true, switchedTo: result.activeContext };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Infer from content
|
|
161
|
+
const inferred = this.inferContext(message);
|
|
162
|
+
if (inferred !== this.activeContext) {
|
|
163
|
+
this.touchContext(inferred);
|
|
164
|
+
return { context: inferred, switched: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.touchContext(this.activeContext);
|
|
168
|
+
return { context: this.activeContext, switched: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns contexts that should be searched: active context + global.
|
|
173
|
+
*/
|
|
174
|
+
getSearchContexts(): string[] {
|
|
175
|
+
const contexts = [this.activeContext];
|
|
176
|
+
if (this.activeContext !== "global") {
|
|
177
|
+
contexts.push("global");
|
|
178
|
+
}
|
|
179
|
+
return contexts;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
hasContext(name: string): boolean {
|
|
183
|
+
return this.contexts.has(name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private touchContext(name: string): void {
|
|
187
|
+
const ctx = this.contexts.get(name);
|
|
188
|
+
if (ctx) {
|
|
189
|
+
ctx.last_active = new Date().toISOString();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|