@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.
- package/dist/consolidation/consolidator.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/settings.d.ts.map +1 -0
- package/dist/storage/database.d.ts.map +1 -1
- package/dist/web/chat-handler.d.ts.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/consolidation/consolidator.ts +2 -1
- package/src/index.ts +30 -2
- package/src/settings.ts +73 -0
- package/src/storage/database.ts +4 -4
- package/src/web/chat-handler.ts +349 -4
- package/src/web/server.ts +213 -10
- package/src/web/static/app.js +301 -32
- package/src/web/static/index.html +39 -0
- package/src/web/static/style.css +385 -38
package/src/web/chat-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
432
|
-
return { success: true,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
: "
|
|
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
|