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/src/index.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * pi-devin-fusion: Devin sidekick planner/executor split for pi.
3
+ *
4
+ * The active model is the planner/reviewer. It delegates implementation and
5
+ * exploration to a separate, cheaper executor model via the `sidekick` tool.
6
+ * Mutating executor tools require one-time consent and run serialized.
7
+ */
8
+
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { applyDefaults, generateConfigExample, loadConfig } from "./config.ts";
14
+ import { buildRecentContextFromEntries, type DevinContextMode, normalizeContextTurns } from "./context.ts";
15
+ import { runSidekick } from "./executor.ts";
16
+ import { resolveExecutorModel } from "./models.ts";
17
+ import { modelDisplay } from "./models.ts";
18
+ import { clampMaxToolCalls, isMutatingSelection, selectionLabel } from "./tools.ts";
19
+ import { selectDevinSetup, type DevinSetupState } from "./ui.ts";
20
+ import { PLANNER_FORCE_PROMPT_PREFIX } from "./prompts.ts";
21
+ import { SIDEKICK_SYSTEM_PROMPT } from "./prompts.ts";
22
+ import type { DevinConfig, DevinMode, FooterDisplay, SidekickOptions, ToolMode } from "./types.ts";
23
+
24
+ const SidekickParams = Type.Object({
25
+ prompt: Type.String({
26
+ description: "The task or question for the executor. Be specific enough to act on directly.",
27
+ }),
28
+ context_mode: Type.Optional(
29
+ Type.Union([Type.Literal("none"), Type.Literal("recent")], {
30
+ description: "Whether to include recent conversation context for the executor. Default 'none'.",
31
+ default: "none",
32
+ }),
33
+ ),
34
+ context_turns: Type.Optional(
35
+ Type.Integer({
36
+ description: "Number of recent user turns to include when context_mode is 'recent' (1–10). Default 4.",
37
+ minimum: 1,
38
+ maximum: 10,
39
+ default: 4,
40
+ }),
41
+ ),
42
+ });
43
+
44
+ export function normalizeFooterDisplay(value: unknown): FooterDisplay {
45
+ return value === "off" || value === "compact" || value === "full" ? value : "full";
46
+ }
47
+
48
+ function normalizeMode(state: DevinState | undefined): DevinMode {
49
+ if (state?.mode) return state.mode;
50
+ return "available";
51
+ }
52
+
53
+ function isForcePrompt(text: string): boolean {
54
+ return text.startsWith("Delegate the following task to the sidekick executor before answering.");
55
+ }
56
+
57
+ function forceDevinPrompt(prompt: string): string {
58
+ return [
59
+ PLANNER_FORCE_PROMPT_PREFIX,
60
+ "",
61
+ "Delegate the following task to the sidekick executor before answering.",
62
+ "After the sidekick returns, review the result before your final response.",
63
+ "",
64
+ "User task:",
65
+ prompt.trim(),
66
+ ].join("\n");
67
+ }
68
+
69
+ interface DevinState {
70
+ executorId?: string;
71
+ executorAuto?: boolean;
72
+ mode?: DevinMode;
73
+ executorTools?: ToolMode;
74
+ maxToolCalls?: number;
75
+ toolsConsented?: boolean;
76
+ footerDisplay?: FooterDisplay;
77
+ }
78
+
79
+ function persistSessionState(
80
+ pi: ExtensionAPI,
81
+ state: DevinState & { mode: DevinMode },
82
+ ): void {
83
+ pi.appendEntry("devin-state", {
84
+ executorId: state.executorId,
85
+ executorAuto: state.executorAuto ?? false,
86
+ mode: state.mode,
87
+ executorTools: state.executorTools,
88
+ maxToolCalls: state.maxToolCalls,
89
+ toolsConsented: state.toolsConsented ?? false,
90
+ footerDisplay: state.footerDisplay ?? "full",
91
+ timestamp: Date.now(),
92
+ });
93
+ }
94
+
95
+ function restoreSessionState(ctx: ExtensionContext): DevinState | undefined {
96
+ const entries = ctx.sessionManager.getBranch();
97
+ for (let i = entries.length - 1; i >= 0; i--) {
98
+ const entry = entries[i];
99
+ if (entry.type === "custom" && entry.customType === "devin-state" && "data" in entry && entry.data) {
100
+ const data = entry.data as {
101
+ executorId?: string;
102
+ executorAuto?: boolean;
103
+ mode?: DevinMode;
104
+ executorTools?: ToolMode;
105
+ maxToolCalls?: number;
106
+ toolsConsented?: boolean;
107
+ footerDisplay?: FooterDisplay;
108
+ };
109
+ return {
110
+ executorId: data.executorId,
111
+ executorAuto: data.executorAuto ?? false,
112
+ mode: normalizeMode(data),
113
+ executorTools: data.executorTools,
114
+ maxToolCalls: data.maxToolCalls,
115
+ toolsConsented: data.toolsConsented ?? false,
116
+ footerDisplay: normalizeFooterDisplay(data.footerDisplay),
117
+ };
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ function devinFooterText(mode: DevinMode, executorId: string | undefined, display: FooterDisplay): string | undefined {
124
+ if (display === "off") return undefined;
125
+ if (!executorId && mode !== "off") return undefined;
126
+ if (mode === "off") return "Devin off";
127
+ const base = `Devin ${mode} • executor ${executorId ?? "unset"}`;
128
+ return display === "compact" ? base.replace(/ • .*/, " • executor set") : base;
129
+ }
130
+
131
+ function sessionConfigOverrides(state: DevinState | undefined): Pick<DevinConfig, "executor" | "executorTools" | "maxToolCalls" | "footerDisplay"> {
132
+ const overrides: Pick<DevinConfig, "executor" | "executorTools" | "maxToolCalls" | "footerDisplay"> = {};
133
+ if (state?.executorAuto) overrides.executor = "";
134
+ else if (state?.executorId !== undefined) overrides.executor = state.executorId;
135
+ if (state?.executorTools !== undefined) overrides.executorTools = state.executorTools;
136
+ if (state?.maxToolCalls !== undefined) overrides.maxToolCalls = state.maxToolCalls;
137
+ if (state?.footerDisplay !== undefined) overrides.footerDisplay = state.footerDisplay;
138
+ return overrides;
139
+ }
140
+
141
+ function updateStatus(pi: ExtensionAPI, ctx: ExtensionContext, mode: DevinMode, executorId?: string, displayOverride?: FooterDisplay): void {
142
+ const display = normalizeFooterDisplay(displayOverride ?? loadConfig(ctx.cwd, ctx.isProjectTrusted()).footerDisplay);
143
+ const text = devinFooterText(mode, executorId, display);
144
+ if (!text) {
145
+ ctx.ui.setFooter(undefined);
146
+ return;
147
+ }
148
+ ctx.ui.setFooter(() => ({
149
+ dispose() {},
150
+ invalidate() {},
151
+ render: () => [text],
152
+ }));
153
+ }
154
+
155
+ export function buildInitialState(
156
+ ctx: ExtensionContext,
157
+ resolvedExecutorId: string | undefined,
158
+ configExecutorTools?: DevinConfig["executorTools"],
159
+ configMaxToolCalls?: DevinConfig["maxToolCalls"],
160
+ configFooterDisplay?: DevinConfig["footerDisplay"],
161
+ ): DevinSetupState {
162
+ const session = restoreSessionState(ctx);
163
+ const configTools = typeof configExecutorTools === "string" ? configExecutorTools : undefined;
164
+ return {
165
+ executorId: session ? session.executorId : resolvedExecutorId,
166
+ executorAuto: session ? (session.executorAuto ?? false) : !resolvedExecutorId,
167
+ mode: normalizeMode(session),
168
+ executorTools: session?.executorTools ?? configTools ?? "all",
169
+ maxToolCalls: session?.maxToolCalls ?? configMaxToolCalls,
170
+ toolsConsented: session?.toolsConsented ?? false,
171
+ footerDisplay: session?.footerDisplay ?? normalizeFooterDisplay(configFooterDisplay),
172
+ };
173
+ }
174
+
175
+ async function applySetup(pi: ExtensionAPI, ctx: ExtensionContext, state: DevinSetupState): Promise<boolean> {
176
+ const next: DevinState & { mode: DevinMode } = {
177
+ executorId: state.executorId,
178
+ executorAuto: !state.executorId,
179
+ mode: state.mode ?? "available",
180
+ executorTools: state.executorTools,
181
+ maxToolCalls: state.maxToolCalls,
182
+ toolsConsented: state.toolsConsented ?? false,
183
+ footerDisplay: state.footerDisplay,
184
+ };
185
+ const warnings: string[] = [];
186
+ if (isMutatingSelection(next.executorTools) && !next.toolsConsented) {
187
+ const ok = await ctx.ui.confirm(
188
+ "Enable sidekick mutating tools?",
189
+ "The sidekick executor will be able to run bash and edit/write files in this project. Mutating runs are serialized. Continue?",
190
+ );
191
+ if (ok) next.toolsConsented = true;
192
+ else {
193
+ next.executorTools = "readonly";
194
+ next.toolsConsented = false;
195
+ warnings.push("Mutating tools declined; using read-only.");
196
+ }
197
+ }
198
+ if (!isMutatingSelection(next.executorTools)) next.toolsConsented = false;
199
+
200
+ persistSessionState(pi, next);
201
+ const config = applyDefaults(loadConfig(ctx.cwd, ctx.isProjectTrusted()), sessionConfigOverrides(next));
202
+ const resolvedExecutor = resolveExecutorModel(ctx.modelRegistry, ctx.model, config.executor, warnings);
203
+ const activeExecutorId = resolvedExecutor ? modelDisplay(resolvedExecutor) : next.executorId;
204
+ updateStatus(pi, ctx, next.mode, activeExecutorId, next.footerDisplay);
205
+ const executorLabel = next.executorAuto ? "auto" : (next.executorId ?? "auto");
206
+ ctx.ui.notify(
207
+ [
208
+ `Executor: ${executorLabel}`,
209
+ `Tools: ${selectionLabel(next.executorTools)} (max ${clampMaxToolCalls(next.maxToolCalls)})`,
210
+ `Footer: ${normalizeFooterDisplay(next.footerDisplay)}`,
211
+ ...(warnings.length ? [`Warnings: ${warnings.join("; ")}`] : []),
212
+ ].join("\n"),
213
+ "info",
214
+ );
215
+ return true;
216
+ }
217
+
218
+ export default function (pi: ExtensionAPI) {
219
+ pi.registerTool({
220
+ name: "sidekick",
221
+ label: "Sidekick",
222
+ description: [
223
+ "Devin-fusion executor tool. Delegate mechanical implementation, refactors, lint/test fixes,",
224
+ "and read-only exploration to a cheaper executor model. The active model stays the planner/reviewer.",
225
+ "Use it for well-specified, low-judgment work; do not use it for design decisions or ambiguous requirements.",
226
+ ].join(" "),
227
+ promptGuidelines: [
228
+ "Use the sidekick tool for mechanical implementation, multi-file find-and-replace, lint/test fixes, and read-only exploration before code changes.",
229
+ "Hand the sidekick a precise spec: exact files, exact changes, constraints to preserve.",
230
+ "Do not use the sidekick tool for hard features with subtle intent, architecture decisions, or ambiguous requirements — keep those for yourself.",
231
+ ],
232
+ parameters: SidekickParams,
233
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
234
+ const state = restoreSessionState(ctx);
235
+ if (normalizeMode(state) === "off") {
236
+ return {
237
+ content: [{ type: "text", text: JSON.stringify({ status: "error", error: "devin disabled" }, null, 2) }],
238
+ details: { status: "error", error: "devin disabled", failure_reason: "unexpected_error" },
239
+ };
240
+ }
241
+
242
+ const overrides = sessionConfigOverrides(state);
243
+ const config = applyDefaults(loadConfig(ctx.cwd, ctx.isProjectTrusted()), overrides);
244
+ const contextMode = (params.context_mode ?? "none") as DevinContextMode;
245
+ const contextText = contextMode === "recent"
246
+ ? buildRecentContextFromEntries(ctx.sessionManager.getBranch(), normalizeContextTurns(params.context_turns))
247
+ : undefined;
248
+
249
+ // Consent for mutating executor tools.
250
+ const mutatingEnabled = isMutatingSelection(config.executorTools);
251
+ const consented = state?.toolsConsented || config.executorToolsConsent === true;
252
+ let toolsConsented = consented;
253
+ if (mutatingEnabled && !consented) {
254
+ if (ctx.hasUI) {
255
+ const ok = await ctx.ui.confirm(
256
+ "Enable sidekick mutating tools?",
257
+ "The sidekick executor will be able to run bash and edit/write files in this project. Continue?",
258
+ );
259
+ if (!ok) {
260
+ return {
261
+ content: [{
262
+ type: "text",
263
+ text: JSON.stringify({
264
+ status: "error",
265
+ error: "executor mutating tools require consent",
266
+ failure_reason: "mutation_consent_required",
267
+ }, null, 2),
268
+ }],
269
+ details: { status: "error", error: "executor mutating tools require consent", failure_reason: "mutation_consent_required" },
270
+ };
271
+ }
272
+ toolsConsented = true;
273
+ persistSessionState(pi, {
274
+ executorId: state?.executorId ?? (state?.executorAuto ? undefined : config.executor),
275
+ executorAuto: state?.executorAuto ?? false,
276
+ mode: normalizeMode(state),
277
+ executorTools: state?.executorTools ?? (typeof config.executorTools === "string" ? config.executorTools : undefined),
278
+ maxToolCalls: state?.maxToolCalls ?? config.maxToolCalls,
279
+ toolsConsented: true,
280
+ footerDisplay: state?.footerDisplay ?? normalizeFooterDisplay(config.footerDisplay),
281
+ });
282
+ } else if (!config.executorToolsConsent) {
283
+ return {
284
+ content: [{
285
+ type: "text",
286
+ text: JSON.stringify({
287
+ status: "error",
288
+ error: "executor mutating tools require consent",
289
+ failure_reason: "mutation_consent_required",
290
+ }, null, 2),
291
+ }],
292
+ details: { status: "error", error: "executor mutating tools require consent", failure_reason: "mutation_consent_required" },
293
+ };
294
+ }
295
+ }
296
+
297
+ const options: SidekickOptions = { ...overrides, context_text: contextText };
298
+ return runSidekick(
299
+ ctx.cwd,
300
+ ctx.modelRegistry,
301
+ ctx.model,
302
+ params.prompt,
303
+ ctx.isProjectTrusted(),
304
+ options,
305
+ ctx,
306
+ toolsConsented,
307
+ signal,
308
+ onUpdate,
309
+ );
310
+ },
311
+ });
312
+
313
+ pi.registerCommand("devin", {
314
+ description: "Set Devin mode: /devin on | available | off (no arg toggles available/forced; /devin <prompt> forces once)",
315
+ getArgumentCompletions: (prefix: string) => {
316
+ const items = [
317
+ { value: "on", label: "on", description: "Force every prompt through the planner/sidekick split" },
318
+ { value: "available", label: "available", description: "Let the model decide when to delegate" },
319
+ { value: "off", label: "off", description: "Disable the sidekick tool for this session" },
320
+ ];
321
+ const filtered = items.filter((i) => i.value.startsWith(prefix.trim().toLowerCase()));
322
+ return filtered.length > 0 ? filtered : null;
323
+ },
324
+ handler: async (args, ctx) => {
325
+ const prompt = args.trim();
326
+ const state = restoreSessionState(ctx);
327
+ const lower = prompt.toLowerCase();
328
+ const modeCommand: DevinMode | undefined =
329
+ lower === "off" || lower === "disable" || lower === "disabled"
330
+ ? "off"
331
+ : lower === "available" || lower === "auto"
332
+ ? "available"
333
+ : lower === "forced" || lower === "force" || lower === "on"
334
+ ? "forced"
335
+ : undefined;
336
+
337
+ // Resolve the active executor for readiness display (session setup, config, or auto).
338
+ const config = applyDefaults(loadConfig(ctx.cwd, ctx.isProjectTrusted()), sessionConfigOverrides(state));
339
+ const warnings: string[] = [];
340
+ const resolvedExecutor = resolveExecutorModel(ctx.modelRegistry, ctx.model, config.executor, warnings);
341
+ const activeExecutorId = resolvedExecutor ? modelDisplay(resolvedExecutor) : config.executor || undefined;
342
+
343
+ if (!prompt || modeCommand) {
344
+ if (!activeExecutorId && (modeCommand === "forced" || (!prompt && !modeCommand))) {
345
+ const message = "No devin setup yet. Run /devin-setup, /devin-init, or set an executor in .pi/devin.json.";
346
+ if (ctx.mode === "print") console.log(message);
347
+ else ctx.ui.notify(message, "warning");
348
+ return;
349
+ }
350
+ const currentMode = normalizeMode(state);
351
+ const nextMode = modeCommand ?? (currentMode === "forced" ? "available" : "forced");
352
+ persistSessionState(pi, {
353
+ executorId: state?.executorAuto ? undefined : activeExecutorId,
354
+ executorAuto: state?.executorAuto ?? false,
355
+ mode: nextMode,
356
+ executorTools: state?.executorTools,
357
+ maxToolCalls: state?.maxToolCalls,
358
+ toolsConsented: state?.toolsConsented,
359
+ footerDisplay: state?.footerDisplay,
360
+ });
361
+ updateStatus(pi, ctx, nextMode, activeExecutorId, normalizeFooterDisplay(config.footerDisplay));
362
+ const summary = devinFooterText(nextMode, activeExecutorId, normalizeFooterDisplay(config.footerDisplay)) ?? modeLabel(nextMode);
363
+ if (ctx.mode === "print") console.log(summary);
364
+ else ctx.ui.notify(summary, "info");
365
+ return;
366
+ }
367
+
368
+ if (normalizeMode(state) === "off") {
369
+ const message = "Devin is off. Use /devin available or /devin forced before using /devin <prompt>.";
370
+ if (ctx.mode === "print") console.log(message);
371
+ else ctx.ui.notify(message, "warning");
372
+ return;
373
+ }
374
+
375
+ if (ctx.mode === "print") {
376
+ console.log(forceDevinPrompt(prompt));
377
+ return;
378
+ }
379
+ pi.sendUserMessage(forceDevinPrompt(prompt));
380
+ },
381
+ });
382
+
383
+ pi.registerCommand("devin-setup", {
384
+ description: "Open the Devin sidekick setup UI",
385
+ handler: async (_args, ctx) => {
386
+ if (!ctx.hasUI) {
387
+ ctx.ui.notify("devin-setup requires interactive mode", "error");
388
+ return;
389
+ }
390
+
391
+ const available = ctx.modelRegistry.getAvailable().filter((m) => m.input.includes("text"));
392
+ if (available.length === 0) {
393
+ ctx.ui.notify("No authed text models available.", "error");
394
+ return;
395
+ }
396
+
397
+ const state = restoreSessionState(ctx);
398
+ const config = loadConfig(ctx.cwd, ctx.isProjectTrusted());
399
+ const resolvedExecutor = resolveExecutorModel(
400
+ ctx.modelRegistry,
401
+ ctx.model,
402
+ state?.executorAuto ? undefined : (state?.executorId ?? config.executor),
403
+ [],
404
+ );
405
+ const initial = buildInitialState(
406
+ ctx,
407
+ resolvedExecutor ? modelDisplay(resolvedExecutor) : undefined,
408
+ config.executorTools,
409
+ config.maxToolCalls,
410
+ config.footerDisplay,
411
+ );
412
+
413
+ const next = await selectDevinSetup(ctx, available, initial);
414
+ if (!next) {
415
+ ctx.ui.notify("Devin setup cancelled", "info");
416
+ return;
417
+ }
418
+
419
+ if (!(await applySetup(pi, ctx, next))) return;
420
+ },
421
+ });
422
+
423
+ pi.registerCommand("devin-init", {
424
+ description: "Create a project-local .pi/devin.json template",
425
+ handler: async (_args, ctx) => {
426
+ if (!ctx.isProjectTrusted()) {
427
+ ctx.ui.notify("Project is not trusted; cannot write project-local config", "error");
428
+ return;
429
+ }
430
+ const configDir = join(ctx.cwd, ".pi");
431
+ const configPath = join(configDir, "devin.json");
432
+ let example = generateConfigExample();
433
+ try {
434
+ const executor = resolveExecutorModel(ctx.modelRegistry, ctx.model, loadConfig(ctx.cwd, ctx.isProjectTrusted()).executor, []);
435
+ if (executor) example = generateConfigExample(modelDisplay(executor));
436
+ } catch {
437
+ // no authed text model; write template without executor
438
+ }
439
+ if (existsSync(configPath)) {
440
+ const overwrite = await ctx.ui.confirm(".pi/devin.json already exists", `Overwrite ${configPath} with the template?`);
441
+ if (!overwrite) {
442
+ ctx.ui.notify("devin-init cancelled", "info");
443
+ return;
444
+ }
445
+ }
446
+ mkdirSync(configDir, { recursive: true });
447
+ writeFileSync(configPath, JSON.stringify(example, null, 2) + "\n", "utf8");
448
+ ctx.ui.notify(`Wrote ${configPath}`, "info");
449
+ },
450
+ });
451
+
452
+ pi.registerCommand("devin-status", {
453
+ description: "Show the current Devin mode and executor",
454
+ handler: async (_args, ctx) => {
455
+ const state = restoreSessionState(ctx);
456
+ const config = loadConfig(ctx.cwd, ctx.isProjectTrusted());
457
+ const mode = normalizeMode(state);
458
+ const executorId = state?.executorId ?? config.executor ?? "auto";
459
+ const tools = typeof state?.executorTools === "string" ? state.executorTools : config.executorTools ?? "all";
460
+ const footer = normalizeFooterDisplay(state?.footerDisplay ?? config.footerDisplay);
461
+ const maxCalls = clampMaxToolCalls(state?.maxToolCalls ?? config.maxToolCalls);
462
+ const lines = [
463
+ `Mode: ${mode}`,
464
+ `Executor: ${executorId}`,
465
+ `Tools: ${selectionLabel(tools)} (max ${maxCalls})`,
466
+ `Consent: ${state?.toolsConsented || config.executorToolsConsent ? "granted" : "not granted"}`,
467
+ `Footer: ${footer}`,
468
+ ];
469
+ const text = lines.join("\n");
470
+ if (ctx.mode === "print") console.log(text);
471
+ else ctx.ui.notify(text, "info");
472
+ },
473
+ });
474
+
475
+ pi.on("tool_call", async (event, ctx) => {
476
+ if (event.toolName !== "sidekick") return;
477
+ const state = restoreSessionState(ctx);
478
+ if (normalizeMode(state) === "off") {
479
+ return { block: true, reason: "Devin is off for this session. Use /devin available or /devin forced to re-enable it." };
480
+ }
481
+ });
482
+
483
+ pi.on("input", async (event, ctx) => {
484
+ if (event.source === "extension") return { action: "continue" };
485
+ if (event.text.trim().startsWith("/")) return { action: "continue" };
486
+ if (isForcePrompt(event.text.trim())) return { action: "continue" };
487
+ const state = restoreSessionState(ctx);
488
+ if (normalizeMode(state) !== "forced") return { action: "continue" };
489
+ return { action: "transform", text: forceDevinPrompt(event.text), images: event.images };
490
+ });
491
+
492
+ // Refresh the footer whenever the session/model changes.
493
+ const refreshFooter = (ctx: ExtensionContext) => {
494
+ const state = restoreSessionState(ctx);
495
+ const mode = normalizeMode(state);
496
+ if (mode === "off" || state?.executorId) {
497
+ updateStatus(pi, ctx, mode, state?.executorId, state?.footerDisplay);
498
+ }
499
+ };
500
+ pi.on("session_start", async (_event, ctx) => refreshFooter(ctx));
501
+ pi.on("session_tree", async (_event, ctx) => refreshFooter(ctx));
502
+ pi.on("model_select", async (_event, ctx) => refreshFooter(ctx));
503
+ }
504
+
505
+ function modeLabel(mode: DevinMode): string {
506
+ if (mode === "forced") return "Devin forced";
507
+ if (mode === "off") return "Devin off";
508
+ return "Devin available";
509
+ }