@199-bio/engram 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/dist/index.js +44 -1
- package/dist/web/server.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +48 -1
- package/src/web/server.ts +280 -0
- package/src/web/static/app.js +362 -0
- package/src/web/static/index.html +95 -0
- package/src/web/static/style.css +457 -0
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { EngramDatabase } from "./storage/database.js";
|
|
|
14
14
|
import { KnowledgeGraph } from "./graph/knowledge-graph.js";
|
|
15
15
|
import { createRetriever } from "./retrieval/colbert.js";
|
|
16
16
|
import { HybridSearch } from "./retrieval/hybrid.js";
|
|
17
|
+
import { EngramWebServer } from "./web/server.js";
|
|
17
18
|
// ============ Configuration ============
|
|
18
19
|
const DB_PATH = process.env.ENGRAM_DB_PATH
|
|
19
20
|
? path.resolve(process.env.ENGRAM_DB_PATH.replace("~", os.homedir()))
|
|
@@ -23,6 +24,7 @@ const DB_FILE = path.join(DB_PATH, "engram.db");
|
|
|
23
24
|
let db;
|
|
24
25
|
let graph;
|
|
25
26
|
let search;
|
|
27
|
+
let webServer = null;
|
|
26
28
|
async function initialize() {
|
|
27
29
|
console.error(`[Engram] Initializing with database at ${DB_FILE}`);
|
|
28
30
|
db = new EngramDatabase(DB_FILE);
|
|
@@ -40,7 +42,7 @@ async function initialize() {
|
|
|
40
42
|
// ============ MCP Server ============
|
|
41
43
|
const server = new Server({
|
|
42
44
|
name: "engram",
|
|
43
|
-
version: "0.
|
|
45
|
+
version: "0.3.0",
|
|
44
46
|
}, {
|
|
45
47
|
capabilities: {
|
|
46
48
|
tools: {},
|
|
@@ -280,6 +282,27 @@ const TOOLS = [
|
|
|
280
282
|
openWorldHint: false,
|
|
281
283
|
},
|
|
282
284
|
},
|
|
285
|
+
{
|
|
286
|
+
name: "engram_web",
|
|
287
|
+
description: "Launch the Engram web interface for browsing, searching, and editing memories visually. Returns a URL to open in your browser.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
port: {
|
|
292
|
+
type: "number",
|
|
293
|
+
description: "Port to run the web server on (default: 3847)",
|
|
294
|
+
default: 3847,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
annotations: {
|
|
299
|
+
title: "Launch Web Interface",
|
|
300
|
+
readOnlyHint: false,
|
|
301
|
+
destructiveHint: false,
|
|
302
|
+
idempotentHint: true,
|
|
303
|
+
openWorldHint: true,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
283
306
|
];
|
|
284
307
|
// List available tools
|
|
285
308
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -505,6 +528,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
505
528
|
],
|
|
506
529
|
};
|
|
507
530
|
}
|
|
531
|
+
case "engram_web": {
|
|
532
|
+
const { port = 3847 } = args;
|
|
533
|
+
// Create or reuse web server
|
|
534
|
+
if (!webServer) {
|
|
535
|
+
webServer = new EngramWebServer({ db, graph, search, port });
|
|
536
|
+
}
|
|
537
|
+
const url = await webServer.start();
|
|
538
|
+
return {
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: "text",
|
|
542
|
+
text: JSON.stringify({
|
|
543
|
+
success: true,
|
|
544
|
+
url,
|
|
545
|
+
message: `Web interface running at ${url}`,
|
|
546
|
+
}, null, 2),
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
508
551
|
default:
|
|
509
552
|
throw new Error(`Unknown tool: ${name}`);
|
|
510
553
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAMH,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAiBtD,UAAU,gBAAgB;IACxB,EAAE,EAAE,cAAc,CAAC;IACnB,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,EAAE,CAAiB;IAC3B,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,IAAI,CAAS;gBAET,OAAO,EAAE,gBAAgB;IAO/B,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAkB9B,IAAI,IAAI,IAAI;YAOE,aAAa;YA+Bb,SAAS;YAgIT,WAAW;IAgCzB,OAAO,CAAC,SAAS;CAclB"}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { EngramDatabase } from "./storage/database.js";
|
|
|
19
19
|
import { KnowledgeGraph } from "./graph/knowledge-graph.js";
|
|
20
20
|
import { createRetriever } from "./retrieval/colbert.js";
|
|
21
21
|
import { HybridSearch } from "./retrieval/hybrid.js";
|
|
22
|
+
import { EngramWebServer } from "./web/server.js";
|
|
22
23
|
|
|
23
24
|
// ============ Configuration ============
|
|
24
25
|
|
|
@@ -33,6 +34,7 @@ const DB_FILE = path.join(DB_PATH, "engram.db");
|
|
|
33
34
|
let db: EngramDatabase;
|
|
34
35
|
let graph: KnowledgeGraph;
|
|
35
36
|
let search: HybridSearch;
|
|
37
|
+
let webServer: EngramWebServer | null = null;
|
|
36
38
|
|
|
37
39
|
async function initialize(): Promise<void> {
|
|
38
40
|
console.error(`[Engram] Initializing with database at ${DB_FILE}`);
|
|
@@ -58,7 +60,7 @@ async function initialize(): Promise<void> {
|
|
|
58
60
|
const server = new Server(
|
|
59
61
|
{
|
|
60
62
|
name: "engram",
|
|
61
|
-
version: "0.
|
|
63
|
+
version: "0.3.0",
|
|
62
64
|
},
|
|
63
65
|
{
|
|
64
66
|
capabilities: {
|
|
@@ -303,6 +305,27 @@ const TOOLS = [
|
|
|
303
305
|
openWorldHint: false,
|
|
304
306
|
},
|
|
305
307
|
},
|
|
308
|
+
{
|
|
309
|
+
name: "engram_web",
|
|
310
|
+
description: "Launch the Engram web interface for browsing, searching, and editing memories visually. Returns a URL to open in your browser.",
|
|
311
|
+
inputSchema: {
|
|
312
|
+
type: "object" as const,
|
|
313
|
+
properties: {
|
|
314
|
+
port: {
|
|
315
|
+
type: "number",
|
|
316
|
+
description: "Port to run the web server on (default: 3847)",
|
|
317
|
+
default: 3847,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
annotations: {
|
|
322
|
+
title: "Launch Web Interface",
|
|
323
|
+
readOnlyHint: false,
|
|
324
|
+
destructiveHint: false,
|
|
325
|
+
idempotentHint: true,
|
|
326
|
+
openWorldHint: true,
|
|
327
|
+
},
|
|
328
|
+
},
|
|
306
329
|
];
|
|
307
330
|
|
|
308
331
|
// List available tools
|
|
@@ -587,6 +610,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
587
610
|
};
|
|
588
611
|
}
|
|
589
612
|
|
|
613
|
+
case "engram_web": {
|
|
614
|
+
const { port = 3847 } = args as { port?: number };
|
|
615
|
+
|
|
616
|
+
// Create or reuse web server
|
|
617
|
+
if (!webServer) {
|
|
618
|
+
webServer = new EngramWebServer({ db, graph, search, port });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const url = await webServer.start();
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
content: [
|
|
625
|
+
{
|
|
626
|
+
type: "text" as const,
|
|
627
|
+
text: JSON.stringify({
|
|
628
|
+
success: true,
|
|
629
|
+
url,
|
|
630
|
+
message: `Web interface running at ${url}`,
|
|
631
|
+
}, null, 2),
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
590
637
|
default:
|
|
591
638
|
throw new Error(`Unknown tool: ${name}`);
|
|
592
639
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engram Web Interface
|
|
3
|
+
* Local web server for browsing, searching, and editing memories
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from "http";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { EngramDatabase } from "../storage/database.js";
|
|
11
|
+
import { KnowledgeGraph } from "../graph/knowledge-graph.js";
|
|
12
|
+
import { HybridSearch } from "../retrieval/hybrid.js";
|
|
13
|
+
import { ColBERTRetriever, SimpleRetriever } from "../retrieval/colbert.js";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
|
|
18
|
+
const STATIC_DIR = path.join(__dirname, "..", "..", "src", "web", "static");
|
|
19
|
+
|
|
20
|
+
const MIME_TYPES: Record<string, string> = {
|
|
21
|
+
".html": "text/html",
|
|
22
|
+
".css": "text/css",
|
|
23
|
+
".js": "application/javascript",
|
|
24
|
+
".json": "application/json",
|
|
25
|
+
".png": "image/png",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface WebServerOptions {
|
|
30
|
+
db: EngramDatabase;
|
|
31
|
+
graph: KnowledgeGraph;
|
|
32
|
+
search: HybridSearch;
|
|
33
|
+
port?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class EngramWebServer {
|
|
37
|
+
private server: http.Server | null = null;
|
|
38
|
+
private db: EngramDatabase;
|
|
39
|
+
private graph: KnowledgeGraph;
|
|
40
|
+
private search: HybridSearch;
|
|
41
|
+
private port: number;
|
|
42
|
+
|
|
43
|
+
constructor(options: WebServerOptions) {
|
|
44
|
+
this.db = options.db;
|
|
45
|
+
this.graph = options.graph;
|
|
46
|
+
this.search = options.search;
|
|
47
|
+
this.port = options.port || 3847;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start(): Promise<string> {
|
|
51
|
+
if (this.server) {
|
|
52
|
+
return `http://localhost:${this.port}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
this.server!.listen(this.port, () => {
|
|
59
|
+
const url = `http://localhost:${this.port}`;
|
|
60
|
+
console.error(`[Engram] Web interface running at ${url}`);
|
|
61
|
+
resolve(url);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.server!.on("error", reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop(): void {
|
|
69
|
+
if (this.server) {
|
|
70
|
+
this.server.close();
|
|
71
|
+
this.server = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
76
|
+
const url = new URL(req.url || "/", `http://localhost:${this.port}`);
|
|
77
|
+
const pathname = url.pathname;
|
|
78
|
+
|
|
79
|
+
// CORS headers for local development
|
|
80
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
81
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
82
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
83
|
+
|
|
84
|
+
if (req.method === "OPTIONS") {
|
|
85
|
+
res.writeHead(204);
|
|
86
|
+
res.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// API routes
|
|
92
|
+
if (pathname.startsWith("/api/")) {
|
|
93
|
+
await this.handleAPI(req, res, pathname, url);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Static files
|
|
98
|
+
await this.serveStatic(req, res, pathname);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("[Engram Web] Error:", error);
|
|
101
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
102
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async handleAPI(
|
|
107
|
+
req: http.IncomingMessage,
|
|
108
|
+
res: http.ServerResponse,
|
|
109
|
+
pathname: string,
|
|
110
|
+
url: URL
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const method = req.method || "GET";
|
|
113
|
+
const body = method !== "GET" ? await this.parseBody(req) : null;
|
|
114
|
+
|
|
115
|
+
res.setHeader("Content-Type", "application/json");
|
|
116
|
+
|
|
117
|
+
// GET /api/stats
|
|
118
|
+
if (pathname === "/api/stats" && method === "GET") {
|
|
119
|
+
const stats = this.db.getStats();
|
|
120
|
+
res.end(JSON.stringify(stats));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// GET /api/memories
|
|
125
|
+
if (pathname === "/api/memories" && method === "GET") {
|
|
126
|
+
const query = url.searchParams.get("q");
|
|
127
|
+
const limit = parseInt(url.searchParams.get("limit") || "50");
|
|
128
|
+
|
|
129
|
+
if (query) {
|
|
130
|
+
const results = await this.search.search(query, { limit });
|
|
131
|
+
res.end(JSON.stringify({
|
|
132
|
+
memories: results.map(r => ({
|
|
133
|
+
...r.memory,
|
|
134
|
+
score: r.score,
|
|
135
|
+
sources: r.sources,
|
|
136
|
+
})),
|
|
137
|
+
}));
|
|
138
|
+
} else {
|
|
139
|
+
const memories = this.db.getAllMemories(limit);
|
|
140
|
+
res.end(JSON.stringify({ memories }));
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// POST /api/memories
|
|
146
|
+
if (pathname === "/api/memories" && method === "POST") {
|
|
147
|
+
const { content, source, importance } = body as any;
|
|
148
|
+
const memory = this.db.createMemory(content, source || "web", importance || 0.5);
|
|
149
|
+
await this.search.indexMemory(memory);
|
|
150
|
+
const { entities, observations } = this.graph.extractAndStore(content, memory.id);
|
|
151
|
+
res.writeHead(201);
|
|
152
|
+
res.end(JSON.stringify({ memory, entities_extracted: entities.length, observations_created: observations.length }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// PUT /api/memories/:id
|
|
157
|
+
const memoryMatch = pathname.match(/^\/api\/memories\/([a-f0-9-]+)$/);
|
|
158
|
+
if (memoryMatch && method === "PUT") {
|
|
159
|
+
const id = memoryMatch[1];
|
|
160
|
+
const { content, importance } = body as any;
|
|
161
|
+
const updated = this.db.updateMemory(id, { content, importance });
|
|
162
|
+
if (updated) {
|
|
163
|
+
res.end(JSON.stringify({ memory: updated }));
|
|
164
|
+
} else {
|
|
165
|
+
res.writeHead(404);
|
|
166
|
+
res.end(JSON.stringify({ error: "Memory not found" }));
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// DELETE /api/memories/:id
|
|
172
|
+
if (memoryMatch && method === "DELETE") {
|
|
173
|
+
const id = memoryMatch[1];
|
|
174
|
+
await this.search.removeFromIndex(id);
|
|
175
|
+
const deleted = this.db.deleteMemory(id);
|
|
176
|
+
res.end(JSON.stringify({ success: deleted }));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// GET /api/entities
|
|
181
|
+
if (pathname === "/api/entities" && method === "GET") {
|
|
182
|
+
const type = url.searchParams.get("type") as any;
|
|
183
|
+
const limit = parseInt(url.searchParams.get("limit") || "100");
|
|
184
|
+
const entities = this.graph.listEntities(type || undefined, limit);
|
|
185
|
+
res.end(JSON.stringify({ entities }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// GET /api/entities/:name
|
|
190
|
+
const entityMatch = pathname.match(/^\/api\/entities\/(.+)$/);
|
|
191
|
+
if (entityMatch && method === "GET") {
|
|
192
|
+
const name = decodeURIComponent(entityMatch[1]);
|
|
193
|
+
const details = this.graph.getEntityDetails(name);
|
|
194
|
+
if (details) {
|
|
195
|
+
res.end(JSON.stringify(details));
|
|
196
|
+
} else {
|
|
197
|
+
res.writeHead(404);
|
|
198
|
+
res.end(JSON.stringify({ error: "Entity not found" }));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// GET /api/graph
|
|
204
|
+
if (pathname === "/api/graph" && method === "GET") {
|
|
205
|
+
const entities = this.graph.listEntities(undefined, 500);
|
|
206
|
+
const nodes = entities.map(e => ({
|
|
207
|
+
id: e.id,
|
|
208
|
+
label: e.name,
|
|
209
|
+
type: e.type,
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
// Get all relations
|
|
213
|
+
const edges: Array<{ from: string; to: string; label: string }> = [];
|
|
214
|
+
for (const entity of entities) {
|
|
215
|
+
const relations = this.db.getEntityRelations(entity.id, "from");
|
|
216
|
+
for (const rel of relations) {
|
|
217
|
+
edges.push({
|
|
218
|
+
from: rel.from_entity,
|
|
219
|
+
to: rel.to_entity,
|
|
220
|
+
label: rel.type,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
res.end(JSON.stringify({ nodes, edges }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 404 for unknown API routes
|
|
230
|
+
res.writeHead(404);
|
|
231
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async serveStatic(
|
|
235
|
+
req: http.IncomingMessage,
|
|
236
|
+
res: http.ServerResponse,
|
|
237
|
+
pathname: string
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
// Default to index.html
|
|
240
|
+
if (pathname === "/" || pathname === "") {
|
|
241
|
+
pathname = "/index.html";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const filePath = path.join(STATIC_DIR, pathname);
|
|
245
|
+
|
|
246
|
+
// Security: prevent directory traversal
|
|
247
|
+
if (!filePath.startsWith(STATIC_DIR)) {
|
|
248
|
+
res.writeHead(403);
|
|
249
|
+
res.end("Forbidden");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const content = fs.readFileSync(filePath);
|
|
255
|
+
const ext = path.extname(filePath);
|
|
256
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
257
|
+
|
|
258
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
259
|
+
res.end(content);
|
|
260
|
+
} catch {
|
|
261
|
+
res.writeHead(404);
|
|
262
|
+
res.end("Not found");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private parseBody(req: http.IncomingMessage): Promise<unknown> {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
let data = "";
|
|
269
|
+
req.on("data", (chunk) => (data += chunk));
|
|
270
|
+
req.on("end", () => {
|
|
271
|
+
try {
|
|
272
|
+
resolve(data ? JSON.parse(data) : {});
|
|
273
|
+
} catch (e) {
|
|
274
|
+
reject(e);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
req.on("error", reject);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engram Web Interface
|
|
3
|
+
* Vanilla JavaScript - no build step required
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const API_BASE = '';
|
|
7
|
+
|
|
8
|
+
// State
|
|
9
|
+
let currentView = 'memories';
|
|
10
|
+
let editingMemoryId = null;
|
|
11
|
+
|
|
12
|
+
// DOM Elements
|
|
13
|
+
const views = {
|
|
14
|
+
memories: document.getElementById('memories-view'),
|
|
15
|
+
entities: document.getElementById('entities-view'),
|
|
16
|
+
graph: document.getElementById('graph-view'),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const statsEl = document.getElementById('stats');
|
|
20
|
+
const memoriesList = document.getElementById('memories-list');
|
|
21
|
+
const entitiesList = document.getElementById('entities-list');
|
|
22
|
+
const graphContainer = document.getElementById('graph-container');
|
|
23
|
+
const searchInput = document.getElementById('search-input');
|
|
24
|
+
const entityTypeFilter = document.getElementById('entity-type-filter');
|
|
25
|
+
|
|
26
|
+
// Modal elements
|
|
27
|
+
const modal = document.getElementById('modal');
|
|
28
|
+
const modalTitle = document.getElementById('modal-title');
|
|
29
|
+
const modalForm = document.getElementById('modal-form');
|
|
30
|
+
const modalContentInput = document.getElementById('modal-content-input');
|
|
31
|
+
const modalSource = document.getElementById('modal-source');
|
|
32
|
+
const modalImportance = document.getElementById('modal-importance');
|
|
33
|
+
const importanceValue = document.getElementById('importance-value');
|
|
34
|
+
|
|
35
|
+
const entityModal = document.getElementById('entity-modal');
|
|
36
|
+
const entityModalTitle = document.getElementById('entity-modal-title');
|
|
37
|
+
const entityModalBody = document.getElementById('entity-modal-body');
|
|
38
|
+
|
|
39
|
+
// API helpers
|
|
40
|
+
async function api(path, options = {}) {
|
|
41
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
...options,
|
|
44
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
45
|
+
});
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Format date
|
|
50
|
+
function formatDate(dateStr) {
|
|
51
|
+
const date = new Date(dateStr);
|
|
52
|
+
return date.toLocaleDateString('en-GB', {
|
|
53
|
+
day: 'numeric',
|
|
54
|
+
month: 'short',
|
|
55
|
+
year: 'numeric',
|
|
56
|
+
hour: '2-digit',
|
|
57
|
+
minute: '2-digit',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Load stats
|
|
62
|
+
async function loadStats() {
|
|
63
|
+
const stats = await api('/api/stats');
|
|
64
|
+
statsEl.textContent = `${stats.memories} memories \u00b7 ${stats.entities} entities \u00b7 ${stats.relations} relations`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Load memories
|
|
68
|
+
async function loadMemories(query = '') {
|
|
69
|
+
const path = query ? `/api/memories?q=${encodeURIComponent(query)}` : '/api/memories';
|
|
70
|
+
const data = await api(path);
|
|
71
|
+
|
|
72
|
+
if (data.memories.length === 0) {
|
|
73
|
+
memoriesList.innerHTML = '<div class="empty-state">No memories found</div>';
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
memoriesList.innerHTML = data.memories.map(m => `
|
|
78
|
+
<div class="list-item memory-item" data-id="${m.id}">
|
|
79
|
+
<div class="content">${escapeHtml(m.content)}</div>
|
|
80
|
+
<div class="meta">
|
|
81
|
+
<span>${formatDate(m.timestamp)}</span>
|
|
82
|
+
<span>${m.source}</span>
|
|
83
|
+
<span>importance: ${m.importance}</span>
|
|
84
|
+
${m.score ? `<span class="score">${m.score.toFixed(4)}</span>` : ''}
|
|
85
|
+
</div>
|
|
86
|
+
<div class="actions">
|
|
87
|
+
<button class="edit-btn" data-id="${m.id}">Edit</button>
|
|
88
|
+
<button class="delete-btn" data-id="${m.id}">Delete</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`).join('');
|
|
92
|
+
|
|
93
|
+
// Attach event listeners
|
|
94
|
+
memoriesList.querySelectorAll('.edit-btn').forEach(btn => {
|
|
95
|
+
btn.addEventListener('click', (e) => {
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
editMemory(btn.dataset.id);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
memoriesList.querySelectorAll('.delete-btn').forEach(btn => {
|
|
102
|
+
btn.addEventListener('click', (e) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
deleteMemory(btn.dataset.id);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Load entities
|
|
110
|
+
async function loadEntities(type = '') {
|
|
111
|
+
const path = type ? `/api/entities?type=${type}` : '/api/entities';
|
|
112
|
+
const data = await api(path);
|
|
113
|
+
|
|
114
|
+
if (data.entities.length === 0) {
|
|
115
|
+
entitiesList.innerHTML = '<div class="empty-state">No entities found</div>';
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
entitiesList.innerHTML = data.entities.map(e => `
|
|
120
|
+
<div class="list-item entity-item" data-name="${escapeHtml(e.name)}">
|
|
121
|
+
<div class="name">${escapeHtml(e.name)}</div>
|
|
122
|
+
<div class="type">${e.type}</div>
|
|
123
|
+
</div>
|
|
124
|
+
`).join('');
|
|
125
|
+
|
|
126
|
+
// Attach event listeners
|
|
127
|
+
entitiesList.querySelectorAll('.entity-item').forEach(item => {
|
|
128
|
+
item.addEventListener('click', () => {
|
|
129
|
+
showEntityDetails(item.dataset.name);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Show entity details
|
|
135
|
+
async function showEntityDetails(name) {
|
|
136
|
+
const data = await api(`/api/entities/${encodeURIComponent(name)}`);
|
|
137
|
+
|
|
138
|
+
entityModalTitle.textContent = data.name;
|
|
139
|
+
|
|
140
|
+
let html = `<p><strong>Type:</strong> ${data.type}</p>`;
|
|
141
|
+
|
|
142
|
+
if (data.observations && data.observations.length > 0) {
|
|
143
|
+
html += `<h3>Observations</h3><ul>`;
|
|
144
|
+
data.observations.forEach(o => {
|
|
145
|
+
html += `<li>${escapeHtml(o.content)}</li>`;
|
|
146
|
+
});
|
|
147
|
+
html += `</ul>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (data.relationsFrom && data.relationsFrom.length > 0) {
|
|
151
|
+
html += `<h3>Relationships (outgoing)</h3><ul>`;
|
|
152
|
+
data.relationsFrom.forEach(r => {
|
|
153
|
+
html += `<li>${r.type} \u2192 ${escapeHtml(r.targetEntity?.name || r.to)}</li>`;
|
|
154
|
+
});
|
|
155
|
+
html += `</ul>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (data.relationsTo && data.relationsTo.length > 0) {
|
|
159
|
+
html += `<h3>Relationships (incoming)</h3><ul>`;
|
|
160
|
+
data.relationsTo.forEach(r => {
|
|
161
|
+
html += `<li>${escapeHtml(r.sourceEntity?.name || r.from)} \u2192 ${r.type}</li>`;
|
|
162
|
+
});
|
|
163
|
+
html += `</ul>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
entityModalBody.innerHTML = html;
|
|
167
|
+
entityModal.classList.remove('hidden');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Load graph
|
|
171
|
+
async function loadGraph() {
|
|
172
|
+
const data = await api('/api/graph');
|
|
173
|
+
|
|
174
|
+
// Simple visualization using CSS
|
|
175
|
+
if (data.nodes.length === 0) {
|
|
176
|
+
graphContainer.innerHTML = '<div class="empty-state">No entities in graph</div>';
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Create a simple text-based visualization
|
|
181
|
+
let html = '<div style="padding: 2rem; font-size: 0.875rem;">';
|
|
182
|
+
html += '<p style="margin-bottom: 1rem; color: var(--text-muted);">Knowledge graph visualization. Click entities to see details.</p>';
|
|
183
|
+
|
|
184
|
+
// Group by type
|
|
185
|
+
const byType = {};
|
|
186
|
+
data.nodes.forEach(n => {
|
|
187
|
+
if (!byType[n.type]) byType[n.type] = [];
|
|
188
|
+
byType[n.type].push(n);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (const [type, nodes] of Object.entries(byType)) {
|
|
192
|
+
html += `<div style="margin-bottom: 1.5rem;">`;
|
|
193
|
+
html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">${type}</h3>`;
|
|
194
|
+
html += `<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">`;
|
|
195
|
+
nodes.forEach(n => {
|
|
196
|
+
html += `<span class="graph-node" data-name="${escapeHtml(n.label)}" style="padding: 0.375rem 0.75rem; background: var(--bg-tertiary); cursor: pointer;">${escapeHtml(n.label)}</span>`;
|
|
197
|
+
});
|
|
198
|
+
html += `</div></div>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (data.edges.length > 0) {
|
|
202
|
+
html += `<div style="margin-top: 2rem;">`;
|
|
203
|
+
html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">Relationships</h3>`;
|
|
204
|
+
html += `<ul style="list-style: none;">`;
|
|
205
|
+
data.edges.forEach(e => {
|
|
206
|
+
const fromNode = data.nodes.find(n => n.id === e.from);
|
|
207
|
+
const toNode = data.nodes.find(n => n.id === e.to);
|
|
208
|
+
if (fromNode && toNode) {
|
|
209
|
+
html += `<li style="padding: 0.25rem 0; color: var(--text-secondary);">${escapeHtml(fromNode.label)} <span style="color: var(--accent);">\u2192 ${e.label} \u2192</span> ${escapeHtml(toNode.label)}</li>`;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
html += `</ul></div>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
html += '</div>';
|
|
216
|
+
graphContainer.innerHTML = html;
|
|
217
|
+
|
|
218
|
+
// Attach click handlers
|
|
219
|
+
graphContainer.querySelectorAll('.graph-node').forEach(node => {
|
|
220
|
+
node.addEventListener('click', () => {
|
|
221
|
+
showEntityDetails(node.dataset.name);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Edit memory
|
|
227
|
+
async function editMemory(id) {
|
|
228
|
+
const data = await api('/api/memories');
|
|
229
|
+
const memory = data.memories.find(m => m.id === id);
|
|
230
|
+
if (!memory) return;
|
|
231
|
+
|
|
232
|
+
editingMemoryId = id;
|
|
233
|
+
modalTitle.textContent = 'Edit Memory';
|
|
234
|
+
modalContentInput.value = memory.content;
|
|
235
|
+
modalSource.value = memory.source;
|
|
236
|
+
modalImportance.value = memory.importance;
|
|
237
|
+
importanceValue.textContent = memory.importance;
|
|
238
|
+
modal.classList.remove('hidden');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Delete memory
|
|
242
|
+
async function deleteMemory(id) {
|
|
243
|
+
if (!confirm('Delete this memory?')) return;
|
|
244
|
+
|
|
245
|
+
await api(`/api/memories/${id}`, { method: 'DELETE' });
|
|
246
|
+
await loadMemories(searchInput.value);
|
|
247
|
+
await loadStats();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Save memory
|
|
251
|
+
async function saveMemory() {
|
|
252
|
+
const content = modalContentInput.value.trim();
|
|
253
|
+
if (!content) return;
|
|
254
|
+
|
|
255
|
+
const body = {
|
|
256
|
+
content,
|
|
257
|
+
source: modalSource.value || 'web',
|
|
258
|
+
importance: parseFloat(modalImportance.value),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (editingMemoryId) {
|
|
262
|
+
await api(`/api/memories/${editingMemoryId}`, { method: 'PUT', body });
|
|
263
|
+
} else {
|
|
264
|
+
await api('/api/memories', { method: 'POST', body });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
closeModal();
|
|
268
|
+
await loadMemories(searchInput.value);
|
|
269
|
+
await loadStats();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Close modal
|
|
273
|
+
function closeModal() {
|
|
274
|
+
modal.classList.add('hidden');
|
|
275
|
+
editingMemoryId = null;
|
|
276
|
+
modalContentInput.value = '';
|
|
277
|
+
modalSource.value = 'web';
|
|
278
|
+
modalImportance.value = '0.5';
|
|
279
|
+
importanceValue.textContent = '0.5';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Escape HTML
|
|
283
|
+
function escapeHtml(str) {
|
|
284
|
+
if (!str) return '';
|
|
285
|
+
return str
|
|
286
|
+
.replace(/&/g, '&')
|
|
287
|
+
.replace(/</g, '<')
|
|
288
|
+
.replace(/>/g, '>')
|
|
289
|
+
.replace(/"/g, '"')
|
|
290
|
+
.replace(/'/g, ''');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Switch view
|
|
294
|
+
function switchView(view) {
|
|
295
|
+
currentView = view;
|
|
296
|
+
|
|
297
|
+
// Update nav buttons
|
|
298
|
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
299
|
+
btn.classList.toggle('active', btn.dataset.view === view);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Update views
|
|
303
|
+
Object.entries(views).forEach(([name, el]) => {
|
|
304
|
+
el.classList.toggle('active', name === view);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Load data for view
|
|
308
|
+
if (view === 'memories') loadMemories(searchInput.value);
|
|
309
|
+
if (view === 'entities') loadEntities(entityTypeFilter.value);
|
|
310
|
+
if (view === 'graph') loadGraph();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Event listeners
|
|
314
|
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
315
|
+
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
document.getElementById('search-btn').addEventListener('click', () => {
|
|
319
|
+
loadMemories(searchInput.value);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
searchInput.addEventListener('keypress', (e) => {
|
|
323
|
+
if (e.key === 'Enter') loadMemories(searchInput.value);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
entityTypeFilter.addEventListener('change', () => {
|
|
327
|
+
loadEntities(entityTypeFilter.value);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
document.getElementById('add-memory-btn').addEventListener('click', () => {
|
|
331
|
+
editingMemoryId = null;
|
|
332
|
+
modalTitle.textContent = 'Add Memory';
|
|
333
|
+
modal.classList.remove('hidden');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
document.getElementById('modal-cancel').addEventListener('click', closeModal);
|
|
337
|
+
|
|
338
|
+
modalForm.addEventListener('submit', (e) => {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
saveMemory();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
modalImportance.addEventListener('input', () => {
|
|
344
|
+
importanceValue.textContent = modalImportance.value;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
document.getElementById('entity-modal-close').addEventListener('click', () => {
|
|
348
|
+
entityModal.classList.add('hidden');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Close modals on backdrop click
|
|
352
|
+
modal.addEventListener('click', (e) => {
|
|
353
|
+
if (e.target === modal) closeModal();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
entityModal.addEventListener('click', (e) => {
|
|
357
|
+
if (e.target === entityModal) entityModal.classList.add('hidden');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Initialize
|
|
361
|
+
loadStats();
|
|
362
|
+
loadMemories();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Engram</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<h1>Engram</h1>
|
|
12
|
+
<nav>
|
|
13
|
+
<button class="nav-btn active" data-view="memories">Memories</button>
|
|
14
|
+
<button class="nav-btn" data-view="entities">Entities</button>
|
|
15
|
+
<button class="nav-btn" data-view="graph">Graph</button>
|
|
16
|
+
</nav>
|
|
17
|
+
<div class="stats" id="stats"></div>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<main>
|
|
21
|
+
<!-- Memories View -->
|
|
22
|
+
<section id="memories-view" class="view active">
|
|
23
|
+
<div class="search-bar">
|
|
24
|
+
<input type="text" id="search-input" placeholder="Search memories...">
|
|
25
|
+
<button id="search-btn">Search</button>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="toolbar">
|
|
29
|
+
<button id="add-memory-btn">+ Add Memory</button>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div id="memories-list" class="list"></div>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<!-- Entities View -->
|
|
36
|
+
<section id="entities-view" class="view">
|
|
37
|
+
<div class="filter-bar">
|
|
38
|
+
<select id="entity-type-filter">
|
|
39
|
+
<option value="">All Types</option>
|
|
40
|
+
<option value="person">Person</option>
|
|
41
|
+
<option value="organization">Organization</option>
|
|
42
|
+
<option value="place">Place</option>
|
|
43
|
+
<option value="concept">Concept</option>
|
|
44
|
+
<option value="event">Event</option>
|
|
45
|
+
</select>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div id="entities-list" class="list"></div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<!-- Graph View -->
|
|
52
|
+
<section id="graph-view" class="view">
|
|
53
|
+
<div id="graph-container"></div>
|
|
54
|
+
</section>
|
|
55
|
+
</main>
|
|
56
|
+
|
|
57
|
+
<!-- Modal for adding/editing -->
|
|
58
|
+
<div id="modal" class="modal hidden">
|
|
59
|
+
<div class="modal-content">
|
|
60
|
+
<h2 id="modal-title">Add Memory</h2>
|
|
61
|
+
<form id="modal-form">
|
|
62
|
+
<textarea id="modal-content-input" placeholder="Memory content..." rows="6"></textarea>
|
|
63
|
+
<div class="form-row">
|
|
64
|
+
<label>
|
|
65
|
+
Source:
|
|
66
|
+
<input type="text" id="modal-source" value="web">
|
|
67
|
+
</label>
|
|
68
|
+
<label>
|
|
69
|
+
Importance:
|
|
70
|
+
<input type="range" id="modal-importance" min="0" max="1" step="0.1" value="0.5">
|
|
71
|
+
<span id="importance-value">0.5</span>
|
|
72
|
+
</label>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="modal-actions">
|
|
75
|
+
<button type="button" id="modal-cancel">Cancel</button>
|
|
76
|
+
<button type="submit">Save</button>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Entity Detail Modal -->
|
|
83
|
+
<div id="entity-modal" class="modal hidden">
|
|
84
|
+
<div class="modal-content">
|
|
85
|
+
<h2 id="entity-modal-title"></h2>
|
|
86
|
+
<div id="entity-modal-body"></div>
|
|
87
|
+
<div class="modal-actions">
|
|
88
|
+
<button type="button" id="entity-modal-close">Close</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script src="app.js"></script>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/* Engram Web Interface
|
|
2
|
+
* Claude Desktop aesthetic: serif, cream, sharp corners, generous spacing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
/* Cream color scheme */
|
|
7
|
+
--bg-primary: #faf8f5;
|
|
8
|
+
--bg-secondary: #f5f2ed;
|
|
9
|
+
--bg-tertiary: #ebe7e0;
|
|
10
|
+
--text-primary: #1a1a1a;
|
|
11
|
+
--text-secondary: #5c5c5c;
|
|
12
|
+
--text-muted: #8c8c8c;
|
|
13
|
+
--border: #d4d0c8;
|
|
14
|
+
--accent: #d97706;
|
|
15
|
+
--accent-hover: #b45309;
|
|
16
|
+
--success: #059669;
|
|
17
|
+
--danger: #dc2626;
|
|
18
|
+
--shadow: rgba(0, 0, 0, 0.06);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
html {
|
|
28
|
+
font-size: 16px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
body {
|
|
32
|
+
font-family: "Tiempos Text", "Times New Roman", Georgia, serif;
|
|
33
|
+
background: var(--bg-primary);
|
|
34
|
+
color: var(--text-primary);
|
|
35
|
+
line-height: 1.6;
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Typography */
|
|
40
|
+
h1, h2, h3, h4 {
|
|
41
|
+
font-weight: 500;
|
|
42
|
+
letter-spacing: -0.02em;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
h1 {
|
|
46
|
+
font-size: 1.75rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
h2 {
|
|
50
|
+
font-size: 1.25rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Header */
|
|
54
|
+
header {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 2rem;
|
|
58
|
+
padding: 1.5rem 2rem;
|
|
59
|
+
background: var(--bg-secondary);
|
|
60
|
+
border-bottom: 1px solid var(--border);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
header h1 {
|
|
64
|
+
color: var(--text-primary);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
nav {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 0.5rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.nav-btn {
|
|
73
|
+
font-family: inherit;
|
|
74
|
+
font-size: 0.875rem;
|
|
75
|
+
padding: 0.5rem 1rem;
|
|
76
|
+
background: transparent;
|
|
77
|
+
border: 1px solid transparent;
|
|
78
|
+
color: var(--text-secondary);
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
transition: all 0.15s ease;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.nav-btn:hover {
|
|
84
|
+
color: var(--text-primary);
|
|
85
|
+
background: var(--bg-tertiary);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.nav-btn.active {
|
|
89
|
+
color: var(--text-primary);
|
|
90
|
+
border-color: var(--border);
|
|
91
|
+
background: var(--bg-primary);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.stats {
|
|
95
|
+
margin-left: auto;
|
|
96
|
+
font-size: 0.8125rem;
|
|
97
|
+
color: var(--text-muted);
|
|
98
|
+
font-variant-numeric: tabular-nums;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Main content */
|
|
102
|
+
main {
|
|
103
|
+
max-width: 960px;
|
|
104
|
+
margin: 0 auto;
|
|
105
|
+
padding: 2rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.view {
|
|
109
|
+
display: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.view.active {
|
|
113
|
+
display: block;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Search bar */
|
|
117
|
+
.search-bar {
|
|
118
|
+
display: flex;
|
|
119
|
+
gap: 0.75rem;
|
|
120
|
+
margin-bottom: 1.5rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.search-bar input {
|
|
124
|
+
flex: 1;
|
|
125
|
+
font-family: inherit;
|
|
126
|
+
font-size: 1rem;
|
|
127
|
+
padding: 0.75rem 1rem;
|
|
128
|
+
border: 1px solid var(--border);
|
|
129
|
+
background: var(--bg-secondary);
|
|
130
|
+
color: var(--text-primary);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.search-bar input:focus {
|
|
134
|
+
outline: none;
|
|
135
|
+
border-color: var(--accent);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.search-bar input::placeholder {
|
|
139
|
+
color: var(--text-muted);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Buttons */
|
|
143
|
+
button {
|
|
144
|
+
font-family: inherit;
|
|
145
|
+
font-size: 0.875rem;
|
|
146
|
+
padding: 0.625rem 1.25rem;
|
|
147
|
+
background: var(--text-primary);
|
|
148
|
+
color: var(--bg-primary);
|
|
149
|
+
border: none;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
transition: background 0.15s ease;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
button:hover {
|
|
155
|
+
background: var(--text-secondary);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
button:disabled {
|
|
159
|
+
opacity: 0.5;
|
|
160
|
+
cursor: not-allowed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.toolbar {
|
|
164
|
+
display: flex;
|
|
165
|
+
gap: 0.75rem;
|
|
166
|
+
margin-bottom: 1.5rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#add-memory-btn {
|
|
170
|
+
background: var(--accent);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#add-memory-btn:hover {
|
|
174
|
+
background: var(--accent-hover);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Filter bar */
|
|
178
|
+
.filter-bar {
|
|
179
|
+
margin-bottom: 1.5rem;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.filter-bar select {
|
|
183
|
+
font-family: inherit;
|
|
184
|
+
font-size: 0.875rem;
|
|
185
|
+
padding: 0.625rem 1rem;
|
|
186
|
+
border: 1px solid var(--border);
|
|
187
|
+
background: var(--bg-secondary);
|
|
188
|
+
color: var(--text-primary);
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.filter-bar select:focus {
|
|
193
|
+
outline: none;
|
|
194
|
+
border-color: var(--accent);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* List items */
|
|
198
|
+
.list {
|
|
199
|
+
display: flex;
|
|
200
|
+
flex-direction: column;
|
|
201
|
+
gap: 1rem;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.list-item {
|
|
205
|
+
background: var(--bg-secondary);
|
|
206
|
+
border: 1px solid var(--border);
|
|
207
|
+
padding: 1.25rem 1.5rem;
|
|
208
|
+
transition: border-color 0.15s ease;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.list-item:hover {
|
|
212
|
+
border-color: var(--text-muted);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.memory-item .content {
|
|
216
|
+
font-size: 1rem;
|
|
217
|
+
line-height: 1.7;
|
|
218
|
+
margin-bottom: 1rem;
|
|
219
|
+
white-space: pre-wrap;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.memory-item .meta {
|
|
223
|
+
display: flex;
|
|
224
|
+
gap: 1.5rem;
|
|
225
|
+
font-size: 0.8125rem;
|
|
226
|
+
color: var(--text-muted);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.memory-item .meta span {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
gap: 0.375rem;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.memory-item .actions {
|
|
236
|
+
display: flex;
|
|
237
|
+
gap: 0.5rem;
|
|
238
|
+
margin-top: 1rem;
|
|
239
|
+
padding-top: 1rem;
|
|
240
|
+
border-top: 1px solid var(--border);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.memory-item .actions button {
|
|
244
|
+
font-size: 0.75rem;
|
|
245
|
+
padding: 0.375rem 0.75rem;
|
|
246
|
+
background: var(--bg-tertiary);
|
|
247
|
+
color: var(--text-secondary);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.memory-item .actions button:hover {
|
|
251
|
+
background: var(--border);
|
|
252
|
+
color: var(--text-primary);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.memory-item .actions .delete-btn:hover {
|
|
256
|
+
background: var(--danger);
|
|
257
|
+
color: white;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Entity items */
|
|
261
|
+
.entity-item {
|
|
262
|
+
cursor: pointer;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.entity-item .name {
|
|
266
|
+
font-size: 1rem;
|
|
267
|
+
font-weight: 500;
|
|
268
|
+
margin-bottom: 0.375rem;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.entity-item .type {
|
|
272
|
+
font-size: 0.8125rem;
|
|
273
|
+
color: var(--text-muted);
|
|
274
|
+
text-transform: capitalize;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* Graph container */
|
|
278
|
+
#graph-container {
|
|
279
|
+
width: 100%;
|
|
280
|
+
height: 600px;
|
|
281
|
+
background: var(--bg-secondary);
|
|
282
|
+
border: 1px solid var(--border);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* Modal */
|
|
286
|
+
.modal {
|
|
287
|
+
position: fixed;
|
|
288
|
+
inset: 0;
|
|
289
|
+
background: rgba(0, 0, 0, 0.4);
|
|
290
|
+
display: flex;
|
|
291
|
+
align-items: center;
|
|
292
|
+
justify-content: center;
|
|
293
|
+
padding: 2rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.modal.hidden {
|
|
297
|
+
display: none;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.modal-content {
|
|
301
|
+
background: var(--bg-primary);
|
|
302
|
+
border: 1px solid var(--border);
|
|
303
|
+
padding: 2rem;
|
|
304
|
+
width: 100%;
|
|
305
|
+
max-width: 600px;
|
|
306
|
+
max-height: 90vh;
|
|
307
|
+
overflow-y: auto;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.modal-content h2 {
|
|
311
|
+
margin-bottom: 1.5rem;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.modal-content textarea,
|
|
315
|
+
.modal-content input[type="text"] {
|
|
316
|
+
width: 100%;
|
|
317
|
+
font-family: inherit;
|
|
318
|
+
font-size: 1rem;
|
|
319
|
+
padding: 0.75rem 1rem;
|
|
320
|
+
border: 1px solid var(--border);
|
|
321
|
+
background: var(--bg-secondary);
|
|
322
|
+
color: var(--text-primary);
|
|
323
|
+
resize: vertical;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.modal-content textarea:focus,
|
|
327
|
+
.modal-content input:focus {
|
|
328
|
+
outline: none;
|
|
329
|
+
border-color: var(--accent);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.form-row {
|
|
333
|
+
display: flex;
|
|
334
|
+
gap: 1.5rem;
|
|
335
|
+
margin-top: 1rem;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.form-row label {
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
gap: 0.5rem;
|
|
342
|
+
font-size: 0.875rem;
|
|
343
|
+
color: var(--text-secondary);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.form-row input[type="text"] {
|
|
347
|
+
width: 120px;
|
|
348
|
+
padding: 0.5rem;
|
|
349
|
+
font-size: 0.875rem;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.form-row input[type="range"] {
|
|
353
|
+
width: 100px;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#importance-value {
|
|
357
|
+
font-variant-numeric: tabular-nums;
|
|
358
|
+
min-width: 2rem;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.modal-actions {
|
|
362
|
+
display: flex;
|
|
363
|
+
justify-content: flex-end;
|
|
364
|
+
gap: 0.75rem;
|
|
365
|
+
margin-top: 1.5rem;
|
|
366
|
+
padding-top: 1.5rem;
|
|
367
|
+
border-top: 1px solid var(--border);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#modal-cancel,
|
|
371
|
+
#entity-modal-close {
|
|
372
|
+
background: var(--bg-tertiary);
|
|
373
|
+
color: var(--text-secondary);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#modal-cancel:hover,
|
|
377
|
+
#entity-modal-close:hover {
|
|
378
|
+
background: var(--border);
|
|
379
|
+
color: var(--text-primary);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* Entity detail modal */
|
|
383
|
+
#entity-modal-body {
|
|
384
|
+
font-size: 0.9375rem;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#entity-modal-body h3 {
|
|
388
|
+
font-size: 0.875rem;
|
|
389
|
+
color: var(--text-muted);
|
|
390
|
+
text-transform: uppercase;
|
|
391
|
+
letter-spacing: 0.05em;
|
|
392
|
+
margin: 1.5rem 0 0.75rem;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#entity-modal-body h3:first-child {
|
|
396
|
+
margin-top: 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#entity-modal-body ul {
|
|
400
|
+
list-style: none;
|
|
401
|
+
padding: 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#entity-modal-body li {
|
|
405
|
+
padding: 0.5rem 0;
|
|
406
|
+
border-bottom: 1px solid var(--border);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#entity-modal-body li:last-child {
|
|
410
|
+
border-bottom: none;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* Empty states */
|
|
414
|
+
.empty-state {
|
|
415
|
+
text-align: center;
|
|
416
|
+
padding: 3rem 2rem;
|
|
417
|
+
color: var(--text-muted);
|
|
418
|
+
font-style: italic;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* Score badge */
|
|
422
|
+
.score {
|
|
423
|
+
display: inline-block;
|
|
424
|
+
font-size: 0.6875rem;
|
|
425
|
+
padding: 0.125rem 0.375rem;
|
|
426
|
+
background: var(--bg-tertiary);
|
|
427
|
+
color: var(--text-muted);
|
|
428
|
+
font-variant-numeric: tabular-nums;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* Responsive */
|
|
432
|
+
@media (max-width: 640px) {
|
|
433
|
+
header {
|
|
434
|
+
flex-wrap: wrap;
|
|
435
|
+
gap: 1rem;
|
|
436
|
+
padding: 1rem;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
nav {
|
|
440
|
+
width: 100%;
|
|
441
|
+
order: 3;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.stats {
|
|
445
|
+
margin-left: 0;
|
|
446
|
+
width: 100%;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
main {
|
|
450
|
+
padding: 1rem;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.form-row {
|
|
454
|
+
flex-direction: column;
|
|
455
|
+
gap: 1rem;
|
|
456
|
+
}
|
|
457
|
+
}
|