little-coder 1.0.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.
Files changed (76) hide show
  1. package/.pi/extensions/benchmark-profiles/index.ts +159 -0
  2. package/.pi/extensions/benchmark-profiles/profiles.test.ts +78 -0
  3. package/.pi/extensions/browser/index.ts +304 -0
  4. package/.pi/extensions/browser-extract-retention/index.ts +170 -0
  5. package/.pi/extensions/browser-extract-retention/live-integration.test.ts +176 -0
  6. package/.pi/extensions/browser-extract-retention/retention.test.ts +195 -0
  7. package/.pi/extensions/checkpoint/index.ts +66 -0
  8. package/.pi/extensions/evidence/evidence.test.ts +30 -0
  9. package/.pi/extensions/evidence/index.ts +119 -0
  10. package/.pi/extensions/evidence-compact/bridge.test.ts +25 -0
  11. package/.pi/extensions/evidence-compact/index.ts +32 -0
  12. package/.pi/extensions/extra-tools/index.ts +139 -0
  13. package/.pi/extensions/finalize-warn/index.ts +73 -0
  14. package/.pi/extensions/hello/index.ts +7 -0
  15. package/.pi/extensions/knowledge-inject/index.ts +149 -0
  16. package/.pi/extensions/knowledge-inject/scoring.test.ts +81 -0
  17. package/.pi/extensions/llama-cpp-provider/index.ts +58 -0
  18. package/.pi/extensions/output-parser/index.ts +56 -0
  19. package/.pi/extensions/output-parser/parser.test.ts +90 -0
  20. package/.pi/extensions/output-parser/parser.ts +126 -0
  21. package/.pi/extensions/permission-gate/index.ts +53 -0
  22. package/.pi/extensions/permission-gate/permission.test.ts +26 -0
  23. package/.pi/extensions/quality-monitor/index.ts +70 -0
  24. package/.pi/extensions/quality-monitor/quality.test.ts +75 -0
  25. package/.pi/extensions/quality-monitor/quality.ts +84 -0
  26. package/.pi/extensions/shell-session/helpers.test.ts +62 -0
  27. package/.pi/extensions/shell-session/helpers.ts +58 -0
  28. package/.pi/extensions/shell-session/index.ts +139 -0
  29. package/.pi/extensions/skill-inject/frontmatter.test.ts +72 -0
  30. package/.pi/extensions/skill-inject/frontmatter.ts +39 -0
  31. package/.pi/extensions/skill-inject/index.ts +256 -0
  32. package/.pi/extensions/skill-inject/selector.test.ts +91 -0
  33. package/.pi/extensions/thinking-budget/budget.test.ts +182 -0
  34. package/.pi/extensions/thinking-budget/index.ts +105 -0
  35. package/.pi/extensions/tool-gating/index.ts +38 -0
  36. package/.pi/extensions/turn-cap/index.ts +37 -0
  37. package/.pi/extensions/write-guard/index.ts +61 -0
  38. package/.pi/settings.json +76 -0
  39. package/AGENTS.md +61 -0
  40. package/CHANGELOG.md +618 -0
  41. package/LICENSE +201 -0
  42. package/NOTICE +22 -0
  43. package/README.md +245 -0
  44. package/bin/little-coder.mjs +99 -0
  45. package/models.json +45 -0
  46. package/package.json +46 -0
  47. package/skills/knowledge/bfs_state_space.md +9 -0
  48. package/skills/knowledge/binary_search.md +9 -0
  49. package/skills/knowledge/dfs_vs_bfs.md +9 -0
  50. package/skills/knowledge/dynamic_programming.md +9 -0
  51. package/skills/knowledge/hash_vs_tree.md +9 -0
  52. package/skills/knowledge/io_wrapper.md +9 -0
  53. package/skills/knowledge/recursion_backtracking.md +9 -0
  54. package/skills/knowledge/rule_string_transform.md +9 -0
  55. package/skills/knowledge/sorting_choice.md +9 -0
  56. package/skills/knowledge/tree_rerooting.md +9 -0
  57. package/skills/knowledge/tree_zipper.md +9 -0
  58. package/skills/knowledge/two_pointers.md +9 -0
  59. package/skills/knowledge/workspace_docs.md +10 -0
  60. package/skills/protocols/cite_before_answer.md +19 -0
  61. package/skills/protocols/research_protocol.md +20 -0
  62. package/skills/protocols/task_decomposition.md +24 -0
  63. package/skills/tools/agent.md +24 -0
  64. package/skills/tools/bash.md +29 -0
  65. package/skills/tools/browser_click.md +25 -0
  66. package/skills/tools/browser_extract.md +24 -0
  67. package/skills/tools/browser_navigate.md +22 -0
  68. package/skills/tools/browser_type.md +22 -0
  69. package/skills/tools/edit.md +30 -0
  70. package/skills/tools/evidence_add.md +23 -0
  71. package/skills/tools/glob.md +28 -0
  72. package/skills/tools/grep.md +29 -0
  73. package/skills/tools/read.md +28 -0
  74. package/skills/tools/shell_session.md +31 -0
  75. package/skills/tools/webfetch.md +22 -0
  76. package/skills/tools/write.md +29 -0
