@hicaru/pi-rlm 0.3.16 → 0.3.17

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 (86) hide show
  1. package/README.md +0 -4
  2. package/README.ru.md +56 -66
  3. package/README.zh-CN.md +61 -65
  4. package/package.json +5 -5
  5. package/src/bridge/add-context.ts +1 -1
  6. package/src/bridge/handlers/await.ts +13 -22
  7. package/src/bridge/handlers/completion.ts +27 -5
  8. package/src/bridge/handlers/emitting.ts +2 -2
  9. package/src/bridge/handlers/llm-query.ts +46 -68
  10. package/src/bridge/handlers/rlm-query.ts +14 -84
  11. package/src/bridge/handlers/task-registry.ts +22 -17
  12. package/src/bridge/handlers/types.ts +8 -6
  13. package/src/bridge/model.ts +6 -3
  14. package/src/commands/rlm-llm.ts +1 -10
  15. package/src/commands/rlm-rlm.ts +1 -8
  16. package/src/config/defaults.ts +28 -12
  17. package/src/config/settings.ts +41 -31
  18. package/src/config/skillstate.ts +465 -0
  19. package/src/context/md-cache.ts +1 -1
  20. package/src/context/merge.ts +1 -1
  21. package/src/context/namespace.ts +2 -2
  22. package/src/context/refresh.ts +1 -1
  23. package/src/context/source-dir.ts +21 -11
  24. package/src/context/source-doc.ts +1 -1
  25. package/src/context/source-git.ts +3 -15
  26. package/src/context/source-text.ts +1 -1
  27. package/src/context/walk.ts +6 -14
  28. package/src/core/budget.ts +107 -21
  29. package/src/core/compaction.ts +44 -1
  30. package/src/core/engine.ts +141 -84
  31. package/src/core/iteration.ts +1 -1
  32. package/src/core/ledger.ts +10 -13
  33. package/src/core/limits.ts +1 -1
  34. package/src/core/model-registry.ts +1 -1
  35. package/src/core/resource-limits.ts +1 -1
  36. package/src/core/root-context.ts +126 -0
  37. package/src/core/root-digest.ts +213 -0
  38. package/src/core/root-state.ts +240 -0
  39. package/src/core/run-state.ts +577 -0
  40. package/src/core/types.ts +51 -12
  41. package/src/index.ts +167 -36
  42. package/src/mode/llm-model.ts +13 -1
  43. package/src/mode/native-guards.ts +0 -6
  44. package/src/mode/rlm-mode.ts +34 -11
  45. package/src/mode/subagent.ts +5 -5
  46. package/src/prompts/glossary.ts +41 -25
  47. package/src/prompts/native.ts +1 -3
  48. package/src/prompts/system.ts +12 -4
  49. package/src/sandbox/context-file.ts +1 -1
  50. package/src/sandbox/interrupts.ts +25 -31
  51. package/src/sandbox/protocol.ts +14 -20
  52. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  53. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  54. package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
  55. package/src/sandbox/py/guards.py +1 -1
  56. package/src/sandbox/py/scaffold.py +24 -31
  57. package/src/sandbox/py/worker.py +3 -1
  58. package/src/sandbox/sandbox-manager.ts +2 -2
  59. package/src/sandbox/sandbox.ts +21 -4
  60. package/src/text/agent-text.ts +58 -0
  61. package/src/text/parsing.ts +35 -3
  62. package/src/text/preview.ts +3 -0
  63. package/src/text/repl-output.ts +1 -1
  64. package/src/tool/background-tasks.ts +1 -1
  65. package/src/tool/repl-render.ts +1 -1
  66. package/src/tool/repl-result.ts +1 -1
  67. package/src/tool/repl-tool.ts +50 -26
  68. package/src/tool/rlm-tool.ts +4 -5
  69. package/src/tool/subcall-render.ts +1 -1
  70. package/src/tool/subcall-store.ts +2 -2
  71. package/src/tool/tool-utils.ts +5 -5
  72. package/src/ui/intro.ts +1 -1
  73. package/src/ui/modal/timeline-store.ts +1 -1
  74. package/src/ui/model-picker/drilldown.ts +1 -1
  75. package/src/ui/model-picker/levels.ts +1 -1
  76. package/src/ui/panel/run-registry.ts +1 -1
  77. package/src/ui/tree/tree-rows.ts +1 -1
  78. package/src/ui/tree/tree-widget.ts +1 -1
  79. package/src/util/bm25.ts +97 -0
  80. package/src/util/concurrency.ts +1 -1
  81. package/src/util/errors.ts +1 -1
  82. package/src/util/retry.ts +22 -7
  83. package/src/util/state-merge.ts +34 -0
  84. package/src/util/throttle.ts +1 -1
  85. package/src/util/type-guards.ts +6 -0
  86. package/src/core/memory.ts +0 -589
