@hicaru/pi-rlm 0.2.1 → 0.3.0

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 (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
@@ -9,22 +9,19 @@ const CHOICES = Object.freeze({
9
9
  maxDepth: Object.freeze(["1", "2", "3", "4"]),
10
10
  maxIterations: Object.freeze(["10", "20", "30", "50"]),
11
11
  execTimeoutS: Object.freeze(["30", "60", "120", "300"]),
12
- maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16"]),
13
- maxBudgetUsd: Object.freeze(["none", "0.50", "1", "5"]),
12
+ maxConcurrentSubcalls: Object.freeze(["2", "4", "8", "16", "32"]),
13
+ maxConcurrentChildren: Object.freeze(["1", "2", "3", "4", "6", "8"]),
14
14
  maxTimeoutMs: Object.freeze(["none", "60", "120", "300"]),
15
15
  maxTokens: Object.freeze(["none", "10000", "50000", "100000"]),
16
16
  maxErrors: Object.freeze(["3", "5", "10", "none"]),
17
17
  orchestrator: Object.freeze(["on", "off"]),
18
- pipeline: Object.freeze(["on", "off"]),
19
- maxBackwardJumps: Object.freeze(["0", "1", "2", "3"]),
20
18
  compaction: Object.freeze(["on", "off"]),
21
19
  compactionThresholdPct: Object.freeze(["50", "65", "80", "90"]),
22
20
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
23
21
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
24
22
  requestTimeoutMs: Object.freeze(["2", "5", "10", "20"]),
25
- askUserQuestion: Object.freeze(["on", "off"]),
26
- todo: Object.freeze(["on", "off"]),
27
- libraryLoader: Object.freeze(["on", "off"]),
23
+ contextLoader: Object.freeze(["on", "off"]),
24
+ autoSeedCwd: Object.freeze(["on", "off"]),
28
25
  });
29
26
 
30
27
  function item(id: string, label: string, currentValue: string, values: readonly string[], description: string): SettingItem {
@@ -43,22 +40,20 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
43
40
  item("maxIterations", "Max iterations", String(config.maxIterations), CHOICES.maxIterations, "Maximum root REPL turns before RLM asks the model for a final answer."),
44
41
  item("execTimeoutS", "REPL block timeout (s)", String(config.execTimeoutS), CHOICES.execTimeoutS, "Wall-clock limit for one model-authored Python REPL block."),
45
42
  item("maxConcurrentSubcalls", "Max concurrent sub-calls", String(config.maxConcurrentSubcalls), CHOICES.maxConcurrentSubcalls, "Concurrency pool size for llm_query_batched and rlm_query_batched."),
46
- item("maxBudgetUsd", "Budget ceiling (USD)", config.maxBudgetUsd != null ? String(config.maxBudgetUsd) : "none", CHOICES.maxBudgetUsd, "Total spend cap for the whole recursive tree; none disables the cap."),
43
+ item("maxConcurrentChildren", "Max concurrent children", String(config.maxConcurrentChildren), CHOICES.maxConcurrentChildren, "Concurrent rlm_query child engines per depth. Each is a Python process holding its own copy of the inherited context."),
47
44
  item("maxTimeoutMs", "Wall-clock ceiling (min)", config.maxTimeoutMs != null ? String(Math.round(config.maxTimeoutMs / 60_000)) : "none", CHOICES.maxTimeoutMs, "Total runtime cap for the whole recursive tree; none disables the cap."),
48
45
  item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
49
46
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
50
47
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
51
- item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→validate (read-only plan pipeline; clarify needs Ask user on)."),
52
- item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
53
48
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
54
49
  item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "Compact once estimated history tokens reach this share of the root model's context window."),
55
50
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
56
51
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
57
52
  item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
58
- item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
59
- item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
60
- item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
61
- "Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
53
+ item("contextLoader", "Context loader", config.contextLoader ? "on" : "off", CHOICES.contextLoader,
54
+ "Allow add_context() to pull an external dir, file, document, or git repo into context."),
55
+ item("autoSeedCwd", "Auto-seed cwd", config.autoSeedCwd ? "on" : "off", CHOICES.autoSeedCwd,
56
+ "Seed the working directory into context on the first repl() call (otherwise starts empty)."),
62
57
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
63
58
  ];
64
59
 
