Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ cd /path/to/project && pi # Start coding
/config apply gemini # smart=Gemini-3.1-Pro, general=Flash, fast=Flash-Lite
/config apply deepseek # smart=DeepSeek-R1, general=V3, fast=V3
/config apply qwen # smart=Qwen3.5-235B, general=Qwen3.5-32B, fast=Qwen3.5-8B
/config apply local # Ollama: DeepSeek-Coder-V3 + Qwen2.5-Coder
/config apply local # Ollama: qwen3.6-36b + gemma4-26b
/config apply lmstudio # LM Studio: same models, OpenAI-compatible API
/config apply vllm # vLLM: high-throughput serving
/config apply llamacpp # llama.cpp: lightweight C++ inference
/config apply llamacpp # llama-swap: qwen3.6-36b (reasoning) + gemma4-26b (general)
/config aliases # Show current mappings
```

Expand Down Expand Up @@ -110,6 +110,7 @@ Built-in skills for code quality and workflow authoring:
| `/config apply <profile>` | Switch model profile |
| `/module show/hide <name>` | Toggle tool groups |
| `/todo add/done/list/clear` | Manage session todos |
| `/health` | Check local model endpoint availability |
| `/rtk` | Show RTK compression stats |
| `/test-gen <target>` | Generate tests for a file or directory |
| `/changelog [since]` | Generate changelog from git history |
10 changes: 5 additions & 5 deletions configs/llamacpp.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Local (llama.cpp)
description: Local models via llama.cpp server — lightweight C++ inference
name: Local (llama.cpp + llama-swap)
description: Local models via llama-swap — gemma4-26b for general tasks, qwen3.6-36b for reasoning
configs:
- name: smart
value: llamacpp/deepseek-coder-v3
value: llamacpp/qwen3.6-36b
- name: general
value: llamacpp/qwen2.5-coder-32b
value: llamacpp/gemma4-26b
- name: fast
value: llamacpp/qwen2.5-coder-7b
value: llamacpp/gemma4-26b
6 changes: 3 additions & 3 deletions configs/lmstudio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Local (LM Studio)
description: Local models via LM Studio — OpenAI-compatible API on localhost
configs:
- name: smart
value: lmstudio/deepseek-coder-v3
value: lmstudio/qwen3.6-36b
- name: general
value: lmstudio/qwen2.5-coder-32b
value: lmstudio/gemma4-26b
- name: fast
value: lmstudio/qwen2.5-coder-7b
value: lmstudio/gemma4-26b
6 changes: 3 additions & 3 deletions configs/local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Local (Ollama)
description: Local models via Ollama — no API key needed
configs:
- name: smart
value: ollama/deepseek-coder-v3
value: ollama/qwen3.6-36b
- name: general
value: ollama/qwen2.5-coder-32b
value: ollama/gemma4-26b
- name: fast
value: ollama/qwen2.5-coder-7b
value: ollama/gemma4-26b
6 changes: 3 additions & 3 deletions configs/vllm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Local (vLLM)
description: Local models via vLLM — high-throughput OpenAI-compatible serving
configs:
- name: smart
value: vllm/deepseek-coder-v3
value: vllm/qwen3.6-36b
- name: general
value: vllm/qwen2.5-coder-32b
value: vllm/gemma4-26b
- name: fast
value: vllm/qwen2.5-coder-7b
value: vllm/gemma4-26b
12 changes: 7 additions & 5 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ echo " Set API key: export ANTHROPIC_API_KEY=sk-ant-..."
echo " Switch model: /config apply codex (or anthropic, gemini)"
echo ""
echo "Workflows:"
echo " /workflow brainstorm <idea> — Open ideation"
echo " /workflow feature <prompt> — Brainstorm → Plan → Implement"
echo " /workflow code-review <target> — AnalyzeSummarize"
echo " /workflow bugfix <description> — ReproduceDiagnose → Fix"
echo " /workflow research <question> — Clarify → Search → Analyze → Synthesize"
echo " /workflow test <target> — Analyze → Generate → Run → Fix"
echo " /workflow tri-review <target> — 3-tier review → Consolidate"
echo " /workflow tri-dispatch <prompt> — 3 models → Compare"
echo " /workflow self-improve <metric cmd> — Metric-gated iteration"
echo " /workflow self-test <test cmd> — Run → Fix → Repeat"
echo ""
echo "Local models:"
echo " /config apply llamacpp — llama-swap (gemma4 + qwen3.6)"
echo " /config apply local — Ollama"
echo " /health — Check local endpoints"
29 changes: 29 additions & 0 deletions src/commands/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { checkEndpoint, formatHealth } from "../core/health.js";

/** Known local endpoints to check */
const LOCAL_ENDPOINTS: Record<string, string> = {
"llama.cpp / llama-swap": "http://localhost:8080/v1",
"Ollama": "http://localhost:11434/v1",
"LM Studio": "http://localhost:1234/v1",
"vLLM": "http://localhost:8000/v1",
};

export function registerHealth(pi: ExtensionAPI): void {
pi.registerCommand("health", {
description: "Check local model endpoint availability",
async handler(args, ctx) {
const lines: string[] = ["Local endpoint health check:", ""];

const results = await Promise.all(
Object.entries(LOCAL_ENDPOINTS).map(async ([name, url]) => {
const result = await checkEndpoint(url);
return `${name}: ${formatHealth(result)}`;
}),
);

lines.push(...results);
ctx.ui.notify(lines.join("\n"), "info");
},
});
}
29 changes: 29 additions & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { listWorkflows } from "../workflows/loader.js";
import { discoverSkills } from "../skills/loader.js";
import { checkEndpoint, formatHealth } from "../core/health.js";
import { activeProfile, getAliases } from "../config/profiles.js";

function checkCli(name: string): string | null {
try {
Expand All @@ -24,6 +26,33 @@ export function registerStatus(pi: ExtensionAPI): void {
const cwd = ctx.cwd;
const lines: string[] = ["## Pikit Status", ""];

// Active model profile
const profile = activeProfile(cwd);
if (profile) {
const aliases = getAliases(cwd);
const aliasStr = Object.entries(aliases)
.map(([name, { current }]) => `${name}=${current}`)
.join(", ");
lines.push(`### Model Profile: ${profile}`);
lines.push(aliasStr);
lines.push("");
}

