@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,180 @@
1
+ /**
2
+ * Source-id / path-prefix derivation for add_context sources.
3
+ *
4
+ * Lifted from the old library-context module with `lib/` → `ctx/`. Fingerprinted source ids
5
+ * keep two sources that share a basename from colliding.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { basename, isAbsolute, resolve } from "node:path";
10
+ import { estimateTokens } from "../text/tokens.ts";
11
+ import { LEGACY_UNKNOWN_PREFIX, type ContextFile } from "./types.ts";
12
+
13
+ export interface ContextNamespace {
14
+ readonly sourceId: string;
15
+ readonly pathPrefix: string;
16
+ }
17
+
18
+ /** https://host/… or git@host:… — option-injection safe (never starts with "-"). */
19
+ export const GIT_URL = /^(https:\/\/|git@)[\w.-]+[:/]\S+$/;
20
+
21
+ /** Short, stable discriminator so two sources never share a namespace. */
22
+ function sourceFingerprint(canonical: string): string {
23
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 8);
24
+ }
25
+
26
+ /**
27
+ * Sanitize a path/url basename into a stable, filesystem-safe source id.
28
+ * `resolvedPath` (absolute path) or the git URL is fingerprinted into the id so
29
+ * distinct sources sharing a basename get distinct namespaces
30
+ * (`ctx/utils-3f9a1c02/` vs `ctx/utils-a1b2c3d4/`).
31
+ */
32
+ export function contextSourceId(source: string, resolvedPath?: string): string {
33
+ const trimmed = source.trim();
34
+ const isGit = GIT_URL.test(trimmed);
35
+ let raw: string;
36
+ if (isGit) {
37
+ const m = trimmed.match(/(?:\/|:)([\w.-]+?)(?:\.git)?\/?\s*$/);
38
+ raw = m?.[1] ?? "repo";
39
+ } else {
40
+ raw = basename(resolvedPath ?? trimmed);
41
+ }
42
+ const cleaned = raw
43
+ .replace(/\.git$/i, "")
44
+ .replace(/[^\w.-]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, 80);
47
+ const canonical = isGit ? trimmed : (resolvedPath ?? trimmed);
48
+ return `${cleaned.length > 0 ? cleaned : "ctx"}-${sourceFingerprint(canonical)}`;
49
+ }
50
+
51
+ export function pathPrefixFor(sourceId: string): string {
52
+ return `ctx/${sourceId}/`;
53
+ }
54
+
55
+ /**
56
+ * Derive the namespace for a source string without packing (host-side idempotency).
57
+ * Returns both sourceId and pathPrefix so callers never un-parse the prefix.
58
+ */
59
+ export function contextNamespace(source: string, cwd: string): ContextNamespace {
60
+ const trimmed = source.trim();
61
+ const sourceId = GIT_URL.test(trimmed)
62
+ ? contextSourceId(trimmed)
63
+ : contextSourceId(trimmed, isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed));
64
+ return Object.freeze({ sourceId, pathPrefix: pathPrefixFor(sourceId) });
65
+ }
66
+
67
+ /** Derive the path prefix for a source string without packing. */
68
+ export function contextPathPrefix(source: string, cwd: string): string {
69
+ return contextNamespace(source, cwd).pathPrefix;
70
+ }
71
+
72
+ /**
73
+ * Apply a path prefix. Empty prefix is identity — the cwd source stays un-prefixed so
74
+ * search() hits remain real paths that edit/write can act on.
75
+ */
76
+ export function applyPathPrefix(relPath: string, pathPrefix: string): string {
77
+ const cleaned = relPath.replace(/^\/+/, "");
78
+ if (pathPrefix === "") return cleaned;
79
+ if (cleaned.startsWith(pathPrefix)) return cleaned;
80
+ return `${pathPrefix}${cleaned}`;
81
+ }
82
+
83
+ /** Namespaced files plus summed content chars (one pass). */
84
+ export function namespaceContextFilesWithChars(
85
+ payload: unknown,
86
+ sourceId: string,
87
+ ): { readonly files: readonly ContextFile[]; readonly chars: number } {
88
+ const prefix = pathPrefixFor(sourceId);
89
+ if (typeof payload === "string") {
90
+ return Object.freeze({
91
+ files: Object.freeze([
92
+ Object.freeze({
93
+ path: `${prefix}content`,
94
+ content: payload,
95
+ tokens: Math.max(1, estimateTokens(payload.length)),
96
+ }),
97
+ ]),
98
+ chars: payload.length,
99
+ });
100
+ }
101
+ if (!Array.isArray(payload)) return Object.freeze({ files: Object.freeze([]), chars: 0 });
102
+ const out = new Array<ContextFile>(payload.length);
103
+ let n = 0;
104
+ let chars = 0;
105
+ for (const item of payload) {
106
+ if (item === null || typeof item !== "object") continue;
107
+ const rec = item as Record<string, unknown>;
108
+ const content = typeof rec.content === "string" ? rec.content : String(rec.content ?? "");
109
+ let path = typeof rec.path === "string" ? rec.path : "unknown";
110
+ path = applyPathPrefix(path, prefix);
111
+ const tokens = typeof rec.tokens === "number" && Number.isFinite(rec.tokens)
112
+ ? Math.max(0, Math.floor(rec.tokens))
113
+ : Math.max(1, estimateTokens(content.length));
114
+ out[n++] = Object.freeze({ path, content, tokens });
115
+ chars += content.length;
116
+ }
117
+ out.length = n;
118
+ return Object.freeze({ files: Object.freeze(out), chars });
119
+ }
120
+
121
+ /** Namespace file entries under `ctx/<sourceId>/…` (single shared implementation). */
122
+ export function namespaceContextFiles(
123
+ payload: unknown,
124
+ sourceId: string,
125
+ ): readonly ContextFile[] {
126
+ return namespaceContextFilesWithChars(payload, sourceId).files;
127
+ }
128
+
129
+ /** The one `ctx/<id>/` matcher. Never re-declare this regex; use the helpers below. */
130
+ const CTX_PREFIX_RE = /^(ctx\/[^/]+\/)/;
131
+
132
+ /** Narrow an unknown context entry to a ContextFile. Type guard, never a cast. */
133
+ export function isContextFile(entry: unknown): entry is ContextFile {
134
+ if (entry === null || typeof entry !== "object") return false;
135
+ return "path" in entry && typeof entry.path === "string"
136
+ && "content" in entry && typeof entry.content === "string";
137
+ }
138
+
139
+ /** `path` of a context entry, or undefined when the entry is not file-shaped. */
140
+ export function contextEntryPath(entry: unknown): string | undefined {
141
+ return isContextFile(entry) ? entry.path : undefined;
142
+ }
143
+
144
+ /** The `ctx/<id>/` prefix owning this path, or undefined. Skips the legacy catch-all. */
145
+ function ctxPrefixOf(path: string): string | undefined {
146
+ const prefix = CTX_PREFIX_RE.exec(path)?.[1];
147
+ // `ctx/unknown/` is the legacy catch-all: never treat it as an identity.
148
+ return prefix === undefined || prefix === LEGACY_UNKNOWN_PREFIX ? undefined : prefix;
149
+ }
150
+
151
+ /** First `ctx/<id>/` prefix found in the payload, or undefined. Skips `ctx/unknown/`. */
152
+ export function payloadPrefix(payload: readonly unknown[]): string | undefined {
153
+ for (let i = 0; i < payload.length; i++) {
154
+ const path = contextEntryPath(payload[i]);
155
+ if (path === undefined) continue;
156
+ const prefix = ctxPrefixOf(path);
157
+ if (prefix !== undefined) return prefix;
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /**
163
+ * Every distinct `ctx/<id>/` prefix present in a context payload.
164
+ *
165
+ * The loaded-prefix set in bridge/add-context.ts is a CACHE of this — derived state, never
166
+ * independent state, so it may only be cleared by re-deriving it from the live payload.
167
+ * Empty-prefix (cwd) sources are intentionally invisible here — the host `loaded` set holds
168
+ * `""` as its sentinel for those.
169
+ */
170
+ export function contextPrefixesIn(context: unknown): readonly string[] {
171
+ if (!Array.isArray(context)) return Object.freeze([]);
172
+ const seen = new Set<string>();
173
+ for (let i = 0; i < context.length; i++) {
174
+ const path = contextEntryPath(context[i]);
175
+ if (path === undefined) continue;
176
+ const prefix = ctxPrefixOf(path);
177
+ if (prefix !== undefined) seen.add(prefix);
178
+ }
179
+ return Object.freeze(Array.from(seen));
180
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * One router: source string → SourceResult.
3
+ * git URL → source-git · dir → source-dir · file → document? source-doc : source-text.
4
+ */
5
+
6
+ import { basename, isAbsolute, resolve } from "node:path";
7
+ import { stat } from "node:fs/promises";
8
+ import type { Result } from "../util/errors.ts";
9
+ import { documentExtFromPath, getAnydoc } from "./anydoc.ts";
10
+ import { GIT_URL, contextSourceId, pathPrefixFor } from "./namespace.ts";
11
+ import { documentToContextFile } from "./source-doc.ts";
12
+ import { sourceDir } from "./source-dir.ts";
13
+ import { sourceGit } from "./source-git.ts";
14
+ import { textToContextFile } from "./source-text.ts";
15
+ import { checkPathSafety, isSensitivePath } from "./walk.ts";
16
+ import {
17
+ MAX_CONTEXT_FILE_BYTES,
18
+ type ResolveOpts,
19
+ type SourceResult,
20
+ } from "./types.ts";
21
+
22
+ function emptySkipped(): SourceResult["skipped"] {
23
+ return Object.freeze([]);
24
+ }
25
+
26
+ function singleFileResult(
27
+ file: SourceResult["payload"][number],
28
+ sourceId: string,
29
+ pathPrefix: string,
30
+ documents: number,
31
+ converted: number,
32
+ skipped: SourceResult["skipped"] = emptySkipped(),
33
+ ): SourceResult {
34
+ return Object.freeze({
35
+ payload: Object.freeze([file]),
36
+ files: 1,
37
+ chars: file.content.length,
38
+ sourceId,
39
+ pathPrefix,
40
+ documents,
41
+ converted,
42
+ skipped,
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Resolve a source string (local path or git URL) into a sandbox-ready SourceResult.
48
+ * `pathPrefix: ""` marks the primary/cwd source (un-prefixed paths).
49
+ */
50
+ export async function resolveSource(
51
+ source: string,
52
+ opts: ResolveOpts,
53
+ ): Promise<Result<SourceResult, string>> {
54
+ const trimmed = source.trim();
55
+ if (trimmed === "") return { ok: false, error: "add_context: empty source" };
56
+ if (GIT_URL.test(trimmed)) return await sourceGit(trimmed, opts);
57
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
58
+ return { ok: false, error: `unsupported URL scheme (only https:// and git@ are allowed): ${trimmed}` };
59
+ }
60
+ const path = isAbsolute(trimmed) ? trimmed : resolve(opts.cwd, trimmed);
61
+ let s: Awaited<ReturnType<typeof stat>>;
62
+ try {
63
+ s = await stat(path);
64
+ } catch {
65
+ return { ok: false, error: `add_context: path not found: ${path}` };
66
+ }
67
+ const sourceId = contextSourceId(trimmed, path);
68
+ const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
69
+
70
+ if (s.isDirectory()) {
71
+ return { ok: true, value: await sourceDir(path, trimmed, { ...opts, pathPrefix }) };
72
+ }
73
+
74
+ // Single-file secret refuse — never pack a .env / key into context for a sub-LLM.
75
+ const rel = basename(path);
76
+ if (isSensitivePath(rel) || isSensitivePath(trimmed.replace(/^\.\//, ""))) {
77
+ return { ok: false, error: `add_context: refused sensitive path: ${path}` };
78
+ }
79
+
80
+ // Symlink safety for single-file: refuse escape of cwd / resolved sensitive targets.
81
+ const safety = await checkPathSafety(path, opts.cwd);
82
+ if (!safety.ok) {
83
+ return {
84
+ ok: false,
85
+ error: `add_context: refused ${safety.reason} path: ${path}`,
86
+ };
87
+ }
88
+
89
+ // Single file — preserve oversize wording (phase-context asserts on limit + llm_query_chunked).
90
+ if (s.size > MAX_CONTEXT_FILE_BYTES) {
91
+ return {
92
+ ok: false,
93
+ error: `add_context: ${path} is ${s.size.toLocaleString()} bytes `
94
+ + `(limit ${MAX_CONTEXT_FILE_BYTES.toLocaleString()}) — `
95
+ + "open() it in the REPL and delegate with llm_query_chunked instead",
96
+ };
97
+ }
98
+
99
+ const anydoc = await getAnydoc();
100
+ // formatFromPath is extension-based; one call on the path is enough.
101
+ const format = anydoc?.formatFromPath(path) ?? documentExtFromPath(path);
102
+ if (format !== null) {
103
+ const doc = await documentToContextFile(safety.realAbs, rel, pathPrefix, anydoc);
104
+ if (!doc.ok) {
105
+ return { ok: false, error: `add_context: could not convert ${path} (${doc.skipped.reason})` };
106
+ }
107
+ return {
108
+ ok: true,
109
+ value: singleFileResult(
110
+ doc.value, sourceId, pathPrefix,
111
+ 1, // documents
112
+ doc.converted ? 1 : 0,
113
+ ),
114
+ };
115
+ }
116
+
117
+ const text = await textToContextFile(safety.realAbs, rel, pathPrefix, MAX_CONTEXT_FILE_BYTES);
118
+ if (!text.ok) {
119
+ return { ok: false, error: `add_context: could not read ${path} (${text.skipped.reason})` };
120
+ }
121
+ return { ok: true, value: singleFileResult(text.value, sourceId, pathPrefix, 0, 0) };
122
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Directory pack: walk + per-file routing (bounded pool).
3
+ *
4
+ * Routing order is load-bearing:
5
+ * sensitive (link name)? → skipped: "sensitive"
6
+ * checkPathSafety (lstat/realpath; escape / resolved-sensitive)
7
+ * format != null → documentToContextFile() ← MUST come before isBinary
8
+ * else → textToContextFile()
9
+ *
10
+ * A .docx IS binary. Reversed, every document in the tree would be dropped as an asset —
11
+ * precisely the bug this change exists to fix.
12
+ */
13
+
14
+ import { join } from "node:path";
15
+ import { documentExtFromPath, getAnydoc, type AnydocHandle } from "./anydoc.ts";
16
+ import { Semaphore } from "../util/concurrency.ts";
17
+ import { documentToContextFile } from "./source-doc.ts";
18
+ import { textToContextFile } from "./source-text.ts";
19
+ import { checkPathSafety, enumerateFiles, isSensitivePath } from "./walk.ts";
20
+ import {
21
+ MAX_SKIPPED_REPORTED,
22
+ type ContextFile,
23
+ type ResolveOpts,
24
+ type SkippedFile,
25
+ type SourceResult,
26
+ } from "./types.ts";
27
+ import { applyPathPrefix, contextSourceId, pathPrefixFor } from "./namespace.ts";
28
+
29
+ /** Conversions are libuv-threadpool bound (default pool 4); 8 keeps the pool busy without thrash. */
30
+ const CONVERT_CONCURRENCY = 8;
31
+
32
+ export interface PackDirResult {
33
+ readonly files: readonly ContextFile[];
34
+ readonly chars: number;
35
+ readonly documents: number;
36
+ readonly converted: number;
37
+ readonly skipped: readonly SkippedFile[];
38
+ }
39
+
40
+ /**
41
+ * Walk `dir` and produce ContextFiles. Resolves the anydoc handle once per source
42
+ * (not per file) and threads it into the per-file router.
43
+ */
44
+ export async function packDirectory(
45
+ dir: string,
46
+ pathPrefix: string,
47
+ signal?: AbortSignal,
48
+ anydoc?: AnydocHandle | null,
49
+ ): Promise<PackDirResult> {
50
+ const paths = await enumerateFiles(dir, signal);
51
+ if (paths.length === 0) {
52
+ return Object.freeze({
53
+ files: Object.freeze([]),
54
+ chars: 0,
55
+ documents: 0,
56
+ converted: 0,
57
+ skipped: Object.freeze([]),
58
+ });
59
+ }
60
+
61
+ // One handle check per source, not per file.
62
+ const handle = anydoc !== undefined ? anydoc : await getAnydoc();
63
+ const gate = new Semaphore(CONVERT_CONCURRENCY);
64
+ // Pre-allocated slots; compacted in one pass with a single length trim — house style.
65
+ const slots = new Array<ContextFile | undefined>(paths.length);
66
+ const drops = new Array<SkippedFile | undefined>(paths.length);
67
+ const didConvert = new Array<boolean>(paths.length); // true = fresh anydoc conversion
68
+ const isDoc = new Array<boolean>(paths.length); // true = document-type entry in payload
69
+
70
+ await Promise.all(paths.map((rel, i) => gate.run(async () => {
71
+ if (signal?.aborted) {
72
+ drops[i] = Object.freeze({ path: applyPathPrefix(rel, pathPrefix), reason: "aborted" });
73
+ return;
74
+ }
75
+ // Sensitive deny-list on the enumerated name — second net under .gitignore.
76
+ if (isSensitivePath(rel)) {
77
+ drops[i] = Object.freeze({ path: applyPathPrefix(rel, pathPrefix), reason: "sensitive" });
78
+ return;
79
+ }
80
+ const abs = join(dir, rel);
81
+ // Symlink safety: realpath, refuse escape of pack root, re-check sensitive on target.
82
+ const safety = await checkPathSafety(abs, dir);
83
+ if (!safety.ok) {
84
+ drops[i] = Object.freeze({ path: applyPathPrefix(rel, pathPrefix), reason: safety.reason });
85
+ return;
86
+ }
87
+ const readAbs = safety.realAbs;
88
+ // Document router FIRST — a .docx is binary and would be dropped by the text path.
89
+ // When anydoc is absent, still detect by extension so documents skip as "no-converter".
90
+ const format = handle?.formatFromPath(rel) ?? documentExtFromPath(rel);
91
+ if (format !== null) {
92
+ const doc = await documentToContextFile(readAbs, rel, pathPrefix, handle);
93
+ if (doc.ok) {
94
+ slots[i] = doc.value;
95
+ didConvert[i] = doc.converted;
96
+ isDoc[i] = true;
97
+ } else {
98
+ drops[i] = doc.skipped;
99
+ }
100
+ return;
101
+ }
102
+ const text = await textToContextFile(readAbs, rel, pathPrefix);
103
+ if (text.ok) {
104
+ slots[i] = text.value;
105
+ didConvert[i] = false;
106
+ isDoc[i] = false;
107
+ } else {
108
+ drops[i] = text.skipped;
109
+ }
110
+ })));
111
+
112
+ const files = new Array<ContextFile>(paths.length);
113
+ const skipped = new Array<SkippedFile>(paths.length);
114
+ let nFiles = 0;
115
+ let nSkip = 0;
116
+ let chars = 0;
117
+ let converted = 0;
118
+ let documents = 0;
119
+ for (let i = 0; i < paths.length; i++) {
120
+ const f = slots[i];
121
+ if (f !== undefined) {
122
+ files[nFiles++] = f;
123
+ chars += f.content.length;
124
+ if (didConvert[i]) converted += 1;
125
+ if (isDoc[i]) documents += 1;
126
+ continue;
127
+ }
128
+ const d = drops[i];
129
+ // Filter unreadable from the model-facing skip list (ENOENT on deleted-tracked paths).
130
+ // Cap so an asset-heavy repo cannot flood the wire / reply frame.
131
+ if (d !== undefined && d.reason !== "unreadable" && nSkip < MAX_SKIPPED_REPORTED) {
132
+ skipped[nSkip++] = d;
133
+ }
134
+ }
135
+ files.length = nFiles;
136
+ skipped.length = nSkip;
137
+ return Object.freeze({
138
+ files: Object.freeze(files),
139
+ chars,
140
+ documents,
141
+ converted,
142
+ skipped: Object.freeze(skipped),
143
+ });
144
+ }
145
+
146
+ /** Pack a directory into a SourceResult with derived (or explicit) namespace. */
147
+ export async function sourceDir(
148
+ dir: string,
149
+ source: string,
150
+ opts: ResolveOpts,
151
+ ): Promise<SourceResult> {
152
+ const sourceId = contextSourceId(source, dir);
153
+ // Explicit pathPrefix (including "") wins; otherwise derive ctx/<id>/.
154
+ const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
155
+ const packed = await packDirectory(dir, pathPrefix, opts.signal);
156
+ return Object.freeze({
157
+ payload: packed.files,
158
+ files: packed.files.length,
159
+ chars: packed.chars,
160
+ sourceId,
161
+ pathPrefix,
162
+ documents: packed.documents,
163
+ converted: packed.converted,
164
+ skipped: packed.skipped,
165
+ });
166
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Document container → anydoc → Markdown → ContextFile, with on-disk MD cache.
3
+ *
4
+ * Stamp is captured BEFORE conversion so mid-edit races cannot freeze a stale body forever.
5
+ * Document size is capped at MAX_DOCUMENT_BYTES (walk and single-file paths share this).
6
+ */
7
+
8
+ import type { AnydocHandle } from "./anydoc.ts";
9
+ import { captureStamp, readMdCache, writeMdCache } from "./md-cache.ts";
10
+ import { makeContextFile } from "./merge.ts";
11
+ import { applyPathPrefix } from "./namespace.ts";
12
+ import {
13
+ MAX_DOCUMENT_BYTES,
14
+ type ContextFile,
15
+ type SkipReason,
16
+ type SkippedFile,
17
+ } from "./types.ts";
18
+
19
+ export type DocFileResult =
20
+ | { readonly ok: true; readonly value: ContextFile; /** true only for a real anydoc conversion */ readonly converted: boolean }
21
+ | { readonly ok: false; readonly skipped: SkippedFile };
22
+
23
+ function skipReasonFromError(err: unknown): SkipReason {
24
+ if (err !== null && typeof err === "object" && "code" in err) {
25
+ const code: unknown = err.code;
26
+ // Preserve anydoc ConvertErrorCode values rather than collapsing to "convert-failed".
27
+ if (code === "unsupported" || code === "malformed" || code === "encrypted"
28
+ || code === "resourceLimit" || code === "missingPart" || code === "io") {
29
+ return code;
30
+ }
31
+ }
32
+ return "convert-failed";
33
+ }
34
+
35
+ /**
36
+ * Convert a document file to a ContextFile via anydoc.
37
+ * Cache hits do not increment `converted`. Failures degrade to skipped, never throw.
38
+ */
39
+ export async function documentToContextFile(
40
+ absPath: string,
41
+ relPath: string,
42
+ pathPrefix: string,
43
+ anydoc: AnydocHandle | null,
44
+ ): Promise<DocFileResult> {
45
+ const path = applyPathPrefix(relPath, pathPrefix);
46
+ if (anydoc === null) {
47
+ return { ok: false, skipped: Object.freeze({ path, reason: "no-converter" }) };
48
+ }
49
+
50
+ const stamp = await captureStamp(absPath);
51
+ if (stamp === undefined) {
52
+ return { ok: false, skipped: Object.freeze({ path, reason: "unreadable" }) };
53
+ }
54
+ if (stamp.size > MAX_DOCUMENT_BYTES) {
55
+ return { ok: false, skipped: Object.freeze({ path, reason: "oversized" }) };
56
+ }
57
+
58
+ try {
59
+ const cached = await readMdCache(absPath);
60
+ if (cached !== undefined) {
61
+ // Cache hit — document is in context, but no conversion was performed.
62
+ return { ok: true, value: makeContextFile(path, cached), converted: false };
63
+ }
64
+ const markdown = await anydoc.toMarkdown(absPath);
65
+ // Pass the PRE-conversion stamp — never re-stat after convert (stale-forever bug).
66
+ await writeMdCache(absPath, markdown, stamp);
67
+ return { ok: true, value: makeContextFile(path, markdown), converted: true };
68
+ } catch (err: unknown) {
69
+ return { ok: false, skipped: Object.freeze({ path, reason: skipReasonFromError(err) }) };
70
+ }
71
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shallow-clone a git URL, then pack with source-dir.
3
+ */
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { mkdtemp, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ import { errorMessage, type Result } from "../util/errors.ts";
11
+ import { contextSourceId, pathPrefixFor } from "./namespace.ts";
12
+ import { packDirectory } from "./source-dir.ts";
13
+ import type { ResolveOpts, SourceResult } from "./types.ts";
14
+
15
+ const execFileP = promisify(execFile);
16
+
17
+ /** Shallow-clone `url` into a temp dir, pack, then remove the clone. */
18
+ export async function sourceGit(
19
+ url: string,
20
+ opts: ResolveOpts,
21
+ ): Promise<Result<SourceResult, string>> {
22
+ const dir = await mkdtemp(join(tmpdir(), "rlm-ctx-"));
23
+ const sourceId = contextSourceId(url);
24
+ const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
25
+ try {
26
+ await execFileP("git", ["clone", "--depth", "1", "--", url, dir], {
27
+ signal: opts.signal,
28
+ timeout: 120_000,
29
+ // Never stall on a credential helper for a private URL — fail fast instead.
30
+ env: Object.freeze({ ...process.env, GIT_TERMINAL_PROMPT: "0" }),
31
+ });
32
+ const packed = await packDirectory(dir, pathPrefix, opts.signal);
33
+ return {
34
+ ok: true,
35
+ value: Object.freeze({
36
+ payload: packed.files,
37
+ files: packed.files.length,
38
+ chars: packed.chars,
39
+ sourceId,
40
+ pathPrefix,
41
+ documents: packed.documents,
42
+ converted: packed.converted,
43
+ skipped: packed.skipped,
44
+ }),
45
+ };
46
+ } catch (err: unknown) {
47
+ return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
48
+ } finally {
49
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
50
+ }
51
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Plain (non-document) file → ContextFile.
3
+ * The isBinary probe lives HERE — documents are routed earlier by formatFromPath.
4
+ */
5
+
6
+ import { readFile, stat } from "node:fs/promises";
7
+ import { makeContextFile } from "./merge.ts";
8
+ import { applyPathPrefix } from "./namespace.ts";
9
+ import { isBinary } from "./walk.ts";
10
+ import { MAX_WALK_FILE_BYTES, type ContextFile, type SkippedFile } from "./types.ts";
11
+
12
+ export type TextFileResult =
13
+ | { readonly ok: true; readonly value: ContextFile }
14
+ | { readonly ok: false; readonly skipped: SkippedFile };
15
+
16
+ /**
17
+ * Read a plain-text file into a ContextFile.
18
+ * Skips binaries (NUL probe), oversize files, and unreadable paths.
19
+ */
20
+ export async function textToContextFile(
21
+ absPath: string,
22
+ relPath: string,
23
+ pathPrefix: string,
24
+ maxBytes: number = MAX_WALK_FILE_BYTES,
25
+ ): Promise<TextFileResult> {
26
+ const path = applyPathPrefix(relPath, pathPrefix);
27
+ try {
28
+ const s = await stat(absPath);
29
+ if (!s.isFile()) {
30
+ return { ok: false, skipped: Object.freeze({ path, reason: "unreadable" }) };
31
+ }
32
+ if (s.size > maxBytes) {
33
+ return { ok: false, skipped: Object.freeze({ path, reason: "oversized" }) };
34
+ }
35
+ // Binary probe AFTER the document router has already claimed known containers —
36
+ // a .docx IS binary; routing order in source-dir is load-bearing.
37
+ if (await isBinary(absPath)) {
38
+ return { ok: false, skipped: Object.freeze({ path, reason: "binary" }) };
39
+ }
40
+ const content = await readFile(absPath, "utf-8");
41
+ return { ok: true, value: makeContextFile(path, content) };
42
+ } catch {
43
+ return { ok: false, skipped: Object.freeze({ path, reason: "unreadable" }) };
44
+ }
45
+ }