@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 +24 -0
- package/dist/bin/commands/infra.d.ts +3 -0
- package/dist/bin/commands/infra.js +222 -0
- package/dist/bin/commands/init.js +143 -8
- package/dist/lib/graphiti-client.d.ts +93 -0
- package/dist/lib/graphiti-client.js +209 -0
- package/dist/lib/infra-config.d.ts +10 -0
- package/dist/lib/infra-config.js +31 -0
- package/dist/server/index.js +26 -2
- package/dist/tools/graph-tools.js +31 -0
- package/extensions/graphiti/manifest.json +24 -0
- package/extensions/graphiti/skill.md +91 -0
- package/extensions/otel/skill.md +4 -11
- package/package.json +1 -1
- package/skills/handoff.md +2 -9
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,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
|
|
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(
|
|
134
|
-
console.info(
|
|
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(
|
|
142
|
+
console.info(` ${induskCommand}`);
|
|
139
143
|
}
|
|
140
144
|
}
|
|
141
145
|
else {
|
|
142
|
-
|
|
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.
|
|
477
|
-
console.info(" 2.
|
|
478
|
-
console.info(" 3.
|
|
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,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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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)
|
package/extensions/otel/skill.md
CHANGED
|
@@ -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.
|
|
198
|
+
// 1. Record on span
|
|
199
|
+
span.recordException(err);
|
|
201
200
|
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
202
201
|
|
|
203
|
-
// 2. Log
|
|
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
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.
|