// Local endpoint check
const localEndpoints = [
["llama-swap", "http://localhost:8080/v1"],
["Ollama", "http://localhost:11434/v1"],
] as const;
const endpointResults = await Promise.all(
localEndpoints.map(async ([name, url]) => {
const result = await checkEndpoint(url, 1500);
return `- **${name}**: ${formatHealth(result)}`;
}),
);
lines.push("### Local Endpoints");
lines.push(...endpointResults);
lines.push("");

// CLIs
lines.push("### External CLIs");
const clis: Array<[string, string, string]> = [
Expand Down
53 changes: 53 additions & 0 deletions src/core/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Health check for local model endpoints */

interface HealthResult {
ok: boolean;
endpoint: string;
models: string[];
error?: string;
latencyMs: number;
}

/** Ping an OpenAI-compatible /v1/models endpoint and return available model IDs */
export async function checkEndpoint(baseUrl: string, timeoutMs = 3000): Promise<HealthResult> {
const endpoint = baseUrl.replace(/\/+$/, "");
const start = Date.now();

try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

const res = await fetch(`${endpoint}/models`, {
signal: controller.signal,
headers: { Authorization: "Bearer llamacpp" },
});
clearTimeout(timer);

const latencyMs = Date.now() - start;

if (!res.ok) {
return { ok: false, endpoint, models: [], error: `HTTP ${res.status}`, latencyMs };
}

const body = await res.json() as { data?: Array<{ id: string }> };
const models = (body.data ?? []).map((m) => m.id);

return { ok: true, endpoint, models, latencyMs };
} catch (err) {
const latencyMs = Date.now() - start;
const message = err instanceof Error ? err.message : String(err);
const error = message.includes("abort") ? `Timeout after ${timeoutMs}ms` : message;
return { ok: false, endpoint, models: [], error, latencyMs };
}
}

/** Format a health result for display */
export function formatHealth(result: HealthResult): string {
if (!result.ok) {
return `OFFLINE ${result.endpoint} — ${result.error}`;
}
const models = result.models.length > 0
? result.models.join(", ")
: "no models listed";
return `ONLINE ${result.endpoint} (${result.latencyMs}ms) — ${models}`;
}
13 changes: 10 additions & 3 deletions src/hooks/context-prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,23 @@ export function registerContextPrune(pi: ExtensionAPI): void {
});
}

