13 // Data-driven provider registration. Reads:
14 // 1. <pkgRoot>/models.json (shipped default)
15 // 2. $LITTLE_CODER_MODELS_FILE (if set), else
16 // $XDG_CONFIG_HOME/little-coder/models.json, else
17 // $HOME/.config/little-coder/models.json (user override; per-provider replace)
18 // 3. LLAMACPP_BASE_URL / OLLAMA_BASE_URL env (per-provider baseUrl override)
19 //
20 // Issue #13: previously the model list was hardcoded here and models.json was
21 // only documentation, which made any user edit a no-op until they forked.
157 // ---- 4. Auto-discover bundled extensions ----
158 // Load order matters: bundled first, then the env var, then the user
159 // directory. pi applies later `--extension` flags after earlier ones, so a
160 // user extension can override bundled behavior rather than being shadowed by
161 // it. The three sources are recorded in LITTLE_CODER_EXTENSION_MANIFEST below
162 // so the `/extensions` command can tell the user where each one came from.
163 const extDir = join(pkgRoot, ".pi", "extensions");
164 const extArgs = [];
165 const loadedBundled = [];
166 if (existsSync(extDir)) {
167 for (const name of readdirSync(extDir).sort()) {
168 const subdir = join(extDir, name);
169 const idx = join(subdir, "index.ts");
170 try {
171 if (statSync(subdir).isDirectory() && existsSync(idx)) {
172 extArgs.push("--extension", idx);
173 loadedBundled.push(idx);
174 }
175 } catch {
176 // skip unreadable entries
177 }
178 }
179 }
180
181 // ---- 4b. Third-party extensions via LITTLE_CODER_EXTRA_EXTENSIONS ----
… 25 lines omitted; exact range 157–217 …
207 let userExtensionWarnings = [];
208 {
209 const discovered = discoverUserExtensions(process.env);
210 userExtensionsDir = discovered.dir;
211 userExtensionWarnings = discovered.warnings;
212 for (const w of discovered.warnings) console.error(w);
213 for (const entry of discovered.entries) {
214 extArgs.push("--extension", entry);
215 loadedFromUserDir.push(entry);
216 }
217 }
35 * Resolve a write `path` argument to a concrete on-disk path.
36 *
37 * Two deterministic rewrites:
38 *
39 * 1. `"/<single-segment>"` (e.g. `/foo.md`) → `<cwd>/<single-segment>`.
40 * Background: the model has been seen to anchor at filesystem root when
41 * given an "Absolute file path" schema and no obvious directory context.
42 * Genuine system-path writes always include at least one intermediate
43 * directory (`/etc/X`, `/tmp/Y/Z`), so a root + bare filename is almost
44 * always a mistake. Rewriting to cwd matches user intent and avoids
45 * accidentally writing to `/`.
46 *
47 * 2. Bare filename / relative path (no leading slash) → resolved against cwd.
48 *
49 * Anything else (absolute path with at least one intermediate directory) is
50 * left untouched.
51 */
52 export function normalizeWritePath(
53 filePath: string,
54 cwd: string = process.cwd(),
55 ): { path: string; rewrittenFrom?: string } {
56 if (/^\/[^/]+$/.test(filePath)) {
57 return { path: join(cwd, filePath.slice(1)), rewrittenFrom: filePath };
58 }
59 if (!isAbsolute(filePath)) {
… 5 lines omitted; exact range 35–75 …
65 // Read whichever key carries the destination path. pi's built-in `write` uses
66 // `path`; older little-coder builds and some prompts use `file_path`. We accept
67 // both so the guard is independent of which write implementation is in play.
68 function pathKey(input: Record<string, unknown>): "path" | "file_path" | undefined {
69 if (typeof input.path === "string") return "path";
70 if (typeof input.file_path === "string") return "file_path";
71 return undefined;
72 }
73
74 // Tools that hand a string to a shell, and so can reach the filesystem without
75 // going anywhere near the `write` tool (issue #70).
4
5 // Read-before-edit guard.
6 //
7 // Small models routinely fire `edit` with an `oldText` they never actually saw
8 // — guessing at the current file contents — which either fails the exact-match
9 // requirement (wasting a turn) or, worse, matches the wrong span. Editors the
10 // user is used to (Claude Code et al.) enforce a simple invariant: a file must
11 // be Read before it can be Edited. We reproduce that here.
12 //
13 // Mechanism mirrors write-guard: we don't own pi's built-in `read`/`edit`
14 // tools, so we enforce at the event layer. We remember every file that was
15 // successfully `read` this session (`tool_result`, !isError), and block any
16 // `edit` whose target hasn't been read, redirecting the model to Read first.
17 //
18 // Why a separate extension from `read-guard`: read-guard trims an oversized
19 // read so it can't overflow a small context window — a different concern from
20 // the read-before-edit invariant. Keeping them apart keeps each single-purpose.
21 //
22 // A successful `edit` or `write` also marks the path as known: an edit only
23 // succeeds when the file was already read (we'd have blocked it otherwise), and
24 // a write means the model authored the file's contents, so a follow-up edit to
25 // either is legitimate without a re-read.
26
27 // Files read (or authored) in the current session. Module-scoped: one pi
28 // process drives one session at a time, and we clear on session_start.
29 export const readFiles = new Set<string>();
5 // Detects malformed/fenced tool calls in assistant text and nudges the model
6 // back onto native tool-calling. Active-repair (executing extracted calls
7 // and synthesizing tool_result messages) is intentionally not attempted on
8 // the headline Qwen3.6-35B-A3B path, which uses native tool calling. When
9 // extracted calls ARE detected, we log them via ctx.ui.notify and queue a
10 // follow-up nudge for the next turn.
11 //
12 // One format is handled differently: LFM2/Liquid "Pythonic" tool calls
13 // (`<|tool_call_start|>[Read(path='…')]<|tool_call_end|>`, issue #42). Pythonic
14 // IS that model's native channel, so a "use native tool calls" nudge can't move
15 // it to another format — it would just re-emit the same text every turn and
16 // loop. little-coder also can't execute the calls itself (pi exposes no
17 // extension API to run a tool + synthesize its result). So for that format we
18 // surface a single, accurate diagnostic pointing at the real fix — serving
19 // llama.cpp with `--jinja` and the model's chat template, which parses the
20 // calls into native tool_calls upstream — instead of looping a futile nudge.
3 // Mid-run context watchdog (issue #59).
4 //
5 // pi only evaluates auto-compaction at a *user-turn boundary* — its
6 // `_checkCompaction` runs inside `_handlePostAgentRun`, which fires only after
7 // `agent.prompt()` has fully returned (i.e. once the model stops requesting
8 // tools and goes idle). During one long autonomous run this boundary is never
9 // reached: little-coder's small models routinely chain dozens of tool-call
10 // turns before yielding, so context grows unchecked and can blow straight past
11 // the window — pi then only reacts to the *overflow error* after the fact.
12 // charly1r reproduced exactly this: context climbing 34k → 40k → … → 64k across
13 // many `slot release` turns with no compaction until the request overflowed.
14 //
15 // pi does expose the levers to fix this from an extension: `ctx.getContextUsage()`
16 // reports live token usage against the active model's window, and `ctx.compact()`
17 // triggers pi's own compaction without awaiting it. This extension watches usage
18 // at every turn boundary and, once it crosses a threshold, proactively kicks off
19 // compaction — so a long single run compacts *before* it overflows, at roughly
20 // the same point pi would have if the model had yielded.
21 //
22 // Tuning / opt-out:
23 // LITTLE_CODER_COMPACT_AT_PERCENT trigger threshold, percent of the context
24 // window (default 80). <=0 or >=100 disables.
25 // LITTLE_CODER_NO_COMPACT_WATCHDOG=1 hard off.
26 //
27 // This is complementary to pi's end-of-run compaction, not a replacement — the
28 // `compacting` guard below keeps us from re-firing while a compaction is already
29 // in flight, and pi's own threshold/overflow paths still run at run boundaries.
4 // Harness intervention: trim a `read` result that would overflow the context window.
5 //
6 // little-coder drives SMALL local models with small context windows (the
7 // model's registered contextWindow, read live below via getContextUsage()).
8 // pi's built-in `read` returns up to ~2000 lines in a single tool result
9 // — for a small model that one result can blow past the remaining budget, evict
10 // earlier conversation, and wreck the run. That's exactly the class of failure
11 // the harness-intervention layer exists to catch (cf. thinking-budget cap,
12 // write-guard redirect, turn-cap).
13 //
14 // When a read result would push context usage past the window, we replace it
15 // with only the file's first HEAD_LINES lines plus a message telling the model
16 // why it was trimmed and to use those lines to understand the structure, then
17 // locate what it needs with grep/find or a targeted read (offset/limit) — rather
18 // than re-reading the whole file. The user sees one uniform "harness
19 // intervention: …" line, like every other intervention.
20 //
21 // Why `tool_result`, not `tool_call`: a `tool_call` handler can only `block`
22 // with a `reason` string (no file content) or mutate `input.limit` (lines but no
23 // message). Delivering BOTH the first 30 lines AND an explanation in one result
24 // requires `tool_result`, whose return value replaces the content the model sees
25 // (ToolResultEventResult.content). The full file is still read from disk (pi
26 // already caps that at ~2000 lines) but the oversized text never reaches the LLM
27 // context because we swap it out before it lands.
11 // LITTLE_CODER_PERMISSION_MODE=auto|accept-all|manual
12 // LITTLE_CODER_BASH_ALLOW="cmd1,cmd2 sub,..." extra allow-prefixes,
13 // merged with the built-in list.
14 //
15 // Issue #70: the gate used to match only `bash`/`Bash`, so a model that hit a
16 // refusal could re-run the same thing through the `ShellSession` tool and land
17 // in an execSync with no gate at all. Every shell-executing tool is listed in
18 // SHELL_TOOLS now, and they all go through the same whitelist.
19
20 const BUILTIN_SAFE_PREFIXES: readonly string[] = [
21 "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "date",
22 "which", "type", "env", "printenv", "uname", "whoami", "id",
23 "git log", "git status", "git diff", "git show", "git branch",
24 "git remote", "git stash list", "git tag",
25 "find ", "grep ", "rg ", "ag ", "fd ", "sed ",
26 "python ", "python3 ", "node ", "ruby ", "perl ",
27 "pip show", "pip list", "npm list", "cargo metadata",
28 "df ", "du ", "free ", "top -bn", "ps ",
29 "curl -I", "curl --head",
30 // Routine filesystem scaffolding. Trailing space = word boundary, so
31 // "cp " matches "cp a b" but not "cpufetch". rm stays off the list by
32 // design; use LITTLE_CODER_BASH_ALLOW=rm if a deployment needs it.
33 "cp ", "mv ", "mkdir ", "touch ",
34 ];
35
… 12 lines omitted; exact range 11–58 …
48 export function getSafePrefixes(): string[] {
49 return [...BUILTIN_SAFE_PREFIXES, ...parseExtraPrefixes(process.env.LITTLE_CODER_BASH_ALLOW)];
50 }
51
52 /**
53 * True when EVERY command in `command` is whitelisted and none of them writes.
54 *
55 * Two hardenings over the original `startsWith` check, both from issue #70:
56 *
57 * 1. **Judge every segment.** The check ran on the raw string, so only the
58 * first command was ever inspected — `ls && rm -rf /` was "safe" because it
6 // Port of local/tools/shell_session.py. Two backends implemented:
7 // 1. tmux-proxy — when LITTLE_CODER_TB_MODE=1, route every command to the
8 // parent TB adapter over the extension_ui_request channel. The parent
9 // drives the actual TmuxSession so commands appear in TB's trajectory.
10 // 2. subprocess — child_process.execSync for local use (GAIA doesn't use
11 // ShellSession; this is for local REPL + debugging of TB adapter).
12 //
13 // The sentinel-prompt pexpect backend from the Python version (persistent
14 // bash process with state between calls) is deliberately skipped because
15 // neither Terminal-Bench nor GAIA requires it; TB uses tmux, GAIA uses Bash.
16
1 // Where little-coder's per-turn context augmentation lands (issue #73).
2 //
3 // Four extensions add a block of guidance to a turn: skill-inject (tool skill
4 // cards + the research directive), knowledge-inject (algorithm reference
5 // entries), plan-mode (planning instructions + research), and deep-research
6 // (the report brief). All four used to append to the SYSTEM PROMPT.
7 //
8 // That destroyed the KV cache. The system prompt is the first thing in the
9 // request, so changing it invalidates the entire cached prefix — and these
10 // blocks are recomputed per turn from the user's prompt, so they changed
11 // almost every turn. manueloverride caught it with `cache-hunter`: llama.cpp
12 // re-churning 120k of message history "for no reason" mid-conversation.
13 //
14 // pi already has the right hook. `before_agent_start` may return a `message`
15 // instead of a `systemPrompt` (core/extensions/types.d.ts::
16 // BeforeAgentStartEventResult); pi appends it AFTER the user's message
17 // (core/agent-session.js) and converts `role: "custom"` to a `user` message on
18 // the way to the provider (core/messages.js::convertToLlm). So the block lands
19 // at the TAIL of the conversation with every preceding byte untouched — the
20 // prefix stays cached and only the new tokens are processed.
21 //
22 // The recency argument that put these blocks last in the system prompt gets
23 // stronger, not weaker: small models weight the end of the context most, and
24 // the conversation tail is as late as it gets.
25 //
26 // `LITTLE_CODER_INJECT_MODE=system` restores the old system-prompt behavior,
27 // which is what the whitepaper scaffold reproduction was measured against.
8 // ── Tool-skill registry ─────────────────────────────────────────────────
9 // Port of local/skill_augment.py. Loads skills/tools/*.md once, hooks
10 // `before_agent_start` to add a `## Tool Usage Guidance` block to the turn.
11 // Per-user-prompt selection using the whitepaper's 3-priority algorithm
12 // (error recovery > recency > intent). Budget-guarded, cached.
13 //
14 // The block is delivered as a tail message rather than appended to the system
15 // prompt — see _shared/inject.ts for why (issue #73: it was invalidating the
16 // KV cache on every turn).
17
18 interface ToolSkill {
19 targetTool: string;
20 body: string;
21 tokenCost: number;
22 }
23
24 const skills = new Map<string, ToolSkill>();
25 const selectionCache = new Map<string, string>();
26 let loaded = false;
27
28 // State tracked across the session so we have error-recovery + recency
29 // signals by the time the next `before_agent_start` fires.
30 const recentToolCalls: string[] = []; // most-recent-first, capped at 8
31 let lastFailedTool: string | null = null;
12 // The `dispatch` tool: the main little-coder spawns isolated child little-coder
13 // sessions ("sub-coders") to research a focused question — they read the repo
14 // and browse online, then return a CONCISE report. The full child transcript
15 // lives in the tool's `details` (UI-only); only the short report enters the
16 // parent model's context. A live panel above the input tracks them while they
17 // run. See spawn.ts for the engine and the read-only constraints.
18
19 const MAX_PARALLEL = 4;
20
15 // Plan Mode — a Claude-Code-style "research, ask, then plan" flow.
16 //
17 // ctrl+q toggles plan mode (an indicator appears below the input). While it is
18 // on, submitting a prompt does NOT run a normal coding turn; instead the
19 // extension orchestrates:
20 // 1. decompose the request into 1-4 exploration tasks (a reasoning sub-coder),
21 // 2. dispatch those as read-only explorer sub-coders (isolated context; only
22 // their concise reports survive — their transcripts never enter this window),
23 // 3. generate 1-3 clarifying questions with suggested answers (a sub-coder),
24 // 4. ask them via the UI (with a free-text "Other" option),
25 // 5. synthesize the reports + answers into a written plan in the main window,
26 // 6. exit plan mode.
27 //
28 // An extension can't call inference directly, so every reasoning step is a
29 // child little-coder (spawned via ../subagent/spawn.ts), and the final plan is
30 // injected as a normal turn via pi.sendUserMessage so it lands in the chat.
31 //
32 // ctrl+q is unbound by pi AND by the emacs-style editor (which claims nearly
33 // every other ctrl+<letter> — ctrl+y is its yank/paste, ctrl+a/e line motion,
34 // etc.), so the extension can claim it cleanly without a conflict warning or
35 // shadowing a built-in (shift+tab stays pi's thinking-level cycle — issue #47).
1 // UI-agnostic research phase engine — the single source of truth for the
2 // Scope → Research pipeline (phases 1-4). Both the interactive flow
3 // (index.ts orchestrate()) and the headless batch eval drive THIS function, so
4 // the eval measures the same code production runs. The WRITE phase (step 5) is
5 // intentionally NOT here: the interactive flow hands it to the main agent, and
6 // the eval writes via a dedicated sub-coder — both take {brief, digest} from the
7 // result below.
8 //
9 // Every reasoning step is a child little-coder (an extension can't call
10 // inference directly); research waves fan out read-only research sub-coders.
11 // UI is injected via hooks so this module has no ctx/widget dependency.
5 // Port of local/quality.py. Hooks turn_end, inspects the assistant message
6 // + previous turn's tool calls, and — if we detect a failure mode — sends
7 // a correction user message with deliverAs:"steer" so the model gets it
8 // immediately on its next turn rather than waiting for the next user input.
9
10 // Session-scoped state. Pi reuses extensions across turns within a session;
11 // a fresh extension instance is loaded per session via the session lifecycle.
12 let previousToolCalls: ToolCall[] = [];
13 let consecutiveFailures = 0;
14 const MAX_CONSECUTIVE_CORRECTIONS = 2; // stop nudging after 2 failed corrections
15
16 export default function (pi: ExtensionAPI) {
17 // Populate the known-tools set lazily by observing tool_execution events.
18 // This avoids needing to read pi's tool registry directly.