@@ -101,22 +96,19 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
101
96
  case "maxIterations": return Object.freeze({ ...config, maxIterations: Number(value) });
102
97
  case "execTimeoutS": return Object.freeze({ ...config, execTimeoutS: Number(value) });
103
98
  case "maxConcurrentSubcalls": return Object.freeze({ ...config, maxConcurrentSubcalls: Number(value) });
104
- case "maxBudgetUsd": return Object.freeze({ ...config, maxBudgetUsd: optionalNumber(value) });
99
+ case "maxConcurrentChildren": return Object.freeze({ ...config, maxConcurrentChildren: Number(value) });
105
100
  case "maxTimeoutMs": return Object.freeze({ ...config, maxTimeoutMs: optionalNumber(value, 60_000) });
106
101
  case "maxTokens": return Object.freeze({ ...config, maxTokens: optionalNumber(value) });
107
102
  case "maxErrors": return Object.freeze({ ...config, maxErrors: optionalNumber(value) });
108
103
  case "orchestrator": return Object.freeze({ ...config, orchestrator: value === "on" });
109
- case "pipeline": return Object.freeze({ ...config, pipeline: value === "on" });
110
- case "maxBackwardJumps": return Object.freeze({ ...config, maxBackwardJumps: Number(value) });
111
104
  case "compaction": return Object.freeze({ ...config, compaction: value === "on" });
112
105
  case "compactionThresholdPct": return Object.freeze({ ...config, compactionThresholdPct: Number(value) / 100 });
113
106
  case "rootSamplingMaxTokens":
114
107
  return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
115
108
  case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
116
109
  case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
117
- case "askUserQuestion": return Object.freeze({ ...config, askUserQuestion: value === "on" });
118
- case "todo": return Object.freeze({ ...config, todo: value === "on" });
119
- case "libraryLoader": return Object.freeze({ ...config, libraryLoader: value === "on" });
110
+ case "contextLoader": return Object.freeze({ ...config, contextLoader: value === "on" });
111
+ case "autoSeedCwd": return Object.freeze({ ...config, autoSeedCwd: value === "on" });
120
112
  default: return config;
121
113
  }
122
114
  }
package/src/ui/intro.ts CHANGED
@@ -11,9 +11,8 @@ export const RLM_GUIDE = `# RLM mode
11
11
  ## Commands
12
12
 
13
13
  - \`/rlm\` — toggle RLM mode (shortcut: Ctrl+Shift+R). Turning it OFF also stops a running query.
14
- - \`/rlm-config\` — choose models, reasoning, and budget limits
14
+ - \`/rlm-config\` — choose models, reasoning, and run limits
15
15
  - \`/rlm-stop\` — cancel the current run but stay in RLM mode (use /rlm or Ctrl+Shift+R to leave)
16
- - \`/rlm-help\` — show this guide again
17
16
 
18
17
  When RLM mode is ON, \`read\`/\`grep\` are disabled and the agent reads the repository through the
19
18
  \`repl\` tool, delegating bulk analysis to sub-LLMs. The footer/status line shows the current state.`;
@@ -1,10 +1,11 @@
1
1
  /** Model picker TUI — choose a model and, when supported, a thinking level. */
