Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

276 lines
9.0 KiB
TypeScript

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import path from "node:path";
import crypto from "node:crypto";
import { createPlaintextBearerAuthGuard } from "./security.js";
type TextBlock = { type?: string; text?: string };
type AssistantMessage = { role?: string; content?: unknown };
type SmartSearchResult = {
title?: string;
narrative?: string;
type?: string;
combinedScore?: number;
score?: number;
observation?: {
title?: string;
narrative?: string;
type?: string;
};
};
type HealthResponse = {
status?: string;
service?: string;
version?: string;
health?: {
status?: string;
notes?: string[];
};
};
const DEFAULT_URL = process.env.AGENTMEMORY_URL || "http://localhost:3111";
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard();
const TOOL_GUIDANCE = [
"agentmemory is available for cross-session memory.",
"Use memory_search to recall prior decisions, preferences, bugs, and workflows.",
"Use memory_save when you discover durable facts worth remembering beyond this session.",
].join(" ");
function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function getText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((part) => {
if (!part || typeof part !== "object") return [] as string[];
const block = part as TextBlock;
if (block.type === "text" && typeof block.text === "string") return [block.text];
return [] as string[];
})
.join("\n")
.trim();
}
function getLastAssistantText(messages: unknown[]): string {
for (const msg of [...messages].reverse()) {
if (!msg || typeof msg !== "object") continue;
const assistant = msg as AssistantMessage;
if (assistant.role !== "assistant") continue;
const text = getText(assistant.content);
if (text) return text;
}
return "";
}
function formatSearchResults(results: SmartSearchResult[]): string {
if (!results.length) return "No relevant memories found.";
return results
.slice(0, 5)
.map((result, index) => {
const obs = result.observation ?? result;
const title = obs.title?.trim() || `Memory ${index + 1}`;
const narrative = obs.narrative?.trim() || "";
const type = obs.type?.trim() || "memory";
const score = result.combinedScore ?? result.score;
const scoreText = typeof score === "number" ? ` [score=${score.toFixed(3)}]` : "";
return `- ${title} (${type})${scoreText}${narrative ? `: ${narrative}` : ""}`;
})
.join("\n");
}
async function callAgentMemory<T>(
pathname: string,
options?: {
method?: "GET" | "POST";
body?: unknown;
baseUrl?: string;
},
): Promise<T | null> {
const baseUrl = normalizeBaseUrl(options?.baseUrl || process.env.AGENTMEMORY_URL || DEFAULT_URL);
const method = options?.method || "POST";
const url = `${baseUrl}/agentmemory/${pathname.replace(/^\/+/, "")}`;
const headers: Record<string, string> = {};
const secret = process.env.AGENTMEMORY_SECRET;
guardPlaintextBearerAuth(baseUrl, secret);
if (options?.body !== undefined) headers["Content-Type"] = "application/json";
if (secret) headers.Authorization = `Bearer ${secret}`;
try {
const response = await fetch(url, {
method,
headers,
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
export default function agentmemoryExtension(pi: ExtensionAPI) {
if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
guardPlaintextBearerAuth(
normalizeBaseUrl(process.env.AGENTMEMORY_URL || DEFAULT_URL),
process.env.AGENTMEMORY_SECRET,
);
}
let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
let currentProject = process.cwd();
let lastPrompt = "";
let lastHealthOk = false;
async function getHealth() {
return await callAgentMemory<HealthResponse>("health", { method: "GET" });
}
async function refreshStatus(ctx: { ui: { setStatus: (key: string, text: string) => void } }) {
const health = await getHealth();
lastHealthOk = !!health && (health.status === "healthy" || health.health?.status === "healthy");
ctx.ui.setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off");
}
pi.registerCommand("agentmemory-status", {
description: "Check local agentmemory server health",
handler: async (_args, ctx) => {
const health = await getHealth();
if (!health) {
ctx.ui.notify("agentmemory is unreachable at http://localhost:3111", "warning");
return;
}
ctx.ui.notify(
`agentmemory ${health.status || health.health?.status || "unknown"}${health.version ? ` v${health.version}` : ""}`,
"info",
);
},
});
pi.registerTool({
name: "memory_health",
label: "Memory Health",
description: "Check whether the local agentmemory server is reachable and healthy",
parameters: Type.Object({}),
async execute() {
const health = await getHealth();
if (!health) {
return {
content: [{ type: "text", text: "agentmemory is unreachable at http://localhost:3111" }],
details: { ok: false },
};
}
return {
content: [
{
type: "text",
text: `agentmemory status: ${health.status || health.health?.status || "unknown"}${health.version ? ` (v${health.version})` : ""}`,
},
],
details: health,
};
},
});
pi.registerTool({
name: "memory_search",
label: "Memory Search",
description: "Search agentmemory for cross-session project memory, prior decisions, bugs, and user preferences",
parameters: Type.Object({
query: Type.String({ description: "What to search for in memory" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 5, description: "Maximum results" })),
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
body: { query: params.query, limit: params.limit ?? 5 },
});
const results = result?.results || [];
return {
content: [{ type: "text", text: formatSearchResults(results) }],
details: { query: params.query, results },
};
},
});
pi.registerTool({
name: "memory_save",
label: "Memory Save",
description: "Save a durable fact, convention, workflow, preference, or bug fix into agentmemory",
parameters: Type.Object({
content: Type.String({ description: "What should be remembered" }),
type: Type.Optional(
Type.String({
description: "Memory type",
default: "fact",
}),
),
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory<Record<string, unknown>>("remember", {
body: { content: params.content, type: params.type || "fact" },
});
if (!result) {
return {
content: [{ type: "text", text: "Failed to save memory to agentmemory." }],
details: { ok: false },
};
}
return {
content: [{ type: "text", text: `Saved memory (${params.type || "fact"}): ${params.content}` }],
details: result,
};
},
});
pi.on("session_start", async (_event, ctx) => {
const sessionFile = ctx.sessionManager.getSessionFile();
sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
currentProject = process.cwd();
await refreshStatus(ctx);
});
pi.on("before_agent_start", async (event, ctx) => {
currentProject = event.systemPromptOptions.cwd || process.cwd();
lastPrompt = event.prompt?.trim() || "";
if (!lastPrompt) return;
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
body: { query: lastPrompt, limit: 5 },
});
const results = result?.results || [];
const recallBlock = results.length
? [
"Relevant long-term memory from agentmemory:",
formatSearchResults(results),
].join("\n")
: "";
await refreshStatus(ctx);
return {
systemPrompt: [event.systemPrompt, TOOL_GUIDANCE, recallBlock].filter(Boolean).join("\n\n"),
};
});
pi.on("agent_end", async (event) => {
if (!lastHealthOk || !lastPrompt) return;
const assistantText = getLastAssistantText(event.messages as unknown[]);
if (!assistantText) return;
void callAgentMemory("observe", {
body: {
hookType: "post_tool_use",
sessionId,
project: currentProject,
cwd: currentProject,
timestamp: new Date().toISOString(),
data: {
tool_name: "conversation",
tool_input: lastPrompt.slice(0, 500),
tool_output: assistantText.slice(0, 4000),
},
},
});
});
}