This commit is contained in:
2026-07-12 21:26:08 +08:00
commit 9dd41afd48
502 changed files with 129901 additions and 0 deletions
@@ -0,0 +1,172 @@
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<string, unknown>;
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<string, unknown>;
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(
[
"<details>",
"<summary>工具调用</summary>",
"",
tools.map((name) => `- ${name}`).join("\n"),
"",
"</details>",
].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<string, unknown> = {}) {
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 });
},
});
};
}