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:
h z
2026-06-06 00:26:16 +01:00
parent c9792d1290
commit 2553d102f3
2 changed files with 190 additions and 4 deletions

144
plugin/tools/fade.ts Normal file
View 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+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]];
}
}

View File

@@ -9,12 +9,16 @@
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/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 <kb-block> inner body — flat sequence of
* <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
* 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('</kb-fact>');
});
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;
}
}