This commit is contained in:
Harivansh Rathi 2026-03-05 15:55:27 -08:00
parent 863135d429
commit 43337449e3
88 changed files with 18387 additions and 11 deletions

View file

@ -0,0 +1,629 @@
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);
}