add pi-graphify extension

This commit is contained in:
2026-05-10 15:29:02 +10:00
parent 9e3160a8fa
commit 6a0107aee3
15 changed files with 10167 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createTestSession, says, type TestSession, when } from "@gaodes/pi-test-harness";
import { afterEach, describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const COMMANDS_ENTRY = path.resolve(PROJECT_ROOT, "src/commands/index.ts");
function createBashMock() {
return (params: Record<string, unknown>) => {
const cmd = String(params.command ?? "");
if (cmd.includes("cat graphify-out/.graphify_python")) {
return `$ ${cmd}\n/usr/bin/python3`;
}
if (cmd.includes("import graphify") && cmd.includes("echo $?")) {
return `$ ${cmd}\n0`;
}
return `$ ${cmd}\n`;
};
}
function textOfContent(content: unknown): string {
if (!Array.isArray(content)) return String(content ?? "");
return content
.map((c) => (typeof c === "object" && c !== null && "text" in c ? String(c.text ?? "") : ""))
.join("");
}
describe("/graphify command integration", () => {
let t: TestSession;
afterEach(() => t?.dispose());
async function createSession() {
const session = await createTestSession({
extensions: [COMMANDS_ENTRY],
mockTools: {
bash: createBashMock(),
},
});
// Polyfill setTools for pi-agent-core compatibility
// biome-ignore lint/suspicious/noExplicitAny: compatibility shim accessing untyped internals
const agent = (session.session as any).agent;
if (agent && !agent.setTools) {
agent.setTools = (tools: unknown[]) => {
agent.state.tools = tools;
};
}
return session;
}
it("forwards build flags into graphify_build params contract", async () => {
t = await createSession();
await t.run(
when("/graphify . --mode deep --no-viz --obsidian --svg --graphml --neo4j", [says("ok")]),
);
const userMessages = t.events.messages.filter((m) => m.role === "user");
expect(userMessages.length).toBeGreaterThan(0);
const promptText = textOfContent(userMessages[0].content);
expect(promptText).toContain("Use the graphify_build tool with these exact params");
expect(promptText).toContain('"path":"."');
expect(promptText).toContain('"mode":"deep"');
expect(promptText).toContain('"no_viz":true');
expect(promptText).toContain('"obsidian":true');
expect(promptText).toContain('"svg":true');
expect(promptText).toContain('"graphml":true');
expect(promptText).toContain('"neo4j":true');
});
it("parses --debounce for watch subcommand", async () => {
t = await createSession();
await t.run(when("/graphify watch . --debounce 7", [says("ok")]));
const userMessages = t.events.messages.filter((m) => m.role === "user");
expect(userMessages.length).toBeGreaterThan(0);
const promptText = textOfContent(userMessages[0].content);
expect(promptText).toContain("Use the graphify_watch tool");
expect(promptText).toContain('watch "." for changes with debounce 7s');
});
});

View File

@@ -0,0 +1,564 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { AutocompleteItem } from "@earendil-works/pi-tui";
import { ensurePrimeSettings, loadConfig, type ResolvedConfig } from "../config";
import type { ExecFn } from "../lib/runner";
import {
clusterOnly,
detectPython,
ensureInstalled,
explainNode,
findPath,
hookAction,
queryGraph,
updateGraph,
} from "../lib/runner";
// ---------------------------------------------------------------------------
// Autocomplete definitions
// ---------------------------------------------------------------------------
const SUBCOMMANDS: AutocompleteItem[] = [
{
value: "",
label: "<path>",
description: "Build graph from directory (full pipeline)",
},
{
value: "query",
label: "query",
description: "Query the graph — BFS for broad context, DFS for tracing paths",
},
{
value: "path",
label: "path",
description: "Find shortest path between two concepts",
},
{
value: "explain",
label: "explain",
description: "Plain-language explanation of a node",
},
{
value: "add",
label: "add",
description: "Fetch a URL and add it to the corpus",
},
{
value: "update",
label: "update",
description: "Incremental update — re-extract only changed files",
},
{
value: "watch",
label: "watch",
description: "Watch directory for changes, auto-rebuild graph",
},
{
value: "cluster",
label: "cluster",
description: "Re-run clustering on existing graph (no re-extraction)",
},
{
value: "hook",
label: "hook",
description: "Manage git hooks (install/uninstall/status)",
},
];
const BUILD_FLAGS: AutocompleteItem[] = [
{
value: "--mode deep",
label: "--mode deep",
description: "More aggressive relationship inference",
},
{ value: "--no-viz", label: "--no-viz", description: "Skip HTML visualization" },
{ value: "--obsidian", label: "--obsidian", description: "Generate Obsidian vault" },
{ value: "--svg", label: "--svg", description: "Export graph.svg" },
{ value: "--graphml", label: "--graphml", description: "Export for Gephi / yEd" },
{ value: "--neo4j", label: "--neo4j", description: "Generate cypher.txt for Neo4j" },
{
value: "--update",
label: "--update",
description: "Incremental — re-extract only changed files",
},
{
value: "--cluster-only",
label: "--cluster-only",
description: "Rerun clustering on existing graph",
},
];
const QUERY_FLAGS: AutocompleteItem[] = [
{ value: "--dfs", label: "--dfs", description: "DFS traversal — trace a specific path" },
{
value: "--budget",
label: "--budget N",
description: "Token budget for the answer (default 2000)",
},
];
function getCompletions(argumentPrefix: string): AutocompleteItem[] {
const parts = argumentPrefix.trim().split(/\s+/);
if (parts.length <= 1) {
const prefix = parts[0] ?? "";
if (prefix.startsWith("--")) {
return BUILD_FLAGS.filter((f) => f.value.startsWith(prefix));
}
return SUBCOMMANDS.filter((s) => s.value === "" || s.value.startsWith(prefix.toLowerCase()));
}
const subcommand = parts[0].toLowerCase();
switch (subcommand) {
case "query": {
if (parts[parts.length - 1]?.startsWith("--")) {
return QUERY_FLAGS.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return [
{
value: `query "${parts.slice(1).join(" ")}`,
label: `"${parts.slice(1).join(" ")}..."`,
description: "Your question (wrap in quotes)",
},
];
}
case "path": {
return [
{
value: `path ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || '"A" "B"',
description: "Two concept names in quotes",
},
];
}
case "explain": {
return [
{
value: `explain ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || "ConceptName",
description: "Name of the concept to explain",
},
];
}
case "add": {
const addFlags: AutocompleteItem[] = [
{ value: "--author", label: '--author "Name"', description: "Tag who wrote it" },
{
value: "--contributor",
label: '--contributor "Name"',
description: "Tag who added it",
},
];
if (parts[parts.length - 1]?.startsWith("--")) {
return addFlags.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return [
{
value: `add ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || "<url>",
description: "URL to fetch and add",
},
];
}
case "update": {
return [
{
value: `update ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || ".",
description: "Directory path to update",
},
];
}
case "watch": {
return [
{
value: `watch ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || ".",
description: "Directory path to watch",
},
];
}
case "cluster": {
return [{ value: "cluster", label: "cluster", description: "Re-cluster existing graph" }];
}
case "hook": {
const hookActions: AutocompleteItem[] = [
{ value: "hook install", label: "install", description: "Install git hooks" },
{ value: "hook uninstall", label: "uninstall", description: "Remove git hooks" },
{ value: "hook status", label: "status", description: "Check hook status" },
];
const partial = parts.slice(1).join(" ").toLowerCase();
return hookActions.filter((h) => h.label.startsWith(partial || h.label));
}
default: {
if (parts[parts.length - 1]?.startsWith("--")) {
return BUILD_FLAGS.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return BUILD_FLAGS;
}
}
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
interface ParsedArgs {
subcommand:
| "build"
| "query"
| "path"
| "explain"
| "add"
| "update"
| "watch"
| "cluster"
| "hook";
positionals: string[];
flags: Record<string, string | boolean>;
}
function parseArgs(raw: string): ParsedArgs {
const tokens = raw.trim().split(/\s+/).filter(Boolean);
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
let i = 0;
let subcommand: ParsedArgs["subcommand"] = "build";
if (tokens.length > 0) {
const first = tokens[0].toLowerCase();
if (
["query", "path", "explain", "add", "update", "watch", "cluster", "hook"].includes(first) &&
!first.startsWith("-")
) {
subcommand = first as ParsedArgs["subcommand"];
i = 1;
}
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--mode" && tokens[i + 1]) {
flags.mode = tokens[i + 1];
i += 2;
} else if (token === "--budget" && tokens[i + 1]) {
flags.budget = tokens[i + 1];
i += 2;
} else if (token === "--author" && tokens[i + 1]) {
flags.author = tokens[i + 1];
i += 2;
} else if (token === "--contributor" && tokens[i + 1]) {
flags.contributor = tokens[i + 1];
i += 2;
} else if (token === "--debounce" && tokens[i + 1]) {
flags.debounce = tokens[i + 1];
i += 2;
} else if (token.startsWith("--")) {
flags[token.slice(2)] = true;
i++;
} else {
positionals.push(token);
i++;
}
}
return { subcommand, positionals, flags };
}
// ---------------------------------------------------------------------------
// Exec adapter for commands
// ---------------------------------------------------------------------------
function createExec(pi: ExtensionAPI, cwd: string): ExecFn {
return async (command, options) => {
const result = await pi.exec("sh", ["-c", command], {
cwd: options?.cwd ?? cwd,
signal: options?.signal,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.code,
};
};
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
async function handleBuild(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const inputPath = positionals[0] ?? ".";
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
if (flags["cluster-only"] === true) {
const result = await pi.exec(
"sh",
[
"-c",
`${python} -c "
import json
from networkx.readwrite import json_graph
from graphify.cluster import cluster, score_all
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
communities = cluster(G)
to_json(G, communities, 'graphify-out/graph.json')
print(f'Re-clustered: {len(communities)} communities')
"`,
],
{ cwd: ctx.cwd },
);
await ctx.ui.notify(result.stdout.trim() || "Re-clustered graph.");
return;
}
if (flags.update === true) {
const result = await updateGraph(exec, python, ctx.cwd, inputPath);
await ctx.ui.notify(
result.newFiles === 0
? "No files changed. Graph is up to date."
: `Updated: ${result.newFiles} files re-extracted. ${result.nodes} nodes, ${result.edges} edges.`,
);
return;
}
const buildArgs = {
path: inputPath,
...(flags.mode === "deep" ? { mode: "deep" } : {}),
...(flags["no-viz"] === true ? { no_viz: true } : {}),
...(flags.obsidian === true ? { obsidian: true } : {}),
...(flags.svg === true ? { svg: true } : {}),
...(flags.graphml === true ? { graphml: true } : {}),
...(flags.neo4j === true ? { neo4j: true } : {}),
};
// Full build — send a message to the agent so it uses the tool with explicit params
pi.sendUserMessage(
`Use the graphify_build tool with these exact params: ${JSON.stringify(buildArgs)}. After the graph is built, read graphify-out/GRAPH_REPORT.md and show me the God Nodes, Surprising Connections, and Suggested Questions.`,
);
}
async function handleQuery(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const question = positionals.join(" ").replace(/^["']|["']$/g, "");
if (!question) {
await ctx.ui.notify('Usage: /graphify query "<question>"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const mode = flags.dfs === true ? "dfs" : "bfs";
const budget = typeof flags.budget === "string" ? Number.parseInt(flags.budget, 10) : 2000;
const result = await queryGraph(exec, python, ctx.cwd, { question, mode, budget });
pi.sendUserMessage(
`Based on the graph query result below, answer this question: "${question}"\n\nGraph traversal result:\n${result}`,
);
}
async function handlePath(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
if (positionals.length < 2) {
await ctx.ui.notify('Usage: /graphify path "ConceptA" "ConceptB"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await findPath(exec, python, ctx.cwd, positionals[0], positionals[1]);
pi.sendUserMessage(`Explain this graph path in plain language:\n${result}`);
}
async function handleExplain(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
const concept = positionals.join(" ").replace(/^["']|["']$/g, "");
if (!concept) {
await ctx.ui.notify('Usage: /graphify explain "ConceptName"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await explainNode(exec, python, ctx.cwd, concept);
pi.sendUserMessage(
`Based on the graph data below, provide a plain-language explanation of "${concept}":\n${result}`,
);
}
async function handleAdd(
pi: ExtensionAPI,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const url = positionals[0];
if (!url) {
await pi.sendUserMessage("Usage: /graphify add <url>");
return;
}
const authorStr = typeof flags.author === "string" ? ` (author: ${flags.author})` : "";
const contributorStr =
typeof flags.contributor === "string" ? ` (contributor: ${flags.contributor})` : "";
pi.sendUserMessage(
`Use the graphify_add tool to fetch and add this URL to the corpus: ${url}${authorStr}${contributorStr}. After adding it, run an incremental graph update.`,
);
}
async function handleUpdate(pi: ExtensionAPI, positionals: string[]) {
const inputPath = positionals[0] ?? ".";
pi.sendUserMessage(
`Use the graphify_update tool to incrementally update the knowledge graph for path "${inputPath}".`,
);
}
async function handleWatch(
pi: ExtensionAPI,
_ctx: ExtensionCommandContext,
_config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const inputPath = positionals[0] ?? ".";
const debounce = typeof flags.debounce === "string" ? flags.debounce : "3";
pi.sendUserMessage(
`Use the graphify_watch tool to watch "${inputPath}" for changes with debounce ${debounce}s. Run it as a background process.`,
);
}
async function handleCluster(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
) {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
try {
const result = await clusterOnly(exec, python, ctx.cwd);
await ctx.ui.notify(`Re-clustered: ${result.communities} communities`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await ctx.ui.notify(`Cluster failed: ${message}`, "error");
}
}
async function handleHook(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
const action = positionals[0];
if (!action || !["install", "uninstall", "status"].includes(action)) {
await ctx.ui.notify("Usage: /graphify hook <install|uninstall|status>", "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await hookAction(
exec,
python,
ctx.cwd,
action as "install" | "uninstall" | "status",
);
await ctx.ui.notify(result);
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
ensurePrimeSettings();
const config = loadConfig(process.cwd());
if (!config.enabled) return;
pi.registerCommand("graphify", {
description: "Knowledge graph: build, query, explore, and update graphs from directories",
getArgumentCompletions(argumentPrefix: string): AutocompleteItem[] {
return getCompletions(argumentPrefix);
},
async handler(args: string, ctx: ExtensionCommandContext) {
const parsed = parseArgs(args);
try {
switch (parsed.subcommand) {
case "build":
await handleBuild(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "query":
await handleQuery(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "path":
await handlePath(pi, ctx, config, parsed.positionals);
break;
case "explain":
await handleExplain(pi, ctx, config, parsed.positionals);
break;
case "add":
await handleAdd(pi, parsed.positionals, parsed.flags);
break;
case "update":
await handleUpdate(pi, parsed.positionals);
break;
case "watch":
await handleWatch(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "cluster":
await handleCluster(pi, ctx, config);
break;
case "hook":
await handleHook(pi, ctx, config, parsed.positionals);
break;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await ctx.ui.notify(`Graphify error: ${message}`, "error");
}
},
});
}

View File

@@ -0,0 +1,162 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { StatusbarConfig } from "./statusbar.js";
export const EXTENSION_ID = "pi-graphify";
export const PRIME_SETTINGS_FILE = "prime-settings.json";
export interface RawConfig {
enabled?: boolean;
pythonPath?: string;
outputDir?: string;
statusbar?: StatusbarConfig;
}
export interface ResolvedConfig {
enabled: boolean;
pythonPath: string;
outputDir: string;
statusbar?: StatusbarConfig;
}
export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: true,
pythonPath: "python3",
outputDir: "graphify-out",
statusbar: {
enabled: true,
icon: "f035b",
icon_color: "accent",
icon_color_uninitialized: "dim",
text_font_color: "dim",
text_font_color_uninitialized: "dim",
show_icon: true,
show_text: true,
placement: { line: 2, side: "left", index: 4 },
separator_before: { icon: "eb8a", icon_color: "dim" },
separator_after: { icon: "eb8a", icon_color: "dim" },
},
};
function readJsonFile(path: string): Record<string, unknown> | undefined {
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return undefined;
}
}
export function resolveConfig(...configs: Array<RawConfig | undefined>): ResolvedConfig {
const resolved: ResolvedConfig = { ...DEFAULT_CONFIG };
for (const raw of configs) {
if (!raw) continue;
if (raw.enabled !== undefined) resolved.enabled = raw.enabled;
if (raw.pythonPath !== undefined) resolved.pythonPath = raw.pythonPath;
if (raw.outputDir !== undefined) resolved.outputDir = raw.outputDir;
if (raw.statusbar !== undefined) resolved.statusbar = raw.statusbar;
}
return resolved;
}
export function loadConfig(cwd: string): ResolvedConfig {
const globalPath = join(getAgentDir(), PRIME_SETTINGS_FILE);
const projectPath = join(cwd, ".pi", PRIME_SETTINGS_FILE);
const globalSettings = existsSync(globalPath) ? readJsonFile(globalPath) : undefined;
const projectSettings = existsSync(projectPath) ? readJsonFile(projectPath) : undefined;
return resolveConfig(
globalSettings?.[EXTENSION_ID] as RawConfig | undefined,
projectSettings?.[EXTENSION_ID] as RawConfig | undefined,
);
}
// ---------------------------------------------------------------------------
// Auto-seed defaults into global prime-settings.json
// ---------------------------------------------------------------------------
const DEFAULT_STATUSBAR_CONFIG: StatusbarConfig = {
enabled: true,
icon: "f035b",
icon_color: "accent",
icon_color_uninitialized: "dim",
text_font_color: "dim",
text_font_color_uninitialized: "dim",
show_icon: true,
show_text: true,
placement: { line: 2, side: "left", index: 4 },
separator_before: { icon: "eb8a", icon_color: "dim" },
separator_after: { icon: "eb8a", icon_color: "dim" },
};
const DEFAULT_EXTENSION_SETTINGS: Record<string, unknown> = {
enabled: true,
pythonPath: "python3",
outputDir: "graphify-out",
statusbar: DEFAULT_STATUSBAR_CONFIG,
};
/**
* Ensure the extension's config exists in prime-settings.json.
*
* - If the key "pi-graphify" is missing, seeds full defaults.
* - If only the legacy key "graphify" exists, migrates it to "pi-graphify".
* - Expands a minimal statusbar config to the full config.
* - Only writes when changes are actually needed.
*/
export function ensurePrimeSettings(): void {
const agentDir = getAgentDir();
const primeSettingsPath = join(agentDir, PRIME_SETTINGS_FILE);
// Only operate when prime-settings.json already exists (real Pi install)
if (!existsSync(primeSettingsPath)) return;
let settings: Record<string, unknown>;
try {
settings = JSON.parse(readFileSync(primeSettingsPath, "utf-8")) as Record<string, unknown>;
} catch {
return;
}
let changed = false;
// Migrate legacy "graphify" key → "pi-graphify"
if ("graphify" in settings && !(EXTENSION_ID in settings)) {
settings[EXTENSION_ID] = settings.graphify;
delete settings.graphify;
changed = true;
}
// Seed defaults if key is missing
if (!(EXTENSION_ID in settings)) {
settings[EXTENSION_ID] = { ...DEFAULT_EXTENSION_SETTINGS };
changed = true;
}
// Seed or expand statusbar sub-key inside existing pi-graphify config
const extensionSettings = settings[EXTENSION_ID] as Record<string, unknown>;
if (!("statusbar" in extensionSettings)) {
extensionSettings.statusbar = { ...DEFAULT_STATUSBAR_CONFIG };
changed = true;
} else {
const sb = extensionSettings.statusbar as Record<string, unknown>;
// Expand a minimal statusbar config to the full config
const needsExpansion =
!("icon" in sb) &&
!("placement" in sb) &&
!("separator_before" in sb) &&
!("separator_after" in sb);
if (needsExpansion) {
extensionSettings.statusbar = { ...DEFAULT_STATUSBAR_CONFIG, ...sb };
changed = true;
}
}
if (!changed) return;
try {
writeFileSync(primeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
} catch (error) {
console.warn("Failed to write prime-settings.json:", error);
}
}

View File

@@ -0,0 +1,226 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { detectFiles, detectPython, ensureGraphifyGitignore, ensureInstalled } from "./runner";
// ---------------------------------------------------------------------------
// Mock exec function
// ---------------------------------------------------------------------------
function createMockExec(
responses: Record<string, { stdout: string; stderr: string; exitCode: number }>,
) {
return vi.fn(async (cmd: string, _opts?: unknown) => {
for (const [pattern, response] of Object.entries(responses)) {
if (cmd.includes(pattern)) return response;
}
return { stdout: "", stderr: "unknown command", exitCode: 1 };
});
}
// ---------------------------------------------------------------------------
// detectPython
// ---------------------------------------------------------------------------
describe("detectPython", () => {
it("returns cached python from graphify-out/.graphify_python", async () => {
const mockExec = createMockExec({
"cat graphify-out/.graphify_python": {
stdout: "/usr/bin/python3\n",
stderr: "",
exitCode: 0,
},
});
const result = await detectPython(mockExec, "python3", "/tmp/test");
expect(result).toBe("/usr/bin/python3");
});
it("detects python from graphify shebang when no cache", async () => {
const mockExec = createMockExec({
"cat graphify-out": { stdout: "", stderr: "", exitCode: 1 },
"which graphify": { stdout: "/usr/local/bin/graphify\n", stderr: "", exitCode: 0 },
"head -1": { stdout: "#!/usr/local/bin/python3.11\n", stderr: "", exitCode: 0 },
});
const result = await detectPython(mockExec, "python3", "/tmp/test");
expect(result).toBe("/usr/local/bin/python3.11");
});
it("falls back to config python when graphify not found", async () => {
const mockExec = createMockExec({
"cat graphify-out": { stdout: "", stderr: "", exitCode: 1 },
"which graphify": { stdout: "", stderr: "", exitCode: 1 },
});
const result = await detectPython(mockExec, "python3.12", "/tmp/test");
expect(result).toBe("python3.12");
});
});
// ---------------------------------------------------------------------------
// ensureInstalled
// ---------------------------------------------------------------------------
describe("ensureInstalled", () => {
it("skips install when graphify is importable", async () => {
const mockExec = vi.fn(async (cmd: string, _opts?: unknown) => {
// All calls return success
if (cmd.includes("import graphify")) {
return { stdout: "0", stderr: "", exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await ensureInstalled(mockExec, "python3", "/tmp/test");
// Only called once (the initial check), no pip install
expect(mockExec).toHaveBeenCalledTimes(1);
expect(mockExec).not.toHaveBeenCalledWith(
expect.stringContaining("pip install"),
expect.anything(),
);
});
it("installs graphifyy when not importable", async () => {
let callIdx = 0;
const mockExec = vi.fn(async (cmd: string, _opts?: unknown) => {
callIdx++;
// First call: check import → fails
if (callIdx === 1 && cmd.includes("import graphify")) {
return { stdout: "1", stderr: "", exitCode: 0 };
}
// Second call: pip install → succeeds
if (cmd.includes("pip install")) {
return { stdout: "", stderr: "", exitCode: 0 };
}
// Third call: verify → succeeds
if (callIdx === 3 && cmd.includes("import graphify")) {
return { stdout: "0", stderr: "", exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await ensureInstalled(mockExec, "python3", "/tmp/test");
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining("pip install graphifyy"),
expect.anything(),
);
});
});
// ---------------------------------------------------------------------------
// detectFiles
// ---------------------------------------------------------------------------
describe("detectFiles", () => {
it("parses detection output correctly", async () => {
const detectionResult = {
total_files: 5,
total_words: 1200,
files: {
code: ["/tmp/test/main.ts", "/tmp/test/util.ts"],
document: ["/tmp/test/README.md"],
paper: [],
image: [],
video: [],
},
};
const mockExec = createMockExec({
"from graphify.detect import detect": {
stdout: JSON.stringify(detectionResult),
stderr: "",
exitCode: 0,
},
});
const result = await detectFiles(mockExec, "python3", "/tmp/test", "/tmp/test");
expect(result.total_files).toBe(5);
expect(result.total_words).toBe(1200);
expect(result.files.code).toHaveLength(2);
expect(result.files.document).toHaveLength(1);
});
it("throws on detection failure", async () => {
const mockExec = createMockExec({
"from graphify.detect import detect": {
stdout: "",
stderr: "ModuleNotFoundError: No module named 'graphify'",
exitCode: 1,
},
});
await expect(detectFiles(mockExec, "python3", "/tmp/test", "/tmp/test")).rejects.toThrow(
"graphify detect failed",
);
});
});
// ---------------------------------------------------------------------------
// ensureGraphifyGitignore
// ---------------------------------------------------------------------------
describe("ensureGraphifyGitignore", () => {
it("creates .gitignore with graphify cache exclusions when missing", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(true);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toContain("graphify-out/cache/");
expect(content).toContain("graphify-out/.graphify_python");
expect(content).toContain("graphify-out/.graphify_root");
expect(content).toContain("graphify-out/cost.json");
expect(content).not.toContain("graphify-out/\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("removes legacy graphify-out/ ignore and preserves other entries", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
await writeFile(
join(dir, ".gitignore"),
"node_modules/\ngraphify-out/\ncustom-file.txt\n",
"utf-8",
);
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(true);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toContain("node_modules/");
expect(content).toContain("custom-file.txt");
expect(content).toContain("graphify-out/cache/");
expect(content).toContain("graphify-out/.graphify_python");
expect(content).toContain("graphify-out/.graphify_root");
expect(content).toContain("graphify-out/cost.json");
expect(content).not.toContain("\ngraphify-out/\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("is idempotent when required entries already exist", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
const expected =
"node_modules/\ngraphify-out/cache/\ngraphify-out/.graphify_python\ngraphify-out/.graphify_root\ngraphify-out/cost.json\n";
await writeFile(join(dir, ".gitignore"), expected, "utf-8");
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(false);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toBe(expected);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { ResolvedConfig } from "./config.js";
const MODULE_ID = "pi-graphify";
// ── Config Interface ───────────────────────────────────────────────────────
export interface StatusbarConfig {
enabled?: boolean;
placement?: {
line?: number; // 1-based line number
side?: "left" | "right";
index?: number; // Position within side (0 = first)
};
show_icon?: boolean;
show_text?: boolean;
icon?: string; // Hex code
icon_color?: string; // "accent", "dim", "success", "warning", "error"
icon_color_uninitialized?: string;
text_font_color?: string;
text_font_color_uninitialized?: string;
separator_before?: { icon?: string; text?: string; icon_color?: string };
separator_after?: { icon?: string; text?: string; icon_color?: string };
auto_heal?: {
enabled?: boolean;
max_retries?: number;
retry_interval_ms?: number;
};
}
// ── State ──────────────────────────────────────────────────────────────────
export interface StatusbarState {
consecutiveFailures: number;
lastErrorAt: number;
healedAt: number;
isDegraded: boolean;
}
function makeStatusbarState(): StatusbarState {
return { consecutiveFailures: 0, lastErrorAt: 0, healedAt: 0, isDegraded: false };
}
// ── Helpers ────────────────────────────────────────────────────────────────
function emitStatusbarEvent(
pi: ExtensionAPI,
event: string,
data: Record<string, unknown>,
): boolean {
try {
pi.events.emit(event, data);
return true;
} catch {
return false;
}
}
function getDefaultPlacement(): NonNullable<StatusbarConfig["placement"]> {
return { line: 2, side: "left", index: 4 };
}
function resolveStatusbarConfig(config: ResolvedConfig): StatusbarConfig {
const sb = config.statusbar ?? {};
return {
enabled: sb.enabled !== false,
placement: { ...getDefaultPlacement(), ...sb.placement },
show_icon: sb.show_icon !== false,
show_text: sb.show_text !== false,
icon: sb.icon ?? "f035b",
icon_color: sb.icon_color ?? "accent",
icon_color_uninitialized: sb.icon_color_uninitialized ?? "dim",
text_font_color: sb.text_font_color ?? "dim",
text_font_color_uninitialized: sb.text_font_color_uninitialized ?? "dim",
separator_before: sb.separator_before,
separator_after: sb.separator_after,
auto_heal: {
enabled: sb.auto_heal?.enabled !== false,
max_retries: sb.auto_heal?.max_retries ?? 3,
retry_interval_ms: sb.auto_heal?.retry_interval_ms ?? 5000,
},
};
}
function shouldAttemptUpdate(
state: StatusbarState,
autoHeal: StatusbarConfig["auto_heal"],
): boolean {
if (!state.isDegraded) return true;
if (!autoHeal?.enabled) return false;
const now = Date.now();
const retryInterval = autoHeal.retry_interval_ms ?? 5000;
const maxRetries = autoHeal.max_retries ?? 3;
if (state.consecutiveFailures >= maxRetries) {
return now - state.lastErrorAt > retryInterval * 2;
}
return now - state.lastErrorAt > retryInterval;
}
// ── Public API ─────────────────────────────────────────────────────────────
export function registerGraphifyStatusbar(pi: ExtensionAPI, config: ResolvedConfig): void {
const sbConfig = resolveStatusbarConfig(config);
if (!sbConfig.enabled) return;
const contributePayload: Record<string, unknown> = {
id: MODULE_ID,
label: "Graphify",
description: "Knowledge graph status",
default_placement: sbConfig.placement,
default_style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: sbConfig.icon_color,
show_text: sbConfig.show_text,
text_font_color: sbConfig.text_font_color,
text_font_caps: "small",
text_font_style: "regular",
},
priority: 0,
};
if (sbConfig.separator_before) contributePayload.separator_before = sbConfig.separator_before;
if (sbConfig.separator_after) contributePayload.separator_after = sbConfig.separator_after;
emitStatusbarEvent(pi, "statusbar:widget:contribute", contributePayload);
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text: "initializing...",
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: sbConfig.icon_color,
show_text: sbConfig.show_text,
text_font_color: sbConfig.text_font_color,
text_font_caps: "small",
text_font_style: "regular",
},
});
}
export function unregisterGraphifyStatusbar(pi: ExtensionAPI): void {
emitStatusbarEvent(pi, "statusbar:module:unregister", { id: MODULE_ID });
}
export async function updateGraphifyStatusbar(
pi: ExtensionAPI,
config: ResolvedConfig,
ctx: ExtensionContext,
state: StatusbarState,
): Promise<void> {
const sbConfig = resolveStatusbarConfig(config);
if (!sbConfig.enabled) return;
if (!shouldAttemptUpdate(state, sbConfig.auto_heal)) return;
try {
const checkResult = await pi.exec(
"sh",
["-c", "test -f graphify-out/graph.json && echo 'yes' || echo 'no'"],
{ cwd: ctx.cwd },
);
let text: string;
let iconColor = sbConfig.icon_color ?? "accent";
let textColor = sbConfig.text_font_color ?? "dim";
if (checkResult.stdout.trim() === "yes") {
const statsResult = await pi.exec(
"sh",
[
"-c",
`python3 -c "
import json
from pathlib import Path
try:
data = json.loads(Path('graphify-out/graph.json').read_text())
nodes = len(data.get('nodes', []))
edges = len(data.get('links', []))
print(f'{nodes}n {edges}e')
except Exception:
print('graph ready')
" 2>/dev/null || echo "graph ready"`,
],
{ cwd: ctx.cwd },
);
text = statsResult.stdout.trim() || "graph ready";
} else {
text = "no graph";
iconColor = sbConfig.icon_color_uninitialized ?? "dim";
textColor = sbConfig.text_font_color_uninitialized ?? "dim";
}
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text,
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: iconColor,
show_text: sbConfig.show_text,
text_font_color: textColor,
text_font_caps: "small",
text_font_style: "regular",
},
});
if (state.isDegraded) {
state.healedAt = Date.now();
}
state.consecutiveFailures = 0;
state.isDegraded = false;
} catch (_error) {
state.consecutiveFailures += 1;
state.lastErrorAt = Date.now();
state.isDegraded = true;
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text: "error",
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: "error",
show_text: sbConfig.show_text,
text_font_color: "error",
text_font_caps: "small",
text_font_style: "regular",
},
});
}
}
export { makeStatusbarState as createStatusbarState };

View File

@@ -0,0 +1,965 @@
import type {
AgentToolResult,
AgentToolUpdateCallback,
ExtensionAPI,
ExtensionContext,
Theme,
ToolRenderResultOptions,
} from "@earendil-works/pi-coding-agent";
import { defineTool, truncateHead } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { ToolBody, ToolCallHeader, ToolFooter } from "@gaodes/pi-utils-ui";
import { type Static, Type } from "typebox";
import type { ResolvedConfig } from "../config";
import type { ExecFn } from "../lib/runner";
import {
addUrl,
buildGraph,
clusterOnly,
detectPython,
ensureInstalled,
explainNode,
findPath,
queryGraph,
startWatch,
updateGraph,
} from "../lib/runner";
// ---------------------------------------------------------------------------
// Shared exec adapter — wraps pi.exec(command, args[], opts) → ExecFn
// ---------------------------------------------------------------------------
function createExec(pi: ExtensionAPI, cwd: string): ExecFn {
return async (command, options) => {
const result = await pi.exec("sh", ["-c", command], {
cwd: options?.cwd ?? cwd,
signal: options?.signal,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.code,
};
};
}
// ---------------------------------------------------------------------------
// graphify_build
// ---------------------------------------------------------------------------
const buildParameters = Type.Object({
path: Type.String({ description: "Directory path to build graph from" }),
mode: Type.Optional(
Type.Union([Type.Literal("standard"), Type.Literal("deep")], {
description: "Extraction mode: 'deep' for more aggressive relationship inference",
}),
),
no_viz: Type.Optional(Type.Boolean({ description: "Skip HTML visualization" })),
obsidian: Type.Optional(Type.Boolean({ description: "Generate Obsidian vault" })),
svg: Type.Optional(Type.Boolean({ description: "Export graph.svg" })),
graphml: Type.Optional(Type.Boolean({ description: "Export graph.graphml" })),
neo4j: Type.Optional(Type.Boolean({ description: "Generate cypher.txt for Neo4j" })),
});
type BuildParams = Static<typeof buildParameters>;
interface BuildDetails {
path: string;
nodes: number;
edges: number;
communities: number;
outputDir: string;
}
export function createBuildTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_build",
label: "Graphify Build",
description:
"Build a knowledge graph from a directory. Runs the full pipeline: file detection, entity/relationship extraction, community detection, and output generation (HTML, JSON, report).",
parameters: buildParameters,
promptSnippet: "Use graphify_build to create a knowledge graph from any directory of files.",
promptGuidelines: [
"Call graphify_build before graphify_query, graphify_path, or graphify_explain — those tools require an existing graph.",
"Provide the exact directory path. Use '.' for the current directory.",
],
async execute(
_toolCallId: string,
params: BuildParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<BuildDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<BuildDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const result = await buildGraph(
exec,
python,
ctx.cwd,
{
inputPath: params.path,
mode: params.mode ?? "standard",
noViz: params.no_viz,
obsidian: params.obsidian,
svg: params.svg,
graphml: params.graphml,
neo4j: params.neo4j,
},
signal,
(msg) =>
onUpdate?.({
content: [{ type: "text", text: msg }],
details: {} as BuildDetails,
}),
);
return {
content: [
{
type: "text",
text: `Graph built: ${result.nodes} nodes, ${result.edges} edges, ${result.communities} communities. Output in ${config.outputDir}/`,
},
],
details: {
path: params.path,
nodes: result.nodes,
edges: result.edges,
communities: result.communities,
outputDir: config.outputDir,
},
};
},
renderCall(params: BuildParams, theme: Theme) {
const optionArgs: Array<{ label: string; value: string }> = [];
if (params.mode === "deep") optionArgs.push({ label: "mode", value: "deep" });
if (params.no_viz) optionArgs.push({ label: "no-viz", value: "true" });
if (params.obsidian) optionArgs.push({ label: "obsidian", value: "true" });
if (params.svg) optionArgs.push({ label: "svg", value: "true" });
if (params.graphml) optionArgs.push({ label: "graphml", value: "true" });
if (params.neo4j) optionArgs.push({ label: "neo4j", value: "true" });
return new ToolCallHeader(
{
toolName: "Graphify",
action: "build",
mainArg: params.path,
optionArgs,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<BuildDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: building graph..."), 0, 0);
}
const details = result.details as BuildDetails | undefined;
if (!details?.nodes) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Build failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Graph",
value: `${details.nodes} nodes | ${details.edges} edges | ${details.communities} communities`,
showCollapsed: false,
},
{ label: "Path", value: details.path, showCollapsed: true },
{ label: "Output", value: details.outputDir, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "status", value: "complete" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_query
// ---------------------------------------------------------------------------
const queryParameters = Type.Object({
question: Type.String({ description: "Natural language question to answer from the graph" }),
mode: Type.Optional(
Type.Union([Type.Literal("bfs"), Type.Literal("dfs")], {
description: "Traversal mode: bfs (broad context) or dfs (trace a specific path)",
}),
),
budget: Type.Optional(
Type.Number({ description: "Token budget for the answer (default 2000)", default: 2000 }),
),
});
type QueryParams = Static<typeof queryParameters>;
interface QueryDetails {
question: string;
mode: string;
result: string;
}
export function createQueryTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_query",
label: "Graphify Query",
description:
"Query the knowledge graph using BFS (broad context) or DFS (trace a path). Requires an existing graph built with graphify_build.",
parameters: queryParameters,
promptSnippet:
"Use graphify_query to answer questions about a codebase using its knowledge graph.",
promptGuidelines: [
"Run graphify_build before graphify_query — the graph must exist first.",
"Use BFS mode for 'what is X connected to?' questions.",
"Use DFS mode for 'how does X reach Y?' questions.",
],
async execute(
_toolCallId: string,
params: QueryParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<QueryDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<QueryDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const queryResult = await queryGraph(
exec,
python,
ctx.cwd,
{
question: params.question,
mode: params.mode ?? "bfs",
budget: params.budget,
},
signal,
);
return {
content: [{ type: "text", text: queryResult }],
details: {
question: params.question,
mode: params.mode ?? "bfs",
result: queryResult,
},
};
},
renderCall(params: QueryParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "query",
mainArg: params.question,
optionArgs: [
...(params.mode === "dfs" ? [{ label: "mode", value: "dfs" }] : []),
...(params.budget ? [{ label: "budget", value: String(params.budget) }] : []),
],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<QueryDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: querying graph..."), 0, 0);
}
const details = result.details as QueryDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Query failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
const truncated = truncateHead(details.result, {
maxBytes: 50000,
maxLines: 2000,
});
return new ToolBody(
{
fields: [
{ label: "Mode", value: details.mode.toUpperCase(), showCollapsed: true },
{ label: "Question", value: details.question, showCollapsed: true },
{ label: "Result", value: truncated.content, showCollapsed: false },
],
footer: truncated.truncated
? new ToolFooter(theme, {
items: [
{
label: "lines",
value: `${truncated.outputLines}/${truncated.totalLines}`,
},
],
separator: " | ",
})
: undefined,
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_path
// ---------------------------------------------------------------------------
const pathParameters = Type.Object({
from: Type.String({ description: "Starting concept name" }),
to: Type.String({ description: "Target concept name" }),
});
type PathParams = Static<typeof pathParameters>;
interface PathDetails {
from: string;
to: string;
result: string;
}
export function createPathTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_path",
label: "Graphify Path",
description:
"Find the shortest path between two concepts in the knowledge graph. Requires an existing graph.",
parameters: pathParameters,
promptSnippet:
"Use graphify_path to trace connections between two concepts in the knowledge graph.",
promptGuidelines: [
"Both concepts must exist as nodes in the graph.",
"Use concept names that appear in graph node labels.",
],
async execute(
_toolCallId: string,
params: PathParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<PathDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<PathDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const pathResult = await findPath(exec, python, ctx.cwd, params.from, params.to, signal);
return {
content: [{ type: "text", text: pathResult }],
details: { from: params.from, to: params.to, result: pathResult },
};
},
renderCall(params: PathParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "path",
mainArg: `${params.from}${params.to}`,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<PathDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: finding path..."), 0, 0);
}
const details = result.details as PathDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Path not found";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Path",
value: `${details.from}${details.to}`,
showCollapsed: true,
},
{ label: "Result", value: details.result, showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_explain
// ---------------------------------------------------------------------------
const explainParameters = Type.Object({
concept: Type.String({ description: "Name of the concept/node to explain" }),
});
type ExplainParams = Static<typeof explainParameters>;
interface ExplainDetails {
concept: string;
result: string;
}
export function createExplainTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_explain",
label: "Graphify Explain",
description:
"Explain a concept from the knowledge graph — shows everything connected to it. Requires an existing graph.",
parameters: explainParameters,
promptSnippet:
"Use graphify_explain to get a plain-language explanation of a concept and all its connections in the graph.",
promptGuidelines: [
"graphify_explain works best with concept names that appear as node labels in the graph.",
],
async execute(
_toolCallId: string,
params: ExplainParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<ExplainDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<ExplainDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const explainResult = await explainNode(exec, python, ctx.cwd, params.concept, signal);
return {
content: [{ type: "text", text: explainResult }],
details: { concept: params.concept, result: explainResult },
};
},
renderCall(params: ExplainParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "explain",
mainArg: params.concept,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<ExplainDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: explaining node..."), 0, 0);
}
const details = result.details as ExplainDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Explain failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "Concept", value: details.concept, showCollapsed: true },
{ label: "Details", value: details.result, showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_add
// ---------------------------------------------------------------------------
const addParameters = Type.Object({
url: Type.String({ description: "URL to fetch and add to the corpus" }),
author: Type.Optional(Type.String({ description: "Author of the content" })),
contributor: Type.Optional(Type.String({ description: "Who added this to the corpus" })),
});
type AddParams = Static<typeof addParameters>;
interface AddDetails {
url: string;
savedTo: string;
}
export function createAddTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_add",
label: "Graphify Add",
description:
"Fetch a URL (paper, tweet, PDF, image, webpage) and add it to the corpus. Then update the graph.",
parameters: addParameters,
promptSnippet: "Use graphify_add to fetch a URL and incorporate it into the knowledge graph.",
promptGuidelines: [
"graphify_add fetches the content and runs an incremental graph update automatically.",
"Supports arXiv papers, Twitter/X, PDFs, images, and general web pages.",
],
async execute(
_toolCallId: string,
params: AddParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<AddDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<AddDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
onUpdate?.({
content: [{ type: "text", text: `Fetching ${params.url}...` }],
details: {} as AddDetails,
});
const savedTo = await addUrl(
exec,
python,
ctx.cwd,
{
url: params.url,
author: params.author,
contributor: params.contributor,
},
signal,
);
onUpdate?.({
content: [{ type: "text", text: "Updating graph with new content..." }],
details: {} as AddDetails,
});
await updateGraph(exec, python, ctx.cwd, "./raw", signal);
return {
content: [{ type: "text", text: `Added ${params.url} to corpus and updated graph.` }],
details: { url: params.url, savedTo },
};
},
renderCall(params: AddParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "add",
mainArg: params.url,
optionArgs: [
...(params.author ? [{ label: "author", value: params.author }] : []),
...(params.contributor ? [{ label: "contributor", value: params.contributor }] : []),
],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<AddDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: adding URL..."), 0, 0);
}
const details = result.details as AddDetails | undefined;
if (!details?.url) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Add failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "URL", value: details.url, showCollapsed: true },
{ label: "Saved", value: details.savedTo, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "graph", value: "updated" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_update
// ---------------------------------------------------------------------------
const updateParameters = Type.Object({
path: Type.String({
description: "Directory path to update (re-extract changed files only)",
}),
});
type UpdateParams = Static<typeof updateParameters>;
interface UpdateDetails {
path: string;
newFiles: number;
nodes: number;
edges: number;
}
export function createUpdateTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_update",
label: "Graphify Update",
description:
"Incrementally update the knowledge graph — re-extract only new or changed files. Much faster than a full rebuild.",
parameters: updateParameters,
promptSnippet:
"Use graphify_update for incremental graph updates after files change, instead of rebuilding from scratch.",
promptGuidelines: [
"graphify_update is cheaper and faster than graphify_build — it only processes changed files.",
"Run graphify_update after adding or modifying files in an already-graphed directory.",
],
async execute(
_toolCallId: string,
params: UpdateParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<UpdateDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<UpdateDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const updateResult = await updateGraph(exec, python, ctx.cwd, params.path, signal, (msg) =>
onUpdate?.({
content: [{ type: "text", text: msg }],
details: {} as UpdateDetails,
}),
);
if (updateResult.newFiles === 0) {
return {
content: [
{
type: "text",
text: "No files changed since last build. Graph is up to date.",
},
],
details: { path: params.path, newFiles: 0, nodes: 0, edges: 0 },
};
}
return {
content: [
{
type: "text",
text: `Updated graph: ${updateResult.newFiles} files re-extracted. Graph now has ${updateResult.nodes} nodes and ${updateResult.edges} edges.`,
},
],
details: {
path: params.path,
newFiles: updateResult.newFiles,
nodes: updateResult.nodes,
edges: updateResult.edges,
},
};
},
renderCall(params: UpdateParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "update",
mainArg: params.path,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<UpdateDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: updating graph..."), 0, 0);
}
const details = result.details as UpdateDetails | undefined;
if (!details) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Update failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
if (details.newFiles === 0) {
return new Text(theme.fg("muted", "Graph is up to date — no files changed."), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Updated",
value: `${details.newFiles} files re-extracted`,
showCollapsed: false,
},
{
label: "Graph",
value: `${details.nodes} nodes | ${details.edges} edges`,
showCollapsed: true,
},
{ label: "Path", value: details.path, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "status", value: "updated" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_watch
// ---------------------------------------------------------------------------
const watchParameters = Type.Object({
path: Type.String({ description: "Directory path to watch" }),
debounce: Type.Optional(
Type.Number({
description: "Debounce seconds before triggering rebuild (default 3)",
default: 3,
}),
),
});
type WatchParams = Static<typeof watchParameters>;
interface WatchDetails {
path: string;
message: string;
}
export function createWatchTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_watch",
label: "Graphify Watch",
description:
"Watch a directory for file changes and auto-rebuild the graph. Code changes trigger AST rebuild; doc changes flag for manual update.",
parameters: watchParameters,
promptSnippet:
"Use graphify_watch to start a file watcher that auto-updates the knowledge graph when code changes.",
promptGuidelines: [
"graphify_watch runs in the foreground — use the process tool to run it in the background.",
"Code-only changes are rebuilt automatically. Doc/image changes require manual /graphify --update.",
],
async execute(
_toolCallId: string,
params: WatchParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<WatchDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<WatchDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const message = await startWatch(
exec,
python,
ctx.cwd,
params.path,
params.debounce ?? 3,
signal,
(msg) =>
onUpdate?.({ content: [{ type: "text", text: msg }], details: {} as WatchDetails }),
);
return {
content: [{ type: "text", text: message }],
details: { path: params.path, message },
};
},
renderCall(params: WatchParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "watch",
mainArg: params.path,
optionArgs: params.debounce
? [{ label: "debounce", value: String(params.debounce) }]
: [],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<WatchDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: watching for changes..."), 0, 0);
}
const details = result.details as WatchDetails | undefined;
if (!details?.message) {
return new Text(theme.fg("muted", "Watch ended."), 0, 0);
}
return new Text(theme.fg("muted", details.message), 0, 0);
},
});
}
// ---------------------------------------------------------------------------
// graphify_cluster
// ---------------------------------------------------------------------------
const clusterParameters = Type.Object({});
type ClusterParams = Static<typeof clusterParameters>;
interface ClusterDetails {
communities: number;
}
export function createClusterTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_cluster",
label: "Graphify Cluster",
description:
"Re-run community detection on an existing graph.json and regenerate the report. No re-extraction needed.",
parameters: clusterParameters,
promptSnippet:
"Use graphify_cluster to re-cluster an existing graph without re-extracting files.",
promptGuidelines: [
"graphify_cluster is cheap — it only reruns the clustering algorithm on existing data.",
"Requires an existing graphify-out/graph.json.",
],
async execute(
_toolCallId: string,
_params: ClusterParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<ClusterDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<ClusterDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const result = await clusterOnly(exec, python, ctx.cwd, signal);
return {
content: [{ type: "text", text: `Re-clustered: ${result.communities} communities` }],
details: { communities: result.communities },
};
},
renderCall(_params: ClusterParams, theme: Theme) {
return new ToolCallHeader(
{ toolName: "Graphify", action: "cluster", mainArg: "re-cluster", showColon: true },
theme,
);
},
renderResult(
result: AgentToolResult<ClusterDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: re-clustering..."), 0, 0);
}
const details = result.details as ClusterDetails | undefined;
if (!details?.communities) {
return new Text(theme.fg("error", "Cluster failed"), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "Communities", value: String(details.communities), showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// Re-export all creators
// ---------------------------------------------------------------------------
export function createAllTools(pi: ExtensionAPI, config: ResolvedConfig) {
return [
createBuildTool(pi, config),
createQueryTool(pi, config),
createPathTool(pi, config),
createExplainTool(pi, config),
createAddTool(pi, config),
createUpdateTool(pi, config),
createWatchTool(pi, config),
createClusterTool(pi, config),
];
}

View File

@@ -0,0 +1,390 @@
/**
* Integration tests for pi-graphify extension using @gaodes/pi-test-harness.
*
* These tests exercise the full extension lifecycle:
* - Extension loading and tool registration
* - Tool execution via playbook DSL (when/calls/says)
* - Mock bash responses simulating the graphify Python CLI
*
* The extension's tools call pi.exec("sh", ["-c", ...]) which routes through
* the built-in "bash" tool. We mock that to return deterministic responses.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { calls, createTestSession, says, type TestSession, when } from "@gaodes/pi-test-harness";
import { afterEach, describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const TOOLS_ENTRY = path.resolve(PROJECT_ROOT, "src/tools/index.ts");
// ---------------------------------------------------------------------------
// Mock responses for the graphify Python CLI
// ---------------------------------------------------------------------------
/** Successful detect output */
const MOCK_DETECT_OUTPUT = JSON.stringify({
total_files: 3,
total_words: 500,
files: {
code: ["src/main.ts", "src/util.ts"],
document: ["README.md"],
paper: [],
image: [],
video: [],
},
});
/** Mock python check -- graphify already installed */
const MOCK_PYTHON_CHECK = "0";
/** Mock detect Python from cache */
const MOCK_PYTHON_PATH = "/usr/bin/python3";
/** Successful query output */
const MOCK_QUERY_OUTPUT = `Traversal: BFS | Start: ['Main Module'] | 2 nodes
NODE Main Module [src=src/main.ts loc=]
NODE Util Module [src=src/util.ts loc=]
EDGE Main Module --imports [EXTRACTED]--> Util Module`;
/** Successful explain output */
const MOCK_EXPLAIN_OUTPUT = `NODE: Main Module
source: src/main.ts
type: code
degree: 1
CONNECTIONS:
--imports--> Util Module [EXTRACTED] (src/util.ts)`;
/** Successful ingest output */
const MOCK_INGEST_OUTPUT = "Saved to ./raw/article.md";
// ---------------------------------------------------------------------------
// Bash mock that matches graphify command patterns
// ---------------------------------------------------------------------------
function createGraphifyBashMock(responses?: Record<string, string>) {
const defaultResponses: Record<string, string> = {
".graphify_python": MOCK_PYTHON_PATH,
"import graphify": MOCK_PYTHON_CHECK,
"from graphify.detect import detect": MOCK_DETECT_OUTPUT,
"from graphify.extract import collect_files": JSON.stringify({
nodes: [
{ id: "src_main", label: "Main Module", file_type: "code", source_file: "src/main.ts" },
],
edges: [],
input_tokens: 0,
output_tokens: 0,
}),
graphify_semantic: JSON.stringify({
nodes: [],
edges: [],
hyperedges: [],
input_tokens: 0,
output_tokens: 0,
}),
"from graphify.build import build_from_json": "Graph: 2 nodes, 1 edges, 1 communities",
"Traversal:": MOCK_QUERY_OUTPUT,
"NODE:": MOCK_EXPLAIN_OUTPUT,
"from graphify.ingest import ingest": MOCK_INGEST_OUTPUT,
detect_incremental: JSON.stringify({ new_total: 0, new_files: {} }),
"from graphify.cluster import cluster": "Re-clustered: 3 communities",
"hook status": "Git hooks are not installed.",
mkdir: "",
"cat graphify-out": MOCK_PYTHON_PATH,
save_manifest: "",
benchmark: "Token reduction: 12.5x",
"rm -f": "",
write_text: "",
god_nodes: "[]",
surprising_connections: "[]",
"from graphify.report import generate": "",
to_json: "",
to_html: "graph.html written",
shortest_path: `Shortest path (1 hops):
Main Module --imports--> [EXTRACTED]
Util Module`,
"graphify.watch": "Watching . for changes...",
};
const all = { ...defaultResponses, ...responses };
return (params: Record<string, unknown>) => {
const cmd = String(params.command || "");
// Detect what kind of graphify command this is and respond accordingly
// The mock must match the *purpose* of the command, not just substrings,
// because graphify-out paths appear in many commands.
// Python detection: very first commands
if (cmd.includes("cat graphify-out/.graphify_python")) {
return `$ ${cmd}\n${all[".graphify_python"]}`;
}
if (cmd.includes("import graphify") && cmd.includes("echo $?")) {
return `$ ${cmd}\n${all["import graphify"]}`;
}
if (cmd.includes("pip install graphifyy")) {
return `$ ${cmd}\n`;
}
// mkdir
if (cmd.startsWith("mkdir")) {
return `$ ${cmd}\n${all.mkdir}`;
}
// Cleanup
if (cmd.includes("rm -f ")) {
return `$ ${cmd}\n${all["rm -f"]}`;
}
// Detect files
if (cmd.includes("from graphify.detect import detect")) {
return `$ ${cmd}\n${all["from graphify.detect import detect"]}`;
}
// AST extraction
if (cmd.includes("from graphify.extract import collect_files")) {
return `$ ${cmd}\n${all["from graphify.extract import collect_files"]}`;
}
// Semantic merge
if (cmd.includes("graphify_semantic") || cmd.includes("graphify_cached")) {
return `$ ${cmd}\n${all.graphify_semantic}`;
}
// Build graph
if (cmd.includes("from graphify.build import build_from_json")) {
return `$ ${cmd}\n${all["from graphify.build import build_from_json"]}`;
}
// Query
if (
cmd.includes("Traversal:") ||
cmd.includes("mode = 'bfs'") ||
cmd.includes("mode = 'dfs'")
) {
return `$ ${cmd}\n${all["Traversal:"]}`;
}
// Explain
if (cmd.includes("CONNECTIONS:")) {
return `$ ${cmd}\n${all["NODE:"]}`;
}
// Path
if (cmd.includes("shortest_path")) {
return `$ ${cmd}\n${all.shortest_path}`;
}
// Ingest
if (cmd.includes("from graphify.ingest import ingest")) {
return `$ ${cmd}\n${all["from graphify.ingest import ingest"]}`;
}
// Incremental update
if (cmd.includes("detect_incremental")) {
return `$ ${cmd}\n${all.detect_incremental}`;
}
// Cluster
if (cmd.includes("from graphify.cluster import cluster") && !cmd.includes("build_from_json")) {
return `$ ${cmd}\n${all["from graphify.cluster import cluster"]}`;
}
// Hook
if (cmd.includes("hook status")) {
return `$ ${cmd}\n${all["hook status"]}`;
}
// Watch
if (cmd.includes("graphify.watch")) {
return `$ ${cmd}\n${all["graphify.watch"]}`;
}
// Manifest / benchmark / report / export
if (cmd.includes("save_manifest")) return `$ ${cmd}\n${all.save_manifest}`;
if (cmd.includes("benchmark")) return `$ ${cmd}\n${all.benchmark}`;
if (cmd.includes("from graphify.report import generate"))
return `$ ${cmd}\n${all["from graphify.report import generate"]}`;
if (cmd.includes("to_json")) return `$ ${cmd}\n${all.to_json}`;
if (cmd.includes("to_html")) return `$ ${cmd}\n${all.to_html}`;
if (cmd.includes("write_text")) return `$ ${cmd}\n${all.write_text}`;
// Graph existence check
if (cmd.includes("graphify-out/graph.json") && cmd.includes("exists()")) {
// For explain/path/query — graph exists
return `$ ${cmd}\n${all["NODE:"]}`;
}
return `$ ${cmd}\n`;
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("pi-graphify extension", () => {
let t: TestSession;
afterEach(() => t?.dispose());
/** Helper: create a test session with graphify bash mock */
async function createSession(overrides?: Record<string, string>) {
const session = await createTestSession({
extensions: [TOOLS_ENTRY],
mockTools: {
bash: createGraphifyBashMock(overrides),
},
});
// Polyfill setTools for pi-agent-core compatibility
// (test-harness 1.0.1 calls setTools but pi-coding-agent 0.73 uses state.tools setter)
// biome-ignore lint/suspicious/noExplicitAny: compatibility shim accessing untyped internals
const agent = (session.session as any).agent;
if (agent && !agent.setTools) {
agent.setTools = (tools: unknown[]) => {
agent.state.tools = tools;
};
}
return session;
}
// -- Extension loading --------------------------------------------------
it("loads and registers all 8 tools", async () => {
t = await createSession();
await t.run(when("Build a knowledge graph from .", [calls("graphify_build", { path: "." })]));
const buildCalls = t.events.toolCallsFor("graphify_build");
expect(buildCalls.length).toBeGreaterThanOrEqual(1);
expect(buildCalls[0].blocked).toBe(false);
});
// -- graphify_build -----------------------------------------------------
it("builds a graph from a directory", async () => {
t = await createSession();
await t.run(
when("Build a knowledge graph from the current directory", [
calls("graphify_build", { path: "." }),
says("graph"),
]),
);
const results = t.events.toolResultsFor("graphify_build");
expect(results.length).toBeGreaterThanOrEqual(1);
});
it("respects --no-viz flag", async () => {
t = await createSession();
await t.run(
when("Build graph without visualization", [
calls("graphify_build", { path: ".", noViz: true }),
]),
);
expect(t.events.toolResultsFor("graphify_build").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_query -----------------------------------------------------
it("performs BFS traversal", async () => {
t = await createSession({
"from pathlib import Path": "graphify-out/graph.json",
});
await t.run(
when("Query the graph: what does Main Module connect to?", [
calls("graphify_query", { question: "What does Main Module connect to?" }),
]),
);
expect(t.events.toolResultsFor("graphify_query").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_explain ---------------------------------------------------
it("returns node details", async () => {
t = await createSession({
NODE: MOCK_EXPLAIN_OUTPUT,
});
await t.run(
when("Explain the Main Module concept in the graph", [
calls("graphify_explain", { concept: "Main Module" }),
]),
);
expect(t.events.toolResultsFor("graphify_explain").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_add -------------------------------------------------------
it("fetches a URL and adds to corpus", async () => {
t = await createSession();
await t.run(
when("Add this article to the graph: https://example.com/article", [
calls("graphify_add", { url: "https://example.com/article" }),
]),
);
expect(t.events.toolResultsFor("graphify_add").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_update ----------------------------------------------------
it("checks for changed files", async () => {
t = await createSession({
detect_incremental: JSON.stringify({
new_total: 2,
new_files: { code: ["src/main.ts"] },
}),
});
await t.run(when("Update the graph incrementally", [calls("graphify_update", { path: "." })]));
expect(t.events.toolResultsFor("graphify_update").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_cluster ---------------------------------------------------
it("re-runs community detection", async () => {
t = await createSession();
await t.run(when("Re-cluster the existing graph", [calls("graphify_cluster", {})]));
expect(t.events.toolResultsFor("graphify_cluster").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_watch -----------------------------------------------------
it("starts watching a directory", async () => {
t = await createSession();
await t.run(
when("Watch the current directory for changes", [calls("graphify_watch", { path: "." })]),
);
expect(t.events.toolResultsFor("graphify_watch").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_path ------------------------------------------------------
it("finds shortest path between concepts", async () => {
t = await createSession();
await t.run(
when("Find the path from Main Module to Util Module", [
calls("graphify_path", { from: "Main Module", to: "Util Module" }),
]),
);
expect(t.events.toolResultsFor("graphify_path").length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,46 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { ensurePrimeSettings, loadConfig } from "../config";
import {
createStatusbarState,
registerGraphifyStatusbar,
type StatusbarState,
unregisterGraphifyStatusbar,
updateGraphifyStatusbar,
} from "../statusbar.js";
import { createAllTools } from "./graphify-tools";
type ToolsExtensionState = {
statusbarState: StatusbarState;
};
function createToolsExtensionState(): ToolsExtensionState {
return {
statusbarState: createStatusbarState(),
};
}
export default function (pi: ExtensionAPI) {
ensurePrimeSettings();
const config = loadConfig(process.cwd());
if (!config.enabled) return;
for (const tool of createAllTools(pi, config)) {
pi.registerTool(tool);
}
const state = createToolsExtensionState();
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
registerGraphifyStatusbar(pi, config);
await updateGraphifyStatusbar(pi, config, ctx, state.statusbarState);
});
pi.on("before_agent_start", async (_event: unknown, ctx: ExtensionContext) => {
await updateGraphifyStatusbar(pi, config, ctx, state.statusbarState);
});
pi.on("session_shutdown", async () => {
unregisterGraphifyStatusbar(pi);
});
}