@199-bio/engram 0.7.0 → 0.7.4

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.
@@ -7,6 +7,7 @@ import Anthropic from "@anthropic-ai/sdk";
7
7
  import { EngramDatabase, Entity, Memory } from "../storage/database.js";
8
8
  import { KnowledgeGraph } from "../graph/knowledge-graph.js";
9
9
  import { HybridSearch } from "../retrieval/hybrid.js";
10
+ import { getAnthropicApiKey } from "../settings.js";
10
11
 
11
12
  // Tool definitions for Claude
12
13
  const TOOLS: Anthropic.Tool[] = [
@@ -135,7 +136,7 @@ const TOOLS: Anthropic.Tool[] = [
135
136
  },
136
137
  {
137
138
  name: "delete_memory",
138
- description: "Delete a memory by its ID.",
139
+ description: "Delete a memory by its ID (soft-delete, can be recovered).",
139
140
  input_schema: {
140
141
  type: "object" as const,
141
142
  properties: {
@@ -147,6 +148,87 @@ const TOOLS: Anthropic.Tool[] = [
147
148
  required: ["id"],
148
149
  },
149
150
  },
151
+ {
152
+ name: "edit_memory",
153
+ description: "Edit an existing memory's content or importance.",
154
+ input_schema: {
155
+ type: "object" as const,
156
+ properties: {
157
+ id: {
158
+ type: "string",
159
+ description: "The memory ID to edit",
160
+ },
161
+ content: {
162
+ type: "string",
163
+ description: "New content (replaces existing)",
164
+ },
165
+ importance: {
166
+ type: "number",
167
+ description: "New importance (0-1): 0.9=core identity, 0.8=major, 0.5=normal, 0.3=minor",
168
+ },
169
+ },
170
+ required: ["id"],
171
+ },
172
+ },
173
+ {
174
+ name: "create_memory",
175
+ description: "Create a new memory. Use for storing user information, preferences, or facts.",
176
+ input_schema: {
177
+ type: "object" as const,
178
+ properties: {
179
+ content: {
180
+ type: "string",
181
+ description: "The information to store",
182
+ },
183
+ importance: {
184
+ type: "number",
185
+ description: "0-1 score: 0.9=core identity, 0.8=major, 0.5=normal (default), 0.3=minor",
186
+ },
187
+ },
188
+ required: ["content"],
189
+ },
190
+ },
191
+ {
192
+ name: "create_entity",
193
+ description: "Create a new entity (person, organization, or place).",
194
+ input_schema: {
195
+ type: "object" as const,
196
+ properties: {
197
+ name: {
198
+ type: "string",
199
+ description: "The entity name",
200
+ },
201
+ type: {
202
+ type: "string",
203
+ enum: ["person", "organization", "place"],
204
+ description: "Entity type",
205
+ },
206
+ },
207
+ required: ["name", "type"],
208
+ },
209
+ },
210
+ {
211
+ name: "create_relationship",
212
+ description: "Create a relationship between two entities.",
213
+ input_schema: {
214
+ type: "object" as const,
215
+ properties: {
216
+ from: {
217
+ type: "string",
218
+ description: "Source entity name",
219
+ },
220
+ to: {
221
+ type: "string",
222
+ description: "Target entity name",
223
+ },
224
+ type: {
225
+ type: "string",
226
+ description: "Relationship type (e.g., 'works_at', 'lives_in', 'knows', 'sibling_of')",
227
+ },
228
+ },
229
+ required: ["from", "to", "type"],
230
+ },
231
+ },
150
232
  {
151
233
  name: "find_duplicates",
152
234
  description: "Find potential duplicate entities that could be merged.",
@@ -183,12 +265,22 @@ interface ChatMessage {
183
265
  content: string;
184
266
  }
185
267
 
268
+ // Stream event types for SSE
269
+ export interface StreamEvent {
270
+ type: "text" | "tool_start" | "tool_end" | "error" | "done";
271
+ content?: string;
272
+ tool?: string;
273
+ result?: unknown;
274
+ }
275
+
186
276
  export class ChatHandler {
187
277
  private client: Anthropic | null = null;
188
278
  private db: EngramDatabase;
189
279
  private graph: KnowledgeGraph;
190
280
  private search: HybridSearch;
191
281
  private conversationHistory: Anthropic.MessageParam[] = [];
282
+ private isProcessing: boolean = false;
283
+ private messageQueue: Array<{ message: string; resolve: (value: string) => void; reject: (error: Error) => void }> = [];
192
284
 
193
285
  constructor(options: {
194
286
  db: EngramDatabase;
@@ -199,9 +291,19 @@ export class ChatHandler {
199
291
  this.graph = options.graph;
200
292
  this.search = options.search;
201
293
 
202
- const apiKey = process.env.ANTHROPIC_API_KEY;
294
+ // Initialize client from settings or env var
295
+ this.refreshClient();
296
+ }
297
+
298
+ /**
299
+ * Refresh the Anthropic client (call after settings change)
300
+ */
301
+ refreshClient(): void {
302
+ const apiKey = getAnthropicApiKey();
203
303
  if (apiKey) {
204
304
  this.client = new Anthropic({ apiKey });
305
+ } else {
306
+ this.client = null;
205
307
  }
206
308
  }
207
309
 
@@ -209,15 +311,174 @@ export class ChatHandler {
209
311
  return this.client !== null;
210
312
  }
211
313
 
314
+ isBusy(): boolean {
315
+ return this.isProcessing;
316
+ }
317
+
318
+ getQueueLength(): number {
319
+ return this.messageQueue.length;
320
+ }
321
+
212
322
  clearHistory(): void {
213
323
  this.conversationHistory = [];
214
324
  }
215
325
 
326
+ // Process message queue
327
+ private async processQueue(): Promise<void> {
328
+ if (this.isProcessing || this.messageQueue.length === 0) return;
329
+
330
+ const { message, resolve, reject } = this.messageQueue.shift()!;
331
+ try {
332
+ const result = await this.processMessage(message);
333
+ resolve(result);
334
+ } catch (error) {
335
+ reject(error instanceof Error ? error : new Error(String(error)));
336
+ }
337
+
338
+ // Process next in queue
339
+ this.processQueue();
340
+ }
341
+
342
+ // Queue-aware chat method
216
343
  async chat(userMessage: string): Promise<string> {
217
344
  if (!this.client) {
218
345
  return "Chat is not configured. Set ANTHROPIC_API_KEY environment variable.";
219
346
  }
220
347
 
348
+ // If busy, queue the message
349
+ if (this.isProcessing) {
350
+ return new Promise((resolve, reject) => {
351
+ this.messageQueue.push({ message: userMessage, resolve, reject });
352
+ });
353
+ }
354
+
355
+ return this.processMessage(userMessage);
356
+ }
357
+
358
+ // Streaming chat with callbacks for real-time updates
359
+ async *chatStream(userMessage: string): AsyncGenerator<StreamEvent> {
360
+ if (!this.client) {
361
+ yield { type: "error", content: "Chat is not configured. Set ANTHROPIC_API_KEY environment variable." };
362
+ return;
363
+ }
364
+
365
+ this.isProcessing = true;
366
+
367
+ // Add user message to history
368
+ this.conversationHistory.push({
369
+ role: "user",
370
+ content: userMessage,
371
+ });
372
+
373
+ try {
374
+ // Keep conversation history manageable
375
+ if (this.conversationHistory.length > 20) {
376
+ this.conversationHistory = this.conversationHistory.slice(-20);
377
+ }
378
+
379
+ let continueLoop = true;
380
+ let fullResponse = "";
381
+
382
+ while (continueLoop) {
383
+ const stream = this.client.messages.stream({
384
+ model: "claude-haiku-4-5-20241022",
385
+ max_tokens: 1024,
386
+ system: SYSTEM_PROMPT,
387
+ tools: TOOLS,
388
+ messages: this.conversationHistory,
389
+ });
390
+
391
+ let currentToolUse: { id: string; name: string; input: string } | null = null;
392
+
393
+ for await (const event of stream) {
394
+ if (event.type === "content_block_start") {
395
+ if (event.content_block.type === "tool_use") {
396
+ currentToolUse = {
397
+ id: event.content_block.id,
398
+ name: event.content_block.name,
399
+ input: "",
400
+ };
401
+ yield { type: "tool_start", tool: event.content_block.name };
402
+ }
403
+ } else if (event.type === "content_block_delta") {
404
+ if (event.delta.type === "text_delta") {
405
+ fullResponse += event.delta.text;
406
+ yield { type: "text", content: event.delta.text };
407
+ } else if (event.delta.type === "input_json_delta" && currentToolUse) {
408
+ currentToolUse.input += event.delta.partial_json;
409
+ }
410
+ } else if (event.type === "content_block_stop") {
411
+ if (currentToolUse) {
412
+ // Execute the tool
413
+ let toolInput: Record<string, unknown> = {};
414
+ try {
415
+ toolInput = JSON.parse(currentToolUse.input || "{}");
416
+ } catch {
417
+ toolInput = {};
418
+ }
419
+
420
+ const result = await this.executeTool(currentToolUse.name, toolInput);
421
+ yield { type: "tool_end", tool: currentToolUse.name, result };
422
+ currentToolUse = null;
423
+ }
424
+ }
425
+ }
426
+
427
+ // Get final message to check stop reason
428
+ const finalMessage = await stream.finalMessage();
429
+
430
+ if (finalMessage.stop_reason === "tool_use") {
431
+ // Process tool results and continue
432
+ const toolUseBlocks = finalMessage.content.filter(
433
+ (block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
434
+ );
435
+
436
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
437
+ for (const toolUse of toolUseBlocks) {
438
+ const result = await this.executeTool(toolUse.name, toolUse.input as Record<string, unknown>);
439
+ toolResults.push({
440
+ type: "tool_result",
441
+ tool_use_id: toolUse.id,
442
+ content: JSON.stringify(result),
443
+ });
444
+ }
445
+
446
+ // Add to history
447
+ this.conversationHistory.push({
448
+ role: "assistant",
449
+ content: finalMessage.content,
450
+ });
451
+ this.conversationHistory.push({
452
+ role: "user",
453
+ content: toolResults,
454
+ });
455
+ } else {
456
+ // Done - add final response to history
457
+ this.conversationHistory.push({
458
+ role: "assistant",
459
+ content: finalMessage.content,
460
+ });
461
+ continueLoop = false;
462
+ }
463
+ }
464
+
465
+ yield { type: "done" };
466
+ } catch (error) {
467
+ const message = error instanceof Error ? error.message : String(error);
468
+ yield { type: "error", content: message };
469
+ } finally {
470
+ this.isProcessing = false;
471
+ // Process any queued messages
472
+ this.processQueue();
473
+ }
474
+ }
475
+
476
+ // Original non-streaming method for backwards compatibility
477
+ private async processMessage(userMessage: string): Promise<string> {
478
+ if (!this.client) {
479
+ return "Chat is not configured. Set ANTHROPIC_API_KEY environment variable.";
480
+ }
481
+
221
482
  // Add user message to history
222
483
  this.conversationHistory.push({
223
484
  role: "user",
@@ -427,9 +688,93 @@ export class ChatHandler {
427
688
  return { error: `Memory not found: ${id}` };
428
689
  }
429
690
 
691
+ // Soft-delete: remove from index and disable
430
692
  await this.search.removeFromIndex(id);
431
- this.db.deleteMemory(id);
432
- return { success: true, deleted_id: id };
693
+ this.db.updateMemory(id, { disabled: true });
694
+ return { success: true, disabled_id: id, message: "Memory disabled (soft-deleted)" };
695
+ }
696
+
697
+ case "edit_memory": {
698
+ const id = input.id as string;
699
+ const content = input.content as string | undefined;
700
+ const importance = input.importance as number | undefined;
701
+
702
+ const memory = this.db.getMemory(id);
703
+ if (!memory) {
704
+ return { error: `Memory not found: ${id}` };
705
+ }
706
+
707
+ const updates: { content?: string; importance?: number } = {};
708
+ if (content !== undefined) updates.content = content;
709
+ if (importance !== undefined) updates.importance = importance;
710
+
711
+ if (Object.keys(updates).length === 0) {
712
+ return { error: "No updates provided" };
713
+ }
714
+
715
+ const updated = this.db.updateMemory(id, updates);
716
+
717
+ // Re-index if content changed
718
+ if (content !== undefined && updated) {
719
+ await this.search.removeFromIndex(id);
720
+ await this.search.indexMemory(updated);
721
+ }
722
+
723
+ return {
724
+ success: true,
725
+ memory_id: id,
726
+ updated_fields: Object.keys(updates),
727
+ };
728
+ }
729
+
730
+ case "create_memory": {
731
+ const content = input.content as string;
732
+ const importance = (input.importance as number) || 0.5;
733
+
734
+ const memory = this.db.createMemory(content, "chat", importance);
735
+ await this.search.indexMemory(memory);
736
+
737
+ return {
738
+ success: true,
739
+ memory_id: memory.id,
740
+ content: memory.content.substring(0, 100) + (memory.content.length > 100 ? "..." : ""),
741
+ };
742
+ }
743
+
744
+ case "create_entity": {
745
+ const name = input.name as string;
746
+ const type = input.type as "person" | "organization" | "place";
747
+
748
+ // Check if entity already exists
749
+ const existing = this.db.findEntityByName(name);
750
+ if (existing) {
751
+ return { error: `Entity already exists: ${name}`, existing_id: existing.id };
752
+ }
753
+
754
+ const entity = this.graph.getOrCreateEntity(name, type);
755
+ return {
756
+ success: true,
757
+ entity_id: entity.id,
758
+ name: entity.name,
759
+ type: entity.type,
760
+ };
761
+ }
762
+
763
+ case "create_relationship": {
764
+ const fromName = input.from as string;
765
+ const toName = input.to as string;
766
+ const type = input.type as string;
767
+
768
+ // Get or create both entities (default to person type if not existing)
769
+ const fromEntity = this.graph.getOrCreateEntity(fromName, "person");
770
+ const toEntity = this.graph.getOrCreateEntity(toName, "person");
771
+
772
+ this.graph.relate(fromEntity.name, toEntity.name, type);
773
+
774
+ return {
775
+ success: true,
776
+ relationship: `${fromName} -[${type}]-> ${toName}`,
777
+ };
433
778
  }
434
779
 
435
780
  case "find_duplicates": {
package/src/web/server.ts CHANGED
@@ -6,18 +6,26 @@
6
6
  import http from "http";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
+ import os from "os";
9
10
  import { fileURLToPath } from "url";
10
11
  import { EngramDatabase } from "../storage/database.js";
11
12
  import { KnowledgeGraph } from "../graph/knowledge-graph.js";
12
13
  import { HybridSearch } from "../retrieval/hybrid.js";
13
14
  import { ChatHandler } from "./chat-handler.js";
14
15
  import { Consolidator } from "../consolidation/consolidator.js";
16
+ import { loadSettings, saveSettings, hasAnthropicApiKey } from "../settings.js";
15
17
 
16
18
  const __filename = fileURLToPath(import.meta.url);
17
19
  const __dirname = path.dirname(__filename);
18
20
 
19
21
  const STATIC_DIR = path.join(__dirname, "..", "..", "src", "web", "static");
20
22
 
23
+ // Port file for discovery - allows finding the running web server
24
+ const PORT_FILE = path.join(
25
+ process.env.ENGRAM_DB_PATH?.replace("~", os.homedir()) || path.join(os.homedir(), ".engram"),
26
+ "web-server.json"
27
+ );
28
+
21
29
  const MIME_TYPES: Record<string, string> = {
22
30
  ".html": "text/html",
23
31
  ".css": "text/css",
@@ -27,6 +35,31 @@ const MIME_TYPES: Record<string, string> = {
27
35
  ".svg": "image/svg+xml",
28
36
  };
29
37
 
38
+ /**
39
+ * Get the URL of a currently running Engram web server
40
+ * Returns null if no server is running
41
+ */
42
+ export function getRunningServerUrl(): string | null {
43
+ try {
44
+ if (fs.existsSync(PORT_FILE)) {
45
+ const data = JSON.parse(fs.readFileSync(PORT_FILE, "utf-8"));
46
+ const { port, pid } = data;
47
+
48
+ // Check if process is still running
49
+ try {
50
+ process.kill(pid, 0); // Signal 0 = check if process exists
51
+ return `http://localhost:${port}`;
52
+ } catch {
53
+ // Process not running, clean up stale file
54
+ fs.unlinkSync(PORT_FILE);
55
+ }
56
+ }
57
+ } catch {
58
+ // File doesn't exist or can't be read
59
+ }
60
+ return null;
61
+ }
62
+
30
63
  interface WebServerOptions {
31
64
  db: EngramDatabase;
32
65
  graph: KnowledgeGraph;
@@ -61,21 +94,92 @@ export class EngramWebServer {
61
94
  return `http://localhost:${this.port}`;
62
95
  }
63
96
 
97
+ // Check if another server is already running
98
+ const existingUrl = getRunningServerUrl();
99
+ if (existingUrl) {
100
+ console.error(`[Engram] Web interface already running at ${existingUrl}`);
101
+ return existingUrl;
102
+ }
103
+
64
104
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
65
105
 
66
- return new Promise((resolve, reject) => {
67
- this.server!.listen(this.port, () => {
68
- const url = `http://localhost:${this.port}`;
69
- console.error(`[Engram] Web interface running at ${url}`);
70
- resolve(url);
71
- });
106
+ // Try to start on preferred port, auto-increment if taken
107
+ const maxAttempts = 10;
108
+ let currentPort = this.port;
72
109
 
73
- this.server!.on("error", reject);
74
- });
110
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
111
+ try {
112
+ await new Promise<void>((resolve, reject) => {
113
+ this.server!.once("error", (err: NodeJS.ErrnoException) => {
114
+ if (err.code === "EADDRINUSE") {
115
+ currentPort++;
116
+ resolve(); // Try next port
117
+ } else {
118
+ reject(err);
119
+ }
120
+ });
121
+
122
+ this.server!.listen(currentPort, () => {
123
+ resolve();
124
+ });
125
+ });
126
+
127
+ // If we get here without error, server is listening
128
+ if (this.server!.listening) {
129
+ this.port = currentPort;
130
+ const url = `http://localhost:${this.port}`;
131
+
132
+ // Write port file for discovery
133
+ this.writePortFile();
134
+
135
+ console.error(`[Engram] Web interface running at ${url}`);
136
+ return url;
137
+ }
138
+ } catch (err) {
139
+ if (attempt === maxAttempts - 1) {
140
+ throw err;
141
+ }
142
+ }
143
+ }
144
+
145
+ throw new Error(`Could not find available port after ${maxAttempts} attempts`);
146
+ }
147
+
148
+ private writePortFile(): void {
149
+ try {
150
+ // Ensure directory exists
151
+ const dir = path.dirname(PORT_FILE);
152
+ if (!fs.existsSync(dir)) {
153
+ fs.mkdirSync(dir, { recursive: true });
154
+ }
155
+
156
+ fs.writeFileSync(PORT_FILE, JSON.stringify({
157
+ port: this.port,
158
+ pid: process.pid,
159
+ started: new Date().toISOString(),
160
+ }));
161
+ } catch (err) {
162
+ console.error("[Engram] Failed to write port file:", err);
163
+ }
164
+ }
165
+
166
+ private removePortFile(): void {
167
+ try {
168
+ if (fs.existsSync(PORT_FILE)) {
169
+ const data = JSON.parse(fs.readFileSync(PORT_FILE, "utf-8"));
170
+ // Only remove if we wrote it
171
+ if (data.pid === process.pid) {
172
+ fs.unlinkSync(PORT_FILE);
173
+ }
174
+ }
175
+ } catch {
176
+ // Ignore cleanup errors
177
+ }
75
178
  }
76
179
 
77
180
  stop(): void {
78
181
  if (this.server) {
182
+ this.removePortFile();
79
183
  this.server.close();
80
184
  this.server = null;
81
185
  }
@@ -134,6 +238,7 @@ export class EngramWebServer {
134
238
  if (pathname === "/api/memories" && method === "GET") {
135
239
  const query = url.searchParams.get("q");
136
240
  const limit = parseInt(url.searchParams.get("limit") || "50");
241
+ const offset = parseInt(url.searchParams.get("offset") || "0");
137
242
 
138
243
  if (query) {
139
244
  const results = await this.search.search(query, { limit });
@@ -145,7 +250,7 @@ export class EngramWebServer {
145
250
  })),
146
251
  }));
147
252
  } else {
148
- const memories = this.db.getAllMemories(limit);
253
+ const memories = this.db.getAllMemories(limit, false, offset);
149
254
  res.end(JSON.stringify({ memories }));
150
255
  }
151
256
  return;
@@ -275,13 +380,58 @@ export class EngramWebServer {
275
380
  return;
276
381
  }
277
382
 
383
+ // ============ Settings Endpoints ============
384
+
385
+ // GET /api/settings - get current settings (without exposing full API key)
386
+ if (pathname === "/api/settings" && method === "GET") {
387
+ const settings = loadSettings();
388
+ res.end(JSON.stringify({
389
+ has_api_key: hasAnthropicApiKey(),
390
+ api_key_preview: settings.anthropic_api_key
391
+ ? `${settings.anthropic_api_key.slice(0, 12)}...${settings.anthropic_api_key.slice(-4)}`
392
+ : null,
393
+ api_key_source: settings.anthropic_api_key
394
+ ? "settings"
395
+ : process.env.ANTHROPIC_API_KEY
396
+ ? "environment"
397
+ : null,
398
+ }));
399
+ return;
400
+ }
401
+
402
+ // POST /api/settings - update settings
403
+ if (pathname === "/api/settings" && method === "POST") {
404
+ const { anthropic_api_key } = body as { anthropic_api_key?: string };
405
+
406
+ if (anthropic_api_key !== undefined) {
407
+ const settings = loadSettings();
408
+ if (anthropic_api_key === "") {
409
+ // Clear the API key
410
+ delete settings.anthropic_api_key;
411
+ } else {
412
+ settings.anthropic_api_key = anthropic_api_key;
413
+ }
414
+ saveSettings(settings);
415
+
416
+ // Refresh the chat client
417
+ this.chat.refreshClient();
418
+ this.consolidator = new Consolidator(this.db); // Reinit consolidator
419
+ }
420
+
421
+ res.end(JSON.stringify({
422
+ success: true,
423
+ configured: this.chat.isConfigured(),
424
+ }));
425
+ return;
426
+ }
427
+
278
428
  // GET /api/chat/status - check if chat is configured
279
429
  if (pathname === "/api/chat/status" && method === "GET") {
280
430
  res.end(JSON.stringify({
281
431
  configured: this.chat.isConfigured(),
282
432
  message: this.chat.isConfigured()
283
433
  ? "Chat is ready"
284
- : "Set ANTHROPIC_API_KEY environment variable to enable chat",
434
+ : "Configure API key in Settings",
285
435
  }));
286
436
  return;
287
437
  }
@@ -307,6 +457,59 @@ export class EngramWebServer {
307
457
  return;
308
458
  }
309
459
 
460
+ // POST /api/chat/stream - streaming chat with SSE
461
+ if (pathname === "/api/chat/stream" && method === "POST") {
462
+ const { message } = body as { message: string };
463
+ if (!message) {
464
+ res.writeHead(400, { "Content-Type": "application/json" });
465
+ res.end(JSON.stringify({ error: "Message is required" }));
466
+ return;
467
+ }
468
+
469
+ // Check if chat is busy
470
+ if (this.chat.isBusy()) {
471
+ res.writeHead(429, { "Content-Type": "application/json" });
472
+ res.end(JSON.stringify({
473
+ error: "Chat is busy",
474
+ queue_length: this.chat.getQueueLength(),
475
+ }));
476
+ return;
477
+ }
478
+
479
+ // Set up SSE headers
480
+ res.writeHead(200, {
481
+ "Content-Type": "text/event-stream",
482
+ "Cache-Control": "no-cache",
483
+ "Connection": "keep-alive",
484
+ });
485
+
486
+ // Stream events to client
487
+ try {
488
+ for await (const event of this.chat.chatStream(message)) {
489
+ const data = JSON.stringify(event);
490
+ res.write(`data: ${data}\n\n`);
491
+ }
492
+ } catch (error) {
493
+ const errorEvent = {
494
+ type: "error",
495
+ content: error instanceof Error ? error.message : String(error),
496
+ };
497
+ res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
498
+ }
499
+
500
+ res.end();
501
+ return;
502
+ }
503
+
504
+ // GET /api/chat/queue - check queue status
505
+ if (pathname === "/api/chat/queue" && method === "GET") {
506
+ res.end(JSON.stringify({
507
+ busy: this.chat.isBusy(),
508
+ queue_length: this.chat.getQueueLength(),
509
+ }));
510
+ return;
511
+ }
512
+
310
513
  // ============ Consolidation Endpoints ============
311
514
 
312
515
  // GET /api/consolidation/status - get consolidation status