@infinitedusky/indusk-mcp 1.6.7 → 1.7.3

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/bin/cli.js CHANGED
@@ -105,6 +105,30 @@ program
105
105
  const { checkGates } = await import("./commands/check-gates.js");
106
106
  await checkGates(process.cwd(), { file: opts.file, phase: opts.phase });
107
107
  });
108
+ const infra = program
109
+ .command("infra")
110
+ .description("Manage the indusk-infra container (FalkorDB + Graphiti)");
111
+ infra
112
+ .command("start")
113
+ .description("Start the infrastructure container (creates if needed)")
114
+ .action(async () => {
115
+ const { infraStart } = await import("./commands/infra.js");
116
+ await infraStart();
117
+ });
118
+ infra
119
+ .command("stop")
120
+ .description("Stop the infrastructure container (preserves data)")
121
+ .action(async () => {
122
+ const { infraStop } = await import("./commands/infra.js");
123
+ await infraStop();
124
+ });
125
+ infra
126
+ .command("status")
127
+ .description("Show infrastructure container health and configuration")
128
+ .action(async () => {
129
+ const { infraStatus } = await import("./commands/infra.js");
130
+ await infraStatus();
131
+ });
108
132
  program
109
133
  .command("serve")
110
134
  .description("Start the MCP server (used by Claude Code via .mcp.json)")
