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
+2 -1
View File
@@ -494,7 +494,8 @@ export function compressByScoreCascade(
for (const tuId of c.allToolUseIds) {
replacedIds.add(tuId);
replacedToolCallIdList.push(tuId);
const tuEntry = c.allOffloadEntries.find((e: OffloadEntry) => e.tool_call_id === tuId);
const tuIdNorm = normalizeToolCallIdForLookup(tuId);
const tuEntry = c.allOffloadEntries.find((e: OffloadEntry) => e.tool_call_id === tuId || e.tool_call_id === tuIdNorm || normalizeToolCallIdForLookup(e.tool_call_id) === tuIdNorm);
replacedDetails.push({ toolCallId: tuId, score: c.score, summaryPreview: (tuEntry?.summary ?? "").slice(0, 120) });
}
for (let ei = 0; ei < c.allToolUseIds.length; ei++) {
+91 -34
View File
@@ -50,6 +50,7 @@ import { findHistoryMmdInsertionPoint } from "./mmd-injector.js";
import type { OffloadConfig } from "../config.js";
import type { PluginConfig, PluginLogger } from "./types.js";
import { BackendClient } from "./backend-client.js";
import { LocalLlmClient } from "./local-llm/index.js";
import type { L1Request, L15Request, L2Request } from "./backend-client.js";
import { parseMmdMeta } from "./mmd-meta.js";
import { sanitizeText, writeRefMd } from "./storage.js";
@@ -299,36 +300,91 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
}
const sessions = _sharedSessions;
// Resolve LLM Configuration — backend mode only
// Backend Client (required for L1/L1.5/L2/L4 — but context engine still registers for L3)
// Resolve LLM Configuration — mode-based selection
// - "backend": use remote backend service (requires backendUrl)
// - "local": call LLM directly via AI SDK (uses offload.model or main agent model)
//
// User identity: prefer offloadConfig.userId; fall back to the host's
// primary non-loopback IPv4 address. The backend `/offload/v1/store`
// endpoint upserts state keyed by this value (as Mongo `_id`).
// primary non-loopback IPv4 address.
const _resolvedUserId = resolveUserId(offloadConfig.userId ?? null);
logger.debug?.(
`[context-offload] user-id resolved: "${_resolvedUserId}" (source=${getUserIdSource() ?? "?"})`,
);
const backendClient = offloadConfig.backendUrl
? new BackendClient(
let backendClient: BackendClient | LocalLlmClient | null = null;
if (offloadConfig.mode === "backend") {
// Remote backend mode
if (!offloadConfig.backendUrl) {
logger.error("[context-offload] mode=backend but backendUrl not configured. L1/L1.5/L2/L4 disabled.");
} else {
backendClient = new BackendClient(
offloadConfig.backendUrl,
logger,
offloadConfig.backendApiKey,
offloadConfig.backendTimeoutMs,
() => _lastActiveSessionKey,
() => _resolvedUserId,
() => _lastActiveSessionKey, // use sessionKey as task scope — coarse but stable
)
: null;
() => _lastActiveSessionKey,
);
}
} else {
// Local LLM mode — resolve model from offload.model or fall back to agents.defaults.model
let resolvedModelRef = offloadConfig.model;
if (!resolvedModelRef) {
// Fallback: use main agent model from openclaw.json agents.defaults.model
const mainConfig = api.config as Record<string, unknown> | undefined;
const agents = mainConfig?.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | undefined;
const modelCfg = defaults?.model;
if (typeof modelCfg === "string" && modelCfg.includes("/")) {
resolvedModelRef = modelCfg;
} else if (modelCfg && typeof modelCfg === "object") {
const primary = (modelCfg as Record<string, unknown>).primary;
if (typeof primary === "string" && primary.includes("/")) {
resolvedModelRef = primary;
}
}
if (resolvedModelRef) {
logger.info(`[context-offload] offload.model not set, using main agent model: ${resolvedModelRef}`);
}
}
if (resolvedModelRef) {
const modelParts = resolvedModelRef.split("/", 2);
const providerKey = modelParts[0];
const modelId = modelParts[1] ?? resolvedModelRef;
const models = (api.config as any)?.models;
const providerCfg = models?.providers?.[providerKey];
const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL;
const apiKey = providerCfg?.apiKey;
if (baseUrl && apiKey) {
backendClient = new LocalLlmClient(
{ baseUrl, apiKey, model: modelId, temperature: offloadConfig.temperature, timeoutMs: offloadConfig.backendTimeoutMs },
logger,
);
} else {
logger.error(
`[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers. ` +
`L1/L1.5/L2 disabled.`,
);
}
} else {
logger.warn("[context-offload] No model resolved (offload.model not set, agents.defaults.model not found). L1/L1.5/L2 disabled.");
}
}
// Track last active session key for BackendClient header
let _lastActiveSessionKey: string | null = null;
if (backendClient) {
logger.debug?.(`[context-offload] Backend mode: ${offloadConfig.backendUrl}`);
if (backendClient && offloadConfig.mode === "backend") {
logger.debug?.(`[context-offload] LLM mode: backend (${offloadConfig.backendUrl})`);
} else if (backendClient) {
logger.debug?.(`[context-offload] LLM mode: local (${offloadConfig.model ?? "main-agent-model"})`);
} else {
logger.warn("[context-offload] backendUrl not configured. L1/L1.5/L2/L4 disabled (L3 compression still active).");
logger.warn("[context-offload] LLM client not available. L1/L1.5/L2/L4 disabled (L3 compression still active).");
}
// ─── Fault tolerance constants ──────────────────────────────────────────────
@@ -361,7 +417,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
const beforeFilter = takenPairs.length;
const pairs = takenPairs.filter((p) => !isHeartbeat(p));
if (beforeFilter > pairs.length) {
logger.info(`[context-offload] Backend L1: filtered ${beforeFilter - pairs.length} heartbeat pair(s)`);
logger.info(`[context-offload] L1: filtered ${beforeFilter - pairs.length} heartbeat pair(s)`);
}
if (pairs.length === 0) return;
@@ -376,7 +432,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
const refPath = await writeRefMd(stateManager.ctx, p.timestamp, p.toolName, content);
refByToolCallId.set(p.toolCallId, refPath);
} catch (err) {
logger.error(`[context-offload] Backend L1.1 ref write error (${p.toolCallId}): ${err}`);
logger.error(`[context-offload] L1.1 ref write error (${p.toolCallId}): ${err}`);
}
}
@@ -385,7 +441,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
for (let i = 0; i < pairs.length; i += L1_BATCH_SIZE) {
batches.push(pairs.slice(i, i + L1_BATCH_SIZE));
}
logger.info(`[context-offload] Backend L1 (${triggerSource}): ${pairs.length} pairs → ${batches.length} batch(es) of ≤${L1_BATCH_SIZE}`);
logger.info(`[context-offload] L1 (${triggerSource}): ${pairs.length} pairs → ${batches.length} batch(es) of ≤${L1_BATCH_SIZE}`);
const recentMessages = _buildL1RecentContext(stateManager);
logger.info(`[context-offload] L1 recentMessages (${recentMessages.length} chars):\n${recentMessages}`);
@@ -417,15 +473,15 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
}
await appendOffloadEntries(stateManager.ctx, resp.entries, undefined, logger);
stateManager.entryCounter += resp.entries.length;
logger.info(`[context-offload] Backend L1 batch OK: ${resp.entries.length} entries from ${chunk.length} pairs (entryCounter=${stateManager.entryCounter})`);
logger.info(`[context-offload] L1 batch OK: ${resp.entries.length} entries from ${chunk.length} pairs (entryCounter=${stateManager.entryCounter})`);
}
} catch (err) {
const newFails = prevFails + 1;
logger.warn(`[context-offload] Backend L1 batch FAILED (${chunkKey}, attempt ${newFails}/${MAX_L1_CHUNK_RETRIES}): ${err}`);
logger.warn(`[context-offload] L1 batch FAILED (${chunkKey}, attempt ${newFails}/${MAX_L1_CHUNK_RETRIES}): ${err}`);
if (newFails >= MAX_L1_CHUNK_RETRIES) {
// Exceeded retry limit — generate local fallback entries (no LLM summary)
logger.warn(`[context-offload] Backend L1 batch DEGRADED: ${chunk.length} pairs → fallback entries (no LLM summary)`);
logger.warn(`[context-offload] L1 batch DEGRADED: ${chunk.length} pairs → fallback entries (no LLM summary)`);
stateManager._l1ChunkFailCounts.delete(chunkKey);
const fallbackEntries: import("./types.js").OffloadEntry[] = [];
for (const p of chunk) {
@@ -446,7 +502,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
}
await appendOffloadEntries(stateManager.ctx, fallbackEntries, undefined, logger);
stateManager.entryCounter += fallbackEntries.length;
logger.info(`[context-offload] Backend L1 fallback: wrote ${fallbackEntries.length} degraded entries`);
logger.info(`[context-offload] L1 fallback: wrote ${fallbackEntries.length} degraded entries`);
} else {
// Under retry limit — re-enqueue this chunk for next flush
stateManager._l1ChunkFailCounts.set(chunkKey, newFails);
@@ -454,7 +510,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
stateManager.processedToolCallIds.delete(p.toolCallId);
stateManager.pendingToolPairs.push(p as any);
}
logger.info(`[context-offload] Backend L1 batch: re-enqueued ${chunk.length} pairs (retry ${newFails}/${MAX_L1_CHUNK_RETRIES})`);
logger.info(`[context-offload] L1 batch: re-enqueued ${chunk.length} pairs (retry ${newFails}/${MAX_L1_CHUNK_RETRIES})`);
}
}
}
@@ -512,13 +568,13 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
// Normalize backend response (handles null fields from fallback)
const judgment = normalizeJudgment(resp as unknown as Record<string, unknown>);
if (!judgment) {
logger.warn("[context-offload] Backend L1.5: all-null response (backend LLM unavailable)");
logger.warn("[context-offload] L1.5: all-null response (backend LLM unavailable)");
return false; // trigger retry
}
// Success
logger.info(
`[context-offload] Backend L1.5: completed=${judgment.taskCompleted}, continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, label=${judgment.newTaskLabel ?? "none"}, contFile=${judgment.continuationMmdFile ?? "none"}`,
`[context-offload] L1.5: completed=${judgment.taskCompleted}, continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, label=${judgment.newTaskLabel ?? "none"}, contFile=${judgment.continuationMmdFile ?? "none"}`,
);
// ── Flush residual null entries for the OLD mmd before task transition ──
@@ -578,10 +634,10 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
await stateManager.save();
stateManager.setMmdInjectionReady(true);
stateManager.l15Settled = true;
logger.info("[context-offload] Backend L1.5: settled, MMD injection ready");
logger.info("[context-offload] L1.5: settled, MMD injection ready");
return true;
} catch (err) {
logger.warn(`[context-offload] Backend L1.5 attempt failed: ${err}`);
logger.warn(`[context-offload] L1.5 attempt failed: ${err}`);
return false;
}
};
@@ -634,7 +690,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
for (let i = 0; i < mmdEntries.length; i += L2_BATCH_SIZE) {
batches.push(mmdEntries.slice(i, i + L2_BATCH_SIZE));
}
logger.info(`[context-offload] Backend L2 (${triggerSource}): mmd=${mmdFile}, ${mmdEntries.length} entries → ${batches.length} batch(es) of ≤${L2_BATCH_SIZE}`);
logger.info(`[context-offload] L2 (${triggerSource}): mmd=${mmdFile}, ${mmdEntries.length} entries → ${batches.length} batch(es) of ≤${L2_BATCH_SIZE}`);
for (let bIdx = 0; bIdx < batches.length; bIdx++) {
const batch = batches[bIdx];
@@ -678,7 +734,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
// Handle backend degraded response (empty fileAction = LLM unavailable)
if (!resp.fileAction) {
logger.warn(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: degraded response, applying fallback backfill`);
logger.warn(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: degraded response, applying fallback backfill`);
await backfillNodeIds(stateManager.ctx, resp.nodeMapping ?? {}, batchWaitIds, logger, {
mmdFallbackText: existingMmd ?? "",
mmdPrefix,
@@ -689,14 +745,14 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
// Apply MMD file changes
if (resp.fileAction === "replace" && resp.replaceBlocks && resp.replaceBlocks.length > 0) {
const patchOk = await patchMmd(stateManager.ctx, mmdFile, resp.replaceBlocks);
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: patchMmd: ${patchOk ? "ok" : "FAILED"} (${resp.replaceBlocks.length} blocks)`);
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: patchMmd: ${patchOk ? "ok" : "FAILED"} (${resp.replaceBlocks.length} blocks)`);
if (!patchOk && resp.mmdContent) {
await writeMmd(stateManager.ctx, mmdFile, resp.mmdContent);
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: fallback writeMmd: ${resp.mmdContent.length} chars`);
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: fallback writeMmd: ${resp.mmdContent.length} chars`);
}
} else if (resp.mmdContent) {
await writeMmd(stateManager.ctx, mmdFile, resp.mmdContent);
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: writeMmd: ${resp.mmdContent.length} chars`);
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: writeMmd: ${resp.mmdContent.length} chars`);
}
// Backfill node_ids
@@ -712,15 +768,15 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
mmdPrefix,
});
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} (${triggerSource}): applied, action=${resp.fileAction}, mapping=${Object.keys(resp.nodeMapping ?? {}).length}`);
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} (${triggerSource}): applied, action=${resp.fileAction}, mapping=${Object.keys(resp.nodeMapping ?? {}).length}`);
} catch (err) {
logger.error(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} failed: ${err}`);
logger.error(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} failed: ${err}`);
// Continue with remaining batches — failed entries stay as "wait" for retry
}
}
}
} catch (err) {
logger.error(`[context-offload] Backend L2 failed: ${err}`);
logger.error(`[context-offload] L2 failed: ${err}`);
}
};
@@ -745,12 +801,13 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
nodeIds.add(match[1]);
}
const filtered = allEntries.filter((e) => e.node_id && nodeIds.has(e.node_id));
const resp = await backendClient.l4Generate({
const resp = await (backendClient as any).l4Generate({
mmdFilename,
mmdContent,
offloadEntries: filtered,
skillFocus: skillCommand.skillFocus,
});
if (!resp) return null;
// Write skill file locally
const { mkdir, writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
@@ -960,7 +1017,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
logger.warn(`[context-offload] <<< after_tool_call SKIP: no session manager (${Date.now() - _atcStart}ms)`);
return;
}
const afterToolCallHandler = createAfterToolCallHandler(_mgr, logger, getContextWindow, pCfg, backendClient);
const afterToolCallHandler = createAfterToolCallHandler(_mgr, logger, getContextWindow, pCfg, backendClient as any);
await afterToolCallHandler(event, ctx);
const _handlerDone = Date.now();
logger.info(`[context-offload] after_tool_call handler done: ${_handlerDone - _atcStart}ms`);
+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");
}