@hicaru/pi-rlm 0.2.2 → 0.3.1
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 +38 -16
- package/README.ru.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +1 -1
- package/src/config/defaults.ts +2 -1
- package/src/config/settings.ts +5 -2
- 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 +15 -19
- package/src/core/types.ts +7 -2
- package/src/index.ts +119 -47
- package/src/mode/rlm-mode.ts +5 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +31 -28
- package/src/prompts/native.ts +4 -4
- package/src/prompts/system.ts +2 -2
- package/src/sandbox/context-file.ts +4 -4
- package/src/sandbox/interrupts.ts +25 -10
- package/src/sandbox/protocol.ts +13 -7
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/hostio.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 +8 -1
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +1 -1
- package/src/sandbox/py/tasks.py +17 -4
- package/src/sandbox/py/worker.py +71 -52
- package/src/sandbox/sandbox-manager.ts +18 -16
- package/src/sandbox/sandbox.ts +9 -2
- package/src/text/tokens.ts +3 -3
- package/src/tool/repl-details.ts +1 -1
- package/src/tool/repl-tool.ts +31 -19
- package/src/tool/rlm-tool.ts +1 -1
- package/src/ui/config-panel.ts +8 -4
- package/src/bridge/library.ts +0 -190
- package/src/context/library-context.ts +0 -339
- package/src/context/repomix-context.ts +0 -204
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|