/** * L2 Mermaid Generation Pipeline (Independent Trigger): * * L2 is NO LONGER triggered directly from L1. Instead it runs independently: * - Trigger condition A: offload.jsonl has >= l2NullThreshold entries with node_id=null * - Trigger condition B: time since last L2 trigger exceeds l2TimeoutSeconds */ import { PLUGIN_DEFAULTS, type OffloadEntry, type PluginConfig, type PluginLogger } from "../types.js"; import { readAllOffloadEntries, rewriteAllOffloadEntries, type StorageContext, } from "../storage.js"; import type { OffloadStateManager } from "../state-manager.js"; function isHeartbeatEntry(entry: OffloadEntry): boolean { try { const tc = entry.tool_call ?? ""; return tc.includes("HEARTBEAT.md"); } catch { return false; } } function hasNullEntryAfterLastL2( nullEntries: OffloadEntry[], lastL2Iso: string, ): boolean { const lastMs = new Date(lastL2Iso).getTime(); if (Number.isNaN(lastMs)) return true; return nullEntries.some((e) => { if (!e.timestamp) return true; const ts = new Date(e.timestamp).getTime(); if (Number.isNaN(ts)) return true; return ts > lastMs; }); } const MMD_NODE_ID_RE = /\b(\d{3}-N\d+)\b/g; function normalizeNodeMapping(raw: any): Record { const out: Record = Object.create(null); if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out; for (const [k, v] of Object.entries(raw)) { if (typeof k !== "string" || !k) continue; const s = typeof v === "string" ? v.trim() : v != null ? String(v).trim() : ""; if (s) out[k] = s; } return out; } function extractMmdNodeIdsFromText(text: string | null | undefined): string[] { if (text == null || typeof text !== "string") return []; const seen = new Set(); const out: string[] = []; let m: RegExpExecArray | null; MMD_NODE_ID_RE.lastIndex = 0; while ((m = MMD_NODE_ID_RE.exec(text)) !== null) { const id = m[1]; if (!seen.has(id)) { seen.add(id); out.push(id); } } return out; } function pickMmdDerivedFallbackNodeId( mmdText: string, mmdPrefix: string, ): string | null { const ids = extractMmdNodeIdsFromText(mmdText); if (ids.length === 0) return null; const prefix = typeof mmdPrefix === "string" && /^\d{3}$/.test(mmdPrefix) ? `${mmdPrefix}-` : null; const pool = prefix ? ids.filter((id) => id.startsWith(prefix)) : ids; const candidates = pool.length > 0 ? pool : ids; let best: string | null = null; let bestN = -1; for (const id of candidates) { const mm = id.match(/^(\d{3})-N(\d+)$/); if (!mm) continue; const n = Number(mm[2]); if (Number.isFinite(n) && n > bestN) { bestN = n; best = id; } } return best; } // ─── L2 Independent Trigger Check ───────────────────────────────────────────── export async function checkL2Trigger( stateManager: OffloadStateManager, pluginConfig: Partial | undefined, logger: PluginLogger, ): Promise<{ shouldTrigger: boolean; reason: string; entriesByMmd: Map; }> { const nullThreshold = pluginConfig?.l2NullThreshold ?? PLUGIN_DEFAULTS.l2NullThreshold; const timeoutSeconds = pluginConfig?.l2TimeoutSeconds ?? PLUGIN_DEFAULTS.l2TimeoutSeconds; const timeNeedsNewOffload = (pluginConfig as any)?.l2TimeTriggerRequiresNewOffload ?? PLUGIN_DEFAULTS.l2TimeTriggerRequiresNewOffload; const waitRetrySeconds = (pluginConfig as any)?.l2WaitRetrySeconds ?? PLUGIN_DEFAULTS.l2WaitRetrySeconds; const emptyResult = { shouldTrigger: false as const, reason: "", entriesByMmd: new Map() }; const allEntries = await readAllOffloadEntries(stateManager.ctx); const nowMs = Date.now(); // Collect eligible null entries using boundary-based grouping const entriesByMmd = new Map(); let eligibleNullCount = 0; for (let i = 0; i < allEntries.length; i++) { const entry = allEntries[i]; if (isHeartbeatEntry(entry)) continue; if (entry.node_id !== null && entry.node_id !== "wait") continue; // For "wait" entries, only include if they exceeded retry timeout if (entry.node_id === "wait") { const tsIso = entry.timestamp; if (tsIso) { const tsMs = new Date(tsIso).getTime(); if (!Number.isNaN(tsMs) && (nowMs - tsMs) / 1000 < waitRetrySeconds) continue; } } // Use boundary to determine which mmd this entry belongs to const boundary = stateManager.resolveEntryBoundary(i); if (!boundary) continue; // no boundary coverage → skip if (boundary.result !== "long") continue; // short task → skip if (!boundary.targetMmd) continue; // no target mmd → skip if (entry.node_id === null) eligibleNullCount++; const mmd = boundary.targetMmd; let bucket = entriesByMmd.get(mmd); if (!bucket) { bucket = []; entriesByMmd.set(mmd, bucket); } // Dedup by tool_call_id within the same bucket if (entry.tool_call_id && bucket.some((e) => e.tool_call_id === entry.tool_call_id)) continue; bucket.push(entry); } const totalEligible = Array.from(entriesByMmd.values()).reduce((sum, arr) => sum + arr.length, 0); if (totalEligible === 0) { return { ...emptyResult, reason: "no eligible entries (boundary-filtered)" }; } // Condition A: null count threshold if (eligibleNullCount >= nullThreshold) { return { shouldTrigger: true, reason: `null_count=${eligibleNullCount} >= threshold=${nullThreshold} (${entriesByMmd.size} mmd(s))`, entriesByMmd, }; } // Condition B: timeout const lastL2Time = stateManager.getLastL2TriggerTime(); if (lastL2Time) { const elapsed = (Date.now() - new Date(lastL2Time).getTime()) / 1000; if (elapsed >= timeoutSeconds) { if (timeNeedsNewOffload) { // Check if any null entry is newer than last L2 const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e)); if (!hasNullEntryAfterLastL2(nullEntries, lastL2Time) && totalEligible === eligibleNullCount) { return { ...emptyResult, reason: "timeout but no new offload rows" }; } } return { shouldTrigger: true, reason: `timeout: ${elapsed.toFixed(0)}s >= ${timeoutSeconds}s (${entriesByMmd.size} mmd(s))`, entriesByMmd, }; } } else { // No prior L2: check retry-wait entries or oldest null age const hasRetryWait = totalEligible > eligibleNullCount; if (hasRetryWait) { return { shouldTrigger: true, reason: `no prior L2 + retry-wait entries (${entriesByMmd.size} mmd(s))`, entriesByMmd, }; } const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e)); if (nullEntries.length > 0) { const oldestTs = nullEntries[0]?.timestamp; if (oldestTs) { const elapsed = (Date.now() - new Date(oldestTs).getTime()) / 1000; if (elapsed >= timeoutSeconds) { return { shouldTrigger: true, reason: `no prior L2 + oldest null entry age=${elapsed.toFixed(0)}s`, entriesByMmd, }; } } } } return { ...emptyResult, reason: `null_count=${eligibleNullCount} < ${nullThreshold}, timeout not reached`, }; } export async function backfillNodeIds( ctx: StorageContext, nodeMapping: Record, waitIds: Set, logger: PluginLogger, options?: { mmdFallbackText?: string | null; mmdPrefix?: string }, ): Promise { const mapping = normalizeNodeMapping(nodeMapping); const mmdFallbackText = options?.mmdFallbackText ?? null; const mmdPrefix = options?.mmdPrefix ?? "000"; const allEntries = await readAllOffloadEntries(ctx); let changed = false; const mappedNodeIds = Object.values(mapping); const fallbackFromMapping = getMostFrequent(mappedNodeIds); const fallbackFromMmd = pickMmdDerivedFallbackNodeId( mmdFallbackText ?? "", mmdPrefix, ); const effectiveFallback = fallbackFromMapping || fallbackFromMmd; let mappedCount = 0; let fallbackCount = 0; let skippedCount = 0; for (const entry of allEntries) { const mapped = mapping[entry.tool_call_id]; if (mapped) { entry.node_id = mapped; changed = true; mappedCount++; continue; } if (entry.node_id === "wait" && waitIds.has(entry.tool_call_id)) { if (effectiveFallback) { entry.node_id = effectiveFallback; changed = true; fallbackCount++; } else { skippedCount++; } } } if (changed) { await rewriteAllOffloadEntries(ctx, allEntries); } logger.debug?.(`[context-offload] L2 backfill: mapped=${mappedCount}, fallback=${fallbackCount} (to ${effectiveFallback ?? "N/A"}), skipped=${skippedCount}, total=${waitIds.size}`); } function getMostFrequent(arr: string[]): string | null { if (arr.length === 0) return null; const freq = new Map(); for (const v of arr) { freq.set(v, (freq.get(v) ?? 0) + 1); } let maxKey = arr[0]; let maxCount = 0; for (const [key, count] of freq) { if (count > maxCount) { maxCount = count; maxKey = key; } } return maxKey; } // Local runL2Pipeline removed — all L2 processing goes through backend (index.ts → backendClient.l2Generate).