@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
@@ -1,25 +1,8 @@
1
1
  /** `/rlm` — toggle persistent Recursive Language Model mode. */
2
2
 
3
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
- import { Container, Text, type Component } from "@earendil-works/pi-tui";
5
- import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
6
- import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
7
- import { postRlmGuide } from "../ui/intro.ts";
8
- import { clearRlmStatus, setRlmModeStatus } from "../ui/status.ts";
9
- import { listRunIds, readContextSidecar, readHeader, resolveRunId } from "../state/index.ts";
10
- import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
11
- import { reconstructRlmState } from "../state/resume.ts";
12
- import type { ReconstructResult } from "../state/resume.ts";
13
- import type { RunHeader } from "../state/rows.ts";
14
- import { buildRlmSystemPrompt } from "../prompts/system.ts";
15
- import { RlmEmitter } from "../tool/rlm-events.ts";
16
- import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
17
- import type { RlmDetails } from "../tool/rlm-details.ts";
18
- import { cardHeader, cardStatsLine, renderCollapsedSubcallTree } from "../tool/subcall-render.ts";
19
- import { errorMessage } from "../util/errors.ts";
20
-
21
- /** Run ids offered for `/rlm-resume <TAB>`. */
22
- const MAX_COMPLETIONS = 20;
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { RlmController } from "../mode/rlm-mode.ts";
5
+ import { setRlmModeStatus } from "../ui/status.ts";
23
6
 
24
7
  export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
25
8
  pi.registerCommand("rlm", {
@@ -43,69 +26,6 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
43
26
  },
44
27
  });
45
28
 
46
- pi.registerCommand("rlm-help", {
47
- description: "Show the RLM startup guide and command cheatsheet.",
48
- handler: async () => {
49
- postRlmGuide(pi, controller);
50
- },
51
- });
52
-
53
- pi.registerCommand("rlm-resume", {
54
- description: "Resume an interrupted RLM run (default @latest).",
55
- getArgumentCompletions: async (prefix) => {
56
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
57
- const ids = await listRunIds(process.cwd(), dir);
58
- const candidates = ["@latest", ...ids];
59
- return candidates
60
- .filter((value) => value.startsWith(prefix))
61
- .slice(0, MAX_COMPLETIONS)
62
- .map((value) => ({ value, label: value }));
63
- },
64
- handler: async (args, ctx) => {
65
- if (controller.isBusy()) {
66
- ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
67
- return;
68
- }
69
- const ref = args.trim() || "@latest";
70
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
71
- const cwd = ctx.cwd ?? process.cwd();
72
- const runId = await resolveRunId(cwd, dir, ref);
73
- if (!runId) { ctx.ui.notify(`No resumable RLM run for '${ref}'.`, "error"); return; }
74
- const header = await readHeader(cwd, dir, runId);
75
- if (!header) { ctx.ui.notify(`Run ${runId} has no header.`, "error"); return; }
76
- const systemPrompt = buildRlmSystemPrompt(
77
- { contextType: header.context.type, contextChars: header.context.chars, rootPrompt: header.rootPrompt },
78
- {
79
- orchestrator: header.meta.orchestrator,
80
- recursion: 1 < header.meta.maxDepth,
81
- askUserQuestion: controller.config.askUserQuestion,
82
- todo: controller.config.todo,
83
- },
84
- );
85
- let recon: ReconstructResult;
86
- try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
87
- catch (e) {
88
- ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
89
- return;
90
- }
91
- if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
92
- if (recon.terminated) { ctx.ui.notify(`Run ${runId} already finished.`, "info"); return; }
93
- const context = await readContextSidecar(cwd, dir, runId, header.context.json);
94
- if (context === undefined) // R-C2: warn instead of silently resuming on empty context
95
- ctx.ui.notify(`Warning: context sidecar missing for ${runId} — resuming without original context.`, "warning");
96
- await executeRlmRunWithResume(pi, controller, ctx, recon, header, context ?? "");
97
- },
98
- });
99
-
100
- pi.registerCommand("rlm-runs", {
101
- description: "List recent RLM runs.",
102
- handler: async (_args, ctx) => {
103
- const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
104
- const ids = (await listRunIds(ctx.cwd ?? process.cwd(), dir)).slice(0, 20);
105
- ctx.ui.notify(ids.length ? ids.join("\n") : "No RLM runs recorded.", "info");
106
- },
107
- });
108
-
109
29
  pi.registerShortcut?.("ctrl+shift+r", {
110
30
  description: "Toggle RLM mode (off also stops a running query)",
111
31
  handler: async (ctx) => {
@@ -115,72 +35,3 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
115
35
  },
116
36
  });
