feat: release v0.3.4 — offload local LLM, CLI restore, bugfix scripts

This commit is contained in:
chrishuan
2026-05-13 14:56:56 +08:00
parent 28be408fb8
commit d377b09fbc
63 changed files with 2191 additions and 14410 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* LocalLlmClient — local-mode offload LLM client.
*
* Implements the same interface as BackendClient (l1Summarize, l15Judge, l2Generate)
* but calls the LLM directly via AI SDK instead of routing through a remote backend.
*
* Used when `offload.model` is configured and `offload.backendUrl` is not set.
*/
import { callLlm, type LlmCallerConfig } from "./llm-caller.js";
import { L1_SYSTEM_PROMPT, buildL1UserPrompt, type L1ToolPair } from "./prompts/l1-prompt.js";
import { L15_SYSTEM_PROMPT, buildL15UserPrompt, type L15CurrentMmd, type L15MmdMeta } from "./prompts/l15-prompt.js";
import { L2_SYSTEM_PROMPT, buildL2UserPrompt, type L2NewEntry } from "./prompts/l2-prompt.js";
import { parseL1Response } from "./parsers/l1-parser.js";
import { parseL15Response } from "./parsers/l15-parser.js";
import { parseL2Response, type L2ParsedResponse } from "./parsers/l2-parser.js";
import type { OffloadEntry, TaskJudgment, PluginLogger } from "../types.js";
import type { L1Request, L1Response, L15Request, L15Response, L2Request, L2Response } from "../backend-client.js";
const TAG = "[context-offload] [local-llm]";
export interface LocalLlmClientConfig {
baseUrl: string;
apiKey: string;
model: string;
temperature?: number;
timeoutMs?: number;
}
export class LocalLlmClient {
private config: LlmCallerConfig;
private logger?: PluginLogger;
constructor(cfg: LocalLlmClientConfig, logger?: PluginLogger) {
this.config = {
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.model,
temperature: cfg.temperature ?? 0.2,
timeoutMs: cfg.timeoutMs ?? 120_000,
};
this.logger = logger;
logger?.info?.(`${TAG} Initialized: model=${cfg.model}, baseUrl=${cfg.baseUrl}`);
}
// ─── L1 Summarize ──────────────────────────────────────────────────────────
async l1Summarize(req: L1Request): Promise<L1Response> {
const pairs: L1ToolPair[] = req.toolPairs.map((p) => ({
toolName: p.toolName,
toolCallId: p.toolCallId,
params: p.params,
result: p.result,
timestamp: p.timestamp,
}));
const userPrompt = buildL1UserPrompt(req.recentMessages, pairs);
const raw = await callLlm(this.config, {
systemPrompt: L1_SYSTEM_PROMPT,
userPrompt,
label: "L1",
}, this.logger);
const entries = parseL1Response(raw);
if (entries.length === 0) {
this.logger?.warn?.(`${TAG} L1: parsed 0 entries from LLM response (${raw.length} chars)`);
}
return { entries };
}
// ─── L1.5 Judge ────────────────────────────────────────────────────────────
async l15Judge(req: L15Request): Promise<L15Response> {
const currentMmd: L15CurrentMmd | null = req.currentMmd
? { filename: req.currentMmd.filename, content: req.currentMmd.content, path: req.currentMmd.path }
: null;
const metas: L15MmdMeta[] = req.availableMmdMetas.map((m) => ({
filename: m.filename,
path: m.path,
taskGoal: m.taskGoal,
doneCount: m.doneCount,
doingCount: m.doingCount,
todoCount: m.todoCount,
updatedTime: m.updatedTime,
nodeSummaries: m.nodeSummaries?.map((n) => ({
nodeId: n.nodeId,
status: n.status,
summary: n.summary,
})),
}));
const userPrompt = buildL15UserPrompt(req.recentMessages, currentMmd, metas);
const raw = await callLlm(this.config, {
systemPrompt: L15_SYSTEM_PROMPT,
userPrompt,
label: "L1.5",
}, this.logger);
const result = parseL15Response(raw);
if (!result) {
this.logger?.warn?.(`${TAG} L1.5: failed to parse judgment from LLM response (${raw.length} chars)`);
// Return all-null to trigger normalizeJudgment's "LLM unavailable" path
return {
taskCompleted: false,
isContinuation: false,
isLongTask: false,
} as L15Response;
}
return result as L15Response;
}
// ─── L2 Generate ───────────────────────────────────────────────────────────
async l2Generate(req: L2Request): Promise<L2Response> {
const entries: L2NewEntry[] = req.newEntries.map((e) => ({
toolCallId: e.tool_call_id,
toolCall: e.tool_call,
summary: e.summary,
timestamp: e.timestamp,
}));
const userPrompt = buildL2UserPrompt({
existingMmd: req.existingMmd,
entries,
recentHistory: req.recentHistory,
currentTurn: req.currentTurn,
taskLabel: req.taskLabel,
mmdPrefix: req.mmdPrefix,
charCount: req.mmdCharCount,
});
const raw = await callLlm(this.config, {
systemPrompt: L2_SYSTEM_PROMPT,
userPrompt,
label: "L2",
timeoutMs: 120_000, // L2 may take longer due to complex prompts
}, this.logger);
const result = parseL2Response(raw);
if (!result) {
this.logger?.error?.(`${TAG} L2: failed to parse response (${raw.length} chars)`);
throw new Error("L2 response parsing failed");
}
return {
fileAction: result.fileAction,
mmdContent: result.mmdContent,
replaceBlocks: result.replaceBlocks?.map((b) => ({
startLine: b.startLine,
endLine: b.endLine,
content: b.content,
})),
nodeMapping: result.nodeMapping,
};
}
// ─── Stubs (not applicable in local mode) ──────────────────────────────────
/** No-op in local mode — state reporting requires a remote backend. */
async storeState(_payload: unknown): Promise<void> {}
/** L4 Skill generation is not supported in local mode. */
async l4Generate(_req: unknown): Promise<unknown> {
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Unified LLM caller for offload local mode.
*
* Uses Vercel AI SDK (`ai` + `@ai-sdk/openai`) with "compatible" mode
* to support any OpenAI-compatible backend.
*/
import { generateText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import type { PluginLogger } from "../types.js";
const TAG = "[context-offload] [local-llm]";
export interface LlmCallerConfig {
baseUrl: string;
apiKey: string;
model: string;
temperature: number;
timeoutMs: number;
}
export interface CallLlmOpts {
systemPrompt: string;
userPrompt: string;
/** Override temperature for this call */
temperature?: number;
/** Override timeout for this call */
timeoutMs?: number;
/** Label for logging (e.g. "L1", "L1.5", "L2") */
label?: string;
}
/**
* Call LLM with the given prompts and return the text response.
* Throws on timeout or API errors.
*/
export async function callLlm(
config: LlmCallerConfig,
opts: CallLlmOpts,
logger?: PluginLogger,
): Promise<string> {
const startMs = Date.now();
const label = opts.label ?? "call";
const temperature = opts.temperature ?? config.temperature;
const timeoutMs = opts.timeoutMs ?? config.timeoutMs;
logger?.info?.(
`${TAG} ${label} >>> model=${config.model}, temp=${temperature}, timeout=${timeoutMs}ms, ` +
`systemLen=${opts.systemPrompt.length}, userLen=${opts.userPrompt.length}`,
);
const provider = createOpenAI({
baseURL: config.baseUrl,
apiKey: config.apiKey,
compatibility: "compatible",
});
try {
const result = await generateText({
model: provider.chat(config.model),
system: opts.systemPrompt,
prompt: opts.userPrompt,
temperature,
abortSignal: AbortSignal.timeout(timeoutMs),
});
const text = result.text.trim();
const elapsedMs = Date.now() - startMs;
logger?.info?.(
`${TAG} ${label} <<< ${elapsedMs}ms, output=${text.length} chars`,
);
return text;
} catch (err) {
const elapsedMs = Date.now() - startMs;
const errMsg = err instanceof Error ? err.message : String(err);
logger?.error?.(`${TAG} ${label} FAILED (${elapsedMs}ms): ${errMsg}`);
throw err;
}
}
@@ -0,0 +1,85 @@
/**
* Tolerant JSON parsing utilities for LLM responses.
*
* LLMs often wrap JSON in markdown code fences, include trailing commas,
* or prepend explanatory text. These utilities handle common deviations.
*/
/**
* Extract JSON from LLM output — handles code fences, prefix text, etc.
* Returns the parsed object/array, or null if parsing fails.
*/
export function extractJson<T = unknown>(raw: string): T | null {
if (!raw || typeof raw !== "string") return null;
const trimmed = raw.trim();
// Strategy 1: Direct parse (ideal case)
const direct = tryParse<T>(trimmed);
if (direct !== null) return direct;
// Strategy 2: Extract from markdown code fence (```json ... ``` or ``` ... ```)
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
if (fenceMatch) {
const inner = fenceMatch[1].trim();
const parsed = tryParse<T>(inner);
if (parsed !== null) return parsed;
}
// Strategy 3: Find first { to last } (or first [ to last ])
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
const candidate = trimmed.slice(firstBrace, lastBrace + 1);
const parsed = tryParse<T>(candidate);
if (parsed !== null) return parsed;
// Try with trailing comma fix
const fixed = fixTrailingCommas(candidate);
const parsedFixed = tryParse<T>(fixed);
if (parsedFixed !== null) return parsedFixed;
}
const firstBracket = trimmed.indexOf("[");
const lastBracket = trimmed.lastIndexOf("]");
if (firstBracket >= 0 && lastBracket > firstBracket) {
const candidate = trimmed.slice(firstBracket, lastBracket + 1);
const parsed = tryParse<T>(candidate);
if (parsed !== null) return parsed;
}
// Strategy 4: Try fixing the entire string
const fixed = fixTrailingCommas(trimmed);
const parsedFixed = tryParse<T>(fixed);
if (parsedFixed !== null) return parsedFixed;
return null;
}
/**
* Extract mermaid content from a code fence.
* Returns the raw mermaid text (without fence markers).
*/
export function extractMermaidFromFence(text: string): string | null {
if (!text) return null;
const match = text.match(/```mermaid\s*\n?([\s\S]*?)```/);
if (match) return match[1].trim();
// Fallback: if no fence, return as-is (might already be raw mermaid)
if (text.includes("flowchart") || text.includes("graph")) return text.trim();
return null;
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
function tryParse<T>(s: string): T | null {
try {
return JSON.parse(s) as T;
} catch {
return null;
}
}
function fixTrailingCommas(s: string): string {
// Remove trailing commas before } or ]
return s.replace(/,\s*([}\]])/g, "$1");
}
@@ -0,0 +1,41 @@
/**
* L1 Response Parser — extracts summarization results from LLM output.
*/
import { extractJson } from "./json-utils.js";
import type { OffloadEntry } from "../../types.js";
interface RawL1Entry {
tool_call?: string;
summary?: string;
tool_call_id?: string;
timestamp?: string;
score?: number;
}
/**
* Parse L1 LLM response into OffloadEntry array.
* Tolerant of markdown wrapping, missing fields, etc.
*/
export function parseL1Response(raw: string): OffloadEntry[] {
const parsed = extractJson<RawL1Entry[]>(raw);
if (!parsed || !Array.isArray(parsed)) return [];
const entries: OffloadEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object") continue;
const toolCallId = item.tool_call_id ?? "";
if (!toolCallId) continue; // tool_call_id is required
entries.push({
tool_call_id: toolCallId,
tool_call: item.tool_call ?? "",
summary: item.summary ?? "",
timestamp: item.timestamp ?? "",
score: typeof item.score === "number" ? item.score : 5,
node_id: null,
});
}
return entries;
}
@@ -0,0 +1,37 @@
/**
* L1.5 Response Parser — extracts task judgment from LLM output.
*/
import { extractJson } from "./json-utils.js";
import type { TaskJudgment } from "../../types.js";
interface RawL15Response {
taskCompleted?: boolean | null;
isContinuation?: boolean | null;
isLongTask?: boolean | null;
continuationMmdFile?: string | null;
newTaskLabel?: string | null;
}
/**
* Parse L1.5 LLM response into TaskJudgment.
* Returns null if the response is completely unparseable or all-null (backend unavailable).
*/
export function parseL15Response(raw: string): TaskJudgment | null {
const parsed = extractJson<RawL15Response>(raw);
if (!parsed || typeof parsed !== "object") return null;
// All-null check (mirrors normalizeJudgment logic)
if (parsed.taskCompleted == null && parsed.isContinuation == null && parsed.isLongTask == null) {
return null;
}
return {
taskCompleted: Boolean(parsed.taskCompleted),
isContinuation: Boolean(parsed.isContinuation),
isLongTask: Boolean(parsed.isLongTask),
continuationMmdFile:
typeof parsed.continuationMmdFile === "string" ? parsed.continuationMmdFile : undefined,
newTaskLabel:
typeof parsed.newTaskLabel === "string" ? parsed.newTaskLabel : undefined,
};
}
@@ -0,0 +1,92 @@
/**
* L2 Response Parser — extracts MMD generation results from LLM output.
*/
import { extractJson, extractMermaidFromFence } from "./json-utils.js";
export interface L2ParsedResponse {
fileAction: "write" | "replace";
mmdContent?: string;
replaceBlocks?: Array<{
startLine: number;
endLine: number;
content: string;
}>;
nodeMapping: Record<string, string>;
}
interface RawL2Response {
file_action?: string;
mmd_content?: string | null;
replace_blocks?: Array<{
start_line?: number | string;
end_line?: number | string;
content?: string;
}> | null;
node_mapping?: Record<string, string>;
}
/**
* Parse L2 LLM response into structured L2 result.
* Returns null if parsing fails completely.
*/
export function parseL2Response(raw: string): L2ParsedResponse | null {
const parsed = extractJson<RawL2Response>(raw);
if (!parsed || typeof parsed !== "object") {
// Fallback: try extracting ```mermaid ... ``` code block (same as Go backend)
const mmd = extractMermaidFromFence(raw);
if (mmd) {
return { fileAction: "write", mmdContent: mmd, nodeMapping: {} };
}
return null;
}
const fileAction = parsed.file_action === "replace" ? "replace" : "write";
// Extract mmd_content (may be wrapped in code fence)
let mmdContent: string | undefined;
if (fileAction === "write") {
if (parsed.mmd_content) {
mmdContent = extractMermaidFromFence(parsed.mmd_content) ?? parsed.mmd_content;
} else {
// mmd_content missing in write mode — try extracting from raw response
const fallbackMmd = extractMermaidFromFence(raw);
if (fallbackMmd) mmdContent = fallbackMmd;
}
}
// Parse replace_blocks
let replaceBlocks: L2ParsedResponse["replaceBlocks"] | undefined;
if (fileAction === "replace" && Array.isArray(parsed.replace_blocks)) {
replaceBlocks = [];
for (const block of parsed.replace_blocks) {
if (!block || typeof block !== "object") continue;
const startLine = Number(block.start_line);
const endLine = Number(block.end_line);
if (isNaN(startLine) || isNaN(endLine)) continue;
let content = block.content ?? "";
// Extract mermaid from fence if present
const extracted = extractMermaidFromFence(content);
if (extracted) content = extracted;
replaceBlocks.push({ startLine, endLine, content });
}
}
// Parse node_mapping
const nodeMapping: Record<string, string> = {};
if (parsed.node_mapping && typeof parsed.node_mapping === "object") {
for (const [key, value] of Object.entries(parsed.node_mapping)) {
if (typeof value === "string") {
nodeMapping[key] = value;
}
}
}
return {
fileAction,
mmdContent,
replaceBlocks,
nodeMapping,
};
}
@@ -0,0 +1,98 @@
/**
* L1 Summarization Prompt — migrated from context-offload-server.
*
* Converts tool call/result pairs into high-density JSON summaries.
*/
// ─── System Prompt ───────────────────────────────────────────────────────────
export const L1_SYSTEM_PROMPT = `你是一个专为 AI 编码助手提供支持的"工具结果摘要器"。你的核心任务是深度理解当前的对话上下文,并将繁杂的工具调用与执行结果(一对toolcall和tool result整合成一条summary输出),提炼为高信息密度的 JSON 数组。
在生成摘要前,请务必进行以下内部思考:
1. 任务对齐:结合最近的对话记录,识别用户当前的核心目标和最新意图。若上下文存在冲突,始终以最新的用户意图为准。
2. 价值过滤:忽略工具如何工作的冗余细节,直接提取"发现了什么关键线索"、"做了什么关键动作"、"修改了什么具体内容"或"遇到了什么具体报错"。
3. 影响评估:判断该结果对当前任务的实质性影响(例如:证实了某个假设、推进了哪一步、做出了什么决策,或因为什么报错导致了阻塞)。
【输出格式要求】
你必须且只能输出一个合法的 JSON 对象数组 [{...}],每个对象**必须**包含以下字段:
- "tool_call": 工具调用的简洁描述。处理规则如下:
· 如果输入中该 tool pair 标记了 [NEEDS_COMPRESS],你必须将工具名+关键参数压缩为一句简洁的描述(≤150字符),保留工具名、操作目标(如文件路径、命令意图),省略内联脚本/大段内容的细节。
示例:exec({"command":"python3 -c 'import csv; ...200行脚本...'"}) → "exec: 运行 Python xx/xx/xx.sh,标明具体路径和文件)脚本分析 sales_channels.csv 数据质量"
示例:write_file({"path":"/root/app.py","content":"...5000字符..."}) → "write_file: 写入 /root/app.py (Flask 应用主文件),大致内容是……"
· 如果未标记 [NEEDS_COMPRESS],直接简述工具与参数即可(系统会用原始值覆盖)。
- "summary": 融合上述思考的精炼总结(≤200个字符)。必须一针见血地说清楚结果的业务价值,以及它对任务的推进/阻塞作用。
- "tool_call_id": 原始的 tool_call_id(必须原样透传)。
- "timestamp": 原始的中国标准时间(+08:00)ISO 8601 时间戳(必须原样透传)。
- "score"**必填**: 结合信息密度和任务目的分析summary对于原文的可替代性,范围在0-10之间,越接近10表示summary越能替代原文。
【严格规则】
只允许输出纯 JSON 数组,严禁输出思考过程或其他解释性文本。`;
// ─── Constants ───────────────────────────────────────────────────────────────
const PARAMS_MAX_LEN = 500;
const RESULT_MAX_LEN = 2000;
const COMPRESS_THRESHOLD = 200;
// ─── Types ───────────────────────────────────────────────────────────────────
export interface L1ToolPair {
toolName: string;
toolCallId: string;
params: unknown;
result: unknown;
timestamp: string;
}
// ─── User Prompt Builder ─────────────────────────────────────────────────────
/**
* Build the L1 user prompt for summarization.
* Mirrors context-offload-server/internal/service/prompt/BuildL1UserPrompt.
*/
export function buildL1UserPrompt(recentMessages: string, pairs: L1ToolPair[]): string {
const parts: string[] = [];
parts.push("## 最近的对话上下文(用于理解当前任务):");
parts.push(recentMessages);
parts.push("\n## Tool call/result pairs to summarize:");
for (let i = 0; i < pairs.length; i++) {
const p = pairs[i];
const paramsStr = truncate(stringify(p.params), PARAMS_MAX_LEN);
const resultStr = truncate(stringify(p.result), RESULT_MAX_LEN);
const canonical = `${p.toolName}(${stringify(p.params)})`;
const needsCompress = canonical.length > COMPRESS_THRESHOLD;
parts.push(`--- Tool Pair ${i + 1} ---`);
parts.push(`tool_call_id: ${p.toolCallId}`);
parts.push(`timestamp: ${p.timestamp}`);
if (needsCompress) {
parts.push(`Tool: ${p.toolName} [NEEDS_COMPRESS]`);
} else {
parts.push(`Tool: ${p.toolName}`);
}
parts.push(`Params: ${paramsStr}`);
parts.push(`Result: ${resultStr}\n`);
}
parts.push("Summarize each pair into the JSON array format described.");
return parts.join("\n");
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function stringify(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function truncate(s: string, maxLen: number): string {
if (s.length <= maxLen) return s;
return s.slice(0, maxLen) + "...";
}
+101
View File
@@ -0,0 +1,101 @@
/**
* L1.5 Task Judgment Prompt — migrated from context-offload-server.
*
* Determines task lifecycle: completion, continuation, new task detection.
*/
// ─── System Prompt ───────────────────────────────────────────────────────────
export const L15_SYSTEM_PROMPT = `你是一个面向 AI 编码助手的"任务生命周期门神"。
你的职责是交叉分析提供的三个输入源,精准研判任务状态,并输出纯 JSON 对象。
【输入数据利用指南(必须遵循的思考链路)】
1. 第一步 - 剖析 recentMessages(识别意图):根据当前和历史对话,提取用户最新回复的核心诉求。判断是"继续排查"、"宣布完工(如:跑通了)"、"单轮闲聊问答"还是"开启全新需求"。
2. 第二步 - 对齐 currentMmd(评估当前基线):将用户的最新意图与 currentMmd 的完整 Mermaid 内容进行比对——关注 taskGoal、各节点的 statusdone/doing/todo)以及 summary。如果诉求完全超出了当前图表的范畴或目标已实现(所有节点 done 且无后续),则 taskCompleted 为 true。若仍在解决图表中的子问题(包括 doing 节点或修 bug),则为 false。(如果没有currentMmd,就只根据当前对话和历史对话来判断是否继续任务)
3. 第三步 - 检索 availableMmds(判断是否延续):如果判定要开启新任务(isLongTask=true 且 taskCompleted=true/当前无任务),必须扫描 availableMmds 的 taskGoal 和时间信息。若新诉求与列表中某个旧任务高度重合(如回到昨天没做完的模块),则是延续(isContinuation=true)。
【严格 JSON 输出格式】
务必输出合法的纯 JSON 对象,格式如下:
{
"taskCompleted": boolean, // 当前任务是否已结束(如果 currentMmd 为 none,这里必须填 true
"isLongTask": boolean, // 最新诉求是否是需要多步操作的复杂工程(普通技术问答、闲聊填 false)
"isContinuation": boolean, // 是否在延续 availableMmds 中的历史任务
"continuationMmdFile": "string|null", // 若延续旧任务,精确填入 availableMmds 中的文件名(不含路径前缀),否则为 null
"newTaskLabel": "string|null" // 若是全新长任务,生成简短标签(≤30字符,kebab-case,如 "refactor-api"),否则为 null
}
只输出纯 JSON 对象,绝不允许包含解释文字。`;
// ─── Types ───────────────────────────────────────────────────────────────────
export interface L15CurrentMmd {
filename: string;
content: string;
path: string;
}
export interface L15MmdMeta {
filename: string;
path: string;
taskGoal: string;
doneCount: number;
doingCount: number;
todoCount: number;
updatedTime?: string | null;
nodeSummaries?: Array<{ nodeId: string; status: string; summary: string }>;
}
// ─── User Prompt Builder ─────────────────────────────────────────────────────
/**
* Build the L1.5 user prompt for task judgment.
* Mirrors context-offload-server/internal/service/prompt/BuildL15UserPrompt.
*/
export function buildL15UserPrompt(
recentMessages: string,
currentMmd: L15CurrentMmd | null,
metas: L15MmdMeta[],
): string {
const parts: string[] = [];
parts.push("## 1. 最近的对话上下文 (Recent 6 messages):");
parts.push(recentMessages);
parts.push("\n## 2. 当前挂载的任务图 (Active Mermaid — 完整内容):");
if (currentMmd && currentMmd.filename) {
parts.push(`**File:** ${currentMmd.filename}`);
if (currentMmd.path) {
parts.push(`**Path:** \`${currentMmd.path}\``);
}
parts.push(`\n\`\`\`mermaid\n${currentMmd.content}\n\`\`\``);
} else {
parts.push("(none - 当前处于闲置状态,无活跃任务)");
}
parts.push("\n## 3. 历史可用的任务图 (Available Mermaid task files):");
if (metas.length === 0) {
parts.push("(none - 暂无历史长任务)");
} else {
for (const m of metas) {
parts.push(`- **${m.filename}**`);
parts.push(` path: \`${m.path}\``);
parts.push(` taskGoal: ${m.taskGoal}`);
const total = m.doneCount + m.doingCount + m.todoCount;
parts.push(` progress: ${m.doneCount}/${total} done, ${m.doingCount} doing, ${m.todoCount} todo`);
if (m.updatedTime) {
parts.push(` lastUpdated: ${m.updatedTime}`);
}
if (m.nodeSummaries && m.nodeSummaries.length > 0) {
parts.push(" recentNodes:");
for (const n of m.nodeSummaries) {
parts.push(` - [${n.nodeId}] (${n.status}) ${n.summary}`);
}
}
parts.push("");
}
}
parts.push("请严格根据系统指令的【三步思考链路】进行研判,并输出合法的 JSON 对象。");
return parts.join("\n");
}
+127
View File
@@ -0,0 +1,127 @@
/**
* L2 MMD Generation Prompt — migrated from context-offload-server.
*
* Generates/updates Mermaid flowchart diagrams from offload entries.
*/
// ─── System Prompt ───────────────────────────────────────────────────────────
export const L2_SYSTEM_PROMPT = `你是一个究极实用主义的 AI 任务拓扑架构师与视觉叙事者。
你的核心逻辑是用尽量少的字符表达尽量多的信息,让LLM模型能看懂,不是为人类服务,尽量减少无用的视觉符号。任务是将底层工具调用记录,升维映射为一张高度语义化、表现力丰富且极度克制的 Mermaid (flowchart TD) 认知状态机。你要根据当前任务和意图,归纳"过去",要思考"未来"如何用这些已有的信息(你只需要记录已有信息,不需要写下一步规划)并标记"雷区"。保持图表的高度概括性。
【高阶认知与拓扑指南(你的自主权与极简原则)】
1. 弹性聚合:你拥有决定节点拆合的完全自主权。对于连续的、意图相同的常规动作(如连续查看多个文件以了解上下文),建议合并为一个宏观节点;,但保留关键转折点或重大发现为独立节点。图表必须保持宏观和克制,绝不事无巨细地记流水账。
2. 认知墓碑 (防重蹈覆辙):遇到彻底走不通的死胡同或引发严重报错的废弃方案,可以建立警示节点(status: blocked)(如果是价值不高的fail信息则不需要记录)。
3. 结论导向的摘要:节点的 summary(注意:尽量小于150字)应聚焦于"得出了什么结论"或"发生了什么实质改变",而非罗列琐碎的数据或参数,记得保持极简原则。
4. 要实事求是,你的任务是记录并归纳已经发生的事情,不是规划未来的具体操作,未发生的节点不要写,记录的已发生节点要有对应的消息来源(对应标注node_id)。
【符号即语义:高维认知字典(你的核心武器)】为了极致压缩 Token 并为你下一步推理提供"认知锚点",请自由使用不同的mmd形状来代表不同的节点逻辑。让形状替你说话,省略冗余的文字描述。
【高度自由的拓扑与极简法则】
1. 语义浓缩:既然形状已经表达了"领域",你的 summary 必须极其精简(≤150字),如"发现死锁"、"依赖冲突"、"已修复"。
2. 弹性拓扑:自主使用带标签的连线(-->|测试失败|)和虚线(-.->|参考|)来构建"依赖树"和"假设验证环"。不要记流水账。
3. 动态更新 (Token 极简)
- replace (增量微调):仅修改现有节点的状态、时间戳、短文本或追加极少节点时。
- write (全量重写):逻辑大洗牌、重构图表或初始化时。
注意:Existing Mermaid content 中每行开头都带有行号标记(如 "L1: ..."),这些行号仅供你在 replace 模式中引用,不是 MMD 内容的一部分。
【严格的工程底线】
1.节点标准格式:NodeID["阶段名: 宏观动作简述<br/>status: done|doing|paused|blocked <br/>summary: 核心结论摘要<br/>Timestamp: ISO8601"]
2. 全员归宿映射:输入的每一个新 tool_call_id,都必须在 node_mapping 中被分配到一个 Node ID;MMD里的每一个node都应该有源头的tool_call消息来源,不能乱编,绝对不允许遗漏!(Node_id和tool_call_id是一对多的关系)
3. 你可以通过各种整合方法,尽量把更新后mmd文件大小控制在4000字以内
【严格时间戳与元数据规则】
1. 顶部元数据(必填):%%{ "taskGoal": "一句话总结此次任务的目标(可动态更新)", "progress0-100": "进度百分比(严格点,几乎确认完成再打到90+)", createdTime": "ISO时间", "updatedTime": "ISO时间" }%%updatedTime为node中的最新时间)。
2. 节点内时间:如果合并了多个新条目,节点内的 Timestamp 必须取其中最新的 ISO 时间。
【严格 JSON 输出格式】
务必正确转义双引号。所有 Mermaid 代码(无论是 mmd_content 还是 replace_blocks 中的 content)都必须用 \`\`\`mermaid ... \`\`\` 代码块包裹起来。必须输出如下 JSON 结构:
{
"file_action": "replace 或 write",
"mmd_content": "完整的、带转义的 .mmd 代码,必须用 \`\`\`mermaid ... \`\`\` 包裹。(仅在 file_action 为 write 时填写,否则必须设为 null",
"replace_blocks": [
{
"start_line": "需要更新范围的起始行号(整数,对应 Existing Mermaid content 中的 L 标号)",
"end_line": "需要更新范围的结束行号(整数,包含该行)。要在某行之前插入新内容而不删除任何行,将 start_line 设为该行号,end_line 设为 start_line - 1",
"content": "替换后的新内容(不需要带行号前缀),必须用 \`\`\`mermaid ... \`\`\` 包裹"
}
],
"node_mapping": {
"tool_call_id_1": "N1",
"tool_call_id_2": "N1"
}
}
仅输出纯 JSON 对象,绝不允许包含任何解释。`;
// ─── Types ───────────────────────────────────────────────────────────────────
export interface L2NewEntry {
toolCallId: string;
toolCall: string;
summary: string;
timestamp: string;
}
// ─── User Prompt Builder ─────────────────────────────────────────────────────
/**
* Build the L2 user prompt for MMD generation.
* Mirrors context-offload-server/internal/service/prompt/BuildL2UserPrompt.
*/
export function buildL2UserPrompt(opts: {
existingMmd: string | null;
entries: L2NewEntry[];
recentHistory: string | null;
currentTurn: string | null;
taskLabel: string;
mmdPrefix: string;
charCount: number;
}): string {
const { existingMmd, entries, recentHistory, currentTurn, taskLabel, mmdPrefix, charCount } = opts;
const parts: string[] = [];
// History section
if (recentHistory) {
parts.push(`## 近期对话历史:\n${recentHistory}`);
} else {
parts.push("## 近期对话历史:\n(无可用历史)");
}
if (currentTurn) {
parts.push(`\n## 当前最新一轮:\n${currentTurn}`);
}
parts.push(`\n## MMD prefix: ${mmdPrefix}`);
parts.push(`(所有节点 ID 必须以此前缀开头,如 ${mmdPrefix}-N1, ${mmdPrefix}-N2...`);
parts.push(`\n## Current task label: ${taskLabel}`);
// Char count warning
if (charCount > 2500) {
parts.push(`\n## Current MMD size: ${charCount} chars (budget: 4000 chars)`);
parts.push("⚠ 接近上限,请积极合并节点、精简 summary,优先使用 replace 模式微调而非 write 全量重写。");
} else if (charCount > 2000) {
parts.push(`\n## Current MMD size: ${charCount} chars (budget: 4000 chars)`);
parts.push("注意控制增长,合并同类节点。");
}
// Existing MMD with line numbers
parts.push("\n## Existing Mermaid content:");
if (existingMmd) {
const lines = existingMmd.split("\n");
for (let i = 0; i < lines.length; i++) {
parts.push(`L${i + 1}: ${lines[i]}`);
}
} else {
parts.push("(empty — create new)");
}
// New entries
parts.push("\n## New offload entries to incorporate:");
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
parts.push(`${i + 1}. [${e.toolCallId}] ${e.toolCall}${e.summary} (${e.timestamp})`);
}
parts.push("\n请根据系统指令生成/更新 Mermaid 流程图,并输出合法的 JSON 对象(含 node_mapping)。");
return parts.join("\n");
}