little-coder 1.0.3 → 1.2.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.
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { applyEnvOverrides, loadProviders, mergeProviders, resolveOverridePath, type ProviderEntry } from "./config.ts";
7
+
8
+ const sampleProvider = (baseUrl: string, modelId: string): ProviderEntry => ({
9
+ api: "openai-completions",
10
+ baseUrl,
11
+ apiKey: "SAMPLE_KEY",
12
+ models: [
13
+ {
14
+ id: modelId,
15
+ name: modelId,
16
+ reasoning: true,
17
+ input: ["text"],
18
+ contextWindow: 32768,
19
+ maxTokens: 4096,
20
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
21
+ },
22
+ ],
23
+ });
24
+
25
+ describe("resolveOverridePath", () => {
26
+ it("prefers LITTLE_CODER_MODELS_FILE", () => {
27
+ expect(resolveOverridePath({ LITTLE_CODER_MODELS_FILE: "/explicit.json", HOME: "/h" })).toBe("/explicit.json");
28
+ });
29
+ it("falls back to XDG_CONFIG_HOME", () => {
30
+ expect(resolveOverridePath({ XDG_CONFIG_HOME: "/xdg", HOME: "/h" })).toBe("/xdg/little-coder/models.json");
31
+ });
32
+ it("falls back to HOME/.config", () => {
33
+ expect(resolveOverridePath({ HOME: "/h" })).toBe("/h/.config/little-coder/models.json");
34
+ });
35
+ it("returns undefined when neither is set", () => {
36
+ expect(resolveOverridePath({})).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("mergeProviders", () => {
41
+ it("returns the package default unchanged when there's no override", () => {
42
+ const pkg = { llamacpp: sampleProvider("http://a/v1", "m1") };
43
+ expect(mergeProviders(pkg, undefined)).toEqual(pkg);
44
+ });
45
+ it("user provider replaces same-key package provider", () => {
46
+ const pkg = { llamacpp: sampleProvider("http://a/v1", "pkg-model") };
47
+ const user = { llamacpp: sampleProvider("http://b/v1", "user-model") };
48
+ const merged = mergeProviders(pkg, user);
49
+ expect(merged.llamacpp.baseUrl).toBe("http://b/v1");
50
+ expect(merged.llamacpp.models[0].id).toBe("user-model");
51
+ });
52
+ it("user provider not in package is added", () => {
53
+ const pkg = { llamacpp: sampleProvider("http://a/v1", "m1") };
54
+ const user = { custom: sampleProvider("http://c/v1", "c1") };
55
+ const merged = mergeProviders(pkg, user);
56
+ expect(Object.keys(merged).sort()).toEqual(["custom", "llamacpp"]);
57
+ });
58
+ it("package providers without an override are kept as-is", () => {
59
+ const pkg = {
60
+ llamacpp: sampleProvider("http://a/v1", "m1"),
61
+ ollama: sampleProvider("http://o/v1", "m2"),
62
+ };
63
+ const user = { llamacpp: sampleProvider("http://b/v1", "m1b") };
64
+ const merged = mergeProviders(pkg, user);
65
+ expect(merged.ollama.baseUrl).toBe("http://o/v1");
66
+ });
67
+ });
68
+
69
+ describe("applyEnvOverrides", () => {
70
+ it("LLAMACPP_BASE_URL overrides llamacpp baseUrl", () => {
71
+ const providers = { llamacpp: sampleProvider("http://file/v1", "m1") };
72
+ const out = applyEnvOverrides(providers, { LLAMACPP_BASE_URL: "http://env/v1" });
73
+ expect(out.llamacpp.baseUrl).toBe("http://env/v1");
74
+ });
75
+ it("OLLAMA_BASE_URL overrides ollama baseUrl", () => {
76
+ const providers = { ollama: sampleProvider("http://file/v1", "m2") };
77
+ const out = applyEnvOverrides(providers, { OLLAMA_BASE_URL: "http://env/v1" });
78
+ expect(out.ollama.baseUrl).toBe("http://env/v1");
79
+ });
80
+ it("LMSTUDIO_BASE_URL overrides lmstudio baseUrl", () => {
81
+ const providers = { lmstudio: sampleProvider("http://127.0.0.1:1234/v1", "local-model") };
82
+ const out = applyEnvOverrides(providers, { LMSTUDIO_BASE_URL: "http://127.0.0.1:5678/v1" });
83
+ expect(out.lmstudio.baseUrl).toBe("http://127.0.0.1:5678/v1");
84
+ });
85
+ it("does not alter providers without a known env knob", () => {
86
+ const providers = { custom: sampleProvider("http://file/v1", "m") };
87
+ const out = applyEnvOverrides(providers, { LLAMACPP_BASE_URL: "http://env/v1" });
88
+ expect(out.custom.baseUrl).toBe("http://file/v1");
89
+ });
90
+ });
91
+
92
+ describe("loadProviders (filesystem)", () => {
93
+ let dir: string;
94
+ beforeEach(() => {
95
+ dir = mkdtempSync(join(tmpdir(), "lc-providers-"));
96
+ });
97
+ afterEach(() => {
98
+ rmSync(dir, { recursive: true, force: true });
99
+ });
100
+
101
+ it("loads the package default when present", () => {
102
+ writeFileSync(
103
+ join(dir, "models.json"),
104
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "m1") } }),
105
+ );
106
+ const result = loadProviders(dir, {});
107
+ expect(Object.keys(result.providers)).toEqual(["llamacpp"]);
108
+ expect(result.sources[0]).toMatchObject({ status: "ok" });
109
+ });
110
+
111
+ it("merges a user override file when LITTLE_CODER_MODELS_FILE points at one", () => {
112
+ writeFileSync(
113
+ join(dir, "models.json"),
114
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "pkg") } }),
115
+ );
116
+ const userPath = join(dir, "user-models.json");
117
+ writeFileSync(
118
+ userPath,
119
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://b/v1", "user") } }),
120
+ );
121
+ const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
122
+ expect(result.providers.llamacpp.baseUrl).toBe("http://b/v1");
123
+ expect(result.providers.llamacpp.models[0].id).toBe("user");
124
+ });
125
+
126
+ it("reports invalid JSON in the package default and returns empty providers", () => {
127
+ writeFileSync(join(dir, "models.json"), "{ this is not json");
128
+ const result = loadProviders(dir, {});
129
+ expect(result.providers).toEqual({});
130
+ expect(result.sources[0].status).toBe("invalid");
131
+ });
132
+
133
+ it("reports a missing user override without failing the load", () => {
134
+ writeFileSync(
135
+ join(dir, "models.json"),
136
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "m1") } }),
137
+ );
138
+ const missing = join(dir, "no-such-dir", "models.json");
139
+ const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: missing });
140
+ expect(result.providers.llamacpp.baseUrl).toBe("http://a/v1");
141
+ expect(result.sources.find((s) => s.path === missing)?.status).toBe("missing");
142
+ });
143
+
144
+ it("env var still overrides baseUrl after merge", () => {
145
+ writeFileSync(
146
+ join(dir, "models.json"),
147
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://file/v1", "m") } }),
148
+ );
149
+ const result = loadProviders(dir, { LLAMACPP_BASE_URL: "http://env/v1" });
150
+ expect(result.providers.llamacpp.baseUrl).toBe("http://env/v1");
151
+ });
152
+
153
+ it("XDG_CONFIG_HOME overrides applied when no LITTLE_CODER_MODELS_FILE set", () => {
154
+ writeFileSync(
155
+ join(dir, "models.json"),
156
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "pkg") } }),
157
+ );
158
+ const xdg = join(dir, "xdg");
159
+ mkdirSync(join(xdg, "little-coder"), { recursive: true });
160
+ writeFileSync(
161
+ join(xdg, "little-coder", "models.json"),
162
+ JSON.stringify({ providers: { llamacpp: sampleProvider("http://x/v1", "via-xdg") } }),
163
+ );
164
+ const result = loadProviders(dir, { XDG_CONFIG_HOME: xdg });
165
+ expect(result.providers.llamacpp.models[0].id).toBe("via-xdg");
166
+ });
167
+ });
168
+
169
+ describe("shipped models.json", () => {
170
+ const here = dirname(fileURLToPath(import.meta.url));
171
+ const pkgRoot = resolve(here, "..", "..", "..");
172
+
173
+ it("registers lmstudio/local-model on http://127.0.0.1:1234/v1", () => {
174
+ const result = loadProviders(pkgRoot, {});
175
+ const lmstudio = result.providers.lmstudio;
176
+ expect(lmstudio, "lmstudio provider should be present in shipped models.json").toBeDefined();
177
+ expect(lmstudio.baseUrl).toBe("http://127.0.0.1:1234/v1");
178
+ expect(lmstudio.api).toBe("openai-completions");
179
+ expect(lmstudio.apiKey).toBe("LMSTUDIO_API_KEY");
180
+ expect(lmstudio.models.find((m) => m.id === "local-model")).toBeDefined();
181
+ });
182
+
183
+ it("still registers llamacpp and ollama alongside lmstudio", () => {
184
+ const result = loadProviders(pkgRoot, {});
185
+ expect(Object.keys(result.providers).sort()).toEqual(["llamacpp", "lmstudio", "ollama"]);
186
+ });
187
+ });
@@ -0,0 +1,148 @@
1
+ // Pure config-loading logic for the providers extension. Kept separate from
2
+ // the pi wiring in index.ts so it can be unit-tested without a pi runtime.
3
+ //
4
+ // Schema (all required unless noted):
5
+ // {
6
+ // "providers": {
7
+ // "<name>": {
8
+ // "api": "openai-completions",
9
+ // "baseUrl": "http://...",
10
+ // "apiKey": "ENV_VAR_NAME",
11
+ // "models": [ { id, name, reasoning, input, contextWindow, maxTokens, cost }, ... ]
12
+ // }, ...
13
+ // }
14
+ // }
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ export interface ProviderModelEntry {
20
+ id: string;
21
+ name: string;
22
+ reasoning: boolean;
23
+ input: ("text" | "image")[];
24
+ contextWindow: number;
25
+ maxTokens: number;
26
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
27
+ }
28
+
29
+ export interface ProviderEntry {
30
+ api: string;
31
+ baseUrl: string;
32
+ apiKey: string;
33
+ models: ProviderModelEntry[];
34
+ }
35
+
36
+ export interface ModelsFile {
37
+ providers: Record<string, ProviderEntry>;
38
+ }
39
+
40
+ export interface LoadResult {
41
+ providers: Record<string, ProviderEntry>;
42
+ /** Files that were attempted, in resolution order. Useful for diagnostics. */
43
+ sources: { path: string; status: "ok" | "missing" | "invalid"; error?: string }[];
44
+ }
45
+
46
+ /** Provider env knob: if set, overrides the provider's baseUrl. Originally a
47
+ * back-compat shim for the two providers we shipped before the data-driven
48
+ * refactor; kept as the per-provider env-override pattern for any provider
49
+ * whose baseUrl changes between deployments. */
50
+ const LEGACY_BASE_URL_ENV: Record<string, string> = {
51
+ llamacpp: "LLAMACPP_BASE_URL",
52
+ ollama: "OLLAMA_BASE_URL",
53
+ lmstudio: "LMSTUDIO_BASE_URL",
54
+ };
55
+
56
+ /** Resolution order for the user-override file. First existing path wins. */
57
+ export function resolveOverridePath(env: NodeJS.ProcessEnv = process.env): string | undefined {
58
+ if (env.LITTLE_CODER_MODELS_FILE) return env.LITTLE_CODER_MODELS_FILE;
59
+ const xdg = env.XDG_CONFIG_HOME;
60
+ if (xdg) return join(xdg, "little-coder", "models.json");
61
+ if (env.HOME) return join(env.HOME, ".config", "little-coder", "models.json");
62
+ return undefined;
63
+ }
64
+
65
+ function parseModelsFile(raw: string): ModelsFile {
66
+ const parsed = JSON.parse(raw);
67
+ if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
68
+ throw new Error("expected top-level { providers: { ... } }");
69
+ }
70
+ return parsed as ModelsFile;
71
+ }
72
+
73
+ function readIfPresent(path: string): { kind: "ok"; data: ModelsFile } | { kind: "missing" } | { kind: "invalid"; error: string } {
74
+ if (!existsSync(path)) return { kind: "missing" };
75
+ try {
76
+ const raw = readFileSync(path, "utf-8");
77
+ return { kind: "ok", data: parseModelsFile(raw) };
78
+ } catch (err) {
79
+ return { kind: "invalid", error: err instanceof Error ? err.message : String(err) };
80
+ }
81
+ }
82
+
83
+ export function applyEnvOverrides(providers: Record<string, ProviderEntry>, env: NodeJS.ProcessEnv = process.env): Record<string, ProviderEntry> {
84
+ const out: Record<string, ProviderEntry> = {};
85
+ for (const [name, entry] of Object.entries(providers)) {
86
+ const envVar = LEGACY_BASE_URL_ENV[name];
87
+ if (envVar && env[envVar]) {
88
+ out[name] = { ...entry, baseUrl: env[envVar]! };
89
+ } else {
90
+ out[name] = entry;
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Merge: user file's providers fully replace package providers with the same
98
+ * key. Providers only in the user file are added. Providers only in the
99
+ * package default are kept. (We deliberately avoid deep per-model merging —
100
+ * the user redeclares the whole provider entry if they want to change it,
101
+ * which is far less surprising than "your override silently inherited fields
102
+ * from a future package release.")
103
+ */
104
+ export function mergeProviders(
105
+ pkgDefault: Record<string, ProviderEntry>,
106
+ userOverride: Record<string, ProviderEntry> | undefined,
107
+ ): Record<string, ProviderEntry> {
108
+ if (!userOverride) return { ...pkgDefault };
109
+ return { ...pkgDefault, ...userOverride };
110
+ }
111
+
112
+ /**
113
+ * Load the package default models.json + (optionally) the user override file,
114
+ * apply env-var baseUrl overrides for the legacy providers, and return the
115
+ * merged provider map plus diagnostics for each source.
116
+ */
117
+ export function loadProviders(pkgRoot: string, env: NodeJS.ProcessEnv = process.env): LoadResult {
118
+ const sources: LoadResult["sources"] = [];
119
+ const defaultPath = join(pkgRoot, "models.json");
120
+ const defaultRead = readIfPresent(defaultPath);
121
+ let pkgDefault: Record<string, ProviderEntry> = {};
122
+ if (defaultRead.kind === "ok") {
123
+ pkgDefault = defaultRead.data.providers;
124
+ sources.push({ path: defaultPath, status: "ok" });
125
+ } else if (defaultRead.kind === "missing") {
126
+ sources.push({ path: defaultPath, status: "missing" });
127
+ } else {
128
+ sources.push({ path: defaultPath, status: "invalid", error: defaultRead.error });
129
+ }
130
+
131
+ const overridePath = resolveOverridePath(env);
132
+ let userOverride: Record<string, ProviderEntry> | undefined;
133
+ if (overridePath) {
134
+ const userRead = readIfPresent(overridePath);
135
+ if (userRead.kind === "ok") {
136
+ userOverride = userRead.data.providers;
137
+ sources.push({ path: overridePath, status: "ok" });
138
+ } else if (userRead.kind === "missing") {
139
+ sources.push({ path: overridePath, status: "missing" });
140
+ } else {
141
+ sources.push({ path: overridePath, status: "invalid", error: userRead.error });
142
+ }
143
+ }
144
+
145
+ const merged = mergeProviders(pkgDefault, userOverride);
146
+ const withEnv = applyEnvOverrides(merged, env);
147
+ return { providers: withEnv, sources };
148
+ }
@@ -1,58 +1,44 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
1
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { loadProviders } from "./config.ts";
2
5
 
3
- const LLAMACPP_BASE_URL = process.env.LLAMACPP_BASE_URL || "http://127.0.0.1:8888/v1";
4
- const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/v1";
6
+ // Data-driven provider registration. Reads:
7
+ // 1. <pkgRoot>/models.json (shipped default)
8
+ // 2. $LITTLE_CODER_MODELS_FILE (if set), else
9
+ // $XDG_CONFIG_HOME/little-coder/models.json, else
10
+ // $HOME/.config/little-coder/models.json (user override; per-provider replace)
11
+ // 3. LLAMACPP_BASE_URL / OLLAMA_BASE_URL env (per-provider baseUrl override)
12
+ //
13
+ // Issue #13: previously the model list was hardcoded here and models.json was
14
+ // only documentation, which made any user edit a no-op until they forked.
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const pkgRoot = resolve(here, "..", "..", "..");
5
18
 
6
19
  export default function (pi: ExtensionAPI) {
7
- pi.registerProvider("llamacpp", {
8
- baseUrl: LLAMACPP_BASE_URL,
9
- apiKey: "LLAMACPP_API_KEY",
10
- api: "openai-completions",
11
- models: [
12
- {
13
- id: "qwen3.6-27b",
14
- name: "Qwen3.6-27B (dense, local llama.cpp)",
15
- reasoning: true,
16
- input: ["text"],
17
- contextWindow: 32768,
18
- maxTokens: 4096,
19
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
20
- },
21
- {
22
- id: "qwen3.6-35b-a3b",
23
- name: "Qwen3.6-35B-A3B (MoE, local llama.cpp)",
24
- reasoning: true,
25
- input: ["text"],
26
- contextWindow: 32768,
27
- maxTokens: 4096,
28
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
29
- },
30
- {
31
- id: "qwen3.5-9b",
32
- name: "Qwen3.5-9B (local llama.cpp)",
33
- reasoning: true,
34
- input: ["text"],
35
- contextWindow: 32768,
36
- maxTokens: 4096,
37
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
38
- },
39
- ],
40
- });
20
+ const result = loadProviders(pkgRoot);
21
+
22
+ for (const src of result.sources) {
23
+ if (src.status === "invalid") {
24
+ console.error(`[llama-cpp-provider] ignoring ${src.path}: ${src.error}`);
25
+ }
26
+ }
27
+
28
+ const providerCount = Object.keys(result.providers).length;
29
+ if (providerCount === 0) {
30
+ console.error(
31
+ `[llama-cpp-provider] no providers loaded — checked: ${result.sources.map((s) => `${s.path} [${s.status}]`).join(", ")}`,
32
+ );
33
+ return;
34
+ }
41
35
 
42
- pi.registerProvider("ollama", {
43
- baseUrl: OLLAMA_BASE_URL,
44
- apiKey: "OLLAMA_API_KEY",
45
- api: "openai-completions",
46
- models: [
47
- {
48
- id: "qwen3.5",
49
- name: "Qwen3.5 (ollama)",
50
- reasoning: true,
51
- input: ["text"],
52
- contextWindow: 32768,
53
- maxTokens: 4096,
54
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
55
- },
56
- ],
57
- });
36
+ for (const [name, entry] of Object.entries(result.providers)) {
37
+ pi.registerProvider(name, {
38
+ baseUrl: entry.baseUrl,
39
+ apiKey: entry.apiKey,
40
+ api: entry.api,
41
+ models: entry.models,
42
+ });
43
+ }
58
44
  }
@@ -5,8 +5,13 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
5
  // "accept-all" mode all commands pass (benchmark runs set this explicitly).
6
6
  // Write/Edit confirmations are deferred to the TUI's own prompt; we simply
7
7
  // add an extra guardrail on bash here to match little-coder's behavior.
8
+ //
9
+ // Per-deployment customization (issue #15):
10
+ // LITTLE_CODER_PERMISSION_MODE=auto|accept-all|manual
11
+ // LITTLE_CODER_BASH_ALLOW="cmd1,cmd2 sub,..." extra allow-prefixes,
12
+ // merged with the built-in list.
8
13
 
9
- const SAFE_PREFIXES: readonly string[] = [
14
+ const BUILTIN_SAFE_PREFIXES: readonly string[] = [
10
15
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "date",
11
16
  "which", "type", "env", "printenv", "uname", "whoami", "id",
12
17
  "git log", "git status", "git diff", "git show", "git branch",
@@ -18,9 +23,25 @@ const SAFE_PREFIXES: readonly string[] = [
18
23
  "curl -I", "curl --head",
19
24
  ];
20
25
 
21
- export function isSafeBash(command: string): boolean {
26
+ // Trailing whitespace is meaningful — it acts as a word boundary in startsWith
27
+ // matching ("find " refuses "findbug"). We only strip leading whitespace so
28
+ // callers retain control over that boundary.
29
+ export function parseExtraPrefixes(raw: string | undefined): string[] {
30
+ if (!raw) return [];
31
+ return raw
32
+ .split(",")
33
+ .map((s) => s.trimStart())
34
+ .map((s) => (s.length > 0 && s !== " ".repeat(s.length) ? s : ""))
35
+ .filter((s) => s.length > 0);
36
+ }
37
+
38
+ export function getSafePrefixes(): string[] {
39
+ return [...BUILTIN_SAFE_PREFIXES, ...parseExtraPrefixes(process.env.LITTLE_CODER_BASH_ALLOW)];
40
+ }
41
+
42
+ export function isSafeBash(command: string, prefixes: readonly string[] = getSafePrefixes()): boolean {
22
43
  const c = command.trim();
23
- return SAFE_PREFIXES.some((p) => c.startsWith(p));
44
+ return prefixes.some((p) => c.startsWith(p));
24
45
  }
25
46
 
26
47
  function getPermissionMode(): "auto" | "accept-all" | "manual" {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { isSafeBash } from "./index.ts";
2
+ import { isSafeBash, parseExtraPrefixes, getSafePrefixes } from "./index.ts";
3
3
 
4
4
  describe("isSafeBash", () => {
5
5
  it("allows whitelisted read-only commands", () => {
@@ -23,4 +23,45 @@ describe("isSafeBash", () => {
23
23
  expect(isSafeBash("git push origin main")).toBe(false);
24
24
  expect(isSafeBash("git commit -m x")).toBe(false);
25
25
  });
26
+ it("respects an explicit prefix list (LITTLE_CODER_BASH_ALLOW shape)", () => {
27
+ const extra = ["make ", "docker compose ps"];
28
+ expect(isSafeBash("make test", extra)).toBe(true);
29
+ expect(isSafeBash("docker compose ps", extra)).toBe(true);
30
+ expect(isSafeBash("docker compose down", extra)).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe("parseExtraPrefixes", () => {
35
+ it("returns empty for undefined / empty / whitespace", () => {
36
+ expect(parseExtraPrefixes(undefined)).toEqual([]);
37
+ expect(parseExtraPrefixes("")).toEqual([]);
38
+ expect(parseExtraPrefixes(" ")).toEqual([]);
39
+ });
40
+ it("splits on comma and trims leading whitespace, preserving trailing space as word boundary", () => {
41
+ expect(parseExtraPrefixes("make , docker compose ps, bun run")).toEqual([
42
+ "make ",
43
+ "docker compose ps",
44
+ "bun run",
45
+ ]);
46
+ });
47
+ it("drops empty / whitespace-only segments", () => {
48
+ expect(parseExtraPrefixes("a,,b,")).toEqual(["a", "b"]);
49
+ expect(parseExtraPrefixes("a, ,b")).toEqual(["a", "b"]);
50
+ });
51
+ });
52
+
53
+ describe("getSafePrefixes", () => {
54
+ it("merges builtins with LITTLE_CODER_BASH_ALLOW from the env", () => {
55
+ const prev = process.env.LITTLE_CODER_BASH_ALLOW;
56
+ process.env.LITTLE_CODER_BASH_ALLOW = "make ,docker compose ps";
57
+ try {
58
+ const all = getSafePrefixes();
59
+ expect(all).toContain("ls"); // builtin still present
60
+ expect(all).toContain("make ");
61
+ expect(all).toContain("docker compose ps");
62
+ } finally {
63
+ if (prev === undefined) delete process.env.LITTLE_CODER_BASH_ALLOW;
64
+ else process.env.LITTLE_CODER_BASH_ALLOW = prev;
65
+ }
66
+ });
26
67
  });
@@ -2,9 +2,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { assessResponse, buildCorrectionMessage, type ToolCall } from "./quality.ts";
3
3
 
4
4
  // Port of local/quality.py. Hooks turn_end, inspects the assistant message
5
- // + previous turn's tool calls, and — if we detect a failure mode — queues
6
- // a correction user message via session.followUp() so the model gets a
7
- // chance to recover on its next turn.
5
+ // + previous turn's tool calls, and — if we detect a failure mode — sends
6
+ // a correction user message with deliverAs:"steer" so the model gets it
7
+ // immediately on its next turn rather than waiting for the next user input.
8
8
 
9
9
  // Session-scoped state. Pi reuses extensions across turns within a session;
10
10
  // a fresh extension instance is loaded per session via the session lifecycle.
@@ -62,9 +62,12 @@ export default function (pi: ExtensionAPI) {
62
62
 
63
63
  const correction = buildCorrectionMessage(verdict.reason);
64
64
  ctx.ui.notify(
65
- `quality-monitor: ${verdict.reason} → queued correction`,
65
+ `quality-monitor: ${verdict.reason} → injecting correction`,
66
66
  "warning",
67
67
  );
68
- pi.sendUserMessage(correction, { deliverAs: "followUp" });
68
+ // "steer" delivers the correction promptly to the in-flight loop. The
69
+ // prior "followUp" mode parked the message until the *next* user input,
70
+ // by which point it was no longer relevant (issue #16).
71
+ pi.sendUserMessage(correction, { deliverAs: "steer" });
69
72
  });
70
73
  }
@@ -56,7 +56,6 @@ const INTENT_MAP: Record<string, string[]> = {
56
56
  browse: ["BrowserNavigate", "BrowserExtract"],
57
57
  page: ["BrowserExtract"],
58
58
  click: ["BrowserClick"],
59
- agent: ["Agent"], delegate: ["Agent"], spawn: ["Agent"],
60
59
  };
61
60
 
62
61
  function skillsDir(): string {
@@ -21,7 +21,6 @@ const INTENT_MAP: Record<string, string[]> = {
21
21
  grep: ["Grep"], glob: ["Glob"],
22
22
  fetch: ["WebFetch"], download: ["WebFetch"], url: ["WebFetch"],
23
23
  web: ["WebSearch"],
24
- agent: ["Agent"], delegate: ["Agent"], spawn: ["Agent"],
25
24
  };
26
25
 
27
26
  function predictTools(userText: string): string[] {
@@ -61,10 +60,10 @@ describe("skills directory loads from repo", () => {
61
60
  const here = dirname(fileURLToPath(import.meta.url));
62
61
  const toolsDir = join(here, "..", "..", "..", "skills", "tools");
63
62
 
64
- it("exists and has 14 markdown files", () => {
63
+ it("exists and has 13 markdown files", () => {
65
64
  expect(existsSync(toolsDir)).toBe(true);
66
65
  const files = readdirSync(toolsDir).filter((f) => f.endsWith(".md"));
67
- expect(files.length).toBe(14);
66
+ expect(files.length).toBe(13);
68
67
  });
69
68
 
70
69
  it("every tool skill has target_tool in frontmatter", () => {
package/.pi/settings.json CHANGED
@@ -70,6 +70,14 @@
70
70
  "skill_token_budget": 300,
71
71
  "knowledge_token_budget": 200,
72
72
  "temperature": 0.3
73
+ },
74
+ "lmstudio/local-model": {
75
+ "context_limit": 32768,
76
+ "max_tokens": 4096,
77
+ "thinking_budget": 2048,
78
+ "skill_token_budget": 300,
79
+ "knowledge_token_budget": 200,
80
+ "temperature": 0.3
73
81
  }
74
82
  }
75
83
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
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.2.0] — 2026-05-10
6
+
7
+ Issue-cleanup release that also ships built-in LM Studio support. Closes [#17](https://github.com/itayinbarr/little-coder/issues/17) (Windows), [#19](https://github.com/itayinbarr/little-coder/issues/19) (phantom Agent tool), [#21](https://github.com/itayinbarr/little-coder/issues/21) (skill param mismatch).
8
+
9
+ ### Added
10
+ - **Built-in `lmstudio/local-model` provider.** [LM Studio](https://lmstudio.ai/) exposes an OpenAI-compatible server on `http://127.0.0.1:1234/v1` by default, and previously the only way to use it was to overload `LLAMACPP_BASE_URL`. Now you can run `little-coder --model lmstudio/local-model` and it routes to whatever model LM Studio currently has loaded — no extra config for the single-model case. New env knobs `LMSTUDIO_BASE_URL` (overrides baseUrl, parity with `LLAMACPP_BASE_URL`/`OLLAMA_BASE_URL`) and `LMSTUDIO_API_KEY` (any value; LM Studio ignores it locally but pi requires the env var to exist). README has a new **Option C — LM Studio** under *Local model setup*. `.pi/settings.json` ships a `lmstudio/local-model` profile so the same context/thinking-budget tuning as the llamacpp profiles applies.
11
+
12
+ ### Fixed
13
+ - **Windows launch ([#17](https://github.com/itayinbarr/little-coder/issues/17), thanks @Grogger for [PR #18](https://github.com/itayinbarr/little-coder/pull/18)).** On Windows, `node_modules/.bin/pi` is a `.cmd` shim that Node 20's `spawn()` can't execute directly without `shell: true`, and `shell: true` reintroduces the CVE-2024-27980 / DEP0190 shell-injection class. The launcher now resolves `pi.cmd` on Windows and invokes `cmd.exe /c pi.cmd ...` with args as an array — works on Windows 11, no Linux/macOS regression.
14
+ - **Edit skill documentation ([#21](https://github.com/itayinbarr/little-coder/issues/21)).** `skills/tools/edit.md` advertised `old_string` / `new_string`, but pi's Edit tool only accepts `oldText` / `newText` (single-edit form) or `edits: [{oldText, newText}]` (array form). Rewritten to show the canonical array form *and* the single-edit back-compat form. While in there, also corrected `skills/tools/read.md` and `skills/tools/write.md` (`file_path` → `path` — pi aliases both, but the canonical name is now in the docs) and `skills/tools/grep.md` (`include` → `glob`, `max_results` → `limit`; pi does not alias these, so the old skill could genuinely produce tool-call errors on the grep path the same way Edit did).
15
+
16
+ ### Changed
17
+ - **Removed phantom `Agent` skill ([#19](https://github.com/itayinbarr/little-coder/issues/19)).** `skills/tools/agent.md` documented an `Agent` tool that little-coder never actually registered — pi ships `examples/extensions/subagent/` as a reference impl, but it was not wired up by default. Deleted the skill card and the `agent` / `delegate` / `spawn` keys from `.pi/extensions/skill-inject/index.ts`'s `INTENT_MAP` so the model is no longer told it has a delegation tool. The `skills/protocols/task_decomposition.md` cheatsheet is untouched — decomposition guidance does not depend on a delegation tool.
18
+
19
+ ### Notes for upgraders
20
+ - No CLI flag, settings, or skill-pack breaks. `--model lmstudio/local-model` works out of the box if LM Studio is serving on its default port 1234 with a model loaded.
21
+ - If you'd been overloading `LLAMACPP_BASE_URL=http://127.0.0.1:1234/v1` to point at LM Studio, that keeps working — but the cleaner path is now `--model lmstudio/local-model` with no env tweaking.
22
+
23
+ ---
24
+
25
+ ## [v1.1.0] — 2026-05-03
26
+
27
+ Issue-cleanup release. Three small features and one bug fix, driven by GitHub issues #12 / #13 / #15 / #16.
28
+
29
+ ### Added
30
+ - **`models.json` is now the canonical provider registration.** ([#13](https://github.com/itayinbarr/little-coder/issues/13))
31
+ Previously `.pi/extensions/llama-cpp-provider/index.ts` hardcoded the model list and `models.json` was decorative; editing it had no effect. Now the extension loads providers and models from `models.json` at startup and registers them dynamically. **User override file** (first match wins): `$LITTLE_CODER_MODELS_FILE` → `$XDG_CONFIG_HOME/little-coder/models.json` → `~/.config/little-coder/models.json`. Per-provider replace semantics — your override fully replaces a same-keyed provider in the shipped file. Diagnostics for missing/invalid sources surface via `console.error`. The legacy `LLAMACPP_BASE_URL` / `OLLAMA_BASE_URL` env vars still beat both files for those two providers. New unit-test module `.pi/extensions/llama-cpp-provider/config.test.ts` covers merge, env override, and resolution-order semantics. README has a new **Configuring models** section.
32
+ - **`LITTLE_CODER_BASH_ALLOW` env var** ([#15](https://github.com/itayinbarr/little-coder/issues/15)) — comma-separated extra prefixes merged with the built-in `permission-gate` whitelist, so deployments can allow extra bash commands without forking. Trailing whitespace is meaningful (acts as a word boundary, matching the built-in convention). README has a new **Permissions** section that also documents the existing `LITTLE_CODER_PERMISSION_MODE=accept-all` escape hatch (which was undocumented before).
33
+ - **`bun add -g little-coder` install path documented** ([#12](https://github.com/itayinbarr/little-coder/issues/12)). Node ≥ 20.6 is still required at runtime because of the launcher shebang; users who want a fully node-less setup get a one-line shebang-swap recipe.
34
+ - `qwen3.6-27b` re-added to `models.json` so the data-driven extension preserves the four-model lineup (`llamacpp/qwen3.6-27b`, `llamacpp/qwen3.6-35b-a3b`, `llamacpp/qwen3.5-9b`, `ollama/qwen3.5`) that `.pi/settings.json` profiles already reference.
35
+
36
+ ### Fixed
37
+ - **Empty-response correction is no longer parked until the next user input.** ([#16](https://github.com/itayinbarr/little-coder/issues/16))
38
+ `quality-monitor` was sending its correction message via `pi.sendUserMessage(..., { deliverAs: "followUp" })`, which queued the message until the user typed something — by which point "your previous response was empty" had nothing to steer. Switched to `deliverAs: "steer"` so the correction injects into the in-flight loop. Same fix applies to the other quality-monitor reasons (`unknown_tool`, `repeated_tool_call`, `malformed_args`, `empty_tool_name`); they all benefit from prompt delivery for the same reason. The `thinking-budget` extension's deliberate use of `followUp` (post-abort retry; see commit `50becc3`) is unchanged.
39
+
40
+ ### Changed
41
+ - README architecture diagram: `llama-cpp-provider/` is now described as "data-driven provider registration from models.json (+ user override file)"; `models.json` is now described as "canonical provider registration", reflecting the actual load path.
42
+
43
+ ### Notes for upgraders
44
+ - No CLI flag, settings.json, or skill-pack breaks. Existing `.pi/settings.json` `model_profiles` keys (`llamacpp/qwen3.6-27b`, `llamacpp/qwen3.6-35b-a3b`, `llamacpp/qwen3.5-9b`, `ollama/qwen3.5`) all still match.
45
+ - If you'd been editing the installed package's `models.json` manually, those edits will keep working — but they're erased on the next `npm install -g little-coder@latest`. Move them to `~/.config/little-coder/models.json` to make them survive upgrades.
46
+
47
+ ---
48
+
5
49
  ## [v1.0.3] — 2026-04-28
6
50
 
7
51
  ### Changed
package/README.md CHANGED
@@ -26,8 +26,16 @@ Or with npm directly:
26
26
  npm install -g little-coder
27
27
  ```
28
28
 
29
+ Or with [bun](https://bun.sh):
30
+
31
+ ```bash
32
+ bun add -g little-coder
33
+ ```
34
+
29
35
  That's the whole install. No clone, no `npm install` in a workspace, no PATH fiddling. `little-coder` is now on your PATH and works from any directory.
30
36
 
37
+ > **Note for `bun add -g` users.** The launcher (`bin/little-coder.mjs`) is a Node.js script with `#!/usr/bin/env node` at the top, so Node ≥ 20.6 still has to be on your PATH for the binary to start — bun is fine for installing/updating the package, but the runtime is Node. If you want a fully node-less setup, replace the shebang in `$(bun pm bin -g)/little-coder` with `#!/usr/bin/env bun`.
38
+
31
39
  ## Run
32
40
 
33
41
  ```bash
@@ -43,19 +51,21 @@ Cloud models work the same way:
43
51
  little-coder --model anthropic/claude-haiku-4-5
44
52
  little-coder --model openai/gpt-4o-mini "What does this codebase do?"
45
53
  little-coder --model ollama/qwen3.5 # local Ollama
54
+ little-coder --model lmstudio/local-model # local LM Studio (whatever model you have loaded)
46
55
  little-coder --list-models # see everything pi knows about
47
56
  ```
48
57
 
49
58
  The agent uses the directory you launched it from as its working directory — `Read` / `Write` / `Edit` / `Bash` operate on your project, not on little-coder's install path.
50
59
 
51
- For local providers (llama.cpp, Ollama) pi expects *some* value in the API-key env even though local servers ignore it:
60
+ For local providers (llama.cpp, Ollama, LM Studio) pi expects *some* value in the API-key env even though local servers ignore it:
52
61
 
53
62
  ```bash
54
63
  export LLAMACPP_API_KEY=noop
55
64
  export OLLAMA_API_KEY=noop
65
+ export LMSTUDIO_API_KEY=noop
56
66
  ```
57
67
 
58
- `LLAMACPP_BASE_URL` and `OLLAMA_BASE_URL` override the defaults (`http://127.0.0.1:8888/v1`, `http://127.0.0.1:11434/v1`).
68
+ `LLAMACPP_BASE_URL`, `OLLAMA_BASE_URL`, and `LMSTUDIO_BASE_URL` override the defaults (`http://127.0.0.1:8888/v1`, `http://127.0.0.1:11434/v1`, `http://127.0.0.1:1234/v1`).
59
69
 
60
70
  For cloud providers, set the standard env (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) and pi will discover it.
61
71
 
@@ -89,10 +99,91 @@ ollama pull qwen3.5 # 9.7B — the paper's model
89
99
  # or: ollama pull qwen3.6-35b-a3b
90
100
  ```
91
101
 
102
+ **Option C — LM Studio** (GUI; OpenAI-compatible server on port 1234):
103
+
104
+ 1. Install [LM Studio](https://lmstudio.ai/) and download a model (e.g. Qwen3.6 35B A3B GGUF).
105
+ 2. Open the **Developer** / **Local Server** tab, load the model, and click **Start Server** (default `http://127.0.0.1:1234`).
106
+ 3. Run little-coder:
107
+ ```bash
108
+ export LMSTUDIO_API_KEY=noop
109
+ little-coder --model lmstudio/local-model
110
+ ```
111
+ The shipped `lmstudio/local-model` id routes to whatever model LM Studio currently has loaded — no extra config needed for the single-model case. If you serve on a non-default port, set `LMSTUDIO_BASE_URL=http://127.0.0.1:<port>/v1`. To target a specific model when you have several loaded, add an entry to `~/.config/little-coder/models.json` (see **Configuring models** below).
112
+
92
113
  All small-model-specific extensions auto-disable for large/cloud models so they don't interfere.
93
114
 
94
115
  ---
95
116
 
117
+ ## Configuring models
118
+
119
+ The shipped model list lives in **`models.json`** at the package root. The `llama-cpp-provider` extension reads it at startup and registers each provider via pi's `registerProvider()`. Editing this file in your global install **does** take effect — but it's overwritten on `npm install -g little-coder@latest`, so for anything you want to keep, use a user override file instead.
120
+
121
+ User override resolution (first match wins):
122
+
123
+ 1. `$LITTLE_CODER_MODELS_FILE` — explicit path, useful for ad-hoc tests.
124
+ 2. `$XDG_CONFIG_HOME/little-coder/models.json`
125
+ 3. `~/.config/little-coder/models.json`
126
+
127
+ Merge semantics: each top-level provider key in your override file **fully replaces** the same key in the shipped `models.json`. Providers only in your file are added; providers only in the shipped file are kept. (We don't deep-merge per-model fields — you redeclare the whole provider entry, which avoids "your override silently inherited new fields from a future package release" surprises.)
128
+
129
+ Example — switch the llama.cpp port and bump `qwen3.6-35b-a3b` to a 150K context, leave ollama untouched:
130
+
131
+ ```json
132
+ {
133
+ "providers": {
134
+ "llamacpp": {
135
+ "api": "openai-completions",
136
+ "baseUrl": "http://127.0.0.1:1234/v1",
137
+ "apiKey": "LLAMACPP_API_KEY",
138
+ "models": [
139
+ {
140
+ "id": "qwen3.6-35b-a3b",
141
+ "name": "Qwen3.6-35B-A3B (local llama.cpp, 150K)",
142
+ "reasoning": true,
143
+ "input": ["text"],
144
+ "contextWindow": 150000,
145
+ "maxTokens": 4096,
146
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
147
+ }
148
+ ]
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ Then verify with `little-coder --list-models` — you should see your overridden entry.
155
+
156
+ `LLAMACPP_BASE_URL`, `OLLAMA_BASE_URL`, and `LMSTUDIO_BASE_URL` env vars still beat both files for those three providers.
157
+
158
+ `.pi/settings.json` is a separate concern: it controls per-model **profiles** (context_limit, thinking_budget, temperature, benchmark_overrides) referenced by the `<provider>/<id>` key. Profiles don't register or describe models — they only tune how little-coder runs against models that are already registered.
159
+
160
+ ---
161
+
162
+ ## Permissions
163
+
164
+ little-coder gates `Bash` tool calls against a built-in safe-prefix whitelist (`ls`, `cat`, `git log/status/diff`, `find`, `grep`, etc.) before pi's own confirmation flow ever sees them.
165
+
166
+ Two env vars control the gate:
167
+
168
+ | Env var | Values | Effect |
169
+ |---|---|---|
170
+ | `LITTLE_CODER_PERMISSION_MODE` | `auto` *(default)* / `accept-all` / `manual` | `auto`: block any bash command not on the whitelist. `accept-all`: skip the gate entirely, every bash call passes (the benchmark runner sets this). `manual`: same as `auto` but with a different rejection message. |
171
+ | `LITTLE_CODER_BASH_ALLOW` | comma-separated prefixes | Extra allow-prefixes merged with the built-in list. **Trailing whitespace is meaningful**: `"make "` allows `make test` but not `makefoo`; `"make"` allows both. |
172
+
173
+ Examples:
174
+
175
+ ```bash
176
+ # Add 'make' (with word-boundary) and 'docker compose ps' on top of the defaults
177
+ export LITTLE_CODER_BASH_ALLOW="make ,docker compose ps"
178
+
179
+ # Skip the gate entirely (use this only inside controlled environments)
180
+ export LITTLE_CODER_PERMISSION_MODE=accept-all
181
+ ```
182
+
183
+ Write/Edit confirmations are pi's responsibility; little-coder doesn't intercept those.
184
+
185
+ ---
186
+
96
187
  ## Paper / benchmark results
97
188
 
98
189
  | Release | Model | Benchmark | Result |
@@ -163,7 +254,7 @@ little-coder/
163
254
  ├── .pi/
164
255
  │ ├── settings.json # per-model profiles + benchmark_overrides (terminal_bench, gaia)
165
256
  │ └── extensions/ # 20 TypeScript extensions, auto-discovered by pi
166
- │ ├── llama-cpp-provider/ # registers llamacpp/* and ollama/* as OpenAI-compat providers
257
+ │ ├── llama-cpp-provider/ # data-driven provider registration from models.json — ships llamacpp, ollama, lmstudio (+ user override file)
167
258
  │ ├── write-guard/ # Write refuses on existing files — the whitepaper invariant
168
259
  │ ├── extra-tools/ # glob, webfetch, websearch (pi ships grep/find)
169
260
  │ ├── skill-inject/ # per-turn tool-skill selection (error > recency > intent)
@@ -193,7 +284,7 @@ little-coder/
193
284
  │ ├── tb_status.sh / harbor_status.sh
194
285
  │ └── test_rpc_client.py
195
286
  ├── AGENTS.md # project system prompt (pi discovers it automatically)
196
- ├── models.json # documented provider registration (extension is canonical)
287
+ ├── models.json # canonical provider registration (loaded by llama-cpp-provider; user override at $XDG_CONFIG_HOME/little-coder/models.json)
197
288
  └── docs/
198
289
  ├── benchmark-*.md # per-benchmark narratives
199
290
  └── architecture.md # v0.0.5-era Python architecture (historical)
@@ -29,7 +29,9 @@ const here = dirname(fileURLToPath(import.meta.url));
29
29
  const pkgRoot = resolve(here, "..");
30
30
 
31
31
  // ---- 3. Resolve the bundled pi binary ----
32
- const piBin = join(pkgRoot, "node_modules", ".bin", "pi");
32
+ const isWindows = process.platform === "win32";
33
+ const piBinBase = join(pkgRoot, "node_modules", ".bin", "pi");
34
+ const piBin = isWindows && existsSync(`${piBinBase}.cmd`) ? `${piBinBase}.cmd` : piBinBase;
33
35
  if (!existsSync(piBin)) {
34
36
  console.error(
35
37
  `little-coder: cannot find pi at ${piBin}.\n` +
@@ -86,7 +88,11 @@ const piArgs = [
86
88
  ];
87
89
 
88
90
  // ---- 7. Spawn pi in the user's cwd ----
89
- const child = spawn(piBin, piArgs, {
91
+ const [spawnCmd, spawnArgs] = isWindows
92
+ ? ["cmd.exe", ["/c", piBin, ...piArgs]]
93
+ : [piBin, piArgs];
94
+
95
+ const child = spawn(spawnCmd, spawnArgs, {
90
96
  stdio: "inherit",
91
97
  cwd: process.cwd(),
92
98
  env: process.env,
package/models.json CHANGED
@@ -5,9 +5,18 @@
5
5
  "baseUrl": "http://127.0.0.1:8888/v1",
6
6
  "apiKey": "LLAMACPP_API_KEY",
7
7
  "models": [
8
+ {
9
+ "id": "qwen3.6-27b",
10
+ "name": "Qwen3.6-27B (dense, local llama.cpp)",
11
+ "reasoning": true,
12
+ "input": ["text"],
13
+ "contextWindow": 32768,
14
+ "maxTokens": 4096,
15
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
16
+ },
8
17
  {
9
18
  "id": "qwen3.6-35b-a3b",
10
- "name": "Qwen3.6-35B-A3B (local llama.cpp)",
19
+ "name": "Qwen3.6-35B-A3B (MoE, local llama.cpp)",
11
20
  "reasoning": true,
12
21
  "input": ["text"],
13
22
  "contextWindow": 32768,
@@ -40,6 +49,22 @@
40
49
  "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
41
50
  }
42
51
  ]
52
+ },
53
+ "lmstudio": {
54
+ "api": "openai-completions",
55
+ "baseUrl": "http://127.0.0.1:1234/v1",
56
+ "apiKey": "LMSTUDIO_API_KEY",
57
+ "models": [
58
+ {
59
+ "id": "local-model",
60
+ "name": "LM Studio (currently-loaded local model)",
61
+ "reasoning": true,
62
+ "input": ["text"],
63
+ "contextWindow": 32768,
64
+ "maxTokens": 4096,
65
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
66
+ }
67
+ ]
43
68
  }
44
69
  }
45
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.0.3",
3
+ "version": "1.2.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": {
@@ -9,22 +9,28 @@ user-invocable: false
9
9
  ## Edit Tool
10
10
  Replace exact text in a file. This is the **default tool for changing any existing file** — prefer it over Write for anything except creating a new file from scratch.
11
11
 
12
- REQUIRED: file_path (absolute), old_string (exact text to find), new_string (replacement)
13
- OPTIONAL: replace_all (boolean, replace all occurrences)
12
+ REQUIRED: path (absolute), edits (array of {oldText, newText})
13
+ OPTIONAL: none
14
14
 
15
15
  RULES:
16
- - old_string must match EXACTLY (whitespace, indentation, line endings all matter)
17
- - old_string must appear exactly ONCE unless replace_all=true
18
- - Include enough surrounding context (2-3 lines) to make old_string unique
19
- - To delete text: set new_string to ""
16
+ - Each `oldText` must match EXACTLY (whitespace, indentation, line endings all matter)
17
+ - Each `oldText` must be unique in the file — include 2-3 lines of surrounding context if needed
18
+ - `edits` is matched against the **original** file, not after earlier edits apply — do not overlap or nest
19
+ - To delete text: set `newText` to ""
20
20
  - Read the file first if you do not already have its current content
21
+ - Batch multiple disjoint changes in one call by passing multiple `edits[]` entries
21
22
 
22
- EXAMPLE:
23
+ EXAMPLE (single change):
23
24
  ```tool
24
- {"name": "Edit", "input": {"file_path": "/absolute/path/file.py", "old_string": "def hello():\n return 1", "new_string": "def hello():\n return 2"}}
25
+ {"name": "Edit", "input": {"path": "/absolute/path/file.py", "edits": [{"oldText": "def hello():\n return 1", "newText": "def hello():\n return 2"}]}}
26
+ ```
27
+
28
+ EXAMPLE (two changes in one call):
29
+ ```tool
30
+ {"name": "Edit", "input": {"path": "/absolute/path/file.py", "edits": [{"oldText": "MAX = 10", "newText": "MAX = 20"}, {"oldText": "TIMEOUT = 5", "newText": "TIMEOUT = 30"}]}}
25
31
  ```
26
32
 
27
33
  RECOVERY WHEN Edit FAILS:
28
34
  - "String not found" → Read the file to get the exact current content (whitespace often differs), then retry Edit with the exact string
29
- - "Found multiple times" → include more surrounding context so old_string is unique, then retry Edit
30
- - Do NOT fall back to Write just because Edit failed once — re-read, fix old_string, retry. Write is almost always the wrong recovery here for an existing file.
35
+ - "Found multiple times" → include more surrounding context so `oldText` is unique, then retry Edit
36
+ - Do NOT fall back to Write just because Edit failed once — re-read, fix `oldText`, retry. Write is almost always the wrong recovery here for an existing file.
@@ -10,17 +10,18 @@ user-invocable: false
10
10
  Search file contents with regex. Uses ripgrep.
11
11
 
12
12
  REQUIRED: pattern (regex pattern)
13
- OPTIONAL: path (directory/file), include (file glob filter like "*.py"), max_results (limit)
13
+ OPTIONAL: path (directory/file), glob (file glob filter like "*.py"), ignoreCase (bool), literal (bool — treat pattern as literal text), context (lines of context before/after), limit (max matches, default 100)
14
14
 
15
15
  RULES:
16
- - Supports full regex syntax
17
- - Use include to filter by file type (e.g. "*.py", "*.js")
16
+ - Supports full regex syntax (unless `literal: true`)
17
+ - Use `glob` to filter by file type (e.g. "*.py", "*.js")
18
+ - Use `limit` to cap results; default 100
18
19
  - Returns matching lines with file path and line number
19
20
  - Good for finding function definitions, imports, references
20
21
 
21
22
  EXAMPLE:
22
23
  ```tool
23
- {"name": "Grep", "input": {"pattern": "def main", "include": "*.py"}}
24
+ {"name": "Grep", "input": {"pattern": "def main", "glob": "*.py"}}
24
25
  ```
25
26
 
26
27
  EXAMPLE with path:
@@ -9,7 +9,7 @@ user-invocable: false
9
9
  ## Read Tool
10
10
  Read a file's contents with line numbers.
11
11
 
12
- REQUIRED: file_path (absolute path)
12
+ REQUIRED: path (absolute path)
13
13
  OPTIONAL: limit (max lines), offset (start line, 0-indexed)
14
14
 
15
15
  RULES:
@@ -19,10 +19,10 @@ RULES:
19
19
 
20
20
  EXAMPLE:
21
21
  ```tool
22
- {"name": "Read", "input": {"file_path": "/absolute/path/to/file.py"}}
22
+ {"name": "Read", "input": {"path": "/absolute/path/to/file.py"}}
23
23
  ```
24
24
 
25
25
  EXAMPLE with range:
26
26
  ```tool
27
- {"name": "Read", "input": {"file_path": "/absolute/path/to/file.py", "limit": 50, "offset": 100}}
27
+ {"name": "Read", "input": {"path": "/absolute/path/to/file.py", "limit": 50, "offset": 100}}
28
28
  ```
@@ -9,7 +9,7 @@ user-invocable: false
9
9
  ## Write Tool
10
10
  Create a **new** file with the given content. Creates parent directories automatically.
11
11
 
12
- REQUIRED: file_path (absolute), content (full file content)
12
+ REQUIRED: path (absolute), content (full file content)
13
13
 
14
14
  **Write is for creating new files only.** If the file already exists, Write will be **refused** by the tool and return an error telling you to use Edit instead. Do not retry Write on the same path — it will be refused again.
15
15
 
@@ -17,13 +17,13 @@ WHEN TO USE Write:
17
17
  - The file does not exist yet and you are creating it from scratch
18
18
 
19
19
  WHEN TO USE Edit INSTEAD:
20
- - ANY change to an existing file — bug fixes, refactors, format tweaks, adding a function, renaming a variable, everything. Edit takes old_string + new_string and patches in place.
20
+ - ANY change to an existing file — bug fixes, refactors, format tweaks, adding a function, renaming a variable, everything. Edit takes `path` + `edits: [{oldText, newText}]` and patches in place.
21
21
  - Iterating after a failed test — never retype the whole file
22
22
 
23
- If you need to completely replace an existing file's content, Edit can still do that: pass the entire current content as old_string and the full new content as new_string. Read the file first if you don't already have its current content.
23
+ If you need to completely replace an existing file's content, Edit can still do that: pass the entire current content as `oldText` and the full new content as `newText`. Read the file first if you don't already have its current content.
24
24
 
25
25
  EXAMPLE:
26
26
  ```tool
27
- {"name": "Write", "input": {"file_path": "/tmp/example/new_module.py", "content": "def hello():\n return 'hi'\n"}}
27
+ {"name": "Write", "input": {"path": "/tmp/example/new_module.py", "content": "def hello():\n return 'hi'\n"}}
28
28
  ```
29
29
  NOTE: Always use the EXACT file path given in the task, never a placeholder.
@@ -1,24 +0,0 @@
1
- ---
2
- name: agent-guidance
3
- type: tool-guidance
4
- target_tool: Agent
5
- priority: 6
6
- token_cost: 120
7
- user-invocable: false
8
- ---
9
- ## Agent Tool
10
- Spawn a sub-agent to handle a task autonomously.
11
-
12
- REQUIRED: prompt (task description for the sub-agent)
13
- OPTIONAL: subagent_type (coder/reviewer/researcher/tester/general-purpose), name (for messaging), isolation ("worktree" for git isolation)
14
-
15
- RULES:
16
- - Use for independent tasks that don't need your direct attention
17
- - Sub-agents get their own context and can use all tools
18
- - Use subagent_type to get specialized behavior
19
- - Use isolation="worktree" when the agent needs to modify files independently
20
-
21
- EXAMPLE:
22
- ```tool
23
- {"name": "Agent", "input": {"prompt": "Find all Python files that import requests and list them", "subagent_type": "researcher"}}
24
- ```