src / promptPreprocessor.ts
src / promptPreprocessor.ts
import { ChatMessage, type PromptPreprocessorController } from "@lmstudio/sdk";
import { readFile, stat } from "fs/promises";
import { isAbsolute, join } from "path";
import { configSchematics } from "./configSchematics";
import { renderTasks, TASK_FILE_NAME, type Task } from "./tasks";
import { maybeCompact } from "./compaction";
import { clamp } from "./workspace";
/**
* Prepends a short operating brief plus live workspace facts to every user
* message. A 7.5B model forgets the rules between turns, so the rules travel
* with each message instead of living only in the system prompt.
*
* This runs on every single message, so nothing here may spawn a process,
* walk the tree, or throw.
*/
const CACHE_TTL_MS = 30_000;
const MAX_CACHED_ROOTS = 8;
const AGENT_DOC_CHARS = 1500;
const CHECKLIST_CHARS = 700;
const MAX_MANIFEST_BYTES = 256 * 1024;
const MAX_SCRIPTS_SHOWN = 8;
const OPEN_TAG = "<workspace-context>";
const CLOSE_TAG = "</workspace-context>";
const OPERATING_BRIEF = [
"You are a coding agent with tools over ONE sandboxed folder. How to work:",
"- Unfamiliar folder: call project_overview first. Do not guess the layout.",
"- Big file? file_outline first, then read_file with start_line. Do not read 800 lines to find one.",
"- Looking for a symbol: find_definition. Changing one: find_references first, to see what breaks.",
"- Read a file with read_file before changing it. Never edit blind.",
"- Writing a big file: write_file the first ~200 lines, then append_file the rest in pieces.",
"- Before a tricky edit or a choice between approaches, use think to reason it out first.",
"- Prefer edit_file (targeted replacement) over rewriting a file with write_file.",
"- One small change at a time, then verify it before the next one.",
"- More than two steps: call set_tasks first, then update_task as each step lands.",
"- After changing code, call verify. It finds and runs this project's build and tests for you.",
"- If verify fails, fix the reported errors and call verify again. Do not move on while it is red.",
"- An edit that went wrong: call undo_last_edit rather than patching over the damage.",
'- A result starting with "Error:" means fix the cause; do not repeat the same call unchanged.',
"- Finish by saying which files you changed and what verify reported.",
"Only paths inside the workspace root exist. Never invent file contents, command output, or APIs.",
].join("\n");
interface CacheEntry {
block: string;
expiresAt: number;
}
const blockCache = new Map<string, CacheEntry>();
export async function promptPreprocessor(
ctl: PromptPreprocessorController,
userMessage: ChatMessage,
): Promise<string | ChatMessage> {
try {
const text = readMessageText(userMessage);
if (text === undefined) return userMessage;
let configuredRoot = "";
let inject = true;
let autoCompact = true;
let compactAt = 75;
try {
const config = ctl.getPluginConfig(configSchematics);
inject = config.get("injectContext");
configuredRoot = config.get("rootDirectory").trim();
autoCompact = config.get("autoCompact");
compactAt = config.get("compactAtPercent");
} catch {
// No config available (older host, or called outside a prediction): use defaults.
}
// Compaction is independent of the context block: a user who turns the brief
// off still wants long conversations to survive.
let summaryBlock = "";
if (autoCompact) {
const compaction = await maybeCompact(ctl, compactAt);
summaryBlock = compaction.block;
if (compaction.note !== "") ctl.debug?.(`[compaction] ${compaction.note}`);
}
if (!inject) {
return summaryBlock === "" ? userMessage : withPrepended(userMessage, `${summaryBlock}\n\n${text}`);
}
const root = configuredRoot === "" ? fallbackRoot(ctl) : configuredRoot;
const block = await buildContextBlock(root);
const prefix = [block, summaryBlock].filter((part) => part !== "").join("\n\n");
if (prefix === "") return userMessage;
return withPrepended(userMessage, `${prefix}\n\n${text}`);
} catch {
// A preprocessor failure must never cost the user their message.
return userMessage;
}
}
/**
* Builds the delimited context block for a root. Exported so the tools provider
* can hand the same facts to the model on demand. Cached briefly because it is
* rebuilt on every message of a fast back-and-forth.
*/
export async function buildContextBlock(root: string): Promise<string> {
const now = Date.now();
const cached = blockCache.get(root);
if (cached !== undefined && cached.expiresAt > now) return cached.block;
const lines: string[] = [OPEN_TAG, OPERATING_BRIEF, "", `workspace root: ${root}`];
if (!(await pathExists(root))) {
lines.push(
"WARNING: that folder does not exist. Tell the user to set the workspace root in the",
"plugin settings; every file tool will fail until they do.",
CLOSE_TAG,
);
return remember(root, lines.join("\n"), now);
}
const git = await describeGit(root);
if (git !== "") lines.push(`git: ${git}`);
const project = await describeProject(root);
if (project !== "") lines.push(`project: ${project}`);
const checklist = await describeChecklist(root);
if (checklist !== "") lines.push("", checklist);
const doc = await describeAgentDoc(root);
if (doc !== "") lines.push("", doc);
lines.push(CLOSE_TAG);
return remember(root, lines.join("\n"), now);
}
function remember(root: string, block: string, now: number): string {
blockCache.set(root, { block, expiresAt: now + CACHE_TTL_MS });
while (blockCache.size > MAX_CACHED_ROOTS) {
const oldest = blockCache.keys().next();
if (oldest.done === true) break;
blockCache.delete(oldest.value);
}
return block;
}
function fallbackRoot(ctl: PromptPreprocessorController): string {
try {
const cwd = ctl.getWorkingDirectory();
if (typeof cwd === "string" && cwd.trim() !== "") return cwd;
} catch {
// Method missing or unavailable outside a chat.
}
return process.cwd();
}
/** Accepts a ChatMessage, a plain string, or anything message-shaped. */
function readMessageText(message: unknown): string | undefined {
if (typeof message === "string") return message;
if (message === null || typeof message !== "object") return undefined;
const candidate = message as {
getText?: () => unknown;
text?: unknown;
content?: unknown;
};
try {
if (typeof candidate.getText === "function") {
const text = candidate.getText();
if (typeof text === "string") return text;
}
} catch {
// Fall through to the plain shapes below.
}
if (typeof candidate.text === "string") return candidate.text;
if (typeof candidate.content === "string") return candidate.content;
return undefined;
}
/**
* Returns a message carrying `full`. Attachments only survive on a real
* ChatMessage, so that path is preferred whenever the message has files.
*/
function withPrepended(message: unknown, full: string): string | ChatMessage {
try {
if (message instanceof ChatMessage && message.hasFiles()) {
const copy = ChatMessage.from(message);
copy.replaceText(full);
return copy;
}
} catch {
// Fall back to plain text rather than losing the turn.
}
return full;
}
async function pathExists(target: string): Promise<boolean> {
try {
await stat(target);
return true;
} catch {
return false;
}
}
async function readSmallFile(target: string, limitBytes: number): Promise<string | undefined> {
try {
const info = await stat(target);
if (!info.isFile() || info.size > limitBytes) return undefined;
return await readFile(target, "utf-8");
} catch {
return undefined;
}
}
/** Resolves the real git directory, following the pointer file a worktree uses. */
async function resolveGitDir(root: string): Promise<string | undefined> {
const candidate = join(root, ".git");
let info;
try {
info = await stat(candidate);
} catch {
return undefined;
}
if (info.isDirectory()) return candidate;
const pointer = await readSmallFile(candidate, 4096);
const match = pointer === undefined ? null : /^gitdir:\s*(.+)$/m.exec(pointer.trim());
if (match === null) return undefined;
const target = match[1].trim();
return isAbsolute(target) ? target : join(root, target);
}
/**
* Branch and in-progress operation, read straight off disk -- spawning git on
* every message is too expensive. Dirtiness is deliberately NOT guessed: the
* only cheap signal (.git/index mtime vs the branch tip) is refreshed by plain
* reads like `git status`, and measured wrong on three of four local repos. A
* confident wrong fact is worse for a small model than a pointer to git_status.
*/
async function describeGit(root: string): Promise<string> {
try {
const gitDir = await resolveGitDir(root);
if (gitDir === undefined) return "not a git repository";
const head = (await readSmallFile(join(gitDir, "HEAD"), 4096))?.trim();
if (head === undefined || head === "") return "repository present, HEAD unreadable";
const refMatch = /^ref:\s*(refs\/[\w./-]+)$/.exec(head);
const ref = refMatch === null ? undefined : refMatch[1];
const parts: string[] = [
ref === undefined
? `detached HEAD at ${head.slice(0, 12)}`
: `branch ${ref.replace(/^refs\/heads\//, "")}`,
];
const inProgress = await describeGitOperation(gitDir);
if (inProgress !== "") parts.push(inProgress);
parts.push("call git_status before committing or branching");
return parts.join(", ");
} catch {
return "";
}
}
async function describeGitOperation(gitDir: string): Promise<string> {
if (await pathExists(join(gitDir, "MERGE_HEAD"))) return "MERGE IN PROGRESS";
if (await pathExists(join(gitDir, "rebase-merge"))) return "REBASE IN PROGRESS";
if (await pathExists(join(gitDir, "rebase-apply"))) return "REBASE IN PROGRESS";
if (await pathExists(join(gitDir, "CHERRY_PICK_HEAD"))) return "CHERRY-PICK IN PROGRESS";
return "";
}
const MANIFESTS: ReadonlyArray<readonly [string, string]> = [
["package.json", "node"],
["deno.json", "deno"],
["tsconfig.json", "typescript"],
["Cargo.toml", "rust"],
["pyproject.toml", "python"],
["requirements.txt", "python"],
["setup.py", "python"],
["go.mod", "go"],
["pom.xml", "java/maven"],
["build.gradle", "java/gradle"],
["build.gradle.kts", "kotlin/gradle"],
["Gemfile", "ruby"],
["composer.json", "php"],
["CMakeLists.txt", "cmake"],
["Makefile", "make"],
];
const LOCKFILES: ReadonlyArray<readonly [string, string]> = [
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
["bun.lockb", "bun"],
["package-lock.json", "npm"],
];
async function describeProject(root: string): Promise<string> {
try {
const found: string[] = [];
const manifests: string[] = [];
for (const [file, label] of MANIFESTS) {
if (!(await pathExists(join(root, file)))) continue;
manifests.push(file);
if (!found.includes(label)) found.push(label);
}
if (manifests.length === 0) return "no manifest found (plain folder)";
const parts = [`${found.join(" + ")} (${manifests.join(", ")})`];
if (manifests.includes("package.json")) {
const runner = await detectRunner(root);
const scripts = await readPackageScripts(root);
if (scripts.length > 0) {
parts.push(`run: ${scripts.map((name) => `${runner} run ${name}`).join(", ")}`);
}
}
return parts.join(" -- ");
} catch {
return "";
}
}
async function detectRunner(root: string): Promise<string> {
for (const [file, runner] of LOCKFILES) {
if (await pathExists(join(root, file))) return runner;
}
return "npm";
}
async function readPackageScripts(root: string): Promise<string[]> {
const raw = await readSmallFile(join(root, "package.json"), MAX_MANIFEST_BYTES);
if (raw === undefined) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return [];
const scripts = (parsed as { scripts?: unknown }).scripts;
if (typeof scripts !== "object" || scripts === null) return [];
const names = Object.keys(scripts as Record<string, unknown>);
return names.length > MAX_SCRIPTS_SHOWN ? names.slice(0, MAX_SCRIPTS_SHOWN) : names;
} catch {
return [];
}
}
async function describeChecklist(root: string): Promise<string> {
try {
const raw = await readSmallFile(join(root, TASK_FILE_NAME), MAX_MANIFEST_BYTES);
if (raw === undefined) return "";
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return "";
const tasks = parsed.filter(isTaskLike);
if (tasks.length === 0) return "";
return `current checklist (${TASK_FILE_NAME}):\n${clamp(
renderTasks(tasks),
CHECKLIST_CHARS,
"checklist -- read it all with get_tasks",
)}`;
} catch {
return "";
}
}
function isTaskLike(value: unknown): value is Task {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.text === "string" &&
typeof candidate.status === "string" &&
["pending", "in_progress", "done", "blocked"].includes(candidate.status) &&
typeof candidate.note === "string"
);
}
const AGENT_DOCS = ["AGENTS.md", "CLAUDE.md"];
async function describeAgentDoc(root: string): Promise<string> {
for (const name of AGENT_DOCS) {
const raw = await readSmallFile(join(root, name), MAX_MANIFEST_BYTES);
if (raw === undefined) continue;
const body = raw.trim();
if (body === "") continue;
return `${name} (project rules, follow them):\n${clamp(body, AGENT_DOC_CHARS, name)}`;
}
return "";
}
import { ChatMessage, type PromptPreprocessorController } from "@lmstudio/sdk";
import { readFile, stat } from "fs/promises";
import { isAbsolute, join } from "path";
import { configSchematics } from "./configSchematics";
import { renderTasks, TASK_FILE_NAME, type Task } from "./tasks";
import { maybeCompact } from "./compaction";
import { clamp } from "./workspace";
/**
* Prepends a short operating brief plus live workspace facts to every user
* message. A 7.5B model forgets the rules between turns, so the rules travel
* with each message instead of living only in the system prompt.
*
* This runs on every single message, so nothing here may spawn a process,
* walk the tree, or throw.
*/
const CACHE_TTL_MS = 30_000;
const MAX_CACHED_ROOTS = 8;
const AGENT_DOC_CHARS = 1500;
const CHECKLIST_CHARS = 700;
const MAX_MANIFEST_BYTES = 256 * 1024;
const MAX_SCRIPTS_SHOWN = 8;
const OPEN_TAG = "<workspace-context>";
const CLOSE_TAG = "</workspace-context>";
const OPERATING_BRIEF = [
"You are a coding agent with tools over ONE sandboxed folder. How to work:",
"- Unfamiliar folder: call project_overview first. Do not guess the layout.",
"- Big file? file_outline first, then read_file with start_line. Do not read 800 lines to find one.",
"- Looking for a symbol: find_definition. Changing one: find_references first, to see what breaks.",
"- Read a file with read_file before changing it. Never edit blind.",
"- Writing a big file: write_file the first ~200 lines, then append_file the rest in pieces.",
"- Before a tricky edit or a choice between approaches, use think to reason it out first.",
"- Prefer edit_file (targeted replacement) over rewriting a file with write_file.",
"- One small change at a time, then verify it before the next one.",
"- More than two steps: call set_tasks first, then update_task as each step lands.",
"- After changing code, call verify. It finds and runs this project's build and tests for you.",
"- If verify fails, fix the reported errors and call verify again. Do not move on while it is red.",
"- An edit that went wrong: call undo_last_edit rather than patching over the damage.",
'- A result starting with "Error:" means fix the cause; do not repeat the same call unchanged.',
"- Finish by saying which files you changed and what verify reported.",
"Only paths inside the workspace root exist. Never invent file contents, command output, or APIs.",
].join("\n");
interface CacheEntry {
block: string;
expiresAt: number;
}
const blockCache = new Map<string, CacheEntry>();
export async function promptPreprocessor(
ctl: PromptPreprocessorController,
userMessage: ChatMessage,
): Promise<string | ChatMessage> {
try {
const text = readMessageText(userMessage);
if (text === undefined) return userMessage;
let configuredRoot = "";
let inject = true;
let autoCompact = true;
let compactAt = 75;
try {
const config = ctl.getPluginConfig(configSchematics);
inject = config.get("injectContext");
configuredRoot = config.get("rootDirectory").trim();
autoCompact = config.get("autoCompact");
compactAt = config.get("compactAtPercent");
} catch {
// No config available (older host, or called outside a prediction): use defaults.
}
// Compaction is independent of the context block: a user who turns the brief
// off still wants long conversations to survive.
let summaryBlock = "";
if (autoCompact) {
const compaction = await maybeCompact(ctl, compactAt);
summaryBlock = compaction.block;
if (compaction.note !== "") ctl.debug?.(`[compaction] ${compaction.note}`);
}
if (!inject) {
return summaryBlock === "" ? userMessage : withPrepended(userMessage, `${summaryBlock}\n\n${text}`);
}
const root = configuredRoot === "" ? fallbackRoot(ctl) : configuredRoot;
const block = await buildContextBlock(root);
const prefix = [block, summaryBlock].filter((part) => part !== "").join("\n\n");
if (prefix === "") return userMessage;
return withPrepended(userMessage, `${prefix}\n\n${text}`);
} catch {
// A preprocessor failure must never cost the user their message.
return userMessage;
}
}
/**
* Builds the delimited context block for a root. Exported so the tools provider
* can hand the same facts to the model on demand. Cached briefly because it is
* rebuilt on every message of a fast back-and-forth.
*/
export async function buildContextBlock(root: string): Promise<string> {
const now = Date.now();
const cached = blockCache.get(root);
if (cached !== undefined && cached.expiresAt > now) return cached.block;
const lines: string[] = [OPEN_TAG, OPERATING_BRIEF, "", `workspace root: ${root}`];
if (!(await pathExists(root))) {
lines.push(
"WARNING: that folder does not exist. Tell the user to set the workspace root in the",
"plugin settings; every file tool will fail until they do.",
CLOSE_TAG,
);
return remember(root, lines.join("\n"), now);
}
const git = await describeGit(root);
if (git !== "") lines.push(`git: ${git}`);
const project = await describeProject(root);
if (project !== "") lines.push(`project: ${project}`);
const checklist = await describeChecklist(root);
if (checklist !== "") lines.push("", checklist);
const doc = await describeAgentDoc(root);
if (doc !== "") lines.push("", doc);
lines.push(CLOSE_TAG);
return remember(root, lines.join("\n"), now);
}
function remember(root: string, block: string, now: number): string {
blockCache.set(root, { block, expiresAt: now + CACHE_TTL_MS });
while (blockCache.size > MAX_CACHED_ROOTS) {
const oldest = blockCache.keys().next();
if (oldest.done === true) break;
blockCache.delete(oldest.value);
}
return block;
}
function fallbackRoot(ctl: PromptPreprocessorController): string {
try {
const cwd = ctl.getWorkingDirectory();
if (typeof cwd === "string" && cwd.trim() !== "") return cwd;
} catch {
// Method missing or unavailable outside a chat.
}
return process.cwd();
}
/** Accepts a ChatMessage, a plain string, or anything message-shaped. */
function readMessageText(message: unknown): string | undefined {
if (typeof message === "string") return message;
if (message === null || typeof message !== "object") return undefined;
const candidate = message as {
getText?: () => unknown;
text?: unknown;
content?: unknown;
};
try {
if (typeof candidate.getText === "function") {
const text = candidate.getText();
if (typeof text === "string") return text;
}
} catch {
// Fall through to the plain shapes below.
}
if (typeof candidate.text === "string") return candidate.text;
if (typeof candidate.content === "string") return candidate.content;
return undefined;
}
/**
* Returns a message carrying `full`. Attachments only survive on a real
* ChatMessage, so that path is preferred whenever the message has files.
*/
function withPrepended(message: unknown, full: string): string | ChatMessage {
try {
if (message instanceof ChatMessage && message.hasFiles()) {
const copy = ChatMessage.from(message);
copy.replaceText(full);
return copy;
}
} catch {
// Fall back to plain text rather than losing the turn.
}
return full;
}
async function pathExists(target: string): Promise<boolean> {
try {
await stat(target);
return true;
} catch {
return false;
}
}
async function readSmallFile(target: string, limitBytes: number): Promise<string | undefined> {
try {
const info = await stat(target);
if (!info.isFile() || info.size > limitBytes) return undefined;
return await readFile(target, "utf-8");
} catch {
return undefined;
}
}
/** Resolves the real git directory, following the pointer file a worktree uses. */
async function resolveGitDir(root: string): Promise<string | undefined> {
const candidate = join(root, ".git");
let info;
try {
info = await stat(candidate);
} catch {
return undefined;
}
if (info.isDirectory()) return candidate;
const pointer = await readSmallFile(candidate, 4096);
const match = pointer === undefined ? null : /^gitdir:\s*(.+)$/m.exec(pointer.trim());
if (match === null) return undefined;
const target = match[1].trim();
return isAbsolute(target) ? target : join(root, target);
}
/**
* Branch and in-progress operation, read straight off disk -- spawning git on
* every message is too expensive. Dirtiness is deliberately NOT guessed: the
* only cheap signal (.git/index mtime vs the branch tip) is refreshed by plain
* reads like `git status`, and measured wrong on three of four local repos. A
* confident wrong fact is worse for a small model than a pointer to git_status.
*/
async function describeGit(root: string): Promise<string> {
try {
const gitDir = await resolveGitDir(root);
if (gitDir === undefined) return "not a git repository";
const head = (await readSmallFile(join(gitDir, "HEAD"), 4096))?.trim();
if (head === undefined || head === "") return "repository present, HEAD unreadable";
const refMatch = /^ref:\s*(refs\/[\w./-]+)$/.exec(head);
const ref = refMatch === null ? undefined : refMatch[1];
const parts: string[] = [
ref === undefined
? `detached HEAD at ${head.slice(0, 12)}`
: `branch ${ref.replace(/^refs\/heads\//, "")}`,
];
const inProgress = await describeGitOperation(gitDir);
if (inProgress !== "") parts.push(inProgress);
parts.push("call git_status before committing or branching");
return parts.join(", ");
} catch {
return "";
}
}
async function describeGitOperation(gitDir: string): Promise<string> {
if (await pathExists(join(gitDir, "MERGE_HEAD"))) return "MERGE IN PROGRESS";
if (await pathExists(join(gitDir, "rebase-merge"))) return "REBASE IN PROGRESS";
if (await pathExists(join(gitDir, "rebase-apply"))) return "REBASE IN PROGRESS";
if (await pathExists(join(gitDir, "CHERRY_PICK_HEAD"))) return "CHERRY-PICK IN PROGRESS";
return "";
}
const MANIFESTS: ReadonlyArray<readonly [string, string]> = [
["package.json", "node"],
["deno.json", "deno"],
["tsconfig.json", "typescript"],
["Cargo.toml", "rust"],
["pyproject.toml", "python"],
["requirements.txt", "python"],
["setup.py", "python"],
["go.mod", "go"],
["pom.xml", "java/maven"],
["build.gradle", "java/gradle"],
["build.gradle.kts", "kotlin/gradle"],
["Gemfile", "ruby"],
["composer.json", "php"],
["CMakeLists.txt", "cmake"],
["Makefile", "make"],
];
const LOCKFILES: ReadonlyArray<readonly [string, string]> = [
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
["bun.lockb", "bun"],
["package-lock.json", "npm"],
];
async function describeProject(root: string): Promise<string> {
try {
const found: string[] = [];
const manifests: string[] = [];
for (const [file, label] of MANIFESTS) {
if (!(await pathExists(join(root, file)))) continue;
manifests.push(file);
if (!found.includes(label)) found.push(label);
}
if (manifests.length === 0) return "no manifest found (plain folder)";
const parts = [`${found.join(" + ")} (${manifests.join(", ")})`];
if (manifests.includes("package.json")) {
const runner = await detectRunner(root);
const scripts = await readPackageScripts(root);
if (scripts.length > 0) {
parts.push(`run: ${scripts.map((name) => `${runner} run ${name}`).join(", ")}`);
}
}
return parts.join(" -- ");
} catch {
return "";
}
}
async function detectRunner(root: string): Promise<string> {
for (const [file, runner] of LOCKFILES) {
if (await pathExists(join(root, file))) return runner;
}
return "npm";
}
async function readPackageScripts(root: string): Promise<string[]> {
const raw = await readSmallFile(join(root, "package.json"), MAX_MANIFEST_BYTES);
if (raw === undefined) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return [];
const scripts = (parsed as { scripts?: unknown }).scripts;
if (typeof scripts !== "object" || scripts === null) return [];
const names = Object.keys(scripts as Record<string, unknown>);
return names.length > MAX_SCRIPTS_SHOWN ? names.slice(0, MAX_SCRIPTS_SHOWN) : names;
} catch {
return [];
}
}
async function describeChecklist(root: string): Promise<string> {
try {
const raw = await readSmallFile(join(root, TASK_FILE_NAME), MAX_MANIFEST_BYTES);
if (raw === undefined) return "";
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return "";
const tasks = parsed.filter(isTaskLike);
if (tasks.length === 0) return "";
return `current checklist (${TASK_FILE_NAME}):\n${clamp(
renderTasks(tasks),
CHECKLIST_CHARS,
"checklist -- read it all with get_tasks",
)}`;
} catch {
return "";
}
}
function isTaskLike(value: unknown): value is Task {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.text === "string" &&
typeof candidate.status === "string" &&
["pending", "in_progress", "done", "blocked"].includes(candidate.status) &&
typeof candidate.note === "string"
);
}
const AGENT_DOCS = ["AGENTS.md", "CLAUDE.md"];
async function describeAgentDoc(root: string): Promise<string> {
for (const name of AGENT_DOCS) {
const raw = await readSmallFile(join(root, name), MAX_MANIFEST_BYTES);
if (raw === undefined) continue;
const body = raw.trim();
if (body === "") continue;
return `${name} (project rules, follow them):\n${clamp(body, AGENT_DOC_CHARS, name)}`;
}
return "";
}