pi-devin-fusion 0.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/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # pi-devin-fusion
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+
5
+ Devin Fusion "sidekick" pattern for [pi](https://github.com/earendil-works/pi).
6
+
7
+ Two models work together: the active **planner** (you pick it) plans and reviews, and a cheaper **executor** (auto-selected) implements. The planner delegates to the executor via the `sidekick` tool. Mutating executor tools require consent and run serialized to prevent clobbered writes.
8
+
9
+ ## Why
10
+
11
+ From [Cognition's blog post](https://cognition.com/blog/devin-fusion):
12
+
13
+ > the main agent should take minimal actions, and only read what is absolutely necessary. By default it should delegate and monitor, while making the significant decisions: the plan, the interpretation of ambiguity, the final review.
14
+
15
+ This extension makes that pattern work in pi — not as a suggestion, but as a mechanical tool wrapper. The planner model is always in control; the executor model runs a controlled tool loop with bounded turns.
16
+
17
+ ## Installation
18
+
19
+ Install directly in your pi project:
20
+
21
+ ```bash
22
+ pi install ./
23
+ ```
24
+
25
+ Or install from npm:
26
+
27
+ ```bash
28
+ pi install npm:pi-devin-fusion
29
+ ```
30
+
31
+ You can also install from a local path:
32
+
33
+ ```bash
34
+ pi install ./path/to/pi-devin-fusion
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Commands
40
+
41
+ | Command | Description |
42
+ |---------|-------------|
43
+ | `/devin on` / `forced` | Force every user message through the planner/sidekick split |
44
+ | `/devin available` / `auto` | Let the model decide when to use the sidekick (default) |
45
+ | `/devin off` | Disable the sidekick tool for this session |
46
+ | `/devin <prompt>` | Send a prompt through the forced planner prefix once |
47
+ | `/devin-setup` | Interactive picker to choose executor model, tools, and config (session-scoped) |
48
+ | `/devin-init` | Create a `.pi/devin.json` template to set the executor model and tool selection |
49
+ | `/devin-status` | Show current Devin mode, executor, tool selection, and consent state |
50
+ ### Tools
51
+
52
+ The extension registers one tool:
53
+
54
+ - **`sidekick`** — Delegate a task to the executor model. Accepts a `prompt` string, optional `context_mode` (`"none"` / `"recent"`), and optional `context_turns` (1–10, default 4 for "recent" mode).
55
+
56
+ ## Workflow
57
+
58
+ ### Interactive setup (recommended for first use)
59
+
60
+ 1. Run `/devin-setup` to pick an executor model and session config interactively.
61
+ 2. Turn Devin on with `/devin on`.
62
+ 3. Ask your question. The model uses the sidekick executor for implementation tasks.
63
+
64
+ ### Config-file setup
65
+
66
+ 1. (Optional) Run `/devin-init` to create a `.pi/devin.json` template.
67
+ 2. Turn Devin on with `/devin on`.
68
+ 3. Ask your question.
69
+
70
+ The planner delegates to the sidekick executor for exploration and implementation; it reviews
71
+ the result before responding. Use `/devin <prompt>` for a one-off delegation without changing the mode.
72
+
73
+ ### Session vs file configuration
74
+
75
+ Settings from `/devin-setup` take precedence over `.pi/devin.json`. Session state persists
76
+ within the conversation and is restored on session restore. Use `/devin-status` to inspect
77
+ the effective configuration.
78
+
79
+ ## Configuration
80
+
81
+ `.pi/devin.json` in your project root:
82
+
83
+ ```json
84
+ {
85
+ "$schema": "devin-fusion-config",
86
+ "executor": "openai/gpt-4.1-mini",
87
+ "executorTools": "all",
88
+ "executorToolsConsent": false,
89
+ "maxExecutorOutputTokens": 4096,
90
+ "temperature": 0.2,
91
+ "maxToolCalls": 16,
92
+ "footerDisplay": "full"
93
+ }
94
+ ```
95
+
96
+ | Key | Default | Description |
97
+ |-----|---------|-------------|
98
+ | `executor` | auto-selected | Model identifier for the executor (e.g. `"openai/gpt-4.1-mini"`). Auto-selects the first non-current text model if unset. |
99
+ | `executorTools` | `"all"` | Tool selection: `"none"`, `"readonly"`, `"all"`, or an array like `["read", "grep", "write"]`. |
100
+ | `executorToolsConsent` | `false` | When `true`, skips the consent prompt for mutating tools. |
101
+ | `maxExecutorOutputTokens` | `4096` | Max output tokens per executor call. |
102
+ | `temperature` | `0.2` | Temperature for the executor model. |
103
+ | `maxToolCalls` | `16` | Max tool calls per executor run (clamped to 1–100). |
104
+ | `footerDisplay` | `"full"` | Footer verbosity: `"full"`, `"compact"`, or `"off"`. |
105
+
106
+ **Session precedence:** `/devin-setup` selection overrides `.pi/devin.json`. Session state persists in the conversation and is restored on session restore.
107
+
108
+ ## Files
109
+
110
+ | File | Purpose |
111
+ |------|---------|
112
+ | `src/index.ts` | Extension entry: `sidekick` tool, `/devin` commands, session state, footers |
113
+ | `src/config.ts` | Config loading, defaults, template generation |
114
+ | `src/context.ts` | Context normalization and recent-history builder |
115
+ | `src/executor.ts` | Executor pipeline: model resolution, consent gate, serialized mutating runs |
116
+ | `src/executor_policy.ts` | Pure consent-policy check (testable without models) |
117
+ | `src/llm.ts` | Low-level LLM calls, tool loop, circuit breakers, output truncation |
118
+ | `src/models.ts` | Model resolution helpers, executor auto-selection with auth check |
119
+ | `src/prompts.ts` | Planner prefix prompt and sidekick system prompt |
120
+ | `src/tools.ts` | Tool-definition factory, selection normalization, mutating detection |
121
+ | `src/types.ts` | Shared DevinConfig, SidekickOptions, ToolSelection, FooterDisplay types |
122
+ | `src/ui.ts` | Interactive session setup picker via pi TUI components |
123
+ | `src/utils.ts` | Concurrency-limited map, byte truncation, JSON extraction |
124
+ ## Credit
125
+
126
+ Inspired by [Devin Fusion](https://cognition.com/blog/devin-fusion) by [Cognition](https://cognition.com).
127
+
128
+ ## License
129
+
130
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "pi-devin-fusion",
3
+ "version": "0.1.0",
4
+ "description": "Devin sidekick planner/executor split for pi, inspired by devin fusion",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/khanhdeptraivaicachuong/pi-devin-fusion.git"
10
+ },
11
+ "license": "MIT",
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ },
15
+ "files": [
16
+ "src/*.ts",
17
+ "README.md",
18
+ "LICENSE",
19
+ "tsconfig.json"
20
+ ],
21
+ "scripts": {
22
+ "clean": "rm -rf node_modules",
23
+ "build": "echo 'pi-devin-fusion is loaded directly by pi via jiti; no build step required'",
24
+ "check": "tsc --noEmit",
25
+ "test": "node --import jiti/register src/__tests__/run.ts"
26
+ },
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ },
32
+ "keywords": ["pi-package", "pi-extension", "devin", "fusion", "sidekick", "planner", "executor", "coding-agent", "llm"],
33
+ "dependencies": {},
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*",
38
+ "typebox": "*"
39
+ },
40
+ "devDependencies": {
41
+ "jiti": "^2.4.0",
42
+ "typescript": "^5.5.0"
43
+ }
44
+ }
package/src/config.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Devin configuration loading and validation.
3
+ */
4
+
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { DevinConfig, ResolvedDevinConfig } from "./types.ts";
9
+
10
+ export type { DevinConfig, ResolvedDevinConfig };
11
+
12
+ export const DEFAULT_MAX_EXECUTOR_OUTPUT_TOKENS = 4096;
13
+ export const DEFAULT_TEMPERATURE = 0.2;
14
+ export const DEFAULT_EXECUTOR_TOOLS: "all" = "all";
15
+ export const DEFAULT_MAX_TOOL_CALLS = 16;
16
+ export const MIN_TOOL_CALLS = 1;
17
+ export const MAX_TOOL_CALLS = 100;
18
+ /** Per tool result, before it re-enters the loop transcript (keeps executor context bounded). */
19
+ export const TOOL_OUTPUT_MAX_BYTES = 12_000;
20
+
21
+ export function loadConfig(cwd: string, projectTrusted: boolean): DevinConfig {
22
+ const paths: string[] = [];
23
+ if (projectTrusted) {
24
+ paths.push(join(cwd, ".pi", "devin.json"));
25
+ }
26
+ paths.push(join(getAgentDir(), "devin.json"));
27
+
28
+ for (const path of paths) {
29
+ if (!existsSync(path)) continue;
30
+ try {
31
+ return JSON.parse(readFileSync(path, "utf8")) as DevinConfig;
32
+ } catch (err) {
33
+ console.error(`[pi-devin-fusion] failed to parse ${path}:`, err);
34
+ }
35
+ }
36
+ return {};
37
+ }
38
+
39
+ export function applyDefaults(
40
+ config: DevinConfig,
41
+ overrides: Pick<DevinConfig, "executor" | "executorTools" | "maxToolCalls" | "footerDisplay"> = {},
42
+ ): ResolvedDevinConfig {
43
+ const merged: DevinConfig = { ...config };
44
+ if (overrides.executor !== undefined) merged.executor = overrides.executor;
45
+ if (overrides.executorTools !== undefined) merged.executorTools = overrides.executorTools;
46
+ if (overrides.maxToolCalls !== undefined) merged.maxToolCalls = overrides.maxToolCalls;
47
+ if (overrides.footerDisplay !== undefined) merged.footerDisplay = overrides.footerDisplay;
48
+
49
+ // Single source of truth for defaults: callers can read these knobs directly.
50
+ // The invoking model/tool can only choose context; executor, tools, max calls,
51
+ // output tokens, and temperature are user config/session setup or defaults.
52
+ return {
53
+ ...merged,
54
+ executor: merged.executor,
55
+ maxExecutorOutputTokens: merged.maxExecutorOutputTokens ?? DEFAULT_MAX_EXECUTOR_OUTPUT_TOKENS,
56
+ temperature: merged.temperature ?? DEFAULT_TEMPERATURE,
57
+ executorTools: merged.executorTools ?? DEFAULT_EXECUTOR_TOOLS,
58
+ maxToolCalls: merged.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS,
59
+ footerDisplay: merged.footerDisplay ?? "full",
60
+ };
61
+ }
62
+
63
+ export function generateConfigExample(executor?: string): DevinConfig {
64
+ return {
65
+ ...(executor ? { executor } : {}),
66
+ maxExecutorOutputTokens: DEFAULT_MAX_EXECUTOR_OUTPUT_TOKENS,
67
+ temperature: DEFAULT_TEMPERATURE,
68
+ executorTools: DEFAULT_EXECUTOR_TOOLS,
69
+ maxToolCalls: DEFAULT_MAX_TOOL_CALLS,
70
+ executorToolsConsent: false,
71
+ footerDisplay: "full",
72
+ };
73
+ }
package/src/context.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Conversation-context helpers for optional executor context.
3
+ */
4
+
5
+ const MAX_CONTEXT_TURNS = 10;
6
+ const DEFAULT_CONTEXT_TURNS = 4;
7
+ const MAX_CONTEXT_CHARS = 20000;
8
+
9
+ export type DevinContextMode = "none" | "recent";
10
+
11
+ export function normalizeContextTurns(value: number | undefined): number {
12
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_CONTEXT_TURNS;
13
+ return Math.max(1, Math.min(MAX_CONTEXT_TURNS, Math.floor(value)));
14
+ }
15
+
16
+ interface MaybeMessage {
17
+ content?: unknown;
18
+ }
19
+
20
+ function getContentText(content: unknown): string {
21
+ if (typeof content === "string") return content.trim();
22
+ if (!Array.isArray(content)) return "";
23
+ return content
24
+ .map((part) => {
25
+ if (typeof part === "string") return part;
26
+ if (part && typeof part === "object" && "type" in part) {
27
+ const p = part as { type?: unknown };
28
+ if (p.type === "text") {
29
+ const text = (part as { text?: unknown }).text;
30
+ return typeof text === "string" ? text : "";
31
+ }
32
+ }
33
+ return "";
34
+ })
35
+ .join("\n")
36
+ .trim();
37
+ }
38
+
39
+ export function extractMessageText(message: unknown): string {
40
+ const m = message as MaybeMessage;
41
+ return getContentText(m?.content);
42
+ }
43
+
44
+ export function buildRecentContextFromEntries(entries: unknown[], turns: number | undefined): string | undefined {
45
+ const maxTurns = normalizeContextTurns(turns);
46
+ const messages: Array<{ role: "user" | "assistant"; text: string }> = [];
47
+ let userMessagesSeen = 0;
48
+
49
+ for (let i = entries.length - 1; i >= 0; i--) {
50
+ const entry = entries[i] as { type?: unknown; message?: MaybeMessage };
51
+ if (entry?.type !== "message" || !entry.message) continue;
52
+ const role = (entry.message as { role?: unknown }).role;
53
+ if (role !== "user" && role !== "assistant") continue;
54
+ const text = getContentText(entry.message.content);
55
+ if (!text) continue;
56
+ messages.unshift({ role: role as "user" | "assistant", text });
57
+ if (role === "user") {
58
+ userMessagesSeen++;
59
+ if (userMessagesSeen >= maxTurns) break;
60
+ }
61
+ }
62
+
63
+ if (messages.length === 0) return undefined;
64
+
65
+ let rendered = messages
66
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`)
67
+ .join("\n\n");
68
+
69
+ if (rendered.length > MAX_CONTEXT_CHARS) {
70
+ rendered = rendered.slice(rendered.length - MAX_CONTEXT_CHARS).trimStart();
71
+ rendered = `[truncated to last ${MAX_CONTEXT_CHARS} chars]\n${rendered}`;
72
+ }
73
+
74
+ return rendered;
75
+ }
76
+
77
+ export function buildSidekickTaskText(prompt: string, contextText: string | undefined): string {
78
+ if (!contextText?.trim()) return prompt;
79
+ return [
80
+ "Recent conversation context:",
81
+ contextText.trim(),
82
+ "",
83
+ "Current task:",
84
+ prompt.trim(),
85
+ ].join("\n");
86
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Single-executor (sidekick) pipeline: resolve executor, run its tool loop.
3
+ */
4
+
5
+ import type { Api, Model } from "@earendil-works/pi-ai";
6
+ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
7
+ import { applyDefaults, loadConfig } from "./config.ts";
8
+ import { buildSidekickTaskText } from "./context.ts";
9
+ import { SIDEKICK_SYSTEM_PROMPT } from "./prompts.ts";
10
+ import { callModelWithTools, getTextContent } from "./llm.ts";
11
+ import { resolveExecutorModel } from "./models.ts";
12
+ import { isMutatingSelection, resolveToolDefs } from "./tools.ts";
13
+ import type { SidekickOptions, SidekickResult } from "./types.ts";
14
+
15
+ /** Serialize runs that can mutate shared filesystem/state to avoid clobbered writes. */
16
+ let mutationQueue: Promise<unknown> = Promise.resolve();
17
+ function runSerialized<T>(fn: () => Promise<T>): Promise<T> {
18
+ const run = mutationQueue.then(fn, fn);
19
+ // Keep the chain alive even if a run rejects.
20
+ mutationQueue = run.then(
21
+ () => undefined,
22
+ () => undefined,
23
+ );
24
+ return run;
25
+ }
26
+
27
+ function classifyError(message: string): SidekickResult["details"]["failure_reason"] {
28
+ const lower = message.toLowerCase();
29
+ if (lower.includes("credit") || lower.includes("quota") || lower.includes("billing")) {
30
+ return "insufficient_credits";
31
+ }
32
+ if (lower.includes("rate limit") || lower.includes("429")) {
33
+ return "rate_limited";
34
+ }
35
+ return "unexpected_error";
36
+ }
37
+
38
+ export async function runSidekick(
39
+ cwd: string,
40
+ registry: ModelRegistry,
41
+ currentModel: Model<Api> | undefined,
42
+ task: string,
43
+ projectTrusted: boolean,
44
+ overrides: SidekickOptions,
45
+ ctx: ExtensionContext,
46
+ consented: boolean,
47
+ signal: AbortSignal | undefined,
48
+ onUpdate?: (partial: { content: Array<{ type: "text"; text: string }>; details: unknown }) => void,
49
+ ): Promise<SidekickResult> {
50
+ const config = applyDefaults(loadConfig(cwd, projectTrusted), overrides);
51
+ const warnings: string[] = [];
52
+
53
+ const executor = resolveExecutorModel(registry, currentModel, config.executor, warnings);
54
+ if (!executor) {
55
+ const details = {
56
+ status: "error" as const,
57
+ error: "no authed text executor model available",
58
+ failure_reason: "no_executor_model" as const,
59
+ warnings,
60
+ };
61
+ return { content: [{ type: "text", text: JSON.stringify(details, null, 2) }], details };
62
+ }
63
+
64
+ // Fail-closed: mutating executor tools require explicit consent.
65
+ const hasConsent = consented || config.executorToolsConsent === true;
66
+ if (isMutatingSelection(config.executorTools) && !hasConsent) {
67
+ const details = {
68
+ status: "error" as const,
69
+ executor_model: undefined,
70
+ error: "executor mutating tools require consent",
71
+ failure_reason: "mutation_consent_required" as const,
72
+ warnings,
73
+ };
74
+ return { content: [{ type: "text", text: JSON.stringify(details, null, 2) }], details };
75
+ }
76
+
77
+ const taskText = buildSidekickTaskText(task, overrides.context_text);
78
+ const toolDefs = resolveToolDefs(config.executorTools, cwd);
79
+ const mutating = isMutatingSelection(config.executorTools);
80
+ const executorModel = modelDisplayLocal(executor);
81
+ const label = `Sidekick: ${executorModel} | tools: ${config.executorTools}${mutating ? " (serialized)" : ""}`;
82
+
83
+ onUpdate?.({ content: [{ type: "text", text: label }], details: { phase: "executing" } });
84
+
85
+ const runLoop = () =>
86
+ callModelWithTools(
87
+ registry,
88
+ executor,
89
+ SIDEKICK_SYSTEM_PROMPT,
90
+ taskText,
91
+ config.maxExecutorOutputTokens,
92
+ config.temperature,
93
+ signal,
94
+ toolDefs,
95
+ config.maxToolCalls,
96
+ ctx,
97
+ );
98
+
99
+ try {
100
+ const result = mutating ? await runSerialized(runLoop) : await runLoop();
101
+ const output = getTextContent(result.message);
102
+ const details = {
103
+ status: "ok" as const,
104
+ executor_model: executorModel,
105
+ output,
106
+ turns: result.turns,
107
+ tool_calls: result.toolCalls,
108
+ capped: result.cappedOut,
109
+ warnings,
110
+ };
111
+ return { content: [{ type: "text", text: output }], details };
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ const details = {
115
+ status: "error" as const,
116
+ executor_model: executorModel,
117
+ error: message,
118
+ failure_reason: classifyError(message),
119
+ warnings,
120
+ };
121
+ return { content: [{ type: "text", text: JSON.stringify(details, null, 2) }], details };
122
+ }
123
+ }
124
+
125
+ function modelDisplayLocal(model: Model<Api>): string {
126
+ return `${model.provider}/${model.id}`;
127
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure executor tool-selection policy, extracted so tests can assert the
3
+ * fail-closed behavior without invoking models or tools.
4
+ */
5
+
6
+ import { applyDefaults } from "./config.ts";
7
+ import { isMutatingSelection } from "./tools.ts";
8
+ import type { SidekickOptions } from "./types.ts";
9
+
10
+ export interface ExecutorPolicy {
11
+ executor?: string;
12
+ toolSelectionConsentRequired: boolean;
13
+ }
14
+
15
+ /**
16
+ * Decide whether running the executor needs mutating-tool consent.
17
+ * Returns the resolved executor id and whether mutation consent is required.
18
+ */
19
+ export function resolveExecutorPolicy(
20
+ cwd: string,
21
+ projectTrusted: boolean,
22
+ overrides: SidekickOptions,
23
+ consented: boolean,
24
+ ): ExecutorPolicy {
25
+ const config = applyDefaults(loadConfigLocal(cwd, projectTrusted), overrides);
26
+ const mutating = isMutatingSelection(config.executorTools);
27
+ const hasConsent = consented || config.executorToolsConsent === true;
28
+ return {
29
+ executor: config.executor,
30
+ toolSelectionConsentRequired: mutating && !hasConsent,
31
+ };
32
+ }
33
+
34
+ // Local import avoids a cycle with config's own loader name.
35
+ import { loadConfig } from "./config.ts";
36
+ function loadConfigLocal(cwd: string, projectTrusted: boolean) {
37
+ return loadConfig(cwd, projectTrusted);
38
+ }