diff --git a/plugin/tools/fade.ts b/plugin/tools/fade.ts new file mode 100644 index 0000000..901829b --- /dev/null +++ b/plugin/tools/fade.ts @@ -0,0 +1,144 @@ +// fade.ts — TypeScript port of Plexum-sdk-go/fade. Deterministic fade- +// out for kb-block / future subblock entries. Matches Plexum decision +// #30 (n=5/w=10/m=70 default) so cross-runtime behaviour stays +// identical when ports are compared. +// +// The algorithm: +// - For elapsed `d <= n` turns since last_refresh, content unchanged +// - For d > n: ticks = d - n; each tick masks ceil(w%) of the +// currently-unmasked maskable-rune positions with `_`. Mask set is +// cumulative across ticks. PRNG is seeded by (entrySeed ^ tick), +// so the masking pattern is reproducible across processes. +// - Tick drops the entry when underscore_ratio > m% of maskable. +// +// Maskable runes (matches Go source): +// - ASCII alphanumerics [0-9A-Za-z] +// - CJK ideographs (U+4E00–U+9FFF) +// - Hiragana (U+3040–U+309F) + Katakana (U+30A0–U+30FF) +// - Hangul syllables (U+AC00–U+D7A3) +// Everything else (whitespace, punctuation, XML structure characters) +// stays unmodified so the rendered XML keeps parseable shape. + +export interface FadeParams { + n: number; // safe-period turns + w: number; // % maskable chars masked per tick + m: number; // % drop threshold on underscore_ratio +} + +export interface FadeResult { + rendered: string; + maskedCount: number; + totalMaskable: number; +} + +export function defaultFadeParams(): FadeParams { + return { n: 5, w: 10, m: 70 }; +} + +/** shouldDrop returns true iff the entry's underscore ratio crosses m%. */ +export function shouldDrop(r: FadeResult, params: FadeParams): boolean { + if (r.totalMaskable === 0) return false; + return r.maskedCount * 100 > params.m * r.totalMaskable; +} + +/** + * Fade computes the rendered form of `content` given the entry seed + + * elapsed turns since last refresh. + */ +export function fade(content: string, seed: number | bigint, d: number, params: FadeParams): FadeResult { + const runes = Array.from(content); // surrogate-pair safe + const maskable: number[] = []; + for (let i = 0; i < runes.length; i++) { + if (isMaskable(runes[i])) maskable.push(i); + } + if (d <= params.n) { + return { rendered: content, maskedCount: 0, totalMaskable: maskable.length }; + } + const ticks = d - params.n; + const masked = positionsToMaskCumulative(maskable, BigInt(seed), ticks, params.w); + return { + rendered: applyUnderscores(runes, masked), + maskedCount: masked.length, + totalMaskable: maskable.length, + }; +} + +// ---- helpers ---- + +function isMaskable(s: string): boolean { + if (s.length === 0) return false; + // For surrogate pairs, take the first code point. + const cp = s.codePointAt(0); + if (cp === undefined) return false; + if (cp >= 0x30 && cp <= 0x39) return true; // 0-9 + if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) return true; // A-Z a-z + if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified Ideographs + if (cp >= 0x3040 && cp <= 0x309F) return true; // Hiragana + if (cp >= 0x30A0 && cp <= 0x30FF) return true; // Katakana + if (cp >= 0xAC00 && cp <= 0xD7A3) return true; // Hangul syllables + return false; +} + +function positionsToMaskCumulative(maskable: number[], seed: bigint, ticks: number, w: number): number[] { + if (maskable.length === 0 || ticks <= 0 || w <= 0) return []; + const used = new Set(); + const result: number[] = []; + for (let tick = 1; tick <= ticks; tick++) { + const remaining = maskable.length - used.size; + if (remaining === 0) break; + let target = Math.ceil((w * remaining) / 100); + if (target < 1) target = 1; + if (target > remaining) target = remaining; + const unmasked: number[] = []; + for (const p of maskable) { + if (!used.has(p)) unmasked.push(p); + } + // Deterministic shuffle using a tick-specific seed. + const tickSeed = seed ^ BigInt(tick); + shuffleDeterministic(unmasked, tickSeed); + for (let i = 0; i < target; i++) { + used.add(unmasked[i]); + result.push(unmasked[i]); + } + } + result.sort((a, b) => a - b); + return result; +} + +function applyUnderscores(runes: string[], masked: number[]): string { + if (masked.length === 0) return runes.join(''); + const maskSet = new Set(masked); + let out = ''; + for (let i = 0; i < runes.length; i++) { + out += maskSet.has(i) ? '_' : runes[i]; + } + return out; +} + +/** + * Fisher-Yates shuffle using a Mulberry32-style xorshift PRNG seeded + * by `seed`. Deterministic for the same seed input. + * + * Note: the Go source uses math/rand.NewSource(seed).Shuffle, which + * uses a different PRNG algorithm. So the SAME (seed, tick) on Go vs + * TS will produce different mask positions. That's acceptable for v1 + * because no cross-runtime comparison of rendered fade is expected; + * each runtime fades its own session-local kb-block independently. + * If future use cases need cross-runtime parity, port Go's rand + * algorithm to TS (~50 LOC). + */ +function shuffleDeterministic(arr: T[], seed: bigint): void { + // Mulberry32 needs a u32 seed. + let s = Number(seed & 0xFFFFFFFFn) >>> 0; + const rng = () => { + s = (s + 0x6D2B79F5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; + }; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } +} diff --git a/plugin/tools/kbblock.ts b/plugin/tools/kbblock.ts index b820a6a..5333400 100644 --- a/plugin/tools/kbblock.ts +++ b/plugin/tools/kbblock.ts @@ -9,12 +9,16 @@ // /agents//sessions//plugins/ // harbor-forge/kb-block.json // -// Fade NOT applied in v1 (§9 #3 placeholder; defer until prod data). -// Cross-runtime aligned with Plexum kbblock. +// Fade algorithm + types are implemented (see fade.ts + renderFaded / +// tick methods), but the openclaw `before_prompt_build` hook ctx +// doesn't currently expose currentTurn to plugins. Plain render() (no +// fade) is used in the hook for v1; renderFaded scaffolding stays for +// when turn signal lands. Plexum side fully wires fade per turn. import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { fade, shouldDrop, type FadeParams } from './fade.js'; export interface Entry { id: number; // HF backend DB primary key @@ -24,6 +28,7 @@ export interface Entry { insert_seq: number; added_at_turn: number; last_refresh_at_turn: number; + seed: number; // PRNG seed for fade — generated at add() time } interface BlockShape { @@ -92,6 +97,7 @@ export class Block { insert_seq: this.next_seq, added_at_turn: at_turn, last_refresh_at_turn: at_turn, + seed: Math.floor(Math.random() * 0x7FFFFFFF), }; this.next_seq += 1; this.entries.push(e); @@ -135,9 +141,23 @@ export class Block { /** * Render the inner body — flat sequence of * source=topic:>content - * ordered by insert_seq (§9 #4). Empty block → "". + * ordered by insert_seq (§9 #4). Empty block → "". No fade applied. */ render(): string { + return this.renderInner(null); + } + + /** + * RenderFaded mirrors render() but applies fade() per entry given + * currentTurn + params. Available for callers that have turn signal + * (Plexum side does; openclaw `before_prompt_build` hook doesn't + * currently — uses plain render()). + */ + renderFaded(currentTurn: number, params: FadeParams): string { + return this.renderInner((e) => fade(e.content, e.seed, currentTurn - e.last_refresh_at_turn, params).rendered); + } + + private renderInner(transform: ((e: Entry) => string) | null): string { if (this.entries.length === 0) return ''; const ordered = [...this.entries].sort((a, b) => a.insert_seq - b.insert_seq); const lines: string[] = []; @@ -147,9 +167,31 @@ export class Block { if (e.source_topic) head += ` source=topic:${e.source_topic}`; head += '>'; lines.push(head); - lines.push(e.content); + lines.push(transform ? transform(e) : e.content); lines.push(''); }); return lines.join('\n') + '\n'; } + + /** + * Tick applies fade tick — drops entries whose underscore ratio + * crossed the m% threshold. Returns IDs dropped. Caller must + * save() after. + */ + tick(currentTurn: number, params: FadeParams): number[] { + if (this.entries.length === 0) return []; + const dropped: number[] = []; + const kept: Entry[] = []; + for (const e of this.entries) { + const d = currentTurn - e.last_refresh_at_turn; + const r = fade(e.content, e.seed, d, params); + if (shouldDrop(r, params)) { + dropped.push(e.id); + } else { + kept.push(e); + } + } + this.entries = kept; + return dropped; + } }