add pi-graphify extension
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
564
extensions/pi-graphify/src/commands/index.ts
Normal file
564
extensions/pi-graphify/src/commands/index.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
162
extensions/pi-graphify/src/config.ts
Normal file
162
extensions/pi-graphify/src/config.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
226
extensions/pi-graphify/src/lib/runner.test.ts
Normal file
226
extensions/pi-graphify/src/lib/runner.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
1029
extensions/pi-graphify/src/lib/runner.ts
Normal file
1029
extensions/pi-graphify/src/lib/runner.ts
Normal file
File diff suppressed because it is too large
Load Diff
242
extensions/pi-graphify/src/statusbar.ts
Normal file
242
extensions/pi-graphify/src/statusbar.ts
Normal 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 };
|
||||
965
extensions/pi-graphify/src/tools/graphify-tools.ts
Normal file
965
extensions/pi-graphify/src/tools/graphify-tools.ts
Normal 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),
|
||||
];
|
||||
}
|
||||
390
extensions/pi-graphify/src/tools/graphify.integration.test.ts
Normal file
390
extensions/pi-graphify/src/tools/graphify.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
extensions/pi-graphify/src/tools/index.ts
Normal file
46
extensions/pi-graphify/src/tools/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user