@@ -0,0 +1,119 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { randomBytes } from "node:crypto";
4
+
5
+ // Port of local/tools/evidence.py. Per-session in-memory store of evidence
6
+ // entries. GAIA requires cite-before-answer, and these entries survive
7
+ // compaction (Phase 10's evidence-compact extension preserves them).
8
+
9
+ const SNIPPET_CAP = 1024;
10
+
11
+ interface EvidenceEntry {
12
+ id: string;
13
+ source: string;
14
+ note: string;
15
+ snippet: string;
16
+ }
17
+
18
+ // Map<sessionId, entries[]>
19
+ const stores = new Map<string, EvidenceEntry[]>();
20
+
21
+ function sessionKey(): string {
22
+ return process.env.LITTLE_CODER_SESSION_ID || "default";
23
+ }
24
+
25
+ function bucket(): EvidenceEntry[] {
26
+ const key = sessionKey();
27
+ let b = stores.get(key);
28
+ if (!b) {
29
+ b = [];
30
+ stores.set(key, b);
31
+ }
32
+ return b;
33
+ }
34
+
35
+ // Exported so tests and the evidence-compact extension can reach in.
36
+ export function resetSessionStore(sessionId?: string): void {
37
+ stores.delete(sessionId ?? sessionKey());
38
+ }
39
+
40
+ export function getSessionStore(sessionId?: string): EvidenceEntry[] {
41
+ return stores.get(sessionId ?? sessionKey()) ?? [];
42
+ }
43
+
44
+ export default function (pi: ExtensionAPI) {
45
+ pi.on("session_shutdown", async () => {
46
+ resetSessionStore();
47
+ });
48
+
49
+ pi.registerTool({
50
+ name: "EvidenceAdd",
51
+ label: "EvidenceAdd",
52
+ description:
53
+ "Save a short evidence snippet with its source and a one-line note. " +
54
+ "Use for any fact you will cite in your final answer. Snippet is capped at 1KB.",
55
+ parameters: Type.Object({
56
+ source: Type.String({ description: "URL or identifier of origin" }),
57
+ note: Type.String({ description: "One-line summary for later recall" }),
58
+ snippet: Type.String({ description: "The exact citable span (<=1KB)" }),
59
+ }),
60
+ async execute(_id, { source, note, snippet }) {
61
+ const src = (source ?? "").trim();
62
+ const n = (note ?? "").trim();
63
+ let sn = snippet ?? "";
64
+ if (!src) {
65
+ return { content: [{ type: "text", text: "Error: source is required (URL or identifier)" }], details: {}, isError: true };
66
+ }
67
+ if (!n) {
68
+ return { content: [{ type: "text", text: "Error: note is required (1-line summary of the snippet)" }], details: {}, isError: true };
69
+ }
70
+ if (!sn) {
71
+ return { content: [{ type: "text", text: "Error: snippet is required" }], details: {}, isError: true };
72
+ }
73
+ if (sn.length > SNIPPET_CAP) {
74
+ sn = sn.slice(0, SNIPPET_CAP) + `\n[... snippet truncated, kept ${SNIPPET_CAP} chars ...]`;
75
+ }
76
+ const id = "e" + randomBytes(3).toString("hex");
77
+ bucket().push({ id, source: src, note: n, snippet: sn });
78
+ return { content: [{ type: "text", text: `stored ${id}: ${n}` }], details: {} };
79
+ },
80
+ });
81
+
82
+ pi.registerTool({
83
+ name: "EvidenceGet",
84
+ label: "EvidenceGet",
85
+ description: "Retrieve a previously-saved evidence entry by its id.",
86
+ parameters: Type.Object({
87
+ id: Type.String({ description: "Evidence id from EvidenceAdd/List" }),
88
+ }),
89
+ async execute(_id, { id }) {
90
+ const eid = (id ?? "").trim();
91
+ if (!eid) {
92
+ return { content: [{ type: "text", text: "Error: id is required" }], details: {}, isError: true };
93
+ }
94
+ const e = bucket().find((x) => x.id === eid);
95
+ if (!e) {
96
+ return { content: [{ type: "text", text: `Error: evidence id '${eid}' not found` }], details: {}, isError: true };
97
+ }
98
+ return {
99
+ content: [{ type: "text", text: `[${e.id}] source: ${e.source}\nnote: ${e.note}\nsnippet:\n${e.snippet}` }],
100
+ details: {},
101
+ };
102
+ },
103
+ });
104
+
105
+ pi.registerTool({
106
+ name: "EvidenceList",
107
+ label: "EvidenceList",
108
+ description: "List all evidence entries in this session: id, source, one-line note.",
109
+ parameters: Type.Object({}),
110
+ async execute() {
111
+ const b = bucket();
112
+ if (b.length === 0) {
113
+ return { content: [{ type: "text", text: "(no evidence stored yet)" }], details: {} };
114
+ }
115
+ const lines = b.map((e) => `${e.id}\t${e.source}\t${e.note}`);
116
+ return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Exercise the bridge template as a pure function.
4
+ const BRIDGE_TEMPLATE = (n: number): string =>
5
+ `[Preserved evidence from earlier in the conversation follows.] ` +
6
+ `${n} evidence entr${n === 1 ? "y remains" : "ies remain"} available via ` +
7
+ `EvidenceList and EvidenceGet.`;
8
+
9
+ describe("evidence-compact bridge message", () => {
10
+ it("starts with exact preservation prefix (Python-version parity)", () => {
11
+ const m = BRIDGE_TEMPLATE(3);
12
+ expect(m.startsWith("[Preserved evidence from earlier in the conversation follows.]")).toBe(true);
13
+ });
14
+ it("uses singular for 1 entry", () => {
15
+ expect(BRIDGE_TEMPLATE(1)).toContain("1 evidence entry remains");
16
+ });
17
+ it("uses plural for multiple entries", () => {
18
+ expect(BRIDGE_TEMPLATE(5)).toContain("5 evidence entries remain");
19
+ });
20
+ it("references the retrieval tools by name", () => {
21
+ const m = BRIDGE_TEMPLATE(2);
22
+ expect(m).toContain("EvidenceList");
23
+ expect(m).toContain("EvidenceGet");
24
+ });
25
+ });
@@ -0,0 +1,32 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { getSessionStore } from "../evidence/index.ts";
3
+
4
+ // Port of compaction.py's Evidence-preservation contract.
5
+ //
6
+ // In the Python version, Evidence entries lived as tool-result content
7
+ // inside the message array, so compaction had to explicitly skip them
8
+ // (via _PRESERVE_TOOL_NAMES) and re-emit them with a bridge message. The
9
+ // TypeScript port stores Evidence in extension-state (evidence/index.ts
10
+ // `stores` map), so it survives message-array compaction automatically.
11
+ //
12
+ // This extension preserves the BEHAVIORAL contract: after compaction, the
13
+ // model sees an assistant-side bridge reminding it that its evidence is
14
+ // still addressable via EvidenceList/EvidenceGet. The exact bridge string
15
+ // matches the Python version so replay stays deterministic.
16
+
17
+ const BRIDGE_TEMPLATE = (n: number): string =>
18
+ `[Preserved evidence from earlier in the conversation follows.] ` +
19
+ `${n} evidence entr${n === 1 ? "y remains" : "ies remain"} available via ` +
20
+ `EvidenceList and EvidenceGet.`;
21
+
22
+ export default function (pi: ExtensionAPI) {
23
+ pi.on("session_compact", async (_event, ctx) => {
24
+ const store = getSessionStore();
25
+ if (store.length === 0) return;
26
+ ctx.ui.notify(
27
+ `evidence-compact: ${store.length} evidence entries preserved across compaction`,
28
+ "info",
29
+ );
30
+ pi.sendUserMessage(BRIDGE_TEMPLATE(store.length), { deliverAs: "followUp" });
31
+ });
32
+ }
@@ -0,0 +1,139 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { glob as globSync } from "node:fs/promises";
4
+
5
+ // Ports of tools.py::_glob, _webfetch, _websearch. Pi ships its own grep/find,
6
+ // so those are not re-registered here.
7
+ export default function (pi: ExtensionAPI) {
8
+ // ── glob ────────────────────────────────────────────────────────────────
9
+ pi.registerTool({
10
+ name: "glob",
11
+ label: "Glob",
12
+ description:
13
+ "Find files matching a glob pattern. Returns a sorted list of matching paths (up to 500).",
14
+ parameters: Type.Object({
15
+ pattern: Type.String({ description: "Glob pattern e.g. **/*.py" }),
16
+ path: Type.Optional(Type.String({ description: "Base directory (default: cwd)" })),
17
+ }),
18
+ async execute(_id, { pattern, path }) {
19
+ try {
20
+ const base = path || process.cwd();
21
+ const matches: string[] = [];
22
+ // Node 22's fs/promises.glob — returns an async iterator
23
+ for await (const m of globSync(pattern, { cwd: base })) {
24
+ matches.push(`${base}/${m}`);
25
+ if (matches.length >= 500) break;
26
+ }
27
+ matches.sort();
28
+ const text = matches.length === 0 ? "No files matched" : matches.join("\n");
29
+ return {
30
+ content: [{ type: "text", text }],
31
+ details: {},
32
+ };
33
+ } catch (e) {
34
+ return {
35
+ content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
36
+ details: {},
37
+ isError: true,
38
+ };
39
+ }
40
+ },
41
+ });
42
+
43
+ // ── webfetch ────────────────────────────────────────────────────────────
44
+ pi.registerTool({
45
+ name: "webfetch",
46
+ label: "WebFetch",
47
+ description: "Fetch a URL and return its text content (HTML stripped). Capped at 25K chars.",
48
+ parameters: Type.Object({
49
+ url: Type.String({ description: "URL to fetch" }),
50
+ prompt: Type.Optional(Type.String({ description: "Hint for what to extract (informational)" })),
51
+ }),
52
+ async execute(_id, { url }) {
53
+ try {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), 30_000);
56
+ const res = await fetch(url, {
57
+ headers: { "User-Agent": "little-coder/0.1" },
58
+ redirect: "follow",
59
+ signal: controller.signal,
60
+ });
61
+ clearTimeout(timer);
62
+ if (!res.ok) {
63
+ return {
64
+ content: [{ type: "text", text: `Error: HTTP ${res.status} ${res.statusText}` }],
65
+ details: {},
66
+ isError: true,
67
+ };
68
+ }
69
+ const ct = res.headers.get("content-type") || "";
70
+ let text = await res.text();
71
+ if (ct.includes("html")) {
72
+ text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
73
+ text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
74
+ text = text.replace(/<[^>]+>/g, " ");
75
+ text = text.replace(/\s+/g, " ").trim();
76
+ }
77
+ if (text.length > 25_000) text = text.slice(0, 25_000);
78
+ return { content: [{ type: "text", text }], details: {} };
79
+ } catch (e) {
80
+ return {
81
+ content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
82
+ details: {},
83
+ isError: true,
84
+ };
85
+ }
86
+ },
87
+ });
88
+
89
+ // ── websearch ───────────────────────────────────────────────────────────
90
+ pi.registerTool({
91
+ name: "websearch",
92
+ label: "WebSearch",
93
+ description: "Search the web via DuckDuckGo and return the top ~8 results as Markdown.",
94
+ parameters: Type.Object({
95
+ query: Type.String({ description: "Search query" }),
96
+ }),
97
+ async execute(_id, { query }) {
98
+ try {
99
+ const controller = new AbortController();
100
+ const timer = setTimeout(() => controller.abort(), 30_000);
101
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
102
+ const res = await fetch(url, {
103
+ headers: { "User-Agent": "Mozilla/5.0 (compatible)" },
104
+ redirect: "follow",
105
+ signal: controller.signal,
106
+ });
107
+ clearTimeout(timer);
108
+ const body = await res.text();
109
+ const titleRe = /class="result__title"[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
110
+ const snippetRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/div>/g;
111
+ const titles: Array<{ link: string; title: string }> = [];
112
+ let m: RegExpExecArray | null;
113
+ while ((m = titleRe.exec(body)) && titles.length < 8) {
114
+ titles.push({ link: m[1], title: m[2].replace(/<[^>]+>/g, "").trim() });
115
+ }
116
+ const snippets: string[] = [];
117
+ while ((m = snippetRe.exec(body)) && snippets.length < 8) {
118
+ snippets.push(m[1].replace(/<[^>]+>/g, "").trim());
119
+ }
120
+ if (titles.length === 0) {
121
+ return {
122
+ content: [{ type: "text", text: "No results found" }],
123
+ details: {},
124
+ };
125
+ }
126
+ const out = titles
127
+ .map((t, i) => `**${t.title}**\n${t.link}\n${snippets[i] ?? ""}`)
128
+ .join("\n\n");
129
+ return { content: [{ type: "text", text: out }], details: {} };
130
+ } catch (e) {
131
+ return {
132
+ content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
133
+ details: {},
134
+ isError: true,
135
+ };
136
+ }
137
+ },
138
+ });
139
+ }
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ // Pre-cap finalize-warn: when the agent has WARN_REMAINING turns left
4
+ // (this turn included), inject a follow-up user message reminding it to
5
+ // emit `Answer: <value>` before the cap aborts.
6
+ //
7
+ // Why this exists: a recurring small-model failure mode is "ran out of
8
+ // turns mid-thought, never produced final-answer line, extract_final_answer
9
+ // fell back to last line of prose and returned garbage." The warning fires
10
+ // once per agent run, only when the cap is large enough for the warning
11
+ // to give the model real headroom (cap > WARN_REMAINING).
12
+ //
13
+ // This is intentionally a separate extension from turn-cap so that the
14
+ // abort policy and the warn policy stay independent and can be tuned /
15
+ // disabled separately.
16
+ //
17
+ // pi.sendUserMessage(...,{deliverAs:"followUp"}) queues the message for the
18
+ // NEXT user turn — so a warning fired at turn 39 only reaches the model at
19
+ // turn 40, leaving 1 useful turn of headroom (then turn 41 = abort). Raised
20
+ // to 5 so the message lands ~4 turns before cap, giving the model real room.
21
+
22
+ const WARN_REMAINING = 5;
23
+
24
+ let turnsThisRun = 0;
25
+ let capForRun = 0;
26
+ let warnedThisRun = false;
27
+
28
+ function envCap(): number {
29
+ const raw = process.env.LITTLE_CODER_MAX_TURNS;
30
+ if (!raw) return 0;
31
+ const n = parseInt(raw, 10);
32
+ return Number.isFinite(n) && n > 0 ? n : 0;
33
+ }
34
+
35
+ export default function (pi: ExtensionAPI) {
36
+ pi.on("before_agent_start", async (event) => {
37
+ turnsThisRun = 0;
38
+ warnedThisRun = false;
39
+ const opts: any = (event as any).systemPromptOptions ?? {};
40
+ const lcCap = Number(opts?.littleCoder?.maxTurns);
41
+ capForRun = Number.isFinite(lcCap) && lcCap > 0 ? lcCap : envCap();
42
+ });
43
+
44
+ pi.on("turn_start", async (_event, ctx) => {
45
+ if (capForRun <= 0) return;
46
+ turnsThisRun++;
47
+ if (warnedThisRun) return;
48
+ if (capForRun <= WARN_REMAINING) return;
49
+
50
+ // Fire once when the agent is starting the turn that leaves it
51
+ // exactly WARN_REMAINING turns to play with. For cap=40, that's
52
+ // turn 39 — the agent then has turn 39 and turn 40 before the
53
+ // abort at turn 41.
54
+ if (turnsThisRun !== capForRun - WARN_REMAINING + 1) return;
55
+
56
+ warnedThisRun = true;
57
+ const msg =
58
+ `You have ${WARN_REMAINING} turns left. Produce your final reply now, ` +
59
+ `ending with a single line: \`Answer: <value>\`. ` +
60
+ `Do not start new tool chains; if you need a fact you don't have, ` +
61
+ `answer with your best supported guess from EvidenceList rather than ` +
62
+ `leaving it blank.`;
63
+ ctx.ui.notify(
64
+ `finalize-warn: ${WARN_REMAINING} turns left at ${turnsThisRun}/${capForRun}`,
65
+ "info",
66
+ );
67
+ try {
68
+ pi.sendUserMessage(msg, { deliverAs: "followUp" });
69
+ } catch {
70
+ // SDK without sendUserMessage — silently no-op rather than break the run
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ export default function (pi: ExtensionAPI) {
4
+ pi.on("session_start", async (_event, ctx) => {
5
+ ctx.ui.notify("little-coder scaffold loaded", "info");
6
+ });
7
+ }
@@ -0,0 +1,149 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseSkillFile } from "../skill-inject/frontmatter.ts";
6
+
7
+ // ── Knowledge-entry registry ────────────────────────────────────────────
8
+ // Port of local/knowledge_augment.py. Loads skills/knowledge/*.md plus the
9
+ // three root-level protocol skills (skills/protocols/*.md). Scores entries
10
+ // against the user's prompt, selects top within budget, publishes
11
+ // `requires_tools` on systemPromptOptions so skill-inject can include them.
12
+
13
+ interface KnowledgeEntry {
14
+ topic: string;
15
+ body: string;
16
+ tokenCost: number;
17
+ keywords: string[];
18
+ requiresTools: string[];
19
+ }
20
+
21
+ const entries = new Map<string, KnowledgeEntry>();
22
+ const cache = new Map<string, string>();
23
+ let loaded = false;
24
+
25
+ const MIN_SCORE_THRESHOLD = 2.0;
26
+ const PER_ENTRY_CAP = 150;
27
+
28
+ function dirs(): string[] {
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const repo = join(here, "..", "..", "..");
31
+ return [join(repo, "skills", "knowledge"), join(repo, "skills", "protocols")];
32
+ }
33
+
34
+ function loadEntries(): void {
35
+ if (loaded) return;
36
+ loaded = true;
37
+ for (const dir of dirs()) {
38
+ if (!existsSync(dir)) continue;
39
+ for (const file of readdirSync(dir)) {
40
+ if (!file.endsWith(".md")) continue;
41
+ const parsed = parseSkillFile(readFileSync(join(dir, file), "utf-8"));
42
+ if (!parsed) continue;
43
+ const fm = parsed.frontmatter;
44
+ const topic = (typeof fm.topic === "string" ? fm.topic : "") ||
45
+ (typeof fm.name === "string" ? fm.name : "");
46
+ if (!topic || !parsed.body) continue;
47
+ let cost = typeof fm.token_cost === "number" ? fm.token_cost : 150;
48
+ if (cost > PER_ENTRY_CAP) cost = PER_ENTRY_CAP;
49
+ const keywords = Array.isArray(fm.keywords)
50
+ ? (fm.keywords as string[]).map((k) => k.toLowerCase())
51
+ : [];
52
+ const requiresTools = Array.isArray(fm.requires_tools)
53
+ ? (fm.requires_tools as string[])
54
+ : [];
55
+ entries.set(topic, { topic, body: parsed.body, tokenCost: cost, keywords, requiresTools });
56
+ }
57
+ }
58
+ }
59
+
60
+ // ── Scoring (word=1.0, bigram/phrase=2.0) ───────────────────────────────
61
+ function scoreEntry(userText: string, e: KnowledgeEntry): number {
62
+ if (e.keywords.length === 0) return 0;
63
+ const textLower = userText.toLowerCase();
64
+ const words = new Set(textLower.split(/\s+/).filter(Boolean));
65
+ let score = 0;
66
+ for (const kw of e.keywords) {
67
+ if (kw.includes(" ")) {
68
+ if (textLower.includes(kw)) score += 2.0;
69
+ } else {
70
+ if (words.has(kw)) score += 1.0;
71
+ }
72
+ }
73
+ return score;
74
+ }
75
+
76
+ function estimateTokens(text: string): number {
77
+ return Math.ceil(text.length / 3.5);
78
+ }
79
+
80
+ function buildBlock(selected: KnowledgeEntry[]): string {
81
+ let out = "\n\n## Algorithm Reference\n";
82
+ for (const e of selected) out += `\n### ${e.topic}\n${e.body}\n`;
83
+ return out;
84
+ }
85
+
86
+ export default function (pi: ExtensionAPI) {
87
+ pi.on("before_agent_start", async (event, ctx) => {
88
+ loadEntries();
89
+ if (entries.size === 0) return;
90
+
91
+ const opts: any = (event as any).systemPromptOptions ?? {};
92
+ const lc = opts.littleCoder ?? {};
93
+ const budget: number = lc.knowledgeTokenBudget ?? 200;
94
+ if (budget <= 0) return;
95
+ if (lc.isSubtask) return;
96
+
97
+ const base = event.systemPrompt ?? "";
98
+ const contextLimit: number = lc.contextLimit ?? 8192;
99
+ if (estimateTokens(base) > contextLimit * 0.4) return;
100
+
101
+ const prompt = event.prompt ?? "";
102
+ if (!prompt) return;
103
+
104
+ const scored: Array<{ score: number; entry: KnowledgeEntry }> = [];
105
+ for (const e of entries.values()) {
106
+ const s = scoreEntry(prompt, e);
107
+ if (s >= MIN_SCORE_THRESHOLD) scored.push({ score: s, entry: e });
108
+ }
109
+ if (scored.length === 0) return;
110
+ scored.sort((a, b) => b.score - a.score);
111
+
112
+ const selected: KnowledgeEntry[] = [];
113
+ let used = 0;
114
+ for (const { entry } of scored) {
115
+ if (used + entry.tokenCost > budget) continue;
116
+ selected.push(entry);
117
+ used += entry.tokenCost;
118
+ }
119
+ if (selected.length === 0) return;
120
+
121
+ // Publish required tools on systemPromptOptions. skill-inject reads this
122
+ // to include the requires_tools' skill cards in its own selection.
123
+ const requiredTools = Array.from(
124
+ new Set(selected.flatMap((e) => e.requiresTools)),
125
+ );
126
+ if (requiredTools.length > 0) {
127
+ if (!opts.littleCoder) opts.littleCoder = {};
128
+ opts.littleCoder.requiredTools = requiredTools;
129
+ }
130
+
131
+ const key = selected.map((e) => e.topic).sort().join("|");
132
+ let block = cache.get(key);
133
+ if (block === undefined) {
134
+ block = buildBlock(selected);
135
+ cache.set(key, block);
136
+ }
137
+
138
+ try {
139
+ ctx.ui.notify(
140
+ `knowledge-inject: +${selected.length} [${selected.map((e) => e.topic).join(",")}]`,
141
+ "info",
142
+ );
143
+ } catch {
144
+ // best-effort
145
+ }
146
+
147
+ return { systemPrompt: base + block };
148
+ });
149
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseSkillFile } from "../skill-inject/frontmatter.ts";
6
+
7
+ // Duplicate scoring so tests can exercise it as a pure function.
8
+ function scoreEntry(userText: string, keywords: string[]): number {
9
+ if (keywords.length === 0) return 0;
10
+ const textLower = userText.toLowerCase();
11
+ const words = new Set(textLower.split(/\s+/).filter(Boolean));
12
+ let score = 0;
13
+ for (const kw of keywords) {
14
+ if (kw.includes(" ")) {
15
+ if (textLower.includes(kw)) score += 2.0;
16
+ } else {
17
+ if (words.has(kw)) score += 1.0;
18
+ }
19
+ }
20
+ return score;
21
+ }
22
+
23
+ describe("knowledge entry scoring", () => {
24
+ it("scores single word matches at 1.0 each", () => {
25
+ expect(scoreEntry("find the bucket", ["bucket"])).toBe(1.0);
26
+ expect(scoreEntry("find the bucket and pour", ["bucket", "pour"])).toBe(2.0);
27
+ });
28
+
29
+ it("scores bigram/phrase matches at 2.0 each", () => {
30
+ expect(scoreEntry("minimum moves to solve", ["minimum moves"])).toBe(2.0);
31
+ expect(scoreEntry("state space search", ["state space"])).toBe(2.0);
32
+ });
33
+
34
+ it("combines word + bigram scores", () => {
35
+ const kw = ["bucket", "minimum moves", "pour"];
36
+ // "bucket" word (1.0) + "minimum moves" phrase (2.0) + "pour" word (1.0) = 4.0
37
+ expect(scoreEntry("bucket pouring problem with minimum moves and pour", kw)).toBe(4.0);
38
+ });
39
+
40
+ it("does not match partial words", () => {
41
+ // 'bucket' shouldn't match 'buckets' because the scorer tokenizes on whitespace
42
+ expect(scoreEntry("many buckets here", ["bucket"])).toBe(0);
43
+ });
44
+
45
+ it("threshold at 2.0 requires at least two signals", () => {
46
+ // The extension's MIN_SCORE_THRESHOLD = 2.0 means one word isn't enough
47
+ expect(scoreEntry("find bucket", ["bucket", "pour"])).toBeLessThan(2.0);
48
+ expect(scoreEntry("bucket pour together", ["bucket", "pour"])).toBeGreaterThanOrEqual(2.0);
49
+ });
50
+ });
51
+
52
+ describe("knowledge directory loads from repo", () => {
53
+ const here = dirname(fileURLToPath(import.meta.url));
54
+ const kDir = join(here, "..", "..", "..", "skills", "knowledge");
55
+ const pDir = join(here, "..", "..", "..", "skills", "protocols");
56
+
57
+ it("knowledge dir has 13 files", () => {
58
+ expect(existsSync(kDir)).toBe(true);
59
+ expect(readdirSync(kDir).filter((f) => f.endsWith(".md")).length).toBe(13);
60
+ });
61
+
62
+ it("protocols dir has 3 files", () => {
63
+ expect(existsSync(pDir)).toBe(true);
64
+ expect(readdirSync(pDir).filter((f) => f.endsWith(".md")).length).toBe(3);
65
+ });
66
+
67
+ it("every knowledge entry has topic + keywords in frontmatter", () => {
68
+ const files = readdirSync(kDir).filter((f) => f.endsWith(".md"));
69
+ for (const file of files) {
70
+ const parsed = parseSkillFile(readFileSync(join(kDir, file), "utf-8"));
71
+ expect(parsed, `${file} should parse`).not.toBeNull();
72
+ expect(typeof parsed!.frontmatter.topic).toBe("string");
73
+ expect(Array.isArray(parsed!.frontmatter.keywords), `${file} keywords`).toBe(true);
74
+ }
75
+ });
76
+
77
+ it("workspace_docs declares requires_tools", () => {
78
+ const parsed = parseSkillFile(readFileSync(join(kDir, "workspace_docs.md"), "utf-8"));
79
+ expect(parsed!.frontmatter.requires_tools).toEqual(["Read", "Glob"]);
80
+ });
81
+ });