indra_db_mcp 0.1.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.
@@ -0,0 +1,488 @@
1
+ /**
2
+ * IndraClient - CLI wrapper for indra_db
3
+ *
4
+ * Handles:
5
+ * - Spawning indra CLI subprocess
6
+ * - Auto-installation if binary not found
7
+ * - Database initialization
8
+ * - JSON parsing and error handling
9
+ */
10
+
11
+ import { spawn } from "child_process";
12
+ import { existsSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { join, dirname } from "path";
15
+ import { mkdir } from "fs/promises";
16
+ import type {
17
+ Thought,
18
+ Edge,
19
+ SearchResult,
20
+ Neighbor,
21
+ Commit,
22
+ Branch,
23
+ DatabaseStatus,
24
+ ListThoughtsResponse,
25
+ SearchResponse,
26
+ NeighborsResponse,
27
+ LogResponse,
28
+ BranchesResponse,
29
+ CommitResponse,
30
+ Diff,
31
+ TraversalDirection,
32
+ } from "./types.js";
33
+ import { IndraError } from "./types.js";
34
+
35
+ // ============================================================================
36
+ // Configuration
37
+ // ============================================================================
38
+
39
+ export interface IndraClientConfig {
40
+ /** Path to the database file. Defaults to ./thoughts.indra or ~/.thoughts.indra */
41
+ databasePath?: string;
42
+ /** Path to the indra binary. Defaults to searching PATH, then bundled, then auto-install */
43
+ binaryPath?: string;
44
+ /** Whether to auto-commit after each mutation. Defaults to true */
45
+ autoCommit?: boolean;
46
+ /** Timeout for CLI commands in milliseconds. Defaults to 30000 */
47
+ timeout?: number;
48
+ /** Whether to auto-initialize the database if it doesn't exist. Defaults to true */
49
+ autoInit?: boolean;
50
+ }
51
+
52
+ const DEFAULT_CONFIG: Required<IndraClientConfig> = {
53
+ databasePath: "", // Will be resolved dynamically
54
+ binaryPath: "", // Will be resolved dynamically
55
+ autoCommit: true,
56
+ timeout: 30000,
57
+ autoInit: true,
58
+ };
59
+
60
+ // ============================================================================
61
+ // Binary Management
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Attempts to find or install the indra binary.
66
+ * Priority:
67
+ * 1. Explicit path in config
68
+ * 2. System PATH
69
+ * 3. Bundled binary in ./bin
70
+ * 4. Auto-install via cargo
71
+ */
72
+ async function resolveBinaryPath(configPath?: string): Promise<string> {
73
+ // 1. Explicit config path
74
+ if (configPath && existsSync(configPath)) {
75
+ return configPath;
76
+ }
77
+
78
+ // 2. Check system PATH
79
+ const pathBinary = await findInPath("indra");
80
+ if (pathBinary) {
81
+ return pathBinary;
82
+ }
83
+
84
+ // 3. Check bundled binary
85
+ const bundledPath = join(dirname(import.meta.dir), "bin", "indra");
86
+ if (existsSync(bundledPath)) {
87
+ return bundledPath;
88
+ }
89
+
90
+ // 4. Auto-install via cargo
91
+ console.error("[indra_db_mcp] indra binary not found. Installing via cargo...");
92
+ await installViaCargo();
93
+
94
+ // Check PATH again after install
95
+ const installed = await findInPath("indra");
96
+ if (installed) {
97
+ return installed;
98
+ }
99
+
100
+ throw new Error(
101
+ "Failed to find or install indra binary. Please install manually:\n" +
102
+ " cargo install indra_db --features hf-embeddings\n" +
103
+ "Or download from: https://github.com/moonstripe/indra_db/releases"
104
+ );
105
+ }
106
+
107
+ async function findInPath(binary: string): Promise<string | null> {
108
+ return new Promise((resolve) => {
109
+ const proc = spawn("which", [binary]);
110
+ let stdout = "";
111
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
112
+ proc.on("close", (code) => {
113
+ if (code === 0 && stdout.trim()) {
114
+ resolve(stdout.trim());
115
+ } else {
116
+ resolve(null);
117
+ }
118
+ });
119
+ proc.on("error", () => resolve(null));
120
+ });
121
+ }
122
+
123
+ async function installViaCargo(): Promise<void> {
124
+ return new Promise((resolve, reject) => {
125
+ console.error("[indra_db_mcp] Running: cargo install indra_db --features hf-embeddings");
126
+
127
+ const proc = spawn("cargo", ["install", "indra_db", "--features", "hf-embeddings"], {
128
+ stdio: ["inherit", "inherit", "inherit"],
129
+ });
130
+
131
+ proc.on("close", (code) => {
132
+ if (code === 0) {
133
+ console.error("[indra_db_mcp] Successfully installed indra_db");
134
+ resolve();
135
+ } else {
136
+ reject(new Error(`cargo install failed with exit code ${code}`));
137
+ }
138
+ });
139
+
140
+ proc.on("error", (err) => {
141
+ reject(new Error(`Failed to run cargo: ${err.message}. Is Rust installed?`));
142
+ });
143
+ });
144
+ }
145
+
146
+ // ============================================================================
147
+ // Database Path Resolution
148
+ // ============================================================================
149
+
150
+ function resolveDatabasePath(): string {
151
+ // 1. Check environment variable
152
+ const envPath = process.env.INDRA_DB_PATH;
153
+ if (envPath) {
154
+ // If explicitly set, use global path
155
+ if (envPath.startsWith("~")) {
156
+ return join(homedir(), envPath.slice(1));
157
+ }
158
+ return envPath;
159
+ }
160
+
161
+ // 2. Default to local directory for development
162
+ return join(process.cwd(), "thoughts.indra");
163
+ }
164
+
165
+ // ============================================================================
166
+ // IndraClient
167
+ // ============================================================================
168
+
169
+ export class IndraClient {
170
+ private config: Required<IndraClientConfig>;
171
+ private binaryPath: string | null = null;
172
+ private initialized = false;
173
+
174
+ constructor(config: IndraClientConfig = {}) {
175
+ this.config = {
176
+ ...DEFAULT_CONFIG,
177
+ ...config,
178
+ databasePath: config.databasePath || resolveDatabasePath(),
179
+ };
180
+ }
181
+
182
+ // --------------------------------------------------------------------------
183
+ // Initialization
184
+ // --------------------------------------------------------------------------
185
+
186
+ private async ensureReady(): Promise<void> {
187
+ if (!this.binaryPath) {
188
+ this.binaryPath = await resolveBinaryPath(this.config.binaryPath);
189
+ }
190
+
191
+ if (!this.initialized && this.config.autoInit) {
192
+ await this.initializeIfNeeded();
193
+ this.initialized = true;
194
+ }
195
+ }
196
+
197
+ private async initializeIfNeeded(): Promise<void> {
198
+ if (!existsSync(this.config.databasePath)) {
199
+ // Ensure parent directory exists
200
+ const parentDir = dirname(this.config.databasePath);
201
+ if (!existsSync(parentDir)) {
202
+ await mkdir(parentDir, { recursive: true });
203
+ }
204
+
205
+ console.error(`[indra_db_mcp] Initializing new database at ${this.config.databasePath}`);
206
+ // Pass skipInit=true to prevent infinite recursion
207
+ await this.exec(["init"], true);
208
+ }
209
+ }
210
+
211
+ // --------------------------------------------------------------------------
212
+ // CLI Execution
213
+ // --------------------------------------------------------------------------
214
+
215
+ private async exec<T = unknown>(args: string[], skipInit = false): Promise<T> {
216
+ // Resolve binary if not already done
217
+ if (!this.binaryPath) {
218
+ this.binaryPath = await resolveBinaryPath(this.config.binaryPath);
219
+ }
220
+
221
+ // Auto-initialize database if needed (but not for init command itself)
222
+ if (!skipInit && !this.initialized && this.config.autoInit) {
223
+ await this.initializeIfNeeded();
224
+ this.initialized = true;
225
+ }
226
+
227
+ const fullArgs = ["-d", this.config.databasePath, "-f", "json", ...args];
228
+
229
+ return new Promise((resolve, reject) => {
230
+ const proc = spawn(this.binaryPath!, fullArgs, {
231
+ timeout: this.config.timeout,
232
+ });
233
+
234
+ let stdout = "";
235
+ let stderr = "";
236
+
237
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
238
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
239
+
240
+ proc.on("close", (code) => {
241
+ if (code === 0) {
242
+ try {
243
+ // Handle empty output (some commands may not return JSON)
244
+ if (!stdout.trim()) {
245
+ resolve({} as T);
246
+ return;
247
+ }
248
+ resolve(JSON.parse(stdout) as T);
249
+ } catch (e) {
250
+ // If JSON parsing fails, return raw output
251
+ resolve(stdout as unknown as T);
252
+ }
253
+ } else {
254
+ reject(
255
+ new IndraError(
256
+ `Command failed: indra ${args.join(" ")}`,
257
+ code ?? 1,
258
+ stderr || stdout,
259
+ ["indra", ...fullArgs]
260
+ )
261
+ );
262
+ }
263
+ });
264
+
265
+ proc.on("error", (err) => {
266
+ reject(
267
+ new IndraError(
268
+ `Failed to execute indra: ${err.message}`,
269
+ -1,
270
+ err.message,
271
+ ["indra", ...fullArgs]
272
+ )
273
+ );
274
+ });
275
+ });
276
+ }
277
+
278
+ // --------------------------------------------------------------------------
279
+ // Thought Operations
280
+ // --------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Create a new thought in the knowledge graph.
284
+ * The thought will be assigned embeddings automatically for semantic search.
285
+ */
286
+ async createThought(content: string, options?: { id?: string }): Promise<Thought> {
287
+ const args = ["create", content];
288
+ if (options?.id) {
289
+ args.push("--id", options.id);
290
+ }
291
+ const result = await this.exec<Thought>(args);
292
+
293
+ if (this.config.autoCommit) {
294
+ await this.commit(`Create thought: ${options?.id || result.id}`);
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * Retrieve a thought by its ID.
302
+ */
303
+ async getThought(id: string): Promise<Thought> {
304
+ return this.exec<Thought>(["get", id]);
305
+ }
306
+
307
+ /**
308
+ * Update the content of an existing thought.
309
+ * This creates a new version while preserving history.
310
+ */
311
+ async updateThought(id: string, content: string): Promise<Thought> {
312
+ const result = await this.exec<Thought>(["update", id, content]);
313
+
314
+ if (this.config.autoCommit) {
315
+ await this.commit(`Update thought: ${id}`);
316
+ }
317
+
318
+ return result;
319
+ }
320
+
321
+ /**
322
+ * Delete a thought from the current state.
323
+ * The thought remains in history and can be recovered via branching.
324
+ */
325
+ async deleteThought(id: string): Promise<void> {
326
+ await this.exec(["delete", id]);
327
+
328
+ if (this.config.autoCommit) {
329
+ await this.commit(`Delete thought: ${id}`);
330
+ }
331
+ }
332
+
333
+ /**
334
+ * List all thoughts in the current state.
335
+ */
336
+ async listThoughts(): Promise<ListThoughtsResponse> {
337
+ return this.exec<ListThoughtsResponse>(["list"]);
338
+ }
339
+
340
+ // --------------------------------------------------------------------------
341
+ // Relationship Operations
342
+ // --------------------------------------------------------------------------
343
+
344
+ /**
345
+ * Create a typed relationship between two thoughts.
346
+ */
347
+ async relate(
348
+ sourceId: string,
349
+ targetId: string,
350
+ edgeType: string = "relates_to",
351
+ options?: { weight?: number }
352
+ ): Promise<Edge> {
353
+ const args = ["relate", sourceId, targetId, "-t", edgeType];
354
+ if (options?.weight !== undefined) {
355
+ args.push("-w", options.weight.toString());
356
+ }
357
+ const result = await this.exec<Edge>(args);
358
+
359
+ if (this.config.autoCommit) {
360
+ await this.commit(`Relate ${sourceId} --[${edgeType}]--> ${targetId}`);
361
+ }
362
+
363
+ return result;
364
+ }
365
+
366
+ /**
367
+ * Remove a relationship between two thoughts.
368
+ */
369
+ async unrelate(sourceId: string, targetId: string, edgeType?: string): Promise<void> {
370
+ const args = ["unrelate", sourceId, targetId];
371
+ if (edgeType) {
372
+ args.push("-t", edgeType);
373
+ }
374
+ await this.exec(args);
375
+
376
+ if (this.config.autoCommit) {
377
+ await this.commit(`Unrelate ${sourceId} from ${targetId}`);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Get all neighbors of a thought (connected via edges).
383
+ */
384
+ async getNeighbors(
385
+ id: string,
386
+ direction: TraversalDirection = "both"
387
+ ): Promise<NeighborsResponse> {
388
+ const args = ["neighbors", id];
389
+ if (direction !== "both") {
390
+ args.push("-d", direction);
391
+ }
392
+ return this.exec<NeighborsResponse>(args);
393
+ }
394
+
395
+ // --------------------------------------------------------------------------
396
+ // Search Operations
397
+ // --------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Search thoughts by semantic similarity.
401
+ * Uses vector embeddings to find conceptually related thoughts.
402
+ */
403
+ async search(query: string, limit: number = 10): Promise<SearchResponse> {
404
+ return this.exec<SearchResponse>(["search", query, "-l", limit.toString()]);
405
+ }
406
+
407
+ // --------------------------------------------------------------------------
408
+ // Versioning Operations
409
+ // --------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Commit current changes with a message.
413
+ * Creates a new snapshot in the history DAG.
414
+ */
415
+ async commit(message: string): Promise<CommitResponse> {
416
+ return this.exec<CommitResponse>(["commit", message]);
417
+ }
418
+
419
+ /**
420
+ * Get commit history for the current branch.
421
+ */
422
+ async log(limit?: number): Promise<LogResponse> {
423
+ const args = ["log"];
424
+ if (limit) {
425
+ args.push("-l", limit.toString());
426
+ }
427
+ return this.exec<LogResponse>(args);
428
+ }
429
+
430
+ /**
431
+ * Create a new branch from the current HEAD.
432
+ */
433
+ async createBranch(name: string): Promise<Branch> {
434
+ return this.exec<Branch>(["branch", name]);
435
+ }
436
+
437
+ /**
438
+ * Switch to a different branch.
439
+ */
440
+ async checkout(branchName: string): Promise<void> {
441
+ await this.exec(["checkout", branchName]);
442
+ }
443
+
444
+ /**
445
+ * List all branches.
446
+ */
447
+ async listBranches(): Promise<BranchesResponse> {
448
+ return this.exec<BranchesResponse>(["branches"]);
449
+ }
450
+
451
+ /**
452
+ * Show diff between two commits.
453
+ */
454
+ async diff(from?: string, to?: string): Promise<Diff> {
455
+ const args = ["diff"];
456
+ if (from) args.push(from);
457
+ if (to) args.push(to);
458
+ return this.exec<Diff>(args);
459
+ }
460
+
461
+ /**
462
+ * Get current database status.
463
+ */
464
+ async status(): Promise<DatabaseStatus> {
465
+ return this.exec<DatabaseStatus>(["status"]);
466
+ }
467
+
468
+ // --------------------------------------------------------------------------
469
+ // Utility
470
+ // --------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Get the database path being used.
474
+ */
475
+ getDatabasePath(): string {
476
+ return this.config.databasePath;
477
+ }
478
+
479
+ /**
480
+ * Manually trigger initialization.
481
+ */
482
+ async init(): Promise<void> {
483
+ await this.ensureReady();
484
+ }
485
+ }
486
+
487
+ // Export a default instance that can be reconfigured
488
+ export const defaultClient = new IndraClient();