117
37
  }
118
-
119
- /** Above-editor progress card for a `/rlm-resume` run: header + the live sub-call tree. */
120
- function renderResumeWidget(details: RlmDetails | undefined, theme: Theme): Component {
121
- const container = new Container();
122
- if (!details) return container;
123
- const turns = details.turns;
124
- const stats = cardStatsLine(
125
- details.totals,
126
- theme,
127
- turns.max > 0 ? `turn ${turns.current}/${turns.max}` : undefined,
128
- );
129
- container.addChild(new Text(cardHeader("RLM resume", details.status, stats, theme), 0, 0));
130
- if (details.subcalls.length > 0) {
131
- container.addChild(new Text(renderCollapsedSubcallTree(details.subcalls, theme), 0, 0));
132
- }
133
- return container;
134
- }
135
-
136
- async function executeRlmRunWithResume(
137
- pi: ExtensionAPI,
138
- controller: RlmController,
139
- ctx: ExtensionContext,
140
- recon: ReconstructResult & { ok: true },
141
- header: RunHeader,
142
- context: unknown,
143
- ): Promise<void> {
144
- let handle: RunHandle | undefined;
145
- let emitter: RlmEmitter | undefined;
146
- let aggregator: RlmEventAggregator | undefined;
147
- try {
148
- emitter = new RlmEmitter();
149
- // Component factory rather than the string[] form: the array form is hard-capped at 10
150
- // lines by pi, which the live sub-call tree exceeds as soon as a run fans out. The factory
151
- // also receives the live theme, so the widget follows /theme switches.
152
- let latest: RlmDetails | undefined;
153
- aggregator = new RlmEventAggregator(emitter, (partial) => {
154
- latest = partial.details;
155
- if (!latest) return;
156
- ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
157
- placement: "aboveEditor",
158
- });
159
- });
160
- emitter.emitRootPrompt(header.rootPrompt);
161
- const interactive = createPiInteractiveDeps(ctx);
162
- if (controller.config.todo) {
163
- for (const row of recon.todoRows) await interactive.onTodo?.(row.action, row.params);
164
- }
165
- handle = controller.start(ctx, { kind: "resume", resume: recon, context }, emitter, {
166
- onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
167
- onTodo: controller.config.todo ? interactive.onTodo : undefined,
168
- });
169
- } catch (e) {
170
- ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
171
- return;
172
- }
173
- pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
174
- const { done } = handle;
175
- try {
176
- const result = await done;
177
- pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
178
- } catch (e) {
179
- ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
180
- } finally {
181
- clearRlmStatus(ctx.ui);
182
- ctx.ui.setWidget?.("rlm-status", undefined);
183
- aggregator?.dispose();
184
- emitter?.shutdown();
185
- }
186
- }
@@ -1,8 +1,4 @@
1
1
  import type { RlmConfig } from "../core/types.ts";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- export const DEFAULT_RUN_DIR = join(tmpdir(), "rlm-runs");
6
2
 
7
3
  /** Frozen default sub-LLM system prompt — avoids re-allocation on every llm_query call. */
8
4
  const DEFAULT_SUB_SYSTEM_PROMPT =
@@ -17,28 +13,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
17
13
  execTimeoutS: 120,
