import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { ActiveContext } from "../agent.js"; type ToolContent = { type: "text"; text: string }; interface SedimentParams { topic?: string; note?: string; } function formatLocalIso(date: Date): string { const offsetMinutes = -date.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; const abs = Math.abs(offsetMinutes); const hh = String(Math.floor(abs / 60)).padStart(2, "0"); const mm = String(abs % 60).padStart(2, "0"); const yyyy = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hour = String(date.getHours()).padStart(2, "0"); const minute = String(date.getMinutes()).padStart(2, "0"); const second = String(date.getSeconds()).padStart(2, "0"); return `${yyyy}-${month}-${day}T${hour}:${minute}:${second}${sign}${hh}:${mm}`; } function filenameStamp(date: Date): string { const yyyy = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hour = String(date.getHours()).padStart(2, "0"); const minute = String(date.getMinutes()).padStart(2, "0"); return `${yyyy}-${month}-${day}-${hour}${minute}`; } function extractText(message: AgentMessage): string { const content = (message as { content?: unknown }).content; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { if (!part || typeof part !== "object") return ""; const record = part as Record; return record.type === "text" && typeof record.text === "string" ? record.text : ""; }) .join(""); } function extractToolNames(message: AgentMessage): string[] { const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return []; const names: string[] = []; for (const part of content) { if (!part || typeof part !== "object") continue; const record = part as Record; if (record.type !== "tool_call" && record.type !== "toolCall") continue; if (typeof record.name === "string") names.push(record.name); else if (typeof record.toolName === "string") names.push(record.toolName); } return names; } function slugify(input: string): string { const slug = input .trim() .replace(/[^\p{Script=Han}\p{Letter}\p{Number}-]+/gu, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); return slug || "chat"; } function firstUserMessage(messages: AgentMessage[]): string { for (const message of messages) { if (message.role === "user") { const text = extractText(message).trim(); if (text) return text.slice(0, 20); } } return "chat"; } function renderMessages(messages: AgentMessage[]): string { const parts: string[] = []; for (const message of messages) { if (message.role !== "user" && message.role !== "assistant") continue; const text = extractText(message).trim(); const tools = extractToolNames(message); if (!text && tools.length === 0) continue; parts.push(message.role === "user" ? "## 👤 用户" : "## 🤖 助手"); if (text) parts.push(text); if (tools.length > 0) { parts.push( [ "
", "工具调用", "", tools.map((name) => `- ${name}`).join("\n"), "", "
", ].join("\n"), ); } } return parts.join("\n\n").trimEnd() + "\n"; } function assertInside(baseDir: string, target: string): void { const resolvedBase = path.resolve(baseDir); const resolvedTarget = path.resolve(target); if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) { throw new Error("目标路径不在 sessions 目录内"); } } function toolResult(text: string, details: Record = {}) { return { content: [{ type: "text", text } satisfies ToolContent], details }; } export function createSynthesisExtension(getActive: () => ActiveContext | null) { return function synthesisExtension(pi: ExtensionAPI) { pi.registerTool({ name: "sediment_to_wiki", label: "结晶到知识库", description: "把当前对话结晶为当前知识库 wiki/synthesis/sessions/ 下的一个 markdown 文件。可选 topic 影响文件名。", parameters: Type.Object({ topic: Type.Optional(Type.String()), note: Type.Optional(Type.String()), }), async execute(_toolCallId, params: SedimentParams) { const active = getActive(); if (!active) return toolResult("当前没有活跃知识库。请先选择一个知识库。"); const messages = active.session.state.messages; if (messages.length === 0) { return toolResult("当前对话还没有可结晶的内容。"); } const now = new Date(); const sessionsDir = path.join(active.kb.path, "wiki", "synthesis", "sessions"); await mkdir(sessionsDir, { recursive: true }); const topic = params.topic?.trim() ?? ""; const slugSource = topic || firstUserMessage(messages); const filename = `${filenameStamp(now)}-${slugify(slugSource)}.md`; const targetPath = path.join(sessionsDir, filename); assertInside(sessionsDir, targetPath); const relPath = path.posix.join("wiki", "synthesis", "sessions", filename); const body = [ "---", `date: ${formatLocalIso(now)}`, "source: chat", `conversation_id: ${active.conversationId}`, `topic: ${topic}`, "---", params.note?.trim() ? `\n${params.note.trim()}` : "", "\n" + renderMessages(messages), ].join("\n"); await writeFile(targetPath, body, "utf8"); return toolResult(`已结晶为 ${relPath}`, { path: relPath }); }, }); }; }