@hicaru/pi-rlm 0.2.2 → 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 (47) hide show
  1. package/README.md +20 -16
  2. package/README.ru.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +1 -1
  7. package/src/config/defaults.ts +2 -1
  8. package/src/config/settings.ts +5 -2
  9. package/src/context/anydoc.ts +67 -0
  10. package/src/context/listing.ts +70 -0
  11. package/src/context/md-cache.ts +112 -0
  12. package/src/context/merge.ts +97 -0
  13. package/src/context/namespace.ts +180 -0
  14. package/src/context/resolve.ts +122 -0
  15. package/src/context/source-dir.ts +166 -0
  16. package/src/context/source-doc.ts +71 -0
  17. package/src/context/source-git.ts +51 -0
  18. package/src/context/source-text.ts +45 -0
  19. package/src/context/types.ts +88 -0
  20. package/src/context/walk.ts +250 -0
  21. package/src/core/engine.ts +15 -19
  22. package/src/core/types.ts +7 -2
  23. package/src/index.ts +69 -42
  24. package/src/mode/rlm-mode.ts +5 -4
  25. package/src/prompts/glossary.ts +31 -28
  26. package/src/prompts/native.ts +4 -4
  27. package/src/prompts/system.ts +2 -2
  28. package/src/sandbox/context-file.ts +4 -4
  29. package/src/sandbox/interrupts.ts +25 -10
  30. package/src/sandbox/protocol.ts +13 -7
  31. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  32. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/guards.py +1 -1
  35. package/src/sandbox/py/retrieval.py +1 -1
  36. package/src/sandbox/py/tasks.py +17 -4
  37. package/src/sandbox/py/worker.py +68 -48
  38. package/src/sandbox/sandbox-manager.ts +18 -16
  39. package/src/sandbox/sandbox.ts +1 -1
  40. package/src/text/tokens.ts +3 -3
  41. package/src/tool/repl-details.ts +1 -1
  42. package/src/tool/repl-tool.ts +31 -19
  43. package/src/tool/rlm-tool.ts +1 -1
  44. package/src/ui/config-panel.ts +8 -4
  45. package/src/bridge/library.ts +0 -190
  46. package/src/context/library-context.ts +0 -339
  47. package/src/context/repomix-context.ts +0 -204
@@ -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
+ }
@@ -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
+ }