@@ -0,0 +1,3 @@
1
+ export declare function infraStart(): Promise<void>;
2
+ export declare function infraStop(): Promise<void>;
3
+ export declare function infraStatus(): Promise<void>;
@@ -0,0 +1,222 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ const CONTAINER_NAME = "indusk-infra";
6
+ const IMAGE_NAME = "indusk-infra";
7
+ const VOLUME_NAME = "indusk-data";
8
+ const CONFIG_DIR = join(homedir(), ".indusk");
9
+ const CONFIG_FILE = join(CONFIG_DIR, "config.env");
10
+ function run(cmd, timeout = 10000) {
11
+ try {
12
+ return execSync(cmd, {
13
+ encoding: "utf-8",
14
+ timeout,
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ }).trim();
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ function loadConfig() {
23
+ const config = {};
24
+ if (!existsSync(CONFIG_FILE))
25
+ return config;
26
+ const content = readFileSync(CONFIG_FILE, "utf-8");
27
+ for (const line of content.split("\n")) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith("#"))
30
+ continue;
31
+ const eq = trimmed.indexOf("=");
32
+ if (eq === -1)
33
+ continue;
34
+ config[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
35
+ }
36
+ return config;
37
+ }
38
+ function ensureConfig() {
39
+ if (!existsSync(CONFIG_DIR)) {
40
+ mkdirSync(CONFIG_DIR, { recursive: true });
41
+ }
42
+ if (!existsSync(CONFIG_FILE)) {
43
+ writeFileSync(CONFIG_FILE, [
44
+ "# InDusk global configuration",
45
+ "# This file is read by `indusk infra start` to configure the infrastructure container.",
46
+ "",
47
+ "# Required for Graphiti (Gemini LLM/embeddings). Get from https://aistudio.google.com/apikey",
48
+ "GOOGLE_API_KEY=",
49
+ "",
50
+ "# Optional: OTel export endpoint (e.g., https://api.region.gcp.dash0.com)",
51
+ "# OTEL_EXPORTER_OTLP_ENDPOINT=",
52
+ "# OTEL_EXPORTER_OTLP_HEADERS=",
53
+ "# OTEL_SERVICE_NAME=indusk-infra",
54
+ "",
55
+ ].join("\n"));
56
+ console.info(`Created ${CONFIG_FILE}`);
57
+ console.info("");
58
+ console.info("Add your GOOGLE_API_KEY to this file:");
59
+ console.info(` ${CONFIG_FILE}`);
60
+ console.info("");
61
+ console.info("Get a key from: https://aistudio.google.com/apikey");
62
+ console.info("Without it, FalkorDB works but Graphiti will not (graceful degradation).");
63
+ }
64
+ return loadConfig();
65
+ }
66
+ function getContainerStatus() {
67
+ const running = run(`docker ps --filter name=^${CONTAINER_NAME}$ --format '{{.Status}}'`);
68
+ if (running)
69
+ return "running";
70
+ const exists = run(`docker ps -a --filter name=^${CONTAINER_NAME}$ --format '{{.Status}}'`);
71
+ if (exists)
72
+ return "stopped";
73
+ return "missing";
74
+ }
75
+ export async function infraStart() {
76
+ const status = getContainerStatus();
77
+ if (status === "running") {
78
+ console.info(`${CONTAINER_NAME} is already running.`);
79
+ await infraStatus();
80
+ return;
81
+ }
82
+ if (status === "stopped") {
83
+ console.info(`Starting ${CONTAINER_NAME}...`);
84
+ const result = run(`docker start ${CONTAINER_NAME}`);
85
+ if (result) {
86
+ console.info("Started.");
87
+ // Wait for FalkorDB to be ready
88
+ await waitForReady();
89
+ await infraStatus();
90
+ }
91
+ else {
92
+ console.error(`Failed to start ${CONTAINER_NAME}.`);
93
+ process.exitCode = 1;
94
+ }
95
+ return;
96
+ }
97
+ // Container doesn't exist — check for image, then create
98
+ const hasImage = run(`docker images -q ${IMAGE_NAME}`);
99
+ if (!hasImage) {
100
+ console.error(`Docker image '${IMAGE_NAME}' not found.`);
101
+ console.error("");
102
+ console.error("Build it from the infinitedusky repo:");
103
+ console.error(" docker build -f docker/Dockerfile.infra -t indusk-infra .");
104
+ console.error("");
105
+ console.error("Or pull from GHCR (when published):");
106
+ console.error(" docker pull ghcr.io/infinitedusky/indusk-infra:latest");
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ // Load config for env vars
111
+ const config = ensureConfig();
112
+ const envArgs = [];
113
+ if (config.GOOGLE_API_KEY) {
114
+ envArgs.push(`-e GOOGLE_API_KEY=${config.GOOGLE_API_KEY}`);
115
+ }
116
+ else {
117
+ console.warn("Warning: GOOGLE_API_KEY not set in ~/.indusk/config.env — Graphiti will not work.");
118
+ }
119
+ if (config.OTEL_EXPORTER_OTLP_ENDPOINT) {
120
+ envArgs.push(`-e OTEL_EXPORTER_OTLP_ENDPOINT=${config.OTEL_EXPORTER_OTLP_ENDPOINT}`);
121
+ }
122
+ if (config.OTEL_EXPORTER_OTLP_HEADERS) {
123
+ envArgs.push(`-e OTEL_EXPORTER_OTLP_HEADERS=${config.OTEL_EXPORTER_OTLP_HEADERS}`);
124
+ }
125
+ if (config.OTEL_SERVICE_NAME) {
126
+ envArgs.push(`-e OTEL_SERVICE_NAME=${config.OTEL_SERVICE_NAME}`);
127
+ }
128
+ console.info(`Creating ${CONTAINER_NAME}...`);
129
+ const createCmd = [
130
+ "docker run -d",
131
+ `--name ${CONTAINER_NAME}`,
132
+ "-p 6379:6379",
133
+ "-p 8100:8100",
134
+ `-v ${VOLUME_NAME}:/data`,
135
+ "--restart unless-stopped",
136
+ ...envArgs,
137
+ IMAGE_NAME,
138
+ ].join(" ");
139
+ const result = run(createCmd, 30000);
140
+ if (result) {
141
+ console.info("Created.");
142
+ await waitForReady();
143
+ await infraStatus();
144
+ }
145
+ else {
146
+ console.error("Failed to create container.");
147
+ console.error(`Command: ${createCmd}`);
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+ export async function infraStop() {
152
+ const status = getContainerStatus();
153
+ if (status === "missing") {
154
+ console.info(`${CONTAINER_NAME} does not exist.`);
155
+ return;
156
+ }
157
+ if (status === "stopped") {
158
+ console.info(`${CONTAINER_NAME} is already stopped.`);
159
+ return;
160
+ }
161
+ console.info(`Stopping ${CONTAINER_NAME}...`);
162
+ const result = run(`docker stop ${CONTAINER_NAME}`, 30000);
163
+ if (result) {
164
+ console.info("Stopped. Data preserved in volume.");
165
+ }
166
+ else {
167
+ console.error("Failed to stop container.");
168
+ process.exitCode = 1;
169
+ }
170
+ }
171
+ export async function infraStatus() {
172
+ const status = getContainerStatus();
173
+ console.info(`Container: ${CONTAINER_NAME}`);
174
+ console.info(`Status: ${status}`);
175
+ if (status !== "running") {
176
+ if (status === "stopped") {
177
+ console.info("\nRun `indusk infra start` to start.");
178
+ }
179
+ else {
180
+ console.info("\nRun `indusk infra start` to create and start.");
181
+ }
182
+ return;
183
+ }
184
+ // FalkorDB health
185
+ const pong = run("redis-cli -h localhost ping");
186
+ console.info(`FalkorDB: ${pong === "PONG" ? "healthy" : "unreachable"}`);
187
+ // Graph count
188
+ if (pong === "PONG") {
189
+ const graphs = run("redis-cli -h localhost GRAPH.LIST");
190
+ const graphList = graphs ? graphs.split("\n").filter((l) => l.trim()) : [];
191
+ console.info(`Graphs: ${graphList.length} (${graphList.join(", ")})`);
192
+ }
193
+ // Graphiti health
194
+ try {
195
+ const health = run("curl -sf http://localhost:8100/health", 5000);
196
+ if (health) {
197
+ console.info("Graphiti: healthy");
198
+ }
199
+ else {
200
+ console.info("Graphiti: starting or unreachable");
201
+ }
202
+ }
203
+ catch {
204
+ console.info("Graphiti: unreachable");
205
+ }
206
+ // Config
207
+ const config = loadConfig();
208
+ console.info(`API Key: ${config.GOOGLE_API_KEY ? "configured" : "not set"}`);
209
+ console.info(`OTel: ${config.OTEL_EXPORTER_OTLP_ENDPOINT ? "enabled" : "disabled"}`);
210
+ }
211
+ async function waitForReady() {
212
+ console.info("Waiting for FalkorDB...");
213
+ for (let i = 0; i < 10; i++) {
214
+ const pong = run("redis-cli -h localhost ping");
215
+ if (pong === "PONG") {
216
+ console.info("FalkorDB ready.");
217
+ return;
218
+ }
219
+ await new Promise((r) => setTimeout(r, 2000));
220
+ }
221
+ console.warn("FalkorDB did not respond within 20s — may still be loading.");
222
+ }
@@ -127,19 +127,79 @@ export async function init(projectRoot, options = {}) {
127
127
  }
128
128
  catch { }
129
129
  }
130
- // Add indusk MCP server (no secrets)
130
+ // Add indusk MCP server prefer global binary, fall back to npx
131
+ const hasGlobalIndusk = run("which indusk") || run("where indusk");
132
+ const induskCommand = hasGlobalIndusk
133
+ ? "claude mcp add -t stdio -s project -e PROJECT_ROOT=. -- indusk indusk serve"
134
+ : "claude mcp add -t stdio -s project -e PROJECT_ROOT=. -- indusk npx --yes @infinitedusky/indusk-mcp serve";
131
135
  if (!existingServers.has("indusk") || force) {
132
136
  try {
133
- execSync(`claude mcp add -t stdio -s project -e PROJECT_ROOT=. -- indusk npx --yes @infinitedusky/indusk-mcp serve`, { cwd: projectRoot, stdio: "pipe", timeout: 10000 });
134
- console.info(" added: indusk MCP server (via claude mcp add)");
137
+ execSync(induskCommand, { cwd: projectRoot, stdio: "pipe", timeout: 10000 });
138
+ console.info(` added: indusk MCP server (${hasGlobalIndusk ? "global binary" : "via npx"})`);
135
139
  }
136
140
  catch {
137
141
  console.info(" failed: could not add indusk MCP server — run manually:");
138
- console.info(" claude mcp add -t stdio -s project -e PROJECT_ROOT=. -- indusk npx --yes @infinitedusky/indusk-mcp serve");
142
+ console.info(` ${induskCommand}`);
139
143
  }
140
144
  }
141
145
  else {
142
- console.info(" skip: indusk MCP server (already exists)");
146
+ // Migrate from npx to global binary if available
147
+ if (hasGlobalIndusk) {
148
+ try {
149
+ const mcpConfig = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
150
+ const induskArgs = mcpConfig.mcpServers?.indusk?.args;
151
+ if (induskArgs?.includes("npx")) {
152
+ execSync(induskCommand, { cwd: projectRoot, stdio: "pipe", timeout: 10000 });
153
+ console.info(" migrated: indusk MCP server (npx → global binary)");
154
+ }
155
+ else {
156
+ console.info(" skip: indusk MCP server (already exists)");
157
+ }
158
+ }
159
+ catch {
160
+ console.info(" skip: indusk MCP server (already exists)");
161
+ }
162
+ }
163
+ else {
164
+ console.info(" skip: indusk MCP server (already exists)");
165
+ }
166
+ }
167
+ // Install CGC if not present
168
+ const cgcInstalled = run("cgc --version");
169
+ if (cgcInstalled) {
170
+ console.info(` skip: codegraphcontext (${cgcInstalled})`);
171
+ }
172
+ else {
173
+ const hasPipx = run("pipx --version");
174
+ if (hasPipx) {
175
+ console.info(" installing: codegraphcontext via pipx...");
176
+ try {
177
+ execSync("pipx install codegraphcontext", {
178
+ timeout: 60000,
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ });
181
+ console.info(" installed: codegraphcontext");
182
+ }
183
+ catch {
184
+ console.info(" failed: could not install codegraphcontext — run manually:");
185
+ console.info(" pipx install codegraphcontext");
186
+ }
187
+ }
188
+ else {
189
+ console.info(" skip: codegraphcontext (pipx not found — install pipx first, then: pipx install codegraphcontext)");
190
+ }
191
+ }
192
+ // Migrate CGC config: falkordb.orb.local → localhost (indusk-infra container)
193
+ if (existingServers.has("codegraphcontext") && !force) {
194
+ try {
195
+ const mcpConfig = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
196
+ const cgcEnv = mcpConfig.mcpServers?.codegraphcontext?.env;
197
+ if (cgcEnv?.FALKORDB_HOST === "falkordb.orb.local") {
198
+ execSync(`claude mcp add -t stdio -s project -e DATABASE_TYPE=falkordb-remote -e FALKORDB_HOST=localhost -e FALKORDB_GRAPH_NAME=${cgcEnv.FALKORDB_GRAPH_NAME || `cgc-${projectName}`} -- codegraphcontext cgc mcp start`, { cwd: projectRoot, stdio: "pipe", timeout: 10000 });
199
+ console.info(" migrated: codegraphcontext FALKORDB_HOST → localhost (indusk-infra)");
200
+ }
201
+ }
202
+ catch { }
143
203
  }
144
204
  // Add codegraphcontext MCP server (no secrets)
145
205
  if (!existingServers.has("codegraphcontext") || force) {
@@ -155,6 +215,45 @@ export async function init(projectRoot, options = {}) {
155
215
  else {
156
216
  console.info(" skip: codegraphcontext MCP server (already exists)");
157
217
  }
218
+ // 4b. Check infrastructure container
219
+ console.info("\n[Infrastructure]");
220
+ try {
221
+ const infraStatus = execSync("docker inspect --format='{{.State.Running}}' indusk-infra", {
222
+ encoding: "utf-8",
223
+ timeout: 5000,
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ }).trim();
226
+ if (infraStatus === "true") {
227
+ console.info(" ok: indusk-infra container is running");
228
+ }
229
+ else {
230
+ console.info(" starting: indusk-infra container...");
231
+ execSync("docker start indusk-infra", {
232
+ timeout: 15000,
233
+ stdio: ["ignore", "pipe", "pipe"],
234
+ });
235
+ console.info(" started: indusk-infra");
236
+ }
237
+ }
238
+ catch {
239
+ console.info(" skip: indusk-infra container not found");
240
+ console.info(" To set up infrastructure: indusk infra start");
241
+ }
242
+ // 4c. Copy Graphiti extension manifest
243
+ const graphitiExtDir = join(projectRoot, ".indusk/extensions/graphiti");
244
+ const graphitiManifest = join(graphitiExtDir, "manifest.json");
245
+ if (existsSync(graphitiManifest) && !force) {
246
+ console.info(" skip: graphiti extension (already exists)");
247
+ }
248
+ else {
249
+ mkdirSync(graphitiExtDir, { recursive: true });
250
+ cpSync(join(packageRoot, "extensions/graphiti/manifest.json"), graphitiManifest);
251
+ const skillSource = join(packageRoot, "extensions/graphiti/skill.md");
252
+ if (existsSync(skillSource)) {
253
+ cpSync(skillSource, join(graphitiExtDir, "skill.md"));
254
+ }
255
+ console.info(" create: .indusk/extensions/graphiti/ (manifest + skill)");
256
+ }
158
257
  // 5. Generate .vscode/settings.json
159
258
  console.info("\n[Editor]");
160
259
  const vscodePath = join(projectRoot, ".vscode/settings.json");
@@ -295,6 +394,41 @@ export async function init(projectRoot, options = {}) {
295
394
  console.info(" wire: node --import ./src/instrumentation.ts src/index.ts");
296
395
  }
297
396
  } // end isInduskMcp else
397
+ // 7b. Next.js webpack config — ensure --webpack dev script and aggregateTimeout watchOptions
398
+ if (!isInduskMcp) {
399
+ const nextConfigs = ["next.config.ts", "next.config.js", "next.config.mjs"].map((f) => join(projectRoot, f));
400
+ const nextConfigPath = nextConfigs.find((f) => existsSync(f));
401
+ if (nextConfigPath) {
402
+ console.info("\n[Next.js Webpack Config]");
403
+ const configContent = readFileSync(nextConfigPath, "utf-8");
404
+ if (!configContent.includes("aggregateTimeout")) {
405
+ console.info(` warn: ${nextConfigPath.split("/").pop()} is missing aggregateTimeout in webpack watchOptions`);
406
+ console.info(" add the following to your next.config:");
407
+ console.info(" webpack: (config) => {");
408
+ console.info(" config.watchOptions = { ...config.watchOptions, aggregateTimeout: 600 };");
409
+ console.info(" return config;");
410
+ console.info(" }");
411
+ }
412
+ else {
413
+ console.info(` ok: aggregateTimeout configured in ${nextConfigPath.split("/").pop()}`);
414
+ }
415
+ // Check dev script for --turbopack
416
+ const pkgJsonPath = join(projectRoot, "package.json");
417
+ if (existsSync(pkgJsonPath)) {
418
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
419
+ const devScript = pkgJson.scripts?.dev || "";
420
+ if (devScript.includes("--turbopack")) {
421
+ console.info(" warn: dev script uses --turbopack — remove it");
422
+ console.info(" Turbopack's 1ms file watcher debounce causes crashes with external tool writes on macOS");
423
+ console.info(" Next.js <16: plain 'next dev' uses webpack by default");
424
+ console.info(" Next.js 16+: use 'next dev --webpack' to opt out of Turbopack");
425
+ }
426
+ else {
427
+ console.info(" ok: dev script does not use Turbopack");
428
+ }
429
+ }
430
+ }
431
+ }
298
432
  // 8. Install gate enforcement hooks
299
433
  console.info("\n[Hooks]");
300
434
  const hooksSource = join(packageRoot, "hooks");
@@ -473,7 +607,8 @@ export async function init(projectRoot, options = {}) {
473
607
  console.info("\nDone!");
474
608
  console.info("\n⚠ Restart Claude Code to load the updated MCP server and skills.");
475
609
  console.info("\nNext steps:");
476
- console.info(" 1. Restart Claude Code");
477
- console.info(" 2. Edit CLAUDE.md with your project details");
478
- console.info(" 3. Start planning: /plan your-first-feature");
610
+ console.info(" 1. Set up infrastructure (if not done): indusk infra start");
611
+ console.info(" 2. Restart Claude Code");
612
+ console.info(" 3. Edit CLAUDE.md with your project details");
613
+ console.info(" 4. Start planning: /plan your-first-feature");
479
614
  }
@@ -0,0 +1,93 @@
1
+ interface AddEpisodeOptions {
2
+ groupId?: string;
3
+ source?: string;
4
+ sourceDescription?: string;
5
+ }
6
+ interface SearchOptions {
7
+ groupIds?: string[];
8
+ maxResults?: number;
9
+ }
10
+ interface GraphitiNode {
11
+ name: string;
12
+ uuid: string;
13
+ summary: string;
14
+ group_id: string;
15
+ [key: string]: unknown;
16
+ }
17
+ interface GraphitiFact {
18
+ uuid: string;
19
+ fact: string;
20
+ valid_at: string | null;
21
+ invalid_at: string | null;
22
+ [key: string]: unknown;
23
+ }
24
+ /**
25
+ * MCP client wrapper for the Graphiti temporal knowledge graph.
26
+ *
27
+ * Connects to the Graphiti MCP server running inside the indusk-infra container.
28
+ * Lazy connection — connects on first call, not at construction.
29
+ * Graceful degradation — returns null/empty on connection failure, never throws.
30
+ */
31
+ export declare class GraphitiClient {
32
+ private client;
33
+ private transport;
34
+ private connected;
35
+ private connecting;
36
+ private serverUrl;
37
+ private projectName;
38
+ constructor(projectName: string, serverUrl?: string);
39
+ /**
40
+ * Lazily connect to the Graphiti MCP server.
41
+ * Returns true if connected, false if connection failed.
42
+ * Multiple concurrent calls share the same connection attempt.
43
+ */
44
+ connect(): Promise<boolean>;
45
+ private doConnect;
46
+ private getClient;
47
+ /**
48
+ * Add an episode to the knowledge graph.
49
+ *
50
+ * @param name - Short name for the episode (e.g., "auth-refactor decision")
51
+ * @param body - Episode content to extract entities and facts from
52
+ * @param options - groupId defaults to project name, source defaults to "text"
53
+ * @returns Success response or null on failure
54
+ */
55
+ addEpisode(name: string, body: string, options?: AddEpisodeOptions): Promise<{
56
+ success: boolean;
57
+ } | null>;
58
+ /**
59
+ * Search for entity nodes in the knowledge graph.
60
+ *
61
+ * @param query - Natural language search query
62
+ * @param options - groupIds defaults to [projectName, "shared"] for cross-group search
63
+ * @returns Array of matching nodes, or empty array on failure
64
+ */
65
+ searchNodes(query: string, options?: SearchOptions): Promise<GraphitiNode[]>;
66
+ /**
67
+ * Search for facts (relationships between entities) in the knowledge graph.
68
+ *
69
+ * @param query - Natural language search query
70
+ * @param options - groupIds defaults to [projectName, "shared"] for cross-group search
71
+ * @returns Array of matching facts, or empty array on failure
72
+ */
73
+ searchFacts(query: string, options?: SearchOptions): Promise<GraphitiFact[]>;
74
+ /**
75
+ * Check if the Graphiti server is reachable and healthy.
76
+ *
77
+ * @returns Status object or null if unreachable
78
+ */
79
+ getStatus(): Promise<{
80
+ status: string;
81
+ } | null>;
82
+ /**
83
+ * Resolve group IDs for search — always includes "shared" for cross-group search.
84
+ * If caller provides groupIds, uses those. Otherwise defaults to [projectName, "shared"].
85
+ * Deduplicates "shared" if already present.
86
+ */
87
+ private resolveGroupIds;
88
+ /**
89
+ * Disconnect from the Graphiti MCP server.
90
+ */
91
+ disconnect(): Promise<void>;
92
+ }
93
+ export {};
@@ -0,0 +1,209 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { getInfraConfig } from "./infra-config.js";
4
+ /**
5
+ * MCP client wrapper for the Graphiti temporal knowledge graph.
6
+ *
7
+ * Connects to the Graphiti MCP server running inside the indusk-infra container.
8
+ * Lazy connection — connects on first call, not at construction.
9
+ * Graceful degradation — returns null/empty on connection failure, never throws.
10
+ */
11
+ export class GraphitiClient {
12
+ client = null;
13
+ transport = null;
14
+ connected = false;
15
+ connecting = null;
16
+ serverUrl;
17
+ projectName;
18
+ constructor(projectName, serverUrl) {
19
+ this.projectName = projectName;
20
+ this.serverUrl = serverUrl ?? getInfraConfig().graphiti.url;
21
+ }
22
+ /**
23
+ * Lazily connect to the Graphiti MCP server.
24
+ * Returns true if connected, false if connection failed.
25
+ * Multiple concurrent calls share the same connection attempt.
26
+ */
27
+ async connect() {
28
+ if (this.connected)
29
+ return true;
30
+ if (this.connecting)
31
+ return this.connecting;
32
+ this.connecting = this.doConnect();
33
+ const result = await this.connecting;
34
+ this.connecting = null;
35
+ return result;
36
+ }
37
+ async doConnect() {
38
+ try {
39
+ // Normalize URL — Graphiti redirects /mcp/ to /mcp
40
+ const url = this.serverUrl.endsWith("/") ? this.serverUrl.slice(0, -1) : this.serverUrl;
41
+ this.transport = new StreamableHTTPClientTransport(new URL(url));
42
+ this.client = new Client({ name: "indusk-mcp", version: "1.0.0" });
43
+ await this.client.connect(this.transport);
44
+ this.connected = true;
45
+ return true;
46
+ }
47
+ catch {
48
+ this.client = null;
49
+ this.transport = null;
50
+ this.connected = false;
51
+ return false;
52
+ }
53
+ }
54
+ getClient() {
55
+ if (!this.client)
56
+ throw new Error("Not connected");
57
+ return this.client;
58
+ }
59
+ /**
60
+ * Add an episode to the knowledge graph.
61
+ *
62
+ * @param name - Short name for the episode (e.g., "auth-refactor decision")
63
+ * @param body - Episode content to extract entities and facts from
64
+ * @param options - groupId defaults to project name, source defaults to "text"
65
+ * @returns Success response or null on failure
66
+ */
67
+ async addEpisode(name, body, options) {
68
+ if (!(await this.connect()))
69
+ return null;
70
+ try {
71
+ const result = await this.getClient().callTool({
72
+ name: "add_memory",
73
+ arguments: {
74
+ name,
75
+ episode_body: body,
76
+ group_id: options?.groupId ?? this.projectName,
77
+ source: options?.source ?? "text",
78
+ source_description: options?.sourceDescription ?? "",
79
+ },
80
+ });
81
+ return { success: !result.isError };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Search for entity nodes in the knowledge graph.
89
+ *
90
+ * @param query - Natural language search query
91
+ * @param options - groupIds defaults to [projectName, "shared"] for cross-group search
92
+ * @returns Array of matching nodes, or empty array on failure
93
+ */
94
+ async searchNodes(query, options) {
95
+ if (!(await this.connect()))
96
+ return [];
97
+ const groupIds = this.resolveGroupIds(options?.groupIds);
98
+ try {
99
+ const result = await this.getClient().callTool({
100
+ name: "search_nodes",
101
+ arguments: {
102
+ query,
103
+ group_ids: groupIds,
104
+ max_nodes: options?.maxResults ?? 10,
105
+ },
106
+ });
107
+ if (result.isError)
108
+ return [];
109
+ const content = result.content;
110
+ const text = content?.[0];
111
+ if (text?.text) {
112
+ const parsed = JSON.parse(text.text);
113
+ return parsed.nodes ?? parsed ?? [];
114
+ }
115
+ return [];
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ /**
122
+ * Search for facts (relationships between entities) in the knowledge graph.
123
+ *
124
+ * @param query - Natural language search query
125
+ * @param options - groupIds defaults to [projectName, "shared"] for cross-group search
126
+ * @returns Array of matching facts, or empty array on failure
127
+ */
128
+ async searchFacts(query, options) {
129
+ if (!(await this.connect()))
130
+ return [];
131
+ const groupIds = this.resolveGroupIds(options?.groupIds);
132
+ try {
133
+ const result = await this.getClient().callTool({
134
+ name: "search_memory_facts",
135
+ arguments: {
136
+ query,
137
+ group_ids: groupIds,
138
+ max_facts: options?.maxResults ?? 10,
139
+ },
140
+ });
141
+ if (result.isError)
142
+ return [];
143
+ const content = result.content;
144
+ const text = content?.[0];
145
+ if (text?.text) {
146
+ const parsed = JSON.parse(text.text);
147
+ return parsed.facts ?? parsed ?? [];
148
+ }
149
+ return [];
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /**
156
+ * Check if the Graphiti server is reachable and healthy.
157
+ *
158
+ * @returns Status object or null if unreachable
159
+ */
160
+ async getStatus() {
161
+ if (!(await this.connect()))
162
+ return null;
163
+ try {
164
+ const result = await this.getClient().callTool({
165
+ name: "get_status",
166
+ arguments: {},
167
+ });
168
+ if (result.isError)
169
+ return null;
170
+ const content = result.content;
171
+ const text = content?.[0];
172
+ if (text?.text) {
173
+ return JSON.parse(text.text);
174
+ }
175
+ return null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * Resolve group IDs for search — always includes "shared" for cross-group search.
183
+ * If caller provides groupIds, uses those. Otherwise defaults to [projectName, "shared"].
184
+ * Deduplicates "shared" if already present.
185
+ */
186
+ resolveGroupIds(groupIds) {
187
+ const ids = groupIds ?? [this.projectName];
188
+ if (!ids.includes("shared")) {
189
+ ids.push("shared");
190
+ }
191
+ return ids;
192
+ }
193
+ /**
194
+ * Disconnect from the Graphiti MCP server.
195
+ */
196
+ async disconnect() {
197
+ if (this.client) {
198
+ try {
199
+ await this.client.close();
200
+ }
201
+ catch {
202
+ // ignore
203
+ }
204
+ }
205
+ this.client = null;
206
+ this.transport = null;
207
+ this.connected = false;
208
+ }
209
+ }
@@ -0,0 +1,10 @@
1
+ export interface InfraConfig {
2
+ falkordb: {
3
+ host: string;
4
+ port: number;
5
+ };
6
+ graphiti: {
7
+ url: string;
8
+ };
9
+ }
10
+ export declare function getInfraConfig(): InfraConfig;
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function getInfraConfig() {
5
+ // Override: hosted endpoint
6
+ const infraUrl = process.env.INDUSK_INFRA_URL;
7
+ if (infraUrl) {
8
+ return {
9
+ falkordb: { host: infraUrl, port: 6379 },
10
+ graphiti: { url: `http://${infraUrl}:8100/mcp/` },
11
+ };
12
+ }
13
+ // Read from global config
14
+ const configFile = join(homedir(), ".indusk", "config.env");
15
+ if (existsSync(configFile)) {
16
+ const content = readFileSync(configFile, "utf-8");
17
+ const hostMatch = content.match(/^INDUSK_INFRA_HOST=(.+)$/m);
18
+ if (hostMatch) {
19
+ const host = hostMatch[1].trim();
20
+ return {
21
+ falkordb: { host, port: 6379 },
22
+ graphiti: { url: `http://${host}:8100/mcp/` },
23
+ };
24
+ }
25
+ }
26
+ // Default: local container
27
+ return {
28
+ falkordb: { host: "localhost", port: 6379 },
29
+ graphiti: { url: "http://localhost:8100/mcp/" },
30
+ };
31
+ }
@@ -1,4 +1,6 @@
1
- import { resolve } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
6
  import { registerContextTools } from "../tools/context-tools.js";
@@ -8,11 +10,33 @@ import { registerLessonTools } from "../tools/lesson-tools.js";
8
10
  import { registerPlanTools } from "../tools/plan-tools.js";
9
11
  import { registerQualityTools } from "../tools/quality-tools.js";
10
12
  import { registerSystemTools } from "../tools/system-tools.js";
13
+ function getLocalVersion() {
14
+ try {
15
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../package.json");
16
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
17
+ }
18
+ catch {
19
+ return "unknown";
20
+ }
21
+ }
22
+ function checkForUpdates(currentVersion) {
23
+ fetch("https://registry.npmjs.org/@infinitedusky/indusk-mcp/latest")
24
+ .then((res) => res.json())
25
+ .then((data) => {
26
+ if (data.version && data.version !== currentVersion) {
27
+ console.error(`[indusk] Update available: ${currentVersion} → ${data.version}. Run: npm i -g @infinitedusky/indusk-mcp@latest`);
28
+ }
29
+ })
30
+ .catch(() => { });
31
+ }
11
32
  export async function startServer() {
12
33
  const projectRoot = resolve(process.env.PROJECT_ROOT ?? ".");
34
+ const version = getLocalVersion();
35
+ // Non-blocking version check
36
+ checkForUpdates(version);
13
37
  const server = new McpServer({
14
38
  name: "indusk",
15
- version: "0.1.0",
39
+ version,
16
40
  });
17
41
  registerPlanTools(server, projectRoot);
18
42
  registerContextTools(server, projectRoot);
@@ -412,6 +412,37 @@ export function registerGraphTools(server, projectRoot) {
412
412
  });
413
413
  }
414
414
  }
415
+ // 5. Check Graphiti health
416
+ if (steps.every((s) => s.step !== "falkordb-container" || s.status !== "error")) {
417
+ try {
418
+ const health = execSync("curl -sf http://localhost:8100/health", {
419
+ encoding: "utf-8",
420
+ timeout: 5000,
421
+ stdio: ["ignore", "pipe", "pipe"],
422
+ }).trim();
423
+ if (health.includes("healthy")) {
424
+ steps.push({
425
+ step: "graphiti-health",
426
+ status: "ok",
427
+ detail: "Graphiti MCP server healthy",
428
+ });
429
+ }
430
+ else {
431
+ steps.push({
432
+ step: "graphiti-health",
433
+ status: "error",
434
+ detail: "Graphiti responded but not healthy",
435
+ });
436
+ }
437
+ }
438
+ catch {
439
+ steps.push({
440
+ step: "graphiti-health",
441
+ status: "error",
442
+ detail: "Graphiti MCP server not reachable on localhost:8100 — may still be starting (takes ~90s)",
443
+ });
444
+ }
445
+ }
415
446
  const hasErrors = steps.some((s) => s.status === "error");
416
447
  const hasFixed = steps.some((s) => s.status === "fixed");
417
448
  return {
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "graphiti",
3
+ "description": "Graphiti temporal knowledge graph — episodic memory, contradiction detection, semantic search via FalkorDB",
4
+ "provides": {
5
+ "skill": true,
6
+ "health_checks": [
7
+ {
8
+ "name": "indusk-infra-running",
9
+ "command": "docker inspect --format='{{.State.Running}}' indusk-infra 2>/dev/null | grep true"
10
+ },
11
+ {
12
+ "name": "graphiti-server-reachable",
13
+ "command": "curl -sf http://localhost:8100/health > /dev/null 2>&1"
14
+ },
15
+ {
16
+ "name": "falkordb-reachable",
17
+ "command": "redis-cli -h localhost ping 2>/dev/null | grep PONG"
18
+ }
19
+ ]
20
+ },
21
+ "detect": {
22
+ "command": "docker inspect --format='{{.State.Running}}' indusk-infra 2>/dev/null | grep true"
23
+ }
24
+ }
@@ -0,0 +1,91 @@
1
+ # Graphiti — Temporal Knowledge Graph
2
+
3
+ Graphiti is an episodic memory system backed by FalkorDB. It extracts entities and facts from text, detects contradictions, and supports semantic search across project-specific and shared knowledge.
4
+
5
+ ## When to Use
6
+
7
+ - **Episode capture**: After a decision, retro finding, or correction — anything worth remembering across sessions
8
+ - **Search**: Before making assumptions — check what's already known about a topic
9
+ - **Context retrieval**: At session start or when working in an unfamiliar area
10
+
11
+ ## Core Concepts
12
+
13
+ ### Episodes
14
+ An episode is a chunk of text that Graphiti processes into entities and facts. Think of it as "something that happened" — a decision was made, a bug was found, a convention was established.
15
+
16
+ ### Group IDs
17
+ Every episode belongs to a group. Groups isolate knowledge:
18
+
19
+ | Group | Purpose | Example |
20
+ |-------|---------|---------|
21
+ | `{project-name}` | Project-specific knowledge | `infinitedusky`, `numero` |
22
+ | `shared` | Cross-project conventions | Developer preferences, universal patterns |
23
+
24
+ When searching, always include both the project group and `shared` to get the full picture. The `GraphitiClient` does this automatically.
25
+
26
+ ### Entities and Facts
27
+ Graphiti extracts:
28
+ - **Entities**: Named things (tools, patterns, files, concepts)
29
+ - **Facts**: Relationships between entities with temporal validity
30
+
31
+ Facts can be contradicted — if you add "the parser handles three gate types" and later "the parser handles four gate types", Graphiti invalidates the old fact.
32
+
33
+ ## Patterns
34
+
35
+ ### Capturing a Decision
36
+ After an ADR is accepted or a significant choice is made:
37
+ ```
38
+ addEpisode("auth-approach-decision",
39
+ "We chose JWT with refresh tokens over session cookies because the API serves both web and mobile clients. Session cookies don't work well with React Native.",
40
+ { groupId: "myproject" })
41
+ ```
42
+
43
+ ### Capturing a Correction
44
+ When someone corrects the agent or a mistake is found:
45
+ ```
46
+ addEpisode("correction-test-database",
47
+ "Integration tests must use a real database, not mocks. We got burned when mocked tests passed but the production migration failed.",
48
+ { groupId: "shared" })
49
+ ```
50
+
51
+ ### Searching Before Acting
52
+ Before making assumptions about how something works:
53
+ ```
54
+ searchNodes("authentication middleware")
55
+ searchFacts("how does auth work in this project")
56
+ ```
57
+
58
+ ### Capturing a Retrospective Finding
59
+ After a plan retrospective surfaces a useful insight:
60
+ ```
61
+ addEpisode("retro-gate-enforcement",
62
+ "Plan gates need hook-based enforcement, not just instructions. The agent skipped gates when they were advisory only. PreToolUse hooks that block phase transitions are the fix.",
63
+ { groupId: "myproject" })
64
+ ```
65
+
66
+ ## What NOT to Capture
67
+
68
+ - Code structure (CGC handles this)
69
+ - Git history (git log handles this)
70
+ - Ephemeral state (current task, in-progress work)
71
+ - Things already in CLAUDE.md or lessons
72
+
73
+ Graphiti is for knowledge that has temporal context — decisions that might change, facts that might be contradicted, insights that accumulate over time.
74
+
75
+ ## Infrastructure
76
+
77
+ Graphiti runs inside the `indusk-infra` container alongside FalkorDB:
78
+
79
+ ```bash
80
+ indusk infra start # start the container
81
+ indusk infra status # check health
82
+ indusk infra stop # stop (preserves data)
83
+ ```
84
+
85
+ Global config (API keys, OTel): `~/.indusk/config.env`
86
+
87
+ ### Graceful Degradation
88
+ If the `indusk-infra` container is down:
89
+ - CGC graph tools still check FalkorDB directly
90
+ - Graphiti client methods return null/empty (never throw)
91
+ - The agent continues working with flat-file context (CLAUDE.md, lessons, skills)
@@ -189,19 +189,17 @@ Wrap this in a helper so every log call includes trace context automatically. Wi
189
189
 
190
190
  ## Error Propagation
191
191
 
192
- Errors must always include trace context. Never swallow silently.
193
-
194
- **Important: Span events (`AddEvent`, `RecordException`) are deprecated as of March 2026.** Use log-based events correlated with the active span instead. The log automatically inherits trace context when emitted inside an active span.
192
+ Errors must always include trace context. Never swallow silently:
195
193
 
196
194
  ```typescript
197
195
  try {
198
196
  await processSettlement(hand);
199
197
  } catch (err) {
200
- // 1. Set error status on span
198
+ // 1. Record on span
199
+ span.recordException(err);
201
200
  span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
202
201
 
203
- // 2. Log the error — automatically correlated with the active span
204
- // This replaces span.recordException(err) which is deprecated
202
+ // 2. Log with correlation
205
203
  logger.error({ err, ...getTraceContext() }, 'settlement failed');
206
204
 
207
205
  // 3. Re-throw — don't swallow
@@ -213,11 +211,6 @@ Every catch block should either:
213
211
  1. Re-throw with context preserved
214
212
  2. Log with trace correlation and handle completely
215
213
 
216
- **Migration from span events to logs:**
217
- - `span.addEvent('name', attrs)` → `logger.info({ ...attrs, ...getTraceContext() }, 'name')`
218
- - `span.recordException(err)` → `logger.error({ err, ...getTraceContext() }, 'error message')`
219
- - Span status (`setStatus`) is NOT deprecated — still use it to mark spans as errored
220
-
221
214
  ## Sensitive Data
222
215
 
223
216
  Never attach these to spans, logs, or metrics:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@infinitedusky/indusk-mcp",
3
- "version": "1.6.7",
3
+ "version": "1.7.3",
4
4
  "description": "InDusk development system — skills, MCP tools, and CLI for structured AI-assisted development",
5
5
  "type": "module",
6
6
  "files": [
package/skills/handoff.md CHANGED
@@ -46,18 +46,11 @@ Create or overwrite `.claude/handoff.md` with:
46
46
 
47
47
  ## When to Write a Handoff
48
48
 
49
+ - Before ending any session where work was done
49
50
  - When the user says "let's stop here", "wrap up", "hand off"
51
+ - When you're about to run out of context
50
52
  - `/handoff` explicitly
51
53
 
52
- ## When to Suggest a Handoff
53
-
54
- Only suggest a handoff in these situations — never ask unprompted otherwise:
55
-
56
- - **You are running low on context** and can feel the conversation getting long. Say so plainly: "I'm getting close to context limits — want me to write a handoff?"
57
- - **A major milestone just completed** (e.g., a full plan phase, a feature landed, a retrospective finished) and there's no obvious next task queued up. Mention it once — if the user keeps going, don't ask again.
58
-
59
- Do **not** suggest a handoff after every task, at natural pauses, or as a closing question. The user decides when to stop.
60
-
61
54
  ## Rules
62
55
 
63
56
  - **Be specific.** "Working on Phase 3" is useless. "Phase 3, item 4: refactored check_health to use extensions. extensions_status MCP tool created. Next: refactor init to remove hardcoded FalkorDB/CGC." is useful.