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:
amosblomqvist
2026-09-19 16:50:20 +03:00
parent dfe1a21989
commit 7a3e19e95f
18 changed files with 977 additions and 41 deletions
+8
View File
@@ -4,6 +4,7 @@ import { DemoCard } from "@/components/artifacts/demo-card";
import { JobCard, PdfJobCard } from "@/components/artifacts/job-card";
import { VisualCard } from "@/components/artifacts/visual-card";
import { ProbeDoneCard } from "@/components/artifacts/probe-done-card";
import { ToolCallCard } from "@/components/artifacts/tool-call-card";
/**
* Artifact renderer registry — the extension point for tool-part UI.
@@ -29,4 +30,11 @@ export const artifactRenderers: Record<string, ComponentType<ArtifactPartProps>>
// The probe conclusion: the assessment belongs to the learner as much as
// to the transcript (docs/probe-teach-split.md §2). Plain v1 card.
probe_done: ProbeDoneCard,
// The learner-filesystem set (sources v1): one quiet expandable
// tool-call card each — collapsed one-liner (tool name + headline
// argument), full input/output on expand.
read: ToolCallCard,
ls: ToolCallCard,
find: ToolCallCard,
grep: ToolCallCard,
};
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { ArtifactPartProps } from "@/src/core";
/**
* The generic tool-call card (sources v1, 2026-09-19): a quiet, collapsed
* one-liner per synchronous tool call — the tool name plus the headline
* argument — expandable to the full input and output. The user's spec:
* "at least the tool name; for read, the file name; everything collapsed
* by default; not distracting — background stuff." Unregistered tools
* still render nothing (chat.tsx's registry default); this card is only
* for the learner-filesystem set (read/ls/find/grep) where the call IS
* worth a trace in the transcript.
*/
interface ToolCallPart {
type: string;
state: string;
toolCallId: string;
input?: unknown;
output?: unknown;
errorText?: string;
}
/** The collapsed headline argument per tool: path for read/ls, pattern for
* find/grep — shown as given, never resolved (the absolute path lands in
* the expanded view when present in the output). */
function headlineArg(tool: string, input: unknown): string {
const inp = (input ?? {}) as Record<string, unknown>;
if (tool === "read" || tool === "ls") {
const p = typeof inp.path === "string" && inp.path.length > 0 ? inp.path : "~";
return p;
}
if (typeof inp.pattern === "string") return inp.pattern;
return "";
}
/** Render a tool result body: plain strings verbatim, image-bearing read
* results show their text + an attachment note (the image itself is the
* model's — the learner sees only that it happened). */
function outputText(output: unknown): string | null {
if (output == null) return null;
if (typeof output === "string") return output;
if (typeof output === "object" && "text" in (output as Record<string, unknown>)) {
const o = output as { text?: unknown; images?: unknown[] };
const text = typeof o.text === "string" ? o.text : null;
const n = Array.isArray(o.images) ? o.images.length : 0;
return n > 0 ? `${text ?? ""}\n[${n} image${n > 1 ? "s" : ""} attached to the model]`.trim() : text;
}
try {
return JSON.stringify(output, null, 2);
} catch {
return String(output);
}
}
export function ToolCallCard({ part }: ArtifactPartProps) {
const p = part as ToolCallPart;
const tool = p.type.replace(/^tool-/, "");
const arg = headlineArg(tool, p.input);
const out = outputText(p.output);
const failed = p.state === "output-error" || typeof p.errorText === "string";
return (
<details
data-testid="tool-call-card"
data-tool={tool}
className="group my-1 w-full max-w-2xl select-none"
>
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
<span className={`font-medium uppercase tracking-wide ${failed ? "text-red-500" : ""}`}>
{tool}
</span>
{arg && <span className="truncate">{arg}</span>}
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
</summary>
<div className="mt-2 space-y-2 rounded-xl border bg-card p-3 text-xs">
{p.input != null && (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Input
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{JSON.stringify(p.input, null, 2)}
</pre>
</div>
)}
{typeof p.errorText === "string" ? (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Error
</div>
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
{p.errorText}
</pre>
</div>
) : out != null ? (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Output
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{out}
</pre>
</div>
) : (
p.state !== "output-available" && (
<div className="text-muted-foreground italic">running…</div>
)
)}
</div>
</details>
);
}
+4
View File
@@ -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.
+6 -11
View File
@@ -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.
+13 -6
View File
@@ -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
View File
@@ -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 }),
},
+78
View File
@@ -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;
},
});
+192
View File
@@ -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 };
}
+149
View File
@@ -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;
},
});
+74
View File
@@ -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
View File
@@ -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
+17
View File
@@ -38,6 +38,23 @@ export type TeacherUITools = {
/** The pdf dispatch tool (sources v0): fire-and-forget ack output like
* research — the brief arrives later as a steer message. */
pdf: { input: PdfToolInput; output: string };
/** The learner-filesystem tools (sources v1, both agents, always-on):
* read may return an image-bearing result; the rest are plain text. */
read: { input: { path: string; offset?: number; limit?: number }; output: unknown };
ls: { input: { path?: string; limit?: number }; output: unknown };
find: { input: { pattern: string; path?: string; limit?: number }; output: unknown };
grep: {
input: {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
};
output: unknown;
};
};
export type TeacherUIDataParts = {
+63
View File
@@ -41,6 +41,7 @@ import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
import { JobCard, PdfJobCard } from "../components/artifacts/job-card.tsx";
import { ToolCallCard } from "../components/artifacts/tool-call-card.tsx";
import { JobsContext } from "../components/jobs-context.ts";
import { demoInputSchema, jobTools, pdfInputSchema, visualInputSchema } from "../src/agent/tools/jobs.ts";
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
@@ -846,5 +847,67 @@ describe("tool + renderer registry wiring", () => {
assert.equal(artifactRenderers.visual, VisualCard);
// sources v0 — pdf maps to its dispatch card (the live-smoke gap)
assert.equal(artifactRenderers.pdf, PdfJobCard);
// sources v1 — the learner-filesystem set maps to the generic tool-call card
for (const t of ["read", "ls", "find", "grep"]) {
assert.equal(artifactRenderers[t], ToolCallCard, `${t} renders the tool-call card`);
}
});
it("the tool-call card: collapsed one-liner with the tool name + headline arg; expand shows input and output", () => {
const part = {
type: "tool-read",
state: "output-available",
toolCallId: "c1",
input: { path: "notes/calculus.md", offset: 1, limit: 20 },
output: "Calculus notes\nThe derivative measures force.",
};
const html = renderToStaticMarkup(<ToolCallCard part={part as never} />);
// collapsed headline: tool name + path as given
assert.match(html, /read/);
assert.match(html, /notes\/calculus\.md/);
// expandable payload: input JSON + output text
assert.match(html, /Input/);
assert.match(html, /Output/);
assert.match(html, /The derivative measures force\./);
assert.ok(html.includes("<details"), "collapsed by default via a details element");
});
it("the tool-call card: image-bearing read results note the attachment; errors render red", () => {
const imgPart = {
type: "tool-read",
state: "output-available",
toolCallId: "c2",
input: { path: "shot.png" },
output: { text: "Read image file [image/png].", images: [{ data: "AAAA", mimeType: "image/png" }] },
};
const html = renderToStaticMarkup(<ToolCallCard part={imgPart as never} />);
assert.match(html, /Read image file \[image\/png\]\./);
assert.match(html, /1 image attached to the model/);
assert.ok(!html.includes("AAAA"), "the image payload itself never renders");
const errPart = {
type: "tool-read",
state: "output-error",
toolCallId: "c3",
input: { path: "/gone.md" },
errorText: "ENOENT: no such file",
};
const errHtml = renderToStaticMarkup(<ToolCallCard part={errPart as never} />);
assert.match(errHtml, /Error/);
assert.match(errHtml, /ENOENT/);
assert.match(errHtml, /text-red-500/);
});
it("the tool-call card: find/grep headline the pattern, ls defaults to ~", () => {
const findHtml = renderToStaticMarkup(
<ToolCallCard part={{ type: "tool-find", state: "output-available", toolCallId: "c", input: { pattern: "*calculus*" }, output: "notes/calculus.md" } as never} />,
);
assert.match(findHtml, /find/);
assert.match(findHtml, /\*calculus\*/);
const lsHtml = renderToStaticMarkup(
<ToolCallCard part={{ type: "tool-ls", state: "output-available", toolCallId: "c", input: {}, output: "notes/" } as never} />,
);
assert.match(lsHtml, /ls/);
assert.match(lsHtml, /~/);
});
});
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { expandBraces, globToRegex, matchGlob } from "../src/agent/tools/fs-shared.ts";
import { findTool } from "../src/agent/tools/find.ts";
import { grepTool } from "../src/agent/tools/grep.ts";
import { lsTool } from "../src/agent/tools/ls.ts";
import { readTool } from "../src/agent/tools/read.ts";
/**
* The learner-filesystem tools (sources v1, 2026-09-19): ls/find/grep as
* pure-Node pi ports, read re-anchored to the home directory. All calls go
* through absolute paths — the tools' relative resolution targets the real
* home, which tests must not touch.
*
* Fixture tree (tmpdir):
* notes/calculus.md "The derivative measures force…"
* notes/linear.txt eigenvector content
* notes/sub/hidden.md
* code/main.ts
* node_modules/skipme.txt (excluded from walks)
* Library/alsoskip.txt (excluded from walks)
* binary.bin NUL byte — grep skips
*/
let FIXTURE: string;
async function setup(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-"));
await fs.mkdir(path.join(root, "notes", "sub"), { recursive: true });
await fs.mkdir(path.join(root, "code"), { recursive: true });
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
await fs.mkdir(path.join(root, "Library"), { recursive: true });
await fs.writeFile(path.join(root, "notes", "calculus.md"), "Calculus notes\nThe derivative measures force.\nChapter 1\n");
await fs.writeFile(path.join(root, "notes", "linear.txt"), "Eigenvectors keep their direction\nonly scaling remains\n");
await fs.writeFile(path.join(root, "notes", "sub", "hidden.md"), "deep file about MU\n");
await fs.writeFile(path.join(root, "code", "main.ts"), "const x = 1;\nconst eigenvector = 'here';\n");
await fs.writeFile(path.join(root, "node_modules", "skipme.txt"), "eigenvector in node_modules\n");
await fs.writeFile(path.join(root, "Library", "alsoskip.txt"), "eigenvector in Library\n");
await fs.writeFile(path.join(root, "binary.bin"), Buffer.from([0x00, 0x01, 0x02, 0x65, 0x69, 0x67, 0x03]));
return root;
}
describe("glob helpers (fs-shared)", () => {
it("basename mode: a pattern without a slash matches at any depth", () => {
assert.equal(matchGlob("notes/sub/calculus.md", "*.md"), true);
assert.equal(matchGlob("calculus.md", "*.md"), true);
assert.equal(matchGlob("notes/calculus.md", "*.ts"), false);
});
it("star does not cross slashes; double-star does", () => {
assert.equal(matchGlob("a/b/c.txt", "*.txt"), true); // basename mode
assert.equal(matchGlob("src/x/a.spec.ts", "src/**/*.spec.ts"), true);
assert.equal(matchGlob("other/x/a.spec.ts", "src/**/*.spec.ts"), false);
assert.equal(matchGlob("src/a.spec.ts", "src/**/*.spec.ts"), true, "** covers zero dirs");
});
it("slash patterns get an implied deep prefix unless anchored", () => {
assert.equal(matchGlob("deep/nested/x/main.ts", "**/nested/**/main.ts"), true);
assert.equal(matchGlob("x/main.ts", "nested/main.ts"), false, "no implied prefix for anchored-looking patterns");
});
it("brace alternatives expand", () => {
assert.deepEqual(expandBraces("*.{ts,tsx}"), ["*.ts", "*.tsx"]);
assert.equal(matchGlob("code/main.ts", "*.{ts,tsx,js}"), true);
assert.equal(matchGlob("code/main.ts", "*.{js,jsx}"), false);
});
it("question mark is one char, literals are escaped", () => {
assert.equal(globToRegex("a?c").test("abc"), true);
assert.equal(globToRegex("a?c").test("ac"), false);
assert.equal(matchGlob("a.b.txt", "a.b*"), true, "the dot is literal, not any-char");
});
});
describe("ls (pi port)", () => {
it("lists sorted, dirs suffixed, dotfiles included", async () => {
FIXTURE = await setup();
const out = (await lsTool.execute!({ path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("code/"));
assert.ok(out.includes("notes/"));
assert.ok(out.includes("binary.bin"));
// sorted alphabetically, case-insensitive
const lines = out.split("\n").filter((l) => !l.startsWith("["));
const sorted = [...lines].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
assert.deepEqual(lines, sorted);
});
it("honors the entry limit with pi's truncation notice", async () => {
const out = (await lsTool.execute!({ path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
assert.match(out, /\[Truncated: 2 entries limit\]/);
});
it("missing path and non-directory throw (the model sees the error)", async () => {
await assert.rejects(async () => lsTool.execute!({ path: "/nonexistent/xyz" }, { toolCallId: "c" }), /Path not found/);
await assert.rejects(
async () => lsTool.execute!({ path: path.join(FIXTURE, "binary.bin") }, { toolCallId: "c" }),
/Not a directory/,
);
});
});
describe("find (pi port)", () => {
it("finds by glob, relativized to the search root", async () => {
const out = (await findTool.execute!({ pattern: "*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("notes/calculus.md"));
assert.ok(out.includes("notes/sub/hidden.md"));
assert.ok(!out.includes(FIXTURE), "paths are relative, not absolute");
});
it("never enters node_modules or Library", async () => {
const out = (await findTool.execute!({ pattern: "*.txt", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(!out.includes("node_modules"));
assert.ok(!out.includes("Library"));
assert.ok(out.includes("notes/linear.txt"));
});
it("result limit carries pi's notice", async () => {
const out = (await findTool.execute!({ pattern: "*", path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
assert.match(out, /\[2 results limit reached\]/);
});
it("no-match message matches pi", async () => {
const out = (await findTool.execute!({ pattern: "*.nomatch", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "No files found matching pattern");
});
it("slash patterns match paths, not basenames", async () => {
const out = (await findTool.execute!({ pattern: "notes/**/*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("notes/sub/hidden.md"));
assert.ok(!out.includes("code/"));
});
});
describe("grep (pi port)", () => {
it("formats matches pi-style: rel:line: text", async () => {
// case-sensitive: the lowercase "eigenvector" hits main.ts but NOT
// linear.txt's capital-E "Eigenvectors" (that case lives below).
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "code/main.ts:2: const eigenvector = 'here';");
assert.ok(!out.includes("node_modules"), "excluded dirs are not searched");
assert.ok(!out.includes("Library"));
assert.ok(!out.includes("binary.bin"), "binary files are skipped");
});
it("ignoreCase and literal flags", async () => {
const icase = (await grepTool.execute!({ pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true }, { toolCallId: "c" })) as string;
assert.match(icase, /linear\.txt:1:/);
const literal = (await grepTool.execute!({ pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") }, { toolCallId: "c" })) as string;
assert.equal(literal, "No matches found", "dots are literal when literal=true");
});
it("context lines use the dash format", async () => {
const out = (await grepTool.execute!({ pattern: "scaling", path: FIXTURE, context: 1 }, { toolCallId: "c" })) as string;
assert.match(out, /notes\/linear\.txt:2: only scaling remains/, "the match line uses colons");
assert.match(out, /notes\/linear\.txt-1- Eigenvectors keep their direction/, "context before uses dashes");
assert.match(out, /notes\/linear\.txt-3- $/, "context after (empty line) uses dashes");
});
it("glob filter restricts files", async () => {
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE, glob: "*.ts" }, { toolCallId: "c" })) as string;
assert.match(out, /main\.ts:2:/);
assert.ok(!out.includes("linear.txt"));
});
it("single-file search prints basename; match limit notices pi-style", async () => {
const file = path.join(FIXTURE, "notes", "calculus.md");
const out = (await grepTool.execute!({ pattern: "e", path: file }, { toolCallId: "c" })) as string;
assert.match(out, /^calculus\.md:1: /, "basename for a single-file search");
const limited = (await grepTool.execute!({ pattern: "e", path: file, limit: 1 }, { toolCallId: "c" })) as string;
assert.match(limited, /\[1 matches limit reached\]/);
});
it("no-match message matches pi", async () => {
const out = (await grepTool.execute!({ pattern: "zzzz", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "No matches found");
});
});
describe("read (home-anchored re-port)", () => {
it("reads a fixture file verbatim through the shared slice machinery", async () => {
const out = (await readTool.execute!({ path: path.join(FIXTURE, "notes", "linear.txt") }, { toolCallId: "c" })) as string;
assert.match(out, /Eigenvectors keep their direction/);
});
});
+15
View File
@@ -1089,6 +1089,21 @@ describe("prompt composition", () => {
assert.ok(prober.includes("the `read` tool can inspect it directly"), "the prober knows it can read learner files");
});
it("the sources-v1 shared fragment composes into BOTH agents (find/ls/grep doctrine)", () => {
for (const kind of ["teacher", "prober"] as const) {
const instructions = composeInstructions(kind);
assert.ok(instructions.includes("## Learner files"), `${kind} carries the shared learner-files fragment`);
assert.ok(instructions.includes("find it yourself with `find`"), `${kind} knows to locate files itself`);
assert.ok(instructions.includes("home directory"), `${kind} knows the home-anchored path rule`);
}
// shared composes BEFORE the agent's own fragments (the seam's order rule)
const teacher = composeInstructions("teacher");
assert.ok(
teacher.indexOf("## Learner files") < teacher.indexOf("## Learner source files"),
"shared fragment precedes the teacher's own 30-jobs section",
);
});
it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => {
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
// teacher turn until the numeric-prefix filter stopped it. This guard
+7 -7
View File
@@ -143,10 +143,10 @@ describe("the prober's quiz: no nodeId field at all", () => {
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
});
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the shared read tool", () => {
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => {
const chatId = newChat("Prober: registry");
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
const schema = quizSchemaOf(tools);
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
});
@@ -165,15 +165,15 @@ describe("tool toggles (core/pending.ts + factories)", () => {
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
const chatId = newChat("Prober: toggles");
const ctx = { chatId, chatStore, deliver: () => {} };
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
const noAsk = createProberTools(ctx, { ask_user_question: false });
assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "probe_done", "quiz", "read"]);
// quiz off is honored by the generic machinery too (future toggleable),
// but probe_done is never droppable — it is how the phase ends.
const noQuiz = createProberTools(ctx, { quiz: false });
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done", "read"]);
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "read"]);
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
});
@@ -186,10 +186,10 @@ describe("tool toggles (core/pending.ts + factories)", () => {
spawn: () => {},
};
const full = Object.keys(createTeacherTools(ctx)).sort();
assert.deepEqual(full, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
assert.deepEqual(full, ["ask_user_question", "demo", "find", "grep", "ls", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
const noResearch = Object.keys(createTeacherTools(ctx, { research: false })).sort();
assert.deepEqual(noResearch, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "visual"]);
assert.deepEqual(noResearch, ["ask_user_question", "demo", "find", "grep", "ls", "pdf", "plan_init", "quiz", "read", "visual"]);
// absent = enabled: an empty record and undefined behave identically
assert.deepEqual(
+9 -4
View File
@@ -31,12 +31,17 @@ describe("read tool — text semantics", () => {
assert.equal(await run({ path: file }), "hello\nworld");
});
it("resolves relative paths against cwd", async () => {
fs.writeFileSync(path.join(process.cwd(), "lh-read-rel-test.txt"), "rel", "utf-8");
it("resolves relative paths against the learner's home (sources v1 anchoring)", async () => {
// sources v1: the harness has no meaningful cwd — "find my notes" must
// work from a bare relative path, so the home directory is the anchor
// (fs-shared resolveSourcePath). Scratch file in home, cleaned up.
const rel = `lh-read-rel-test-${process.pid}.txt`;
const homeFile = path.join(os.homedir(), rel);
fs.writeFileSync(homeFile, "rel", "utf-8");
try {
assert.equal(await run({ path: "lh-read-rel-test.txt" }), "rel");
assert.equal(await run({ path: rel }), "rel");
} finally {
fs.unlinkSync(path.join(process.cwd(), "lh-read-rel-test.txt"));
fs.unlinkSync(homeFile);
}
});