mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
629 lines
21 KiB
TypeScript
629 lines
21 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
import { keyHint } from "@mariozechner/pi-coding-agent";
|
|
import { Text } from "@mariozechner/pi-tui";
|
|
import { Type } from "@sinclair/typebox";
|
|
import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js";
|
|
import {
|
|
getCurrentDate,
|
|
getMemoryDir,
|
|
gitExec,
|
|
listMemoryFiles,
|
|
readMemoryFile,
|
|
syncRepository,
|
|
writeMemoryFile,
|
|
} from "./memory-md.js";
|
|
|
|
function renderWithExpandHint(text: string, theme: Theme, lineCount: number): Text {
|
|
const remaining = lineCount - 1;
|
|
if (remaining > 0) {
|
|
text +=
|
|
"\n" +
|
|
theme.fg("muted", `... (${remaining} more lines,`) +
|
|
" " +
|
|
keyHint("expandTools", "to expand") +
|
|
theme.fg("muted", ")");
|
|
}
|
|
return new Text(text, 0, 0);
|
|
}
|
|
|
|
export function registerMemorySync(
|
|
pi: ExtensionAPI,
|
|
settings: MemoryMdSettings,
|
|
isRepoInitialized: { value: boolean },
|
|
): void {
|
|
pi.registerTool({
|
|
name: "memory_sync",
|
|
label: "Memory Sync",
|
|
description: "Synchronize memory repository with git (pull/push/status)",
|
|
parameters: Type.Object({
|
|
action: Type.Union([Type.Literal("pull"), Type.Literal("push"), Type.Literal("status")], {
|
|
description: "Action to perform",
|
|
}),
|
|
}),
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
const { action } = params as { action: "pull" | "push" | "status" };
|
|
const localPath = settings.localPath!;
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
const coreUserDir = path.join(memoryDir, "core", "user");
|
|
|
|
if (action === "status") {
|
|
const initialized = isRepoInitialized.value && fs.existsSync(coreUserDir);
|
|
if (!initialized) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Memory repository not initialized. Use memory_init to set up.",
|
|
},
|
|
],
|
|
details: { initialized: false },
|
|
};
|
|
}
|
|
|
|
const result = await gitExec(pi, localPath, "status", "--porcelain");
|
|
const dirty = result.stdout.trim().length > 0;
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: dirty ? `Changes detected:\n${result.stdout}` : "No uncommitted changes",
|
|
},
|
|
],
|
|
details: { initialized: true, dirty },
|
|
};
|
|
}
|
|
|
|
if (action === "pull") {
|
|
const result = await syncRepository(pi, settings, isRepoInitialized);
|
|
return {
|
|
content: [{ type: "text", text: result.message }],
|
|
details: { success: result.success },
|
|
};
|
|
}
|
|
|
|
if (action === "push") {
|
|
const statusResult = await gitExec(pi, localPath, "status", "--porcelain");
|
|
const hasChanges = statusResult.stdout.trim().length > 0;
|
|
|
|
if (hasChanges) {
|
|
await gitExec(pi, localPath, "add", ".");
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
const commitMessage = `Update memory - ${timestamp}`;
|
|
const commitResult = await gitExec(pi, localPath, "commit", "-m", commitMessage);
|
|
|
|
if (!commitResult.success) {
|
|
return {
|
|
content: [{ type: "text", text: "Commit failed - nothing pushed" }],
|
|
details: { success: false },
|
|
};
|
|
}
|
|
}
|
|
|
|
const result = await gitExec(pi, localPath, "push");
|
|
if (result.success) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: hasChanges
|
|
? `Committed and pushed changes to repository`
|
|
: `No changes to commit, repository up to date`,
|
|
},
|
|
],
|
|
details: { success: true, committed: hasChanges },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: "Push failed - check git status" }],
|
|
details: { success: false },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: "Unknown action" }],
|
|
details: {},
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_sync "));
|
|
text += theme.fg("accent", args.action);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const content = result.content[0];
|
|
if (content?.type !== "text") {
|
|
return new Text(theme.fg("dim", "Empty result"), 0, 0);
|
|
}
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Syncing..."), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const lines = content.text.split("\n");
|
|
const summary = lines[0];
|
|
return renderWithExpandHint(theme.fg("success", summary), theme, lines.length);
|
|
}
|
|
|
|
return new Text(theme.fg("toolOutput", content.text), 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemoryRead(pi: ExtensionAPI, settings: MemoryMdSettings): void {
|
|
pi.registerTool({
|
|
name: "memory_read",
|
|
label: "Memory Read",
|
|
description: "Read a memory file by path",
|
|
parameters: Type.Object({
|
|
path: Type.String({
|
|
description: "Relative path to memory file (e.g., 'core/user/identity.md')",
|
|
}),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
const { path: relPath } = params as { path: string };
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
const fullPath = path.join(memoryDir, relPath);
|
|
|
|
const memory = readMemoryFile(fullPath);
|
|
if (!memory) {
|
|
return {
|
|
content: [{ type: "text", text: `Failed to read memory file: ${relPath}` }],
|
|
details: { error: true },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `# ${memory.frontmatter.description}\n\nTags: ${memory.frontmatter.tags?.join(", ") || "none"}\n\n${memory.content}`,
|
|
},
|
|
],
|
|
details: { frontmatter: memory.frontmatter },
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_read "));
|
|
text += theme.fg("accent", args.path);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const details = result.details as { error?: boolean; frontmatter?: MemoryFrontmatter } | undefined;
|
|
const content = result.content[0];
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Reading..."), 0, 0);
|
|
}
|
|
|
|
if (details?.error) {
|
|
const text = content?.type === "text" ? content.text : "Error";
|
|
return new Text(theme.fg("error", text), 0, 0);
|
|
}
|
|
|
|
const desc = details?.frontmatter?.description || "Memory file";
|
|
const tags = details?.frontmatter?.tags?.join(", ") || "none";
|
|
const text = content?.type === "text" ? content.text : "";
|
|
|
|
if (!expanded) {
|
|
const lines = text.split("\n");
|
|
const summary = `${theme.fg("success", desc)}\n${theme.fg("muted", `Tags: ${tags}`)}`;
|
|
return renderWithExpandHint(summary, theme, lines.length + 2);
|
|
}
|
|
|
|
let resultText = theme.fg("success", desc);
|
|
resultText += `\n${theme.fg("muted", `Tags: ${tags}`)}`;
|
|
if (text) {
|
|
resultText += `\n${theme.fg("toolOutput", text)}`;
|
|
}
|
|
return new Text(resultText, 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemoryWrite(pi: ExtensionAPI, settings: MemoryMdSettings): void {
|
|
pi.registerTool({
|
|
name: "memory_write",
|
|
label: "Memory Write",
|
|
description: "Create or update a memory file with YAML frontmatter",
|
|
parameters: Type.Object({
|
|
path: Type.String({
|
|
description: "Relative path to memory file (e.g., 'core/user/identity.md')",
|
|
}),
|
|
content: Type.String({ description: "Markdown content" }),
|
|
description: Type.String({ description: "Description for frontmatter" }),
|
|
tags: Type.Optional(Type.Array(Type.String())),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
const {
|
|
path: relPath,
|
|
content,
|
|
description,
|
|
tags,
|
|
} = params as {
|
|
path: string;
|
|
content: string;
|
|
description: string;
|
|
tags?: string[];
|
|
};
|
|
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
const fullPath = path.join(memoryDir, relPath);
|
|
|
|
const existing = readMemoryFile(fullPath);
|
|
const existingFrontmatter = existing?.frontmatter || { description };
|
|
|
|
const frontmatter: MemoryFrontmatter = {
|
|
...existingFrontmatter,
|
|
description,
|
|
updated: getCurrentDate(),
|
|
...(tags && { tags }),
|
|
};
|
|
|
|
writeMemoryFile(fullPath, content, frontmatter);
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Memory file written: ${relPath}` }],
|
|
details: { path: fullPath, frontmatter },
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_write "));
|
|
text += theme.fg("accent", args.path);
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const content = result.content[0];
|
|
if (content?.type !== "text") {
|
|
return new Text(theme.fg("dim", "Empty result"), 0, 0);
|
|
}
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Writing..."), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const details = result.details as { frontmatter?: MemoryFrontmatter } | undefined;
|
|
const lineCount = details?.frontmatter ? 3 : 1;
|
|
return renderWithExpandHint(theme.fg("success", `Written: ${content.text}`), theme, lineCount);
|
|
}
|
|
|
|
const details = result.details as { path?: string; frontmatter?: MemoryFrontmatter } | undefined;
|
|
let text = theme.fg("success", content.text);
|
|
if (details?.frontmatter) {
|
|
const fm = details.frontmatter;
|
|
text += `\n${theme.fg("muted", `Description: ${fm.description}`)}`;
|
|
if (fm.tags) {
|
|
text += `\n${theme.fg("muted", `Tags: ${fm.tags.join(", ")}`)}`;
|
|
}
|
|
}
|
|
return new Text(text, 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemoryList(pi: ExtensionAPI, settings: MemoryMdSettings): void {
|
|
pi.registerTool({
|
|
name: "memory_list",
|
|
label: "Memory List",
|
|
description: "List all memory files in the repository",
|
|
parameters: Type.Object({
|
|
directory: Type.Optional(Type.String({ description: "Filter by directory (e.g., 'core/user')" })),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
const { directory } = params as { directory?: string };
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
const searchDir = directory ? path.join(memoryDir, directory) : memoryDir;
|
|
const files = listMemoryFiles(searchDir);
|
|
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory files (${relPaths.length}):\n\n${relPaths.map((p) => ` - ${p}`).join("\n")}`,
|
|
},
|
|
],
|
|
details: { files: relPaths, count: relPaths.length },
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_list"));
|
|
if (args.directory) {
|
|
text += ` ${theme.fg("accent", args.directory)}`;
|
|
}
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const details = result.details as { count?: number } | undefined;
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Listing..."), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const count = details?.count ?? 0;
|
|
const content = result.content[0];
|
|
const lines = content?.type === "text" ? content.text.split("\n") : [];
|
|
return renderWithExpandHint(theme.fg("success", `${count} memory files`), theme, lines.length);
|
|
}
|
|
|
|
const content = result.content[0];
|
|
const text = content?.type === "text" ? content.text : "";
|
|
return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemorySearch(pi: ExtensionAPI, settings: MemoryMdSettings): void {
|
|
pi.registerTool({
|
|
name: "memory_search",
|
|
label: "Memory Search",
|
|
description: "Search memory files by content or tags",
|
|
parameters: Type.Object({
|
|
query: Type.String({ description: "Search query" }),
|
|
searchIn: Type.Union([Type.Literal("content"), Type.Literal("tags"), Type.Literal("description")], {
|
|
description: "Where to search",
|
|
}),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
const { query, searchIn } = params as {
|
|
query: string;
|
|
searchIn: "content" | "tags" | "description";
|
|
};
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
const files = listMemoryFiles(memoryDir);
|
|
const results: Array<{ path: string; match: string }> = [];
|
|
|
|
const queryLower = query.toLowerCase();
|
|
|
|
for (const filePath of files) {
|
|
const memory = readMemoryFile(filePath);
|
|
if (!memory) continue;
|
|
|
|
const relPath = path.relative(memoryDir, filePath);
|
|
const { frontmatter, content } = memory;
|
|
|
|
if (searchIn === "content") {
|
|
if (content.toLowerCase().includes(queryLower)) {
|
|
const lines = content.split("\n");
|
|
const matchLine = lines.find((line) => line.toLowerCase().includes(queryLower));
|
|
results.push({ path: relPath, match: matchLine || content.substring(0, 100) });
|
|
}
|
|
} else if (searchIn === "tags") {
|
|
if (frontmatter.tags?.some((tag) => tag.toLowerCase().includes(queryLower))) {
|
|
results.push({ path: relPath, match: `Tags: ${frontmatter.tags?.join(", ")}` });
|
|
}
|
|
} else if (searchIn === "description") {
|
|
if (frontmatter.description.toLowerCase().includes(queryLower)) {
|
|
results.push({ path: relPath, match: frontmatter.description });
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Found ${results.length} result(s):\n\n${results.map((r) => ` ${r.path}\n ${r.match}`).join("\n\n")}`,
|
|
},
|
|
],
|
|
details: { results, count: results.length },
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_search "));
|
|
text += theme.fg("accent", `"${args.query}"`);
|
|
text += ` ${theme.fg("muted", args.searchIn)}`;
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const details = result.details as { count?: number } | undefined;
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const count = details?.count ?? 0;
|
|
const content = result.content[0];
|
|
const lines = content?.type === "text" ? content.text.split("\n") : [];
|
|
return renderWithExpandHint(theme.fg("success", `${count} result(s)`), theme, lines.length);
|
|
}
|
|
|
|
const content = result.content[0];
|
|
const text = content?.type === "text" ? content.text : "";
|
|
return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemoryInit(
|
|
pi: ExtensionAPI,
|
|
settings: MemoryMdSettings,
|
|
isRepoInitialized: { value: boolean },
|
|
): void {
|
|
pi.registerTool({
|
|
name: "memory_init",
|
|
label: "Memory Init",
|
|
description: "Initialize memory repository (clone or create initial structure)",
|
|
parameters: Type.Object({
|
|
force: Type.Optional(Type.Boolean({ description: "Reinitialize even if already set up" })),
|
|
}) as any,
|
|
|
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
const { force = false } = params as { force?: boolean };
|
|
|
|
if (isRepoInitialized.value && !force) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Memory repository already initialized. Use force: true to reinitialize.",
|
|
},
|
|
],
|
|
details: { initialized: true },
|
|
};
|
|
}
|
|
|
|
const result = await syncRepository(pi, settings, isRepoInitialized);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: result.success
|
|
? `Memory repository initialized:\n${result.message}\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`
|
|
: `Initialization failed: ${result.message}`,
|
|
},
|
|
],
|
|
details: { success: result.success },
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
let text = theme.fg("toolTitle", theme.bold("memory_init"));
|
|
if (args.force) {
|
|
text += ` ${theme.fg("warning", "--force")}`;
|
|
}
|
|
return new Text(text, 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const details = result.details as { initialized?: boolean; success?: boolean } | undefined;
|
|
const content = result.content[0];
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Initializing..."), 0, 0);
|
|
}
|
|
|
|
if (details?.initialized) {
|
|
return new Text(theme.fg("muted", "Already initialized"), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const success = details?.success;
|
|
const contentText = content?.type === "text" ? content.text : "";
|
|
const lines = contentText.split("\n");
|
|
const summary = success ? theme.fg("success", "Initialized") : theme.fg("error", "Initialization failed");
|
|
return renderWithExpandHint(summary, theme, lines.length);
|
|
}
|
|
|
|
const text = content?.type === "text" ? content.text : "";
|
|
return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerMemoryCheck(pi: ExtensionAPI, settings: MemoryMdSettings): void {
|
|
pi.registerTool({
|
|
name: "memory_check",
|
|
label: "Memory Check",
|
|
description: "Check current project memory folder structure",
|
|
parameters: Type.Object({}) as any,
|
|
|
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
const memoryDir = getMemoryDir(settings, ctx);
|
|
|
|
if (!fs.existsSync(memoryDir)) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory directory not found: ${memoryDir}\n\nProject memory may not be initialized yet.`,
|
|
},
|
|
],
|
|
details: { exists: false },
|
|
};
|
|
}
|
|
|
|
const { execSync } = await import("node:child_process");
|
|
let treeOutput = "";
|
|
|
|
try {
|
|
treeOutput = execSync(`tree -L 3 -I "node_modules" "${memoryDir}"`, { encoding: "utf-8" });
|
|
} catch {
|
|
try {
|
|
treeOutput = execSync(`find "${memoryDir}" -type d -not -path "*/node_modules/*" | head -20`, {
|
|
encoding: "utf-8",
|
|
});
|
|
} catch {
|
|
treeOutput = "Unable to generate directory tree. Please check permissions.";
|
|
}
|
|
}
|
|
|
|
const files = listMemoryFiles(memoryDir);
|
|
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory directory structure for project: ${path.basename(ctx.cwd)}\n\nPath: ${memoryDir}\n\n${treeOutput}\n\nMemory files (${relPaths.length}):\n${relPaths.map((p) => ` ${p}`).join("\n")}`,
|
|
},
|
|
],
|
|
details: { path: memoryDir, fileCount: relPaths.length },
|
|
};
|
|
},
|
|
|
|
renderCall(_args, theme) {
|
|
return new Text(theme.fg("toolTitle", theme.bold("memory_check")), 0, 0);
|
|
},
|
|
|
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
const details = result.details as { exists?: boolean; path?: string; fileCount?: number } | undefined;
|
|
const content = result.content[0];
|
|
|
|
if (isPartial) {
|
|
return new Text(theme.fg("warning", "Checking..."), 0, 0);
|
|
}
|
|
|
|
if (!expanded) {
|
|
const exists = details?.exists ?? true;
|
|
const fileCount = details?.fileCount ?? 0;
|
|
const contentText = content?.type === "text" ? content.text : "";
|
|
const lines = contentText.split("\n");
|
|
const summary = exists
|
|
? theme.fg("success", `Structure: ${fileCount} files`)
|
|
: theme.fg("error", "Not initialized");
|
|
return renderWithExpandHint(summary, theme, lines.length);
|
|
}
|
|
|
|
const text = content?.type === "text" ? content.text : "";
|
|
return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function registerAllTools(
|
|
pi: ExtensionAPI,
|
|
settings: MemoryMdSettings,
|
|
isRepoInitialized: { value: boolean },
|
|
): void {
|
|
registerMemorySync(pi, settings, isRepoInitialized);
|
|
registerMemoryRead(pi, settings);
|
|
registerMemoryWrite(pi, settings);
|
|
registerMemoryList(pi, settings);
|
|
registerMemorySearch(pi, settings);
|
|
registerMemoryInit(pi, settings, isRepoInitialized);
|
|
registerMemoryCheck(pi, settings);
|
|
}
|