Files
HarborForge.OpenclawPlugin/plugin/tools/fade.ts
hzhang 2553d102f3 feat(kbblock,fade): TS fade port + scaffold renderFaded/tick (not wired in v1)
Phase 4b openclaw side. Provides fade primitives matching Plexum-sdk-
go/fade so renderFaded / tick are API-parity available, but stops
short of activating fade in the live before_prompt_build hook since
openclaw plugin SDK ctx doesn't currently expose currentTurn to
plugins. Plexum side fully wires fade per turn.

  plugin/tools/fade.ts (new): TS port of the fade algorithm. Maskable
    rune set matches Plexum source. PRNG is Mulberry32 instead of Go's
    math/rand — documented in the file: cross-runtime mask patterns
    DIFFER, but each runtime fades its own session-local kb-block
    independently so no comparison is expected.

  plugin/tools/kbblock.ts: Entry gains seed (random u31 at add());
    render() unchanged behaviour, plus new renderFaded(currentTurn,
    params) + tick(currentTurn, params) for future wiring when
    openclaw exposes turn signal to plugin hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 00:26:16 +01:00

145 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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+4E00U+9FFF)
// - Hiragana (U+3040U+309F) + Katakana (U+30A0U+30FF)
// - Hangul syllables (U+AC00U+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<number>();
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<T>(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]];
}
}