@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,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
+ }