feat(sources v1): the agent locates files itself — ls/find/grep + tool-call cards
User pivot (2026-09-19): no attachments UI at all. Pasting paths is native for technical users and alien for everyone else - so the agent gets the tools to find what the learner merely NAMES. The learner communicates in messages; the agent locates and reads. Zero cross-stream seams: paths already live in tool parts, so the sources "list" is the transcript. - ls/find/grep: pi-verbatim schemas, descriptions, and output formats (fd's glob semantics, rg's path:line: match format, pi's truncation notices), ported to pure Node - no binaries, no shell-out, read-only by construction. Deltas from pi: home-anchored resolution (the harness has no cwd), .gitignore replaced by hard noise exclusions (node_modules/.git/Library/.Trash), 10s walk timeout with partial results. - read: description swapped to pi-verbatim (doctrine moved to the prompt); relative paths now resolve against the learner's home like the rest. - prompts/shared/05-learner-files.md: one minimal doctrine fragment composed into BOTH agents (composeInstructions grew a shared/ dir, before the agent's own fragments). teacher/30-jobs.md keeps only the pdf-dispatch addendum. - ToolCallCard: the generic quiet tool-call card the user specced - collapsed one-liner (tool name + headline argument), expandable to full input/output, errors red, image payloads never render. Registered for read/ls/find/grep. - ui-message.ts: TeacherUITools entries for the four tools (mechanical). - tests: fs-tools suite (glob semantics, pi formats, exclusions, limits), registry/card renders, shared-fragment composition pins, tool-registry key updates, read re-anchor.
This commit is contained in:
@@ -8,6 +8,10 @@ System prompts are composed at agent construction (src/agent/teacher-agent.ts),
|
||||
Rules for adding a fragment:
|
||||
|
||||
- Name it with a numeric order prefix, inside the right agent directory.
|
||||
- `shared/` fragments compose into BOTH agents (before their own, after the base) —
|
||||
cross-agent doctrine only (e.g. `05-learner-files.md`); the "never reference the
|
||||
other agent's tools" rule below still applies, so shared fragments may only cover
|
||||
tools both agents actually hold.
|
||||
- One fragment per workstream — your brief tells you which filename you own.
|
||||
- Document YOUR agent's tools/behaviors exactly; reference tool input field names verbatim (they must match the zod schemas).
|
||||
- Keep it tight: fragments are always in context. No restating the base prompt.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
## Learner files
|
||||
|
||||
The learner's files live on his machine. He references them in messages — by full path,
|
||||
by name, or by description ("my calculus notes"). Locating and reading them is your job;
|
||||
never ask him to paste file contents into the chat.
|
||||
|
||||
- Paths resolve against the learner's home directory: relative paths and omitted search
|
||||
roots mean "somewhere in the home directory".
|
||||
- The learner may name a file without saying where it is: find it yourself with `find`
|
||||
(glob by filename) or narrow with `ls` before reading. A distinctive name is enough.
|
||||
- `grep` searches file contents across a directory — use it when the file is found but
|
||||
the right passage isn't, or to locate material by phrase.
|
||||
- Read what you locate with `read` (text in slices, images as attachments). These tools
|
||||
are read-only: you cannot and should not modify anything on his machine.
|
||||
@@ -34,14 +34,9 @@ without it (offering to re-dispatch is fine if the learner wants the result).
|
||||
|
||||
## Learner source files
|
||||
|
||||
The learner may paste **file paths** into the chat — course PDFs, notes, screenshots, code.
|
||||
Reading them is on your own terms; nothing is injected automatically.
|
||||
|
||||
- For quick looks at plain text or image files, call `read` directly (it returns the text or
|
||||
shows you the image; large files come back in slices with continuation offsets).
|
||||
- For PDFs, dispatch a `pdf` job instead: it runs a background reader (triage, targeted
|
||||
extraction, visual page reading) and returns a **brief with page citations** as a new
|
||||
message — the same fire-and-forget discipline as research above. Give it the path exactly
|
||||
as the learner gave it plus the specific question you need answered.
|
||||
- Ask for the path when the learner references a file they haven't given you — never ask them
|
||||
to paste file contents into the chat.
|
||||
Locating and reading the learner's files is covered by the shared file doctrine above
|
||||
(find/ls/grep/read, home-anchored). One addition is yours alone: for **PDFs**, dispatch a
|
||||
`pdf` job instead of `read` — it runs a background reader (triage, targeted extraction,
|
||||
visual page reading) and returns a **brief with page citations** as a new message, the
|
||||
same fire-and-forget discipline as research above. Give it the path plus the specific
|
||||
question you need answered.
|
||||
|
||||
@@ -47,17 +47,24 @@ const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
|
||||
/** A prompt fragment: numeric order key + .md (e.g. 20-teaching.md). Nothing else loads. */
|
||||
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
|
||||
|
||||
/** Exported for tests: assert what composes into the model's prompt. */
|
||||
export function composeInstructions(kind: AgentKind): string {
|
||||
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
||||
const dir = path.join(PROMPTS_DIR, kind);
|
||||
if (!fs.existsSync(dir)) return base;
|
||||
const fragments = fs
|
||||
/** Load one directory's fragments (sorted filename order, trimmed,
|
||||
* empty ones dropped) — the loader enforces the numeric prefix. */
|
||||
function loadFragments(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => FRAGMENT_RE.test(f))
|
||||
.sort()
|
||||
.map((f) => fs.readFileSync(path.join(dir, f), "utf-8").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Exported for tests: assert what composes into the model's prompt.
|
||||
* Composition order: shared base → shared/ fragments (both agents, the
|
||||
* sources-v1 seam for cross-agent doctrine) → the agent's own fragments. */
|
||||
export function composeInstructions(kind: AgentKind): string {
|
||||
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
||||
const fragments = [...loadFragments(path.join(PROMPTS_DIR, "shared")), ...loadFragments(path.join(PROMPTS_DIR, kind))];
|
||||
return [base.trim(), ...fragments].join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -12,6 +12,9 @@ import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "
|
||||
import { planTools, type PlanToolContext } from "./tools/plan.ts";
|
||||
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
||||
import { readTool } from "./tools/read.ts";
|
||||
import { lsTool } from "./tools/ls.ts";
|
||||
import { findTool } from "./tools/find.ts";
|
||||
import { grepTool } from "./tools/grep.ts";
|
||||
|
||||
export { jobTools };
|
||||
export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||
@@ -81,9 +84,13 @@ export function createProberTools(
|
||||
{
|
||||
quiz: probeQuizTool,
|
||||
ask_user_question: askUserQuestionTool,
|
||||
// read is always-on (user-decided 2026-09): the prober inspects
|
||||
// learner-provided source files while assessing the edge.
|
||||
// The learner-filesystem set is always-on (user-decided 2026-09,
|
||||
// sources v1): the prober inspects and LOCATES learner-referenced
|
||||
// files while assessing the edge. Read-only by construction.
|
||||
read: readTool,
|
||||
ls: lsTool,
|
||||
find: findTool,
|
||||
grep: grepTool,
|
||||
...done,
|
||||
},
|
||||
enabled,
|
||||
@@ -168,9 +175,14 @@ export function createTeacherTools(
|
||||
{
|
||||
quiz: teachQuizToolFor(ctx),
|
||||
ask_user_question: askUserQuestionTool,
|
||||
// read is always-on (user-decided 2026-09): the teacher reads
|
||||
// learner-provided sources on its own terms — nothing auto-injects.
|
||||
// The learner-filesystem set is always-on (user-decided 2026-09,
|
||||
// sources v1): the teacher locates and reads learner-referenced
|
||||
// files on its own terms — nothing auto-injects. Read-only by
|
||||
// construction (fs-shared.ts).
|
||||
read: readTool,
|
||||
ls: lsTool,
|
||||
find: findTool,
|
||||
grep: grepTool,
|
||||
...planTools(ctx),
|
||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
MAX_OUTPUT_BYTES,
|
||||
matchGlob,
|
||||
resolveSourcePath,
|
||||
walkFiles,
|
||||
} from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
* The find tool — sources v1 (2026-09-19): pi's dist/core/tools/find.js
|
||||
* ported to a pure-Node walker (pi shells out to fd; no binaries here —
|
||||
* Electron/Windows-safe, fs-shared.ts). Schemas, fd's glob semantics
|
||||
* (basename-mode without a slash, implied deep prefix with one), relativized output,
|
||||
* and the notice formats are pi-verbatim; the deltas: home-anchored
|
||||
* default, .gitignore replaced by hard noise exclusions, and a wall-clock
|
||||
* timeout with partial results. Read-only by construction.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
const findInputSchema = z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"),
|
||||
path: z.string().optional().describe("Directory to search in (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of results (default: 1000)"),
|
||||
});
|
||||
|
||||
export const findTool = lhTool({
|
||||
name: "find",
|
||||
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} results or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: findInputSchema,
|
||||
async execute(input) {
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(searchPath);
|
||||
} catch {
|
||||
throw new Error(`Path not found: ${searchPath}`);
|
||||
}
|
||||
if (!stat.isDirectory()) throw new Error(`Not a directory: ${searchPath}`);
|
||||
|
||||
const effectiveLimit = input.limit ?? DEFAULT_LIMIT;
|
||||
const matches: string[] = [];
|
||||
let limitReached = false;
|
||||
const { timedOut } = await walkFiles(
|
||||
searchPath,
|
||||
(filePath) => {
|
||||
const rel = path.relative(searchPath, filePath).split(path.sep).join("/");
|
||||
if (!matchGlob(rel, input.pattern)) return false;
|
||||
if (matches.length >= effectiveLimit) {
|
||||
limitReached = true;
|
||||
return true; // stop the walk
|
||||
}
|
||||
matches.push(rel);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
if (matches.length === 0 && !limitReached) {
|
||||
return timedOut
|
||||
? `Search timed out after 10s with no matches yet. Narrow the search to a subdirectory.`
|
||||
: "No files found matching pattern";
|
||||
}
|
||||
const { content, truncated } = capOutput(matches);
|
||||
const notices: string[] = [];
|
||||
if (limitReached || matches.length >= effectiveLimit) notices.push(`${effectiveLimit} results limit reached`);
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* Shared plumbing for the learner-filesystem tools (ls / find / grep) —
|
||||
* sources v1 (2026-09-19): the agent locates and reads the learner's files
|
||||
* itself, so paths need no UI affordance (user decision: no attachments —
|
||||
* the learner references files in messages; the agent finds them).
|
||||
*
|
||||
* Safety posture (user-locked): READ-ONLY by construction — no shell-out,
|
||||
* no user-controlled process args, no mutation APIs anywhere here. The one
|
||||
* deliberate scope widening vs pi: relative paths and default roots resolve
|
||||
* against the LEARNER'S HOME, not a project cwd — the harness has no
|
||||
* meaningful working directory, and "find my notes" should just work.
|
||||
*
|
||||
* Ported behaviors (mirroring pi's dist/core/tools/{ls,find,grep}.js): the
|
||||
* schemas, descriptions, output formats, and truncation notices are copied
|
||||
* verbatim except where semantics differ (home default, no .gitignore —
|
||||
* replaced by hard noise exclusions).
|
||||
*/
|
||||
|
||||
/** 50KB — pi's DEFAULT_MAX_BYTES, the output ceiling for every fs tool. */
|
||||
export const MAX_OUTPUT_BYTES = 50 * 1024;
|
||||
/** pi's grep line-truncation length (GREP_MAX_LINE_LENGTH). */
|
||||
export const GREP_MAX_LINE_LENGTH = 500;
|
||||
/** Wall-clock budget for tree walks (find/grep over a whole home dir). */
|
||||
export const WALK_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Directory names never entered during walks. node_modules/.git mirror pi's
|
||||
* fd/rg defaults; Library/.Trash are the home-root noise the user approved
|
||||
* (2026-09-19) — caches, app support, and the trash live under them.
|
||||
* Explicit paths (ls/read of ~/Library/…) are NOT filtered — only walks.
|
||||
*/
|
||||
const EXCLUDED_DIR_NAMES = new Set(["node_modules", ".git", "Library", ".Trash"]);
|
||||
|
||||
/** Resolve a tool path argument: absolute stays, relative/absent → home.
|
||||
* (pi resolves against the project cwd; the harness's "project" is the
|
||||
* learner's life, so the home directory is the anchor.) */
|
||||
export function resolveSourcePath(p?: string): string {
|
||||
if (!p) return os.homedir();
|
||||
return path.isAbsolute(p) ? p : path.join(os.homedir(), p);
|
||||
}
|
||||
|
||||
/** pi's formatSize idiom (50KB prints as "50.0KB"). */
|
||||
export function formatSize(bytes: number): string {
|
||||
return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
|
||||
}
|
||||
|
||||
/** Cap output to MAX_OUTPUT_BYTES on whole lines, pi's truncateHead idiom. */
|
||||
export function capOutput(lines: string[]): { content: string; truncated: boolean } {
|
||||
const out: string[] = [];
|
||||
for (const line of lines) {
|
||||
const candidate = [...out, line].join("\n");
|
||||
if (Buffer.byteLength(candidate, "utf-8") > MAX_OUTPUT_BYTES) {
|
||||
return { content: out.join("\n"), truncated: true };
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return { content: out.join("\n"), truncated: false };
|
||||
}
|
||||
|
||||
/** Expand top-level brace alternatives: '*.{ts,tsx}' → ['*.ts', '*.tsx'].
|
||||
* Nested braces are out of scope (pi's fd handles them; models rarely
|
||||
* emit them — a non-expanding literal match is the graceful fallback). */
|
||||
export function expandBraces(pattern: string): string[] {
|
||||
const open = pattern.indexOf("{");
|
||||
if (open === -1) return [pattern];
|
||||
const close = pattern.indexOf("}", open);
|
||||
if (close === -1) return [pattern];
|
||||
const before = pattern.slice(0, open);
|
||||
const after = pattern.slice(close + 1);
|
||||
const alts: string[] = [];
|
||||
for (const alt of pattern.slice(open + 1, close).split(",")) {
|
||||
alts.push(...expandBraces(before + alt + after));
|
||||
}
|
||||
return alts;
|
||||
}
|
||||
|
||||
/** Glob → anchored RegExp. '*' stays within a segment, '**' crosses '/',
|
||||
* '?' is one char, everything else literal. */
|
||||
export function globToRegex(glob: string): RegExp {
|
||||
let re = "";
|
||||
for (let i = 0; i < glob.length; i++) {
|
||||
const c = glob[i]!;
|
||||
if (c === "*") {
|
||||
if (glob[i + 1] === "*") {
|
||||
re += "[\\s\\S]*";
|
||||
i++;
|
||||
if (glob[i + 1] === "/") i++; // '**/' also eats the slash
|
||||
} else {
|
||||
re += "[^/]*";
|
||||
}
|
||||
} else if (c === "?") {
|
||||
re += "[^/]";
|
||||
} else {
|
||||
re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${re}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* pi/fd's matching rule: a pattern with no slash matches against the
|
||||
* BASENAME at any depth; one with a slash matches the relative path, with
|
||||
* an implied leading "**-slash" prefix unless it is itself anchored (see
|
||||
* the code below — the literal sequence cannot appear in this comment
|
||||
* because it terminates block comments).
|
||||
*/
|
||||
export function matchGlob(relativePath: string, pattern: string): boolean {
|
||||
const posix = relativePath.split(path.sep).join("/");
|
||||
const basename = posix.split("/").pop() ?? posix;
|
||||
for (const alt of expandBraces(pattern)) {
|
||||
if (!alt.includes("/")) {
|
||||
if (globToRegex(alt).test(basename)) return true;
|
||||
} else {
|
||||
let effective = alt;
|
||||
if (!effective.startsWith("/") && !effective.startsWith("**/") && effective !== "**") {
|
||||
effective = `**/${effective}`;
|
||||
}
|
||||
if (effective.startsWith("/")) effective = effective.slice(1);
|
||||
if (globToRegex(effective).test(posix)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first walk under `root`, never entering EXCLUDED_DIR_NAMES.
|
||||
* `visit` receives absolute file paths; returning true STOPS the walk
|
||||
* (find's result limit). The deadline bounds a home-dir walk to
|
||||
* WALK_TIMEOUT_MS — partial results are the graceful degradation.
|
||||
*/
|
||||
export async function walkFiles(
|
||||
root: string,
|
||||
visit: (filePath: string) => boolean | Promise<boolean>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ timedOut: boolean }> {
|
||||
const deadline = Date.now() + WALK_TIMEOUT_MS;
|
||||
let timedOut = false;
|
||||
|
||||
const walkDir = async (dir: string): Promise<boolean> => {
|
||||
if (Date.now() > deadline) {
|
||||
timedOut = true;
|
||||
return true; // stop
|
||||
}
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false; // unreadable dir (permissions): skip, keep walking
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (Date.now() > deadline) {
|
||||
timedOut = true;
|
||||
return true;
|
||||
}
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDED_DIR_NAMES.has(entry.name)) continue;
|
||||
if (await walkDir(full)) return true;
|
||||
} else if (entry.isFile()) {
|
||||
if (await visit(full)) return true;
|
||||
}
|
||||
// symlinks/other: not followed (pi's fd doesn't follow either)
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
await walkDir(root);
|
||||
return { timedOut };
|
||||
}
|
||||
|
||||
/** True if the first 8KB chunk contains a NUL byte — pi/rg's binary skip. */
|
||||
export async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(8192);
|
||||
const { bytesRead } = await handle.read(buf, 0, 8192, 0);
|
||||
return buf.subarray(0, bytesRead).includes(0);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** pi's GREP_MAX_LINE_LENGTH line truncation (no mid-word care, hard cap). */
|
||||
export function truncateGrepLine(line: string): { text: string; wasTruncated: boolean } {
|
||||
if (line.length <= GREP_MAX_LINE_LENGTH) return { text: line, wasTruncated: false };
|
||||
return { text: `${line.slice(0, GREP_MAX_LINE_LENGTH)}… [truncated]`, wasTruncated: true };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
GREP_MAX_LINE_LENGTH,
|
||||
isBinaryFile,
|
||||
matchGlob,
|
||||
MAX_OUTPUT_BYTES,
|
||||
resolveSourcePath,
|
||||
truncateGrepLine,
|
||||
walkFiles,
|
||||
} from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
* The grep tool — sources v1 (2026-09-19): pi's dist/core/tools/grep.js
|
||||
* ported to a pure-Node scan (pi shells out to rg). Schemas and output
|
||||
* formats are pi-verbatim — `relative/path:LINE: text` matches,
|
||||
* `relative/path-LINE- text` context lines, the 500-char line cap, the
|
||||
* "use read to see full lines" notice. Deltas: home-anchored default,
|
||||
* .gitignore replaced by hard noise exclusions, binary detection via NUL
|
||||
* sniff, a 2MB file cap, and a wall-clock timeout with partial results.
|
||||
* Read-only by construction.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const grepInputSchema = z.object({
|
||||
pattern: z.string().min(1).describe("Search pattern (regex or literal string)"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory or file to search (default: the learner's home directory)"),
|
||||
glob: z.string().optional().describe("Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"),
|
||||
ignoreCase: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
||||
literal: z.boolean().optional().describe("Treat pattern as literal string instead of regex (default: false)"),
|
||||
context: z.number().int().nonnegative().optional().describe("Number of lines to show before and after each match (default: 0)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of matches to return (default: 100)"),
|
||||
});
|
||||
|
||||
export const grepTool = lhTool({
|
||||
name: "grep",
|
||||
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} matches or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
|
||||
schema: grepInputSchema,
|
||||
async execute(input) {
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(searchPath);
|
||||
} catch {
|
||||
throw new Error(`Path not found: ${searchPath}`);
|
||||
}
|
||||
const isDirectory = stat.isDirectory();
|
||||
|
||||
const regex = new RegExp(
|
||||
input.literal ? input.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : input.pattern,
|
||||
input.ignoreCase ? "i" : undefined,
|
||||
);
|
||||
const contextValue = input.context && input.context > 0 ? input.context : 0;
|
||||
const effectiveLimit = Math.max(1, input.limit ?? DEFAULT_LIMIT);
|
||||
|
||||
// Files to scan: one explicit file, or the walked tree (glob-filtered).
|
||||
const files: string[] = [];
|
||||
let timedOut = false;
|
||||
if (!isDirectory) {
|
||||
files.push(searchPath);
|
||||
} else {
|
||||
const result = await walkFiles(
|
||||
searchPath,
|
||||
(filePath) => {
|
||||
if (input.glob) {
|
||||
const rel = path.relative(searchPath, filePath).split(path.sep).join("/");
|
||||
if (!matchGlob(rel, input.glob)) return false;
|
||||
}
|
||||
files.push(filePath);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
timedOut = result.timedOut;
|
||||
}
|
||||
|
||||
const outLines: string[] = [];
|
||||
let matchCount = 0;
|
||||
let limitReached = false;
|
||||
let linesTruncated = false;
|
||||
|
||||
for (const filePath of files) {
|
||||
if (matchCount >= effectiveLimit) {
|
||||
limitReached = true;
|
||||
break;
|
||||
}
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (fileStat.size > MAX_FILE_BYTES) continue; // rg-style silent skip of huge files
|
||||
if (await isBinaryFile(filePath).catch(() => true)) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const rel = isDirectory
|
||||
? path.relative(searchPath, filePath).split(path.sep).join("/")
|
||||
: path.basename(filePath);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i]!)) {
|
||||
if (matchCount >= effectiveLimit) {
|
||||
limitReached = true;
|
||||
break;
|
||||
}
|
||||
matchCount++;
|
||||
const start = contextValue > 0 ? Math.max(1, i + 1 - contextValue) : i + 1;
|
||||
const end = contextValue > 0 ? Math.min(lines.length, i + 1 + contextValue) : i + 1;
|
||||
for (let current = start; current <= end; current++) {
|
||||
const { text, wasTruncated } = truncateGrepLine(lines[current - 1] ?? "");
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
// pi's format: ':' for match lines, '-' for context lines.
|
||||
outLines.push(current === i + 1 ? `${rel}:${current}: ${text}` : `${rel}-${current}- ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (limitReached) break;
|
||||
}
|
||||
|
||||
if (outLines.length === 0) {
|
||||
return timedOut
|
||||
? `Search timed out after 10s with no matches yet. Narrow the search to a subdirectory.`
|
||||
: "No matches found";
|
||||
}
|
||||
const { content, truncated } = capOutput(outLines);
|
||||
const notices: string[] = [];
|
||||
if (limitReached || matchCount >= effectiveLimit) notices.push(`${effectiveLimit} matches limit reached`);
|
||||
if (linesTruncated) notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
* The ls tool — sources v1 (2026-09-19): a Node port of pi's
|
||||
* dist/core/tools/ls.js, home-anchored (fs-shared.ts). Schemas and the
|
||||
* output format are pi-verbatim; the only semantic deltas: the default
|
||||
* directory is the learner's home, and the path description says so.
|
||||
* Read-only by construction — readdir/stat only, no mutation APIs.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
const lsInputSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory to list (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of entries to return (default: 500)"),
|
||||
});
|
||||
|
||||
export const lsTool = lhTool({
|
||||
name: "ls",
|
||||
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: lsInputSchema,
|
||||
async execute(input) {
|
||||
const dirPath = resolveSourcePath(input.path);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(dirPath);
|
||||
} catch {
|
||||
throw new Error(`Path not found: ${dirPath}`);
|
||||
}
|
||||
if (!stat.isDirectory()) throw new Error(`Not a directory: ${dirPath}`);
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dirPath);
|
||||
} catch (e) {
|
||||
throw new Error(`Cannot read directory: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// pi: alphabetical, case-insensitive.
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
const effectiveLimit = input.limit ?? DEFAULT_LIMIT;
|
||||
const lines: string[] = [];
|
||||
let entryLimitReached = false;
|
||||
for (const entry of entries) {
|
||||
if (lines.length >= effectiveLimit) {
|
||||
entryLimitReached = true;
|
||||
break;
|
||||
}
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const entryStat = await fs.stat(fullPath);
|
||||
lines.push(entryStat.isDirectory() ? `${entry}/` : entry);
|
||||
} catch {
|
||||
// pi: skip entries it cannot stat
|
||||
}
|
||||
}
|
||||
|
||||
const { content, truncated } = capOutput(lines);
|
||||
if (lines.length === 0) return "Directory is empty";
|
||||
const notices: string[] = [];
|
||||
if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`);
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`);
|
||||
return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content;
|
||||
},
|
||||
});
|
||||
+10
-9
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
|
||||
import { resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
* The read tool — sources v0 (2026-09): a Node port of pi's read semantics
|
||||
@@ -86,19 +87,19 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
|
||||
return `${selected.join("\n")}\n\n[Showing lines ${startLine + 1}-${endLineDisplay} of ${totalLines} (${reason}). Use offset=${endLineDisplay + 1} to continue.]`;
|
||||
}
|
||||
|
||||
/** The read tool, shared verbatim by the prober and the teacher. */
|
||||
/**
|
||||
* The read tool, shared verbatim by the prober and the teacher. Sources v1
|
||||
* (2026-09-19): the description is pi-verbatim (user: copy tool usage
|
||||
* guidance exactly) — the teaching doctrine moved to the shared prompt
|
||||
* fragment (prompts/shared/). Relative paths resolve against the
|
||||
* learner's home (fs-shared.ts), the harness's anchor.
|
||||
*/
|
||||
export const readTool = lhTool({
|
||||
name: "read",
|
||||
description:
|
||||
"Read the contents of a local file — the learner's source material for this lesson " +
|
||||
"(he pastes file paths into the chat; read them on your own terms, never ask him to " +
|
||||
"paste file CONTENTS into the chat). Supports text files and images (jpg, png, gif, " +
|
||||
"webp, bmp) — images are shown to you as attachments. Text output is truncated to " +
|
||||
`2000 lines or 50KB (whichever is hit first); use offset/limit for large files, and ` +
|
||||
"continue with offset until you have what you need.",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
schema: readInputSchema,
|
||||
execute: async ({ path: rawPath, offset, limit }): Promise<string | LhToolImageResult> => {
|
||||
const absolute = path.resolve(process.cwd(), rawPath);
|
||||
const absolute = resolveSourcePath(rawPath);
|
||||
const ext = path.extname(absolute).toLowerCase();
|
||||
const mime = IMAGE_MIME[ext];
|
||||
const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees
|
||||
|
||||
Reference in New Issue
Block a user