little-coder 1.0.3 → 1.1.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.
- package/.pi/extensions/llama-cpp-provider/config.test.ts +161 -0
- package/.pi/extensions/llama-cpp-provider/config.ts +145 -0
- package/.pi/extensions/llama-cpp-provider/index.ts +38 -52
- package/.pi/extensions/permission-gate/index.ts +24 -3
- package/.pi/extensions/permission-gate/permission.test.ts +42 -1
- package/.pi/extensions/quality-monitor/index.ts +8 -5
- package/CHANGELOG.md +24 -0
- package/README.md +80 -2
- package/models.json +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
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 { join } from "node:path";
|
|
5
|
+
import { applyEnvOverrides, loadProviders, mergeProviders, resolveOverridePath, type ProviderEntry } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
const sampleProvider = (baseUrl: string, modelId: string): ProviderEntry => ({
|
|
8
|
+
api: "openai-completions",
|
|
9
|
+
baseUrl,
|
|
10
|
+
apiKey: "SAMPLE_KEY",
|
|
11
|
+
models: [
|
|
12
|
+
{
|
|
13
|
+
id: modelId,
|
|
14
|
+
name: modelId,
|
|
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
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("resolveOverridePath", () => {
|
|
25
|
+
it("prefers LITTLE_CODER_MODELS_FILE", () => {
|
|
26
|
+
expect(resolveOverridePath({ LITTLE_CODER_MODELS_FILE: "/explicit.json", HOME: "/h" })).toBe("/explicit.json");
|
|
27
|
+
});
|
|
28
|
+
it("falls back to XDG_CONFIG_HOME", () => {
|
|
29
|
+
expect(resolveOverridePath({ XDG_CONFIG_HOME: "/xdg", HOME: "/h" })).toBe("/xdg/little-coder/models.json");
|
|
30
|
+
});
|
|
31
|
+
it("falls back to HOME/.config", () => {
|
|
32
|
+
expect(resolveOverridePath({ HOME: "/h" })).toBe("/h/.config/little-coder/models.json");
|
|
33
|
+
});
|
|
34
|
+
it("returns undefined when neither is set", () => {
|
|
35
|
+
expect(resolveOverridePath({})).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("mergeProviders", () => {
|
|
40
|
+
it("returns the package default unchanged when there's no override", () => {
|
|
41
|
+
const pkg = { llamacpp: sampleProvider("http://a/v1", "m1") };
|
|
42
|
+
expect(mergeProviders(pkg, undefined)).toEqual(pkg);
|
|
43
|
+
});
|
|
44
|
+
it("user provider replaces same-key package provider", () => {
|
|
45
|
+
const pkg = { llamacpp: sampleProvider("http://a/v1", "pkg-model") };
|
|
46
|
+
const user = { llamacpp: sampleProvider("http://b/v1", "user-model") };
|
|
47
|
+
const merged = mergeProviders(pkg, user);
|
|
48
|
+
expect(merged.llamacpp.baseUrl).toBe("http://b/v1");
|
|
49
|
+
expect(merged.llamacpp.models[0].id).toBe("user-model");
|
|
50
|
+
});
|
|
51
|
+
it("user provider not in package is added", () => {
|
|
52
|
+
const pkg = { llamacpp: sampleProvider("http://a/v1", "m1") };
|
|
53
|
+
const user = { custom: sampleProvider("http://c/v1", "c1") };
|
|
54
|
+
const merged = mergeProviders(pkg, user);
|
|
55
|
+
expect(Object.keys(merged).sort()).toEqual(["custom", "llamacpp"]);
|
|
56
|
+
});
|
|
57
|
+
it("package providers without an override are kept as-is", () => {
|
|
58
|
+
const pkg = {
|
|
59
|
+
llamacpp: sampleProvider("http://a/v1", "m1"),
|
|
60
|
+
ollama: sampleProvider("http://o/v1", "m2"),
|
|
61
|
+
};
|
|
62
|
+
const user = { llamacpp: sampleProvider("http://b/v1", "m1b") };
|
|
63
|
+
const merged = mergeProviders(pkg, user);
|
|
64
|
+
expect(merged.ollama.baseUrl).toBe("http://o/v1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("applyEnvOverrides", () => {
|
|
69
|
+
it("LLAMACPP_BASE_URL overrides llamacpp baseUrl", () => {
|
|
70
|
+
const providers = { llamacpp: sampleProvider("http://file/v1", "m1") };
|
|
71
|
+
const out = applyEnvOverrides(providers, { LLAMACPP_BASE_URL: "http://env/v1" });
|
|
72
|
+
expect(out.llamacpp.baseUrl).toBe("http://env/v1");
|
|
73
|
+
});
|
|
74
|
+
it("OLLAMA_BASE_URL overrides ollama baseUrl", () => {
|
|
75
|
+
const providers = { ollama: sampleProvider("http://file/v1", "m2") };
|
|
76
|
+
const out = applyEnvOverrides(providers, { OLLAMA_BASE_URL: "http://env/v1" });
|
|
77
|
+
expect(out.ollama.baseUrl).toBe("http://env/v1");
|
|
78
|
+
});
|
|
79
|
+
it("does not alter providers without a known env knob", () => {
|
|
80
|
+
const providers = { custom: sampleProvider("http://file/v1", "m") };
|
|
81
|
+
const out = applyEnvOverrides(providers, { LLAMACPP_BASE_URL: "http://env/v1" });
|
|
82
|
+
expect(out.custom.baseUrl).toBe("http://file/v1");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("loadProviders (filesystem)", () => {
|
|
87
|
+
let dir: string;
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
dir = mkdtempSync(join(tmpdir(), "lc-providers-"));
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("loads the package default when present", () => {
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(dir, "models.json"),
|
|
98
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "m1") } }),
|
|
99
|
+
);
|
|
100
|
+
const result = loadProviders(dir, {});
|
|
101
|
+
expect(Object.keys(result.providers)).toEqual(["llamacpp"]);
|
|
102
|
+
expect(result.sources[0]).toMatchObject({ status: "ok" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("merges a user override file when LITTLE_CODER_MODELS_FILE points at one", () => {
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(dir, "models.json"),
|
|
108
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "pkg") } }),
|
|
109
|
+
);
|
|
110
|
+
const userPath = join(dir, "user-models.json");
|
|
111
|
+
writeFileSync(
|
|
112
|
+
userPath,
|
|
113
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://b/v1", "user") } }),
|
|
114
|
+
);
|
|
115
|
+
const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
|
|
116
|
+
expect(result.providers.llamacpp.baseUrl).toBe("http://b/v1");
|
|
117
|
+
expect(result.providers.llamacpp.models[0].id).toBe("user");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reports invalid JSON in the package default and returns empty providers", () => {
|
|
121
|
+
writeFileSync(join(dir, "models.json"), "{ this is not json");
|
|
122
|
+
const result = loadProviders(dir, {});
|
|
123
|
+
expect(result.providers).toEqual({});
|
|
124
|
+
expect(result.sources[0].status).toBe("invalid");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reports a missing user override without failing the load", () => {
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(dir, "models.json"),
|
|
130
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "m1") } }),
|
|
131
|
+
);
|
|
132
|
+
const missing = join(dir, "no-such-dir", "models.json");
|
|
133
|
+
const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: missing });
|
|
134
|
+
expect(result.providers.llamacpp.baseUrl).toBe("http://a/v1");
|
|
135
|
+
expect(result.sources.find((s) => s.path === missing)?.status).toBe("missing");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("env var still overrides baseUrl after merge", () => {
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(dir, "models.json"),
|
|
141
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://file/v1", "m") } }),
|
|
142
|
+
);
|
|
143
|
+
const result = loadProviders(dir, { LLAMACPP_BASE_URL: "http://env/v1" });
|
|
144
|
+
expect(result.providers.llamacpp.baseUrl).toBe("http://env/v1");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("XDG_CONFIG_HOME overrides applied when no LITTLE_CODER_MODELS_FILE set", () => {
|
|
148
|
+
writeFileSync(
|
|
149
|
+
join(dir, "models.json"),
|
|
150
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://a/v1", "pkg") } }),
|
|
151
|
+
);
|
|
152
|
+
const xdg = join(dir, "xdg");
|
|
153
|
+
mkdirSync(join(xdg, "little-coder"), { recursive: true });
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(xdg, "little-coder", "models.json"),
|
|
156
|
+
JSON.stringify({ providers: { llamacpp: sampleProvider("http://x/v1", "via-xdg") } }),
|
|
157
|
+
);
|
|
158
|
+
const result = loadProviders(dir, { XDG_CONFIG_HOME: xdg });
|
|
159
|
+
expect(result.providers.llamacpp.models[0].id).toBe("via-xdg");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
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. Legacy compat
|
|
47
|
+
* for the two providers we shipped before the data-driven refactor. */
|
|
48
|
+
const LEGACY_BASE_URL_ENV: Record<string, string> = {
|
|
49
|
+
llamacpp: "LLAMACPP_BASE_URL",
|
|
50
|
+
ollama: "OLLAMA_BASE_URL",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Resolution order for the user-override file. First existing path wins. */
|
|
54
|
+
export function resolveOverridePath(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
55
|
+
if (env.LITTLE_CODER_MODELS_FILE) return env.LITTLE_CODER_MODELS_FILE;
|
|
56
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
57
|
+
if (xdg) return join(xdg, "little-coder", "models.json");
|
|
58
|
+
if (env.HOME) return join(env.HOME, ".config", "little-coder", "models.json");
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseModelsFile(raw: string): ModelsFile {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
|
|
65
|
+
throw new Error("expected top-level { providers: { ... } }");
|
|
66
|
+
}
|
|
67
|
+
return parsed as ModelsFile;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readIfPresent(path: string): { kind: "ok"; data: ModelsFile } | { kind: "missing" } | { kind: "invalid"; error: string } {
|
|
71
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(path, "utf-8");
|
|
74
|
+
return { kind: "ok", data: parseModelsFile(raw) };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { kind: "invalid", error: err instanceof Error ? err.message : String(err) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyEnvOverrides(providers: Record<string, ProviderEntry>, env: NodeJS.ProcessEnv = process.env): Record<string, ProviderEntry> {
|
|
81
|
+
const out: Record<string, ProviderEntry> = {};
|
|
82
|
+
for (const [name, entry] of Object.entries(providers)) {
|
|
83
|
+
const envVar = LEGACY_BASE_URL_ENV[name];
|
|
84
|
+
if (envVar && env[envVar]) {
|
|
85
|
+
out[name] = { ...entry, baseUrl: env[envVar]! };
|
|
86
|
+
} else {
|
|
87
|
+
out[name] = entry;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Merge: user file's providers fully replace package providers with the same
|
|
95
|
+
* key. Providers only in the user file are added. Providers only in the
|
|
96
|
+
* package default are kept. (We deliberately avoid deep per-model merging —
|
|
97
|
+
* the user redeclares the whole provider entry if they want to change it,
|
|
98
|
+
* which is far less surprising than "your override silently inherited fields
|
|
99
|
+
* from a future package release.")
|
|
100
|
+
*/
|
|
101
|
+
export function mergeProviders(
|
|
102
|
+
pkgDefault: Record<string, ProviderEntry>,
|
|
103
|
+
userOverride: Record<string, ProviderEntry> | undefined,
|
|
104
|
+
): Record<string, ProviderEntry> {
|
|
105
|
+
if (!userOverride) return { ...pkgDefault };
|
|
106
|
+
return { ...pkgDefault, ...userOverride };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load the package default models.json + (optionally) the user override file,
|
|
111
|
+
* apply env-var baseUrl overrides for the legacy providers, and return the
|
|
112
|
+
* merged provider map plus diagnostics for each source.
|
|
113
|
+
*/
|
|
114
|
+
export function loadProviders(pkgRoot: string, env: NodeJS.ProcessEnv = process.env): LoadResult {
|
|
115
|
+
const sources: LoadResult["sources"] = [];
|
|
116
|
+
const defaultPath = join(pkgRoot, "models.json");
|
|
117
|
+
const defaultRead = readIfPresent(defaultPath);
|
|
118
|
+
let pkgDefault: Record<string, ProviderEntry> = {};
|
|
119
|
+
if (defaultRead.kind === "ok") {
|
|
120
|
+
pkgDefault = defaultRead.data.providers;
|
|
121
|
+
sources.push({ path: defaultPath, status: "ok" });
|
|
122
|
+
} else if (defaultRead.kind === "missing") {
|
|
123
|
+
sources.push({ path: defaultPath, status: "missing" });
|
|
124
|
+
} else {
|
|
125
|
+
sources.push({ path: defaultPath, status: "invalid", error: defaultRead.error });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const overridePath = resolveOverridePath(env);
|
|
129
|
+
let userOverride: Record<string, ProviderEntry> | undefined;
|
|
130
|
+
if (overridePath) {
|
|
131
|
+
const userRead = readIfPresent(overridePath);
|
|
132
|
+
if (userRead.kind === "ok") {
|
|
133
|
+
userOverride = userRead.data.providers;
|
|
134
|
+
sources.push({ path: overridePath, status: "ok" });
|
|
135
|
+
} else if (userRead.kind === "missing") {
|
|
136
|
+
sources.push({ path: overridePath, status: "missing" });
|
|
137
|
+
} else {
|
|
138
|
+
sources.push({ path: overridePath, status: "invalid", error: userRead.error });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const merged = mergeProviders(pkgDefault, userOverride);
|
|
143
|
+
const withEnv = applyEnvOverrides(merged, env);
|
|
144
|
+
return { providers: withEnv, sources };
|
|
145
|
+
}
|
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 —
|
|
6
|
-
// a correction user message
|
|
7
|
-
//
|
|
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} →
|
|
65
|
+
`quality-monitor: ${verdict.reason} → injecting correction`,
|
|
66
66
|
"warning",
|
|
67
67
|
);
|
|
68
|
-
|
|
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
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
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.1.0] — 2026-05-03
|
|
6
|
+
|
|
7
|
+
Issue-cleanup release. Three small features and one bug fix, driven by GitHub issues #12 / #13 / #15 / #16.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`models.json` is now the canonical provider registration.** ([#13](https://github.com/itayinbarr/little-coder/issues/13))
|
|
11
|
+
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.
|
|
12
|
+
- **`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).
|
|
13
|
+
- **`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.
|
|
14
|
+
- `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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **Empty-response correction is no longer parked until the next user input.** ([#16](https://github.com/itayinbarr/little-coder/issues/16))
|
|
18
|
+
`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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
### Notes for upgraders
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
5
29
|
## [v1.0.3] — 2026-04-28
|
|
6
30
|
|
|
7
31
|
### 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
|
|
@@ -93,6 +101,76 @@ All small-model-specific extensions auto-disable for large/cloud models so they
|
|
|
93
101
|
|
|
94
102
|
---
|
|
95
103
|
|
|
104
|
+
## Configuring models
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
User override resolution (first match wins):
|
|
109
|
+
|
|
110
|
+
1. `$LITTLE_CODER_MODELS_FILE` — explicit path, useful for ad-hoc tests.
|
|
111
|
+
2. `$XDG_CONFIG_HOME/little-coder/models.json`
|
|
112
|
+
3. `~/.config/little-coder/models.json`
|
|
113
|
+
|
|
114
|
+
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.)
|
|
115
|
+
|
|
116
|
+
Example — switch the llama.cpp port and bump `qwen3.6-35b-a3b` to a 150K context, leave ollama untouched:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"providers": {
|
|
121
|
+
"llamacpp": {
|
|
122
|
+
"api": "openai-completions",
|
|
123
|
+
"baseUrl": "http://127.0.0.1:1234/v1",
|
|
124
|
+
"apiKey": "LLAMACPP_API_KEY",
|
|
125
|
+
"models": [
|
|
126
|
+
{
|
|
127
|
+
"id": "qwen3.6-35b-a3b",
|
|
128
|
+
"name": "Qwen3.6-35B-A3B (local llama.cpp, 150K)",
|
|
129
|
+
"reasoning": true,
|
|
130
|
+
"input": ["text"],
|
|
131
|
+
"contextWindow": 150000,
|
|
132
|
+
"maxTokens": 4096,
|
|
133
|
+
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then verify with `little-coder --list-models` — you should see your overridden entry.
|
|
142
|
+
|
|
143
|
+
`LLAMACPP_BASE_URL` and `OLLAMA_BASE_URL` env vars still beat both files for those two providers (legacy compat).
|
|
144
|
+
|
|
145
|
+
`.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.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Permissions
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
Two env vars control the gate:
|
|
154
|
+
|
|
155
|
+
| Env var | Values | Effect |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `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. |
|
|
158
|
+
| `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. |
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Add 'make' (with word-boundary) and 'docker compose ps' on top of the defaults
|
|
164
|
+
export LITTLE_CODER_BASH_ALLOW="make ,docker compose ps"
|
|
165
|
+
|
|
166
|
+
# Skip the gate entirely (use this only inside controlled environments)
|
|
167
|
+
export LITTLE_CODER_PERMISSION_MODE=accept-all
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Write/Edit confirmations are pi's responsibility; little-coder doesn't intercept those.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
96
174
|
## Paper / benchmark results
|
|
97
175
|
|
|
98
176
|
| Release | Model | Benchmark | Result |
|
|
@@ -163,7 +241,7 @@ little-coder/
|
|
|
163
241
|
├── .pi/
|
|
164
242
|
│ ├── settings.json # per-model profiles + benchmark_overrides (terminal_bench, gaia)
|
|
165
243
|
│ └── extensions/ # 20 TypeScript extensions, auto-discovered by pi
|
|
166
|
-
│ ├── llama-cpp-provider/ #
|
|
244
|
+
│ ├── llama-cpp-provider/ # data-driven provider registration from models.json (+ user override file)
|
|
167
245
|
│ ├── write-guard/ # Write refuses on existing files — the whitepaper invariant
|
|
168
246
|
│ ├── extra-tools/ # glob, webfetch, websearch (pi ships grep/find)
|
|
169
247
|
│ ├── skill-inject/ # per-turn tool-skill selection (error > recency > intent)
|
|
@@ -193,7 +271,7 @@ little-coder/
|
|
|
193
271
|
│ ├── tb_status.sh / harbor_status.sh
|
|
194
272
|
│ └── test_rpc_client.py
|
|
195
273
|
├── AGENTS.md # project system prompt (pi discovers it automatically)
|
|
196
|
-
├── models.json #
|
|
274
|
+
├── models.json # canonical provider registration (loaded by llama-cpp-provider; user override at $XDG_CONFIG_HOME/little-coder/models.json)
|
|
197
275
|
└── docs/
|
|
198
276
|
├── benchmark-*.md # per-benchmark narratives
|
|
199
277
|
└── architecture.md # v0.0.5-era Python architecture (historical)
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "little-coder",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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": {
|