@buyong/pi-devin 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +214 -2
- package/index.ts +16 -0
- package/package.json +62 -3
- package/proto/README.md +78 -0
- package/proto/buf/validate/validate.proto +453 -0
- package/proto/cel/expr/checked.proto +109 -0
- package/proto/cel/expr/eval.proto +44 -0
- package/proto/cel/expr/explain.proto +22 -0
- package/proto/cel/expr/syntax.proto +119 -0
- package/proto/cel/expr/value.proto +47 -0
- package/proto/connectext/grpc/status/v1/status.proto +21 -0
- package/proto/errorspb/errors.proto +58 -0
- package/proto/errorspb/hintdetail.proto +9 -0
- package/proto/errorspb/markers.proto +12 -0
- package/proto/errorspb/tags.proto +14 -0
- package/proto/errorspb/testing.proto +8 -0
- package/proto/exa/analytics_pb/analytics.proto +190 -0
- package/proto/exa/api_server_pb/api_server.proto +2590 -0
- package/proto/exa/auth_pb/auth.proto +21 -0
- package/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +81 -0
- package/proto/exa/browser_preview_pb/browser_preview.proto +26 -0
- package/proto/exa/bug_checker_pb/bug_checker.proto +24 -0
- package/proto/exa/cascade_plugins_pb/cascade_plugins.proto +264 -0
- package/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
- package/proto/exa/chat_pb/chat.proto +452 -0
- package/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
- package/proto/exa/codeium_common_pb/browser_context.proto +31 -0
- package/proto/exa/codeium_common_pb/codeium_common.proto +4183 -0
- package/proto/exa/context_module_pb/context_module.proto +174 -0
- package/proto/exa/cortex_pb/cortex.proto +3288 -0
- package/proto/exa/dev_pb/dev.proto +28 -0
- package/proto/exa/diff_action_pb/diff_action.proto +76 -0
- package/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +106 -0
- package/proto/exa/eval_pb/eval.proto +1318 -0
- package/proto/exa/extension_server_pb/extension_server.proto +555 -0
- package/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
- package/proto/exa/index_pb/index.proto +469 -0
- package/proto/exa/knowledge_base_pb/knowledge_base.proto +146 -0
- package/proto/exa/language_server_pb/language_server.proto +2435 -0
- package/proto/exa/model_management_pb/model_management.proto +184 -0
- package/proto/exa/opensearch_clients_pb/opensearch_clients.proto +495 -0
- package/proto/exa/product_analytics_pb/product_analytics.proto +39 -0
- package/proto/exa/prompt_pb/prompt.proto +97 -0
- package/proto/exa/reactive_component_pb/reactive_component.proto +98 -0
- package/proto/exa/seat_management_pb/seat_management.proto +2910 -0
- package/proto/exa/tokenizer_pb/tokenizer.proto +39 -0
- package/proto/exa/trainer_pb/config.proto +643 -0
- package/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
- package/proto/exa/trust_pb/trust.proto +192 -0
- package/proto/exa/user_analytics_pb/user_analytics.proto +752 -0
- package/proto/google/api/expr/v1alpha1/checked.proto +108 -0
- package/proto/google/api/expr/v1alpha1/eval.proto +38 -0
- package/proto/google/api/expr/v1alpha1/explain.proto +22 -0
- package/proto/google/api/expr/v1alpha1/syntax.proto +126 -0
- package/proto/google/api/expr/v1alpha1/value.proto +47 -0
- package/proto/google/protobuf/any.proto +15 -0
- package/proto/google/protobuf/cpp_features.proto +22 -0
- package/proto/google/protobuf/descriptor.proto +473 -0
- package/proto/google/protobuf/duration.proto +16 -0
- package/proto/google/protobuf/empty.proto +14 -0
- package/proto/google/protobuf/go_features.proto +31 -0
- package/proto/google/protobuf/struct.proto +34 -0
- package/proto/google/protobuf/timestamp.proto +16 -0
- package/proto/google/protobuf/wrappers.proto +47 -0
- package/proto/google/rpc/status.proto +18 -0
- package/proto/grpc/binlog/v1/binarylog.proto +89 -0
- package/proto/io/prometheus/client/metrics.proto +103 -0
- package/src/core/auth/access.ts +6 -0
- package/src/core/auth/index.ts +2 -0
- package/src/core/auth/login.ts +177 -0
- package/src/core/auth/rest-exchange.ts +35 -0
- package/src/core/client.ts +153 -0
- package/src/core/index.ts +12 -0
- package/src/core/inference/chat.ts +229 -0
- package/src/core/inference/images.ts +24 -0
- package/src/core/inference/messages.ts +34 -0
- package/src/core/inference/retry.ts +68 -0
- package/src/core/inference/state.ts +8 -0
- package/src/core/models/access.ts +7 -0
- package/src/core/models/catalog.ts +35 -0
- package/src/core/models/index.ts +4 -0
- package/src/core/models/select.ts +43 -0
- package/src/core/models/variants.ts +163 -0
- package/src/core/protocol/device.ts +73 -0
- package/src/core/protocol/fingerprint.ts +59 -0
- package/src/core/protocol/http.ts +109 -0
- package/src/core/protocol/protobuf.ts +112 -0
- package/src/core/protocol/transport.ts +501 -0
- package/src/core/types.ts +115 -0
- package/src/pi/auth.ts +134 -0
- package/src/pi/devin-command.ts +76 -0
- package/src/pi/devin-panel.ts +77 -0
- package/src/pi/devin-settings.ts +50 -0
- package/src/pi/extension.ts +80 -0
- package/src/pi/fusion-command.ts +83 -0
- package/src/pi/fusion-picker.ts +171 -0
- package/src/pi/fusion-settings.ts +44 -0
- package/src/pi/lifecycle.ts +217 -0
- package/src/pi/messages.ts +107 -0
- package/src/pi/models.ts +378 -0
- package/src/pi/provider.ts +241 -0
- package/src/pi/sidekick-report.ts +42 -0
- package/src/pi/sidekick-runtime.ts +287 -0
- package/src/pi/sidekick.ts +190 -0
- package/src/pi/stream.ts +124 -0
- package/src/pi/usage.ts +153 -0
package/src/pi/auth.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
|
|
3
|
+
import { loginAccount, type LoginOptions } from "../core/auth/index.ts";
|
|
4
|
+
import { DevinClient } from "../core/client.ts";
|
|
5
|
+
import { awaitWithSignal, endpoint, isAuthenticationError, RPCError } from "../core/protocol/transport.ts";
|
|
6
|
+
import { object, requiredString, type Credential, type JsonObject } from "../core/types.ts";
|
|
7
|
+
|
|
8
|
+
export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
|
|
9
|
+
|
|
10
|
+
export interface DevinAccountAuth extends OAuthAuth {
|
|
11
|
+
readonly generation: number;
|
|
12
|
+
assertUsable(credential: Credential): void;
|
|
13
|
+
/** Converts the OAuth credential stored by Pi into the shared credential and remembers it as this process's OAuth session. */
|
|
14
|
+
credentialFromHost(credential: OAuthCredential): Credential;
|
|
15
|
+
/** Rejects sessions that did not come from an OAuth login or stored credential (API keys etc.) before any network request. */
|
|
16
|
+
assertOAuthSession(credential: Credential): void;
|
|
17
|
+
/** Remembers only authentication failures confirmed in the same auth generation. The credential file is never deleted. */
|
|
18
|
+
invalidate(credential: Credential, error: unknown, generation: number): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function credentialFromOAuth(credential: OAuthCredential): Credential {
|
|
22
|
+
if (credential.type !== "oauth" || credential.refresh !== "" || credential.expires !== 0) {
|
|
23
|
+
throw new Error("Unsupported Devin account credential. Sign in again with /login.");
|
|
24
|
+
}
|
|
25
|
+
const metadata = object(credential.devin, "Devin credential metadata");
|
|
26
|
+
if (metadata.version !== 1) throw new Error("The Devin account credential version differs. Sign in again with /login.");
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
sessionToken: requiredString(credential.access, "Devin credential access"),
|
|
30
|
+
apiServerURL: endpoint(requiredString(metadata.apiServerURL, "Devin credential API server")),
|
|
31
|
+
createdAt: requiredString(metadata.createdAt, "Devin credential creation time"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function credentialKey(credential: Credential): string {
|
|
36
|
+
return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Endpoint mismatches are rejected separately by the model list scope. This only checks that the session came from OAuth. */
|
|
40
|
+
function sessionKey(credential: Credential): string {
|
|
41
|
+
return createHash("sha256").update(credential.sessionToken).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDevinAccountAuth(options: {
|
|
45
|
+
loginOptions?: Omit<LoginOptions, "onURL" | "promptCode" | "signal" | "flow" | "encoding">;
|
|
46
|
+
createClient?: (credential: Credential) => DevinClient;
|
|
47
|
+
onLogin?: () => void;
|
|
48
|
+
onValidated?: (credential: Credential, ownerHash: string, status: JsonObject) => void;
|
|
49
|
+
onInvalidated?: (credential: Credential, error: unknown) => void;
|
|
50
|
+
getSignal?: () => AbortSignal;
|
|
51
|
+
} = {}): DevinAccountAuth {
|
|
52
|
+
const invalid = new Set<string>();
|
|
53
|
+
// Remembers only a hash, never the token. Pi calls toAuth on every request, so the session in use moves back to the front.
|
|
54
|
+
const oauthSessions = new Set<string>();
|
|
55
|
+
let generation = 0;
|
|
56
|
+
const requireUsable = (credential: Credential) => {
|
|
57
|
+
if (invalid.has(credentialKey(credential))) throw new RPCError("unauthenticated", "Authentication failed for this session. Sign in again with /login.");
|
|
58
|
+
};
|
|
59
|
+
const rememberOAuth = (credential: Credential) => {
|
|
60
|
+
const key = sessionKey(credential);
|
|
61
|
+
oauthSessions.delete(key);
|
|
62
|
+
if (oauthSessions.size >= 64) oauthSessions.delete(oauthSessions.values().next().value!);
|
|
63
|
+
oauthSessions.add(key);
|
|
64
|
+
return credential;
|
|
65
|
+
};
|
|
66
|
+
const auth: DevinAccountAuth = {
|
|
67
|
+
name: "Devin account",
|
|
68
|
+
get generation() { return generation; },
|
|
69
|
+
assertUsable: requireUsable,
|
|
70
|
+
credentialFromHost: (current) => rememberOAuth(credentialFromOAuth(current)),
|
|
71
|
+
assertOAuthSession(credential) {
|
|
72
|
+
// Kept as a plain error so it does not invalidate authentication or cancel a running request.
|
|
73
|
+
if (!oauthSessions.has(sessionKey(credential))) throw new Error(OAUTH_ONLY_MESSAGE);
|
|
74
|
+
},
|
|
75
|
+
async login(interaction) {
|
|
76
|
+
const lifetime = options.getSignal?.();
|
|
77
|
+
const signal = AbortSignal.any([interaction.signal, ...(lifetime ? [lifetime] : [])]);
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
const choice = await awaitWithSignal(interaction.prompt({ type: "select", signal, message: "Devin account sign-in method", options: [
|
|
80
|
+
{ id: "browser", label: "Browser sign-in", description: "Choose an account, then return through a loopback callback." },
|
|
81
|
+
{ id: "manual", label: "Manual authorization code", description: "Enter a one-time code using the Devin CLI manual flow. This is not an API key prompt." },
|
|
82
|
+
] }), signal);
|
|
83
|
+
if (choice !== "browser" && choice !== "manual") throw new Error("Unsupported Devin sign-in method.");
|
|
84
|
+
const credential = await loginAccount({
|
|
85
|
+
...options.loginOptions, flow: choice, signal,
|
|
86
|
+
onURL: (url) => interaction.notify({ type: "auth_url", url, instructions: "Return to the terminal after signing in. Signing in does not guarantee CLI access or quota." }),
|
|
87
|
+
promptCode: (signal) => interaction.prompt({ type: "manual_code", message: "Paste the authorization code or callback URL. Do not enter an API key or session token.", signal }),
|
|
88
|
+
});
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
invalid.delete(credentialKey(credential));
|
|
91
|
+
rememberOAuth(credential);
|
|
92
|
+
generation++;
|
|
93
|
+
options.onLogin?.();
|
|
94
|
+
interaction.notify({ type: "info", message: "Signed in to the Devin account. The server expiry time and refresh grant are unknown. If authentication fails, sign in again with /login." });
|
|
95
|
+
return {
|
|
96
|
+
type: "oauth", access: credential.sessionToken, refresh: "", expires: 0,
|
|
97
|
+
devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
async refresh(current, callerSignal) {
|
|
101
|
+
const lifetime = options.getSignal?.();
|
|
102
|
+
const signal = AbortSignal.any([callerSignal, ...(lifetime ? [lifetime] : [])]);
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
const credential = credentialFromOAuth(current);
|
|
105
|
+
requireUsable(credential);
|
|
106
|
+
rememberOAuth(credential);
|
|
107
|
+
const expectedGeneration = generation;
|
|
108
|
+
const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
|
|
109
|
+
try {
|
|
110
|
+
// 0 is not a server TTL. Pi's refresh entry point only checks the existing session; it never renews the token.
|
|
111
|
+
const status = await client.status(signal);
|
|
112
|
+
if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
|
|
113
|
+
return current;
|
|
114
|
+
} catch (error) { auth.invalidate(credential, error, expectedGeneration); throw error; }
|
|
115
|
+
finally { client.transport.close(); }
|
|
116
|
+
},
|
|
117
|
+
async toAuth(current) {
|
|
118
|
+
const credential = credentialFromOAuth(current);
|
|
119
|
+
requireUsable(credential);
|
|
120
|
+
rememberOAuth(credential);
|
|
121
|
+
// apiKey is only ModelAuth's transport field; the sign-in/storage auth type is always oauth.
|
|
122
|
+
return { apiKey: credential.sessionToken, baseUrl: credential.apiServerURL };
|
|
123
|
+
},
|
|
124
|
+
invalidate(credential, error, expectedGeneration) {
|
|
125
|
+
if (expectedGeneration !== generation || !isAuthenticationError(error)) return;
|
|
126
|
+
const key = credentialKey(credential);
|
|
127
|
+
if (invalid.has(key)) return;
|
|
128
|
+
if (invalid.size >= 64) invalid.delete(invalid.values().next().value!);
|
|
129
|
+
invalid.add(key);
|
|
130
|
+
options.onInvalidated?.(credential, error);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return auth;
|
|
134
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DevinPanel, PREFERENCE_ITEMS } from "./devin-panel.ts";
|
|
3
|
+
import type { DevinPreferences, DevinSettings } from "./devin-settings.ts";
|
|
4
|
+
import type { DevinProvider } from "./provider.ts";
|
|
5
|
+
import { sessionUsage, usageReport, usageStatusText } from "./usage.ts";
|
|
6
|
+
|
|
7
|
+
export const DEVIN_COMMAND_NAME = "devin";
|
|
8
|
+
/** The only status key this package uses. */
|
|
9
|
+
export const USAGE_STATUS_KEY = "devin-usage";
|
|
10
|
+
const USAGE = "Usage: /devin, /devin recommended on|off, /devin status on|off";
|
|
11
|
+
const ARGUMENT_KEYS: Readonly<Record<string, keyof DevinPreferences>> = { recommended: "recommendedOnly", status: "showUsageStatus" };
|
|
12
|
+
|
|
13
|
+
/** The status is used in the TUI only. Turning the toggle off clears only this package's key. Values come only from the last account status and session records. */
|
|
14
|
+
export function updateUsageStatus(ctx: ExtensionContext, provider: DevinProvider, settings: DevinSettings): void {
|
|
15
|
+
if (ctx.mode !== "tui") return;
|
|
16
|
+
const text = settings.read().showUsageStatus
|
|
17
|
+
? usageStatusText(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), (tone, segment) => ctx.ui.theme.fg(tone, segment)) : undefined;
|
|
18
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const onOff = (value: boolean) => value ? "on" : "off";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `/devin` shows usage details and display settings. Settings are saved globally and applied immediately.
|
|
25
|
+
* The TUI gets a dedicated screen; other modes use arguments and notifications.
|
|
26
|
+
*/
|
|
27
|
+
export function registerDevinCommand(pi: ExtensionAPI, provider: DevinProvider, settings: DevinSettings, options: { now?: () => number } = {}): void {
|
|
28
|
+
const now = options.now ?? Date.now;
|
|
29
|
+
const apply = async (ctx: ExtensionCommandContext, next: DevinPreferences) => {
|
|
30
|
+
const previous = settings.read();
|
|
31
|
+
// Only write failures are thrown to the caller. A list refresh failure after saving is reported as a warning so the screen and the saved value stay in sync.
|
|
32
|
+
settings.write(next);
|
|
33
|
+
if (previous.recommendedOnly !== next.recommendedOnly) {
|
|
34
|
+
provider.rebuildModels();
|
|
35
|
+
// Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
|
|
36
|
+
try {
|
|
37
|
+
await ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
ctx.ui.notify(`Settings were saved, but the model list could not be rebuilt: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
40
|
+
}
|
|
41
|
+
const model = ctx.model;
|
|
42
|
+
if (model?.provider === "devin" && !provider.getModels().some((item) => item.id === model.id)) {
|
|
43
|
+
ctx.ui.notify(`The current model ${model.name} is now hidden from the list but stays in use for this session. To restore it when reopening the session, pick a model from the list.`, "warning");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
updateUsageStatus(ctx, provider, settings);
|
|
47
|
+
};
|
|
48
|
+
pi.registerCommand(DEVIN_COMMAND_NAME, {
|
|
49
|
+
description: "Show Devin usage and change display settings (recommended models only, usage in the status line); saved globally",
|
|
50
|
+
handler: async (args, ctx) => {
|
|
51
|
+
const report = () => usageReport(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), provider.lifecycle.budget(), now());
|
|
52
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
53
|
+
if (tokens.length) {
|
|
54
|
+
const key = ARGUMENT_KEYS[tokens[0]];
|
|
55
|
+
if (tokens.length !== 2 || !key || (tokens[1] !== "on" && tokens[1] !== "off")) {
|
|
56
|
+
ctx.ui.notify(`Unknown argument. ${USAGE}`, "error");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const value = tokens[1] === "on";
|
|
60
|
+
await apply(ctx, { ...settings.read(), [key]: value });
|
|
61
|
+
ctx.ui.notify(`${PREFERENCE_ITEMS.find((item) => item.key === key)!.label}: ${onOff(value)} (saved globally)`, "info");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (ctx.mode === "tui") {
|
|
65
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new DevinPanel({
|
|
66
|
+
theme, report, preferences: settings.read(), onChange: (next) => apply(ctx, next),
|
|
67
|
+
requestRender: () => tui.requestRender(), done: () => done(),
|
|
68
|
+
}));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const preferences = settings.read();
|
|
72
|
+
const summary = PREFERENCE_ITEMS.map((item) => `${item.label} ${onOff(preferences[item.key])}`).join(" · ");
|
|
73
|
+
ctx.ui.notify([...report().map((line) => line.text), `Settings: ${summary}`, USAGE].join("\n"), "info");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { DevinPreferences } from "./devin-settings.ts";
|
|
3
|
+
import type { PickerTheme } from "./fusion-picker.ts";
|
|
4
|
+
import type { UsageLine } from "./usage.ts";
|
|
5
|
+
|
|
6
|
+
export const PREFERENCE_ITEMS: readonly { key: keyof DevinPreferences; label: string; detail: string }[] = [
|
|
7
|
+
{ key: "recommendedOnly", label: "Recommended models only", detail: "/model shows only recommended variants and Devin Fusion" },
|
|
8
|
+
{ key: "showUsageStatus", label: "Usage in status line", detail: "shows quota and session tokens in the footer" },
|
|
9
|
+
];
|
|
10
|
+
const HELP = "↑↓ move · space/↵ toggle · esc close";
|
|
11
|
+
|
|
12
|
+
export interface DevinPanelOptions {
|
|
13
|
+
theme: PickerTheme;
|
|
14
|
+
report: () => UsageLine[];
|
|
15
|
+
preferences: DevinPreferences;
|
|
16
|
+
/** Saves and applies on every toggle. On failure, shows a notice and reverts to the previous value. */
|
|
17
|
+
onChange: (next: DevinPreferences) => Promise<void>;
|
|
18
|
+
requestRender: () => void;
|
|
19
|
+
done: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** TUI screen for `/devin`. Shows usage details together with the display setting toggles. */
|
|
23
|
+
export class DevinPanel {
|
|
24
|
+
private cursor = 0;
|
|
25
|
+
private preferences: DevinPreferences;
|
|
26
|
+
private notice: string | undefined;
|
|
27
|
+
private isSaving = false;
|
|
28
|
+
private cached: { width: number; lines: string[] } | undefined;
|
|
29
|
+
private readonly options: DevinPanelOptions;
|
|
30
|
+
|
|
31
|
+
constructor(options: DevinPanelOptions) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.preferences = { ...options.preferences };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
invalidate(): void { this.cached = undefined; }
|
|
37
|
+
|
|
38
|
+
private changed(): void { this.invalidate(); this.options.requestRender(); }
|
|
39
|
+
|
|
40
|
+
handleInput(data: string): void {
|
|
41
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.options.done();
|
|
42
|
+
if (matchesKey(data, "up")) { this.cursor = (this.cursor + PREFERENCE_ITEMS.length - 1) % PREFERENCE_ITEMS.length; return this.changed(); }
|
|
43
|
+
if (matchesKey(data, "down")) { this.cursor = (this.cursor + 1) % PREFERENCE_ITEMS.length; return this.changed(); }
|
|
44
|
+
if ((matchesKey(data, "space") || matchesKey(data, "enter")) && !this.isSaving) void this.toggle();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async toggle(): Promise<void> {
|
|
48
|
+
const key = PREFERENCE_ITEMS[this.cursor].key, previous = this.preferences;
|
|
49
|
+
this.preferences = { ...previous, [key]: !previous[key] };
|
|
50
|
+
this.isSaving = true; this.notice = undefined; this.changed();
|
|
51
|
+
try {
|
|
52
|
+
await this.options.onChange(this.preferences);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.preferences = previous;
|
|
55
|
+
this.notice = `Could not save: ${error instanceof Error ? error.message : String(error)}`;
|
|
56
|
+
} finally {
|
|
57
|
+
this.isSaving = false; this.changed();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
if (this.cached?.width === width) return this.cached.lines;
|
|
63
|
+
const { theme } = this.options;
|
|
64
|
+
const lines = [theme.fg("accent", theme.bold("Devin")) + theme.fg("muted", " · usage and display settings"), ""];
|
|
65
|
+
for (const line of this.options.report()) lines.push(theme.fg(line.isHeading ? "accent" : line.tone ?? "muted", line.text));
|
|
66
|
+
lines.push("", theme.fg("accent", theme.bold("Settings")));
|
|
67
|
+
PREFERENCE_ITEMS.forEach((item, index) => {
|
|
68
|
+
const isSelected = index === this.cursor;
|
|
69
|
+
const row = `${isSelected ? "›" : " "} [${this.preferences[item.key] ? "x" : " "}] ${item.label}`;
|
|
70
|
+
lines.push((isSelected ? theme.fg("accent", theme.bold(row)) : row) + theme.fg("dim", ` — ${item.detail}`));
|
|
71
|
+
});
|
|
72
|
+
lines.push("", this.notice ? theme.fg("warning", this.notice) : theme.fg("dim", this.isSaving ? "Saving…" : HELP));
|
|
73
|
+
const result = lines.map((line) => truncateToWidth(line, width));
|
|
74
|
+
this.cached = { width, lines: result };
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/** Display settings toggled by `/devin`. Both default to off and never change how models are used or requested. */
|
|
7
|
+
export interface DevinPreferences {
|
|
8
|
+
/** Lists only the variants that the catalog marks as recommended. */
|
|
9
|
+
recommendedOnly: boolean;
|
|
10
|
+
/** Shows quota and session usage in the TUI status line. */
|
|
11
|
+
showUsageStatus: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PREFERENCES: DevinPreferences = { recommendedOnly: false, showUsageStatus: false };
|
|
15
|
+
|
|
16
|
+
export interface DevinSettings {
|
|
17
|
+
read(): DevinPreferences;
|
|
18
|
+
write(preferences: DevinPreferences): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function devinSettingsPath(agentDir: string): string {
|
|
22
|
+
return join(agentDir, "pi-devin", "settings.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Default store so SDK callers and tests never write to the real agent directory. */
|
|
26
|
+
export function memoryDevinSettings(initial: Partial<DevinPreferences> = {}): DevinSettings {
|
|
27
|
+
let current: DevinPreferences = { ...DEFAULT_PREFERENCES, ...initial };
|
|
28
|
+
return { read: () => ({ ...current }), write: (preferences) => { current = { ...preferences }; } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An unreadable or malformed file reads as the defaults and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
32
|
+
export function fileDevinSettings(path: string): DevinSettings {
|
|
33
|
+
return {
|
|
34
|
+
read() {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
37
|
+
if (value.schemaVersion === SCHEMA_VERSION && typeof value.recommendedOnly === "boolean" && typeof value.showUsageStatus === "boolean") {
|
|
38
|
+
return { recommendedOnly: value.recommendedOnly, showUsageStatus: value.showUsageStatus };
|
|
39
|
+
}
|
|
40
|
+
} catch { /* A missing or corrupt file falls back to the defaults. */ }
|
|
41
|
+
return { ...DEFAULT_PREFERENCES };
|
|
42
|
+
},
|
|
43
|
+
write(preferences) {
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
45
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...preferences }, null, 2) + "\n");
|
|
47
|
+
renameSync(temporary, path);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createDevinProvider, type DevinProvider } from "./provider.ts";
|
|
4
|
+
import { DEVIN_STATE_ENTRY } from "./lifecycle.ts";
|
|
5
|
+
import { registerDevinCommand, updateUsageStatus } from "./devin-command.ts";
|
|
6
|
+
import { memoryDevinSettings, type DevinSettings } from "./devin-settings.ts";
|
|
7
|
+
import { registerFusionCommand } from "./fusion-command.ts";
|
|
8
|
+
import { memoryFusionSettings, type FusionSettings } from "./fusion-settings.ts";
|
|
9
|
+
import { createSidekickTool, fusionOf, SIDEKICK_TOOL_NAME, type SidekickToolOptions } from "./sidekick.ts";
|
|
10
|
+
|
|
11
|
+
type ProviderOptions = NonNullable<Parameters<typeof createDevinProvider>[0]>;
|
|
12
|
+
export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly"> {
|
|
13
|
+
/** Store for the `/devin-fusion` choice. Kept in memory only when omitted. */
|
|
14
|
+
fusionSettings?: FusionSettings;
|
|
15
|
+
/** Store for the `/devin` display settings. Kept in memory only when omitted. */
|
|
16
|
+
devinSettings?: DevinSettings;
|
|
17
|
+
/** The child Pi started as the sidekick registers no Fusion model, tool or command, and no `/devin` or status line. */
|
|
18
|
+
isFusionChild?: boolean;
|
|
19
|
+
/** Overrides the sidekick session location and how the child starts. The default location is the Pi agent directory. */
|
|
20
|
+
sidekick?: Partial<SidekickToolOptions>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtensionOptions = {}): DevinProvider {
|
|
24
|
+
const { fusionSettings, devinSettings, isFusionChild, sidekick: sidekickOptions, ...providerOptions } = options;
|
|
25
|
+
const settings = fusionSettings ?? memoryFusionSettings();
|
|
26
|
+
const preferences = isFusionChild ? undefined : devinSettings ?? memoryDevinSettings();
|
|
27
|
+
// The child Pi runs on the exact UID the lead chose, so it lists every exact UID and ignores the recommended filter.
|
|
28
|
+
const provider = createDevinProvider({
|
|
29
|
+
...providerOptions, fusion: isFusionChild ? false : () => settings.read(), listsAllExactModels: isFusionChild === true,
|
|
30
|
+
recommendedOnly: preferences ? () => preferences.read().recommendedOnly : undefined,
|
|
31
|
+
});
|
|
32
|
+
pi.registerProvider(provider);
|
|
33
|
+
if (preferences) registerDevinCommand(pi, provider, preferences);
|
|
34
|
+
const refreshStatus = (context: Parameters<typeof updateUsageStatus>[0]) => { if (preferences) updateUsageStatus(context, provider, preferences); };
|
|
35
|
+
const sidekick = isFusionChild ? undefined
|
|
36
|
+
: createSidekickTool(pi, provider, { agentDir: sidekickOptions?.agentDir ?? getAgentDir, runtime: sidekickOptions?.runtime });
|
|
37
|
+
if (sidekick) {
|
|
38
|
+
pi.registerTool(sidekick.definition);
|
|
39
|
+
registerFusionCommand(pi, provider, settings);
|
|
40
|
+
}
|
|
41
|
+
// Enables the sidekick only for the Fusion model and leaves other extensions' and the user's active tools alone.
|
|
42
|
+
const syncSidekick = (model: Model<any> | undefined) => {
|
|
43
|
+
if (!sidekick) return;
|
|
44
|
+
const active = pi.getActiveTools(), isActive = active.includes(SIDEKICK_TOOL_NAME);
|
|
45
|
+
const shouldBeActive = fusionOf(provider, model) !== undefined;
|
|
46
|
+
if (shouldBeActive && !isActive) pi.setActiveTools([...active, SIDEKICK_TOOL_NAME]);
|
|
47
|
+
else if (!shouldBeActive && isActive) pi.setActiveTools(active.filter((name) => name !== SIDEKICK_TOOL_NAME));
|
|
48
|
+
};
|
|
49
|
+
pi.on("model_select", (event) => { syncSidekick(event.model); });
|
|
50
|
+
pi.on("input", (event) => { sidekick?.onInput(event.source); });
|
|
51
|
+
pi.on("session_start", (_event, context) => {
|
|
52
|
+
if (context.modelRegistry.getRegisteredNativeProvider("devin") !== provider) {
|
|
53
|
+
provider.shutdown();
|
|
54
|
+
throw new Error("The Devin provider ID collides with another registration. Remove the duplicate extension and reload.");
|
|
55
|
+
}
|
|
56
|
+
provider.attachSession({
|
|
57
|
+
getSessionId: () => context.sessionManager.getSessionId(),
|
|
58
|
+
getBranch: () => context.sessionManager.getBranch(),
|
|
59
|
+
append: (data) => pi.appendEntry(DEVIN_STATE_ENTRY, data),
|
|
60
|
+
});
|
|
61
|
+
sidekick?.onSessionStart(context.sessionManager.getSessionId());
|
|
62
|
+
syncSidekick(context.model);
|
|
63
|
+
refreshStatus(context);
|
|
64
|
+
});
|
|
65
|
+
pi.on("message_end", (event, context) => {
|
|
66
|
+
if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context);
|
|
67
|
+
});
|
|
68
|
+
pi.on("before_agent_start", () => { provider.lifecycle.begin("agent"); });
|
|
69
|
+
pi.on("agent_start", () => { provider.lifecycle.agentStarted(); });
|
|
70
|
+
// Host retry or overflow recovery can continue after agent_end.
|
|
71
|
+
pi.on("agent_settled", (_event, context) => { provider.lifecycle.finish(); refreshStatus(context); });
|
|
72
|
+
pi.on("session_before_compact", (event) => { provider.lifecycle.beforeCompaction(event.reason); });
|
|
73
|
+
pi.on("session_compact", (event) => { provider.lifecycle.afterCompaction(event.reason); });
|
|
74
|
+
pi.on("session_compact_failed", (event) => { provider.lifecycle.afterCompaction(event.reason); });
|
|
75
|
+
pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) provider.lifecycle.beforeTree(); });
|
|
76
|
+
pi.on("session_tree", (_event, context) => { provider.lifecycle.treeChanged(); refreshStatus(context); });
|
|
77
|
+
pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
|
|
78
|
+
pi.on("session_shutdown", () => { sidekick?.reset(); provider.shutdown(); });
|
|
79
|
+
return provider;
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { FUSION_MODEL_ID, type DevinModel, type FusionChoices, type FusionSelection } from "./models.ts";
|
|
3
|
+
import { FusionPicker } from "./fusion-picker.ts";
|
|
4
|
+
import type { FusionSettings } from "./fusion-settings.ts";
|
|
5
|
+
import type { DevinProvider } from "./provider.ts";
|
|
6
|
+
|
|
7
|
+
export const FUSION_COMMAND_NAME = "devin-fusion";
|
|
8
|
+
const USAGE = `Usage: /${FUSION_COMMAND_NAME} or /${FUSION_COMMAND_NAME} <lead model ID> <sidekick model ID>`;
|
|
9
|
+
|
|
10
|
+
function currentFusion(provider: DevinProvider): DevinModel | undefined {
|
|
11
|
+
return (provider.getModels() as readonly DevinModel[]).find((model) => model.id === FUSION_MODEL_ID);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Disambiguates duplicate display names in a select list with an index, and maps the chosen label back to its value. */
|
|
15
|
+
async function choose<T>(ctx: ExtensionCommandContext, title: string, items: readonly T[], label: (item: T) => string, isCurrent: (item: T) => boolean) {
|
|
16
|
+
const names = items.map(label);
|
|
17
|
+
const labels = items.map((item, index) => {
|
|
18
|
+
const name = names.filter((other) => other === names[index]).length > 1 ? `${names[index]} (${index + 1})` : names[index];
|
|
19
|
+
return (isCurrent(item) ? "● " : " ") + name;
|
|
20
|
+
});
|
|
21
|
+
const picked = await ctx.ui.select(title, labels);
|
|
22
|
+
return picked === undefined ? undefined : items[labels.indexOf(picked)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function pickInteractively(ctx: ExtensionCommandContext, choices: FusionChoices, current: FusionSelection | undefined): Promise<FusionSelection | undefined> {
|
|
26
|
+
const lead = await choose(ctx, "Devin Fusion · lead model", choices.leads, (item) => item.name, (item) => item.id === current?.leadID);
|
|
27
|
+
if (!lead) return undefined;
|
|
28
|
+
const groups = [...new Set(choices.sidekicks.map((item) => item.groupName))];
|
|
29
|
+
const currentGroup = choices.sidekicks.find((item) => item.id === current?.sidekickID)?.groupName;
|
|
30
|
+
const group = groups.length === 1 ? groups[0] : await choose(ctx, "Devin Fusion · sidekick model", groups, (item) => item, (item) => item === currentGroup);
|
|
31
|
+
if (group === undefined) return undefined;
|
|
32
|
+
const variants = choices.sidekicks.filter((item) => item.groupName === group);
|
|
33
|
+
const sidekick = variants.length === 1 ? variants[0]
|
|
34
|
+
: await choose(ctx, `Devin Fusion · sidekick variant (${group})`, variants, (item) => item.name, (item) => item.id === current?.sidekickID);
|
|
35
|
+
return sidekick ? { leadID: lead.id, sidekickID: sidekick.id } : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `/devin-fusion` only chooses the lead/sidekick pair. Whether Fusion is used is decided by picking Devin Fusion in `/model`.
|
|
40
|
+
* If the current model is Fusion, it is re-selected with the updated model object so the thinking level range matches.
|
|
41
|
+
*/
|
|
42
|
+
export function registerFusionCommand(pi: ExtensionAPI, provider: DevinProvider, settings: FusionSettings): void {
|
|
43
|
+
pi.registerCommand(FUSION_COMMAND_NAME, {
|
|
44
|
+
description: "Choose the lead and sidekick models for Devin Fusion (saved globally)",
|
|
45
|
+
handler: async (args, ctx) => {
|
|
46
|
+
const choices = provider.getFusionChoices();
|
|
47
|
+
if (!choices?.leads.length || !choices.sidekicks.length) {
|
|
48
|
+
ctx.ui.notify("No Devin models are available for Fusion. Check that the model list refreshed after /login.", "warning");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const before = currentFusion(provider);
|
|
52
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
53
|
+
let selection: FusionSelection | undefined;
|
|
54
|
+
if (tokens.length) {
|
|
55
|
+
const [leadID, sidekickID] = tokens;
|
|
56
|
+
if (tokens.length !== 2 || !choices.leads.some((item) => item.id === leadID) || !choices.sidekicks.some((item) => item.id === sidekickID)) {
|
|
57
|
+
ctx.ui.notify(`This model cannot be used for Fusion. ${USAGE}`, "error");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
selection = { leadID, sidekickID };
|
|
61
|
+
} else if (ctx.mode === "tui") {
|
|
62
|
+
selection = await ctx.ui.custom<FusionSelection | undefined>((tui, theme, _keybindings, done) => new FusionPicker({
|
|
63
|
+
choices, current: before?.devin.fusion, theme, requestRender: () => tui.requestRender(), done,
|
|
64
|
+
}));
|
|
65
|
+
if (!selection) return;
|
|
66
|
+
} else if (ctx.hasUI) {
|
|
67
|
+
// RPC clients cannot draw the custom screen, so they get the select dialogs.
|
|
68
|
+
selection = await pickInteractively(ctx, choices, before?.devin.fusion);
|
|
69
|
+
if (!selection) return;
|
|
70
|
+
} else {
|
|
71
|
+
ctx.ui.notify(before ? `Current: ${before.name}` : `No Devin Fusion pair is set. ${USAGE}`, "info");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
settings.write(selection);
|
|
75
|
+
provider.rebuildModels();
|
|
76
|
+
// Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
|
|
77
|
+
await ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false });
|
|
78
|
+
const after = currentFusion(provider);
|
|
79
|
+
if (after && ctx.model?.provider === "devin" && ctx.model.id === FUSION_MODEL_ID) await pi.setModel(after);
|
|
80
|
+
ctx.ui.notify(after ? `Saved ${after.name}. Pick Devin Fusion in /model to use it.` : "The Fusion pair was saved, but it cannot be built from the current model list.", "info");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|