@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
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Shared DTOs for every context producer (text, document, directory, git).
3
+ * All producers return the same shape; nothing over ~150 lines per file.
4
+ */
5
+
6
+ /** One file entry in the sandbox `context` list. */
7
+ export interface ContextFile {
8
+ readonly path: string;
9
+ readonly content: string;
10
+ readonly tokens: number;
11
+ }
12
+
13
+ /**
14
+ * Why a path was not converted into a ContextFile.
15
+ * Closed union — never a bare string (keeps skip reasons type-safe and visible).
16
+ */
17
+ export type SkipReason =
18
+ | "binary"
19
+ | "sensitive"
20
+ | "oversized"
21
+ | "unreadable"
22
+ | "no-converter"
23
+ | "aborted"
24
+ /** Symlink whose realpath escapes the packed root (deny-list bypass). */
25
+ | "symlink-escape"
26
+ /** anydoc ConvertErrorCode values, preserved rather than collapsed. */
27
+ | "unsupported"
28
+ | "malformed"
29
+ | "encrypted"
30
+ | "resourceLimit"
31
+ | "missingPart"
32
+ | "io"
33
+ /** Fallback when an anydoc rejection carries no recognised code. */
34
+ | "convert-failed";
35
+
36
+ /** A path that was enumerated but not converted into a ContextFile. */
37
+ export interface SkippedFile {
38
+ readonly path: string;
39
+ readonly reason: SkipReason;
40
+ }
41
+
42
+ /**
43
+ * Result of resolving one source (dir / file / git URL) into a sandbox-ready payload.
44
+ * Always a namespaced (or un-prefixed for cwd) list of ContextFile.
45
+ */
46
+ export interface SourceResult {
47
+ readonly payload: readonly ContextFile[];
48
+ readonly files: number;
49
+ /** Sum of raw content lengths — what the model should size batches against. */
50
+ readonly chars: number;
51
+ readonly sourceId: string;
52
+ readonly pathPrefix: string;
53
+ /**
54
+ * Document-type files present in the payload (fresh conversions + cache hits).
55
+ * Distinct from `converted` so a second session over cached PDFs is not "0 documents".
56
+ */
57
+ readonly documents: number;
58
+ /** Documents freshly converted to Markdown this call (cache hits do NOT count). */
59
+ readonly converted: number;
60
+ /** Paths skipped (binary, sensitive, no-converter, …). Capped; unreadable filtered. */
61
+ readonly skipped: readonly SkippedFile[];
62
+ }
63
+
64
+ /** Options shared by every resolve / pack path. */
65
+ export interface ResolveOpts {
66
+ readonly cwd: string;
67
+ /**
68
+ * Namespace under which files land. `""` marks the primary/cwd source (un-prefixed paths
69
+ * so search() hits remain real paths edit/write can act on). Omit to derive `ctx/<id>/`.
70
+ */
71
+ readonly pathPrefix?: string;
72
+ readonly signal?: AbortSignal;
73
+ }
74
+
75
+ /** Single-file sources above this must use open() + llm_query_chunked in the REPL. */
76
+ export const MAX_CONTEXT_FILE_BYTES = 8 * 1024 * 1024;
77
+
78
+ /** Per-file text size cap when walking a directory (parity with the old repomix 1MB cap). */
79
+ export const MAX_WALK_FILE_BYTES = 1_048_576;
80
+
81
+ /** Per-document size cap during a directory walk (and single-file document path). */
82
+ export const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
83
+
84
+ /** Cap on model-facing skipped entries so an asset-heavy repo cannot flood the wire. */
85
+ export const MAX_SKIPPED_REPORTED = 64;
86
+
87
+ /** Catch-all prefix for a raw string payload with no namespace — never an identity key. */
88
+ export const LEGACY_UNKNOWN_PREFIX = "ctx/unknown/";
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Native repository walker — replaces repomix.
3
+ *
4
+ * git ls-files -co --exclude-standard -z IS gitignore semantics, not an approximation.
5
+ * -z because a path may legally contain a newline. Tracked-but-deleted paths are listed too;
6
+ * they fail the later read with ENOENT and are dropped there (reason "unreadable").
7
+ * Returns undefined when not a git work tree → caller falls back to walkFs.
8
+ *
9
+ * A second net beneath .gitignore: isSensitivePath() denies secrets (.env*, keys, .ssh/, .aws/)
10
+ * that repomix used to drop via useDefaultPatterns. Applied by packDirectory, not here —
11
+ * enumeration is pure listing; the router reports skipped: "sensitive".
12
+ */
13
+
14
+ import { execFile } from "node:child_process";
15
+ import type { Dirent } from "node:fs";
16
+ import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
17
+ import { basename, join, relative, resolve, sep } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import type { SkipReason } from "./types.ts";
20
+
21
+ const execFileP = promisify(execFile);
22
+
23
+ /** 8KB probe window for the NUL-byte binary check. */
24
+ const BINARY_PROBE_BYTES = 8 * 1024;
25
+
26
+ /**
27
+ * Directory names ignored by the non-git fallback walk. Dot-directories are skipped
28
+ * by default (see walkFs) except DOT_DIR_ALLOWED; this set covers non-dot noise.
29
+ * Growing array is the one walkFs exception (size unknown a priori).
30
+ */
31
+ const FALLBACK_IGNORED: ReadonlySet<string> = Object.freeze(new Set([
32
+ "node_modules", "dist", "build", "out", "coverage",
33
+ "__pycache__", "venv",
34
+ ]));
35
+
36
+ /**
37
+ * Dot-directories still walked on the non-git fallback. Git trees are unaffected
38
+ * (ls-files already lists .github/workflows etc. when tracked/unignored).
39
+ */
40
+ const DOT_DIR_ALLOWED: ReadonlySet<string> = Object.freeze(new Set([
41
+ ".github",
42
+ ]));
43
+
44
+ /**
45
+ * Deny-list beneath .gitignore. Secrets that must never enter context (and therefore never
46
+ * reach a third-party sub-LLM API). Checked against every relative path in packDirectory.
47
+ */
48
+ const SENSITIVE_BASENAME = Object.freeze([
49
+ /^\.env$/i,
50
+ /^\.env\..+/i,
51
+ /^id_rsa/i,
52
+ /^id_dsa/i,
53
+ /^id_ecdsa/i,
54
+ /^id_ed25519/i,
55
+ /\.pem$/i,
56
+ /\.key$/i,
57
+ /\.p12$/i,
58
+ /\.pfx$/i,
59
+ /\.ppk$/i,
60
+ /^\.npmrc$/i,
61
+ /^\.pypirc$/i,
62
+ /^\.netrc$/i,
63
+ /^netrc$/i,
64
+ /^\.git-credentials$/i,
65
+ ]);
66
+
67
+ const SENSITIVE_DIR_SEGMENTS: ReadonlySet<string> = Object.freeze(new Set([
68
+ ".ssh", ".aws", ".gnupg", ".kube", ".docker",
69
+ ]));
70
+
71
+ /**
72
+ * True when a cwd-relative path must not enter context.
73
+ * Matches basenames (.env*, *.pem, id_rsa*, …) and any path under .ssh/ .aws/ etc.
74
+ */
75
+ export function isSensitivePath(relPath: string): boolean {
76
+ // Match on the path as-is. git ls-files -z already emits forward-slashed paths;
77
+ // walkFs normalises to POSIX. Do NOT rewrite backslashes — a POSIX filename may contain `\`.
78
+ const segments = relPath.split("/");
79
+ for (let i = 0; i < segments.length; i++) {
80
+ const seg = segments[i];
81
+ if (seg !== undefined && SENSITIVE_DIR_SEGMENTS.has(seg)) return true;
82
+ }
83
+ const base = basename(relPath);
84
+ for (let i = 0; i < SENSITIVE_BASENAME.length; i++) {
85
+ if (SENSITIVE_BASENAME[i].test(base)) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /** Absolute path with no trailing slash (except root). */
91
+ function absKey(path: string): string {
92
+ const r = resolve(path);
93
+ return r.length > 1 && (r.endsWith("/") || r.endsWith("\\")) ? r.slice(0, -1) : r;
94
+ }
95
+
96
+ /** True when `fileAbs` is `rootAbs` or a descendant (prefix + separator). */
97
+ export function isInsideRoot(fileAbs: string, rootAbs: string): boolean {
98
+ const root = absKey(rootAbs);
99
+ const file = absKey(fileAbs);
100
+ if (file === root) return true;
101
+ const prefix = root.endsWith(sep) ? root : root + sep;
102
+ return file.startsWith(prefix);
103
+ }
104
+
105
+ export type PathSafety =
106
+ | { readonly ok: true; readonly realAbs: string }
107
+ | { readonly ok: false; readonly reason: Extract<SkipReason, "sensitive" | "symlink-escape" | "unreadable"> };
108
+
109
+ /**
110
+ * lstat first; for symlinks, realpath and refuse targets that escape `packRoot` or land on
111
+ * a sensitive path. isSensitivePath on the link name alone is not enough — `notes.txt →
112
+ * /tmp/prod.env` would otherwise leak secrets past the deny-list.
113
+ */
114
+ export async function checkPathSafety(absPath: string, packRoot: string): Promise<PathSafety> {
115
+ try {
116
+ const rootReal = await realpath(packRoot).catch(() => absKey(packRoot));
117
+ const lst = await lstat(absPath);
118
+ if (lst.isSymbolicLink()) {
119
+ let realAbs: string;
120
+ try {
121
+ realAbs = await realpath(absPath);
122
+ } catch {
123
+ return { ok: false, reason: "unreadable" };
124
+ }
125
+ if (!isInsideRoot(realAbs, rootReal)) {
126
+ return { ok: false, reason: "symlink-escape" };
127
+ }
128
+ // Sensitive check on the resolved path (relative to pack root) AND its basename.
129
+ const relFromRoot = relative(rootReal, realAbs).split(sep).join("/");
130
+ if (isSensitivePath(relFromRoot) || isSensitivePath(basename(realAbs))) {
131
+ return { ok: false, reason: "sensitive" };
132
+ }
133
+ return { ok: true, realAbs };
134
+ }
135
+ // Non-symlink: still resolve for a stable absolute path.
136
+ const realAbs = await realpath(absPath).catch(() => absKey(absPath));
137
+ return { ok: true, realAbs };
138
+ } catch {
139
+ return { ok: false, reason: "unreadable" };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * List tracked + untracked, non-ignored files via git. Returns undefined when `cwd` is not a
145
+ * git work tree (or git is unavailable) so the caller can fall back to walkFs.
146
+ *
147
+ * Paths are returned raw from git (forward-slashed). Sensitive paths are NOT filtered here —
148
+ * packDirectory reports them as skipped: "sensitive" so the drop is visible.
149
+ */
150
+ export async function gitFiles(cwd: string, signal?: AbortSignal): Promise<readonly string[] | undefined> {
151
+ try {
152
+ const { stdout } = await execFileP(
153
+ "git",
154
+ ["ls-files", "-co", "--exclude-standard", "-z"],
155
+ { cwd, signal, maxBuffer: 64 * 1024 * 1024, encoding: "buffer" },
156
+ );
157
+ if (stdout.length === 0) return Object.freeze([]);
158
+ // Split on NUL; drop the trailing empty segment git always emits after the last path.
159
+ // git ls-files -z emits raw unescaped paths, already forward-slashed — never rewrite `\`.
160
+ const parts = stdout.toString("utf-8").split("\0");
161
+ const out = new Array<string>(parts.length);
162
+ let n = 0;
163
+ for (let i = 0; i < parts.length; i++) {
164
+ const p = parts[i];
165
+ if (p !== undefined && p !== "") out[n++] = p;
166
+ }
167
+ out.length = n;
168
+ return Object.freeze(out);
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Stack-based recursive walk. Skips:
176
+ * - FALLBACK_IGNORED non-dot dirs (node_modules, dist, …)
177
+ * - Dot-directories except DOT_DIR_ALLOWED (.github) — .ssh/.aws/.git stay off the walk
178
+ * Returns cwd-relative POSIX paths. Sensitive *files* and escaping symlinks are reported
179
+ * by packDirectory after checkPathSafety.
180
+ */
181
+ export async function walkFs(root: string, signal?: AbortSignal): Promise<readonly string[]> {
182
+ const files: string[] = [];
183
+ const stack: string[] = [root];
184
+ while (stack.length > 0) {
185
+ if (signal?.aborted) break;
186
+ const dir = stack.pop();
187
+ if (dir === undefined) break;
188
+ let entries: Dirent[];
189
+ try {
190
+ // Explicit encoding keeps Dirent.name as string under @types/node ≥20.
191
+ entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" });
192
+ } catch {
193
+ continue;
194
+ }
195
+ for (const entry of entries) {
196
+ const name = entry.name;
197
+ if (name === "." || name === "..") continue;
198
+ const abs = join(dir, name);
199
+ if (entry.isDirectory()) {
200
+ if (FALLBACK_IGNORED.has(name)) continue;
201
+ // Dot-dirs blocked except carve-outs (.github/workflows is often the analysis target).
202
+ if (name.startsWith(".") && !DOT_DIR_ALLOWED.has(name)) continue;
203
+ stack.push(abs);
204
+ continue;
205
+ }
206
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
207
+ const rel = relative(root, abs).split(sep).join("/");
208
+ if (rel !== "" && !rel.startsWith("..")) files.push(rel);
209
+ }
210
+ }
211
+ return Object.freeze(files);
212
+ }
213
+
214
+ /**
215
+ * Open once, read 8KB, return true if any NUL byte is present.
216
+ * Failures (unreadable, gone) return false — the later text read will surface ENOENT.
217
+ */
218
+ export async function isBinary(absPath: string): Promise<boolean> {
219
+ let fh: Awaited<ReturnType<typeof open>> | undefined;
220
+ try {
221
+ fh = await open(absPath, "r");
222
+ const buf = Buffer.allocUnsafe(BINARY_PROBE_BYTES);
223
+ const { bytesRead } = await fh.read(buf, 0, BINARY_PROBE_BYTES, 0);
224
+ return buf.subarray(0, bytesRead).includes(0);
225
+ } catch {
226
+ return false;
227
+ } finally {
228
+ await fh?.close().catch(() => {});
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Enumerate files under `root`: git ls-files when possible, walkFs otherwise.
234
+ * Paths are cwd-relative POSIX.
235
+ */
236
+ export async function enumerateFiles(root: string, signal?: AbortSignal): Promise<readonly string[]> {
237
+ const fromGit = await gitFiles(root, signal);
238
+ if (fromGit !== undefined) return fromGit;
239
+ return await walkFs(root, signal);
240
+ }
241
+
242
+ /** True when a path exists and is a regular file (or symlink to one). */
243
+ export async function isRegularFile(absPath: string): Promise<boolean> {
244
+ try {
245
+ const s = await stat(absPath);
246
+ return s.isFile();
247
+ } catch {
248
+ return false;
249
+ }
250
+ }