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/
|
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/plugins/
|
||||||
// harbor-forge/kb-block.json
|
// harbor-forge/kb-block.json
|
||||||
//
|
//
|
||||||
// Fade NOT applied in v1 (§9 #3 placeholder; defer until prod data).
|
// Fade algorithm + types are implemented (see fade.ts + renderFaded /
|
||||||
// Cross-runtime aligned with Plexum kbblock.
|
// 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 fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { fade, shouldDrop, type FadeParams } from './fade.js';
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
id: number; // HF backend DB primary key
|
id: number; // HF backend DB primary key
|
||||||
@@ -24,6 +28,7 @@ export interface Entry {
|
|||||||
insert_seq: number;
|
insert_seq: number;
|
||||||
added_at_turn: number;
|
added_at_turn: number;
|
||||||
last_refresh_at_turn: number;
|
last_refresh_at_turn: number;
|
||||||
|
seed: number; // PRNG seed for fade — generated at add() time
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockShape {
|
interface BlockShape {
|
||||||
@@ -92,6 +97,7 @@ export class Block {
|
|||||||
insert_seq: this.next_seq,
|
insert_seq: this.next_seq,
|
||||||
added_at_turn: at_turn,
|
added_at_turn: at_turn,
|
||||||
last_refresh_at_turn: at_turn,
|
last_refresh_at_turn: at_turn,
|
||||||
|
seed: Math.floor(Math.random() * 0x7FFFFFFF),
|
||||||
};
|
};
|
||||||
this.next_seq += 1;
|
this.next_seq += 1;
|
||||||
this.entries.push(e);
|
this.entries.push(e);
|
||||||
@@ -135,9 +141,23 @@ export class Block {
|
|||||||
/**
|
/**
|
||||||
* Render the <kb-block> inner body — flat sequence of
|
* Render the <kb-block> inner body — flat sequence of
|
||||||
* <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
|
* <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 {
|
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 '';
|
if (this.entries.length === 0) return '';
|
||||||
const ordered = [...this.entries].sort((a, b) => a.insert_seq - b.insert_seq);
|
const ordered = [...this.entries].sort((a, b) => a.insert_seq - b.insert_seq);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@@ -147,9 +167,31 @@ export class Block {
|
|||||||
if (e.source_topic) head += ` source=topic:${e.source_topic}`;
|
if (e.source_topic) head += ` source=topic:${e.source_topic}`;
|
||||||
head += '>';
|
head += '>';
|
||||||
lines.push(head);
|
lines.push(head);
|
||||||
lines.push(e.content);
|
lines.push(transform ? transform(e) : e.content);
|
||||||
lines.push('</kb-fact>');
|
lines.push('</kb-fact>');
|
||||||
});
|
});
|
||||||
return lines.join('\n') + '\n';
|
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