feat: release v0.3.3 — Hermes adapter, context offload, core refactor

This commit is contained in:
chrishuan
2026-05-13 01:58:18 +08:00
parent a74b0b3e43
commit db8f3e516a
100 changed files with 29832 additions and 454 deletions
+286
View File
@@ -0,0 +1,286 @@
/**
* 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<string, string> {
const out: Record<string, string> = 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<string>();
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<PluginConfig> | undefined,
logger: PluginLogger,
): Promise<{
shouldTrigger: boolean;
reason: string;
entriesByMmd: Map<string, OffloadEntry[]>;
}> {
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<string, OffloadEntry[]>() };
const allEntries = await readAllOffloadEntries(stateManager.ctx);
const nowMs = Date.now();
// Collect eligible null entries using boundary-based grouping
const entriesByMmd = new Map<string, OffloadEntry[]>();
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<string, string>,
waitIds: Set<string>,
logger: PluginLogger,
options?: { mmdFallbackText?: string | null; mmdPrefix?: string },
): Promise<void> {
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.info(`[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<string, number>();
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).