first
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@llm-wiki-agent/server",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "llm-wiki-agent 后端:Hono + pi-coding-agent SDK",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"prebuild": "npm run build -w @llm-wiki/graph-engine",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"pretypecheck": "npm run build -w @llm-wiki/graph-engine",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.79.4",
|
||||
"@earendil-works/pi-ai": "^0.79.4",
|
||||
"@earendil-works/pi-coding-agent": "^0.79.4",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@llm-wiki/graph-engine": "0.0.0",
|
||||
"hono": "^4.6.0",
|
||||
"typebox": "^1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract images from docx with context (surrounding paragraphs).
|
||||
|
||||
Parses document.xml to find <w:drawing> elements and extracts:
|
||||
- Image file path (from word/media/)
|
||||
- Relationship ID
|
||||
- Context: text from paragraphs before and after the image
|
||||
|
||||
Output: JSON with image list and context info.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Namespaces
|
||||
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
NS = {
|
||||
"w": W_NS,
|
||||
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
||||
"a": A_NS,
|
||||
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
|
||||
"r": R_NS,
|
||||
}
|
||||
|
||||
|
||||
def extract_docx_images(docx_path: str, output_dir: str) -> dict:
|
||||
"""Extract images from docx with context information.
|
||||
|
||||
Args:
|
||||
docx_path: Path to the .docx file
|
||||
output_dir: Directory to extract images to
|
||||
|
||||
Returns:
|
||||
Dict with 'images' list containing path, relId, contextBefore, contextAfter
|
||||
"""
|
||||
docx = Path(docx_path)
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not docx.exists():
|
||||
return {"error": f"File not found: {docx_path}", "images": []}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(docx, "r") as zf:
|
||||
# Extract all media files
|
||||
media_files = [f for f in zf.namelist() if f.startswith("word/media/")]
|
||||
for mf in media_files:
|
||||
zf.extract(mf, out)
|
||||
|
||||
# Parse document.xml for image positions
|
||||
with zf.open("word/document.xml") as f:
|
||||
doc_xml = f.read()
|
||||
|
||||
# Parse relationships
|
||||
with zf.open("word/_rels/document.xml.rels") as f:
|
||||
rels_xml = f.read()
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to read docx: {e}", "images": []}
|
||||
|
||||
# Build relId -> media file mapping
|
||||
rels_root = ET.fromstring(rels_xml)
|
||||
rel_map = {}
|
||||
for rel in rels_root:
|
||||
rel_id = rel.get("Id")
|
||||
target = rel.get("Target") # e.g., "media/image1.png"
|
||||
if rel_id and target and target.startswith("media/"):
|
||||
rel_map[rel_id] = target
|
||||
|
||||
# Parse document to find images with context
|
||||
doc_root = ET.fromstring(doc_xml)
|
||||
body = doc_root.find(".//w:body", NS)
|
||||
if body is None:
|
||||
return {"error": "No body found in document", "images": []}
|
||||
|
||||
paragraphs = list(body.findall("w:p", NS))
|
||||
|
||||
images = []
|
||||
for idx, para in enumerate(paragraphs):
|
||||
# Find all drawings in this paragraph
|
||||
drawings = para.findall(".//w:drawing", NS)
|
||||
if not drawings:
|
||||
continue
|
||||
|
||||
# Get context: text from previous and next non-empty paragraphs
|
||||
context_before = ""
|
||||
for i in range(idx - 1, -1, -1):
|
||||
text = "".join(t.text or "" for t in paragraphs[i].findall(".//w:t", NS))
|
||||
if text.strip():
|
||||
context_before = text.strip()
|
||||
break
|
||||
|
||||
context_after = ""
|
||||
for i in range(idx + 1, len(paragraphs)):
|
||||
text = "".join(t.text or "" for t in paragraphs[i].findall(".//w:t", NS))
|
||||
if text.strip():
|
||||
context_after = text.strip()
|
||||
break
|
||||
|
||||
# Extract image info from each drawing
|
||||
for drawing in drawings:
|
||||
# Find the blip reference
|
||||
blip = drawing.find(".//a:blip", NS)
|
||||
if blip is None:
|
||||
continue
|
||||
|
||||
rel_id = blip.get(f"{{{R_NS}}}embed")
|
||||
if not rel_id:
|
||||
continue
|
||||
|
||||
media_target = rel_map.get(rel_id)
|
||||
if not media_target:
|
||||
continue
|
||||
|
||||
# Copy image to output directory
|
||||
# zip extracts to word/media/, rels target is media/
|
||||
src_path = out / "word" / media_target
|
||||
if src_path.exists():
|
||||
filename = Path(media_target).name
|
||||
dest_path = out / filename
|
||||
if src_path != dest_path:
|
||||
import shutil
|
||||
shutil.copy2(str(src_path), str(dest_path))
|
||||
|
||||
images.append({
|
||||
"filename": filename,
|
||||
"path": str(dest_path),
|
||||
"relId": rel_id,
|
||||
"contextBefore": context_before,
|
||||
"contextAfter": context_after,
|
||||
})
|
||||
|
||||
return {"images": images}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: extract-docx-images.py <docx_path> <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
result = extract_docx_images(sys.argv[1], sys.argv[2])
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* agent.ts - pi-coding-agent SDK 的活跃会话管理
|
||||
*
|
||||
* 阶段一 step 8:从单 in-memory session 升级为
|
||||
* "活跃上下文 = (当前 KB, 当前对话, pi session 实例)"
|
||||
*
|
||||
* 会话持久化到 ~/.llm-wiki-agent/sessions/<kb-hash>/,由 pi SessionManager 管理。
|
||||
* 任一时刻只有一个 active session(单用户、单浏览器假设)。
|
||||
*
|
||||
* 切换逻辑:
|
||||
* selectKb(kbPath) → 先 dispose 老 session,再尝试 continueRecent,
|
||||
* 如果该 KB 无任何对话则创建新的
|
||||
* selectConversation(kbPath, conversationId) → dispose 老 + 打开指定文件
|
||||
* createNewConversation(kbPath) → dispose 老 + 新建空白对话
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
type AgentSession,
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
|
||||
import { loadConfig, saveConfig } from "./config.js";
|
||||
import type { ModelRef } from "./config.js";
|
||||
import { ensureKbSessionDir, listConversations } from "./conversations.js";
|
||||
import { scanAndRebuildArtifactIndex } from "./artifacts.js";
|
||||
import { createArtifactsExtension } from "./extensions/artifacts.js";
|
||||
import knowledgeBaseExtension from "./extensions/knowledge-base.js";
|
||||
import { setCurrentKnowledgeBase } from "./extensions/knowledge-base.js";
|
||||
import { createNewWikiExtension } from "./extensions/new-wiki.js";
|
||||
import { createSynthesisExtension } from "./extensions/synthesis.js";
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const PROJECT_SKILLS_DIR = path.join(REPO_ROOT, ".claude", "skills");
|
||||
const PI_DEFAULT_SKILLS_DIR = path.join(homedir(), ".pi", "agent", "skills");
|
||||
const USER_GLOBAL_SKILLS_DIR = path.join(homedir(), ".claude", "skills");
|
||||
|
||||
export const authStorage = AuthStorage.create();
|
||||
export const modelRegistry = ModelRegistry.create(authStorage);
|
||||
const settingsManager = SettingsManager.create(REPO_ROOT, getAgentDir());
|
||||
|
||||
function registerPendingProviders(loader: DefaultResourceLoader): void {
|
||||
const extensions = loader.getExtensions();
|
||||
const runtime = extensions.runtime as {
|
||||
pendingProviderRegistrations?: Array<{
|
||||
name: string;
|
||||
config: Parameters<ModelRegistry["registerProvider"]>[1];
|
||||
extensionPath?: string;
|
||||
}>;
|
||||
};
|
||||
const pending = runtime.pendingProviderRegistrations ?? [];
|
||||
for (const { name, config, extensionPath } of pending) {
|
||||
try {
|
||||
modelRegistry.registerProvider(name, config);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[agent] register provider from ${extensionPath ?? "extension"} failed: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.pendingProviderRegistrations = [];
|
||||
}
|
||||
|
||||
async function rememberLastUsedKb(kbPath: string): Promise<void> {
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
if (config.lastUsedKbPath === kbPath) return;
|
||||
await saveConfig({ ...config, lastUsedKbPath: kbPath });
|
||||
} catch (err) {
|
||||
console.warn(`[agent] 写入 lastUsedKbPath 失败: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveContext {
|
||||
kb: { path: string; name: string };
|
||||
session: AgentSession;
|
||||
conversationId: string;
|
||||
isNew: boolean; // 本次 select 是否新建的会话
|
||||
}
|
||||
|
||||
let resourceLoaderState: {
|
||||
includeUserGlobal: boolean;
|
||||
promise: Promise<DefaultResourceLoader>;
|
||||
} | null = null;
|
||||
let active: ActiveContext | null = null;
|
||||
|
||||
export async function getResourceLoader(): Promise<DefaultResourceLoader> {
|
||||
const includeUserGlobal = (await loadConfig()).showUserGlobalSkills === true;
|
||||
if (!resourceLoaderState || resourceLoaderState.includeUserGlobal !== includeUserGlobal) {
|
||||
const additionalSkillPaths = [
|
||||
PROJECT_SKILLS_DIR,
|
||||
...(includeUserGlobal ? [USER_GLOBAL_SKILLS_DIR] : []),
|
||||
];
|
||||
resourceLoaderState = {
|
||||
includeUserGlobal,
|
||||
promise: (async () => {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: REPO_ROOT,
|
||||
agentDir: getAgentDir(),
|
||||
additionalSkillPaths,
|
||||
extensionFactories: [
|
||||
knowledgeBaseExtension,
|
||||
createSynthesisExtension(() => active),
|
||||
createNewWikiExtension(),
|
||||
createArtifactsExtension(() => active),
|
||||
],
|
||||
});
|
||||
await loader.reload();
|
||||
registerPendingProviders(loader);
|
||||
console.log(
|
||||
`[agent] ResourceLoader ready (project skills + user-global ${includeUserGlobal ? "on" : "off"})`,
|
||||
);
|
||||
return loader;
|
||||
})().catch((err) => {
|
||||
resourceLoaderState = null;
|
||||
throw err;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return resourceLoaderState.promise;
|
||||
}
|
||||
|
||||
export async function reloadActiveResources(): Promise<ActiveContext | null> {
|
||||
resourceLoaderState = null;
|
||||
if (!active) return null;
|
||||
return selectConversation(active.kb.path, active.conversationId);
|
||||
}
|
||||
|
||||
async function disposeActive(): Promise<void> {
|
||||
if (active) {
|
||||
try {
|
||||
active.session.dispose();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActive(): ActiveContext | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function getActiveSession(): Promise<AgentSession> {
|
||||
if (!active) {
|
||||
throw new Error("没有活跃对话。请先选择知识库或新建对话。");
|
||||
}
|
||||
return active.session;
|
||||
}
|
||||
|
||||
export interface LoadedSkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
source: "builtin" | "pi-default" | "user-global";
|
||||
skillPath: string;
|
||||
}
|
||||
|
||||
async function findSkillFiles(root: string): Promise<string[]> {
|
||||
const rootInfo = await stat(root).catch(() => null);
|
||||
if (!rootInfo?.isDirectory()) return [];
|
||||
const results: string[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
|
||||
results.push(path.join(dir, "SKILL.md"));
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
await walk(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseSkillFrontmatter(
|
||||
content: string,
|
||||
source: LoadedSkillInfo["source"],
|
||||
skillPath: string,
|
||||
): LoadedSkillInfo | null {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
const frontmatter = match[1] ?? "";
|
||||
const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim();
|
||||
let description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (description === "|") {
|
||||
const lines = frontmatter.split("\n");
|
||||
const start = lines.findIndex((line) => line.startsWith("description:"));
|
||||
description = lines
|
||||
.slice(start + 1)
|
||||
.filter((line) => line.startsWith(" "))
|
||||
.map((line) => line.trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
if (!name || !description) return null;
|
||||
return { name, description, source, skillPath };
|
||||
}
|
||||
|
||||
async function scanSkillDirs(): Promise<LoadedSkillInfo[]> {
|
||||
const roots = [
|
||||
{ root: PROJECT_SKILLS_DIR, source: "builtin" as const },
|
||||
{ root: PI_DEFAULT_SKILLS_DIR, source: "pi-default" as const },
|
||||
{ root: USER_GLOBAL_SKILLS_DIR, source: "user-global" as const },
|
||||
];
|
||||
|
||||
const skills: LoadedSkillInfo[] = [];
|
||||
for (const { root, source } of roots) {
|
||||
for (const file of await findSkillFiles(root)) {
|
||||
const parsed = parseSkillFrontmatter(
|
||||
await readFile(file, "utf8").catch(() => ""),
|
||||
source,
|
||||
path.dirname(file),
|
||||
);
|
||||
if (parsed) skills.push(parsed);
|
||||
}
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function sourceForSkillPath(skillPath: string): LoadedSkillInfo["source"] {
|
||||
const resolved = path.resolve(skillPath);
|
||||
if (resolved === PROJECT_SKILLS_DIR || resolved.startsWith(PROJECT_SKILLS_DIR + path.sep)) {
|
||||
return "builtin";
|
||||
}
|
||||
if (resolved === PI_DEFAULT_SKILLS_DIR || resolved.startsWith(PI_DEFAULT_SKILLS_DIR + path.sep)) {
|
||||
return "pi-default";
|
||||
}
|
||||
return "user-global";
|
||||
}
|
||||
|
||||
export async function listLoadedSkills(): Promise<LoadedSkillInfo[]> {
|
||||
const loader = await getResourceLoader();
|
||||
const sdkSkills = loader.getSkills().skills;
|
||||
const scanned = await scanSkillDirs();
|
||||
const byPath = new Map(scanned.map((skill) => [path.resolve(skill.skillPath), skill]));
|
||||
const results: LoadedSkillInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const skill of scanned) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
seen.add(skill.name);
|
||||
results.push(skill);
|
||||
}
|
||||
for (const skill of sdkSkills) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
const filePath = (skill as { filePath?: string }).filePath;
|
||||
const dir = filePath ? path.dirname(filePath) : "";
|
||||
const known = dir ? byPath.get(path.resolve(dir)) : undefined;
|
||||
seen.add(skill.name);
|
||||
results.push(
|
||||
known ?? {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: dir ? sourceForSkillPath(dir) : "user-global",
|
||||
skillPath: dir,
|
||||
},
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface AvailableModelInfo {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
cost: { input: number; output: number };
|
||||
hasAuth: boolean;
|
||||
}
|
||||
|
||||
export function listAvailableModels(): AvailableModelInfo[] {
|
||||
modelRegistry.refresh();
|
||||
const available = modelRegistry.getAvailable();
|
||||
return available.map((model) => ({
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
cost: { input: model.cost.input, output: model.cost.output },
|
||||
hasAuth: modelRegistry.hasConfiguredAuth(model),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getPiDefaultModelRef(): { provider?: string; modelId?: string } {
|
||||
return {
|
||||
provider: settingsManager.getDefaultProvider(),
|
||||
modelId: settingsManager.getDefaultModel(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRoleModel(role: "main" | "digest"): Promise<Model<any> | undefined> {
|
||||
const config = await loadConfig();
|
||||
const ref = config.modelRoles?.[role];
|
||||
if (ref) {
|
||||
const model = getConfiguredModel(ref);
|
||||
if (model) {
|
||||
console.log(`[agent] role=${role} model=${model.provider}/${model.id}`);
|
||||
return model;
|
||||
}
|
||||
console.warn(`[agent] role=${role} model ${ref.provider}/${ref.modelId} 不可用,回退 pi 默认`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getConfiguredModel(ref: ModelRef): Model<any> | undefined {
|
||||
modelRegistry.refresh();
|
||||
const model = modelRegistry.find(ref.provider, ref.modelId);
|
||||
if (model && modelRegistry.hasConfiguredAuth(model)) return model;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择/进入一个知识库:dispose 老 session,加载/新建该库下的活跃对话。
|
||||
* - 该 KB 有对话:opens most recent
|
||||
* - 该 KB 无对话:creates a new one
|
||||
*/
|
||||
export async function selectKb(kbPath: string): Promise<ActiveContext> {
|
||||
await disposeActive();
|
||||
const kb = await setCurrentKnowledgeBase(kbPath);
|
||||
const dir = await ensureKbSessionDir(kbPath);
|
||||
const loader = await getResourceLoader();
|
||||
|
||||
const existing = await listConversations(kbPath);
|
||||
|
||||
let isNew = false;
|
||||
let sessionManager: ReturnType<typeof SessionManager.create>;
|
||||
const mostRecent = existing[0];
|
||||
if (mostRecent) {
|
||||
sessionManager = SessionManager.open(mostRecent.path);
|
||||
} else {
|
||||
sessionManager = SessionManager.create(process.cwd(), dir);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
const model = await getRoleModel("main");
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
resourceLoader: loader,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (modelFallbackMessage) console.log(`[agent] ${modelFallbackMessage}`);
|
||||
|
||||
active = { kb, session, conversationId: session.sessionId, isNew };
|
||||
await rememberLastUsedKb(kbPath);
|
||||
console.log(
|
||||
`[agent] selectKb ${kb.name} → conversation ${active.conversationId.slice(0, 8)} (${isNew ? "new" : "resumed"})`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切到该 KB 下指定的对话。
|
||||
*/
|
||||
export async function selectConversation(
|
||||
kbPath: string,
|
||||
conversationId: string,
|
||||
): Promise<ActiveContext> {
|
||||
const list = await listConversations(kbPath);
|
||||
const target = list.find((c) => c.id === conversationId);
|
||||
if (!target) {
|
||||
throw new Error(`对话不存在:${conversationId}`);
|
||||
}
|
||||
|
||||
await disposeActive();
|
||||
const kb = await setCurrentKnowledgeBase(kbPath);
|
||||
const loader = await getResourceLoader();
|
||||
|
||||
const model = await getRoleModel("main");
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.open(target.path),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (modelFallbackMessage) console.log(`[agent] ${modelFallbackMessage}`);
|
||||
|
||||
active = { kb, session, conversationId: session.sessionId, isNew: false };
|
||||
await rememberLastUsedKb(kbPath);
|
||||
console.log(
|
||||
`[agent] selectConversation ${kb.name} → ${active.conversationId.slice(0, 8)}`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在该 KB 下新建一个空白对话,并设为活跃。
|
||||
*/
|
||||
export async function createNewConversation(kbPath: string): Promise<ActiveContext> {
|
||||
await disposeActive();
|
||||
const kb = await setCurrentKnowledgeBase(kbPath);
|
||||
const dir = await ensureKbSessionDir(kbPath);
|
||||
const loader = await getResourceLoader();
|
||||
|
||||
const model = await getRoleModel("main");
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.create(process.cwd(), dir),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (modelFallbackMessage) console.log(`[agent] ${modelFallbackMessage}`);
|
||||
|
||||
active = { kb, session, conversationId: session.sessionId, isNew: true };
|
||||
await rememberLastUsedKb(kbPath);
|
||||
console.log(`[agent] createNewConversation ${kb.name} → ${active.conversationId.slice(0, 8)}`);
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全清空活跃上下文(不删除磁盘上的会话文件)。
|
||||
*/
|
||||
export async function clearActive(): Promise<void> {
|
||||
await disposeActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时自动恢复 config.lastUsedKbPath 指向的 KB(PRODUCT.md §5.1.1)。
|
||||
* 失败(路径已删除等)不抛错,仅记 warn。
|
||||
*/
|
||||
export async function bootstrapFromConfig(): Promise<void> {
|
||||
try {
|
||||
await scanAndRebuildArtifactIndex();
|
||||
const config = await loadConfig();
|
||||
if (!config.lastUsedKbPath) return;
|
||||
await selectKb(config.lastUsedKbPath);
|
||||
console.log(`[agent] bootstrap restored: ${config.lastUsedKbPath}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[agent] bootstrap restore failed (path may be invalid or removed): ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { APP_DIR } from "./config.js";
|
||||
|
||||
export type ArtifactKind = "html" | "pdf" | "docx" | "pptx" | "xlsx";
|
||||
export type ArtifactRenderer = "iframe" | "download-only";
|
||||
|
||||
export interface ArtifactManifest {
|
||||
id: string;
|
||||
kind: ArtifactKind;
|
||||
renderer: ArtifactRenderer;
|
||||
metadata: {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
sourceConversationId: string;
|
||||
sourceKbPath: string;
|
||||
sourceSkill: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
files: Array<{
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string;
|
||||
}>;
|
||||
primaryFile: string;
|
||||
}
|
||||
|
||||
interface PendingArtifact {
|
||||
id: string;
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
workspacePath: string;
|
||||
sourceConversationId: string;
|
||||
sourceKbPath: string;
|
||||
sourceSkill: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactCreatedEvent {
|
||||
id: string;
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export const ARTIFACTS_DIR = path.join(APP_DIR, "artifacts");
|
||||
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const manifests = new Map<string, ArtifactManifest>();
|
||||
const pending = new Map<string, PendingArtifact>();
|
||||
export const artifactEvents = new EventEmitter();
|
||||
|
||||
export function isValidArtifactId(id: string): boolean {
|
||||
return UUID_RE.test(id);
|
||||
}
|
||||
|
||||
export function artifactWorkspacePath(id: string): string {
|
||||
assertValidArtifactId(id);
|
||||
return path.join(ARTIFACTS_DIR, id);
|
||||
}
|
||||
|
||||
export async function scanAndRebuildArtifactIndex(): Promise<void> {
|
||||
manifests.clear();
|
||||
await mkdir(ARTIFACTS_DIR, { recursive: true });
|
||||
const entries = await readdir(ARTIFACTS_DIR, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !isValidArtifactId(entry.name)) continue;
|
||||
const manifestPath = path.join(ARTIFACTS_DIR, entry.name, "manifest.json");
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as ArtifactManifest;
|
||||
if (manifest.id === entry.name) manifests.set(manifest.id, manifest);
|
||||
} catch {
|
||||
// Ignore incomplete artifact workspaces.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareArtifact(input: {
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
sourceConversationId: string;
|
||||
sourceKbPath: string;
|
||||
sourceSkill?: string;
|
||||
}): Promise<{ id: string; workspacePath: string }> {
|
||||
const id = randomUUID();
|
||||
const workspacePath = artifactWorkspacePath(id);
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
pending.set(id, {
|
||||
id,
|
||||
kind: input.kind,
|
||||
title: input.title.trim() || input.kind.toUpperCase(),
|
||||
workspacePath,
|
||||
sourceConversationId: input.sourceConversationId,
|
||||
sourceKbPath: input.sourceKbPath,
|
||||
sourceSkill: input.sourceSkill ?? input.kind,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { id, workspacePath };
|
||||
}
|
||||
|
||||
export async function finalizeArtifact(input: {
|
||||
id: string;
|
||||
primaryFile: string;
|
||||
sourceSkill?: string;
|
||||
}): Promise<ArtifactManifest> {
|
||||
assertValidArtifactId(input.id);
|
||||
assertSafeFilename(input.primaryFile);
|
||||
const base = artifactWorkspacePath(input.id);
|
||||
assertInside(ARTIFACTS_DIR, base);
|
||||
const meta = pending.get(input.id);
|
||||
if (!meta) {
|
||||
throw new Error("artifact 未准备或已完成");
|
||||
}
|
||||
|
||||
const files = await listWorkspaceFiles(base);
|
||||
const primary = files.find((file) => file.name === input.primaryFile);
|
||||
if (!primary) {
|
||||
throw new Error(`主文件不存在:${input.primaryFile}`);
|
||||
}
|
||||
|
||||
const manifest: ArtifactManifest = {
|
||||
id: input.id,
|
||||
kind: meta.kind,
|
||||
renderer: meta.kind === "html" ? "iframe" : "download-only",
|
||||
metadata: {
|
||||
title: meta.title,
|
||||
createdAt: meta.createdAt,
|
||||
sourceConversationId: meta.sourceConversationId,
|
||||
sourceKbPath: meta.sourceKbPath,
|
||||
sourceSkill: input.sourceSkill ?? meta.sourceSkill,
|
||||
sizeBytes: primary.sizeBytes,
|
||||
},
|
||||
files,
|
||||
primaryFile: primary.name,
|
||||
};
|
||||
|
||||
await atomicWriteJson(path.join(base, "manifest.json"), manifest);
|
||||
manifests.set(manifest.id, manifest);
|
||||
pending.delete(manifest.id);
|
||||
artifactEvents.emit("artifact_created", {
|
||||
id: manifest.id,
|
||||
kind: manifest.kind,
|
||||
title: manifest.metadata.title,
|
||||
conversationId: manifest.metadata.sourceConversationId,
|
||||
} satisfies ArtifactCreatedEvent);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function listArtifacts(conversationId?: string): ArtifactManifest[] {
|
||||
return [...manifests.values()]
|
||||
.filter((manifest) => !conversationId || manifest.metadata.sourceConversationId === conversationId)
|
||||
.sort((a, b) => a.metadata.createdAt.localeCompare(b.metadata.createdAt));
|
||||
}
|
||||
|
||||
export function getArtifact(id: string): ArtifactManifest | null {
|
||||
assertValidArtifactId(id);
|
||||
return manifests.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function resolveArtifactFile(id: string, filename: string): {
|
||||
path: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
} {
|
||||
assertValidArtifactId(id);
|
||||
assertSafeFilename(filename);
|
||||
const manifest = manifests.get(id);
|
||||
if (!manifest) throw new Error("artifact 不存在");
|
||||
const file = manifest.files.find((item) => item.name === filename);
|
||||
if (!file) throw new Error("文件不在 manifest 中");
|
||||
const target = path.join(artifactWorkspacePath(id), filename);
|
||||
assertInside(artifactWorkspacePath(id), target);
|
||||
return { path: target, mimeType: file.mimeType, sizeBytes: file.sizeBytes };
|
||||
}
|
||||
|
||||
export function createArtifactReadStream(filePath: string) {
|
||||
return createReadStream(filePath);
|
||||
}
|
||||
|
||||
async function listWorkspaceFiles(base: string): Promise<ArtifactManifest["files"]> {
|
||||
const entries = await readdir(base, { withFileTypes: true });
|
||||
const files: ArtifactManifest["files"] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === "manifest.json") continue;
|
||||
assertSafeFilename(entry.name);
|
||||
const fullPath = path.join(base, entry.name);
|
||||
assertInside(base, fullPath);
|
||||
const info = await stat(fullPath);
|
||||
if (info.size > MAX_FILE_BYTES) {
|
||||
throw new Error(`文件超过 100MB 限制:${entry.name}`);
|
||||
}
|
||||
files.push({
|
||||
name: entry.name,
|
||||
sizeBytes: info.size,
|
||||
mimeType: mimeTypeFor(entry.name),
|
||||
});
|
||||
}
|
||||
if (files.length === 0) throw new Error("artifact 目录中没有可登记文件");
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return files;
|
||||
}
|
||||
|
||||
function mimeTypeFor(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".html" || ext === ".htm") return "text/html; charset=utf-8";
|
||||
if (ext === ".pdf") return "application/pdf";
|
||||
if (ext === ".docx") return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
if (ext === ".pptx") return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
if (ext === ".xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
if (ext === ".txt") return "text/plain; charset=utf-8";
|
||||
if (ext === ".json") return "application/json";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tmp = `${filePath}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, filePath);
|
||||
}
|
||||
|
||||
function assertValidArtifactId(id: string): void {
|
||||
if (!isValidArtifactId(id)) throw new Error("artifact id 格式错误");
|
||||
}
|
||||
|
||||
function assertSafeFilename(filename: string): void {
|
||||
if (
|
||||
!filename ||
|
||||
filename.includes("/") ||
|
||||
filename.includes("\\") ||
|
||||
filename.includes("..") ||
|
||||
path.isAbsolute(filename)
|
||||
) {
|
||||
throw new Error("文件名不安全");
|
||||
}
|
||||
}
|
||||
|
||||
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("路径越界");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { copyFile, mkdir, open, readFile, rename, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type AuthCredential = { type: "api_key"; key: string } | Record<string, unknown>;
|
||||
type AuthData = Record<string, AuthCredential>;
|
||||
|
||||
const AUTH_DIR = path.join(homedir(), ".pi", "agent");
|
||||
const AUTH_PATH = path.join(AUTH_DIR, "auth.json");
|
||||
|
||||
const ENV_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
];
|
||||
|
||||
export interface AuthProviderStatus {
|
||||
id: string;
|
||||
type: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export async function getAuthStatus() {
|
||||
const authFileExists = Boolean(await stat(AUTH_PATH).catch(() => null));
|
||||
const data = await readAuthData();
|
||||
const providers = Object.entries(data).map(([id, credential]) => ({
|
||||
id,
|
||||
type:
|
||||
credential && typeof credential === "object" && "type" in credential
|
||||
? String((credential as { type?: unknown }).type ?? "unknown")
|
||||
: "unknown",
|
||||
configured: true,
|
||||
}));
|
||||
const envKeys = ENV_KEYS.map((name) => ({ name, present: Boolean(process.env[name]) }));
|
||||
return { authFileExists, providers, envKeys };
|
||||
}
|
||||
|
||||
async function readAuthData(): Promise<AuthData> {
|
||||
try {
|
||||
const raw = await readFile(AUTH_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as AuthData)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
||||
const handle = await open(tmpPath, "w", 0o600);
|
||||
try {
|
||||
await handle.writeFile(content, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
export async function setAuthKey(provider: string, key: string): Promise<void> {
|
||||
const cleanProvider = provider.trim().toLowerCase();
|
||||
const cleanKey = key.trim();
|
||||
if (!cleanProvider) throw new Error("provider is required");
|
||||
if (!cleanKey) throw new Error("key is required");
|
||||
|
||||
await mkdir(AUTH_DIR, { recursive: true, mode: 0o700 });
|
||||
const existing = await readAuthData();
|
||||
const existed = Boolean(await stat(AUTH_PATH).catch(() => null));
|
||||
const backupPath = `${AUTH_PATH}.bak.${Math.floor(Date.now() / 1000)}`;
|
||||
if (existed) await copyFile(AUTH_PATH, backupPath);
|
||||
|
||||
const next: AuthData = {
|
||||
...existing,
|
||||
[cleanProvider]: { type: "api_key", key: cleanKey },
|
||||
};
|
||||
|
||||
try {
|
||||
await writeFileAtomic(AUTH_PATH, JSON.stringify(next, null, 2) + "\n");
|
||||
} catch (err) {
|
||||
if (existed) await copyFile(backupPath, AUTH_PATH).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function redact(message: string, key: string | undefined): string {
|
||||
if (!key) return message;
|
||||
return message.split(key).join("[redacted]");
|
||||
}
|
||||
|
||||
async function getStoredApiKey(provider: string): Promise<string | undefined> {
|
||||
const data = await readAuthData();
|
||||
const credential = data[provider];
|
||||
if (
|
||||
credential &&
|
||||
typeof credential === "object" &&
|
||||
(credential as { type?: unknown }).type === "api_key" &&
|
||||
typeof (credential as { key?: unknown }).key === "string"
|
||||
) {
|
||||
return (credential as { key: string }).key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function parseError(res: Response, key: string): Promise<string> {
|
||||
const raw = await res.text().catch(() => "");
|
||||
const text = raw ? `${res.status} ${res.statusText}: ${raw.slice(0, 500)}` : `${res.status} ${res.statusText}`;
|
||||
return redact(text, key);
|
||||
}
|
||||
|
||||
async function testAnthropicLike(
|
||||
baseUrl: string,
|
||||
key: string,
|
||||
model: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res, key));
|
||||
}
|
||||
|
||||
async function testOpenAI(key: string): Promise<void> {
|
||||
const res = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await parseError(res, key));
|
||||
}
|
||||
|
||||
export async function testAuthConnection(provider: string): Promise<{ ok: boolean; message?: string; error?: string }> {
|
||||
const cleanProvider = provider.trim().toLowerCase();
|
||||
const key = await getStoredApiKey(cleanProvider);
|
||||
if (!key) return { ok: false, error: "未找到该 provider 的已保存 key" };
|
||||
|
||||
try {
|
||||
if (cleanProvider === "anthropic") {
|
||||
await testAnthropicLike("https://api.anthropic.com", key, "claude-haiku-4-5");
|
||||
} else if (cleanProvider === "openai") {
|
||||
await testOpenAI(key);
|
||||
} else if (cleanProvider === "deepseek") {
|
||||
await testAnthropicLike("https://api.deepseek.com/anthropic", key, "deepseek-v4-flash");
|
||||
} else {
|
||||
return { ok: true, message: "已保存。该 provider 暂无内置连通测试。" };
|
||||
}
|
||||
return { ok: true, message: "连接成功,模型可用" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: redact(err instanceof Error ? err.message : String(err), key) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* config.ts - llm-wiki-agent 自身的应用配置
|
||||
*
|
||||
* 位置:~/.llm-wiki-agent/config.json
|
||||
* 内容:UI 偏好、外部知识库登记、默认模型等(**绝不**含 API key —— ADR-13)
|
||||
*
|
||||
* 设计:
|
||||
* - 文件缺失:返回默认值,不写盘(等真有内容要存再写)
|
||||
* - 文件损坏:抛错,不静默覆盖用户数据
|
||||
* - 写入:原子(先 .tmp 再 rename)
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export const APP_DIR = join(homedir(), ".llm-wiki-agent");
|
||||
export const DEFAULT_KNOWLEDGE_BASE_ROOT = join(homedir(), "llm-wiki");
|
||||
|
||||
const CONFIG_FILE = join(APP_DIR, "config.json");
|
||||
|
||||
export interface AppConfig {
|
||||
version: 1;
|
||||
externalKnowledgeBases: string[];
|
||||
/** 最后一次使用的 KB 绝对路径。下次启动用于自动恢复(PRODUCT.md §5.1.1)。 */
|
||||
lastUsedKbPath?: string;
|
||||
showUserGlobalSkills?: boolean;
|
||||
modelRoles?: {
|
||||
main?: ModelRef | null;
|
||||
digest?: ModelRef | null;
|
||||
};
|
||||
uiPrefs?: {
|
||||
sidebarExpandedKbs?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelRef {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
version: 1,
|
||||
externalKnowledgeBases: [],
|
||||
};
|
||||
|
||||
export async function loadConfig(): Promise<AppConfig> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(CONFIG_FILE, "utf8");
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) return { ...DEFAULT_CONFIG };
|
||||
throw new Error(`读取 ${CONFIG_FILE} 失败:${(err as Error).message}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`${CONFIG_FILE} 格式损坏(不是合法 JSON),请手动修复或删除:${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return normalize(parsed);
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
await mkdir(dirname(CONFIG_FILE), { recursive: true });
|
||||
const tmp = `${CONFIG_FILE}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
await rename(tmp, CONFIG_FILE);
|
||||
}
|
||||
|
||||
function normalize(raw: unknown): AppConfig {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error(`${CONFIG_FILE} 顶层不是对象`);
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const version = obj.version === 1 ? 1 : 1; // 当前只支持 v1,未来加迁移
|
||||
const external = Array.isArray(obj.externalKnowledgeBases)
|
||||
? obj.externalKnowledgeBases.filter((p): p is string => typeof p === "string")
|
||||
: [];
|
||||
const lastUsedKbPath =
|
||||
typeof obj.lastUsedKbPath === "string" && obj.lastUsedKbPath ? obj.lastUsedKbPath : undefined;
|
||||
const showUserGlobalSkills = obj.showUserGlobalSkills === true;
|
||||
const modelRoles = normalizeModelRoles(obj.modelRoles);
|
||||
const uiPrefs = normalizeUiPrefs(obj.uiPrefs);
|
||||
return {
|
||||
version,
|
||||
externalKnowledgeBases: external,
|
||||
lastUsedKbPath,
|
||||
showUserGlobalSkills,
|
||||
...(modelRoles ? { modelRoles } : {}),
|
||||
...(uiPrefs ? { uiPrefs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelRef(raw: unknown): ModelRef | null | undefined {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== "object" || raw === undefined) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.provider !== "string" || typeof obj.modelId !== "string") return undefined;
|
||||
if (!obj.provider.trim() || !obj.modelId.trim()) return undefined;
|
||||
return { provider: obj.provider.trim(), modelId: obj.modelId.trim() };
|
||||
}
|
||||
|
||||
function normalizeModelRoles(raw: unknown): AppConfig["modelRoles"] | undefined {
|
||||
if (typeof raw !== "object" || raw === null) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const main = normalizeModelRef(obj.main);
|
||||
const digest = normalizeModelRef(obj.digest);
|
||||
const result: NonNullable<AppConfig["modelRoles"]> = {};
|
||||
if (main !== undefined) result.main = main;
|
||||
if (digest !== undefined) result.digest = digest;
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function normalizeUiPrefs(raw: unknown): AppConfig["uiPrefs"] | undefined {
|
||||
if (typeof raw !== "object" || raw === null) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const sidebarExpandedKbs = Array.isArray(obj.sidebarExpandedKbs)
|
||||
? obj.sidebarExpandedKbs.filter((value): value is string => typeof value === "string")
|
||||
: undefined;
|
||||
return sidebarExpandedKbs ? { sidebarExpandedKbs } : undefined;
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { homedir } from "node:os";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
|
||||
import { piMessagesToUIMessages } from "./conversations.js";
|
||||
|
||||
test("piMessagesToUIMessages renders history summaries for tool calls and results", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
userMessage("请读取文件"),
|
||||
assistantMessage("我来读取。", [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "read",
|
||||
arguments: { path: `${homedir()}/wiki/index.md` },
|
||||
},
|
||||
]),
|
||||
toolResultMessage("call-1", "read", "读取完成", false),
|
||||
]);
|
||||
|
||||
assert.deepEqual(messages, [
|
||||
{ id: "u-0", role: "user", content: "请读取文件", tools: [] },
|
||||
{
|
||||
id: "a-1",
|
||||
role: "assistant",
|
||||
content: "我来读取。",
|
||||
tools: [{ name: "读取 ~/wiki/index.md:读取完成", status: "done" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages uses best-effort summary for old incomplete tool calls", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
assistantMessage("", [{ type: "tool_call", toolName: "bash" }]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(messages, [
|
||||
{
|
||||
id: "a-0",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tools: [{ name: "历史工具调用:bash", status: "done" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages omits empty summaries for messages without tools", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
assistantMessage("普通回答", []),
|
||||
]);
|
||||
|
||||
assert.deepEqual(messages, [
|
||||
{ id: "a-0", role: "assistant", content: "普通回答", tools: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages does not invent details missing from tool results", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
assistantMessage("执行命令。", [
|
||||
{ type: "toolCall", id: "call-2", name: "bash", arguments: {} },
|
||||
]),
|
||||
toolResultMessage("call-2", "bash", "", true),
|
||||
]);
|
||||
|
||||
assert.deepEqual(messages[0]?.tools, [
|
||||
{ name: "历史工具调用:bash 失败", status: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages redacts private paths in historical tool results", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
assistantMessage("读取文件。", [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-private",
|
||||
name: "read",
|
||||
arguments: { path: `${homedir()}/wiki/private.md` },
|
||||
},
|
||||
]),
|
||||
toolResultMessage("call-private", "read", `读取完成:${homedir()}/wiki/private.md`, false),
|
||||
]);
|
||||
|
||||
assert.equal(JSON.stringify(messages).includes(homedir()), false);
|
||||
assert.deepEqual(messages[0]?.tools, [
|
||||
{ name: "读取 ~/wiki/private.md:读取完成:~/wiki/private.md", status: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages 把多步 assistant 轮合并成一个气泡", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
userMessage("介绍一下"),
|
||||
assistantMessage("让我先看看。", [
|
||||
{ type: "toolCall", id: "t1", name: "read", arguments: { path: "a.md" } },
|
||||
]),
|
||||
toolResultMessage("t1", "read", "内容A", false),
|
||||
assistantMessage("", [
|
||||
{ type: "toolCall", id: "t2", name: "read", arguments: { path: "b.md" } },
|
||||
]),
|
||||
toolResultMessage("t2", "read", "内容B", false),
|
||||
assistantMessage("## 总览\n这是结论。", []),
|
||||
]);
|
||||
|
||||
assert.equal(messages.length, 2);
|
||||
assert.equal(messages[0]?.role, "user");
|
||||
assert.equal(messages[1]?.role, "assistant");
|
||||
assert.equal(messages[1]?.tools.length, 2);
|
||||
assert.match(messages[1]?.content ?? "", /让我先看看/);
|
||||
assert.match(messages[1]?.content ?? "", /这是结论/);
|
||||
});
|
||||
|
||||
test("piMessagesToUIMessages 不跨 user 边界合并(多轮保持独立)", () => {
|
||||
const messages = piMessagesToUIMessages([
|
||||
userMessage("Q1"),
|
||||
assistantMessage("A1", []),
|
||||
userMessage("Q2"),
|
||||
assistantMessage("A2", []),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
messages.map((m) => m.role),
|
||||
["user", "assistant", "user", "assistant"],
|
||||
);
|
||||
assert.equal(messages[1]?.content, "A1");
|
||||
assert.equal(messages[3]?.content, "A2");
|
||||
});
|
||||
|
||||
function userMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, extra: unknown[]): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
...(text ? [{ type: "text", text }] : []),
|
||||
...extra,
|
||||
],
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function toolResultMessage(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
text: string,
|
||||
isError: boolean,
|
||||
): AgentMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
isError,
|
||||
} as AgentMessage;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* conversations.ts - 对话(pi session)管理
|
||||
*
|
||||
* 设计:
|
||||
* - 每个知识库一个会话目录:~/.llm-wiki-agent/sessions/<kb-hash>/
|
||||
* - kb-hash = sha256(absolute_kb_path).slice(0, 16)
|
||||
* 好处:1) 文件系统安全(无中文/斜杠等);2) KB 重命名/挪走时旧会话不会丢
|
||||
* - 会话文件由 pi-agent SessionManager 管理(.jsonl 格式)
|
||||
*
|
||||
* UI 对话气泡转换:
|
||||
* pi 的 AgentMessage 含 user / assistant / toolResult / 自定义类型;
|
||||
* UI 只显示 user 文本和 assistant 文本。tool 调用在历史里降级为提示文字。
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { SessionManager, type SessionInfo } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { APP_DIR } from "./config.js";
|
||||
import { stripKnowledgeContextForDisplay } from "./retrieval.js";
|
||||
import { formatToolDisplay } from "./tool-status-events.js";
|
||||
|
||||
export const SESSIONS_ROOT = join(APP_DIR, "sessions");
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
firstMessage: string;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
export interface UIMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
tools: { name: string; status: "done" }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库路径 → 会话目录路径(哈希命名)
|
||||
*/
|
||||
export function kbSessionDir(kbAbsolutePath: string): string {
|
||||
const hash = createHash("sha256").update(kbAbsolutePath).digest("hex").slice(0, 16);
|
||||
return join(SESSIONS_ROOT, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 KB 的会话目录存在
|
||||
*/
|
||||
export async function ensureKbSessionDir(kbAbsolutePath: string): Promise<string> {
|
||||
const dir = kbSessionDir(kbAbsolutePath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出某 KB 下的所有对话(按修改时间从新到旧)
|
||||
*/
|
||||
export async function listConversations(kbAbsolutePath: string): Promise<ConversationInfo[]> {
|
||||
const dir = await ensureKbSessionDir(kbAbsolutePath);
|
||||
let raw: SessionInfo[] = [];
|
||||
try {
|
||||
raw = await SessionManager.list(process.cwd(), dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.map((info) => ({
|
||||
id: info.id,
|
||||
path: info.path,
|
||||
firstMessage: stripKnowledgeContextForDisplay(info.firstMessage ?? ""),
|
||||
modifiedAt: info.modified instanceof Date ? info.modified.getTime() : 0,
|
||||
}))
|
||||
.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 的 AgentMessage[] → UI 友好的 UIMessage[]
|
||||
*
|
||||
* 规则:
|
||||
* - user 文本 → UIMessage{role:"user"}
|
||||
* - assistant 文本 → UIMessage{role:"assistant"}
|
||||
* 如果该 assistant 消息有工具调用,把工具名标到这个 UIMessage 的 tools 上
|
||||
* - toolResult / 纯工具调用 / 自定义类型 → 忽略(历史里 tool 状态不重要)
|
||||
*/
|
||||
export function piMessagesToUIMessages(messages: AgentMessage[]): UIMessage[] {
|
||||
const result: UIMessage[] = [];
|
||||
const toolResults = indexToolResults(messages);
|
||||
|
||||
// 一轮(两条 user 消息之间)里 pi 往往产出多条 assistant 消息:文字 → 工具调用 →
|
||||
// 续写 …… 直播态把整轮聚合进一个气泡,这里合并成一个 UIMessage 以与直播保持一致。
|
||||
let pending: { texts: string[]; tools: string[] } | null = null;
|
||||
const flushAssistant = () => {
|
||||
if (!pending) return;
|
||||
const content = pending.texts.filter((t) => t.trim()).join("\n\n");
|
||||
const tools = pending.tools;
|
||||
if (content.trim() || tools.length > 0) {
|
||||
result.push({
|
||||
id: `a-${result.length}`,
|
||||
role: "assistant",
|
||||
content,
|
||||
tools: tools.map((name) => ({ name, status: "done" as const })),
|
||||
});
|
||||
}
|
||||
pending = null;
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
flushAssistant();
|
||||
const text = stripKnowledgeContextForDisplay(extractText(msg));
|
||||
if (text.trim()) {
|
||||
result.push({
|
||||
id: `u-${result.length}`,
|
||||
role: "user",
|
||||
content: text,
|
||||
tools: [],
|
||||
});
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const text = extractText(msg);
|
||||
const tools = extractToolSummaries(msg, toolResults);
|
||||
if (!pending) pending = { texts: [], tools: [] };
|
||||
if (text.trim()) pending.texts.push(text);
|
||||
pending.tools.push(...tools);
|
||||
}
|
||||
// toolResult 等:忽略,且不 flush(它夹在 assistant 工具调用与续写之间,flush 会错误拆轮)
|
||||
}
|
||||
flushAssistant();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ToolCallSummarySource {
|
||||
id?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
hasArgs: boolean;
|
||||
}
|
||||
|
||||
interface ToolResultSummarySource {
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
text: string;
|
||||
isError: boolean;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
function extractText(msg: AgentMessage): string {
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((part): part is { type: string; text: string } => {
|
||||
if (typeof part !== "object" || part === null) return false;
|
||||
const p = part as Record<string, unknown>;
|
||||
return p.type === "text" && typeof p.text === "string";
|
||||
})
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractToolSummaries(
|
||||
msg: AgentMessage,
|
||||
toolResults: Map<string, ToolResultSummarySource>,
|
||||
): string[] {
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
const summaries: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part !== "object" || part === null) continue;
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p.type === "tool_call" || p.type === "toolCall") {
|
||||
const call = normalizeToolCall(p);
|
||||
if (!call) continue;
|
||||
const result = call.id ? toolResults.get(call.id) : undefined;
|
||||
summaries.push(renderToolSummary(call, result));
|
||||
}
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function indexToolResults(messages: AgentMessage[]): Map<string, ToolResultSummarySource> {
|
||||
const results = new Map<string, ToolResultSummarySource>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== "toolResult") continue;
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
const toolCallId = typeof record.toolCallId === "string" ? record.toolCallId : undefined;
|
||||
if (!toolCallId) continue;
|
||||
results.set(toolCallId, {
|
||||
toolCallId,
|
||||
toolName: typeof record.toolName === "string" ? record.toolName : undefined,
|
||||
text: extractText(message).trim(),
|
||||
isError: record.isError === true,
|
||||
details: record.details,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeToolCall(record: Record<string, unknown>): ToolCallSummarySource | null {
|
||||
const name = typeof record.name === "string"
|
||||
? record.name
|
||||
: typeof record.toolName === "string"
|
||||
? record.toolName
|
||||
: null;
|
||||
if (!name) return null;
|
||||
const id = typeof record.id === "string"
|
||||
? record.id
|
||||
: typeof record.toolCallId === "string"
|
||||
? record.toolCallId
|
||||
: undefined;
|
||||
const rawArgs = record.arguments ?? record.args ?? record.input;
|
||||
const args = isRecord(rawArgs) ? rawArgs : {};
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
hasArgs: Object.keys(args).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function renderToolSummary(
|
||||
call: ToolCallSummarySource,
|
||||
result: ToolResultSummarySource | undefined,
|
||||
): string {
|
||||
const display = call.hasArgs
|
||||
? formatToolDisplay(call.name, call.args)
|
||||
: { action: "历史工具调用", target: call.name };
|
||||
const resultText = summarizeToolResult(result);
|
||||
const failure = result?.isError ? " 失败" : "";
|
||||
if (!call.hasArgs) {
|
||||
return resultText
|
||||
? `历史工具调用:${call.name}${failure}:${resultText}`
|
||||
: `历史工具调用:${call.name}${failure}`;
|
||||
}
|
||||
return resultText
|
||||
? `${display.action} ${display.target}${failure}:${resultText}`
|
||||
: `${display.action} ${display.target}${failure}`;
|
||||
}
|
||||
|
||||
function summarizeToolResult(result: ToolResultSummarySource | undefined): string {
|
||||
if (!result) return "";
|
||||
if (result.text) return truncateSummary(redactPrivatePaths(result.text));
|
||||
const details = isRecord(result.details) ? result.details : {};
|
||||
for (const key of ["summary", "message", "error", "path"]) {
|
||||
const value = details[key];
|
||||
if (typeof value === "string" && value.trim()) return truncateSummary(redactPrivatePaths(value.trim()));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncateSummary(value: string): string {
|
||||
return value.length <= 80 ? value : `${value.slice(0, 52)}...${value.slice(-25)}`;
|
||||
}
|
||||
|
||||
function redactPrivatePaths(value: string): string {
|
||||
return value.split(homedir()).join("~").replace(/\/Users\/[^/\s]+/g, "/Users/<user>");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ModelRef } from "../config.js";
|
||||
import {
|
||||
inspectKnowledgeBasePath,
|
||||
validateInspectedSourceFiles,
|
||||
} from "../knowledge-bases.js";
|
||||
import { mapWithConcurrencyLimit } from "./concurrency.js";
|
||||
import { digestFileWithSubagent } from "./subagent.js";
|
||||
import { extractDocxImages } from "./docx-images.js";
|
||||
import { uploadImagesToMinio } from "./minio-upload.js";
|
||||
|
||||
export type BatchDigestEvent =
|
||||
| { type: "start"; total: number; concurrency: number; outputDir: string }
|
||||
| { type: "file_start"; index: number; filePath: string }
|
||||
| { type: "file_progress"; index: number; filePath: string; chars: number }
|
||||
| { type: "file_complete"; index: number; filePath: string; outputPath: string }
|
||||
| { type: "file_error"; index: number; filePath: string; error: string }
|
||||
| { type: "done"; total: number; completed: number; failed: number; outputDir: string };
|
||||
|
||||
export interface BatchDigestInput {
|
||||
kbPath: string;
|
||||
filePaths: string[];
|
||||
concurrency?: number;
|
||||
sourceScanId?: string;
|
||||
digestModel?: ModelRef | null;
|
||||
}
|
||||
|
||||
const ALLOWED_EXTS = new Set([".md", ".txt", ".pdf", ".docx"]);
|
||||
|
||||
export async function runBatchDigest(
|
||||
input: BatchDigestInput,
|
||||
emit: (event: BatchDigestEvent) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const validated = await validateBatchDigestInput(input);
|
||||
const purpose = await readPurpose(validated.kbPath);
|
||||
const outputDir = path.join(validated.kbPath, "wiki", "synthesis", "sessions");
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
await emit({
|
||||
type: "start",
|
||||
total: validated.filePaths.length,
|
||||
concurrency: validated.concurrency,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
const outcomes = await mapWithConcurrencyLimit(
|
||||
validated.filePaths,
|
||||
validated.concurrency,
|
||||
async (filePath, index) => {
|
||||
await emit({ type: "file_start", index, filePath });
|
||||
try {
|
||||
const resolvedFilePath = await resolveDigestFile(
|
||||
filePath,
|
||||
validated.kbPath,
|
||||
validated.sourceScanId,
|
||||
);
|
||||
const ext = path.extname(resolvedFilePath).toLowerCase();
|
||||
let extractedImages = undefined;
|
||||
if (ext === ".docx") {
|
||||
const assetsDir = path.join(validated.kbPath, "raw", "assets");
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
const extraction = await extractDocxImages(resolvedFilePath, assetsDir);
|
||||
if (extraction.error) {
|
||||
console.warn(`[digest] docx image extraction failed: ${extraction.error}`);
|
||||
} else {
|
||||
extractedImages = extraction.images;
|
||||
}
|
||||
}
|
||||
const content = await digestFileWithSubagent(
|
||||
{
|
||||
kbPath: validated.kbPath,
|
||||
filePath: resolvedFilePath,
|
||||
purpose,
|
||||
model: input.digestModel ?? null,
|
||||
...(extractedImages ? { extractedImages } : {}),
|
||||
},
|
||||
async (chars) => {
|
||||
await emit({ type: "file_progress", index, filePath, chars });
|
||||
},
|
||||
);
|
||||
const outputPath = await writeDigestOutput(outputDir, resolvedFilePath, index, content);
|
||||
await emit({ type: "file_complete", index, filePath, outputPath });
|
||||
return outputPath;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await emit({ type: "file_error", index, filePath, error });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const completed = outcomes.filter((outcome) => outcome.ok).length;
|
||||
await emit({
|
||||
type: "done",
|
||||
total: validated.filePaths.length,
|
||||
completed,
|
||||
failed: validated.filePaths.length - completed,
|
||||
outputDir,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateBatchDigestInput(input: BatchDigestInput): Promise<{
|
||||
kbPath: string;
|
||||
filePaths: string[];
|
||||
concurrency: 1 | 3 | 5;
|
||||
sourceScanId?: string;
|
||||
}> {
|
||||
const kbPath = await realpath(path.resolve(input.kbPath));
|
||||
const kbCheck = await inspectKnowledgeBasePath(kbPath);
|
||||
if (!kbCheck.valid) throw new Error(`不是合法知识库(${kbCheck.reason}):${kbPath}`);
|
||||
|
||||
const concurrency = input.concurrency ?? 3;
|
||||
if (![1, 3, 5].includes(concurrency)) {
|
||||
throw new Error("concurrency 只能是 1、3 或 5");
|
||||
}
|
||||
if (!Array.isArray(input.filePaths) || input.filePaths.length === 0) {
|
||||
throw new Error("filePaths 不能为空");
|
||||
}
|
||||
|
||||
const filePaths: string[] = [];
|
||||
for (const filePath of input.filePaths) {
|
||||
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
|
||||
throw new Error("filePaths 必须是绝对路径");
|
||||
}
|
||||
filePaths.push(path.resolve(filePath));
|
||||
}
|
||||
|
||||
return {
|
||||
kbPath,
|
||||
filePaths: Array.from(new Set(filePaths)),
|
||||
concurrency: concurrency as 1 | 3 | 5,
|
||||
...(input.sourceScanId ? { sourceScanId: input.sourceScanId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDigestFile(
|
||||
filePath: string,
|
||||
kbPath: string,
|
||||
sourceScanId?: string,
|
||||
): Promise<string> {
|
||||
const resolved = await realpath(filePath);
|
||||
assertReadableDigestFile(resolved);
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) throw new Error(`不是文件:${resolved}`);
|
||||
if (isInside(resolved, kbPath)) return resolved;
|
||||
const sourceCheck = validateInspectedSourceFiles(sourceScanId, [filePath]);
|
||||
if (sourceCheck.ok) return resolved;
|
||||
throw new Error(sourceCheck.error);
|
||||
}
|
||||
|
||||
function assertReadableDigestFile(filePath: string): void {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!ALLOWED_EXTS.has(ext)) {
|
||||
throw new Error(`不支持的文件类型:${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readPurpose(kbPath: string): Promise<string> {
|
||||
for (const rel of ["purpose.md", ".wiki-schema.md"]) {
|
||||
const content = await readFile(path.join(kbPath, rel), "utf8").catch(() => "");
|
||||
if (content.trim()) return content.trim().slice(0, 2000);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function writeDigestOutput(
|
||||
outputDir: string,
|
||||
sourcePath: string,
|
||||
index: number,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const base = path.basename(sourcePath, path.extname(sourcePath));
|
||||
const slug = slugify(base) || `material-${index + 1}`;
|
||||
const outputPath = path.join(outputDir, `${slug}-${timestamp}.md`);
|
||||
const body = [
|
||||
"---",
|
||||
`source: ${JSON.stringify(sourcePath)}`,
|
||||
`createdAt: ${JSON.stringify(new Date().toISOString())}`,
|
||||
"stage: 3.5-batch-digest",
|
||||
"---",
|
||||
"",
|
||||
content.trim(),
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(outputPath, body, "utf8");
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function isInside(target: string, root: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { mapWithConcurrencyLimit } from "./concurrency.js";
|
||||
|
||||
test("limits active work to requested concurrency", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const items = Array.from({ length: 10 }, (_, i) => i);
|
||||
const outcomes = await mapWithConcurrencyLimit(items, 3, async (item) => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active--;
|
||||
return item * 2;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 3);
|
||||
assert.deepEqual(
|
||||
outcomes.map((outcome) => outcome.value),
|
||||
items.map((item) => item * 2),
|
||||
);
|
||||
assert.ok(outcomes.every((outcome) => outcome.ok));
|
||||
});
|
||||
|
||||
test("handles empty and smaller-than-concurrency inputs", async () => {
|
||||
assert.deepEqual(await mapWithConcurrencyLimit([], 3, async (item) => item), []);
|
||||
const outcomes = await mapWithConcurrencyLimit([1, 2], 5, async (item) => item + 1);
|
||||
assert.deepEqual(outcomes.map((outcome) => outcome.value), [2, 3]);
|
||||
});
|
||||
|
||||
test("rejects invalid concurrency", async () => {
|
||||
await assert.rejects(() => mapWithConcurrencyLimit([1], 0, async (item) => item));
|
||||
await assert.rejects(() => mapWithConcurrencyLimit([1], -1, async (item) => item));
|
||||
await assert.rejects(() => mapWithConcurrencyLimit([1], Number.NaN, async (item) => item));
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface Outcome<R> {
|
||||
ok: boolean;
|
||||
value?: R;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function mapWithConcurrencyLimit<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
): Promise<Outcome<R>[]> {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
||||
throw new Error("concurrency must be a positive integer");
|
||||
}
|
||||
const results: Outcome<R>[] = new Array(items.length);
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const i = cursor++;
|
||||
try {
|
||||
results[i] = { ok: true, value: await fn(items[i] as T, i) };
|
||||
} catch (err) {
|
||||
results[i] = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ExtractedImage {
|
||||
filename: string;
|
||||
path: string;
|
||||
relId: string;
|
||||
contextBefore: string;
|
||||
contextAfter: string;
|
||||
}
|
||||
|
||||
export interface DocxExtractionResult {
|
||||
images: ExtractedImage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function extractDocxImages(
|
||||
docxPath: string,
|
||||
outputDir: string
|
||||
): Promise<DocxExtractionResult> {
|
||||
const scriptPath = path.join(
|
||||
process.cwd(),
|
||||
'workbench/server/scripts/extract-docx-images.py'
|
||||
);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('python3', [scriptPath, docxPath, outputDir]);
|
||||
const result = JSON.parse(stdout) as DocxExtractionResult;
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
images: [],
|
||||
error: `Failed to extract images from docx: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatImageContexts(images: ExtractedImage[]): string {
|
||||
if (images.length === 0) return '';
|
||||
|
||||
const lines = ['\n\n## 文档中的图片及其上下文:\n'];
|
||||
images.forEach((img, idx) => {
|
||||
lines.push(`### 图片 ${idx + 1}: ${img.filename}`);
|
||||
if (img.contextBefore) {
|
||||
lines.push(`**上文**: ${img.contextBefore}`);
|
||||
}
|
||||
if (img.contextAfter) {
|
||||
lines.push(`**下文**: ${img.contextAfter}`);
|
||||
}
|
||||
lines.push(`**文件路径**: ${img.path}\n`);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
interface MinioConfig {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function loadMinioConfig(): MinioConfig {
|
||||
return {
|
||||
endpoint: process.env.MINIO_ENDPOINT ?? "http://11.1.1.8:9000",
|
||||
bucket: process.env.MINIO_BUCKET_PATH ?? "test",
|
||||
accessKey: process.env.MINIO_ACCESS_KEY ?? "",
|
||||
secretKey: process.env.MINIO_SECRET_KEY ?? "",
|
||||
enabled: process.env.MINIO_ENABLED === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function s3Sign(
|
||||
method: string,
|
||||
region: string,
|
||||
service: string,
|
||||
secretKey: string,
|
||||
now: Date,
|
||||
canonicalHeaders: string,
|
||||
signedHeaders: string,
|
||||
canonicalRequestHash: string,
|
||||
): string {
|
||||
const amzDate = toAmzDate(now);
|
||||
const date = toShortDate(now);
|
||||
const scope = `${date}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
scope,
|
||||
canonicalRequestHash,
|
||||
].join("\n");
|
||||
|
||||
function hmac(key: Buffer | string, data: string): Buffer {
|
||||
const h = createHash("sha256");
|
||||
h.update(typeof key === "string" ? Buffer.from(key, "utf8") : key);
|
||||
h.update(data);
|
||||
return h.digest();
|
||||
}
|
||||
|
||||
const kDate = hmac("AWS4" + secretKey, date);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
const kSigning = hmac(kService, "aws4_request");
|
||||
const signature = createHash("sha256").update(kSigning).update(stringToSign).digest("hex");
|
||||
|
||||
return `${signedHeaders}, Signature=${signature}`;
|
||||
}
|
||||
|
||||
function toAmzDate(d: Date): string {
|
||||
return d.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
}
|
||||
|
||||
function toShortDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0].replace(/-/g, "");
|
||||
}
|
||||
|
||||
function sha256Hex(data: Buffer): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
async function uploadToMinio(
|
||||
localPath: string,
|
||||
objectKey: string,
|
||||
config: MinioConfig,
|
||||
): Promise<string> {
|
||||
const endpoint = config.endpoint.replace(/\/$/, "");
|
||||
const body = await readFile(localPath);
|
||||
const now = new Date();
|
||||
const amzDate = toAmzDate(now);
|
||||
const shortDate = toShortDate(now);
|
||||
const region = "us-east-1";
|
||||
const service = "s3";
|
||||
const host = new URL(endpoint).host;
|
||||
const payloadHash = sha256Hex(body);
|
||||
|
||||
const canonicalHeaders = [
|
||||
`host:${host}`,
|
||||
`x-amz-content-sha256:${payloadHash}`,
|
||||
`x-amz-date:${amzDate}`,
|
||||
].join("\n") + "\n";
|
||||
|
||||
const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
const canonicalRequest = [
|
||||
"PUT",
|
||||
`/${encodeURIComponent(config.bucket)}/${encodeURIComponent(objectKey)}`,
|
||||
"",
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join("\n");
|
||||
|
||||
const canonicalRequestHash = sha256Hex(Buffer.from(canonicalRequest));
|
||||
const authorization = s3Sign(
|
||||
"PUT", region, service, config.secretKey, now,
|
||||
canonicalHeaders, signedHeaders, canonicalRequestHash,
|
||||
);
|
||||
|
||||
const url = `${endpoint}/${config.bucket}/${encodeURIComponent(objectKey)}`;
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Host: host,
|
||||
"x-amz-date": amzDate,
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKey}/${shortDate}/${region}/${service}/aws4_request,${authorization}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.text().catch(() => "");
|
||||
throw new Error(`Minio upload failed [${response.status}]: ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function uploadImagesToMinio(
|
||||
imagePaths: Array<{ localPath: string; filename: string }>,
|
||||
): Promise<Map<string, string>> {
|
||||
const config = loadMinioConfig();
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
if (!config.enabled) return urlMap;
|
||||
|
||||
for (const img of imagePaths) {
|
||||
const objectKey = `llm-wiki/images/${Date.now()}-${img.filename}`;
|
||||
const url = await uploadToMinio(img.localPath, objectKey, config);
|
||||
urlMap.set(img.localPath, url);
|
||||
}
|
||||
|
||||
return urlMap;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import {
|
||||
authStorage,
|
||||
getConfiguredModel,
|
||||
getResourceLoader,
|
||||
getRoleModel,
|
||||
modelRegistry,
|
||||
} from "../agent.js";
|
||||
import type { ModelRef } from "../config.js";
|
||||
import type { ExtractedImage } from "./docx-images.js";
|
||||
import { formatImageContexts } from "./docx-images.js";
|
||||
|
||||
export interface DigestFileInput {
|
||||
kbPath: string;
|
||||
filePath: string;
|
||||
purpose: string;
|
||||
model?: ModelRef | null;
|
||||
extractedImages?: ExtractedImage[];
|
||||
}
|
||||
|
||||
export async function digestFileWithSubagent(
|
||||
input: DigestFileInput,
|
||||
onProgress?: (chars: number) => void | Promise<void>,
|
||||
): Promise<string> {
|
||||
const loader = await getResourceLoader();
|
||||
const model = input.model
|
||||
? getConfiguredModel(input.model) ?? (await getRoleModel("digest"))
|
||||
: await getRoleModel("digest");
|
||||
const { session, modelFallbackMessage } = await createAgentSession({
|
||||
cwd: input.kbPath,
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(input.kbPath),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
...(model ? { model } : {}),
|
||||
thinkingLevel: "off",
|
||||
tools: ["read"],
|
||||
});
|
||||
if (modelFallbackMessage) console.log(`[digest] ${modelFallbackMessage}`);
|
||||
|
||||
let output = "";
|
||||
let lastEmittedChars = 0;
|
||||
let lastEmittedAt = 0;
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
output += event.assistantMessageEvent.delta;
|
||||
const now = Date.now();
|
||||
if (onProgress && (output.length - lastEmittedChars >= 500 || now - lastEmittedAt >= 300)) {
|
||||
lastEmittedChars = output.length;
|
||||
lastEmittedAt = now;
|
||||
void onProgress(output.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await session.prompt(buildDigestPrompt(input));
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) throw new Error("子代理没有返回内容");
|
||||
await onProgress?.(trimmed.length);
|
||||
return trimmed;
|
||||
} finally {
|
||||
unsubscribe();
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function buildDigestPrompt(input: DigestFileInput): string {
|
||||
const imageContext = input.extractedImages && input.extractedImages.length > 0
|
||||
? formatImageContexts(input.extractedImages)
|
||||
: '';
|
||||
|
||||
return [
|
||||
"你是 llm-wiki 的素材消化子代理。",
|
||||
"只处理一个文件,读完后输出一篇可直接保存到 wiki 的中文 Markdown 笔记。",
|
||||
"不要改写文件,不要调用写入工具,不要输出过程说明。",
|
||||
"",
|
||||
`知识库路径:${input.kbPath}`,
|
||||
`研究方向:${input.purpose || "未提供"}`,
|
||||
`素材文件:${input.filePath}`,
|
||||
imageContext,
|
||||
"",
|
||||
"输出结构:",
|
||||
"# 标题",
|
||||
"",
|
||||
"## 核心摘要",
|
||||
"用 3-5 条 bullet 说明最重要的信息。",
|
||||
"",
|
||||
"## 关键细节",
|
||||
"整理事实、概念、数字、论点和可复用片段。",
|
||||
"",
|
||||
"## 与研究方向的关系",
|
||||
"说明这份素材为什么值得放进当前知识库。",
|
||||
"",
|
||||
"## 后续问题",
|
||||
"列出 2-5 个值得继续追问的问题。",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
import type { ActiveContext } from "../agent.js";
|
||||
import {
|
||||
type ArtifactKind,
|
||||
finalizeArtifact,
|
||||
prepareArtifact,
|
||||
} from "../artifacts.js";
|
||||
|
||||
interface PrepareParams {
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
sourceSkill?: string;
|
||||
}
|
||||
|
||||
interface FinalizeParams {
|
||||
id: string;
|
||||
primaryFile: string;
|
||||
sourceSkill?: string;
|
||||
}
|
||||
|
||||
type ToolContent = { type: "text"; text: string };
|
||||
|
||||
function toolResult(text: string, details: Record<string, unknown> = {}) {
|
||||
return { content: [{ type: "text", text } satisfies ToolContent], details };
|
||||
}
|
||||
|
||||
export function createArtifactsExtension(getActive: () => ActiveContext | null) {
|
||||
return function artifactsExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "prepare_artifact",
|
||||
label: "准备产物目录",
|
||||
description:
|
||||
"为 HTML/PDF/Word/PPT/Excel 产物创建工作目录。生成文件前先调用它,返回 id 和 workspacePath。",
|
||||
parameters: Type.Object({
|
||||
kind: Type.Union([
|
||||
Type.Literal("html"),
|
||||
Type.Literal("pdf"),
|
||||
Type.Literal("docx"),
|
||||
Type.Literal("pptx"),
|
||||
Type.Literal("xlsx"),
|
||||
]),
|
||||
title: Type.String(),
|
||||
sourceSkill: Type.Optional(Type.String()),
|
||||
}),
|
||||
async execute(_toolCallId, params: PrepareParams) {
|
||||
const active = getActive();
|
||||
if (!active) return toolResult("当前没有活跃知识库。请先选择一个知识库。");
|
||||
const prepared = await prepareArtifact({
|
||||
kind: params.kind,
|
||||
title: params.title,
|
||||
sourceConversationId: active.conversationId,
|
||||
sourceKbPath: active.kb.path,
|
||||
sourceSkill: params.sourceSkill,
|
||||
});
|
||||
return toolResult(`已准备产物目录:${prepared.workspacePath}`, prepared);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "finalize_artifact",
|
||||
label: "登记产物",
|
||||
description:
|
||||
"文件生成完成后调用,登记 manifest.json 并通知前端打开右抽屉。primaryFile 必须是产物目录里的文件名。",
|
||||
parameters: Type.Object({
|
||||
id: Type.String(),
|
||||
primaryFile: Type.String(),
|
||||
sourceSkill: Type.Optional(Type.String()),
|
||||
}),
|
||||
async execute(_toolCallId, params: FinalizeParams) {
|
||||
try {
|
||||
const manifest = await finalizeArtifact(params);
|
||||
return toolResult(`产物已登记:${manifest.id} / ${manifest.primaryFile}`, { manifest });
|
||||
} catch (err) {
|
||||
return toolResult(`登记产物失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 知识库上下文 Extension
|
||||
*
|
||||
* 阶段一 step 5:向 agent 注入"当前在哪个知识库"的概念。
|
||||
* 不通过拼 system prompt 实现(ADR-7),而是注册工具让 agent 主动查询。
|
||||
*
|
||||
* 工具:
|
||||
* current_knowledge_base() → 返回当前库路径 + 元数据
|
||||
* list_knowledge_base_pages() → 列出 wiki/ 下所有 .md 文件
|
||||
*
|
||||
* 模块级状态:currentKnowledgeBase
|
||||
* HTTP 层通过 setCurrentKnowledgeBase() 改它,Extension 工具读它。
|
||||
* Extension 是单例(一个 process 一份),所以 module state 等价于应用状态。
|
||||
*/
|
||||
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
import { inspectKnowledgeBasePath } from "../knowledge-bases.js";
|
||||
import {
|
||||
formatSearchResultsForTool,
|
||||
searchKnowledgeBase,
|
||||
} from "../retrieval.js";
|
||||
|
||||
// ============= 状态 =============
|
||||
|
||||
interface KnowledgeBaseState {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let current: KnowledgeBaseState | null = null;
|
||||
let pendingKnowledgeContext: string | null = null;
|
||||
|
||||
export function getCurrentKnowledgeBase(): KnowledgeBaseState | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
export async function setCurrentKnowledgeBase(absolutePath: string): Promise<KnowledgeBaseState> {
|
||||
// 共享的有效性检查(PRODUCT.md §6.2 约定)
|
||||
const check = await inspectKnowledgeBasePath(absolutePath);
|
||||
if (!check.valid) {
|
||||
throw new Error(`不是合法知识库(${check.reason}):${absolutePath}`);
|
||||
}
|
||||
|
||||
const name = absolutePath.split("/").filter(Boolean).pop() ?? absolutePath;
|
||||
current = { path: absolutePath, name };
|
||||
return current;
|
||||
}
|
||||
|
||||
export function clearCurrentKnowledgeBase(): void {
|
||||
current = null;
|
||||
}
|
||||
|
||||
export function setPendingKnowledgeContext(context: string): void {
|
||||
pendingKnowledgeContext = context;
|
||||
}
|
||||
|
||||
export function clearPendingKnowledgeContext(): void {
|
||||
pendingKnowledgeContext = null;
|
||||
}
|
||||
|
||||
// ============= Extension =============
|
||||
|
||||
/**
|
||||
* agent 不读这些目录 / 文件(PRODUCT.md §6.4 Obsidian 共存规则)
|
||||
*/
|
||||
const IGNORE_NAMES = new Set([
|
||||
".obsidian",
|
||||
".DS_Store",
|
||||
".wiki-tmp",
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
".cache",
|
||||
]);
|
||||
|
||||
const IGNORE_SUFFIXES = [".base", ".canvas"];
|
||||
|
||||
function shouldIgnore(name: string): boolean {
|
||||
if (IGNORE_NAMES.has(name)) return true;
|
||||
return IGNORE_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(rootDir: string, base: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (shouldIgnore(entry.name)) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
results.push(relative(base, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDir);
|
||||
results.sort();
|
||||
return results;
|
||||
}
|
||||
|
||||
export default function knowledgeBaseExtension(pi: ExtensionAPI) {
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const context = pendingKnowledgeContext;
|
||||
if (!context) return undefined;
|
||||
pendingKnowledgeContext = null;
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${context}`,
|
||||
};
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "current_knowledge_base",
|
||||
label: "当前知识库",
|
||||
description:
|
||||
"Get the absolute filesystem path and basic metadata of the user's currently active knowledge base. ALWAYS call this FIRST before reading, writing, or listing files in the user's knowledge base, since file paths depend on which library the user has selected. Returns an error message if no knowledge base is selected.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(): Promise<{
|
||||
content: { type: "text"; text: string }[];
|
||||
details: Record<string, unknown>;
|
||||
}> {
|
||||
if (!current) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No knowledge base is currently selected. Ask the user to pick one.",
|
||||
},
|
||||
],
|
||||
details: { selected: false },
|
||||
};
|
||||
}
|
||||
|
||||
// 读 purpose.md 给 agent 一段"这是什么库"的提示
|
||||
let purpose = "";
|
||||
try {
|
||||
purpose = (await readFile(join(current.path, "purpose.md"), "utf8")).slice(0, 500);
|
||||
} catch {
|
||||
// 没 purpose.md 也无所谓
|
||||
}
|
||||
|
||||
const text = [
|
||||
`Current knowledge base:`,
|
||||
` name: ${current.name}`,
|
||||
` path: ${current.path}`,
|
||||
purpose ? `\nResearch direction (from purpose.md):\n${purpose}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: {
|
||||
selected: true,
|
||||
path: current.path,
|
||||
name: current.name,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "list_knowledge_base_pages",
|
||||
label: "列出知识库页面",
|
||||
description:
|
||||
"List all markdown wiki pages in the user's currently active knowledge base (under raw/ and wiki/, excluding Obsidian / dev / temp files). Returns relative paths from the knowledge base root. Use this to discover what content exists before answering questions about the user's wiki.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(): Promise<{
|
||||
content: { type: "text"; text: string }[];
|
||||
details: Record<string, unknown>;
|
||||
}> {
|
||||
if (!current) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No knowledge base is currently selected.",
|
||||
},
|
||||
],
|
||||
details: { selected: false },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await listMarkdownFiles(current.path, current.path);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
files.length === 0
|
||||
? "(Knowledge base is empty)"
|
||||
: `${files.length} markdown files:\n${files.join("\n")}`,
|
||||
},
|
||||
],
|
||||
details: { selected: true, count: files.length, files, path: current.path },
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error listing files: ${msg}` }],
|
||||
details: { selected: true, error: msg },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "query_knowledge_base",
|
||||
label: "检索知识库",
|
||||
description:
|
||||
"Search the user's current knowledge base for pages relevant to a query. Use this when the user asks a follow-up question that may require pulling additional context from the knowledge base beyond what's already in the conversation.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String(),
|
||||
}),
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: { query?: string },
|
||||
): Promise<{
|
||||
content: { type: "text"; text: string }[];
|
||||
details: Record<string, unknown>;
|
||||
}> {
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No knowledge base is currently selected." }],
|
||||
details: { selected: false },
|
||||
};
|
||||
}
|
||||
const query = params.query?.trim() ?? "";
|
||||
if (!query) {
|
||||
return {
|
||||
content: [{ type: "text", text: "query 不能为空。" }],
|
||||
details: { selected: true, count: 0, files: [] },
|
||||
};
|
||||
}
|
||||
const search = await searchKnowledgeBase(current.path, query, {
|
||||
totalBudgetChars: 4000,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: formatSearchResultsForTool(search) }],
|
||||
details: {
|
||||
selected: true,
|
||||
count: search.results.length,
|
||||
files: search.results.map((result) => result.path),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
import { createWiki } from "../wiki-init.js";
|
||||
|
||||
interface NewWikiParams {
|
||||
name: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
type ToolContent = { type: "text"; text: string };
|
||||
|
||||
function toolResult(text: string, details: Record<string, unknown> = {}) {
|
||||
return { content: [{ type: "text", text } satisfies ToolContent], details };
|
||||
}
|
||||
|
||||
export function createNewWikiExtension() {
|
||||
return function newWikiExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "new_wiki",
|
||||
label: "新建知识库",
|
||||
description:
|
||||
"在默认知识库根目录下新建一个 llm-wiki 知识库,调用已安装的 llm-wiki-skill 初始化脚本。",
|
||||
parameters: Type.Object({
|
||||
name: Type.String(),
|
||||
purpose: Type.String(),
|
||||
}),
|
||||
async execute(_toolCallId, params: NewWikiParams) {
|
||||
try {
|
||||
const result = await createWiki(params.name, params.purpose);
|
||||
return toolResult(`知识库已创建:~/llm-wiki/${result.name}`, { ...result });
|
||||
} catch (err) {
|
||||
const error = err as Error & { stdout?: string; stderr?: string };
|
||||
return toolResult(`创建知识库失败:${error.message}`, {
|
||||
stdout: error.stdout ?? "",
|
||||
stderr: error.stderr ?? "",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
GraphRebuildQueue,
|
||||
KnowledgeBaseGraphWatcher,
|
||||
shouldIgnoreGraphWatchPath,
|
||||
} from "./graph.js";
|
||||
|
||||
test("graph watcher debounces rebuild triggers", async () => {
|
||||
const clock = new FakeClock();
|
||||
const events = new FakeWatchSource();
|
||||
const triggered: string[] = [];
|
||||
const watcher = new KnowledgeBaseGraphWatcher({
|
||||
createWatcher: (_kbPath, onEvent) => events.create(onEvent),
|
||||
triggerRebuild: (kbPath) => {
|
||||
triggered.push(kbPath);
|
||||
return { ok: true, status: "started" };
|
||||
},
|
||||
debounceMs: 50,
|
||||
});
|
||||
using _timers = clock.install();
|
||||
|
||||
watcher.start("/kb");
|
||||
events.emit("wiki/a.md");
|
||||
events.emit("wiki/b.md");
|
||||
assert.deepEqual(triggered, []);
|
||||
await clock.advance(49);
|
||||
assert.deepEqual(triggered, []);
|
||||
await clock.advance(1);
|
||||
assert.deepEqual(triggered, ["/kb"]);
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
test("graph watcher ignores external noise and generated graph artifacts", async () => {
|
||||
const clock = new FakeClock();
|
||||
const events = new FakeWatchSource();
|
||||
const triggered: string[] = [];
|
||||
const watcher = new KnowledgeBaseGraphWatcher({
|
||||
createWatcher: (_kbPath, onEvent) => events.create(onEvent),
|
||||
triggerRebuild: (kbPath) => {
|
||||
triggered.push(kbPath);
|
||||
return { ok: true, status: "started" };
|
||||
},
|
||||
debounceMs: 10,
|
||||
});
|
||||
using _timers = clock.install();
|
||||
|
||||
watcher.start("/kb");
|
||||
for (const filename of [
|
||||
".wiki-tmp/run.json",
|
||||
".git/index",
|
||||
".obsidian/workspace.json",
|
||||
"node_modules/pkg/index.js",
|
||||
".DS_Store",
|
||||
"wiki/graph-data.json",
|
||||
"wiki/knowledge-graph.html",
|
||||
"wiki/knowledge-graph-dark.html",
|
||||
".wiki-graph-layout.json",
|
||||
]) {
|
||||
assert.equal(shouldIgnoreGraphWatchPath(filename), true, filename);
|
||||
events.emit(filename);
|
||||
}
|
||||
await clock.advance(20);
|
||||
assert.deepEqual(triggered, []);
|
||||
|
||||
events.emit("wiki/topics/new-page.md");
|
||||
await clock.advance(10);
|
||||
assert.deepEqual(triggered, ["/kb"]);
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
test("graph watcher suspends during batch digest and resumes with one immediate rebuild", async () => {
|
||||
const events = new FakeWatchSource();
|
||||
const triggered: string[] = [];
|
||||
const watcher = new KnowledgeBaseGraphWatcher({
|
||||
createWatcher: (_kbPath, onEvent) => events.create(onEvent),
|
||||
triggerRebuild: (kbPath) => {
|
||||
triggered.push(kbPath);
|
||||
return { ok: true, status: "started" };
|
||||
},
|
||||
debounceMs: 10,
|
||||
});
|
||||
|
||||
watcher.start("/kb");
|
||||
watcher.suspend("/kb");
|
||||
events.emit("wiki/synthesis/sessions/a.md");
|
||||
events.emit("wiki/synthesis/sessions/b.md");
|
||||
assert.deepEqual(triggered, []);
|
||||
watcher.resume("/kb", { trigger: true });
|
||||
assert.deepEqual(triggered, ["/kb"]);
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
test("graph rebuild queue merges triggers into one pending rebuild while running", async () => {
|
||||
const gates = [deferred<void>(), deferred<void>()];
|
||||
const calls: string[] = [];
|
||||
let rebuildIndex = 0;
|
||||
const queue = new GraphRebuildQueue({
|
||||
run: async () => {
|
||||
const gate = gates[rebuildIndex++];
|
||||
assert.ok(gate);
|
||||
calls.push("run");
|
||||
await gate.promise;
|
||||
},
|
||||
onError: (err) => {
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(queue.trigger().status, "started");
|
||||
assert.equal(queue.trigger().status, "queued");
|
||||
assert.equal(queue.trigger().status, "queued");
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ["run"]);
|
||||
gates[0]?.resolve();
|
||||
await waitFor(() => calls.length >= 2);
|
||||
assert.deepEqual(calls, ["run", "run"]);
|
||||
gates[1]?.resolve();
|
||||
await queue.waitForIdle();
|
||||
assert.deepEqual(calls, ["run", "run"]);
|
||||
});
|
||||
|
||||
class FakeWatchSource {
|
||||
private onEvent: ((event: { eventType: string; filename: string | null }) => void) | null = null;
|
||||
|
||||
create(onEvent: (event: { eventType: string; filename: string | null }) => void) {
|
||||
this.onEvent = onEvent;
|
||||
return {
|
||||
close: () => {
|
||||
this.onEvent = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
emit(filename: string): void {
|
||||
this.onEvent?.({ eventType: "rename", filename });
|
||||
}
|
||||
}
|
||||
|
||||
class FakeClock {
|
||||
private now = 0;
|
||||
private nextId = 1;
|
||||
private tasks = new Map<number, { due: number; callback: () => void }>();
|
||||
private originalSetTimeout = globalThis.setTimeout;
|
||||
private originalClearTimeout = globalThis.clearTimeout;
|
||||
|
||||
install() {
|
||||
const self = this;
|
||||
globalThis.setTimeout = ((callback: () => void, ms?: number) => {
|
||||
const id = self.nextId++;
|
||||
self.tasks.set(id, { due: self.now + Number(ms ?? 0), callback });
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
globalThis.clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
|
||||
self.tasks.delete(Number(id));
|
||||
}) as typeof clearTimeout;
|
||||
return {
|
||||
[Symbol.dispose]: () => this.restore(),
|
||||
};
|
||||
}
|
||||
|
||||
async advance(ms: number): Promise<void> {
|
||||
this.now += ms;
|
||||
const due = Array.from(this.tasks.entries())
|
||||
.filter(([, task]) => task.due <= this.now)
|
||||
.sort((a, b) => a[1].due - b[1].due);
|
||||
for (const [id, task] of due) {
|
||||
if (!this.tasks.delete(id)) continue;
|
||||
task.callback();
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
globalThis.setTimeout = this.originalSetTimeout;
|
||||
globalThis.clearTimeout = this.originalClearTimeout;
|
||||
this.tasks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(predicate(), true);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { graphDataPath, graphLayoutPath, readGraphData, readGraphLayout, writeGraphLayout } from "./graph.js";
|
||||
|
||||
test("graph data without node source paths is treated as stale", async () => {
|
||||
const kbPath = await tempKb();
|
||||
try {
|
||||
await mkdir(path.dirname(graphDataPath(kbPath)), { recursive: true });
|
||||
await writeFile(graphDataPath(kbPath), JSON.stringify({
|
||||
meta: { build_date: "2026-01-01T00:00:00Z", wiki_title: "Stale", total_nodes: 1, total_edges: 0 },
|
||||
nodes: [{ id: "session-a", label: "Session A", type: "synthesis" }],
|
||||
edges: [],
|
||||
}), "utf8");
|
||||
|
||||
const result = await readGraphData(kbPath);
|
||||
assert.equal(result.needsBuild, true);
|
||||
} finally {
|
||||
await rm(kbPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("graph layout read/write roundtrip stores kb-local pins", async () => {
|
||||
const kbPath = await tempKb();
|
||||
try {
|
||||
const written = await writeGraphLayout(kbPath, {
|
||||
pins: {
|
||||
"wiki/topics/agent.md": { x: 412.5, y: -88.2, coordinateSpace: "world" },
|
||||
"wiki/entities/tool.md": { x: 130, y: 245.7 },
|
||||
},
|
||||
});
|
||||
assert.equal(written.layout.version, 2);
|
||||
assert.deepEqual(written.layout.pins["wiki/topics/agent.md"], { x: 412.5, y: -88.2, coordinateSpace: "world" });
|
||||
assert.deepEqual(written.layout.pins["wiki/entities/tool.md"], { x: 130, y: 245.7, coordinateSpace: "world" });
|
||||
assert.ok(written.layout.updatedAt);
|
||||
|
||||
const stored = JSON.parse(await readFile(graphLayoutPath(kbPath), "utf8"));
|
||||
assert.equal(stored.version, 2);
|
||||
assert.deepEqual(stored.pins, written.layout.pins);
|
||||
|
||||
const readBack = await readGraphLayout(kbPath);
|
||||
assert.deepEqual(readBack.layout.pins, written.layout.pins);
|
||||
} finally {
|
||||
await rm(kbPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("graph layout treats missing or damaged files as empty", async () => {
|
||||
const kbPath = await tempKb();
|
||||
try {
|
||||
assert.deepEqual((await readGraphLayout(kbPath)).layout.pins, {});
|
||||
|
||||
await writeFile(graphLayoutPath(kbPath), "{not-json", "utf8");
|
||||
assert.deepEqual((await readGraphLayout(kbPath)).layout.pins, {});
|
||||
} finally {
|
||||
await rm(kbPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("graph layout filters unsafe keys and invalid positions", async () => {
|
||||
const kbPath = await tempKb();
|
||||
try {
|
||||
const result = await writeGraphLayout(kbPath, {
|
||||
pins: {
|
||||
"wiki/valid.md": { x: 1, y: 2 },
|
||||
"/absolute.md": { x: 3, y: 4 },
|
||||
"wiki/../escape.md": { x: 5, y: 6 },
|
||||
"raw/not-wiki.md": { x: 7, y: 8 },
|
||||
"wiki/bad.md": { x: Number.NaN, y: 9 },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result.layout.pins, {
|
||||
"wiki/valid.md": { x: 1, y: 2, coordinateSpace: "world" },
|
||||
});
|
||||
} finally {
|
||||
await rm(kbPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("graph layout keeps old percent pins readable and preserves explicit world pins", async () => {
|
||||
const kbPath = await tempKb();
|
||||
try {
|
||||
await mkdir(path.dirname(graphLayoutPath(kbPath)), { recursive: true });
|
||||
await writeFile(graphLayoutPath(kbPath), JSON.stringify({
|
||||
version: 1,
|
||||
pins: {
|
||||
"wiki/old-percent.md": { x: 80, y: 50 },
|
||||
"wiki/new-world.md": { x: 8, y: -12, coordinateSpace: "world" },
|
||||
"wiki/legacy-explicit.md": { x: 13, y: 44, coordinateSpace: "legacy-percent" },
|
||||
"wiki/bad-space.md": { x: 1, y: 2, coordinateSpace: "screen" },
|
||||
},
|
||||
updatedAt: "2026-06-12T00:00:00.000Z",
|
||||
}), "utf8");
|
||||
|
||||
const readBack = await readGraphLayout(kbPath);
|
||||
assert.equal(readBack.layout.version, 2);
|
||||
assert.deepEqual(readBack.layout.pins, {
|
||||
"wiki/old-percent.md": { x: 80, y: 50, coordinateSpace: "legacy-percent" },
|
||||
"wiki/new-world.md": { x: 8, y: -12, coordinateSpace: "world" },
|
||||
"wiki/legacy-explicit.md": { x: 13, y: 44, coordinateSpace: "legacy-percent" },
|
||||
"wiki/bad-space.md": { x: 1, y: 2, coordinateSpace: "legacy-percent" },
|
||||
});
|
||||
} finally {
|
||||
await rm(kbPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function tempKb(): Promise<string> {
|
||||
return mkdtemp(path.join(os.tmpdir(), "llm-wiki-graph-layout-"));
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { watch } from "node:fs";
|
||||
import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { diffGraphData, normalizeGraphLayoutFile, type GraphData, type GraphDiff, type GraphLayoutFile } from "@llm-wiki/graph-engine";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type GraphReadResult =
|
||||
| { ok: true; needsBuild: true; graphPath: string }
|
||||
| { ok: true; needsBuild: false; graphPath: string; data: GraphData };
|
||||
|
||||
export type GraphBuildStatus = "started" | "queued";
|
||||
|
||||
export type GraphEvent =
|
||||
| {
|
||||
type: "graph_updated";
|
||||
kbPath: string;
|
||||
diff: GraphDiff | null;
|
||||
rebuiltAt: string;
|
||||
stats: { nodeCount: number; edgeCount: number };
|
||||
}
|
||||
| {
|
||||
type: "graph_error";
|
||||
kbPath: string;
|
||||
message: string;
|
||||
rebuiltAt: string;
|
||||
};
|
||||
|
||||
type RebuildQueueOptions = {
|
||||
run: () => Promise<void>;
|
||||
onError: (err: unknown) => void;
|
||||
onIdle?: () => void;
|
||||
};
|
||||
|
||||
type WatchEvent = {
|
||||
eventType: string;
|
||||
filename: string | null;
|
||||
};
|
||||
|
||||
type WatchHandle = {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type WatchFactory = (kbPath: string, onEvent: (event: WatchEvent) => void) => WatchHandle;
|
||||
|
||||
type WatcherOptions = {
|
||||
createWatcher: WatchFactory;
|
||||
triggerRebuild: (kbPath: string) => { ok: true; status: GraphBuildStatus };
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
const rebuilds = new Map<string, GraphRebuildQueue>();
|
||||
let graphWatchController: KnowledgeBaseGraphWatcher | null = null;
|
||||
|
||||
export function graphDataPath(kbPath: string): string {
|
||||
return path.join(kbPath, "wiki", "graph-data.json");
|
||||
}
|
||||
|
||||
export function graphLayoutPath(kbPath: string): string {
|
||||
return path.join(kbPath, ".wiki-graph-layout.json");
|
||||
}
|
||||
|
||||
export async function readGraphData(kbPath: string): Promise<GraphReadResult> {
|
||||
const graphPath = graphDataPath(kbPath);
|
||||
const content = await readFile(graphPath, "utf8").catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return null;
|
||||
throw err;
|
||||
});
|
||||
if (content === null) return { ok: true, needsBuild: true, graphPath };
|
||||
const data = JSON.parse(content) as GraphData;
|
||||
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
|
||||
throw new Error("graph-data.json 格式不完整");
|
||||
}
|
||||
if (!graphNodesHavePagePaths(data.nodes)) {
|
||||
return { ok: true, needsBuild: true, graphPath };
|
||||
}
|
||||
return { ok: true, needsBuild: false, graphPath, data };
|
||||
}
|
||||
|
||||
function graphNodesHavePagePaths(nodes: GraphData["nodes"]): boolean {
|
||||
return nodes.every((node) => {
|
||||
const sourcePath = node.source_path || node.path || node.source;
|
||||
return typeof sourcePath === "string" && sourcePath.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function triggerGraphRebuild(kbPath: string): { ok: true; status: GraphBuildStatus } {
|
||||
let queue = rebuilds.get(kbPath);
|
||||
if (!queue) {
|
||||
queue = createDefaultRebuildQueue(kbPath);
|
||||
rebuilds.set(kbPath, queue);
|
||||
}
|
||||
return queue.trigger();
|
||||
}
|
||||
|
||||
export async function readGraphLayout(kbPath: string): Promise<{ ok: true; layoutPath: string; layout: GraphLayoutFile }> {
|
||||
const layoutPath = graphLayoutPath(kbPath);
|
||||
const content = await readFile(layoutPath, "utf8").catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return null;
|
||||
throw err;
|
||||
});
|
||||
if (content === null) return { ok: true, layoutPath, layout: emptyGraphLayout() };
|
||||
try {
|
||||
return { ok: true, layoutPath, layout: normalizeGraphLayout(JSON.parse(content)) };
|
||||
} catch {
|
||||
return { ok: true, layoutPath, layout: emptyGraphLayout() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeGraphLayout(kbPath: string, input: unknown): Promise<{ ok: true; layoutPath: string; layout: GraphLayoutFile }> {
|
||||
const layoutPath = graphLayoutPath(kbPath);
|
||||
const layout = normalizeGraphLayout(input);
|
||||
layout.updatedAt = new Date().toISOString();
|
||||
await mkdir(path.dirname(layoutPath), { recursive: true });
|
||||
await writeFile(layoutPath, `${JSON.stringify(layout, null, 2)}\n`, "utf8");
|
||||
return { ok: true, layoutPath, layout };
|
||||
}
|
||||
|
||||
export function subscribeGraphEvents(listener: (event: GraphEvent) => void): () => void {
|
||||
eventBus.on("graph", listener);
|
||||
return () => eventBus.off("graph", listener);
|
||||
}
|
||||
|
||||
export function watchKnowledgeBaseGraph(kbPath: string): void {
|
||||
defaultGraphWatchController().start(kbPath);
|
||||
}
|
||||
|
||||
export function stopKnowledgeBaseGraphWatcher(): void {
|
||||
graphWatchController?.stop();
|
||||
}
|
||||
|
||||
export function suspendGraphWatcher(kbPath: string): void {
|
||||
graphWatchController?.suspend(kbPath);
|
||||
}
|
||||
|
||||
export function resumeGraphWatcher(kbPath: string, options: { trigger?: boolean } = {}): void {
|
||||
graphWatchController?.resume(kbPath, options);
|
||||
}
|
||||
|
||||
export function shouldIgnoreGraphWatchPath(filename: string | null): boolean {
|
||||
if (!filename) return false;
|
||||
const normalized = filename.replaceAll("\\", "/").replace(/^\/+/, "");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.some((segment) => [".wiki-tmp", ".git", ".obsidian", "node_modules", ".DS_Store"].includes(segment))) {
|
||||
return true;
|
||||
}
|
||||
if (normalized === ".wiki-graph-layout.json") return true;
|
||||
if (normalized === "wiki/graph-data.json") return true;
|
||||
if (/^wiki\/knowledge-graph.*\.html$/.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export class GraphRebuildQueue {
|
||||
private running = false;
|
||||
private pending = false;
|
||||
private idleResolvers: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly options: RebuildQueueOptions) {}
|
||||
|
||||
trigger(): { ok: true; status: GraphBuildStatus } {
|
||||
if (this.running) {
|
||||
this.pending = true;
|
||||
return { ok: true, status: "queued" };
|
||||
}
|
||||
this.running = true;
|
||||
void this.runLoop();
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
waitForIdle(): Promise<void> {
|
||||
if (!this.running) return Promise.resolve();
|
||||
return new Promise((resolve) => this.idleResolvers.push(resolve));
|
||||
}
|
||||
|
||||
private async runLoop(): Promise<void> {
|
||||
try {
|
||||
do {
|
||||
this.pending = false;
|
||||
try {
|
||||
await this.options.run();
|
||||
} catch (err) {
|
||||
this.options.onError(err);
|
||||
}
|
||||
} while (this.pending);
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.options.onIdle?.();
|
||||
const resolvers = this.idleResolvers.splice(0);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class KnowledgeBaseGraphWatcher {
|
||||
private kbPath: string | null = null;
|
||||
private handle: WatchHandle | null = null;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private suspendDepth = 0;
|
||||
private pendingWhileSuspended = false;
|
||||
private readonly debounceMs: number;
|
||||
|
||||
constructor(private readonly options: WatcherOptions) {
|
||||
this.debounceMs = options.debounceMs ?? 5000;
|
||||
}
|
||||
|
||||
start(kbPath: string): void {
|
||||
if (this.kbPath === kbPath && this.handle) return;
|
||||
this.stop();
|
||||
this.kbPath = kbPath;
|
||||
this.handle = this.options.createWatcher(kbPath, (event) => this.handleEvent(event));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
this.pendingWhileSuspended = false;
|
||||
this.suspendDepth = 0;
|
||||
this.kbPath = null;
|
||||
this.handle?.close();
|
||||
this.handle = null;
|
||||
}
|
||||
|
||||
suspend(kbPath: string): void {
|
||||
if (this.kbPath !== kbPath) return;
|
||||
this.suspendDepth++;
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
this.pendingWhileSuspended = true;
|
||||
}
|
||||
}
|
||||
|
||||
resume(kbPath: string, options: { trigger?: boolean } = {}): void {
|
||||
if (this.kbPath !== kbPath) return;
|
||||
this.suspendDepth = Math.max(0, this.suspendDepth - 1);
|
||||
if (this.suspendDepth > 0) return;
|
||||
const shouldTrigger = options.trigger === true || this.pendingWhileSuspended;
|
||||
this.pendingWhileSuspended = false;
|
||||
if (shouldTrigger) this.triggerNow();
|
||||
}
|
||||
|
||||
private handleEvent(event: WatchEvent): void {
|
||||
if (shouldIgnoreGraphWatchPath(event.filename)) return;
|
||||
if (this.suspendDepth > 0) {
|
||||
this.pendingWhileSuspended = true;
|
||||
return;
|
||||
}
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
this.triggerNow();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private triggerNow(): void {
|
||||
if (!this.kbPath) return;
|
||||
this.options.triggerRebuild(this.kbPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildGraph(kbPath: string): Promise<void> {
|
||||
const repoRoot = await findRepoRoot();
|
||||
const script = path.join(repoRoot, "scripts", "build-graph-data.sh");
|
||||
await access(script);
|
||||
await execFileAsync("bash", [script, kbPath], {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
async function findRepoRoot(): Promise<string> {
|
||||
let dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
while (true) {
|
||||
const gitPath = path.join(dir, ".git");
|
||||
const info = await stat(gitPath).catch(() => null);
|
||||
if (info) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error("Cannot locate repository root from server module path");
|
||||
}
|
||||
|
||||
function emitGraphEvent(event: GraphEvent): void {
|
||||
eventBus.emit("graph", event);
|
||||
}
|
||||
|
||||
function createDefaultRebuildQueue(kbPath: string): GraphRebuildQueue {
|
||||
return new GraphRebuildQueue({
|
||||
run: async () => {
|
||||
const previous = await readGraphData(kbPath).catch(() => null);
|
||||
await rebuildGraph(kbPath);
|
||||
const graph = await readGraphData(kbPath);
|
||||
if (graph.needsBuild) return;
|
||||
emitGraphEvent({
|
||||
type: "graph_updated",
|
||||
kbPath,
|
||||
diff: previous && !previous.needsBuild ? diffGraphData(previous.data, graph.data) : null,
|
||||
rebuiltAt: new Date().toISOString(),
|
||||
stats: {
|
||||
nodeCount: Number(graph.data.meta?.total_nodes ?? graph.data.nodes?.length ?? 0),
|
||||
edgeCount: Number(graph.data.meta?.total_edges ?? graph.data.edges?.length ?? 0),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
emitGraphEvent({
|
||||
type: "graph_error",
|
||||
kbPath,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
rebuiltAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
onIdle: () => {
|
||||
rebuilds.delete(kbPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createFsWatchAdapter(kbPath: string, onEvent: (event: WatchEvent) => void): WatchHandle {
|
||||
const watcher = watch(kbPath, { recursive: true }, (eventType, filename) => {
|
||||
onEvent({ eventType, filename: filename ? String(filename) : null });
|
||||
});
|
||||
console.log(`[graph] watching knowledge base for graph rebuilds: ${kbPath}`);
|
||||
return { close: () => watcher.close() };
|
||||
}
|
||||
|
||||
function defaultGraphWatchController(): KnowledgeBaseGraphWatcher {
|
||||
if (!graphWatchController) {
|
||||
graphWatchController = new KnowledgeBaseGraphWatcher({
|
||||
createWatcher: createFsWatchAdapter,
|
||||
triggerRebuild: triggerGraphRebuild,
|
||||
});
|
||||
}
|
||||
return graphWatchController;
|
||||
}
|
||||
|
||||
function emptyGraphLayout(): GraphLayoutFile {
|
||||
return { version: 2, pins: {}, updatedAt: "" };
|
||||
}
|
||||
|
||||
function normalizeGraphLayout(input: unknown): GraphLayoutFile {
|
||||
return normalizeGraphLayoutFile(input);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { findRegisteredKnowledgeBase, type KnowledgeBaseInfo } from "./knowledge-bases.js";
|
||||
|
||||
test("findRegisteredKnowledgeBase only accepts registered valid knowledge bases", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "llm-wiki-kb-registry-"));
|
||||
const registered = path.join(root, "registered");
|
||||
const other = path.join(root, "other");
|
||||
const alias = path.join(root, "registered-link");
|
||||
try {
|
||||
await mkdir(registered);
|
||||
await mkdir(other);
|
||||
await writeFile(path.join(registered, ".wiki-schema.md"), "# schema\n", "utf8");
|
||||
await symlink(registered, alias);
|
||||
const knownBases: KnowledgeBaseInfo[] = [
|
||||
{ path: registered, name: "registered", origin: "external", valid: true },
|
||||
{ path: other, name: "other", origin: "external", valid: false, reason: "Missing .wiki-schema.md" },
|
||||
];
|
||||
|
||||
assert.equal((await findRegisteredKnowledgeBase(alias, knownBases))?.path, registered);
|
||||
assert.equal(await findRegisteredKnowledgeBase(other, knownBases), null);
|
||||
assert.equal(await findRegisteredKnowledgeBase(path.join(root, "missing"), knownBases), null);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* knowledge-bases.ts - 知识库扫描与登记
|
||||
*
|
||||
* 数据源(PRODUCT.md §6.1 混合模式):
|
||||
* 1. ~/llm-wiki/ 默认根 → 扫描含 .wiki-schema.md 的子目录
|
||||
* 2. ~/.llm-wiki-agent/config.json 的 externalKnowledgeBases → 用户登记的外部路径
|
||||
*
|
||||
* 返回结构含 valid 标志;失效路径不自动删除(避免数据丢失),由 UI 提示用户处理
|
||||
* (PRODUCT.md §6.7 边界场景)。
|
||||
*/
|
||||
|
||||
import type { Dirent } from "node:fs";
|
||||
import { mkdir, readdir, realpath, stat } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import {
|
||||
type AppConfig,
|
||||
DEFAULT_KNOWLEDGE_BASE_ROOT,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
} from "./config.js";
|
||||
|
||||
export interface KnowledgeBaseInfo {
|
||||
path: string; // 绝对路径
|
||||
name: string; // 目录最后一段
|
||||
origin: "default" | "external";
|
||||
valid: boolean;
|
||||
reason?: string; // valid=false 时说明
|
||||
}
|
||||
|
||||
export interface InspectPathResult {
|
||||
exists: boolean;
|
||||
isDirectory: boolean;
|
||||
hasWikiSchema: boolean;
|
||||
resolvedPath?: string;
|
||||
ingestibleFiles?: {
|
||||
scanId: string;
|
||||
count: number;
|
||||
samples: string[];
|
||||
paths: string[];
|
||||
truncated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
".git",
|
||||
".obsidian",
|
||||
".wiki-tmp",
|
||||
"node_modules",
|
||||
"venv",
|
||||
".venv",
|
||||
"dist",
|
||||
"build",
|
||||
]);
|
||||
const INGEST_EXTS = new Set([".md", ".txt", ".pdf", ".docx"]);
|
||||
const MAX_SCAN_DEPTH = 5;
|
||||
const MAX_INGESTIBLE_FILES = 500;
|
||||
const INSPECT_SCAN_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const inspectScans = new Map<
|
||||
string,
|
||||
{ root: string; paths: Set<string>; expiresAt: number }
|
||||
>();
|
||||
|
||||
export function expandUserPath(rawPath: string): string {
|
||||
const trimmed = rawPath.trim();
|
||||
if (trimmed === "~") return homedir();
|
||||
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证给定路径是否是一个合法知识库(不抛错,返回结构化结果)。
|
||||
* 规则:路径存在 + 是目录 + 含 .wiki-schema.md
|
||||
*/
|
||||
export async function inspectKnowledgeBasePath(
|
||||
absolutePath: string,
|
||||
): Promise<{ valid: true } | { valid: false; reason: string }> {
|
||||
const info = await stat(absolutePath).catch(() => null);
|
||||
if (!info) return { valid: false, reason: "Path does not exist" };
|
||||
if (!info.isDirectory()) return { valid: false, reason: "Not a directory" };
|
||||
|
||||
const schemaInfo = await stat(join(absolutePath, ".wiki-schema.md")).catch(() => null);
|
||||
if (!schemaInfo) return { valid: false, reason: "Missing .wiki-schema.md" };
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async function comparablePath(rawPath: string): Promise<string> {
|
||||
const absolutePath = resolve(expandUserPath(rawPath));
|
||||
return realpath(absolutePath).catch(() => absolutePath);
|
||||
}
|
||||
|
||||
export async function findRegisteredKnowledgeBase(
|
||||
rawPath: string,
|
||||
knownBases: KnowledgeBaseInfo[],
|
||||
): Promise<KnowledgeBaseInfo | null> {
|
||||
const targetPath = await comparablePath(rawPath);
|
||||
for (const kb of knownBases) {
|
||||
if (!kb.valid) continue;
|
||||
const registeredPath = await comparablePath(kb.path);
|
||||
if (registeredPath === targetPath) return kb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function assertRegisteredKnowledgeBase(rawPath: string): Promise<string> {
|
||||
const match = await findRegisteredKnowledgeBase(rawPath, await listKnowledgeBases());
|
||||
if (!match) {
|
||||
throw Object.assign(new Error("knowledge base is not registered"), { statusCode: 403 });
|
||||
}
|
||||
return match.path;
|
||||
}
|
||||
|
||||
export async function inspectPath(rawPath: string): Promise<InspectPathResult> {
|
||||
const expanded = expandUserPath(rawPath);
|
||||
if (!expanded) throw new Error("path 不能为空");
|
||||
const absolutePath = resolve(expanded);
|
||||
const info = await stat(absolutePath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw Object.assign(new Error(`没有权限访问:${absolutePath}`), { statusCode: 403 });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (!info) return { exists: false, isDirectory: false, hasWikiSchema: false };
|
||||
|
||||
const resolvedPath = await realpath(absolutePath).catch(() => absolutePath);
|
||||
const isDirectory = info.isDirectory();
|
||||
const schemaInfo = isDirectory ? await stat(join(resolvedPath, ".wiki-schema.md")).catch(() => null) : null;
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory,
|
||||
hasWikiSchema: Boolean(schemaInfo?.isFile()),
|
||||
resolvedPath,
|
||||
...(isDirectory ? { ingestibleFiles: await scanIngestibleFiles(resolvedPath) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function scanIngestibleFiles(root: string): Promise<NonNullable<InspectPathResult["ingestibleFiles"]>> {
|
||||
const paths: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
async function walk(dir: string, depth: number): Promise<void> {
|
||||
if (depth > MAX_SCAN_DEPTH || paths.length >= MAX_INGESTIBLE_FILES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (paths.length >= MAX_INGESTIBLE_FILES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
if (entry.name === ".DS_Store") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".") && ![".wiki-tmp"].includes(entry.name)) continue;
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
await walk(fullPath, depth + 1);
|
||||
} else if (entry.isFile() && INGEST_EXTS.has(extname(entry.name).toLowerCase())) {
|
||||
paths.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root, 0);
|
||||
const scanId = randomUUID();
|
||||
inspectScans.set(scanId, {
|
||||
root,
|
||||
paths: new Set(paths),
|
||||
expiresAt: Date.now() + INSPECT_SCAN_TTL_MS,
|
||||
});
|
||||
return {
|
||||
scanId,
|
||||
count: paths.length,
|
||||
samples: paths.slice(0, 5).map((file) => file.slice(root.length + 1)),
|
||||
paths,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateInspectedSourceFiles(
|
||||
scanId: string | undefined,
|
||||
filePaths: string[],
|
||||
): { ok: true; root: string; paths: Set<string> } | { ok: false; error: string } {
|
||||
if (!scanId) return { ok: false, error: "缺少来源扫描凭据,请重新选择目录" };
|
||||
const scan = inspectScans.get(scanId);
|
||||
if (!scan) return { ok: false, error: "来源扫描已过期,请重新选择目录" };
|
||||
if (scan.expiresAt < Date.now()) {
|
||||
inspectScans.delete(scanId);
|
||||
return { ok: false, error: "来源扫描已过期,请重新选择目录" };
|
||||
}
|
||||
for (const filePath of filePaths) {
|
||||
if (!scan.paths.has(filePath)) {
|
||||
return { ok: false, error: `文件不在刚才扫描确认的目录内:${filePath}` };
|
||||
}
|
||||
}
|
||||
return { ok: true, root: scan.root, paths: scan.paths };
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有已知知识库(默认根扫描 + 外部登记),含 valid 状态。
|
||||
* 同绝对路径去重:默认根优先于外部登记。
|
||||
*/
|
||||
export async function listKnowledgeBases(): Promise<KnowledgeBaseInfo[]> {
|
||||
// 1. 确保默认根存在(首次启动)
|
||||
await mkdir(DEFAULT_KNOWLEDGE_BASE_ROOT, { recursive: true });
|
||||
|
||||
// 2. 扫默认根
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await readdir(DEFAULT_KNOWLEDGE_BASE_ROOT, { withFileTypes: true });
|
||||
} catch {
|
||||
// 极端情况(比如权限问题):仅返回外部部分,不崩
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: KnowledgeBaseInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const path = join(DEFAULT_KNOWLEDGE_BASE_ROOT, entry.name);
|
||||
const check = await inspectKnowledgeBasePath(path);
|
||||
results.push({
|
||||
path,
|
||||
name: entry.name,
|
||||
origin: "default",
|
||||
...(check.valid ? { valid: true } : { valid: false, reason: check.reason }),
|
||||
});
|
||||
seen.add(path);
|
||||
}
|
||||
|
||||
// 3. 加外部登记
|
||||
const config = await loadConfig();
|
||||
for (const externalPath of config.externalKnowledgeBases) {
|
||||
if (seen.has(externalPath)) continue; // 已经被默认扫描覆盖
|
||||
const check = await inspectKnowledgeBasePath(externalPath);
|
||||
results.push({
|
||||
path: externalPath,
|
||||
name: basename(externalPath),
|
||||
origin: "external",
|
||||
...(check.valid ? { valid: true } : { valid: false, reason: check.reason }),
|
||||
});
|
||||
seen.add(externalPath);
|
||||
}
|
||||
|
||||
// 4. 排序:default 在前,按名字
|
||||
results.sort((a, b) => {
|
||||
if (a.origin !== b.origin) return a.origin === "default" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, "zh");
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一个外部知识库。
|
||||
* - 绝对路径化(解析 ~ 由调用方负责,这里只做 resolve)
|
||||
* - 验证有效性(路径合法、含 schema 文件)
|
||||
* - 已存在则幂等(不重复追加,不报错)
|
||||
* - 若该路径正好在默认根下,提示但仍登记(用户自己选择)
|
||||
*/
|
||||
export async function registerExternalKnowledgeBase(rawPath: string): Promise<{
|
||||
registered: boolean;
|
||||
path: string;
|
||||
info: KnowledgeBaseInfo;
|
||||
}> {
|
||||
const absolutePath = await realpath(resolve(expandUserPath(rawPath))).catch(() =>
|
||||
resolve(expandUserPath(rawPath)),
|
||||
);
|
||||
|
||||
const check = await inspectKnowledgeBasePath(absolutePath);
|
||||
if (!check.valid) {
|
||||
throw new Error(`不是合法知识库(${check.reason}):${absolutePath}`);
|
||||
}
|
||||
|
||||
const config = await loadConfig();
|
||||
const alreadyRegistered = config.externalKnowledgeBases.includes(absolutePath);
|
||||
|
||||
if (!alreadyRegistered) {
|
||||
const updated: AppConfig = {
|
||||
...config,
|
||||
externalKnowledgeBases: [...config.externalKnowledgeBases, absolutePath],
|
||||
};
|
||||
await saveConfig(updated);
|
||||
}
|
||||
|
||||
return {
|
||||
registered: !alreadyRegistered,
|
||||
path: absolutePath,
|
||||
info: {
|
||||
path: absolutePath,
|
||||
name: basename(absolutePath),
|
||||
origin: "external",
|
||||
valid: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消登记一个外部知识库。从 config.externalKnowledgeBases 移除(不删文件系统)。
|
||||
*/
|
||||
export async function unregisterExternalKnowledgeBase(rawPath: string): Promise<{
|
||||
removed: boolean;
|
||||
path: string;
|
||||
}> {
|
||||
const absolutePath = resolve(rawPath);
|
||||
const config = await loadConfig();
|
||||
const before = config.externalKnowledgeBases.length;
|
||||
const after = config.externalKnowledgeBases.filter((p) => p !== absolutePath);
|
||||
|
||||
if (after.length === before) {
|
||||
return { removed: false, path: absolutePath };
|
||||
}
|
||||
|
||||
await saveConfig({ ...config, externalKnowledgeBases: after });
|
||||
return { removed: true, path: absolutePath };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { listPageRefs, resolveWikiPagePath } from "./pages.js";
|
||||
|
||||
test("resolveWikiPagePath rejects traversal outside the wiki directory", () => {
|
||||
const kbPath = path.join("/", "tmp", "kb");
|
||||
assert.equal(
|
||||
resolveWikiPagePath(kbPath, "wiki/topics/agent.md"),
|
||||
path.join(kbPath, "wiki", "topics", "agent.md"),
|
||||
);
|
||||
assert.throws(() => resolveWikiPagePath(kbPath, "wiki/../purpose.md"), /inside wiki/);
|
||||
assert.throws(() => resolveWikiPagePath(kbPath, "raw/source.md"), /inside wiki/);
|
||||
assert.throws(() => resolveWikiPagePath(kbPath, "/tmp/kb/wiki/topics/agent.md"), /relative/);
|
||||
});
|
||||
|
||||
test("listPageRefs can preload more than 100 refs for current-library search", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "llm-wiki-pages-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "wiki", "entities"), { recursive: true });
|
||||
await writeFile(path.join(root, ".wiki-schema.md"), "# schema\n", "utf8");
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
const padded = String(index).padStart(3, "0");
|
||||
await writeFile(
|
||||
path.join(root, "wiki", "entities", `page-${padded}.md`),
|
||||
`# Page ${padded}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const refs = await listPageRefs(root, "", 150, { assertRegistered: false });
|
||||
|
||||
assert.equal(refs.length, 150);
|
||||
assert.equal(refs.at(-1)?.path, "wiki/entities/page-149.md");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertRegisteredKnowledgeBase } from "./knowledge-bases.js";
|
||||
|
||||
export interface PageRef {
|
||||
path: string;
|
||||
name: string;
|
||||
category: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const PAGE_DIRS = ["entities", "topics", "sources", "comparisons", "synthesis"];
|
||||
const IGNORE_NAMES = new Set([".obsidian", ".DS_Store", ".wiki-tmp", ".git", "node_modules"]);
|
||||
const MAX_REF_LIMIT = 5000;
|
||||
|
||||
interface CacheEntry {
|
||||
items: PageRef[];
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function shouldIgnore(name: string): boolean {
|
||||
return name.startsWith(".") || IGNORE_NAMES.has(name);
|
||||
}
|
||||
|
||||
function toPosix(relativePath: string): string {
|
||||
return relativePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
async function extractTitle(filePath: string, fallback: string): Promise<string> {
|
||||
const head = (await readFile(filePath, "utf8")).slice(0, 1024);
|
||||
const match = head.match(/^#\s+(.+)$/m);
|
||||
return match?.[1]?.trim() || fallback;
|
||||
}
|
||||
|
||||
async function scanCategory(kbPath: string, category: string): Promise<PageRef[]> {
|
||||
const root = path.join(kbPath, "wiki", category);
|
||||
const rootInfo = await stat(root).catch(() => null);
|
||||
if (!rootInfo?.isDirectory()) return [];
|
||||
const results: PageRef[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (shouldIgnore(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
const relFromWiki = toPosix(path.relative(path.join(kbPath, "wiki"), full));
|
||||
const relPath = `wiki/${relFromWiki}`;
|
||||
const name = entry.name.replace(/\.md$/, "");
|
||||
results.push({
|
||||
path: relPath,
|
||||
name,
|
||||
category,
|
||||
title: await extractTitle(full, name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function scanPages(kbPath: string): Promise<PageRef[]> {
|
||||
const items = (await Promise.all(PAGE_DIRS.map((category) => scanCategory(kbPath, category))))
|
||||
.flat()
|
||||
.sort((a, b) => a.path.localeCompare(b.path, "zh"));
|
||||
return items;
|
||||
}
|
||||
|
||||
async function directoryFingerprint(root: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (shouldIgnore(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
const info = await stat(full).catch(() => null);
|
||||
if (!info) continue;
|
||||
if (entry.isDirectory()) {
|
||||
parts.push(`d:${toPosix(path.relative(root, full))}:${info.mtimeMs}`);
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
parts.push(`f:${toPosix(path.relative(root, full))}:${info.mtimeMs}:${info.size}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return parts.sort().join("|");
|
||||
}
|
||||
|
||||
export async function getCachedPages(
|
||||
kbPath: string,
|
||||
options: { assertRegistered?: boolean } = {},
|
||||
): Promise<PageRef[]> {
|
||||
if (options.assertRegistered !== false) await assertRegisteredKb(kbPath);
|
||||
const wikiDir = path.join(kbPath, "wiki");
|
||||
const fingerprint = await directoryFingerprint(wikiDir);
|
||||
const cached = cache.get(kbPath);
|
||||
if (cached && cached.fingerprint === fingerprint) return cached.items;
|
||||
const items = await scanPages(kbPath);
|
||||
cache.set(kbPath, { items, fingerprint });
|
||||
return items;
|
||||
}
|
||||
|
||||
function score(item: PageRef, query: string): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
let value = 0;
|
||||
if (item.title.toLowerCase().includes(q)) value += 2;
|
||||
if (item.name.toLowerCase().includes(q)) value += 1;
|
||||
if (item.path.toLowerCase().includes(q)) value += 0.5;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function listPageRefs(
|
||||
kbPath: string,
|
||||
query = "",
|
||||
limit = 20,
|
||||
options: { assertRegistered?: boolean } = {},
|
||||
): Promise<PageRef[]> {
|
||||
const q = query.trim();
|
||||
const items = await getCachedPages(kbPath, { assertRegistered: options.assertRegistered });
|
||||
return items
|
||||
.map((item) => ({ item, score: score(item, q) }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.item.path.localeCompare(b.item.path, "zh"))
|
||||
.slice(0, Math.max(1, Math.min(limit, MAX_REF_LIMIT)))
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
export async function readWikiPage(kbPath: string, relPath: string): Promise<string> {
|
||||
await assertRegisteredKb(kbPath);
|
||||
return readFile(resolveWikiPagePath(kbPath, relPath), "utf8");
|
||||
}
|
||||
|
||||
export function resolveWikiPagePath(kbPath: string, relPath: string): string {
|
||||
if (!relPath || path.isAbsolute(relPath)) throw new Error("path must be relative");
|
||||
if (!relPath.endsWith(".md")) throw new Error("path must be a markdown file");
|
||||
if (!relPath.split(/[\\/]/).includes("wiki")) throw new Error("path must be inside wiki");
|
||||
|
||||
const kbRoot = path.resolve(kbPath);
|
||||
const wikiRoot = path.join(kbRoot, "wiki");
|
||||
const requested = path.resolve(kbRoot, relPath);
|
||||
if (requested !== wikiRoot && !requested.startsWith(wikiRoot + path.sep)) {
|
||||
throw new Error("path must be inside wiki");
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
async function assertRegisteredKb(kbPath: string): Promise<void> {
|
||||
await assertRegisteredKnowledgeBase(kbPath);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildKnowledgeContextPrompt,
|
||||
contextBudgetFromWindow,
|
||||
parseExplicitPageRefs,
|
||||
searchKnowledgeBase,
|
||||
shouldUseKnowledgeBase,
|
||||
stripKnowledgeContextForDisplay,
|
||||
} from "./retrieval.js";
|
||||
|
||||
test("parseExplicitPageRefs extracts wiki markdown links in order", () => {
|
||||
assert.deepEqual(parseExplicitPageRefs("这是什么 [[wiki/synthesis/sessions/x.md]]"), [
|
||||
"wiki/synthesis/sessions/x.md",
|
||||
]);
|
||||
assert.deepEqual(parseExplicitPageRefs("[[wiki/a.md]] 和 [[wiki/b.md]]"), [
|
||||
"wiki/a.md",
|
||||
"wiki/b.md",
|
||||
]);
|
||||
assert.deepEqual(parseExplicitPageRefs("[[wiki/a.md]] [[wiki/a.md]]"), ["wiki/a.md"]);
|
||||
assert.deepEqual(parseExplicitPageRefs("[[notwiki.md]] 普通文本"), []);
|
||||
});
|
||||
|
||||
test("shouldUseKnowledgeBase follows blacklist strategy", () => {
|
||||
assert.equal(shouldUseKnowledgeBase("总结一下", false), false);
|
||||
assert.equal(shouldUseKnowledgeBase("/sediment", true), false);
|
||||
assert.equal(shouldUseKnowledgeBase("/pdf", true), false);
|
||||
assert.equal(shouldUseKnowledgeBase("你好。", true), false);
|
||||
assert.equal(shouldUseKnowledgeBase("当前模型是什么", true), false);
|
||||
assert.equal(
|
||||
shouldUseKnowledgeBase("请把当前对话整理产出为 PDF,调用 prepare_artifact", true),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldUseKnowledgeBase("[[wiki/x.md]] 说了啥", true), true);
|
||||
assert.equal(shouldUseKnowledgeBase("这是我自媒体创作的文章,总结一下", true), true);
|
||||
assert.equal(shouldUseKnowledgeBase("OpenClaw 相关页面讲了什么", true), true);
|
||||
assert.equal(shouldUseKnowledgeBase("嗯", true), false);
|
||||
});
|
||||
|
||||
test("searchKnowledgeBase finds recent synthesis pages for generic summaries", async () => {
|
||||
const kbPath = await createTestKb();
|
||||
const search = await searchKnowledgeBase(kbPath, "这些文章总结一下", {
|
||||
assertRegistered: false,
|
||||
totalBudgetChars: 2000,
|
||||
});
|
||||
assert.equal(search.missingExplicitRefs.length, 0);
|
||||
assert.ok(search.results.length >= 2);
|
||||
assert.ok(search.results.some((item) => item.path.includes("wiki/synthesis/sessions")));
|
||||
assert.ok(search.results.every((item) => item.snippet.length <= 600));
|
||||
assert.ok(search.totalSnippetChars <= 2000);
|
||||
});
|
||||
|
||||
test("searchKnowledgeBase keeps explicit refs first", async () => {
|
||||
const kbPath = await createTestKb();
|
||||
const search = await searchKnowledgeBase(kbPath, "对比一下 [[wiki/synthesis/sessions/kiro.md]]", {
|
||||
assertRegistered: false,
|
||||
explicitRefs: ["wiki/synthesis/sessions/kiro.md"],
|
||||
totalBudgetChars: 500,
|
||||
});
|
||||
assert.equal(search.results[0]?.path, "wiki/synthesis/sessions/kiro.md");
|
||||
assert.equal(search.results[0]?.hitReason, "explicit");
|
||||
});
|
||||
|
||||
test("searchKnowledgeBase reports missing explicit refs", async () => {
|
||||
const kbPath = await createTestKb();
|
||||
const search = await searchKnowledgeBase(kbPath, "说说 [[wiki/不存在.md]]", {
|
||||
assertRegistered: false,
|
||||
explicitRefs: ["wiki/不存在.md"],
|
||||
totalBudgetChars: 500,
|
||||
});
|
||||
assert.deepEqual(search.missingExplicitRefs, ["wiki/不存在.md"]);
|
||||
const wrapped = buildKnowledgeContextPrompt({
|
||||
originalMessage: "说说 [[wiki/不存在.md]]",
|
||||
kb: { name: "测试库", path: kbPath },
|
||||
search,
|
||||
});
|
||||
assert.match(wrapped, /该页面不存在/);
|
||||
assert.match(wrapped, /wiki\/不存在\.md/);
|
||||
});
|
||||
|
||||
test("searchKnowledgeBase sees new pages through pages cache invalidation", async () => {
|
||||
const kbPath = await createTestKb();
|
||||
const before = await searchKnowledgeBase(kbPath, "新增主题", {
|
||||
assertRegistered: false,
|
||||
totalBudgetChars: 2000,
|
||||
});
|
||||
assert.equal(before.results.some((item) => item.path.includes("new-topic")), false);
|
||||
await writeFile(
|
||||
path.join(kbPath, "wiki", "synthesis", "sessions", "new-topic.md"),
|
||||
"# 新增主题\n新增主题用于验证批量消化后缓存刷新。\n",
|
||||
"utf8",
|
||||
);
|
||||
const after = await searchKnowledgeBase(kbPath, "新增主题", {
|
||||
assertRegistered: false,
|
||||
totalBudgetChars: 2000,
|
||||
});
|
||||
assert.equal(after.results.some((item) => item.path.endsWith("new-topic.md")), true);
|
||||
});
|
||||
|
||||
test("contextBudgetFromWindow follows 20 percent fallback rule", () => {
|
||||
assert.equal(contextBudgetFromWindow(10_000), 2_000);
|
||||
assert.equal(contextBudgetFromWindow(undefined), 4_000);
|
||||
assert.equal(contextBudgetFromWindow("bad"), 4_000);
|
||||
});
|
||||
|
||||
test("buildKnowledgeContextPrompt wraps empty results explicitly", () => {
|
||||
const wrapped = buildKnowledgeContextPrompt({
|
||||
originalMessage: "总结一下",
|
||||
kb: { name: "测试库", path: "/tmp/test-kb" },
|
||||
search: { results: [], missingExplicitRefs: [], totalSnippetChars: 0 },
|
||||
});
|
||||
assert.match(wrapped, /未在当前知识库找到/);
|
||||
assert.match(wrapped, /禁止反问用户提供文章/);
|
||||
});
|
||||
|
||||
test("stripKnowledgeContextForDisplay hides wrapped retrieval context", () => {
|
||||
const wrapped = buildKnowledgeContextPrompt({
|
||||
originalMessage: "这是我的知识库,总结下",
|
||||
kb: { name: "测试库", path: "/tmp/test-kb" },
|
||||
search: { results: [], missingExplicitRefs: [], totalSnippetChars: 0 },
|
||||
});
|
||||
assert.equal(stripKnowledgeContextForDisplay(wrapped), "这是我的知识库,总结下");
|
||||
assert.equal(
|
||||
stripKnowledgeContextForDisplay("问题\n---\n[系统检索上下文 / 用户不可见]\n隐藏内容"),
|
||||
"问题",
|
||||
);
|
||||
});
|
||||
|
||||
async function createTestKb(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "llm-wiki-retrieval-"));
|
||||
await mkdir(path.join(root, "wiki", "synthesis", "sessions"), { recursive: true });
|
||||
await writeFile(path.join(root, ".wiki-schema.md"), "# schema\n", "utf8");
|
||||
await writeFile(path.join(root, "purpose.md"), "# 研究方向\n自媒体文章创作方法论\n", "utf8");
|
||||
await writeFile(path.join(root, "index.md"), "# 首页\n这是文章创作知识库。\n", "utf8");
|
||||
await writeFile(path.join(root, "wiki", "overview.md"), "# 总览\n收集 AI 创作文章。\n", "utf8");
|
||||
await writeFile(
|
||||
path.join(root, "wiki", "synthesis", "sessions", "kiro.md"),
|
||||
"# Kiro 写作方法\nKiro 文章讨论提示词、AI 编程和内容创作。\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "wiki", "synthesis", "sessions", "openclaw.md"),
|
||||
"# OpenClaw 自媒体文章\nOpenClaw 文章讨论多 agent 团队和一人公司。\n",
|
||||
"utf8",
|
||||
);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { APP_DIR } from "./config.js";
|
||||
import { getCachedPages, type PageRef } from "./pages.js";
|
||||
|
||||
export type HitReason =
|
||||
| "explicit"
|
||||
| "title"
|
||||
| "filename"
|
||||
| "body"
|
||||
| "recent_synthesis"
|
||||
| "kb_meta";
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
mtime: number;
|
||||
hitReason: HitReason;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeBaseOutput {
|
||||
results: SearchResult[];
|
||||
missingExplicitRefs: string[];
|
||||
totalSnippetChars: number;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeBaseOptions {
|
||||
explicitRefs?: string[];
|
||||
maxPages?: number;
|
||||
snippetMaxChars?: number;
|
||||
totalBudgetChars?: number;
|
||||
assertRegistered?: boolean;
|
||||
}
|
||||
|
||||
export interface RetrievalLogEntry {
|
||||
ts: number;
|
||||
sessionId: string;
|
||||
kbPath: string;
|
||||
messagePreview: string;
|
||||
triggered: boolean;
|
||||
explicitRefs: string[];
|
||||
results: Array<{ path: string; hitReason: HitReason; score: number }>;
|
||||
wrappedCharCount: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_PAGES = 6;
|
||||
const DEFAULT_SNIPPET_MAX_CHARS = 600;
|
||||
const DEFAULT_TOTAL_BUDGET_CHARS = 4000;
|
||||
const MIN_QUERY_TOKEN_LENGTH = 2;
|
||||
const META_FILES = ["purpose.md", "index.md", "wiki/overview.md"];
|
||||
const GENERIC_QUERY_RE =
|
||||
/(总结|概括|归纳|这些文章|这批文章|自媒体|共同主题|这个库|当前库|刚刚消化|刚消化|文章讲|内容讲|分析一下)/i;
|
||||
const GREETING_SET = new Set(["你好", "hi", "hello", "在吗", "谢谢", "thanks", "thx", "ok", "好的"]);
|
||||
const META_QUESTION_RE =
|
||||
/(当前模型|模型是什么|怎么设置|怎么用|界面|快捷键|登录|api\s*key|API\s*key|整理产出为|prepare_artifact|finalize_artifact|artifact id|生成主文件)/i;
|
||||
|
||||
export function parseExplicitPageRefs(message: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const refs: string[] = [];
|
||||
const re = /\[\[(wiki\/[^\]\n]+?\.md)\]\]/g;
|
||||
for (const match of message.matchAll(re)) {
|
||||
const ref = match[1]?.trim();
|
||||
if (!ref || seen.has(ref)) continue;
|
||||
seen.add(ref);
|
||||
refs.push(ref);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function shouldUseKnowledgeBase(message: string, kbSelected: boolean): boolean {
|
||||
if (!kbSelected) return false;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith("/")) return false;
|
||||
if (parseExplicitPageRefs(trimmed).length > 0) return true;
|
||||
if ([...trimmed].length < 3) return false;
|
||||
const normalized = trimmed.replace(/[。!?!?.,,\s]/g, "").toLowerCase();
|
||||
if (GREETING_SET.has(normalized)) return false;
|
||||
if (META_QUESTION_RE.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function searchKnowledgeBase(
|
||||
kbPath: string,
|
||||
query: string,
|
||||
options: SearchKnowledgeBaseOptions = {},
|
||||
): Promise<SearchKnowledgeBaseOutput> {
|
||||
const maxPages = Math.max(1, options.maxPages ?? DEFAULT_MAX_PAGES);
|
||||
const snippetMaxChars = Math.max(120, options.snippetMaxChars ?? DEFAULT_SNIPPET_MAX_CHARS);
|
||||
const totalBudgetChars = Math.max(300, options.totalBudgetChars ?? DEFAULT_TOTAL_BUDGET_CHARS);
|
||||
const explicitRefs = unique(options.explicitRefs ?? []);
|
||||
const genericQuery = isGenericQuery(query);
|
||||
const tokens = queryTokens(query);
|
||||
const candidates = await buildCandidates(kbPath, options.assertRegistered !== false);
|
||||
const byPath = new Map(candidates.map((candidate) => [candidate.path, candidate]));
|
||||
const missingExplicitRefs: string[] = [];
|
||||
const scored: SearchResult[] = [];
|
||||
|
||||
for (const ref of explicitRefs) {
|
||||
const candidate = byPath.get(ref) ?? (await loadAdHocCandidate(kbPath, ref));
|
||||
if (!candidate) {
|
||||
missingExplicitRefs.push(ref);
|
||||
continue;
|
||||
}
|
||||
scored.push(toResult(candidate, "explicit", 10_000, query, snippetMaxChars));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (explicitRefs.includes(candidate.path)) continue;
|
||||
const evaluated = evaluateCandidate(candidate, tokens, genericQuery, query, snippetMaxChars);
|
||||
if (evaluated) scored.push(evaluated);
|
||||
}
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (a.hitReason === "explicit" && b.hitReason !== "explicit") return -1;
|
||||
if (b.hitReason === "explicit" && a.hitReason !== "explicit") return 1;
|
||||
return b.score - a.score || b.mtime - a.mtime || a.path.localeCompare(b.path, "zh");
|
||||
});
|
||||
|
||||
const limited: SearchResult[] = [];
|
||||
let totalSnippetChars = 0;
|
||||
for (const result of scored) {
|
||||
if (limited.some((item) => item.path === result.path)) continue;
|
||||
const isExplicit = result.hitReason === "explicit";
|
||||
if (!isExplicit && limited.length >= maxPages) continue;
|
||||
if (!isExplicit && totalSnippetChars + result.snippet.length > totalBudgetChars) continue;
|
||||
limited.push(result);
|
||||
totalSnippetChars += result.snippet.length;
|
||||
if (limited.length >= maxPages && totalSnippetChars >= totalBudgetChars) break;
|
||||
}
|
||||
|
||||
return { results: limited, missingExplicitRefs, totalSnippetChars };
|
||||
}
|
||||
|
||||
export function buildKnowledgeContextPrompt(input: {
|
||||
originalMessage: string;
|
||||
kb: { name: string; path: string };
|
||||
search: SearchKnowledgeBaseOutput;
|
||||
}): string {
|
||||
return [
|
||||
input.originalMessage,
|
||||
"",
|
||||
"---",
|
||||
buildKnowledgeContextMessage({ kb: input.kb, search: input.search }),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildKnowledgeContextMessage(input: {
|
||||
kb: { name: string; path: string };
|
||||
search: SearchKnowledgeBaseOutput;
|
||||
}): string {
|
||||
const { kb, search } = input;
|
||||
const missing =
|
||||
search.missingExplicitRefs.length > 0
|
||||
? [
|
||||
"",
|
||||
"用户显式引用了以下页面,但该页面不存在:",
|
||||
...search.missingExplicitRefs.map((ref) => `- ${ref}`),
|
||||
].join("\n")
|
||||
: "";
|
||||
if (search.results.length === 0) {
|
||||
return [
|
||||
"[系统检索上下文 / 用户不可见]",
|
||||
"",
|
||||
`当前知识库:${kb.name}`,
|
||||
`路径:${kb.path}`,
|
||||
"系统已尝试检索,但未在当前知识库找到与该问题相关的页面。",
|
||||
missing,
|
||||
"",
|
||||
"请明确告诉用户:当前知识库未找到相关内容。禁止反问用户提供文章,禁止编造来源。",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const pages = search.results.flatMap((result, index) => [
|
||||
`[${index + 1}] ${result.title}`,
|
||||
` 路径: ${result.path}`,
|
||||
` 命中: ${result.hitReason}`,
|
||||
` 摘要: ${result.snippet}`,
|
||||
"",
|
||||
]);
|
||||
return [
|
||||
"[系统检索上下文 / 用户不可见]",
|
||||
"",
|
||||
"当前知识库:",
|
||||
`- 名称: ${kb.name}`,
|
||||
`- 路径: ${kb.path}`,
|
||||
"",
|
||||
"系统已从当前知识库检索到以下页面(按相关度排序):",
|
||||
"",
|
||||
...pages,
|
||||
missing,
|
||||
"",
|
||||
"回答约束:",
|
||||
"- 必须基于以上页面内容回答;不得编造未在页面中出现的事实。",
|
||||
"- 如果以上页面不足以回答,明确说明当前知识库未找到足够相关内容。",
|
||||
"- 禁止再次反问用户提供文章;系统已经提供了当前知识库页面。",
|
||||
"- 回答末尾必须列出“参考页面”,格式为 `- 《标题》:路径`。",
|
||||
"- 参考页面只能从上述检索结果中选取,禁止编造路径。",
|
||||
"- 如未基于以上页面回答而再次反问用户提供文章,视为错误。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function stripKnowledgeContextForDisplay(text: string): string {
|
||||
const marker = "[系统检索上下文";
|
||||
const markerIndex = text.indexOf(marker);
|
||||
if (markerIndex === -1) return text;
|
||||
|
||||
const beforeMarker = text.slice(0, markerIndex);
|
||||
const separatorIndex = beforeMarker.lastIndexOf("\n---");
|
||||
if (separatorIndex !== -1 && beforeMarker.slice(separatorIndex).trim() === "---") {
|
||||
return beforeMarker.slice(0, separatorIndex).trimEnd();
|
||||
}
|
||||
|
||||
return beforeMarker.trimEnd();
|
||||
}
|
||||
|
||||
export function formatSearchResultsForTool(search: SearchKnowledgeBaseOutput): string {
|
||||
if (search.results.length === 0) return "当前知识库未找到相关页面。";
|
||||
return search.results
|
||||
.map(
|
||||
(result, index) =>
|
||||
[
|
||||
`[${index + 1}] ${result.title}`,
|
||||
`路径: ${result.path}`,
|
||||
`命中: ${result.hitReason}`,
|
||||
result.snippet,
|
||||
].join("\n"),
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function contextBudgetFromWindow(contextWindow: unknown): number {
|
||||
if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
|
||||
return DEFAULT_TOTAL_BUDGET_CHARS;
|
||||
}
|
||||
return Math.max(1000, Math.floor(contextWindow * 0.2));
|
||||
}
|
||||
|
||||
export async function writeRetrievalLog(entry: RetrievalLogEntry): Promise<void> {
|
||||
const date = new Date(entry.ts).toISOString().slice(0, 10);
|
||||
const dir = path.join(APP_DIR, "logs", "retrieval");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, `${date}.jsonl`), `${JSON.stringify(entry)}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "a",
|
||||
});
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
path: string;
|
||||
title: string;
|
||||
content: string;
|
||||
mtime: number;
|
||||
category: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function buildCandidates(kbPath: string, assertRegistered: boolean): Promise<Candidate[]> {
|
||||
const pages = await getCachedPages(kbPath, { assertRegistered });
|
||||
const pageCandidates = await Promise.all(
|
||||
pages.map((page) => loadPageCandidate(kbPath, page).catch(() => null)),
|
||||
);
|
||||
const metaCandidates = await Promise.all(
|
||||
META_FILES.map((relPath) => loadMetaCandidate(kbPath, relPath).catch(() => null)),
|
||||
);
|
||||
return [...pageCandidates, ...metaCandidates].filter((item): item is Candidate => Boolean(item));
|
||||
}
|
||||
|
||||
async function loadPageCandidate(kbPath: string, page: PageRef): Promise<Candidate> {
|
||||
const absolute = safeResolve(kbPath, page.path);
|
||||
const [content, info] = await Promise.all([readFile(absolute, "utf8"), stat(absolute)]);
|
||||
return {
|
||||
path: page.path,
|
||||
title: page.title,
|
||||
content,
|
||||
mtime: info.mtimeMs,
|
||||
category: page.category,
|
||||
name: page.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMetaCandidate(kbPath: string, relPath: string): Promise<Candidate | null> {
|
||||
const absolute = safeResolve(kbPath, relPath);
|
||||
const info = await stat(absolute).catch(() => null);
|
||||
if (!info?.isFile()) return null;
|
||||
const content = await readFile(absolute, "utf8");
|
||||
return {
|
||||
path: relPath,
|
||||
title: extractTitle(content, path.basename(relPath, ".md")),
|
||||
content,
|
||||
mtime: info.mtimeMs,
|
||||
category: "kb_meta",
|
||||
name: path.basename(relPath, ".md"),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAdHocCandidate(kbPath: string, relPath: string): Promise<Candidate | null> {
|
||||
if (!relPath.startsWith("wiki/") || !relPath.endsWith(".md")) return null;
|
||||
return loadMetaCandidate(kbPath, relPath);
|
||||
}
|
||||
|
||||
function evaluateCandidate(
|
||||
candidate: Candidate,
|
||||
tokens: string[],
|
||||
genericQuery: boolean,
|
||||
query: string,
|
||||
snippetMaxChars: number,
|
||||
): SearchResult | null {
|
||||
let hitReason: HitReason | null = null;
|
||||
let score = 0;
|
||||
const title = candidate.title.toLowerCase();
|
||||
const name = candidate.name.toLowerCase();
|
||||
const relPath = candidate.path.toLowerCase();
|
||||
const body = candidate.content.toLowerCase();
|
||||
const loweredTokens = tokens.map((token) => token.toLowerCase());
|
||||
|
||||
for (const token of loweredTokens) {
|
||||
if (title.includes(token)) {
|
||||
score += 8 + token.length / 10;
|
||||
hitReason ??= "title";
|
||||
}
|
||||
if (name.includes(token)) {
|
||||
score += 5 + token.length / 10;
|
||||
hitReason ??= "filename";
|
||||
}
|
||||
if (relPath.includes(token)) {
|
||||
score += 2;
|
||||
hitReason ??= "filename";
|
||||
}
|
||||
if (body.includes(token)) {
|
||||
score += 3 + token.length / 10;
|
||||
hitReason ??= "body";
|
||||
}
|
||||
}
|
||||
|
||||
if (genericQuery && candidate.category === "kb_meta") {
|
||||
score += 25;
|
||||
hitReason = "kb_meta";
|
||||
}
|
||||
if (genericQuery && candidate.path.startsWith("wiki/synthesis/sessions/")) {
|
||||
score += 40 + Math.min(10, candidate.mtime / 1_000_000_000_000);
|
||||
hitReason = "recent_synthesis";
|
||||
}
|
||||
|
||||
if (!hitReason || score <= 0) return null;
|
||||
return toResult(candidate, hitReason, score, query, snippetMaxChars);
|
||||
}
|
||||
|
||||
function toResult(
|
||||
candidate: Candidate,
|
||||
hitReason: HitReason,
|
||||
score: number,
|
||||
query: string,
|
||||
snippetMaxChars: number,
|
||||
): SearchResult {
|
||||
return {
|
||||
path: candidate.path,
|
||||
title: candidate.title,
|
||||
snippet: extractSnippet(candidate.content, query, snippetMaxChars),
|
||||
mtime: candidate.mtime,
|
||||
hitReason,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSnippet(content: string, query: string, maxChars: number): string {
|
||||
const compact = content.replace(/\s+/g, " ").trim();
|
||||
if (compact.length <= maxChars) return compact;
|
||||
const tokens = queryTokens(query);
|
||||
const lower = compact.toLowerCase();
|
||||
const token = tokens.find((item) => lower.includes(item.toLowerCase()));
|
||||
if (!token) return `${compact.slice(0, maxChars).trim()}…`;
|
||||
const index = lower.indexOf(token.toLowerCase());
|
||||
const start = Math.max(0, index - Math.floor(maxChars / 2));
|
||||
const end = Math.min(compact.length, start + maxChars);
|
||||
const prefix = start > 0 ? "…" : "";
|
||||
const suffix = end < compact.length ? "…" : "";
|
||||
return `${prefix}${compact.slice(start, end).trim()}${suffix}`;
|
||||
}
|
||||
|
||||
function queryTokens(query: string): string[] {
|
||||
const normalized = query
|
||||
.replace(/\[\[[^\]]+\]\]/g, " ")
|
||||
.replace(/[^\p{Script=Han}\p{Letter}\p{Number}]+/gu, " ");
|
||||
return unique(
|
||||
normalized
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => [...token].length >= MIN_QUERY_TOKEN_LENGTH),
|
||||
);
|
||||
}
|
||||
|
||||
function isGenericQuery(query: string): boolean {
|
||||
return GENERIC_QUERY_RE.test(query);
|
||||
}
|
||||
|
||||
function extractTitle(content: string, fallback: string): string {
|
||||
const match = content.slice(0, 1024).match(/^#\s+(.+)$/m);
|
||||
return match?.[1]?.trim() || fallback;
|
||||
}
|
||||
|
||||
function safeResolve(kbPath: string, relPath: string): string {
|
||||
if (!relPath || path.isAbsolute(relPath)) throw new Error("path must be relative");
|
||||
const root = path.resolve(kbPath);
|
||||
const target = path.resolve(root, relPath);
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
throw new Error("path must be inside kb");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
import {
|
||||
buildToolStatusContractFixture,
|
||||
formatToolDisplay,
|
||||
OrderedSseWriter,
|
||||
PromptRunRegistry,
|
||||
ToolStatusEventAdapter,
|
||||
type ToolStatusContractEvent,
|
||||
} from "./tool-status-events.js";
|
||||
|
||||
test("tool status adapter emits v1 ordered contract fields", () => {
|
||||
const adapter = createAdapter();
|
||||
const events = [
|
||||
...adapter.adapt(textDelta("hello")),
|
||||
...adapter.adapt(startEvent("read-1", "read", { path: "/Users/example/private/a.md" })),
|
||||
...adapter.finishAssistant(),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => ({
|
||||
schemaVersion: event.schemaVersion,
|
||||
runId: event.runId,
|
||||
messageId: event.messageId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
})),
|
||||
[
|
||||
{ schemaVersion: 1, runId: "run-1", messageId: "message-1", seq: 1, type: "assistant_text_delta" },
|
||||
{ schemaVersion: 1, runId: "run-1", messageId: "message-1", seq: 2, type: "tool_status_start" },
|
||||
{ schemaVersion: 1, runId: "run-1", messageId: "message-1", seq: 3, type: "tool_status_summary" },
|
||||
{ schemaVersion: 1, runId: "run-1", messageId: "message-1", seq: 4, type: "assistant_done" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("tool status adapter covers start, update, end, failure, and missing args", () => {
|
||||
const adapter = createAdapter();
|
||||
|
||||
const start = expectEvent(adapter.adapt(startEvent("bash-1", "bash", undefined))[0], "tool_status_start");
|
||||
const update = expectEvent(adapter.adapt({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: "bash-1",
|
||||
toolName: "bash",
|
||||
args: undefined,
|
||||
partialResult: { details: { command: "npm run typecheck" } },
|
||||
})[0], "tool_status_update");
|
||||
const failed = expectEvent(adapter.adapt({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "bash-1",
|
||||
toolName: "bash",
|
||||
result: { error: "failed in /Users/example/private/project" },
|
||||
isError: true,
|
||||
})[0], "tool_status_end");
|
||||
|
||||
assert.equal(start.type, "tool_status_start");
|
||||
assert.equal(start.action, "运行命令");
|
||||
assert.equal(start.target, "未知命令");
|
||||
assert.deepEqual(start.args, {});
|
||||
assert.equal(update.type, "tool_status_update");
|
||||
assert.equal(update.target, "npm run typecheck");
|
||||
assert.equal(failed.type, "tool_status_end");
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.equal(failed.error, "failed in ~/private/project");
|
||||
assert.equal(failed.runningToolCount, 0);
|
||||
});
|
||||
|
||||
test("tool status adapter formats common tool actions and targets", () => {
|
||||
const redaction = { homeDir: "/Users/example" };
|
||||
|
||||
assert.deepEqual(formatToolDisplay("read", { path: "/Users/example/wiki/index.md" }, undefined, redaction), {
|
||||
action: "读取",
|
||||
target: "~/wiki/index.md",
|
||||
});
|
||||
assert.deepEqual(formatToolDisplay("write", { filePath: "/Users/example/wiki/new.md" }, undefined, redaction), {
|
||||
action: "写入",
|
||||
target: "~/wiki/new.md",
|
||||
});
|
||||
assert.deepEqual(formatToolDisplay("bash", { command: "npm run typecheck --workspace=@llm-wiki-agent/server" }, undefined, redaction), {
|
||||
action: "运行命令",
|
||||
target: "npm run typecheck --workspace=@llm-wiki-agent/server",
|
||||
});
|
||||
assert.deepEqual(formatToolDisplay("search", { query: "tool status", path: "/Users/example/wiki" }, undefined, redaction), {
|
||||
action: "搜索",
|
||||
target: "tool status in ~/wiki",
|
||||
});
|
||||
assert.deepEqual(formatToolDisplay("skill", { skillName: "llm-wiki" }, undefined, redaction), {
|
||||
action: "调用 Skill",
|
||||
target: "llm-wiki",
|
||||
});
|
||||
});
|
||||
|
||||
test("tool status adapter redacts home paths and long command targets", () => {
|
||||
const adapter = createAdapter();
|
||||
const longCommand = [
|
||||
"node",
|
||||
"/Users/example/private/really/long/path/that/should/not/leak/source.js",
|
||||
"--token",
|
||||
"secret",
|
||||
"--output",
|
||||
"/Users/example/private/really/long/path/that/should/not/leak/output.json",
|
||||
].join(" ");
|
||||
|
||||
const start = expectEvent(adapter.adapt(startEvent("bash-1", "bash", { command: longCommand }))[0], "tool_status_start");
|
||||
const end = expectEvent(adapter.adapt({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "bash-1",
|
||||
toolName: "bash",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `wrote /Users/example/private/really/long/path/that/should/not/leak/output.json`,
|
||||
},
|
||||
],
|
||||
},
|
||||
isError: false,
|
||||
})[0], "tool_status_end");
|
||||
|
||||
assert.equal(start.type, "tool_status_start");
|
||||
assert.equal(end.type, "tool_status_end");
|
||||
assert.equal(start.target.includes("/Users/example"), false);
|
||||
assert.equal(JSON.stringify(start).includes("/Users/example"), false);
|
||||
assert.equal(JSON.stringify(end).includes("/Users/example"), false);
|
||||
assert.ok(start.target.length <= 120);
|
||||
assert.ok(end.summary && end.summary.length <= 160);
|
||||
});
|
||||
|
||||
test("tool status adapter keeps parallel tools independent", () => {
|
||||
const adapter = createAdapter();
|
||||
|
||||
const startA = expectEvent(adapter.adapt(startEvent("read-1", "read", { path: "/Users/example/a.md" }))[0], "tool_status_start");
|
||||
const startB = expectEvent(adapter.adapt(startEvent("write-1", "write", { path: "/Users/example/b.md" }))[0], "tool_status_start");
|
||||
const endB = expectEvent(adapter.adapt({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "write-1",
|
||||
toolName: "write",
|
||||
result: { content: [{ type: "text", text: "wrote b.md" }] },
|
||||
isError: false,
|
||||
})[0], "tool_status_end");
|
||||
|
||||
assert.equal(startA.type, "tool_status_start");
|
||||
assert.equal(startA.runningToolCount, 1);
|
||||
assert.equal(startA.otherRunningCount, 0);
|
||||
assert.equal(startB.type, "tool_status_start");
|
||||
assert.equal(startB.runningToolCount, 2);
|
||||
assert.equal(startB.otherRunningCount, 1);
|
||||
assert.equal(endB.type, "tool_status_end");
|
||||
assert.equal(endB.toolCallId, "write-1");
|
||||
assert.equal(endB.runningToolCount, 1);
|
||||
assert.deepEqual(adapter.getRunningTools().map((tool) => tool.toolCallId), ["read-1"]);
|
||||
});
|
||||
|
||||
test("tool status adapter emits cancelled endings for active tools", () => {
|
||||
const adapter = createAdapter();
|
||||
adapter.adapt(startEvent("read-1", "read", { path: "/Users/example/a.md" }));
|
||||
adapter.adapt(startEvent("write-1", "write", { path: "/Users/example/b.md" }));
|
||||
|
||||
const cancelled = adapter.cancelAssistant("client disconnected from /Users/example/private");
|
||||
|
||||
assert.deepEqual(cancelled.map((event) => event.type), [
|
||||
"tool_status_end",
|
||||
"tool_status_end",
|
||||
"assistant_cancelled",
|
||||
]);
|
||||
assert.equal(JSON.stringify(cancelled).includes("/Users/example"), false);
|
||||
assert.deepEqual(adapter.getRunningTools(), []);
|
||||
});
|
||||
|
||||
test("manual tool events cover knowledge-base retrieval lifecycle", () => {
|
||||
const adapter = createAdapter();
|
||||
|
||||
const start = adapter.startTool({
|
||||
toolCallId: "knowledge-search-1",
|
||||
toolName: "knowledge_search",
|
||||
args: { query: "OpenClaw", path: "/Users/example/wiki" },
|
||||
});
|
||||
const done = adapter.endTool({
|
||||
toolCallId: "knowledge-search-1",
|
||||
toolName: "knowledge_search",
|
||||
result: { summary: "found 2 pages", paths: ["/Users/example/wiki/a.md"] },
|
||||
});
|
||||
const failed = adapter.endTool({
|
||||
toolCallId: "knowledge-search-2",
|
||||
toolName: "knowledge_search",
|
||||
result: { error: "read failed at /Users/example/wiki/private.md" },
|
||||
isError: true,
|
||||
});
|
||||
|
||||
assert.equal(start.type, "tool_status_start");
|
||||
assert.equal(start.action, "搜索");
|
||||
assert.equal(start.target, "OpenClaw in ~/wiki");
|
||||
assert.equal(done.type, "tool_status_end");
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.summary, "found 2 pages");
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.equal(failed.error, "read failed at ~/wiki/private.md");
|
||||
});
|
||||
|
||||
test("ordered SSE writer serializes mixed async writes", async () => {
|
||||
const order: string[] = [];
|
||||
const writer = new OrderedSseWriter(async (payload) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, payload.event === "slow" ? 5 : 0));
|
||||
order.push(`${payload.event}:${payload.data}`);
|
||||
});
|
||||
|
||||
const first = writer.writeNamed("slow", "1");
|
||||
const second = writer.writeNamed("fast", "2");
|
||||
await Promise.all([second, first]);
|
||||
await writer.flush();
|
||||
|
||||
assert.deepEqual(order, ["slow:1", "fast:2"]);
|
||||
});
|
||||
|
||||
test("prompt run registry rejects same-session concurrency and releases owner", () => {
|
||||
const registry = new PromptRunRegistry();
|
||||
|
||||
assert.equal(registry.begin("session-1", "run-a"), true);
|
||||
assert.equal(registry.begin("session-1", "run-b"), false);
|
||||
assert.equal(registry.get("session-1"), "run-a");
|
||||
registry.end("session-1", "run-b");
|
||||
assert.equal(registry.get("session-1"), "run-a");
|
||||
registry.end("session-1", "run-a");
|
||||
assert.equal(registry.begin("session-1", "run-b"), true);
|
||||
});
|
||||
|
||||
test("tool status contract fixture snapshots shared sample events", () => {
|
||||
const fixture = buildToolStatusContractFixture();
|
||||
const compact = fixture.map(compactEvent);
|
||||
|
||||
assert.deepEqual(compact, [
|
||||
{
|
||||
type: "assistant_text_delta",
|
||||
seq: 1,
|
||||
delta: "我来检查。",
|
||||
},
|
||||
{
|
||||
type: "tool_status_start",
|
||||
seq: 2,
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
action: "读取",
|
||||
target: "~/projects/private/source.md",
|
||||
status: "running",
|
||||
runningToolCount: 1,
|
||||
otherRunningCount: 0,
|
||||
},
|
||||
{
|
||||
type: "tool_status_update",
|
||||
seq: 3,
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
action: "读取",
|
||||
target: "~/projects/private/source.md",
|
||||
status: "running",
|
||||
runningToolCount: 1,
|
||||
otherRunningCount: 0,
|
||||
},
|
||||
{
|
||||
type: "tool_status_end",
|
||||
seq: 4,
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
action: "读取",
|
||||
target: "~/projects/private/source.md",
|
||||
status: "done",
|
||||
runningToolCount: 0,
|
||||
otherRunningCount: 0,
|
||||
},
|
||||
{
|
||||
type: "tool_status_summary",
|
||||
seq: 5,
|
||||
items: [
|
||||
{
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
action: "读取",
|
||||
target: "~/projects/private/source.md",
|
||||
status: "done",
|
||||
summary: "ok ~/projects/private/source.md",
|
||||
},
|
||||
],
|
||||
remainingRunningCount: 0,
|
||||
},
|
||||
{
|
||||
type: "assistant_done",
|
||||
seq: 6,
|
||||
},
|
||||
]);
|
||||
assert.equal(JSON.stringify(fixture).includes("/Users/example"), false);
|
||||
});
|
||||
|
||||
function createAdapter() {
|
||||
let now = 1_000;
|
||||
return new ToolStatusEventAdapter({
|
||||
runId: "run-1",
|
||||
messageId: "message-1",
|
||||
homeDir: "/Users/example",
|
||||
now: () => {
|
||||
now += 25;
|
||||
return now;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function textDelta(delta: string): AgentEvent {
|
||||
return {
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta,
|
||||
partial: { role: "assistant", content: [{ type: "text", text: delta }] },
|
||||
},
|
||||
} as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
function startEvent(toolCallId: string, toolName: string, args: unknown): AgentEvent {
|
||||
return {
|
||||
type: "tool_execution_start",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
function compactEvent(event: ToolStatusContractEvent): Record<string, unknown> {
|
||||
const base = { type: event.type, seq: event.seq };
|
||||
if (event.type === "assistant_text_delta") return { ...base, delta: event.delta };
|
||||
if (
|
||||
event.type === "assistant_done" ||
|
||||
event.type === "assistant_error" ||
|
||||
event.type === "assistant_cancelled"
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
if (event.type === "tool_status_summary") {
|
||||
return { ...base, items: event.items, remainingRunningCount: event.remainingRunningCount };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
action: event.action,
|
||||
target: event.target,
|
||||
status: event.status,
|
||||
runningToolCount: event.runningToolCount,
|
||||
otherRunningCount: event.otherRunningCount,
|
||||
};
|
||||
}
|
||||
|
||||
function expectEvent<T extends ToolStatusContractEvent["type"]>(
|
||||
event: ToolStatusContractEvent | undefined,
|
||||
type: T,
|
||||
): Extract<ToolStatusContractEvent, { type: T }> {
|
||||
assert.ok(event);
|
||||
assert.equal(event.type, type);
|
||||
return event as Extract<ToolStatusContractEvent, { type: T }>;
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export const TOOL_STATUS_SCHEMA_VERSION = 1;
|
||||
|
||||
export type ToolStatusKind =
|
||||
| "assistant_text_delta"
|
||||
| "tool_status_start"
|
||||
| "tool_status_update"
|
||||
| "tool_status_end"
|
||||
| "tool_status_summary"
|
||||
| "assistant_done"
|
||||
| "assistant_cancelled"
|
||||
| "assistant_error";
|
||||
|
||||
export type ToolRunStatus = "running" | "done" | "failed" | "cancelled";
|
||||
|
||||
export interface ToolStatusBaseEvent {
|
||||
schemaVersion: 1;
|
||||
type: ToolStatusKind;
|
||||
runId: string;
|
||||
messageId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface ToolDisplay {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
action: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ToolStatusStartEvent extends ToolStatusBaseEvent, ToolDisplay {
|
||||
type: "tool_status_start";
|
||||
status: "running";
|
||||
args: Record<string, unknown>;
|
||||
runningToolCount: number;
|
||||
otherRunningCount: number;
|
||||
}
|
||||
|
||||
export interface ToolStatusUpdateEvent extends ToolStatusBaseEvent, ToolDisplay {
|
||||
type: "tool_status_update";
|
||||
status: "running";
|
||||
args: Record<string, unknown>;
|
||||
detail: unknown;
|
||||
runningToolCount: number;
|
||||
otherRunningCount: number;
|
||||
}
|
||||
|
||||
export interface ToolStatusEndEvent extends ToolStatusBaseEvent, ToolDisplay {
|
||||
type: "tool_status_end";
|
||||
status: Exclude<ToolRunStatus, "running">;
|
||||
result: unknown;
|
||||
summary: string | null;
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
runningToolCount: number;
|
||||
otherRunningCount: number;
|
||||
}
|
||||
|
||||
export interface ToolStatusSummaryEvent extends ToolStatusBaseEvent {
|
||||
type: "tool_status_summary";
|
||||
items: Array<ToolDisplay & { status: Exclude<ToolRunStatus, "running">; summary: string | null }>;
|
||||
remainingRunningCount: number;
|
||||
}
|
||||
|
||||
export interface AssistantTextDeltaEvent extends ToolStatusBaseEvent {
|
||||
type: "assistant_text_delta";
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface AssistantDoneEvent extends ToolStatusBaseEvent {
|
||||
type: "assistant_done";
|
||||
}
|
||||
|
||||
export interface AssistantCancelledEvent extends ToolStatusBaseEvent {
|
||||
type: "assistant_cancelled";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AssistantErrorEvent extends ToolStatusBaseEvent {
|
||||
type: "assistant_error";
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ToolStatusContractEvent =
|
||||
| AssistantTextDeltaEvent
|
||||
| ToolStatusStartEvent
|
||||
| ToolStatusUpdateEvent
|
||||
| ToolStatusEndEvent
|
||||
| ToolStatusSummaryEvent
|
||||
| AssistantDoneEvent
|
||||
| AssistantCancelledEvent
|
||||
| AssistantErrorEvent;
|
||||
|
||||
type ToolStatusEventDraft = ToolStatusContractEvent extends infer Event
|
||||
? Event extends ToolStatusContractEvent
|
||||
? Omit<Event, "schemaVersion" | "runId" | "messageId" | "seq">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface ToolStatusAdapterOptions {
|
||||
runId: string;
|
||||
messageId: string;
|
||||
startSeq?: number;
|
||||
now?: () => number;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
type RedactionOptions = Required<Pick<ToolStatusAdapterOptions, "homeDir">>;
|
||||
|
||||
interface RunningToolState extends ToolDisplay {
|
||||
args: Record<string, unknown>;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface CompletedToolState extends ToolDisplay {
|
||||
status: Exclude<ToolRunStatus, "running">;
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
type ToolExecutionEvent = Extract<
|
||||
AgentEvent,
|
||||
{ type: "tool_execution_start" | "tool_execution_update" | "tool_execution_end" }
|
||||
>;
|
||||
|
||||
export interface ToolStatusStartInput {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolStatusUpdateInput extends ToolStatusStartInput {
|
||||
partialResult?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolStatusEndInput {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface SsePayload {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type SsePayloadWriter = (payload: SsePayload) => Promise<void>;
|
||||
|
||||
export class OrderedSseWriter {
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly writePayload: SsePayloadWriter) {}
|
||||
|
||||
writeContract(event: ToolStatusContractEvent): Promise<void> {
|
||||
return this.write({ event: event.type, data: JSON.stringify(event) });
|
||||
}
|
||||
|
||||
writeNamed(event: string, data: unknown): Promise<void> {
|
||||
return this.write({ event, data: typeof data === "string" ? data : JSON.stringify(data) });
|
||||
}
|
||||
|
||||
write(payload: SsePayload): Promise<void> {
|
||||
if (this.closed) return this.queue;
|
||||
const next = this.queue.then(async () => {
|
||||
if (!this.closed) await this.writePayload(payload);
|
||||
});
|
||||
this.queue = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export class PromptRunRegistry {
|
||||
private readonly activeRunIds = new Map<string, string>();
|
||||
|
||||
begin(sessionId: string, runId: string): boolean {
|
||||
if (this.activeRunIds.has(sessionId)) return false;
|
||||
this.activeRunIds.set(sessionId, runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
end(sessionId: string, runId: string): void {
|
||||
if (this.activeRunIds.get(sessionId) === runId) this.activeRunIds.delete(sessionId);
|
||||
}
|
||||
|
||||
get(sessionId: string): string | null {
|
||||
return this.activeRunIds.get(sessionId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolStatusEventAdapter {
|
||||
private seq: number;
|
||||
private readonly now: () => number;
|
||||
private readonly redaction: RedactionOptions;
|
||||
private readonly runningTools = new Map<string, RunningToolState>();
|
||||
private readonly completedTools: CompletedToolState[] = [];
|
||||
|
||||
constructor(private readonly options: ToolStatusAdapterOptions) {
|
||||
this.seq = options.startSeq ?? 0;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.redaction = { homeDir: options.homeDir ?? homedir() };
|
||||
}
|
||||
|
||||
adapt(event: unknown): ToolStatusContractEvent[] {
|
||||
if (isAgentEventType(event, "message_update")) {
|
||||
const inner = event.assistantMessageEvent;
|
||||
if (inner.type === "text_delta") {
|
||||
return [this.makeEvent({ type: "assistant_text_delta", delta: inner.delta })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (isAgentEventType(event, "tool_execution_start")) return [this.toolStart(event)];
|
||||
if (isAgentEventType(event, "tool_execution_update")) return [this.toolUpdate(event)];
|
||||
if (isAgentEventType(event, "tool_execution_end")) return [this.toolEnd(event)];
|
||||
return [];
|
||||
}
|
||||
|
||||
startTool(input: ToolStatusStartInput): ToolStatusStartEvent {
|
||||
return this.toolStart({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
args: input.args,
|
||||
});
|
||||
}
|
||||
|
||||
updateTool(input: ToolStatusUpdateInput): ToolStatusUpdateEvent {
|
||||
return this.toolUpdate({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
args: input.args,
|
||||
partialResult: input.partialResult,
|
||||
});
|
||||
}
|
||||
|
||||
endTool(input: ToolStatusEndInput): ToolStatusEndEvent {
|
||||
return this.toolEnd({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
result: input.result,
|
||||
isError: input.isError ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
finishAssistant(): ToolStatusContractEvent[] {
|
||||
const events: ToolStatusContractEvent[] = [];
|
||||
if (this.completedTools.length > 0 || this.runningTools.size > 0) {
|
||||
events.push(
|
||||
this.makeEvent({
|
||||
type: "tool_status_summary",
|
||||
items: this.completedTools.map((item) => ({ ...item })),
|
||||
remainingRunningCount: this.runningTools.size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
events.push(this.makeEvent({ type: "assistant_done" }));
|
||||
return events;
|
||||
}
|
||||
|
||||
failAssistant(error: unknown): ToolStatusContractEvent[] {
|
||||
return [this.makeEvent({ type: "assistant_error", error: normalizeError(error, this.redaction) })];
|
||||
}
|
||||
|
||||
cancelAssistant(reason = "cancelled"): ToolStatusContractEvent[] {
|
||||
return [
|
||||
...this.cancelActiveTools(reason),
|
||||
this.makeEvent({ type: "assistant_cancelled", reason: redactText(reason, this.redaction, 160) }),
|
||||
];
|
||||
}
|
||||
|
||||
cancelActiveTools(reason = "cancelled"): ToolStatusEndEvent[] {
|
||||
const events: ToolStatusEndEvent[] = [];
|
||||
for (const tool of [...this.runningTools.values()]) {
|
||||
this.runningTools.delete(tool.toolCallId);
|
||||
const summary = redactText(reason, this.redaction, 160);
|
||||
this.completedTools.push({ ...tool, status: "cancelled", summary });
|
||||
events.push(
|
||||
this.makeEvent({
|
||||
type: "tool_status_end",
|
||||
toolCallId: tool.toolCallId,
|
||||
toolName: tool.toolName,
|
||||
action: tool.action,
|
||||
target: tool.target,
|
||||
status: "cancelled",
|
||||
result: null,
|
||||
summary,
|
||||
error: null,
|
||||
durationMs: Math.max(0, this.now() - tool.startedAt),
|
||||
runningToolCount: this.runningTools.size,
|
||||
otherRunningCount: this.runningTools.size,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
getRunningTools(): ToolDisplay[] {
|
||||
return [...this.runningTools.values()].map(({ toolCallId, toolName, action, target }) => ({
|
||||
toolCallId,
|
||||
toolName,
|
||||
action,
|
||||
target,
|
||||
}));
|
||||
}
|
||||
|
||||
private toolStart(event: Extract<ToolExecutionEvent, { type: "tool_execution_start" }>): ToolStatusStartEvent {
|
||||
const args = sanitizeRecord(event.args, this.redaction);
|
||||
const display = formatToolDisplay(event.toolName, args, undefined, this.redaction);
|
||||
this.runningTools.set(event.toolCallId, {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
...display,
|
||||
args,
|
||||
startedAt: this.now(),
|
||||
});
|
||||
return this.makeEvent({
|
||||
type: "tool_status_start",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
...display,
|
||||
status: "running",
|
||||
args,
|
||||
runningToolCount: this.runningTools.size,
|
||||
otherRunningCount: Math.max(0, this.runningTools.size - 1),
|
||||
});
|
||||
}
|
||||
|
||||
private toolUpdate(event: Extract<ToolExecutionEvent, { type: "tool_execution_update" }>): ToolStatusUpdateEvent {
|
||||
const args = sanitizeRecord(event.args, this.redaction);
|
||||
const detail = sanitizeValue(event.partialResult, this.redaction);
|
||||
const display = formatToolDisplay(event.toolName, args, detail, this.redaction);
|
||||
const existing = this.runningTools.get(event.toolCallId);
|
||||
this.runningTools.set(event.toolCallId, {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
...display,
|
||||
args,
|
||||
startedAt: existing?.startedAt ?? this.now(),
|
||||
});
|
||||
return this.makeEvent({
|
||||
type: "tool_status_update",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
...display,
|
||||
status: "running",
|
||||
args,
|
||||
detail,
|
||||
runningToolCount: this.runningTools.size,
|
||||
otherRunningCount: Math.max(0, this.runningTools.size - 1),
|
||||
});
|
||||
}
|
||||
|
||||
private toolEnd(event: Extract<ToolExecutionEvent, { type: "tool_execution_end" }>): ToolStatusEndEvent {
|
||||
const existing = this.runningTools.get(event.toolCallId);
|
||||
const result = sanitizeValue(event.result, this.redaction);
|
||||
const display =
|
||||
existing ?? {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
...formatToolDisplay(event.toolName, {}, result, this.redaction),
|
||||
args: {},
|
||||
startedAt: this.now(),
|
||||
};
|
||||
this.runningTools.delete(event.toolCallId);
|
||||
const status = event.isError ? "failed" : "done";
|
||||
const summary = summarizeResult(result, this.redaction);
|
||||
const error = status === "failed" ? normalizeError(result, this.redaction) : null;
|
||||
this.completedTools.push({
|
||||
toolCallId: display.toolCallId,
|
||||
toolName: display.toolName,
|
||||
action: display.action,
|
||||
target: display.target,
|
||||
status,
|
||||
summary,
|
||||
});
|
||||
return this.makeEvent({
|
||||
type: "tool_status_end",
|
||||
toolCallId: display.toolCallId,
|
||||
toolName: display.toolName,
|
||||
action: display.action,
|
||||
target: display.target,
|
||||
status,
|
||||
result,
|
||||
summary,
|
||||
error,
|
||||
durationMs: Math.max(0, this.now() - display.startedAt),
|
||||
runningToolCount: this.runningTools.size,
|
||||
otherRunningCount: this.runningTools.size,
|
||||
});
|
||||
}
|
||||
|
||||
private makeEvent<T extends ToolStatusEventDraft>(
|
||||
event: T,
|
||||
): Extract<ToolStatusContractEvent, { type: T["type"] }> {
|
||||
this.seq += 1;
|
||||
const fullEvent = {
|
||||
schemaVersion: TOOL_STATUS_SCHEMA_VERSION,
|
||||
runId: this.options.runId,
|
||||
messageId: this.options.messageId,
|
||||
seq: this.seq,
|
||||
...event,
|
||||
};
|
||||
return fullEvent as unknown as Extract<ToolStatusContractEvent, { type: T["type"] }>;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatToolDisplay(
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
detail?: unknown,
|
||||
redaction: RedactionOptions = { homeDir: homedir() },
|
||||
): Pick<ToolDisplay, "action" | "target"> {
|
||||
const lower = toolName.toLowerCase();
|
||||
const argRecord = toRecord(args);
|
||||
const detailRecord = toRecord(detail);
|
||||
const details = toRecord(detailRecord.details);
|
||||
const merged = { ...details, ...detailRecord, ...argRecord };
|
||||
const pathTarget = firstString(merged, [
|
||||
"path",
|
||||
"filePath",
|
||||
"filepath",
|
||||
"targetPath",
|
||||
"target",
|
||||
"absolutePath",
|
||||
"relativePath",
|
||||
]);
|
||||
const queryTarget = firstString(merged, ["query", "pattern", "search", "term", "needle"]);
|
||||
const commandTarget = firstString(merged, ["command", "cmd", "script"]);
|
||||
const skillTarget = firstString(merged, ["skill", "skillName", "name", "slug", "command"]);
|
||||
|
||||
if (lower.includes("read")) {
|
||||
return { action: "读取", target: redactPathTarget(pathTarget, redaction) };
|
||||
}
|
||||
if (lower.includes("write") || lower.includes("edit")) {
|
||||
return { action: lower.includes("edit") ? "编辑" : "写入", target: redactPathTarget(pathTarget, redaction) };
|
||||
}
|
||||
if (lower.includes("bash") || lower.includes("shell") || lower.includes("command")) {
|
||||
return { action: "运行命令", target: redactCommandTarget(commandTarget ?? pathTarget, redaction) };
|
||||
}
|
||||
if (lower.includes("search") || lower.includes("grep") || lower.includes("find")) {
|
||||
const target = queryTarget && pathTarget ? `${queryTarget} in ${pathTarget}` : queryTarget ?? pathTarget;
|
||||
return { action: "搜索", target: redactText(target ?? "未知目标", redaction, 96) };
|
||||
}
|
||||
if (lower.includes("skill")) {
|
||||
return { action: "调用 Skill", target: redactText(skillTarget ?? "未知 Skill", redaction, 96) };
|
||||
}
|
||||
return {
|
||||
action: `运行 ${toolName}`,
|
||||
target: redactText(pathTarget ?? queryTarget ?? commandTarget ?? skillTarget ?? "未知目标", redaction, 96),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolStatusContractFixture(): ToolStatusContractEvent[] {
|
||||
let tick = 1_000;
|
||||
const adapter = new ToolStatusEventAdapter({
|
||||
runId: "fixture-run",
|
||||
messageId: "fixture-message",
|
||||
homeDir: "/Users/example",
|
||||
now: () => {
|
||||
tick += 10;
|
||||
return tick;
|
||||
},
|
||||
});
|
||||
return [
|
||||
...adapter.adapt({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "我来检查。",
|
||||
partial: { role: "assistant", content: [{ type: "text", text: "我来检查。" }] },
|
||||
},
|
||||
} as unknown as AgentEvent),
|
||||
...adapter.adapt({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
args: { path: "/Users/example/projects/private/source.md" },
|
||||
}),
|
||||
...adapter.adapt({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
args: { path: "/Users/example/projects/private/source.md" },
|
||||
partialResult: { details: { bytes: 128, path: "/Users/example/projects/private/source.md" } },
|
||||
}),
|
||||
...adapter.adapt({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
result: { content: [{ type: "text", text: "ok /Users/example/projects/private/source.md" }] },
|
||||
isError: false,
|
||||
}),
|
||||
...adapter.finishAssistant(),
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeRecord(value: unknown, redaction: RedactionOptions): Record<string, unknown> {
|
||||
const sanitized = sanitizeValue(value, redaction);
|
||||
return toRecord(sanitized);
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, redaction: RedactionOptions): unknown {
|
||||
if (typeof value === "string") return redactText(value, redaction, 300);
|
||||
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, redaction));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
const max = /command|cmd|script/i.test(key) ? 140 : 300;
|
||||
result[key] = typeof raw === "string" ? redactText(raw, redaction, max) : sanitizeValue(raw, redaction);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function redactPathTarget(value: string | undefined, redaction: RedactionOptions): string {
|
||||
if (!value?.trim()) return "未知路径";
|
||||
return compactPath(redactHome(value, redaction), 96);
|
||||
}
|
||||
|
||||
function redactCommandTarget(value: string | undefined, redaction: RedactionOptions): string {
|
||||
if (!value?.trim()) return "未知命令";
|
||||
return redactText(value, redaction, 120);
|
||||
}
|
||||
|
||||
function redactText(value: string, redaction: RedactionOptions, maxLength: number): string {
|
||||
const redacted = redactHome(value, redaction).replace(/\/Users\/[^/\s]+/g, "/Users/<user>");
|
||||
if (redacted.length <= maxLength) return redacted;
|
||||
const keep = Math.max(20, maxLength - 3);
|
||||
return `${redacted.slice(0, Math.ceil(keep * 0.6))}...${redacted.slice(-Math.floor(keep * 0.4))}`;
|
||||
}
|
||||
|
||||
function redactHome(value: string, redaction: RedactionOptions): string {
|
||||
const home = redaction.homeDir.replace(/\/+$/, "");
|
||||
if (!home) return value;
|
||||
return value.split(home).join("~");
|
||||
}
|
||||
|
||||
function compactPath(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
const parts = value.split("/").filter(Boolean);
|
||||
if (parts.length <= 3) return redactText(value, { homeDir: "" }, maxLength);
|
||||
const prefix = value.startsWith("~/") ? "~/" : value.startsWith("/") ? "/" : "";
|
||||
const tail = parts.slice(-3).join("/");
|
||||
return `${prefix}.../${tail}`;
|
||||
}
|
||||
|
||||
function summarizeResult(result: unknown, redaction: RedactionOptions): string | null {
|
||||
if (!result) return null;
|
||||
if (typeof result === "string") return redactText(result, redaction, 160);
|
||||
const record = toRecord(result);
|
||||
const content = record.content;
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map((part) => {
|
||||
const p = toRecord(part);
|
||||
return typeof p.text === "string" ? p.text : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (text) return redactText(text, redaction, 160);
|
||||
}
|
||||
for (const key of ["summary", "message", "error", "stderr", "stdout"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return redactText(value, redaction, 160);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown, redaction: RedactionOptions): string {
|
||||
if (error instanceof Error) return redactText(error.message, redaction, 200);
|
||||
if (typeof error === "string") return redactText(error, redaction, 200);
|
||||
const record = toRecord(error);
|
||||
for (const key of ["error", "message", "stderr"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return redactText(value, redaction, 200);
|
||||
}
|
||||
const summary = summarizeResult(error, redaction);
|
||||
return summary ?? "工具执行失败";
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function firstString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isAgentEventType<T extends AgentEvent["type"]>(
|
||||
event: unknown,
|
||||
type: T,
|
||||
): event is Extract<AgentEvent, { type: T }> {
|
||||
return isRecord(event) && event.type === type;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { initScriptCandidates } from "./wiki-init.js";
|
||||
|
||||
test("initScriptCandidates supports Codex and Claude skill install locations", () => {
|
||||
const home = path.join("/", "Users", "example");
|
||||
assert.deepEqual(initScriptCandidates(home), [
|
||||
path.join(home, ".codex", "skills", "llm-wiki", "init-wiki.sh"),
|
||||
path.join(home, ".codex", "skills", "llm-wiki", "scripts", "init-wiki.sh"),
|
||||
path.join(home, ".codex", "skills", "llm-wiki-skill", "init-wiki.sh"),
|
||||
path.join(home, ".codex", "skills", "llm-wiki-skill", "scripts", "init-wiki.sh"),
|
||||
path.join(home, ".claude", "skills", "llm-wiki-skill", "init-wiki.sh"),
|
||||
path.join(home, ".claude", "skills", "llm-wiki-skill", "scripts", "init-wiki.sh"),
|
||||
path.join(home, ".claude", "skills", "llm-wiki", "init-wiki.sh"),
|
||||
path.join(home, ".claude", "skills", "llm-wiki", "scripts", "init-wiki.sh"),
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { DEFAULT_KNOWLEDGE_BASE_ROOT } from "./config.js";
|
||||
import { expandUserPath, registerExternalKnowledgeBase } from "./knowledge-bases.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface CreateWikiResult {
|
||||
name: string;
|
||||
path: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface InitExistingWikiResult {
|
||||
path: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
backedUpFiles: string[];
|
||||
}
|
||||
|
||||
export class InitConflictError extends Error {
|
||||
statusCode = 409;
|
||||
constructor(public conflicts: string[]) {
|
||||
super(`目标目录已有将被初始化覆盖的文件:${conflicts.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const INIT_WRITTEN_FILES = [
|
||||
".gitignore",
|
||||
".wiki-schema.md",
|
||||
"index.md",
|
||||
"log.md",
|
||||
path.join("wiki", "overview.md"),
|
||||
"purpose.md",
|
||||
".wiki-cache.json",
|
||||
];
|
||||
|
||||
export function truncateOutput(text: string): string {
|
||||
return text.length > 4096 ? text.slice(0, 4096) + "\n...[truncated]" : text;
|
||||
}
|
||||
|
||||
export function validateWikiName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("知识库名不能为空");
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
throw new Error("知识库名不能包含路径分隔符");
|
||||
}
|
||||
if (trimmed === "." || trimmed === "..") {
|
||||
throw new Error("知识库名不能是 . 或 ..");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
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("目标路径不在默认知识库根目录内");
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
return Boolean(info?.isFile());
|
||||
}
|
||||
|
||||
export function initScriptCandidates(homeDir = homedir()): string[] {
|
||||
const skillDirs = [
|
||||
path.join(homeDir, ".codex", "skills", "llm-wiki"),
|
||||
path.join(homeDir, ".codex", "skills", "llm-wiki-skill"),
|
||||
path.join(homeDir, ".claude", "skills", "llm-wiki-skill"),
|
||||
path.join(homeDir, ".claude", "skills", "llm-wiki"),
|
||||
];
|
||||
return skillDirs.flatMap((skillDir) => [
|
||||
path.join(skillDir, "init-wiki.sh"),
|
||||
path.join(skillDir, "scripts", "init-wiki.sh"),
|
||||
]);
|
||||
}
|
||||
|
||||
async function findInitScript(): Promise<string | null> {
|
||||
const candidates = initScriptCandidates();
|
||||
for (const candidate of candidates) {
|
||||
if (await fileExists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWiki(nameInput: string, purposeInput: string): Promise<CreateWikiResult> {
|
||||
const name = validateWikiName(nameInput);
|
||||
const scriptPath = await findInitScript();
|
||||
if (!scriptPath) {
|
||||
throw new Error("llm-wiki 未安装。请先安装到 ~/.codex/skills/llm-wiki/ 或 ~/.claude/skills/llm-wiki-skill/。");
|
||||
}
|
||||
|
||||
const targetPath = path.join(DEFAULT_KNOWLEDGE_BASE_ROOT, name);
|
||||
assertInside(DEFAULT_KNOWLEDGE_BASE_ROOT, targetPath);
|
||||
await mkdir(DEFAULT_KNOWLEDGE_BASE_ROOT, { recursive: true });
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
scriptPath,
|
||||
[targetPath, purposeInput.trim() || name, "中文"],
|
||||
{
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
HOME: homedir(),
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
path: targetPath,
|
||||
stdout: truncateOutput(stdout),
|
||||
stderr: truncateOutput(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
export async function initExistingWiki(
|
||||
rawPath: string,
|
||||
purposeInput: string,
|
||||
overwrite = false,
|
||||
): Promise<InitExistingWikiResult> {
|
||||
const absolutePath = path.resolve(expandUserPath(rawPath));
|
||||
const info = await stat(absolutePath).catch(() => null);
|
||||
if (!info?.isDirectory()) throw new Error(`目标路径不是目录:${absolutePath}`);
|
||||
|
||||
const purpose = purposeInput.trim();
|
||||
if (!purpose) throw new Error("研究方向不能为空");
|
||||
|
||||
const conflicts = await findInitConflicts(absolutePath);
|
||||
if (conflicts.length > 0 && !overwrite) {
|
||||
throw new InitConflictError(conflicts);
|
||||
}
|
||||
|
||||
const backedUpFiles =
|
||||
conflicts.length > 0 ? await backupConflicts(absolutePath, conflicts) : [];
|
||||
const scriptPath = await findInitScript();
|
||||
if (!scriptPath) {
|
||||
throw new Error("llm-wiki 未安装。请先安装到 ~/.codex/skills/llm-wiki/ 或 ~/.claude/skills/llm-wiki-skill/。");
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(scriptPath, [absolutePath, purpose, "中文"], {
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
HOME: homedir(),
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
await registerExternalKnowledgeBase(absolutePath);
|
||||
return {
|
||||
path: absolutePath,
|
||||
stdout: truncateOutput(stdout),
|
||||
stderr: truncateOutput(stderr),
|
||||
backedUpFiles,
|
||||
};
|
||||
}
|
||||
|
||||
async function findInitConflicts(targetPath: string): Promise<string[]> {
|
||||
const conflicts: string[] = [];
|
||||
for (const relPath of INIT_WRITTEN_FILES) {
|
||||
const info = await stat(path.join(targetPath, relPath)).catch(() => null);
|
||||
if (info?.isFile()) conflicts.push(relPath);
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
async function backupConflicts(targetPath: string, conflicts: string[]): Promise<string[]> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupRoot = path.join(targetPath, ".llm-wiki-agent-backup", timestamp);
|
||||
const backedUp: string[] = [];
|
||||
for (const relPath of conflicts) {
|
||||
const src = path.join(targetPath, relPath);
|
||||
const dest = path.join(backupRoot, relPath);
|
||||
await mkdir(path.dirname(dest), { recursive: true });
|
||||
await copyFile(src, dest);
|
||||
backedUp.push(dest);
|
||||
}
|
||||
return backedUp;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user