@@ -27,7 +27,7 @@ export function promptCapTokensK(maxPromptChars: number): number {
27
27
  * well, small ones guess keywords badly, and the first decomposition disproportionately decides
28
28
  * the outcome (§5, Fig. 4a). These cost no tokens and no sub-calls.
29
29
  */
30
- export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
30
+ const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
31
31
  "- `search(query: str, k=10, path_glob=None)`: BM25 ranking over `context`. Returns",
32
32
  " [{path, line, score, snippet, text}] — POINTERS, not bodies (`text` aliases `snippet`).",
33
33
  " **Start here.** Free: no sub-LLM call. Use before guessing filenames.",
@@ -42,15 +42,44 @@ export const RETRIEVAL_GLOSSARY_LINES: readonly string[] = Object.freeze([
42
42
  /** v5 delegation doctrine (audit C5): children have NO retrieval tools — their world is the
43
43
  * sliced `context` they were handed. This REPLACES the retrieval lines in child prompts so
44
44
  * the prompt and the runtime sandbox agree (a child taught to `search` burns turns on NameError). */
45
- export const DELEGATION_SURFACE_LINES: readonly string[] = Object.freeze([
45
+ const DELEGATION_SURFACE_LINES: readonly string[] = Object.freeze([
46
46
  "- **No `search` / `grep_context` / `outline` / `add_context` in this REPL** (delegation",
47
47
  " surface, v5 doctrine): your task arrived WITH its world in `context`. Explore it with",
48
48
  " Python (list comprehensions, string matching, slicing) and delegate slices to sub-LLMs —",
49
49
  " never re-ask the parent for retrieval.",
50
50
  ]);
51
51
 
52
+ /** Workstream E: the one new sandbox function (prime-agent rule — surface grows by one). */
53
+ const SKILL_SEARCH_GLOSSARY_LINES: readonly string[] = Object.freeze([
54
+ "- `skill_search(query, k=8) -> [{id, text, tags, score}]`: BM25 over distilled project facts",
55
+ " from PRIOR sessions (SkillState). Free: no sub-LLM call. Use when a config/gotcha/symbol",
56
+ " smells like something already learned — do not re-discover it.",
57
+ ]);
58
+
59
+ /** Single source of wording for the injected SkillState block (headless + native, Workstream C).
60
+ * Takes the dynamic body as an argument — the glossary itself stays static-only. */
61
+ /** One wording source for the skill_search recall hint (Ξ block + root Σ snapshot). */
62
+ export const SKILL_RECALL_LINE =
63
+ "Recall more anytime inside repl: `skill_search(query, k=8)` → [{id, text, tags, score}].";
64
+
65
+ export function skillStateLines(noteCount: number, body: string): string {
66
+ return [
67
+ `[Project facts — SkillState, ${noteCount} note${noteCount === 1 ? "" : "s"}, distilled from prior sessions]`,
68
+ body,
69
+ SKILL_RECALL_LINE,
70
+ ].join("\n");
71
+ }
72
+
73
+ /** Root Σ WS-2 digest wording — the single source for the header and section labels. */
74
+ export const ROOT_DIGEST_HEADER =
75
+ "[Root digest — deterministic structural compaction (no model call). Older turns are " +
76
+ "superseded by this digest plus the verbatim tail that follows; fresh tool results " +
77
+ "outrank the digest when they disagree.]";
78
+ export const ROOT_DIGEST_SECTIONS: Readonly<Record<"task" | "findings" | "state" | "next" | "facts", string>> =
79
+ Object.freeze({ task: "Task", findings: "Findings", state: "State", next: "Next", facts: "Project facts" });
80
+
52
81
  /** One-line delegation helpers — orchestrating must be cheaper than solving. */
53
- export const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
82
+ const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
54
83
  "- `map_files(files, prompt) -> Task`: always spawn. `await_task(t)` → dict[path, answer].",
55
84
  " Accepts context entries or paths; packs into cap-sized batches; splits oversized files.",
56
85
  " **Default way to read many files** — fire independent `map_files` Tasks, free work, then await.",
@@ -59,14 +88,14 @@ export const DELEGATION_GLOSSARY_LINES: readonly string[] = Object.freeze([
59
88
  ]);
60
89
 
61
90
  /** Shared glossary entry for the chunked-query helper (headless + native). */
62
- export const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
91
+ const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
63
92
  "- `llm_query_chunked(text: str, prompt: str) -> Task`: always spawn. `await_task(t)` → list[str]",
64
93
  " (one answer per chunk, order preserved). Auto-splits text to the sub-LLM prompt cap.",
65
94
  " Use for ANY text too large for a single `llm_query` — open()ed files, oversized sub-results.",
66
95
  ]);
67
96
 
68
97
  /** Non-blocking fan-out: spawn now, collect later (headless glossary). */
69
- export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
98
+ const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
70
99
  "- **ALWAYS SPAWN (Task + ↗bg):** `llm_query` / `llm_batch` / `rlm_query` / `rlm_batch` /",
71
100
  " `map_files` / `llm_query_chunked`. Never treat the return as the answer.",
72
101
  " Collect with `await_task(t)`, `await_task([t1,t2,…])`, or `await_task()` (every still-running Task).",
@@ -81,7 +110,7 @@ export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
81
110
  ]);
82
111
 
83
112
  /** v5 (audit C5): the spawn worked example, retrieval flavor — root surface only. */
84
- export const SPAWN_EXAMPLE_RETRIEVAL: readonly string[] = Object.freeze([
113
+ const SPAWN_EXAMPLE_RETRIEVAL: readonly string[] = Object.freeze([
85
114
  "",
86
115
  " ```python",
87
116
  " # Multi-area study: one rlm_batch (parallel workers), free locate, then await",
@@ -96,7 +125,7 @@ export const SPAWN_EXAMPLE_RETRIEVAL: readonly string[] = Object.freeze([
96
125
  ]);
97
126
 
98
127
  /** v5 (audit C5): the spawn worked example, delegation flavor — no retrieval, slice instead. */
99
- export const SPAWN_EXAMPLE_DELEGATION: readonly string[] = Object.freeze([
128
+ const SPAWN_EXAMPLE_DELEGATION: readonly string[] = Object.freeze([
100
129
  "",
101
130
  " ```python",
102
131
  " # Multi-area study: one rlm_batch (parallel workers), slice your world while they run",
@@ -109,7 +138,7 @@ export const SPAWN_EXAMPLE_DELEGATION: readonly string[] = Object.freeze([
109
138
  " # One-shot extracts: map_files / llm_batch also return Task → await_task",
110
139
  " ```",
111
140
  ]);
112
- export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
141
+ const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
113
142
  "",
114
143
  " **What a child sees:** it inherits YOUR `context` — every file you have loaded, including",
115
144
  " sources under `ctx/<id>/…` — and runs `search` / `grep_context` / `outline` / `map_files`",
@@ -125,7 +154,7 @@ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
125
154
 
126
155
  /** v5 recursion section, delegation variant (audit C5): describes what a delegation child
127
156
  * receives — the narrowed pack as text, no retrieval of its own. */
128
- export const RECURSION_DELEGATION_LINES: readonly string[] = Object.freeze([
157
+ const RECURSION_DELEGATION_LINES: readonly string[] = Object.freeze([
129
158
  "",
130
159
  " **What a child sees:** it inherits YOUR `context` (narrowed by `paths=` when given) and works",
131
160
  " on it as text — it has NO retrieval tools, so put what matters in your prompt and `paths`,",
@@ -140,14 +169,14 @@ export const RECURSION_DELEGATION_LINES: readonly string[] = Object.freeze([
140
169
  * Sub-RLM orientation. Emitted only at depth > 0, where `context` is the parent's world rather
141
170
  * than a repository the run packed for itself.
142
171
  */
143
- export const CHILD_CONTEXT_LINES: readonly string[] = Object.freeze([
172
+ const CHILD_CONTEXT_LINES: readonly string[] = Object.freeze([
144
173
  " You are a sub-RLM. This `context` is your parent's world — every file it has loaded (cwd",
145
174
  " paths un-prefixed; external sources under `ctx/<id>/…`). Answer only the question above;",
146
175
  " your REPL and anything you load die with you, and only your final answer string returns.",
147
176
  ]);
148
177
 
149
178
  /** Why a file the user mentioned may be missing from `context`. */
150
- export const CONTEXT_EXCLUSION_NOTE = [
179
+ const CONTEXT_EXCLUSION_NOTE = [
151
180
  " NOTE: `context` holds only the files you have loaded (starts empty; cwd seeds on first use).",
152
181
  " Gitignored files and files larger than 1MB of plain text are skipped. Binary documents",
153
182
  " (PDF, DOCX, XLSX, PPTX, CSV, …) ARE included — converted to Markdown on the way in.",
@@ -231,9 +260,6 @@ export function envTips(delegation = false): string {
231
260
  ].join("\n");
232
261
  }
233
262
 
234
- /** Root-surface doctrine (back-compat alias of `envTips(false)`). */
235
- export const ENV_TIPS = envTips(false);
236
-
237
263
  /** Native-mode variant of the doctrine — same rules, sized for the native prompt budget. */
238
264
  export const ENV_TIPS_CONDENSED = [
239
265
  "### Decomposition doctrine",
@@ -314,6 +340,7 @@ export function replGlossary(
314
340
  " await_task → ordered list[str]. NEVER pass bare file paths as if the worker can open them.",
315
341
  ...CHUNKED_GLOSSARY_LINES,
316
342
  ...SPAWN_GLOSSARY_LINES,
343
+ ...SKILL_SEARCH_GLOSSARY_LINES,
317
344
  ...(delegation ? SPAWN_EXAMPLE_DELEGATION : SPAWN_EXAMPLE_RETRIEVAL),
318
345
  ...DELEGATION_GLOSSARY_LINES,
319
346
  );
@@ -357,17 +384,6 @@ export function replGlossary(
357
384
  " verified result in `answers` — see the decomposition doctrine below.",
358
385
  "- `SHOW_VARS() -> str`: list every variable currently in the REPL (Task handles show as `<Task …>`).",
359
386
  "- `list_tasks()`: every Task this REPL created — [{kind, label, done, var}].",
360
- ...(delegation
361
- ? [
362
- "- `memory.query(q) -> str`: durable notes under `.rlm/` that survive across sessions.",
363
- " **READ-ONLY here** — `memory.add` is root-only. Query before re-studying a known area;",
364
- " your own final answer is recorded as an episode automatically.",
365
- ]
366
- : [
367
- "- `memory.query(q) -> str` / `memory.add(text, paths=…, tags=…)`: durable notes under `.rlm/`",
368
- " that survive across sessions. Query before re-studying a known area; add concise findings",
369
- " (facts, locations, decisions) — never secrets or API keys (notes persist on disk).",
370
- ]),
371
387
  "- `list_claims()`: the live `[ledger]` table of inflight/done agent work.",
372
388
  '- `answer`: a dict initialized to {"content": "", "ready": False}. To submit your final answer,',
373
389
  ' set `answer["content"]` to the answer text and `answer["ready"] = True`.',
@@ -45,9 +45,7 @@ function nativeReplGlossary(): string {
45
45
  "### Memo / finalize",
46
46
  "- `answers` / `plan` — persistent dicts for **collected** results. Task handles are REPL vars (`t`), not `answers` keys.",
47
47
  "- `add_context(source) -> dict` — append external dir/file/doc/git under `ctx/<id>/…` (metadata only).",
48
- "- `memory.query(q)` / `memory.add(text, paths=…, tags=…)` durable notes under `.rlm/` that survive",
49
- " sessions. Query before re-studying a known area; add concise findings — never secrets or API",
50
- " keys (notes persist on disk). `list_claims()` — the live `[ledger]` table of agent work.",
48
+ "- `list_claims()` the live `[ledger]` table of agent work.",
51
49
  "- `SHOW_VARS()` — list REPL vars (Tasks as `<Task …>`). `list_tasks()` finds Task handles. `answer[\"ready\"]=True` only for headless finalize (native: write a normal message).",
52
50
  "",
53
51
  ENV_TIPS_CONDENSED,
@@ -17,14 +17,17 @@ import {
17
17
 
18
18
  export { contextKindOf, type ContextKind } from "./glossary.ts";
19
19
 
20
- export interface PromptMeta {
20
+ interface PromptMeta {
21
21
  readonly contextType: string;
22
22
  readonly contextChars: number;
23
23
  readonly contextStats?: ContextSizeStats;
24
24
  readonly rootPrompt?: string;
25
+ /** SKILL.state Ξ (Workstream C): verified project facts from prior sessions. Per-run
26
+ * argument only — never baked into any module-load snapshot. */
27
+ readonly skillBlock?: string;
25
28
  }
26
29
 
27
- export interface SystemPromptOptions {
30
+ interface SystemPromptOptions {
28
31
  readonly orchestrator?: boolean;
29
32
  readonly recursion?: boolean;
30
33
  readonly maxPromptChars?: number;
@@ -88,7 +91,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
88
91
  parts.push(
89
92
  "",
90
93
  "**REPL API (ONLY these):** llm_query / llm_batch / llm_query_chunked / map_files /",
91
- "llm_map_reduce / rlm_query / rlm_batch / spawn / await_task / list_tasks / memory.* /",
94
+ "llm_map_reduce / rlm_query / rlm_batch / spawn / await_task / list_tasks /",
92
95
  "list_claims. There is no search/grep_context/outline here — your task arrived WITH its",
93
96
  "world in `context`; slice it into llm prompts. rlm_query only for a disjoint path set.",
94
97
  );
@@ -110,12 +113,17 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
110
113
  );
111
114
  if (opts.orchestrator ?? true) {
112
115
  // Two counterweights, both required (paper App. B): the addendum bounds OVER-recursion
113
- // (batching/cost), ENV_TIPS bounds UNDER-recursion (solving it yourself).
116
+ // (batching/cost), envTips() bounds UNDER-recursion (solving it yourself).
114
117
  parts.push("", orchestratorAddendum(maxPromptChars, opts.delegation ?? false), "", envTips(opts.delegation ?? false));
115
118
  }
116
119
  if (kind === "files") {
117
120
  parts.push("", LARGE_FILE_RULE_LINES.join("\n"));
118
121
  }
122
+ // Ξ (Workstream C): lands BEFORE the metadata line so the task header stays last — the model
123
+ // reads it freshest. Verified-fact grounding precedes the fresh task statement.
124
+ if (meta.skillBlock !== undefined && meta.skillBlock !== "") {
125
+ parts.push("", meta.skillBlock);
126
+ }
119
127
  parts.push("", buildMetadataLine(meta, maxPromptChars));
120
128
  return parts.join("\n");
121
129
  }
@@ -23,7 +23,7 @@ import { join } from "node:path";
23
23
  const SERIALIZE_CHUNK = 64;
24
24
 
25
25
  /** A temp file on disk holding a serialized context payload. */
26
- export interface ContextTempFile {
26
+ interface ContextTempFile {
27
27
  readonly path: string;
28
28
  /** True when the file holds JSON; false when the payload was a raw string. */
29
29
  readonly json: boolean;
@@ -53,12 +53,8 @@ export interface SubLlmHandlers {
53
53
  addContext(source: string, depth: number): Promise<AddContextResult>;
54
54
  /** v5: the `[ledger]` claims table for the sandbox's `list_claims()` REPL call. */
55
55
  ledgerClaims(): Promise<string>;
56
- /** v5: durable memory surface for the sandbox's `memory.query/add/stats` object. */
57
- memoryOp(
58
- op: "query" | "add" | "stats",
59
- args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
60
- depth: number,
61
- ): Promise<string>;
56
+ /** SKILL.state (Workstream E): BM25 over the session SkillState store; JSON hits reply. */
57
+ skillSearch(query: string, k: number, depth: number): Promise<unknown>;
62
58
  }
63
59
 
64
60
  function toStringArray(value: unknown): readonly string[] | undefined {
@@ -89,23 +85,23 @@ export const REJECT: SubLlmHandlers = Object.freeze({
89
85
  throw new Error("add_context not configured");
90
86
  },
91
87
  ledgerClaims: async () => UNCONFIGURED,
92
- memoryOp: async () => UNCONFIGURED,
88
+ skillSearch: async () => UNCONFIGURED,
93
89
  });
94
90
 
95
91
  export interface ReplyBody {
96
- response?: string;
97
- responses?: string[];
98
- path?: string;
99
- json?: boolean;
100
- files?: number;
101
- chars?: number;
102
- source_id?: string;
103
- path_prefix?: string;
104
- already_loaded?: boolean;
105
- documents?: number;
106
- converted?: number;
107
- skipped?: readonly { readonly path: string; readonly reason: string }[];
108
- error?: string;
92
+ readonly response?: string;
93
+ readonly responses?: readonly string[];
94
+ readonly path?: string;
95
+ readonly json?: boolean;
96
+ readonly files?: number;
97
+ readonly chars?: number;
98
+ readonly source_id?: string;
99
+ readonly path_prefix?: string;
100
+ readonly already_loaded?: boolean;
101
+ readonly documents?: number;
102
+ readonly converted?: number;
103
+ readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
104
+ readonly error?: string;
109
105
  }
110
106
 
111
107
  const RLM_PATH_TYPES = new Set(["rlm_query", "rlm_batch"]);
@@ -171,10 +167,10 @@ async function resolveSingle(
171
167
  }
172
168
  return { response: collected.result ?? "" };
173
169
  }
174
- return { response: String(collected ?? "") };
170
+ return { response: typeof collected === "string" ? collected : "" };
175
171
  }
176
172
  // Unexpected shape — surface as text rather than crash the worker.
177
- return { response: String(raw ?? "") };
173
+ return { response: typeof raw === "string" ? raw : "" };
178
174
  }
179
175
 
180
176
  /**
@@ -304,7 +300,7 @@ export async function serviceInterrupt(
304
300
  });
305
301
  return;
306
302
  }
307
- reply(msg.rid, { response: String(result ?? "") });
303
+ reply(msg.rid, { response: typeof result === "string" ? result : "" });
308
304
  return;
309
305
  }
310
306
  case "finish": {
@@ -352,13 +348,11 @@ export async function serviceInterrupt(
352
348
  reply(msg.rid, { response: table });
353
349
  return;
354
350
  }
355
- case "memory": {
356
- const out = await h.memoryOp(
357
- msg.op,
358
- { query: msg.query, k: msg.k, content: msg.content, paths: msg.paths, tags: msg.tags },
359
- d,
360
- );
361
- reply(msg.rid, { response: out });
351
+ case "skill_search": {
352
+ // SKILL.state (Workstream E): the store serializes its own hits; plain strings
353
+ // (tests/stubs) pass through. Errors are replied, never thrown.
354
+ const raw = await h.skillSearch(msg.query ?? "", msg.k ?? 8, d);
355
+ reply(msg.rid, { response: typeof raw === "string" ? raw : JSON.stringify(raw) });
362
356
  return;
363
357
  }
364
358
  default: {
@@ -369,6 +363,6 @@ export async function serviceInterrupt(
369
363
  }
370
364
  }
371
365
  } catch (err: unknown) {
372
- reply(msg.rid, { error: errorMessage(err) });
366
+ reply(msg.rid, { error: formatError(errorMessage(err)) });
373
367
  }
374
368
  }
@@ -21,7 +21,7 @@ export type WorkerRequest =
21
21
  | { readonly id: string; readonly type: "shutdown" };
22
22
 
23
23
  /** Reply the parent sends to satisfy a sub-LLM interrupt. */
24
- export interface LlmReply {
24
+ interface LlmReply {
25
25
  readonly type: "llm_reply";
26
26
  readonly rid: string;
27
27
  readonly response?: string;
@@ -45,7 +45,7 @@ export interface LlmReply {
45
45
  }
46
46
 
47
47
  /** Keep-alive while the host is working and has nothing else to write. */
48
- export interface Heartbeat {
48
+ interface Heartbeat {
49
49
  readonly type: "heartbeat";
50
50
  }
51
51
 
@@ -71,8 +71,8 @@ export interface WorkerResponse {
71
71
  readonly index?: number;
72
72
  }
73
73
 
74
- /** Canonical interrupt kinds (api_v5 + v5 ledger + memory). */
75
- export type InterruptKind =
74
+ /** Canonical interrupt kinds (api_v5 + v5 ledger + SKILL.state). */
75
+ type InterruptKind =
76
76
  | "llm_query"
77
77
  | "rlm_query"
78
78
  | "llm_batch"
@@ -81,7 +81,7 @@ export type InterruptKind =
81
81
  | "finish"
82
82
  | "add_context"
83
83
  | "ledger_claims"
84
- | "memory";
84
+ | "skill_search";
85
85
 
86
86
  interface InterruptBase {
87
87
  readonly rid: string;
@@ -118,25 +118,21 @@ interface FinishInterrupt extends InterruptBase {
118
118
  readonly summary?: string;
119
119
  }
120
120
 
121
- export interface AddContextInterrupt extends InterruptBase {
121
+ interface AddContextInterrupt extends InterruptBase {
122
122
  readonly type: "add_context";
123
123
  readonly source?: string;
124
124
  }
125
125
 
126
126
  /** v5: sandbox asks the host for the TaskLedger claims table (`list_claims()`). */
127
- export interface LedgerClaimsInterrupt extends InterruptBase {
127
+ interface LedgerClaimsInterrupt extends InterruptBase {
128
128
  readonly type: "ledger_claims";
129
129
  }
130
130
 
131
- /** v5: sandbox reaches the durable MemoryStore (`memory.query/add/stats`). */
132
- export interface MemoryInterrupt extends InterruptBase {
133
- readonly type: "memory";
134
- readonly op: "query" | "add" | "stats";
131
+ /** SKILL.state (Workstream E): BM25 recall over the session SkillState store. */
132
+ interface SkillSearchInterrupt extends InterruptBase {
133
+ readonly type: "skill_search";
135
134
  readonly query?: string;
136
135
  readonly k?: number;
137
- readonly content?: string;
138
- readonly paths?: readonly string[];
139
- readonly tags?: readonly string[];
140
136
  }
141
137
 
142
138
  /** A mid-exec sub-LLM/tool request from the worker. */
@@ -147,11 +143,11 @@ export type WorkerInterrupt =
147
143
  | FinishInterrupt
148
144
  | AddContextInterrupt
149
145
  | LedgerClaimsInterrupt
150
- | MemoryInterrupt;
146
+ | SkillSearchInterrupt;
151
147
 
152
148
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
153
149
 
154
- export const INTERRUPT_KINDS = Object.freeze(
150
+ const INTERRUPT_KINDS = Object.freeze(
155
151
  new Set<InterruptKind>([
156
152
  "llm_query",
157
153
  "rlm_query",
@@ -161,13 +157,11 @@ export const INTERRUPT_KINDS = Object.freeze(
161
157
  "finish",
162
158
  "add_context",
163
159
  "ledger_claims",
164
- "memory",
160
+ "skill_search",
165
161
  ]),
166
162
  );
167
163
 
168
- function isRecord(value: unknown): value is Record<string, unknown> {
169
- return typeof value === "object" && value !== null;
170
- }
164
+ import { isRecord } from "../util/type-guards.ts";
171
165
 
172
166
  function isWorkerResponse(value: unknown): value is WorkerResponse {
173
167
  return isRecord(value) && typeof value.id === "string" && typeof value.ok === "boolean";
@@ -96,7 +96,7 @@ RESERVED = frozenset(
96
96
  # Helpers (not the old *_query_batched API)
97
97
  "llm_query_chunked", "map_files", "llm_map_reduce",
98
98
  "search", "grep_context", "outline",
99
- "add_context", "list_claims", "memory",
99
+ "add_context", "list_claims", "skill_search",
100
100
  "SHOW_VARS", "answer", "context",
101
101
  }
102
102
  )
@@ -10,6 +10,7 @@ without any packaging step (same mechanism as guards.py / retrieval.py / tasks.p
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
+ import json
13
14
  import os
14
15
  from typing import Any
15
16
 
@@ -37,28 +38,6 @@ from tasks import (
37
38
  )
38
39
 
39
40
 
40
- class _MemoryApi:
41
- """v5 durable memory surface — thin shims over the `memory` interrupt.
42
-
43
- memory.query(q, k=8) → retrieved notes; memory.add(text, paths=…, tags=…) → status;
44
- memory.stats() → store counters. All calls block on the host RPC like add_context does.
45
- """
46
-
47
- def __init__(self, worker: "WorkerScaffold"):
48
- self._w = worker
49
-
50
- def query(self, q, k: int = 8) -> str:
51
- return self._w._memory_rpc("query", {"query": str(q), "k": int(k)})
52
-
53
- def add(self, text, paths=None, tags=None) -> str:
54
- p = [str(x) for x in (paths or [])]
55
- t = [str(x) for x in (tags or [])]
56
- return self._w._memory_rpc("add", {"content": str(text), "paths": p, "tags": t})
57
-
58
- def stats(self) -> str:
59
- return self._w._memory_rpc("stats", {})
60
-
61
-
62
41
  class WorkerScaffold:
63
42
  """Mixin: the model-facing REPL API. Requires the host methods from Worker
64
43
  (`_post`, `_rpc`, `_drain_until`, `_take`, `self.inbox`, `self.ns`, `self._handles`)."""
@@ -596,6 +575,29 @@ class WorkerScaffold:
596
575
  )
597
576
  return self._start_rlm_batch(list(p), paths)
598
577
 
578
+ def _skill_search(self, query: str, k: int = 8) -> list[dict[str, Any]]:
579
+ """SkillState recall: BM25 over distilled facts from PRIOR sessions (host-side store).
580
+
581
+ Free: no sub-LLM call. Returns [{id, text, tags, score}] — "symbol"/"config" notes
582
+ about this exact project learned in earlier runs. Errors surface as a text hit, never
583
+ a raise: a missing store must not crash a cell.
584
+ """
585
+ if not isinstance(query, str) or not query.strip():
586
+ raise TypeError("skill_search() needs a non-empty query string")
587
+ r = self._rpc("skill_search", {"query": query, "k": max(1, min(32, int(k)))})
588
+ if r.get("error"):
589
+ return [{"id": "", "text": f"Error: {r['error']}", "tags": [], "score": 0.0}]
590
+ raw = r.get("response")
591
+ if isinstance(raw, str) and raw:
592
+ if raw.startswith("Error:"):
593
+ return [{"id": "", "text": raw, "tags": [], "score": 0.0}]
594
+ try:
595
+ parsed = json.loads(raw)
596
+ except ValueError:
597
+ return [{"id": "", "text": "Error: malformed skill_search reply", "tags": [], "score": 0.0}]
598
+ return parsed if isinstance(parsed, list) else []
599
+ return []
600
+
599
601
  def _list_claims(self) -> str:
600
602
  """v5 blackboard: the host TaskLedger's claims table (inflight + done work)."""
601
603
  r = self._rpc("ledger_claims", {})
@@ -603,13 +605,4 @@ class WorkerScaffold:
603
605
  return f"Error: {r['error']}"
604
606
  return str(r.get("response") or "ledger: no claims")
605
607
 
606
- def _memory_rpc(self, op: str, payload: dict[str, Any]) -> str:
607
- r = self._rpc("memory", {"op": op, **payload})
608
- if r.get("error"):
609
- return f"Error: {r['error']}"
610
- return str(r.get("response") or "")
611
-
612
- def _memory_api(self) -> "_MemoryApi":
613
- return _MemoryApi(self)
614
-
615
608
  # ---- context + execution --------------------------------------------------------------
@@ -139,6 +139,9 @@ class Worker(WorkerScaffold):
139
139
  ns["list_tasks"] = self._list_tasks
140
140
  ns["map_files"] = self._map_files
141
141
  ns["llm_map_reduce"] = self._llm_map_reduce
142
+ # SKILL.state recall (Workstream E): distilled facts are NOT repo retrieval — they are
143
+ # project knowledge, so delegation children keep them too (unconditional binding).
144
+ ns["skill_search"] = self._skill_search
142
145
  if self.surface != "child":
143
146
  ns["search"] = self._search
144
147
  ns["grep_context"] = self._grep_context
@@ -152,7 +155,6 @@ class Worker(WorkerScaffold):
152
155
  if self.surface != "child":
153
156
  ns["add_context"] = self._add_context
154
157
  ns["list_claims"] = self._list_claims
155
- ns["memory"] = self._memory_api()
156
158
  ns["SHOW_VARS"] = self._show_vars
157
159
  if not isinstance(ns.get("answer"), _AnswerDict):
158
160
  cur = ns.get("answer")
@@ -13,7 +13,7 @@ import {
13
13
  } from "../context/refresh.ts";
14
14
 
15
15
  /** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
16
- export interface SandboxManagerConfig {
16
+ interface SandboxManagerConfig {
17
17
  readonly execTimeoutS: number;
18
18
  readonly requestTimeoutMs: number;
19
19
  readonly python: string;
@@ -127,7 +127,7 @@ export class SandboxManager {
127
127
  this.sandbox = s;
128
128
  this.initPromise = null;
129
129
  return s;
130
- }).catch((err) => {
130
+ }).catch((err: unknown) => {
131
131
  this.contextLoaded = false;
132
132
  this.initPromise = null;
133
133
  throw err;
@@ -27,6 +27,10 @@ import { trace, traceEnabled } from "../util/trace.ts";
27
27
 
28
28
  export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
29
29
 
30
+ /** Event-loop guard: frames are model/summary-sized by construction; payloads travel via temp
31
+ * files, never the wire. Anything near this cap is a runaway producer — drop it. */
32
+ const MAX_FRAME_CHARS = 8_000_000;
33
+
30
34
  export interface SandboxOptions {
31
35
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
32
36
  readonly depth?: number;
@@ -139,7 +143,7 @@ export class PythonSandbox {
139
143
  // windowsHide: without it each sandbox flashes a console window on Windows (pi sets
140
144
  // this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
141
145
  { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
142
- ) as ChildProcessWithoutNullStreams;
146
+ );
143
147
 
144
148
  this.proc.stdout.setEncoding("utf8");
145
149
  this.proc.stdout.on("data", (chunk: string) => this.onData(chunk));
@@ -262,7 +266,8 @@ export class PythonSandbox {
262
266
  this.pending.set("_init", {
263
267
  resolve: (res) => {
264
268
  clearTimeout(timer);
265
- res.ok ? resolve() : reject(new Error(res.error ?? "worker init failed"));
269
+ if (res.ok) resolve();
270
+ else reject(new Error(res.error ?? "worker init failed"));
266
271
  },
267
272
  reject,
268
273
  timer,
@@ -299,7 +304,7 @@ export class PythonSandbox {
299
304
  settle(value);
300
305
  };
301
306
  this.pending.set(id, { resolve: once(resolve), reject: once(reject), timer, requestType: payload.type });
302
- this.send({ id, ...payload } as ParentMessage);
307
+ this.send({ id, ...payload });
303
308
  });
304
309
  }
305
310
 
@@ -356,7 +361,12 @@ export class PythonSandbox {
356
361
  this.appendStderr(`[rlm] dropped '${msg.type}' frame: worker ${this.exitDescription()}\n`);
357
362
  return;
358
363
  }
359
- this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
364
+ const frame = JSON.stringify(msg);
365
+ if (frame.length > MAX_FRAME_CHARS) {
366
+ this.appendStderr(`[rlm] dropped '${msg.type}' frame: ${frame.length} chars exceed the frame cap\n`);
367
+ return;
368
+ }
369
+ this.proc.stdin.write(`${frame}\n`);
360
370
  }
361
371
 
362
372
  /**
@@ -391,6 +401,13 @@ export class PythonSandbox {
391
401
 
392
402
  private onData(chunk: string): void {
393
403
  this.buf += chunk;
404
+ // Untrusted-stream guard: a worker that stops emitting newlines would otherwise balloon
405
+ // this buffer without bound and stall the pump. Reset (both cursors) and keep draining.
406
+ if (this.buf.length > MAX_FRAME_CHARS) {
407
+ this.appendStderr(`\n[protocol] stdout buffer exceeded ${MAX_FRAME_CHARS} chars without a newline — truncated\n`);
408
+ this.buf = "";
409
+ this.scanOffset = 0;
410
+ }
394
411
  let nl: number;
395
412
  while ((nl = this.buf.indexOf("\n", this.scanOffset)) >= 0) {
396
413
  const line = this.buf.slice(this.scanOffset, nl).trim();