18
14
  requestTimeoutMs: 10 * 60_000,
19
15
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
20
- // the only thing bounding fan-out. Worst case is maxDepth × this many child engines (each
21
- // owning a Python subprocess) plus this many leaf completions — keep it modest.
22
- maxConcurrentSubcalls: 6,
16
+ // the only thing bounding leaf fan-out.
17
+ maxConcurrentSubcalls: 16,
18
+ // Children are bounded separately and lower: each is a Python subprocess holding its own copy
19
+ // of the context it inherited, where a leaf is one HTTP request. Worst case is
20
+ // (maxDepth - 1) × this many concurrent child engines.
21
+ maxConcurrentChildren: 6,
23
22
  maxPromptChars: 400_000,
24
23
  maxErrors: 5,
25
24
  orchestrator: true,
26
- pipeline: false,
27
- maxBackwardJumps: 2,
28
25
  compaction: true,
29
26
  compactionThresholdPct: 0.65,
30
27
  python: "python3",
31
28
  sandboxInitTimeoutMs: 30_000,
32
- askUserQuestion: true,
33
- todo: true,
34
- libraryLoader: true,
29
+ contextLoader: true,
30
+ autoSeedCwd: true,
35
31
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
36
32
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
37
33
  subSampling: Object.freeze({ maxTokens: 8192 }),
38
- runLog: Object.freeze({
39
- enabled: true,
40
- dir: DEFAULT_RUN_DIR,
41
- snapshot: true,
42
- maxRuns: 50,
43
- }),
44
34
  });
@@ -1,15 +1,16 @@
1
- /** Persist RLM settings (tunable config + chosen worker model id). */
1
+ /** Persist RLM settings (tunable config + pinned sub-LLM model id). */
2
2
 
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
6
6
  import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
7
- import type { RlmConfig, RunLogConfig } from "../core/types.ts";
7
+ import type { RlmConfig } from "../core/types.ts";
8
8
  import { DEFAULT_CONFIG } from "./defaults.ts";
9
9
 
10
10
  export interface PersistedSettings {
11
11
  readonly config: Partial<RlmConfig>;
12
- readonly worker?: string;
12
+ /** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)". */
13
+ readonly llm?: string;
13
14
  }
14
15
 
15
16
  type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
@@ -43,21 +44,6 @@ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
43
44
  return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
44
45
  }
45
46
 