2
2
 
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
4
4
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
5
5
  import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import { Container, type Component, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
7
7
  import { formatCost } from "./theme.ts";
8
+ import { compareLlm } from "../mode/llm-model.ts";
8
9
 
9
10
  export interface ModelSelection {
10
11
  readonly model: Model<Api>;
@@ -16,15 +17,38 @@ type SelectableThinkingLevel = (typeof LEVELS)[number];
16
17
 
17
18
  const CHEAPEST_VALUE = "__rlm_cheapest__";
18
19
 
19
- function items(models: Model<Api>[], includeCheapest = false): SelectItem[] {
20
- const modelItems = models.map((m) => ({
21
- value: `${m.provider}/${m.id}`,
22
- label: `${m.provider}/${m.id}`,
23
- description: `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok${m.reasoning ? " · reasoning" : ""}`,
24
- }));
20
+ /**
21
+ * Models Pi itself would offer for this session, cheapest-first.
22
+ *
23
+ * Mirrors the built-in model switcher:
24
+ * - if the session has scoped models (`--models` / enabledModels) those only
25
+ * - else → `getAvailable()` (providers with configured auth)
26
+ *
27
+ * Deliberately NOT `getAll()`: the full catalog dumps every provider's catalog entry and is
28
+ * not what the user sees in Pi natively. See Pi extension docs on `ctx.scopedModels`.
29
+ */
30
+ export function pickableModels(
31
+ registry: ModelRegistry,
32
+ scoped?: readonly { readonly model: Model<Api> }[],
33
+ ): readonly Model<Api>[] {
34
+ const source = scoped !== undefined && scoped.length > 0
35
+ ? scoped.map((s) => s.model)
36
+ : registry.getAvailable();
37
+ return [...source].sort(compareLlm);
38
+ }
39
+
40
+ function items(models: readonly Model<Api>[], includeCheapest: boolean): SelectItem[] {
41
+ const modelItems = models.map((m) => {
42
+ const price = `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok`;
43
+ return {
44
+ value: `${m.provider}/${m.id}`,
45
+ label: `${m.provider}/${m.id}`,
46
+ description: `${price}${m.reasoning ? " · reasoning" : ""}`,
47
+ };
48
+ });
25
49
  if (!includeCheapest) return modelItems;
26
50
  return [
27
- { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest available model" },
51
+ { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest model with a configured key" },
28
52
  ...modelItems,
29
53
  ];
30
54
  }
@@ -79,12 +103,12 @@ async function selectThinkingLevel(
79
103
  export async function selectModel(
80
104
  ctx: ExtensionContext,
81
105
  title: string,
82
- models: Model<Api>[],
106
+ models: readonly Model<Api>[],
83
107
  current?: Model<Api>,
84
108
  currentThinking?: ThinkingLevel,
85
109
  ): Promise<ModelSelection | null | undefined> {
86
110
  if (models.length === 0) {
87
- ctx.ui.notify("RLM: no models with configured auth", "warning");
111
+ ctx.ui.notify("RLM: no models available (add a provider key in Pi, or widen --models / enabledModels)", "warning");
88
112
  return undefined;
89
113
  }
90
114
  if (ctx.mode !== "tui") {
package/src/ui/status.ts CHANGED
@@ -12,18 +12,14 @@ export function modelLabel(model: Model<Api> | undefined, fallback: string): str
12
12
 
13
13
  export function formatRlmStateLine(controller: RlmController, contextUsage?: ContextUsage): string {
14
14
  if (!controller.enabled) return "○ RLM OFF";
15
- const worker = modelLabel(controller.workerModel, controller.savedWorkerRef ?? "cheapest");
16
- const workerSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
15
+ const llm = modelLabel(controller.llmModel, controller.savedLlmRef ?? "cheapest");
16
+ const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
17
17
  // `percent` is null right after a compaction, before the next assistant response reports usage.
18
18
  const percent = contextUsage?.percent;
19
19
  const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
20
- return `● RLM ON · worker=${worker}${workerSuffix}${ctxSuffix}`;
20
+ return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}`;
21
21
  }
22
22
 
23
23
  export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
24
24
  ui.setStatus(KEY, formatRlmStateLine(controller, contextUsage));
25
25
  }
26
-
27
- export function clearRlmStatus(ui: ExtensionUIContext): void {
28
- ui.setStatus(KEY, undefined);
29
- }
@@ -80,10 +80,14 @@ export interface SubcallGates {
80
80
  }
81
81
 
82
82
  /**
83
- * Worst case is `maxDepth × limit` concurrent child engines (each owning a Python
84
- * subprocess) plus `limit` leaf completions, so keep `limit` modestsee
85
- * DEFAULT_CONFIG.maxConcurrentSubcalls.
83
+ * Worst case is `(maxDepth - 1) × childLimit` concurrent child engines the cap short-circuits
84
+ * at `childDepth >= maxDepth`, so engines exist at depths 1..maxDepth-1 plus `leafLimit` leaf
85
+ * completions.
86
+ *
87
+ * Children get their own, smaller bound because they are far heavier than leaves: each owns a
88
+ * Python subprocess AND its own copy of the context it inherited from its parent, where a leaf
89
+ * is one HTTP request. See DEFAULT_CONFIG.maxConcurrentChildren.
86
90
  */
87
- export function createSubcallGates(limit: number): SubcallGates {
88
- return Object.freeze({ leaf: new Semaphore(limit), rlm: new DepthGates(limit) });
91
+ export function createSubcallGates(leafLimit: number, childLimit: number = leafLimit): SubcallGates {
92
+ return Object.freeze({ leaf: new Semaphore(leafLimit), rlm: new DepthGates(childLimit) });
89
93
  }
@@ -1,148 +0,0 @@
1
- import { formatError } from "../util/errors.ts";
2
-
3
- type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
4
-
5
- interface Task {
6
- readonly id: number;
7
- readonly subject: string;
8
- readonly description?: string;
9
- readonly status: TaskStatus;
10
- readonly activeForm?: string;
11
- readonly blockedBy?: readonly number[];
12
- readonly owner?: string;
13
- }
14
-
15
- interface TodoParams {
16
- readonly id?: number;
17
- readonly subject?: string;
18
- readonly description?: string;
19
- readonly status?: TaskStatus;
20
- readonly activeForm?: string;
21
- readonly blockedBy?: readonly number[];
22
- readonly addBlockedBy?: readonly number[];
23
- readonly removeBlockedBy?: readonly number[];
24
- readonly owner?: string;
25
- readonly filterStatus?: string;
26
- readonly includeDeleted?: boolean;
27
- }
28
-
29
- const TODO_STATUSES = Object.freeze(new Set<unknown>(["pending", "in_progress", "completed", "deleted"]));
30
-
31
- function isTaskStatus(value: unknown): value is TaskStatus {
32
- return TODO_STATUSES.has(value);
33
- }
34
-
35
- function numericArray(value: unknown): readonly number[] | undefined {
36
- return Array.isArray(value) ? Object.freeze(value.filter((n): n is number => typeof n === "number")) : undefined;
37
- }
38
-
39
- function toTodoParams(raw: Record<string, unknown>): TodoParams {
40
- const blockedBy = numericArray(raw.blockedBy);
41
- const addBlockedBy = numericArray(raw.addBlockedBy);
42
- const removeBlockedBy = numericArray(raw.removeBlockedBy);
43
- return Object.freeze({
44
- ...(typeof raw.id === "number" ? { id: raw.id } : {}),
45
- ...(typeof raw.subject === "string" ? { subject: raw.subject } : {}),
46
- ...(typeof raw.description === "string" ? { description: raw.description } : {}),
47
- ...(isTaskStatus(raw.status) ? { status: raw.status } : {}),
48
- ...(typeof raw.activeForm === "string" ? { activeForm: raw.activeForm } : {}),
49
- ...(blockedBy ? { blockedBy } : {}),
50
- ...(addBlockedBy ? { addBlockedBy } : {}),
51
- ...(removeBlockedBy ? { removeBlockedBy } : {}),
52
- ...(typeof raw.owner === "string" ? { owner: raw.owner } : {}),
53
- ...(typeof raw.filterStatus === "string" ? { filterStatus: raw.filterStatus } : {}),
54
- ...(raw.includeDeleted === true ? { includeDeleted: true } : {}),
55
- });
56
- }
57
-
58
- function patchedBlockedBy(task: Task, params: TodoParams): readonly number[] | undefined {
59
- if (params.blockedBy) return params.blockedBy;
60
-
61
- let next = task.blockedBy ?? Object.freeze([] as readonly number[]);
62
- if (params.addBlockedBy) next = Object.freeze([...next, ...params.addBlockedBy]);
63
-
64
- if (params.removeBlockedBy) {
65
- const removeSet = new Set(params.removeBlockedBy);
66
- next = Object.freeze(next.filter((n) => !removeSet.has(n)));
67
- }
68
- return next.length ? next : undefined;
69
- }
70
-
71
- function taskLines(task: Task): readonly string[] {
72
- const lines: string[] = [`#${task.id} [${task.status}] ${task.subject}`];
73
- if (task.description) lines.push(` description: ${task.description}`);
74
- if (task.activeForm) lines.push(` activeForm: ${task.activeForm}`);
75
- if (task.blockedBy?.length) lines.push(` blockedBy: ${task.blockedBy.map((n) => `#${n}`).join(", ")}`);
76
- if (task.owner) lines.push(` owner: ${task.owner}`);
77
- return lines;
78
- }
79
-
80
- function withPatch(task: Task, params: TodoParams): Task {
81
- const blockedBy = patchedBlockedBy(task, params);
82
- return Object.freeze({
83
- ...task,
84
- ...(params.subject !== undefined ? { subject: params.subject } : {}),
85
- ...(params.description !== undefined ? { description: params.description } : {}),
86
- ...(params.status !== undefined ? { status: params.status } : {}),
87
- ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {}),
88
- ...(blockedBy ? { blockedBy } : {}),
89
- ...(params.owner !== undefined ? { owner: params.owner } : {}),
90
- });
91
- }
92
-
93
- /** The actions `worker.py::_todo` documents. Anything else is a caller error, not a missing task. */
94
- const TODO_ACTIONS: ReadonlySet<string> = Object.freeze(new Set([
95
- "create", "update", "list", "get", "delete", "clear",
96
- ]));
97
-
98
- export function createTodoFallback(): (action: string, params: Record<string, unknown>) => Promise<string> {
99
- let nextId = 1;
100
- let tasks: readonly Task[] = Object.freeze([]);
101
- const fmt = (task: Task): string => taskLines(task)[0] ?? `#${task.id}`;
102
-
103
- const apply = (action: string, rawParams: Record<string, unknown>): string => {
104
- // Validated BEFORE the id lookup below: an unknown action used to fall through to it and
105
- // report "task #? not found", which reads as a missing task rather than a bad action.
106
- if (!TODO_ACTIONS.has(action)) {
107
- return formatError(`unknown todo action '${action}' — expected ${[...TODO_ACTIONS].join(", ")}`);
108
- }
109
- const params = toTodoParams(rawParams);
110
- if (action === "clear") {
111
- const count = tasks.length;
112
- tasks = Object.freeze([]);
113
- nextId = 1;
114
- return `Cleared ${count} task(s).`;
115
- }
116
- if (action === "create") {
117
- const subject = typeof params.subject === "string" && params.subject.trim() ? params.subject.trim() : undefined;
118
- if (!subject) return formatError("create requires subject");
119
- const task = withPatch(Object.freeze({ id: nextId, subject, status: "pending" }), params);
120
- nextId += 1;
121
- tasks = Object.freeze([...tasks, task]);
122
- return `Created ${fmt(task)}`;
123
- }
124
- if (action === "list") {
125
- const filter = params.filterStatus ?? params.status;
126
- const includeDeleted = params.includeDeleted === true;
127
- const rows = tasks.filter((task) => (includeDeleted || task.status !== "deleted") && (!filter || task.status === filter)).map(fmt);
128
- return rows.length ? rows.join("\n") : "No tasks.";
129
- }
130
- const id = params.id;
131
- const task = id !== undefined ? tasks.find((item) => item.id === id) : undefined;
132
- if (!task) return formatError(`task #${id ?? "?"} not found`);
133
- if (action === "get") return taskLines(task).join("\n");
134
- if (action === "delete") {
135
- const deleted = Object.freeze({ ...task, status: "deleted" as const });
136
- tasks = Object.freeze(tasks.map((item) => item.id === task.id ? deleted : item));
137
- return `Deleted ${fmt(deleted)}`;
138
- }
139
- if (action === "update") {
140
- const updated = withPatch(task, params);
141
- tasks = Object.freeze(tasks.map((item) => item.id === task.id ? updated : item));
142
- return `Updated ${fmt(updated)}`;
143
- }
144
- // Unreachable: every TODO_ACTIONS member is handled above. Kept as the exhaustiveness arm.
145
- return formatError(`unhandled todo action '${action}'`);
146
- };
147
- return async (action, params) => apply(action, params);
148
- }
@@ -1,65 +0,0 @@
1
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
2
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
3
- import type { RlmEmitter } from "../tool/rlm-events.ts";
4
- import { formatError } from "../util/errors.ts";
5
-
6
- export interface InteractiveBridgeOpts {
7
- readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
8
- readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
9
- readonly onTodoRow?: (action: string, params: Record<string, unknown>, result: string) => void | Promise<void>;
10
- readonly emitter?: RlmEmitter;
11
- readonly depth: number;
12
- readonly parentId?: string;
13
- }
14
-
15
- export function buildInteractiveHandlers(opts: InteractiveBridgeOpts): {
16
- askUserQuestion: SubLlmHandlers["askUserQuestion"];
17
- todo: SubLlmHandlers["todo"];
18
- } {
19
- return {
20
- async askUserQuestion(questions, depth) {
21
- if (depth > 0) return questions.map((q) => ({
22
- question: q.question,
23
- selected: [],
24
- custom: formatError("ask_user_question not available inside rlm_query sub-calls"),
25
- }));
26
-
27
- const id = opts.emitter?.emitSubcallCreated({
28
- kind: "tool", parentId: opts.parentId,
29
- label: "ask_user_question",
30
- args: `${questions.length} question(s)`,
31
- depth,
32
- });
33
- try {
34
- const cb = opts.onAskUserQuestion;
35
- if (!cb) throw new Error("ask_user_question not configured (no onAskUserQuestion callback)");
36
- const answers = await cb(questions);
37
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done" });
38
- return answers;
39
- } catch (err) {
40
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
41
- throw err;
42
- }
43
- },
44
-
45
- async todo(action, params, depth) {
46
- const id = opts.emitter?.emitSubcallCreated({
47
- kind: "tool", parentId: opts.parentId,
48
- label: `todo:${action}`,
49
- args: params.subject ? String(params.subject) : String(params.id ?? ""),
50
- depth,
51
- });
52
- try {
53
- const cb = opts.onTodo;
54
- if (!cb) throw new Error("todo not configured (no onTodo callback)");
55
- const result = await cb(action, params);
56
- await opts.onTodoRow?.(action, params, result);
57
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done", resultPreview: result.slice(0, 80) });
58
- return result;
59
- } catch (err) {
60
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
61
- throw err;
62
- }
63
- },
64
- };
65
- }
@@ -1,155 +0,0 @@
1
- /**
2
- * Shared load_library handler for headless engine and native repl() mode.
3
- *
4
- * Host packs the source via resolveLibrarySource (namespaced under lib/<id>/),
5
- * assigns a resume-sidecar index, and returns the payload for the worker to
6
- * append into the single `context` list.
7
- *
8
- * Idempotency is host-side: re-loading a source that was already packed does
9
- * not consume an index, write a sidecar, or re-clone/pack. That keeps resume
10
- * trails free of duplicate library slots.
11
- *
12
- * Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
13
- * across native repl() calls — getOrCreate only installs handlers at spawn.
14
- */
15
-
16
- import type { RlmEmitter } from "../tool/rlm-events.ts";
17
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
18
- import {
19
- libraryNamespace,
20
- resolveLibrarySource,
21
- } from "../context/library-context.ts";
22
- import { previewText } from "../text/preview.ts";
23
-
24
- export interface LibraryBridgeOpts {
25
- /** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
26
- readonly cwd?: string;
27
- /** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
28
- readonly getCwd?: () => string;
29
- readonly emitter?: RlmEmitter;
30
- /** Native mode: read the live emitter each call. */
31
- readonly getEmitter?: () => RlmEmitter | null | undefined;
32
- readonly parentId?: string;
33
- readonly signal?: AbortSignal;
34
- /** First resume-sidecar index (slot 0 = repo). Resume passes 1 + max restored. */
35
- readonly startIndex: number;
36
- /**
37
- * Prefixes already present in context (e.g. restored from sidecars).
38
- * Seeded so re-load after resume is still a no-op without re-packing.
39
- */
40
- readonly loadedPrefixes?: readonly string[];
41
- /** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
42
- readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
43
- }
44
-
45
- export interface LibraryHandlerBundle {
46
- readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
47
- /** Reset the sidecar index counter (call when the sandbox is discarded and will re-spawn). */
48
- readonly reset: () => void;
49
- /** Prefixes loaded in this sandbox lifetime (for tests). */
50
- readonly loadedPrefixes: () => ReadonlySet<string>;
51
- }
52
-
53
- export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
54
- let nextIndex = opts.startIndex;
55
- /** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
56
- const loaded = new Set<string>(opts.loadedPrefixes ?? []);
57
- return {
58
- reset: () => {
59
- nextIndex = opts.startIndex;
60
- loaded.clear();
61
- },
62
- loadedPrefixes: () => loaded,
63
- handlers: {
64
- async loadLibrary(source, depth) {
65
- const emitter = opts.getEmitter?.() ?? opts.emitter;
66
- const cwd = opts.getCwd?.() ?? opts.cwd;
67
- if (cwd === undefined || cwd === "") {
68
- throw new Error("load_library: no cwd configured");
69
- }
70
- const id = emitter?.emitSubcallCreated({
71
- kind: "tool", parentId: opts.parentId,
72
- label: "load_library",
73
- args: previewText(source, 80),
74
- depth,
75
- });
76
- try {
77
- // Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
78
- const { sourceId: preId, pathPrefix: prefix } = libraryNamespace(source, cwd);
79
- if (loaded.has(prefix)) {
80
- if (id) {
81
- emitter?.emitSubcallUpdated({
82
- id,
83
- status: "done",
84
- resultPreview: `already loaded (${prefix}*)`,
85
- });
86
- }
87
- // No index consumed, no sidecar written — resume stays consistent.
88
- return {
89
- payload: Object.freeze([]),
90
- index: -1,
91
- files: 0,
92
- chars: 0,
93
- sourceId: preId,
94
- pathPrefix: prefix,
95
- alreadyLoaded: true,
96
- };
97
- }
98
-
99
- const resolved = await resolveLibrarySource(source, cwd, opts.signal);
100
- if (!resolved.ok) throw new Error(resolved.error);
101
- const { payload, files, chars, sourceId, pathPrefix } = resolved.value;
102
-
103
- // Race: another concurrent load of the same prefix finished while we packed.
104
- if (loaded.has(pathPrefix)) {
105
- if (id) {
106
- emitter?.emitSubcallUpdated({
107
- id,
108
- status: "done",
109
- resultPreview: `already loaded (${pathPrefix}*)`,
110
- });
111
- }
112
- return {
113
- payload: Object.freeze([]),
114
- index: -1,
115
- files: 0,
116
- chars: 0,
117
- sourceId,
118
- pathPrefix,
119
- alreadyLoaded: true,
120
- };
121
- }
122
-
123
- // Increment only after a successful sidecar write (or when no hook is set).
124
- const index = nextIndex;
125
- if (opts.onLoaded) {
126
- await opts.onLoaded(index, payload);
127
- }
128
- nextIndex = index + 1;
129
- loaded.add(pathPrefix);
130
-
131
- if (id) {
132
- emitter?.emitSubcallUpdated({
133
- id,
134
- status: "done",
135
- resultPreview:
136
- `+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars)`,
137
- });
138
- }
139
- return {
140
- payload,
141
- index,
142
- files,
143
- chars,
144
- sourceId,
145
- pathPrefix,
146
- alreadyLoaded: false,
147
- };
148
- } catch (err) {
149
- if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
150
- throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
151
- }
152
- },
153
- },
154
- };
155
- }
@@ -1,41 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { InteractiveDeps } from "../core/types.ts";
3
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
4
- import { formatError } from "../util/errors.ts";
5
- import { createTodoFallback } from "./fallback-todo.ts";
6
-
7
- async function askViaUi(ctx: ExtensionContext, questions: readonly AskQuestion[]): Promise<AskAnswer[]> {
8
- if (!ctx.hasUI) throw new Error("ask_user_question requires UI");
9
- const answers = new Array<AskAnswer>(questions.length);
10
- for (let i = 0; i < questions.length; i++) {
11
- const q = questions[i];
12
- if (!q) {
13
- answers[i] = { question: "", selected: [], custom: formatError("malformed question") };
14
- continue;
15
- }
16
- if (q.multiSelect) {
17
- const selected: string[] = [];
18
- while (true) {
19
- const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Done"]);
20
- if (!pick || pick === "Done") break;
21
- if (!selected.includes(pick)) selected.push(pick);
22
- }
23
- answers[i] = { question: q.question, selected };
24
- continue;
25
- }
26
- const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Type something."]);
27
- if (!pick) answers[i] = { question: q.question, selected: [], custom: formatError("user cancelled") };
28
- else if (pick === "Type something.") answers[i] = { question: q.question, selected: [], custom: await ctx.ui.input(q.question) ?? "" };
29
- else answers[i] = { question: q.question, selected: [pick] };
30
- }
31
- return answers;
32
- }
33
-
34
- export function createPiInteractiveDeps(ctx: ExtensionContext): InteractiveDeps {
35
- const fallbackTodo = createTodoFallback();
36
- return Object.freeze({
37
- onAskUserQuestion: (questions: readonly AskQuestion[]): Promise<AskAnswer[]> => askViaUi(ctx, questions),
38
- onTodo: (action: string, params: Record<string, unknown>): Promise<string> =>
39
- Promise.resolve(fallbackTodo(action, params)),
40
- });
41
- }