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]];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user