@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.
- package/README.md +28 -47
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +8 -18
- package/src/config/settings.ts +13 -34
- package/src/context/anydoc.ts +67 -0
- package/src/context/listing.ts +70 -0
- package/src/context/md-cache.ts +112 -0
- package/src/context/merge.ts +97 -0
- package/src/context/namespace.ts +180 -0
- package/src/context/resolve.ts +122 -0
- package/src/context/source-dir.ts +166 -0
- package/src/context/source-doc.ts +71 -0
- package/src/context/source-git.ts +51 -0
- package/src/context/source-text.ts +45 -0
- package/src/context/types.ts +88 -0
- package/src/context/walk.ts +250 -0
- package/src/core/engine.ts +61 -345
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +10 -38
- package/src/index.ts +92 -54
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +28 -58
- package/src/prompts/glossary.ts +290 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +15 -408
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +160 -0
- package/src/sandbox/protocol.ts +20 -75
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +129 -0
- package/src/sandbox/py/worker.py +856 -0
- package/src/sandbox/sandbox-manager.ts +24 -9
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +31 -5
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +60 -170
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +2 -13
- package/src/ui/config-panel.ts +12 -20
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/library.ts +0 -155
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/context/library-context.ts +0 -266
- package/src/context/repomix-context.ts +0 -204
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1456
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/ui/config-panel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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("
|
|
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("
|
|
59
|
-
|
|
60
|
-
item("
|
|
61
|
-
"
|
|
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 "
|
|
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 "
|
|
118
|
-
case "
|
|
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
|
|
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.`;
|
package/src/ui/model-picker.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
16
|
-
const
|
|
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 ·
|
|
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
|
-
}
|
package/src/util/concurrency.ts
CHANGED
|
@@ -80,10 +80,14 @@ export interface SubcallGates {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Worst case is `maxDepth ×
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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(
|
|
88
|
-
return Object.freeze({ leaf: new Semaphore(
|
|
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
|
-
}
|
package/src/bridge/library.ts
DELETED
|
@@ -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
|
-
}
|