46
- function validateRunLog(raw: unknown): Partial<RunLogConfig> | undefined {
47
- if (typeof raw !== "object" || raw === null) return undefined;
48
- const r = raw as Record<string, unknown>;
49
- const out: { enabled?: boolean; dir?: string; snapshot?: boolean; maxRuns?: number } = {};
50
- const enabled = validateBoolean(r.enabled);
51
- if (enabled !== undefined) out.enabled = enabled;
52
- const dir = validateString(r.dir);
53
- if (dir !== undefined) out.dir = dir;
54
- const snapshot = validateBoolean(r.snapshot);
55
- if (snapshot !== undefined) out.snapshot = snapshot;
56
- const maxRuns = validateNumber(r.maxRuns, 1);
57
- if (maxRuns !== undefined) out.maxRuns = maxRuns;
58
- return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
59
- }
60
-
61
47
  function validateConfig(raw: unknown): Partial<RlmConfig> {
62
48
  if (typeof raw !== "object" || raw === null) return {};
63
49
  const r = raw as Record<string, unknown>;
@@ -74,10 +60,10 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
74
60
  if (requestTimeoutMs !== undefined) out.requestTimeoutMs = requestTimeoutMs;
75
61
  const maxConcurrentSubcalls = validateNumber(r.maxConcurrentSubcalls, 1);
76
62
  if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
63
+ const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
64
+ if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
77
65
  const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
78
66
  if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
79
- const maxBudgetUsd = validateNumber(r.maxBudgetUsd, 0.01);
80
- if (maxBudgetUsd !== undefined) out.maxBudgetUsd = maxBudgetUsd;
81
67
  const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
82
68
  if (maxTimeoutMs !== undefined) out.maxTimeoutMs = maxTimeoutMs;
83
69
  const maxTokens = validateNumber(r.maxTokens, 1);
@@ -86,10 +72,6 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
86
72
  if (maxErrors !== undefined) out.maxErrors = maxErrors;
87
73
  const orchestrator = validateBoolean(r.orchestrator);
88
74
  if (orchestrator !== undefined) out.orchestrator = orchestrator;
89
- const pipeline = validateBoolean(r.pipeline);
90
- if (pipeline !== undefined) out.pipeline = pipeline;
91
- const maxBackwardJumps = validateNumber(r.maxBackwardJumps, 0);
92
- if (maxBackwardJumps !== undefined) out.maxBackwardJumps = maxBackwardJumps;
93
75
  const compaction = validateBoolean(r.compaction);
94
76
  if (compaction !== undefined) out.compaction = compaction;
95
77
  const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
@@ -100,16 +82,13 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
100
82
  if (smartReasoning !== undefined) out.smartReasoning = smartReasoning;
101
83
  const subSystemPrompt = validateString(r.subSystemPrompt);
102
84
  if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
103
- const runLog = validateRunLog(r.runLog);
104
- if (runLog) out.runLog = runLog;
105
85
  const sandboxInitTimeoutMs = validateNumber(r.sandboxInitTimeoutMs, 100);
106
86
  if (sandboxInitTimeoutMs !== undefined) out.sandboxInitTimeoutMs = sandboxInitTimeoutMs;
107
- const askUserQuestion = validateBoolean(r.askUserQuestion);
108
- if (askUserQuestion !== undefined) out.askUserQuestion = askUserQuestion;
109
- const todo = validateBoolean(r.todo);
110
- if (todo !== undefined) out.todo = todo;
111
- const libraryLoader = validateBoolean(r.libraryLoader);
112
- if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
87
+ // `libraryLoader` is the pre-rename key — still read so an existing rlm.json survives the upgrade.
88
+ const contextLoader = validateBoolean(r.contextLoader) ?? validateBoolean(r.libraryLoader);
89
+ if (contextLoader !== undefined) out.contextLoader = contextLoader;
90
+ const autoSeedCwd = validateBoolean(r.autoSeedCwd);
91
+ if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
113
92
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
114
93
  const ss = r.subSampling as Record<string, unknown>;
115
94
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -142,7 +121,8 @@ export async function loadSettings(): Promise<PersistedSettings> {
142
121
  const r = raw as Record<string, unknown>;
143
122
  return {
144
123
  config: validateConfig(r.config),
145
- worker: typeof r.worker === "string" ? r.worker : undefined,
124
+ // `worker` is the pre-rename key still read so an existing pin survives the upgrade.
125
+ llm: validateString(r.llm) ?? validateString(r.worker),
146
126
  };
147
127
  } catch {
148
128
  return { config: {} };
@@ -167,7 +147,6 @@ export function mergeConfig(partial: Partial<RlmConfig>): RlmConfig {
167
147
  ...partial,
168
148
  subSampling: { ...DEFAULT_CONFIG.subSampling, ...partial.subSampling },
169
149
  rootSampling: Object.freeze({ ...DEFAULT_CONFIG.rootSampling, ...partial.rootSampling }),
170
- ...(partial.runLog ? { runLog: Object.freeze({ ...DEFAULT_CONFIG.runLog, ...partial.runLog }) } : {}),
171
150
  };
172
151
  }
173
152
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Lazy NAPI handle for @firecrawl/anydoc.
3
+ *
4
+ * anydoc is a native addon with per-platform optionalDependencies (darwin x64/arm64,
5
+ * linux x64/arm64 gnu+musl, win32 x64 — NO win32-arm64). A static `import` on an uncovered
6
+ * platform is a plugin-load crash. So: import() once, memoised, catch → null. Absence is a
7
+ * value; documents degrade to skipped: "no-converter".
8
+ */
9
+
10
+ /** Minimal surface we consume — never re-export the full anydoc package. */
11
+ export interface AnydocHandle {
12
+ /** Detect document format from a path's extension; null for plain text / unknown. */
13
+ readonly formatFromPath: (path: string) => string | null;
14
+ /** Convert a document file to Markdown. Rejects with Error.code = ConvertErrorCode. */
15
+ readonly toMarkdown: (path: string) => Promise<string>;
16
+ }
17
+
18
+ /**
19
+ * Document container extensions anydoc knows about, including Office macro/variants.
20
+ * Used when the native addon is absent so we still route these to "no-converter"
21
+ * instead of reading them as broken binary text.
22
+ */
23
+ const DOCUMENT_EXTENSIONS: ReadonlySet<string> = Object.freeze(new Set([
24
+ "doc", "docx", "docm", "odt", "rtf", "epub", "pdf",
25
+ "ppt", "pptx", "pptm", "ppsx", "odp",
26
+ "xls", "xlsx", "xlsm", "xlsb", "ods", "csv",
27
+ ]));
28
+
29
+ /**
30
+ * Detect a document container from a path when the anydoc handle is unavailable.
31
+ * Returns a non-null token for known extensions so the router can skip as "no-converter".
32
+ */
33
+ export function documentExtFromPath(path: string): string | null {
34
+ const base = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
35
+ const dot = base.lastIndexOf(".");
36
+ if (dot < 0) return null;
37
+ const ext = base.slice(dot + 1).toLowerCase();
38
+ return DOCUMENT_EXTENSIONS.has(ext) ? ext : null;
39
+ }
40
+
41
+ let cached: Promise<AnydocHandle | null> | undefined;
42
+
43
+ /**
44
+ * Resolve the anydoc handle once per process. Returns null when the native addon is
45
+ * missing or fails to load — callers treat documents as skipped: "no-converter".
46
+ */
47
+ export function getAnydoc(): Promise<AnydocHandle | null> {
48
+ cached ??= import("@firecrawl/anydoc")
49
+ .then((mod): AnydocHandle => Object.freeze({
50
+ formatFromPath: (p: string) => mod.formatFromPath(p),
51
+ toMarkdown: (p: string) => mod.toMarkdown(p),
52
+ }))
53
+ .catch(() => null);
54
+ return cached;
55
+ }
56
+
57
+ /**
58
+ * Test seam: force the next getAnydoc() call to return a fixed handle (or null).
59
+ * Pass `undefined` to restore the real lazy loader.
60
+ */
61
+ export function setAnydocForTest(handle: AnydocHandle | null | undefined): void {
62
+ if (handle === undefined) {
63
+ cached = undefined;
64
+ return;
65
+ }
66
+ cached = Promise.resolve(handle);
67
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Compact human-readable listing of currently loaded context files for the parent LLM.
3
+ * Shows paths and token estimates — NOT full file contents.
4
+ */
5
+
6
+ import type { ContextFile } from "./types.ts";
7
+ import { isContextFile } from "./namespace.ts";
8
+
9
+ /** Maximum files shown in the compact LLM listing before truncation. */
10
+ const MAX_LLM_LISTING_FILES = 200;
11
+
12
+ /**
13
+ * Format a context payload (ContextFile[] or empty) for injection into the parent agent's
14
+ * message stream. Empty state points at autoSeedCwd / external add_context — never
15
+ * `add_context(".")`, which would re-pack the already-seeded cwd under a ctx/ prefix.
16
+ */
17
+ export function formatContextListing(context: unknown): string {
18
+ const files = toFileList(context);
19
+ if (files.length === 0) {
20
+ return [
21
+ "RLM `context` is EMPTY — no files loaded yet.",
22
+ "The working directory seeds automatically on the first `repl()` call (autoSeedCwd).",
23
+ 'Use `add_context("/path/to/dir")` / `add_context("docs.pdf")` / `add_context("https://…")` for external sources.',
24
+ "Documents (PDF, DOCX, XLSX, PPTX, CSV, …) are converted to Markdown on the way in.",
25
+ "",
26
+ "Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
27
+ ].join("\n");
28
+ }
29
+
30
+ let totalTokens = 0;
31
+ let totalChars = 0;
32
+ for (let i = 0; i < files.length; i++) {
33
+ totalTokens += files[i].tokens;
34
+ totalChars += files[i].content.length;
35
+ }
36
+
37
+ const shown = files.slice(0, MAX_LLM_LISTING_FILES);
38
+ const truncated = files.length > MAX_LLM_LISTING_FILES
39
+ ? `... and ${files.length - MAX_LLM_LISTING_FILES} more files (truncated)`
40
+ : "";
41
+
42
+ const listingParts = new Array<string>(shown.length);
43
+ for (let i = 0; i < shown.length; i++) {
44
+ const f = shown[i];
45
+ listingParts[i] =
46
+ `${f.path} (${f.tokens.toLocaleString()} tok, ${f.content.length.toLocaleString()} chars)`;
47
+ }
48
+
49
+ return [
50
+ `Context: ${files.length.toLocaleString()} files, ${totalTokens.toLocaleString()} estimated tokens, ${totalChars.toLocaleString()} total characters.`,
51
+ "",
52
+ listingParts.join("\n"),
53
+ truncated,
54
+ "",
55
+ "File contents are loaded in the REPL `context` variable — file-reading tools are disabled.",
56
+ "Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
57
+ ].join("\n");
58
+ }
59
+
60
+ function toFileList(context: unknown): readonly ContextFile[] {
61
+ if (!Array.isArray(context)) return Object.freeze([]);
62
+ const out = new Array<ContextFile>(context.length);
63
+ let n = 0;
64
+ for (let i = 0; i < context.length; i++) {
65
+ const entry: unknown = context[i];
66
+ if (isContextFile(entry)) out[n++] = entry;
67
+ }
68
+ out.length = n;
69
+ return out;
70
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * On-disk Markdown cache for anydoc conversions, keyed by (size, mtimeMs).
3
+ *
4
+ * Cache dir: $XDG_CACHE_HOME/pi-rlm/anydoc (else ~/.cache/…).
5
+ * Per source: <name>-<sha8(absPath)>.md + .json stamp {source, size, mtimeMs}.
6
+ *
7
+ * The stamp is ALWAYS the pre-conversion (size, mtimeMs). Capturing after toMarkdown races
8
+ * with mid-conversion edits and would stamp the new mtime against the old body — every future
9
+ * read would be a permanent stale hit. writeMdCache also refuses to write when a post-conversion
10
+ * stat disagrees with the captured stamp.
11
+ *
12
+ * Write ordering is load-bearing: Markdown body first, stamp second. A crash between them
13
+ * leaves stale-body-no-stamp, which reads as a miss. All cache failures are swallowed —
14
+ * the cache is an optimisation, never a dependency.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
19
+ import { basename, join } from "node:path";
20
+ import { homedir } from "node:os";
21
+
22
+ export interface FileStamp {
23
+ readonly size: number;
24
+ readonly mtimeMs: number;
25
+ }
26
+
27
+ interface CacheStamp {
28
+ readonly source: string;
29
+ readonly size: number;
30
+ readonly mtimeMs: number;
31
+ }
32
+
33
+ function cacheRoot(): string {
34
+ const xdg = process.env.XDG_CACHE_HOME;
35
+ if (typeof xdg === "string" && xdg.trim() !== "") return join(xdg, "pi-rlm", "anydoc");
36
+ return join(homedir(), ".cache", "pi-rlm", "anydoc");
37
+ }
38
+
39
+ function entryBase(absPath: string): string {
40
+ const name = basename(absPath).replace(/[^\w.-]+/g, "-").slice(0, 80) || "doc";
41
+ const sha8 = createHash("sha256").update(absPath).digest("hex").slice(0, 8);
42
+ return `${name}-${sha8}`;
43
+ }
44
+
45
+ function isStamp(value: unknown): value is CacheStamp {
46
+ if (value === null || typeof value !== "object") return false;
47
+ const r = value as Record<string, unknown>;
48
+ return typeof r.source === "string"
49
+ && typeof r.size === "number"
50
+ && typeof r.mtimeMs === "number";
51
+ }
52
+
53
+ /** Capture (size, mtimeMs) for a source file. Returns undefined on I/O failure. */
54
+ export async function captureStamp(absPath: string): Promise<FileStamp | undefined> {
55
+ try {
56
+ const s = await stat(absPath);
57
+ return Object.freeze({ size: s.size, mtimeMs: s.mtimeMs });
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Read a cached Markdown body if the stamp still matches a fresh stat of `absPath`.
65
+ * Returns undefined on any miss or I/O failure.
66
+ */
67
+ export async function readMdCache(absPath: string): Promise<string | undefined> {
68
+ try {
69
+ const base = join(cacheRoot(), entryBase(absPath));
70
+ const stampRaw = await readFile(`${base}.json`, "utf-8");
71
+ const stamp: unknown = JSON.parse(stampRaw);
72
+ if (!isStamp(stamp)) return undefined;
73
+ const s = await stat(absPath);
74
+ if (s.size !== stamp.size || s.mtimeMs !== stamp.mtimeMs) return undefined;
75
+ return await readFile(`${base}.md`, "utf-8");
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Write Markdown body then the PRE-CAPTURED stamp. Failures are swallowed.
83
+ *
84
+ * If a post-conversion stat disagrees with `stamp`, the write is skipped (file changed
85
+ * mid-conversion — stamping the new mtime against the old body would freeze stale content).
86
+ * Body-before-stamp is load-bearing (see module docstring).
87
+ */
88
+ export async function writeMdCache(
89
+ absPath: string,
90
+ markdown: string,
91
+ stamp: FileStamp,
92
+ ): Promise<void> {
93
+ try {
94
+ // Refuse to cache if the source moved under us during conversion.
95
+ const s = await stat(absPath);
96
+ if (s.size !== stamp.size || s.mtimeMs !== stamp.mtimeMs) return;
97
+
98
+ const root = cacheRoot();
99
+ await mkdir(root, { recursive: true });
100
+ const base = join(root, entryBase(absPath));
101
+ // Body first, stamp second — crash between leaves a miss, never a wrong hit.
102
+ await writeFile(`${base}.md`, markdown, "utf-8");
103
+ const out: CacheStamp = Object.freeze({
104
+ source: absPath,
105
+ size: stamp.size,
106
+ mtimeMs: stamp.mtimeMs,
107
+ });
108
+ await writeFile(`${base}.json`, `${JSON.stringify(out)}\n`, "utf-8");
109
+ } catch {
110
+ // optimisation only
111
+ }
112
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Merge / filter helpers for the live context list.
3
+ *
4
+ * Lifted from the old library-context module with `lib/` → `ctx/` and the empty-prefix
5
+ * short-circuit for cwd sources.
6
+ */
7
+
8
+ import { estimateTokens } from "../text/tokens.ts";
9
+ import {
10
+ contextEntryPath,
11
+ isContextFile,
12
+ namespaceContextFiles,
13
+ payloadPrefix,
14
+ } from "./namespace.ts";
15
+ import type { ContextFile } from "./types.ts";
16
+
17
+ export interface FilteredContext {
18
+ readonly files: readonly ContextFile[];
19
+ /** Prefixes that selected zero files — the caller decides whether that is fatal. */
20
+ readonly unmatched: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * Narrow a context payload to entries under any of `prefixes` (plain prefix match, no globs).
25
+ *
26
+ * Backs `rlm_query(prompt, paths=[…])`. Prefix-only is deliberate: the sandbox's own filters use
27
+ * Python `fnmatch`, which has no host-side equivalent here, and a subtree prefix is what callers
28
+ * actually want — the child can still `search()` inside the slice.
29
+ */
30
+ export function filterContextByPaths(context: unknown, prefixes: readonly string[]): FilteredContext {
31
+ if (!Array.isArray(context) || prefixes.length === 0) {
32
+ return Object.freeze({ files: Object.freeze([]), unmatched: Object.freeze(Array.from(prefixes)) });
33
+ }
34
+ const hit = new Array<boolean>(prefixes.length).fill(false);
35
+ const out = new Array<ContextFile>(context.length); // pre-allocated, trimmed once below
36
+ let n = 0;
37
+ for (let i = 0; i < context.length; i++) {
38
+ const entry: unknown = context[i];
39
+ if (!isContextFile(entry)) continue;
40
+ for (let p = 0; p < prefixes.length; p++) {
41
+ if (!entry.path.startsWith(prefixes[p])) continue;
42
+ hit[p] = true;
43
+ out[n++] = entry;
44
+ break;
45
+ }
46
+ }
47
+ out.length = n;
48
+ const unmatched = new Array<string>(prefixes.length); // pre-allocated, no .push()
49
+ let u = 0;
50
+ for (let p = 0; p < prefixes.length; p++) {
51
+ if (!hit[p]) unmatched[u++] = prefixes[p];
52
+ }
53
+ unmatched.length = u;
54
+ return Object.freeze({ files: Object.freeze(out), unmatched: Object.freeze(unmatched) });
55
+ }
56
+
57
+ /**
58
+ * Append a source payload into an existing list context.
59
+ * Skips a payload whose `ctx/<id>/` prefix is already present, so a repeat load is a no-op.
60
+ *
61
+ * Cwd-seeded (un-prefixed) files never carry a `ctx/<id>/` prefix, so `payloadPrefix` returns
62
+ * undefined for them and this path-scan does not fire. Idempotency for the cwd seed is owned
63
+ * by the host `loaded` set (sentinel `""`) and the seeded-cwd absolute-path short-circuit in
64
+ * bridge/add-context.ts — not by this merge.
65
+ */
66
+ export function mergeIntoContext(base: unknown, sourcePayload: unknown): unknown {
67
+ if (!Array.isArray(base)) return base;
68
+ if (Array.isArray(sourcePayload)) {
69
+ if (sourcePayload.length === 0) return base;
70
+ const prefix = payloadPrefix(sourcePayload);
71
+ // Only `ctx/<id>/` prefixes participate in path-scan dedup (payloadPrefix never returns "").
72
+ if (prefix !== undefined) {
73
+ for (let i = 0; i < base.length; i++) {
74
+ const path = contextEntryPath(base[i]);
75
+ if (path !== undefined && path.startsWith(prefix)) return base; // already present
76
+ }
77
+ }
78
+ const merged = new Array<unknown>(base.length + sourcePayload.length);
79
+ for (let i = 0; i < base.length; i++) merged[i] = base[i];
80
+ for (let i = 0; i < sourcePayload.length; i++) merged[base.length + i] = sourcePayload[i];
81
+ return merged;
82
+ }
83
+ if (typeof sourcePayload === "string") {
84
+ // Raw string payload: wrap once under the unknown prefix.
85
+ return mergeIntoContext(base, namespaceContextFiles(sourcePayload, "unknown"));
86
+ }
87
+ return base;
88
+ }
89
+
90
+ /** Build a frozen ContextFile from path + content (shared by every source-* producer). */
91
+ export function makeContextFile(path: string, content: string): ContextFile {
92
+ return Object.freeze({
93
+ path,
94
+ content,
95
+ tokens: Math.max(1, estimateTokens(content.length)),
96
+ });
97
+ }