little-coder 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,13 @@ import { fileURLToPath } from "node:url";
22
22
 
23
23
  const TAGLINE = "A coding agent tuned for small local models";
24
24
 
25
+ // Brand accent — "honey" #E15A1F from the brand book (v1.0). Emitted as a
26
+ // 24-bit truecolor SGR so the cursor matches the documented hex exactly,
27
+ // independent of the active pi theme's named "accent" colour. \x1b[39m resets
28
+ // only the foreground, leaving any surrounding bold/style intact.
29
+ const HONEY = "\x1b[38;2;225;90;31m";
30
+ const honeyFg = (s: string): string => `${HONEY}${s}\x1b[39m`;
31
+
25
32
  function readVersion(): string {
26
33
  // .pi/extensions/branding/index.ts → up 3 → package root (where package.json lives).
27
34
  // The same path math works in the local checkout (loaded via tsx) and in the
@@ -40,8 +47,15 @@ function readVersion(): string {
40
47
  const VERSION = readVersion();
41
48
 
42
49
  function buildHeader(theme: Theme): string[] {
50
+ // Brand-book "prompt lockup" (the variant the brand reserves for terminals
51
+ // and dark surfaces): a honey prompt caret, the wordmark in the foreground,
52
+ // and the honey block cursor — "lc▌"'s ready-to-type punchline, applied to
53
+ // the full wordmark. Honey stays the only accent, well under the brand's
54
+ // ~10%-of-layout cap.
43
55
  const logo =
44
- theme.bold(theme.fg("accent", "little-coder")) +
56
+ honeyFg("> ") +
57
+ theme.bold("little-coder") +
58
+ honeyFg("▌") +
45
59
  theme.fg("dim", ` v${VERSION}`);
46
60
  const tagline = theme.fg("muted", TAGLINE);
47
61
  const dim = (s: string) => theme.fg("dim", s);
@@ -0,0 +1,153 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { harnessIntervention } from "../_shared/intervention.ts";
3
+
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
7
+ // (`context_limit` is 32768 in .pi/settings.json, and the live window is often
8
+ // less). 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.
28
+
29
+ export const HEAD_LINES = 30;
30
+
31
+ // When current context usage is unknown (e.g. right after compaction
32
+ // getContextUsage().tokens is null), fall back to "a single file should never
33
+ // eat more than this fraction of the whole window".
34
+ export const FALLBACK_FRACTION = 0.5;
35
+
36
+ // Tokens to keep in reserve below the window before we call a read an overflow.
37
+ // 0 = trim only on literal overflow; raise it to trim slightly earlier and leave
38
+ // the model headroom to act on the 30 lines.
39
+ export const RESERVE = 0;
40
+
41
+ /** chars→tokens estimate. Same 3.5 ratio as thinking-budget's charsToTokens /
42
+ * local/context_manager.estimate_tokens. */
43
+ export function estimateTokens(chars: number): number {
44
+ return Math.ceil(chars / 3.5);
45
+ }
46
+
47
+ /** First `n` lines of `text`, preserving pi's `cat -n` line-number prefixes so
48
+ * the model keeps a real structural view. Safe when text has fewer than n. */
49
+ export function firstLines(text: string, n: number): string {
50
+ return text.split("\n").slice(0, n).join("\n");
51
+ }
52
+
53
+ export function countLines(text: string): number {
54
+ if (text === "") return 0;
55
+ return text.split("\n").length;
56
+ }
57
+
58
+ /**
59
+ * Decide whether a read result should be trimmed because keeping it whole would
60
+ * exceed the context window.
61
+ *
62
+ * - Nothing to trim if the result is already <= headN lines, or we have no window.
63
+ * - With a known current token count: trim when current + est would cross the
64
+ * window (less RESERVE) — the literal "will result in exceeding the window".
65
+ * - With unknown current usage: trim when the result alone exceeds
66
+ * FALLBACK_FRACTION of the window.
67
+ */
68
+ export function shouldTrimRead(a: {
69
+ contentChars: number;
70
+ currentTokens: number | null;
71
+ contextWindow: number;
72
+ lineCount: number;
73
+ headN: number;
74
+ }): boolean {
75
+ if (!a.contextWindow) return false;
76
+ if (a.lineCount <= a.headN) return false;
77
+ const est = estimateTokens(a.contentChars);
78
+ if (a.currentTokens == null) {
79
+ return est > a.contextWindow * FALLBACK_FRACTION;
80
+ }
81
+ return a.currentTokens + est > a.contextWindow - RESERVE;
82
+ }
83
+
84
+ /** Message appended below the 30 lines, addressed to the model. Leads with the
85
+ * consequence and the directive. */
86
+ export function trimmedReadMessage(a: {
87
+ shownLines: number;
88
+ totalLines: number;
89
+ estTokens: number;
90
+ contextWindow: number;
91
+ }): string {
92
+ return (
93
+ `⚠️ This file is too large to read in full — reading all ${a.totalLines} lines ` +
94
+ `(~${a.estTokens} tokens) would exceed the remaining context window ` +
95
+ `(${a.contextWindow} tokens). Only the first ${a.shownLines} lines are shown above.\n` +
96
+ `\n` +
97
+ `Use these ${a.shownLines} lines to understand the file's structure, then narrow down ` +
98
+ `instead of reading the whole thing:\n` +
99
+ ` • search for what you need with \`grep\` (by content) or \`find\` (by name), then\n` +
100
+ ` • \`read\` only the relevant range with \`offset\` and \`limit\`.\n` +
101
+ `\n` +
102
+ `Do NOT re-read this file in full — it will be trimmed again.`
103
+ );
104
+ }
105
+
106
+ type TextOrImage = { type: string; text?: string };
107
+
108
+ export default function (pi: ExtensionAPI) {
109
+ pi.on("tool_result", async (event, ctx) => {
110
+ if (String((event as any).toolName ?? "").toLowerCase() !== "read") return;
111
+ if ((event as any).isError) return;
112
+
113
+ const content = (((event as any).content ?? []) as TextOrImage[]);
114
+ if (content.length === 0) return;
115
+ // Text-only: an image read can't be line-trimmed, leave it alone.
116
+ if (content.some((c) => c.type !== "text")) return;
117
+ const text = content.map((c) => c.text ?? "").join("");
118
+
119
+ // getContextUsage may be absent on older SDK builds; without a window we
120
+ // can't judge overflow, so leave the result untouched.
121
+ const usage =
122
+ typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
123
+ if (!usage?.contextWindow) return;
124
+
125
+ const lineCount = countLines(text);
126
+ if (
127
+ !shouldTrimRead({
128
+ contentChars: text.length,
129
+ currentTokens: usage.tokens,
130
+ contextWindow: usage.contextWindow,
131
+ lineCount,
132
+ headN: HEAD_LINES,
133
+ })
134
+ ) {
135
+ return;
136
+ }
137
+
138
+ const head = firstLines(text, HEAD_LINES);
139
+ const msg = trimmedReadMessage({
140
+ shownLines: HEAD_LINES,
141
+ totalLines: lineCount,
142
+ estTokens: estimateTokens(text.length),
143
+ contextWindow: usage.contextWindow,
144
+ });
145
+
146
+ harnessIntervention(
147
+ ctx,
148
+ "a read would have overflowed the context window — showed only the file's first 30 lines and told the model to search it instead.",
149
+ );
150
+
151
+ return { content: [{ type: "text" as const, text: head + "\n\n" + msg }] };
152
+ });
153
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import setupReadGuard, {
3
+ HEAD_LINES,
4
+ FALLBACK_FRACTION,
5
+ estimateTokens,
6
+ firstLines,
7
+ countLines,
8
+ shouldTrimRead,
9
+ trimmedReadMessage,
10
+ } from "./index.ts";
11
+
12
+ // ── pure helpers ────────────────────────────────────────────────────────────
13
+
14
+ describe("estimateTokens", () => {
15
+ it("uses the 3.5 chars/token ratio, rounding up", () => {
16
+ expect(estimateTokens(0)).toBe(0);
17
+ expect(estimateTokens(1)).toBe(1); // ceil(1/3.5)
18
+ expect(estimateTokens(35)).toBe(10);
19
+ expect(estimateTokens(36)).toBe(11); // ceil(36/3.5)
20
+ });
21
+ });
22
+
23
+ describe("firstLines", () => {
24
+ const sample = Array.from({ length: 100 }, (_, i) => `${i + 1}\tline ${i + 1}`).join("\n");
25
+
26
+ it("returns the first n lines and preserves cat -n prefixes", () => {
27
+ const out = firstLines(sample, 30);
28
+ expect(countLines(out)).toBe(30);
29
+ expect(out.startsWith("1\tline 1")).toBe(true);
30
+ expect(out.endsWith("30\tline 30")).toBe(true);
31
+ });
32
+
33
+ it("is safe when the text has fewer than n lines", () => {
34
+ expect(firstLines("a\nb", 30)).toBe("a\nb");
35
+ expect(firstLines("", 30)).toBe("");
36
+ });
37
+ });
38
+
39
+ describe("countLines", () => {
40
+ it("counts newline-separated lines, with empty string as zero", () => {
41
+ expect(countLines("")).toBe(0);
42
+ expect(countLines("one")).toBe(1);
43
+ expect(countLines("one\ntwo\nthree")).toBe(3);
44
+ expect(countLines("trailing\n")).toBe(2); // trailing newline => empty final line
45
+ });
46
+ });
47
+
48
+ describe("shouldTrimRead", () => {
49
+ const base = { contextWindow: 32768, headN: HEAD_LINES };
50
+
51
+ it("trims when current tokens + estimate would exceed the window", () => {
52
+ // 100k chars ≈ 28572 tokens; with 10000 already used that crosses 32768.
53
+ expect(
54
+ shouldTrimRead({ ...base, contentChars: 100_000, currentTokens: 10_000, lineCount: 2000 }),
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("does not trim when the result comfortably fits", () => {
59
+ expect(
60
+ shouldTrimRead({ ...base, contentChars: 4_000, currentTokens: 1_000, lineCount: 200 }),
61
+ ).toBe(false);
62
+ });
63
+
64
+ it("never trims when the result is <= headN lines", () => {
65
+ expect(
66
+ shouldTrimRead({ ...base, contentChars: 1_000_000, currentTokens: 30_000, lineCount: HEAD_LINES }),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it("falls back to a window fraction when current usage is unknown (null)", () => {
71
+ const window = 10_000;
72
+ const overChars = Math.ceil(window * FALLBACK_FRACTION * 3.5) + 100; // est just over half
73
+ const underChars = Math.floor(window * FALLBACK_FRACTION * 3.5) - 100; // est just under half
74
+ expect(
75
+ shouldTrimRead({ contextWindow: window, headN: HEAD_LINES, currentTokens: null, contentChars: overChars, lineCount: 2000 }),
76
+ ).toBe(true);
77
+ expect(
78
+ shouldTrimRead({ contextWindow: window, headN: HEAD_LINES, currentTokens: null, contentChars: underChars, lineCount: 2000 }),
79
+ ).toBe(false);
80
+ });
81
+
82
+ it("returns false when there is no context window to judge against", () => {
83
+ expect(
84
+ shouldTrimRead({ contextWindow: 0, headN: HEAD_LINES, currentTokens: 1, contentChars: 1_000_000, lineCount: 2000 }),
85
+ ).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("trimmedReadMessage", () => {
90
+ it("explains the trim and directs to grep/find + targeted read", () => {
91
+ const msg = trimmedReadMessage({ shownLines: 30, totalLines: 2000, estTokens: 28572, contextWindow: 32768 });
92
+ expect(msg).toContain("too large");
93
+ expect(msg).toContain("first 30 lines");
94
+ expect(msg).toContain("grep");
95
+ expect(msg).toContain("find");
96
+ expect(msg).toContain("offset");
97
+ expect(msg).toContain("limit");
98
+ expect(msg).toContain("Do NOT re-read");
99
+ });
100
+ });
101
+
102
+ // ── tool_result handler ─────────────────────────────────────────────────────
103
+
104
+ function getToolResultHandler() {
105
+ let handler: ((event: any, ctx: any) => any) | undefined;
106
+ const pi = {
107
+ on(name: string, h: (event: any, ctx: any) => any) {
108
+ if (name === "tool_result") handler = h;
109
+ },
110
+ };
111
+ setupReadGuard(pi as any);
112
+ if (!handler) throw new Error("read-guard did not register a tool_result handler");
113
+ return handler;
114
+ }
115
+
116
+ function makeCtx(usage: { tokens: number | null; contextWindow: number } | undefined) {
117
+ const notifies: string[] = [];
118
+ return {
119
+ notifies,
120
+ ui: { notify: (m: string) => notifies.push(m) },
121
+ getContextUsage: () => (usage ? { ...usage, percent: null } : undefined),
122
+ };
123
+ }
124
+
125
+ // A read result whose text is `lines` numbered lines, ~chars wide each.
126
+ function bigReadEvent(lines: number, width = 80) {
127
+ const text = Array.from({ length: lines }, (_, i) => `${i + 1}\t${"x".repeat(width)}`).join("\n");
128
+ return { toolName: "read", isError: false, content: [{ type: "text", text }] };
129
+ }
130
+
131
+ describe("read-guard tool_result handler", () => {
132
+ it("trims an oversized read to 30 lines + a directive and fires one intervention", async () => {
133
+ const handler = getToolResultHandler();
134
+ const ctx = makeCtx({ tokens: 20_000, contextWindow: 32768 });
135
+ const result = await handler(bigReadEvent(2000), ctx);
136
+
137
+ expect(result?.content).toHaveLength(1);
138
+ const out = result.content[0].text as string;
139
+ // first 30 lines preserved (and only those), then the directive
140
+ expect(out.startsWith("1\t")).toBe(true);
141
+ expect(out).not.toContain("\n31\t"); // line 31's content must be gone
142
+ const [headPart] = out.split("⚠️");
143
+ expect(countLines(headPart.trimEnd())).toBe(30);
144
+ expect(out).toContain("grep");
145
+ expect(ctx.notifies).toHaveLength(1);
146
+ expect(ctx.notifies[0]).toMatch(/harness intervention:.*first 30 lines/i);
147
+ });
148
+
149
+ it("leaves a read that fits the window untouched", async () => {
150
+ const handler = getToolResultHandler();
151
+ const ctx = makeCtx({ tokens: 1_000, contextWindow: 32768 });
152
+ const result = await handler(bigReadEvent(50), ctx);
153
+ expect(result).toBeUndefined();
154
+ expect(ctx.notifies).toHaveLength(0);
155
+ });
156
+
157
+ it("ignores error results", async () => {
158
+ const handler = getToolResultHandler();
159
+ const ctx = makeCtx({ tokens: 30_000, contextWindow: 32768 });
160
+ const ev = { ...bigReadEvent(2000), isError: true };
161
+ expect(await handler(ev, ctx)).toBeUndefined();
162
+ expect(ctx.notifies).toHaveLength(0);
163
+ });
164
+
165
+ it("ignores results that contain an image block (can't line-trim an image)", async () => {
166
+ const handler = getToolResultHandler();
167
+ const ctx = makeCtx({ tokens: 30_000, contextWindow: 32768 });
168
+ const ev = {
169
+ toolName: "read",
170
+ isError: false,
171
+ content: [{ type: "image", data: "…", mimeType: "image/png" }],
172
+ };
173
+ expect(await handler(ev, ctx)).toBeUndefined();
174
+ });
175
+
176
+ it("ignores non-read tools", async () => {
177
+ const handler = getToolResultHandler();
178
+ const ctx = makeCtx({ tokens: 30_000, contextWindow: 32768 });
179
+ const ev = { ...bigReadEvent(2000), toolName: "bash" };
180
+ expect(await handler(ev, ctx)).toBeUndefined();
181
+ });
182
+
183
+ it("does nothing when context usage is unavailable", async () => {
184
+ const handler = getToolResultHandler();
185
+ const ctx = makeCtx(undefined);
186
+ expect(await handler(bigReadEvent(2000), ctx)).toBeUndefined();
187
+ expect(ctx.notifies).toHaveLength(0);
188
+ });
189
+ });
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
4
4
 
5
+ ## [v1.6.0] — 2026-05-23
6
+
7
+ A new harness intervention for small-context models: oversized file reads no longer blow the context window. little-coder targets local models with small windows (`context_limit` is 32768, and the live window is often less), but pi's built-in `read` returns up to ~2000 lines in a single tool result — enough for one read to evict the conversation and derail the run. The harness now catches that read before it lands and replaces it with the file's head plus a "search, don't slurp" directive, surfaced through the same one-voice `harness intervention: …` line as the thinking-budget cap, write-guard redirect, and turn-cap.
8
+
9
+ ### Added
10
+ - **`read-guard` extension — trims a Read that would overflow the context window.** On the `tool_result` event, when a successful `read`'s content would push context usage past the window (`ctx.getContextUsage().tokens + estimate(result) > contextWindow`, estimated at the same 3.5 chars/token ratio as the thinking-budget cap), the harness replaces the result with **only the file's first 30 lines** followed by a message that explains the trim and directs the model to use those lines to understand the file's structure, then narrow down — locate what it needs with `grep`/`find` or a targeted `read` (`offset`/`limit`) — rather than re-reading the whole file. The full file is still read from disk (pi already caps that at ~2000 lines), but the oversized text never reaches the model's context because the result content is swapped before it lands. `tool_result` (not `tool_call`) is used precisely because it can deliver both the 30 lines and the explanation in one result — a `tool_call` block can only return a `reason` string, and mutating `input.limit` gives lines but no message. When current usage is unknown (e.g. right after compaction, `tokens` is null), it falls back to trimming any single read that alone exceeds half the window. Image reads and error results are left untouched. New extension at `.pi/extensions/read-guard/`, auto-discovered by the launcher.
11
+
12
+ ### Notes for upgraders
13
+ - No CLI flag, `models.json` shape, `.pi/settings.json`, or per-model-profile schema changes. The new extension auto-loads like every other `.pi/extensions/*/index.ts`, and only changes behaviour when a read would otherwise overflow the context window — normal reads pass through untouched. The threshold reads pi's live `getContextUsage()`, so it scales with whatever context window the active model reports.
14
+
15
+ ---
16
+
17
+ ## [v1.5.1] — 2026-05-22
18
+
19
+ A branding release — no behaviour changes. little-coder now wears the v1.0 brand book: the warm **paper / ink / honey** palette (`#F2EBDC` · `#1A1410` · `#E15A1F`), the `lc▌` block-cursor mark, and IBM Plex Mono. The "ready to type" cursor is the punchline — it ties the CLI heritage into the identity without saying so.
20
+
21
+ ### Changed
22
+ - **README hero is now the brand-book terminal banner.** A single self-contained SVG (`assets/banner.svg`, recreating the brand book's "github readme · hero" slide) replaces the old startup screenshot: ink terminal card, `lc▌` monogram in honey, the wordmark + tagline, and the verifiable headline numbers (`qwen3.6-35b-a3b`, terminal-bench 2.0 24.6%, aider polyglot 45.56%). IBM Plex Mono is embedded so it renders in-face on GitHub, with a `ui-monospace` fallback.
23
+ - **TUI header adopts the honey "prompt lockup."** The interactive startup header (`.pi/extensions/branding/index.ts`) now renders `> little-coder▌` with a honey prompt caret and block cursor — the brand's variant for terminals and dark surfaces. Honey is emitted as a 24-bit truecolor SGR so it matches `#E15A1F` exactly regardless of the active pi theme.
24
+
25
+ ### Removed
26
+ - The stale purple (`#7c3aed`) `docs/assets/startup.svg` mockup (`v0.0.1` / `ollama/qwen3.5`), now superseded by the on-brand banner.
27
+
28
+ ---
29
+
5
30
  ## [v1.5.0] — 2026-05-22
6
31
 
7
32
  A reliability + UX release centered on the harness's intervention machinery. Issue [#8](https://github.com/itayinbarr/little-coder/issues/8) reproduced on 1.4.3 through a *new* mechanism, and chasing it down fixed a cluster of related symptoms: thinking never actually turning off after a budget breach, a spurious "empty response" nag after interrupts, and a noisy stack of warnings around every harness decision. Harness interventions now speak with one voice, and the thinking-budget cap is more generous.
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
+ ![little-coder — a coding agent for the laptop in front of you](assets/banner.svg)
2
+
3
+
1
4
  # little-coder
2
5
 
3
6
  **A coding agent tuned for small local models, built on top of [pi](https://pi.dev).**
4
7
 
5
- ![little-coder startup view](docs/assets/startup.svg)
6
-
7
8
  The research story behind all this — why scaffold–model fit matters, how a 9.7 B Qwen beat frontier entries on Aider Polyglot, and what the load-bearing mechanisms actually do — is written up on Substack: **[*Honey, I Shrunk the Coding Agent*](https://open.substack.com/pub/itayinbarr/p/honey-i-shrunk-the-coding-agent)**. Start there if you want the "why"; stay here for the "how".
8
9
 
9
10
  ## How it relates to pi
@@ -298,10 +299,11 @@ The benchmarks harness (`benchmarks/`) is dev-only and not shipped with the npm
298
299
  little-coder/
299
300
  ├── .pi/
300
301
  │ ├── settings.json # per-model profiles + benchmark_overrides (terminal_bench, gaia)
301
- │ └── extensions/ # 21 TypeScript extensions, auto-discovered by pi
302
+ │ └── extensions/ # 23 TypeScript extensions, auto-discovered by pi
302
303
  │ ├── branding/ # little-coder startup header + terminal title (replaces pi's built-in)
303
304
  │ ├── llama-cpp-provider/ # data-driven provider registration from models.json — ships llamacpp, ollama, lmstudio (+ user override file)
304
305
  │ ├── write-guard/ # Write refuses on existing files; rewrites root-bare /foo.md paths to cwd
306
+ │ ├── read-guard/ # trims a Read that would overflow the context window to its first 30 lines + a search-instead directive
305
307
  │ ├── extra-tools/ # glob, webfetch, websearch (pi ships grep/find)
306
308
  │ ├── skill-inject/ # per-turn tool-skill selection (error > recency > intent)
307
309
  │ ├── knowledge-inject/ # algorithm cheat-sheet scoring (word=1.0, bigram=2.0, threshold=2.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
5
5
  "homepage": "https://github.com/itayinbarr/little-coder",
6
6
  "repository": {