@hicaru/pi-rlm 0.3.6 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/bridge/handlers/emitting.ts +5 -23
  4. package/src/bridge/handlers/index.ts +1 -1
  5. package/src/bridge/handlers/llm-query.ts +84 -29
  6. package/src/bridge/handlers/rlm-query.ts +133 -33
  7. package/src/bridge/handlers/types.ts +12 -0
  8. package/src/commands/pins.ts +51 -0
  9. package/src/commands/rlm-config.ts +4 -88
  10. package/src/commands/rlm-llm.ts +59 -0
  11. package/src/commands/rlm-rlm.ts +58 -0
  12. package/src/commands/rlm.ts +2 -2
  13. package/src/config/defaults.ts +17 -0
  14. package/src/config/settings.ts +58 -5
  15. package/src/core/answer.ts +7 -10
  16. package/src/core/budget.ts +182 -0
  17. package/src/core/compaction.ts +46 -0
  18. package/src/core/engine.ts +185 -5
  19. package/src/core/iteration.ts +5 -0
  20. package/src/core/ledger.ts +343 -0
  21. package/src/core/memory.ts +589 -0
  22. package/src/core/model-registry.ts +88 -0
  23. package/src/core/types.ts +44 -3
  24. package/src/index.ts +107 -12
  25. package/src/mode/rlm-mode.ts +58 -10
  26. package/src/prompts/glossary.ts +147 -57
  27. package/src/prompts/native.ts +12 -7
  28. package/src/prompts/system.ts +22 -7
  29. package/src/prompts/user.ts +6 -3
  30. package/src/sandbox/interrupts.ts +24 -0
  31. package/src/sandbox/protocol.ts +69 -5
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +11 -6
  35. package/src/sandbox/py/scaffold.py +615 -0
  36. package/src/sandbox/py/worker.py +53 -506
  37. package/src/sandbox/sandbox.ts +21 -3
  38. package/src/text/repl-output.ts +15 -0
  39. package/src/tool/repl-render.ts +4 -10
  40. package/src/tool/repl-result.ts +54 -10
  41. package/src/tool/repl-tool.ts +50 -3
  42. package/src/tool/rlm-aggregator.ts +16 -3
  43. package/src/tool/rlm-details.ts +7 -0
  44. package/src/tool/rlm-events.ts +17 -1
  45. package/src/tool/rlm-tool.ts +25 -14
  46. package/src/tool/subcall-render.ts +14 -129
  47. package/src/tool/subcall-store.ts +11 -1
  48. package/src/ui/intro.ts +13 -4
  49. package/src/ui/modal/agent-modal.ts +104 -0
  50. package/src/ui/modal/modal-view.ts +132 -0
  51. package/src/ui/modal/timeline-store.ts +85 -0
  52. package/src/ui/model-picker/drilldown.ts +173 -0
  53. package/src/ui/model-picker/grouping.ts +81 -0
  54. package/src/ui/model-picker/levels.ts +63 -0
  55. package/src/ui/model-picker.ts +7 -197
  56. package/src/ui/panel/run-registry.ts +135 -0
  57. package/src/ui/panel/tree-panel.ts +46 -0
  58. package/src/ui/status.ts +26 -10
  59. package/src/ui/theme.ts +0 -4
  60. package/src/ui/tree/tree-model.ts +221 -0
  61. package/src/ui/tree/tree-rows.ts +73 -0
  62. package/src/ui/tree/tree-widget.ts +186 -0
  63. package/src/util/concurrency.ts +47 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * TaskLedger — the session blackboard (port of rlm_test v5 `ledger.py`).
