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>
This commit is contained in:
144
plugin/tools/fade.ts
Normal file
144
plugin/tools/fade.ts
Normal file
@@ -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<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]];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user