ex-brain 0.1.0 → 0.1.1

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.
@@ -0,0 +1,540 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { BrainDb } from "../db/client";
5
+ import { BrainRepository } from "../repositories/brain-repo";
6
+ import { loadSettings } from "../settings";
7
+
8
+ export const TOOL_MANIFEST = [
9
+ "brain_search",
10
+ "brain_query",
11
+ "brain_get",
12
+ "brain_put",
13
+ "brain_delete",
14
+ "brain_ingest",
15
+ "brain_link",
16
+ "brain_backlinks",
17
+ "brain_timeline",
18
+ "brain_timeline_add",
19
+ "brain_timeline_list",
20
+ "brain_timeline_delete",
21
+ "brain_timeline_extract",
22
+ "brain_compile",
23
+ "brain_smart_ingest",
24
+ "brain_tags",
25
+ "brain_tag",
26
+ "brain_list",
27
+ "brain_stats",
28
+ "brain_raw",
29
+ ];
30
+
31
+ export async function startMcpServer(dbPath: string): Promise<void> {
32
+ const db = await BrainDb.connect(dbPath);
33
+ const repo = new BrainRepository(db);
34
+ const settings = await loadSettings();
35
+ const server = new McpServer({ name: "ebrain", version: "0.2.0" });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Search & Query Tools
39
+ // ---------------------------------------------------------------------------
40
+
41
+ server.registerTool(
42
+ "brain_search",
43
+ {
44
+ description: "Full-text search (hybridSearch without KNN)",
45
+ inputSchema: z.object({
46
+ query: z.string(),
47
+ type: z.string().optional(),
48
+ limit: z.number().int().positive().max(50).optional(),
49
+ }),
50
+ },
51
+ async ({ query, type, limit }) => ({
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: JSON.stringify(
56
+ await repo.search(query, limit ?? 10, type),
57
+ null,
58
+ 2,
59
+ ),
60
+ },
61
+ ],
62
+ }),
63
+ );
64
+
65
+ server.registerTool(
66
+ "brain_query",
67
+ {
68
+ description: "Semantic query using vector embeddings",
69
+ inputSchema: z.object({
70
+ question: z.string(),
71
+ limit: z.number().int().positive().max(50).optional(),
72
+ }),
73
+ },
74
+ async ({ question, limit }) => ({
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: JSON.stringify(await repo.query(question, limit ?? 10), null, 2),
79
+ },
80
+ ],
81
+ }),
82
+ );
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Page CRUD Tools
86
+ // ---------------------------------------------------------------------------
87
+
88
+ server.registerTool(
89
+ "brain_get",
90
+ {
91
+ description: "Read a page and return its full content",
92
+ inputSchema: z.object({ slug: z.string() }),
93
+ },
94
+ async ({ slug }) => ({
95
+ content: [
96
+ { type: "text", text: JSON.stringify(await repo.getPage(slug), null, 2) },
97
+ ],
98
+ }),
99
+ );
100
+
101
+ server.registerTool(
102
+ "brain_put",
103
+ {
104
+ description: "Write or update a page",
105
+ inputSchema: z.object({
106
+ slug: z.string(),
107
+ content: z.string(),
108
+ type: z.string().optional(),
109
+ title: z.string().optional(),
110
+ }),
111
+ },
112
+ async ({ slug, content, type, title }) => {
113
+ const page = await repo.putPage({
114
+ slug,
115
+ type: type ?? "note",
116
+ title: title ?? slug,
117
+ compiledTruth: content,
118
+ timeline: "",
119
+ });
120
+ return { content: [{ type: "text", text: JSON.stringify(page, null, 2) }] };
121
+ },
122
+ );
123
+
124
+ server.registerTool(
125
+ "brain_delete",
126
+ {
127
+ description: "Delete a page and all its related data (links, tags, timeline, raw)",
128
+ inputSchema: z.object({ slug: z.string() }),
129
+ },
130
+ async ({ slug }) => {
131
+ await repo.deletePage(slug);
132
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, action: "delete", slug }) }] };
133
+ },
134
+ );
135
+
136
+ server.registerTool(
137
+ "brain_ingest",
138
+ {
139
+ description: "Ingest source content as a new page (simple ingestion)",
140
+ inputSchema: z.object({
141
+ content: z.string(),
142
+ source_type: z.string(),
143
+ source_ref: z.string(),
144
+ }),
145
+ },
146
+ async ({ content, source_type, source_ref }) => {
147
+ const safeRef = source_ref.replace(/[^a-zA-Z0-9/_-]+/g, "_").slice(0, 200);
148
+ const slug = `ingest/${safeRef || "untitled"}`;
149
+ const page = await repo.putPage({
150
+ slug,
151
+ type: source_type,
152
+ title: source_ref,
153
+ compiledTruth: content,
154
+ timeline: "",
155
+ frontmatter: { source_type, source_ref },
156
+ });
157
+ return { content: [{ type: "text", text: JSON.stringify(page, null, 2) }] };
158
+ },
159
+ );
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Link Tools
163
+ // ---------------------------------------------------------------------------
164
+
165
+ server.registerTool(
166
+ "brain_link",
167
+ {
168
+ description: "Create a cross-link between two pages",
169
+ inputSchema: z.object({
170
+ from: z.string(),
171
+ to: z.string(),
172
+ context: z.string().optional(),
173
+ }),
174
+ },
175
+ async ({ from, to, context }) => {
176
+ await repo.link(from, to, context ?? "");
177
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
178
+ },
179
+ );
180
+
181
+ server.registerTool(
182
+ "brain_backlinks",
183
+ {
184
+ description: "List pages that link to this page",
185
+ inputSchema: z.object({ slug: z.string() }),
186
+ },
187
+ async ({ slug }) => ({
188
+ content: [
189
+ { type: "text", text: JSON.stringify(await repo.backlinks(slug), null, 2) },
190
+ ],
191
+ }),
192
+ );
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Timeline Tools (Enhanced)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ server.registerTool(
199
+ "brain_timeline",
200
+ {
201
+ description: "List timeline entries for a specific page",
202
+ inputSchema: z.object({
203
+ slug: z.string(),
204
+ limit: z.number().int().positive().max(200).optional(),
205
+ }),
206
+ },
207
+ async ({ slug, limit }) => ({
208
+ content: [
209
+ {
210
+ type: "text",
211
+ text: JSON.stringify(await repo.timeline(slug, limit ?? 50), null, 2),
212
+ },
213
+ ],
214
+ }),
215
+ );
216
+
217
+ server.registerTool(
218
+ "brain_timeline_add",
219
+ {
220
+ description: "Append a timeline entry to a page",
221
+ inputSchema: z.object({
222
+ slug: z.string().describe("Page slug"),
223
+ date: z.string().describe("Date in YYYY-MM-DD format"),
224
+ summary: z.string().describe("One-line summary (max 120 chars)"),
225
+ source: z.string().optional().describe("Source identifier"),
226
+ detail: z.string().optional().describe("Optional markdown detail"),
227
+ }),
228
+ },
229
+ async ({ slug, date, summary, source, detail }) => {
230
+ await repo.timelineAdd({
231
+ pageSlug: slug,
232
+ date,
233
+ summary,
234
+ source: source ?? "manual",
235
+ detail: detail ?? "",
236
+ });
237
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
238
+ },
239
+ );
240
+
241
+ server.registerTool(
242
+ "brain_timeline_list",
243
+ {
244
+ description: "List timeline entries across all pages (global timeline view)",
245
+ inputSchema: z.object({
246
+ limit: z.number().int().positive().max(200).optional(),
247
+ }),
248
+ },
249
+ async ({ limit }) => ({
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: JSON.stringify(await repo.timelineGlobal(limit ?? 100), null, 2),
254
+ },
255
+ ],
256
+ }),
257
+ );
258
+
259
+ server.registerTool(
260
+ "brain_timeline_delete",
261
+ {
262
+ description: "Delete a specific timeline entry by ID",
263
+ inputSchema: z.object({
264
+ id: z.number().int().positive().describe("Timeline entry ID to delete"),
265
+ }),
266
+ },
267
+ async ({ id }) => {
268
+ await repo.timelineDelete(id);
269
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, action: "timeline-delete", id }) }] };
270
+ },
271
+ );
272
+
273
+ server.registerTool(
274
+ "brain_timeline_extract",
275
+ {
276
+ description: "Extract timeline events from content using AI. Adds extracted entries to page timeline.",
277
+ inputSchema: z.object({
278
+ slug: z.string().describe("Page slug to add timeline entries to"),
279
+ content: z.string().describe("Content to extract timeline events from"),
280
+ source: z.string().optional().describe("Source identifier"),
281
+ default_date: z.string().optional().describe("Default date (YYYY-MM-DD) for entries without explicit dates"),
282
+ }),
283
+ },
284
+ async ({ slug, content, source, default_date }) => {
285
+ const result = await repo.extractAndAddTimeline(
286
+ slug,
287
+ content,
288
+ source ?? "extracted",
289
+ default_date ?? new Date().toISOString().slice(0, 10),
290
+ settings.llm,
291
+ );
292
+ return {
293
+ content: [
294
+ {
295
+ type: "text",
296
+ text: JSON.stringify({
297
+ ok: true,
298
+ entriesAdded: result.entries.length,
299
+ entries: result.entries,
300
+ confidence: result.confidence,
301
+ }, null, 2),
302
+ },
303
+ ],
304
+ };
305
+ },
306
+ );
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Smart Compilation Tools (Core Brain Function)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ server.registerTool(
313
+ "brain_compile",
314
+ {
315
+ description: "Intelligently compile new information into a page's compiled truth. Analyzes semantic meaning, updates/replaces outdated info, adds source attribution, and extracts timeline events. This is the core 'brain' function.",
316
+ inputSchema: z.object({
317
+ slug: z.string().describe("Page slug to compile into"),
318
+ new_info: z.string().describe("New information to process (e.g., 'River AI closed Series A funding')"),
319
+ source: z.string().optional().describe("Source of information (e.g., 'meeting_notes', 'news', 'user')"),
320
+ date: z.string().optional().describe("Date of information (YYYY-MM-DD)"),
321
+ }),
322
+ },
323
+ async ({ slug, new_info, source, date }) => {
324
+ const result = await repo.compilePage(
325
+ slug,
326
+ new_info,
327
+ source ?? "user",
328
+ date ?? new Date().toISOString().slice(0, 10),
329
+ settings.llm,
330
+ );
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: JSON.stringify({
336
+ ok: true,
337
+ slug,
338
+ changed: result.changed,
339
+ changeType: result.changeType,
340
+ changeSummary: result.changeSummary,
341
+ timelineEntriesAdded: result.timelineEntries.length,
342
+ confidence: result.confidence,
343
+ compiledTruthPreview: result.compiledTruth.slice(0, 500),
344
+ }, null, 2),
345
+ },
346
+ ],
347
+ };
348
+ },
349
+ );
350
+
351
+ server.registerTool(
352
+ "brain_smart_ingest",
353
+ {
354
+ description: "Full intelligent ingestion: compile truth, extract timeline, create entity links, sync to search. The complete pipeline for processing new content.",
355
+ inputSchema: z.object({
356
+ slug: z.string().describe("Page slug for the content"),
357
+ content: z.string().describe("Full content to ingest"),
358
+ source: z.string().optional().describe("Source identifier"),
359
+ type: z.string().optional().describe("Page type (person, company, project, note, etc.)"),
360
+ }),
361
+ },
362
+ async ({ slug, content, source, type }) => {
363
+ const result = await repo.ingestContent(
364
+ slug,
365
+ content,
366
+ source ?? "ingest",
367
+ type ?? "note",
368
+ settings.llm,
369
+ );
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text",
374
+ text: JSON.stringify({
375
+ ok: true,
376
+ slug: result.page.slug,
377
+ compileResult: {
378
+ changed: result.compileResult.changed,
379
+ changeType: result.compileResult.changeType,
380
+ changeSummary: result.compileResult.changeSummary,
381
+ confidence: result.compileResult.confidence,
382
+ },
383
+ timelineResult: {
384
+ entriesAdded: result.timelineResult.entries.length,
385
+ confidence: result.timelineResult.confidence,
386
+ },
387
+ updatedAt: result.page.updatedAt,
388
+ }, null, 2),
389
+ },
390
+ ],
391
+ };
392
+ },
393
+ );
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Tag Tools
397
+ // ---------------------------------------------------------------------------
398
+
399
+ server.registerTool(
400
+ "brain_tags",
401
+ {
402
+ description: "List tags on a page",
403
+ inputSchema: z.object({ slug: z.string() }),
404
+ },
405
+ async ({ slug }) => ({
406
+ content: [
407
+ { type: "text", text: JSON.stringify(await repo.tags(slug), null, 2) },
408
+ ],
409
+ }),
410
+ );
411
+
412
+ server.registerTool(
413
+ "brain_tag",
414
+ {
415
+ description: "Add or remove a tag from a page",
416
+ inputSchema: z.object({
417
+ slug: z.string(),
418
+ tag: z.string(),
419
+ remove: z.boolean().optional(),
420
+ }),
421
+ },
422
+ async ({ slug, tag, remove }) => {
423
+ if (remove) {
424
+ await repo.untag(slug, tag);
425
+ } else {
426
+ await repo.tag(slug, tag);
427
+ }
428
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
429
+ },
430
+ );
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Query & List Tools
434
+ // ---------------------------------------------------------------------------
435
+
436
+ server.registerTool(
437
+ "brain_list",
438
+ {
439
+ description: "List pages with optional filters",
440
+ inputSchema: z.object({
441
+ type: z.string().optional(),
442
+ tag: z.string().optional(),
443
+ limit: z.number().int().positive().optional(),
444
+ }),
445
+ },
446
+ async ({ type, tag, limit }) => ({
447
+ content: [
448
+ {
449
+ type: "text",
450
+ text: JSON.stringify(
451
+ await repo.listPages({ type, tag, limit: limit ?? 50 }),
452
+ null,
453
+ 2,
454
+ ),
455
+ },
456
+ ],
457
+ }),
458
+ );
459
+
460
+ server.registerTool(
461
+ "brain_stats",
462
+ { description: "Show knowledge base statistics", inputSchema: z.object({}) },
463
+ async () => ({
464
+ content: [{ type: "text", text: JSON.stringify(await repo.stats(), null, 2) }],
465
+ }),
466
+ );
467
+
468
+ server.registerTool(
469
+ "brain_raw",
470
+ {
471
+ description: "Read or write raw source data for a page",
472
+ inputSchema: z.object({
473
+ slug: z.string(),
474
+ source: z.string().optional(),
475
+ data: z.unknown().optional(),
476
+ }),
477
+ },
478
+ async ({ slug, source, data }) => {
479
+ if (data !== undefined) {
480
+ await repo.writeRaw(slug, source ?? "manual", data);
481
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] };
482
+ }
483
+ return {
484
+ content: [
485
+ {
486
+ type: "text",
487
+ text: JSON.stringify(await repo.readRaw(slug, source), null, 2),
488
+ },
489
+ ],
490
+ };
491
+ },
492
+ );
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Resources
496
+ // ---------------------------------------------------------------------------
497
+
498
+ server.registerResource(
499
+ "brain-index",
500
+ "brain://index",
501
+ { title: "Brain Index", description: "All page slugs grouped in plain list." },
502
+ async () => {
503
+ const slugs = await repo.allSlugs();
504
+ return {
505
+ contents: [
506
+ {
507
+ uri: "brain://index",
508
+ mimeType: "text/plain",
509
+ text: slugs.join("\n"),
510
+ },
511
+ ],
512
+ };
513
+ },
514
+ );
515
+
516
+ const pageTemplate = new ResourceTemplate("brain://pages/{slug}", {
517
+ list: undefined,
518
+ });
519
+ server.registerResource(
520
+ "brain-page",
521
+ pageTemplate,
522
+ { title: "Brain Page", description: "Single page JSON resource." },
523
+ async (uri, vars) => {
524
+ const slug = String(vars.slug ?? "");
525
+ const page = await repo.getPage(slug);
526
+ return {
527
+ contents: [
528
+ {
529
+ uri: uri.href,
530
+ mimeType: "application/json",
531
+ text: JSON.stringify(page, null, 2),
532
+ },
533
+ ],
534
+ };
535
+ },
536
+ );
537
+
538
+ const transport = new StdioServerTransport();
539
+ await server.connect(transport);
540
+ }