3
+ *
4
+ * One instance per root run (engine) or per session (native repl tool), threaded down to every
5
+ * child through `SubcallHandlerDeps.ledger` / `RlmInput.ledger` — the same seam as
6
+ * getChildContext. It gives every agent global state visibility (the `[ledger]` block) and
7
+ * stops duplicate work three ways:
8
+ * - exact-hash coalesce: identical task → one runner, many waiters
9
+ * - ancestor-echo reject: a child restating an ancestor task gets a stub
10
+ * - near-dup coalesce: Jaccard ≥ 0.8, or ≥ 0.7 with the same path set
11
+ * plus the `rlmBudget` demotion counter (extra rlm_query → llm_query).
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+
16
+ const NOISE = /\b(no edits?|do not edit|analysis[- ]only|do not change)\.?/gi;
17
+ const TOK = /[a-z0-9_]{2,}/g;
18
+ const NEAR_JACCARD = 0.8;
19
+ const NEAR_JACCARD_SAME_PATHS = 0.7;
20
+ const ECHO_JACCARD = 0.8;
21
+ const INFLIGHT_LINES = 8;
22
+ const DONE_LINES = 6;
23
+ const PROMPT_PREVIEW = 80;
24
+ /** v5 wait() parity (audit H1): a coalescing twin never parks forever. Generous default —
25
+ * a twin can legitimately wait out a full child engine run. */
26
+ export const WAIT_TIMEOUT_MS = 600_000;
27
+
28
+ export type ClaimKind = "llm" | "rlm";
29
+ export type ClaimStatus = "pending" | "running" | "done" | "error";
30
+
31
+ /** All-readonly (project rule): transitions replace the map entry with a new frozen Claim. */
32
+ export interface Claim {
33
+ readonly key: string;
34
+ readonly kind: ClaimKind;
35
+ readonly prompt: string;
36
+ readonly paths: readonly string[];
37
+ readonly depth: number;
38
+ readonly status: ClaimStatus;
39
+ readonly result: string | null;
40
+ }
41
+
42
+ export interface ClaimRequest {
43
+ readonly kind: ClaimKind;
44
+ readonly prompt: string;
45
+ readonly paths: readonly string[];
46
+ readonly depth: number;
47
+ }
48
+
49
+ /** Result of `tryClaim` — a discriminated union, never an exception. */
50
+ export type ClaimDecision =
51
+ | { readonly type: "run"; readonly key: string }
52
+ | { readonly type: "coalesce"; readonly key: string; readonly done: boolean }
53
+ | { readonly type: "echo" };
54
+
55
+ export interface LedgerHits {
56
+ readonly exact: number;
57
+ readonly echo: number;
58
+ readonly near: number;
59
+ }
60
+
61
+ interface Waiter {
62
+ readonly resolve: (result: string) => void;
63
+ readonly reject: (err: Error) => void;
64
+ }
65
+
66
+ /** v5 `normalize_prompt`: lowercase, strip standing instructions noise, fold whitespace.
67
+ * v5 strips trailing periods too (`.strip(" .\t")`) — `trimEnd(" .")` mirrors that. */
68
+ export function normalizePrompt(prompt: string): string {
69
+ NOISE.lastIndex = 0;
70
+ return prompt
71
+ .toLowerCase()
72
+ .replace(NOISE, " ")
73
+ .replace(/[^a-z0-9_/.-]+/g, " ")
74
+ .replace(/\s+/g, " ")
75
+ .replace(/^[ .\t]+|[ .\t]+$/g, "");
76
+ }
77
+
78
+ export function tokenSet(text: string): ReadonlySet<string> {
79
+ return new Set(text.toLowerCase().match(TOK) ?? []);
80
+ }
81
+
82
+ export function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
83
+ if (a.size === 0 || b.size === 0) return 0;
84
+ let inter = 0;
85
+ for (const t of a) if (b.has(t)) inter++;
86
+ return inter / (a.size + b.size - inter);
87
+ }
88
+
89
+ export function pathSig(paths: readonly string[]): string {
90
+ const cleaned = new Set<string>();
91
+ for (const p of paths) {
92
+ if (p) cleaned.add(p.replace(/\\/g, "/").replace(/\/+$/, ""));
93
+ }
94
+ return [...cleaned].sort().join(",");
95
+ }
96
+
97
+ function sha256Hex(text: string): string {
98
+ return createHash("sha256").update(text).digest("hex");
99
+ }
100
+
101
+ /** Type guard (project rule: no `as` narrowing) — used by contextSig over unknown payloads. */
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === "object" && value !== null;
104
+ }
105
+
106
+ /** v5 `context_sig`: fingerprint a packed context so same-question/different-haystack never collide. */
107
+ export function contextSig(context: unknown): string {
108
+ if (context === undefined || context === null) return "";
109
+ if (typeof context === "string") return context === "" ? "" : sha256Hex(context).slice(0, 16);
110
+ if (Array.isArray(context)) {
111
+ const h = createHash("sha256");
112
+ for (const item of context) {
113
+ if (isRecord(item)) {
114
+ const path = typeof item.path === "string" ? item.path : "";
115
+ const body = typeof item.content === "string" ? item.content : typeof item.text === "string" ? item.text : "";
116
+ h.update(path);
117
+ h.update("\0");
118
+ h.update(body);
119
+ h.update("\n");
120
+ } else {
121
+ h.update(String(item));
122
+ h.update("\n");
123
+ }
124
+ }
125
+ return h.digest("hex").slice(0, 16);
126
+ }
127
+ return sha256Hex(String(context)).slice(0, 16);
128
+ }
129
+
130
+ export function taskKey(
131
+ kind: string,
132
+ prompt: string,
133
+ paths: readonly string[],
134
+ model: string,
135
+ ctx: string,
136
+ ): string {
137
+ const raw = `${kind}|${model}|${pathSig(paths)}|${normalizePrompt(prompt)}|${ctx}`;
138
+ return sha256Hex(raw).slice(0, 24);
139
+ }
140
+
141
+ export const ECHO_STUB: string = Object.freeze(
142
+ "[ledger: ancestor echo rejected. You are already doing this task. " +
143
+ "Use context / answers / the files already in scope. Do not rlm_query the parent task.]",
144
+ );
145
+
146
+
147
+ export class TaskLedger {
148
+ private readonly claims = new Map<string, Claim>();
149
+ private readonly waiters = new Map<string, Waiter[]>();
150
+ private readonly stack: string[] = [];
151
+ private readonly hitCounts = { exact: 0, echo: 0, near: 0 };
152
+ private rlmRuns = 0;
153
+
154
+ /** Engine marks the active run's root prompt — the ancestor chain for echo detection.
155
+ * v5 `begin_run` parity: the ONLY producer. `endRun` pops in the engine's finally, so a
156
+ * stack entry exists exactly while that engine is RUNNING — v5's `status in (pending,
157
+ * running)` filter is structural here. Native `repl()` cells never push ancestors: their
158
+ * spawns claim against an empty stack, so an originator can never echo against itself. */
159
+ beginRun(rootPrompt: string): void {
160
+ this.stack.push(normalizePrompt(rootPrompt));
161
+ }
162
+
163
+ endRun(): void {
164
+ this.stack.pop();
165
+ }
166
+
167
+ /** A child prompt echoing any ancestor (exact or ≥ 0.8 Jaccard) is rejected as a stub. */
168
+ detectEcho(prompt: string): boolean {
169
+ const np = normalizePrompt(prompt);
170
+ if (np === "") return false;
171
+ const toks = tokenSet(np);
172
+ for (const anc of this.stack) {
173
+ if (anc === np) return true;
174
+ if (anc !== "" && jaccard(toks, tokenSet(anc)) >= ECHO_JACCARD) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /** Near-duplicate over inflight + done claims (Jaccard, or lower bar with identical paths). */
180
+ findNear(prompt: string, paths: readonly string[]): Claim | undefined {
181
+ const toks = tokenSet(normalizePrompt(prompt));
182
+ const ps = pathSig(paths);
183
+ for (const c of this.claims.values()) {
184
+ if (c.status === "error") continue;
185
+ const score = jaccard(toks, tokenSet(normalizePrompt(c.prompt)));
186
+ if (score >= NEAR_JACCARD) return c;
187
+ if (score >= NEAR_JACCARD_SAME_PATHS && pathSig(c.paths) === ps) return c;
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ /** Exact-key lookup among live (non-error) claims. */
193
+ lookup(key: string): Claim | undefined {
194
+ const c = this.claims.get(key);
195
+ return c !== undefined && c.status !== "error" ? c : undefined;
196
+ }
197
+
198
+ /** Atomically decide: run it, coalesce onto an existing runner, or reject as echo. */
199
+ tryClaim(req: ClaimRequest, key: string): ClaimDecision {
200
+ if (this.detectEcho(req.prompt)) {
201
+ this.hitCounts.echo++;
202
+ return { type: "echo" };
203
+ }
204
+ const exact = this.lookup(key);
205
+ if (exact !== undefined) {
206
+ this.hitCounts.exact++;
207
+ return { type: "coalesce", key, done: exact.status === "done" };
208
+ }
209
+ const near = this.findNear(req.prompt, req.paths);
210
+ if (near !== undefined) {
211
+ this.hitCounts.near++;
212
+ return { type: "coalesce", key: near.key, done: near.status === "done" };
213
+ }
214
+ this.claims.set(key, Object.freeze({
215
+ key,
216
+ kind: req.kind,
217
+ prompt: req.prompt,
218
+ paths: Object.freeze([...req.paths]),
219
+ depth: req.depth,
220
+ status: "pending",
221
+ result: null,
222
+ }));
223
+ if (req.kind === "rlm") this.rlmRuns++;
224
+ return { type: "run", key };
225
+ }
226
+
227
+ /** v5 `begin_run` parity: the runner actually started — inflight lines say "running". */
228
+ markRunning(key: string): void {
229
+ const claim = this.claims.get(key);
230
+ if (claim === undefined || claim.status !== "pending") return;
231
+ this.claims.set(key, Object.freeze({ ...claim, status: "running" }));
232
+ }
233
+
234
+ /** Park a waiter on a claim (coalescing twin). Bounded by `timeoutMs` (audit H1): a dead
235
+ * runner must park nobody forever. Rejects immediately on an errored claim. */
236
+ waitFor(key: string, timeoutMs: number = WAIT_TIMEOUT_MS): Promise<string> {
237
+ const claim = this.claims.get(key);
238
+ if (claim !== undefined && claim.status === "done" && claim.result !== null) {
239
+ return Promise.resolve(claim.result);
240
+ }
241
+ if (claim !== undefined && claim.status === "error") {
242
+ return Promise.reject(new Error(`ledger: claim ${key} failed while waiting`));
243
+ }
244
+ return new Promise<string>((resolve, reject) => {
245
+ const timer = setTimeout(() => {
246
+ const waiters = this.waiters.get(key);
247
+ if (waiters === undefined) return;
248
+ this.waiters.set(key, waiters.filter((w) => w.resolve !== resolve && w.reject !== reject));
249
+ reject(new Error(`[ledger: timeout waiting for ${key}]`));
250
+ }, timeoutMs);
251
+ const wrapped: Waiter = {
252
+ resolve: (result) => {
253
+ clearTimeout(timer);
254
+ resolve(result);
255
+ },
256
+ reject: (err) => {
257
+ clearTimeout(timer);
258
+ reject(err);
259
+ },
260
+ };
261
+ const list = this.waiters.get(key) ?? [];
262
+ list.push(wrapped);
263
+ this.waiters.set(key, list);
264
+ });
265
+ }
266
+
267
+ /** Runner finished — store the result and wake every coalescing waiter. */
268
+ finish(key: string, result: string): void {
269
+ const claim = this.claims.get(key);
270
+ if (claim === undefined) return;
271
+ this.claims.set(key, Object.freeze({ ...claim, status: "done", result }));
272
+ this.wake(key, result, undefined);
273
+ }
274
+
275
+ /** Runner failed — waiters get the error; the key becomes claimable again. */
276
+ fail(key: string, error: string): void {
277
+ const claim = this.claims.get(key);
278
+ if (claim === undefined) return;
279
+ this.claims.set(key, Object.freeze({ ...claim, status: "error" }));
280
+ this.wake(key, undefined, new Error(error));
281
+ }
282
+
283
+ private wake(key: string, result: string | undefined, err: Error | undefined): void {
284
+ const list = this.waiters.get(key);
285
+ if (list === undefined) return;
286
+ this.waiters.delete(key);
287
+ for (const w of list) {
288
+ if (err !== undefined) w.reject(err);
289
+ else if (result !== undefined) w.resolve(result);
290
+ }
291
+ }
292
+
293
+ /** Real rlm runs started (claimed) — drives the `rlmBudget` demotion. */
294
+ rlmCount(): number {
295
+ return this.rlmRuns;
296
+ }
297
+
298
+ hits(): LedgerHits {
299
+ return Object.freeze({ ...this.hitCounts });
300
+ }
301
+
302
+ /** Compact table for the sandbox's `list_claims()` REPL call. Echo hits are appended —
303
+ * a suppressed spawn must be visible from inside the session (audit BUG-1). */
304
+ listClaims(): string {
305
+ const echoNote = this.hitCounts.echo > 0 ? ` (echo_rejected=${this.hitCounts.echo})` : "";
306
+ if (this.claims.size === 0) return `ledger: no claims${echoNote}`;
307
+ const lines: string[] = new Array<string>(this.claims.size + 1);
308
+ lines[0] = `ledger claims:${echoNote}`;
309
+ let n = 1;
310
+ for (const c of this.claims.values()) {
311
+ lines[n++] = ` ${c.key.slice(0, 8)} ${c.kind} ${c.status} depth=${c.depth} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
312
+ }
313
+ return lines.slice(0, n).join("\n");
314
+ }
315
+
316
+ /** v5 verbatim `[ledger]` block — empty when nothing is claimed and the stack is shallow. */
317
+ injectBlock(): string {
318
+ const inflight: string[] = [];
319
+ const done: string[] = [];
320
+ for (const c of this.claims.values()) {
321
+ const line = ` ${c.key.slice(0, 8)} ${c.kind} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
322
+ if (c.status === "pending" || c.status === "running") inflight.push(line);
323
+ else if (c.status === "done") done.push(line);
324
+ }
325
+ const stackN = this.stack.length;
326
+ if (inflight.length === 0 && done.length === 0 && stackN <= 1) return "";
327
+ const lines: string[] = [
328
+ "[ledger]",
329
+ ` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}` +
330
+ (this.hitCounts.echo > 0 ? ` echo_rejected=${this.hitCounts.echo}` : ""),
331
+ " rlm_query only for a disjoint goal. ancestor echo is rejected.",
332
+ ];
333
+ if (inflight.length > 0) {
334
+ lines.push(" inflight:");
335
+ lines.push(...inflight.slice(0, INFLIGHT_LINES));
336
+ }
337
+ if (done.length > 0) {
338
+ lines.push(" done:");
339
+ lines.push(...done.slice(0, DONE_LINES));
340
+ }
341
+ return lines.join("\n");
342
+ }
343
+ }