function pruneToolResults(messages: Array<Record<string, unknown>>, beforeIndex: number): void {
interface PrunableMessage {
type?: string;
message?: {
role?: string;
content?: Array<{ type: string; text?: string }>;
};
}

function pruneToolResults(messages: PrunableMessage[], beforeIndex: number): void {
for (let i = 0; i < beforeIndex; i++) {
const msg = messages[i] as { type?: string; message?: { role?: string; content?: Array<{ type: string; text?: string }> } };
const msg = messages[i];
if (msg.type !== "message") continue;
if (msg.message?.role !== "toolResult") continue;

const content = msg.message.content;
if (!Array.isArray(content)) continue;

// Replace long text blocks with a short summary
for (let j = 0; j < content.length; j++) {
const block = content[j];
if (block.type === "text" && block.text && block.text.length > 500) {
Expand Down
34 changes: 19 additions & 15 deletions src/hooks/security-patterns.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ExtensionAPI, isToolCallEventType } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";

/**
* Security patterns hook — catches vulnerability patterns at edit time.
Expand Down Expand Up @@ -75,20 +75,24 @@ function checkContent(filePath: string, content: string): string | undefined {
}

export function registerSecurityPatterns(pi: ExtensionAPI): void {
pi.on("tool_call", (event, _ctx) => {
if (isToolCallEventType("write", event)) {
const warning = checkContent(event.input.path, event.input.content);
if (warning) {
return { block: true, reason: warning };
}
}

if (isToolCallEventType("edit", event)) {
const combined = event.input.edits.map((e) => e.newText).join("\n");
const warning = checkContent(event.input.path, combined);
if (warning) {
return { block: true, reason: warning };
}
pi.on("tool_result", (event, _ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return;

const input = event.input as { path?: string; content?: string; edits?: Array<{ newText?: string }> };
const filePath = input.path ?? "";
const content = event.toolName === "write"
? (input.content ?? "")
: (input.edits ?? []).map((e) => e.newText ?? "").join("\n");

const warning = checkContent(filePath, content);
if (warning) {
const existing = event.content ?? [];
return {
content: [
...existing,
{ type: "text" as const, text: `\n[security] ${warning}` },
],
};
}
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerReviewPr } from "./commands/review-pr.js";
import { registerDecompose } from "./commands/decompose.js";
import { registerRepoMap } from "./commands/repo-map.js";
import { registerStatus } from "./commands/status.js";
import { registerHealth } from "./commands/health.js";
import { registerStatusline } from "./statusline/git.js";
import { checkUpstreamAsync } from "./core/upstream.js";
import { registerSafetyCheck } from "./hooks/safety-check.js";
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function pikit(pi: ExtensionAPI): void {
registerDecompose(pi);
registerRepoMap(pi);
registerStatus(pi);
registerHealth(pi);

// Modules (pass known module names)
registerModuleCommands(pi, ["search", "memory", "todos"]);
Expand Down
47 changes: 44 additions & 3 deletions src/workflows/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,12 @@ export async function runWorkflow(
ctx.ui.notify(`Workflow "${workflow.name}" complete.`, "info");
}

/** Fallback order: smart -> general -> fast */
const FALLBACK_CHAIN: readonly string[] = ["smart", "general", "fast"];

/**
* Execute a single step by sending its resolved prompt to the agent.
* If the primary model fails, tries the next tier in the fallback chain.
* Returns the assistant's response text, or null if no prompt.
*/
async function executeStep(
Expand All @@ -116,6 +120,33 @@ async function executeStep(

try {
return await executeStepInner(pi, step, input, memory, ctx);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`[${step.id}] Step failed: ${msg}. Trying fallback...`, "warning");

// Attempt fallback through remaining tiers
const currentTier = step.model ?? "smart";
const startIdx = FALLBACK_CHAIN.indexOf(currentTier);
for (let i = Math.max(startIdx + 1, 0); i < FALLBACK_CHAIN.length; i++) {
const fallbackTier = FALLBACK_CHAIN[i];
const fallbackId = resolveAlias(ctx.cwd, fallbackTier);
const allModels = ctx.modelRegistry.getAll();
const fallbackModel = allModels.find((m) => m.id === fallbackId);
if (!fallbackModel || !ctx.modelRegistry.hasConfiguredAuth(fallbackModel)) continue;

const ok = await pi.setModel(fallbackModel);
if (!ok) continue;

ctx.ui.notify(`[${step.id}] Falling back to ${fallbackTier} (${fallbackId})`, "info");
try {
return await executeStepInner(pi, step, input, memory, ctx);
} catch {
continue;
}
}

ctx.ui.notify(`[${step.id}] All fallbacks exhausted. Skipping step.`, "error");
return null;
} finally {
// Restore previous model
if (previousModel) {
Expand All @@ -141,13 +172,23 @@ async function switchModelForStep(
// Find the target model
const allModels = ctx.modelRegistry.getAll();
const target = allModels.find((m) => m.id === modelId);
if (!target) return null;
if (!target) {
ctx.ui.notify(`[${step.id}] Model "${modelId}" not found in registry. Running on current model.`, "warning");
return null;
}

// Check auth
if (!ctx.modelRegistry.hasConfiguredAuth(target)) return null;
if (!ctx.modelRegistry.hasConfiguredAuth(target)) {
ctx.ui.notify(`[${step.id}] Model "${modelId}" has no configured auth. Running on current model.`, "warning");
return null;
}

const ok = await pi.setModel(target);
return ok && currentModel ? currentModel : null;
if (!ok) {
ctx.ui.notify(`[${step.id}] Failed to switch to "${modelId}". Running on current model.`, "warning");
return null;
}
return currentModel ?? null;
}

async function executeStepInner(
Expand Down
Loading