@buyong/pi-devin 0.1.5 → 0.1.6
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.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
package/src/pi/auth.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
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, isTransientConnectionFailure, 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
|
-
* Pi checks the stored session again after this interval. It is not a server TTL: the server expiry is unknown, no new token
|
|
11
|
-
* is issued, and each model request still checks the account outside Pi's credential lock.
|
|
12
|
-
*/
|
|
13
|
-
export const SESSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
-
/** Upper bound of the account check right after sign-in. Sign-in succeeds even when this check fails. */
|
|
15
|
-
const LOGIN_CHECK_TIMEOUT_MS = 15_000;
|
|
16
|
-
|
|
17
|
-
export interface DevinAccountAuth extends OAuthAuth {
|
|
18
|
-
readonly generation: number;
|
|
19
|
-
assertUsable(credential: Credential): void;
|
|
20
|
-
/** Converts the OAuth credential stored by Pi into the shared credential and remembers it as this process's OAuth session. */
|
|
21
|
-
credentialFromHost(credential: OAuthCredential): Credential;
|
|
22
|
-
/** Rejects sessions that did not come from an OAuth login or stored credential (API keys etc.) before any network request. */
|
|
23
|
-
assertOAuthSession(credential: Credential): void;
|
|
24
|
-
/** Remembers only authentication failures confirmed in the same auth generation. The credential file is never deleted. */
|
|
25
|
-
invalidate(credential: Credential, error: unknown, generation: number): void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function credentialFromOAuth(credential: OAuthCredential): Credential {
|
|
29
|
-
// 0.1.4 and earlier stored `expires: 0`; Pi checks such a session again on its next use.
|
|
30
|
-
if (credential.type !== "oauth" || credential.refresh !== "" || !Number.isSafeInteger(credential.expires) || credential.expires < 0) {
|
|
31
|
-
throw new Error("Unsupported Devin account credential. Sign in again with /login.");
|
|
32
|
-
}
|
|
33
|
-
const metadata = object(credential.devin, "Devin credential metadata");
|
|
34
|
-
if (metadata.version !== 1) throw new Error("The Devin account credential version differs. Sign in again with /login.");
|
|
35
|
-
return {
|
|
36
|
-
version: 1,
|
|
37
|
-
sessionToken: requiredString(credential.access, "Devin credential access"),
|
|
38
|
-
apiServerURL: endpoint(requiredString(metadata.apiServerURL, "Devin credential API server")),
|
|
39
|
-
createdAt: requiredString(metadata.createdAt, "Devin credential creation time"),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function credentialKey(credential: Credential): string {
|
|
44
|
-
return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Endpoint mismatches are rejected separately by the model list scope. This only checks that the session came from OAuth. */
|
|
48
|
-
function sessionKey(credential: Credential): string {
|
|
49
|
-
return createHash("sha256").update(credential.sessionToken).digest("hex");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function createDevinAccountAuth(options: {
|
|
53
|
-
loginOptions?: Omit<LoginOptions, "onURL" | "promptCode" | "signal" | "flow" | "encoding">;
|
|
54
|
-
createClient?: (credential: Credential) => DevinClient;
|
|
55
|
-
onLogin?: () => void;
|
|
56
|
-
onValidated?: (credential: Credential, ownerHash: string, status: JsonObject) => void;
|
|
57
|
-
onInvalidated?: (credential: Credential, error: unknown) => void;
|
|
58
|
-
getSignal?: () => AbortSignal;
|
|
59
|
-
/** The account was already checked in this process; each model request checks it again outside Pi's credential lock. */
|
|
60
|
-
isAccountChecked?: (credential: Credential) => boolean;
|
|
61
|
-
now?: () => number;
|
|
62
|
-
} = {}): DevinAccountAuth {
|
|
63
|
-
const now = options.now ?? Date.now;
|
|
64
|
-
const invalid = new Set<string>();
|
|
65
|
-
// Remembers only a hash, never the token. Pi calls toAuth on every request, so the session in use moves back to the front.
|
|
66
|
-
const oauthSessions = new Set<string>();
|
|
67
|
-
let generation = 0;
|
|
68
|
-
const requireUsable = (credential: Credential) => {
|
|
69
|
-
if (invalid.has(credentialKey(credential))) throw new RPCError("unauthenticated", "Authentication failed for this session. Sign in again with /login.");
|
|
70
|
-
};
|
|
71
|
-
const rememberOAuth = (credential: Credential) => {
|
|
72
|
-
const key = sessionKey(credential);
|
|
73
|
-
oauthSessions.delete(key);
|
|
74
|
-
if (oauthSessions.size >= 64) oauthSessions.delete(oauthSessions.values().next().value!);
|
|
75
|
-
oauthSessions.add(key);
|
|
76
|
-
return credential;
|
|
77
|
-
};
|
|
78
|
-
const checkAccount = async (credential: Credential, signal: AbortSignal, expectedGeneration: number) => {
|
|
79
|
-
const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
|
|
80
|
-
try {
|
|
81
|
-
const status = await client.status(signal);
|
|
82
|
-
if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
|
|
83
|
-
} finally { client.transport.close(); }
|
|
84
|
-
};
|
|
85
|
-
const auth: DevinAccountAuth = {
|
|
86
|
-
name: "Devin account",
|
|
87
|
-
get generation() { return generation; },
|
|
88
|
-
assertUsable: requireUsable,
|
|
89
|
-
credentialFromHost: (current) => rememberOAuth(credentialFromOAuth(current)),
|
|
90
|
-
assertOAuthSession(credential) {
|
|
91
|
-
// Kept as a plain error so it does not invalidate authentication or cancel a running request.
|
|
92
|
-
if (!oauthSessions.has(sessionKey(credential))) throw new Error(OAUTH_ONLY_MESSAGE);
|
|
93
|
-
},
|
|
94
|
-
async login(interaction) {
|
|
95
|
-
const lifetime = options.getSignal?.();
|
|
96
|
-
const signal = AbortSignal.any([interaction.signal, ...(lifetime ? [lifetime] : [])]);
|
|
97
|
-
signal.throwIfAborted();
|
|
98
|
-
const choice = await awaitWithSignal(interaction.prompt({ type: "select", signal, message: "Devin account sign-in method", options: [
|
|
99
|
-
{ id: "browser", label: "Browser sign-in", description: "Choose an account, then return through a loopback callback." },
|
|
100
|
-
{ 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." },
|
|
101
|
-
] }), signal);
|
|
102
|
-
if (choice !== "browser" && choice !== "manual") throw new Error("Unsupported Devin sign-in method.");
|
|
103
|
-
const credential = await loginAccount({
|
|
104
|
-
...options.loginOptions, flow: choice, signal,
|
|
105
|
-
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." }),
|
|
106
|
-
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 }),
|
|
107
|
-
});
|
|
108
|
-
signal.throwIfAborted();
|
|
109
|
-
invalid.delete(credentialKey(credential));
|
|
110
|
-
rememberOAuth(credential);
|
|
111
|
-
generation++;
|
|
112
|
-
options.onLogin?.();
|
|
113
|
-
// Records the new session's account and quota so the same account's model list and usage are available at once.
|
|
114
|
-
// Sign-in is not a permission check: a failure here is left to the first model request.
|
|
115
|
-
if (options.onValidated) {
|
|
116
|
-
try { await checkAccount(credential, AbortSignal.any([signal, AbortSignal.timeout(LOGIN_CHECK_TIMEOUT_MS)]), generation); }
|
|
117
|
-
catch { /* best effort */ }
|
|
118
|
-
}
|
|
119
|
-
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." });
|
|
120
|
-
return {
|
|
121
|
-
type: "oauth", access: credential.sessionToken, refresh: "", expires: now() + SESSION_CHECK_INTERVAL_MS,
|
|
122
|
-
devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
async refresh(current, callerSignal) {
|
|
126
|
-
const lifetime = options.getSignal?.();
|
|
127
|
-
const signal = AbortSignal.any([callerSignal, ...(lifetime ? [lifetime] : [])]);
|
|
128
|
-
signal.throwIfAborted();
|
|
129
|
-
const credential = credentialFromOAuth(current);
|
|
130
|
-
requireUsable(credential);
|
|
131
|
-
rememberOAuth(credential);
|
|
132
|
-
// Pi calls this while holding the shared auth.json lock once the check interval has passed, and saves the result.
|
|
133
|
-
// The new expiry only schedules the next check; the token is never renewed.
|
|
134
|
-
const checked = (): OAuthCredential => ({ ...current, expires: now() + SESSION_CHECK_INTERVAL_MS });
|
|
135
|
-
// Model requests already check the account outside the lock, so a process that checked it does not wait on the network.
|
|
136
|
-
if (options.isAccountChecked?.(credential)) return checked();
|
|
137
|
-
const expectedGeneration = generation;
|
|
138
|
-
try {
|
|
139
|
-
await checkAccount(credential, signal, expectedGeneration);
|
|
140
|
-
return checked();
|
|
141
|
-
} catch (error) {
|
|
142
|
-
auth.invalidate(credential, error, expectedGeneration);
|
|
143
|
-
// An unreachable check must not block the request: keep the stored session unchanged so the model request
|
|
144
|
-
// succeeds or reports its own retryable transport error, and the next use checks again.
|
|
145
|
-
if (!signal.aborted && isTransientConnectionFailure(error)) return current;
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
async toAuth(current) {
|
|
150
|
-
const credential = credentialFromOAuth(current);
|
|
151
|
-
requireUsable(credential);
|
|
152
|
-
rememberOAuth(credential);
|
|
153
|
-
// apiKey is only ModelAuth's transport field; the sign-in/storage auth type is always oauth.
|
|
154
|
-
return { apiKey: credential.sessionToken, baseUrl: credential.apiServerURL };
|
|
155
|
-
},
|
|
156
|
-
invalidate(credential, error, expectedGeneration) {
|
|
157
|
-
if (expectedGeneration !== generation || !isAuthenticationError(error)) return;
|
|
158
|
-
const key = credentialKey(credential);
|
|
159
|
-
if (invalid.has(key)) return;
|
|
160
|
-
if (invalid.size >= 64) invalid.delete(invalid.values().next().value!);
|
|
161
|
-
invalid.add(key);
|
|
162
|
-
options.onInvalidated?.(credential, error);
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
return auth;
|
|
166
|
-
}
|
package/src/pi/devin-command.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
/** Pi stores a message only after every `message_end` handler ran, so a just-finished response is counted explicitly. */
|
|
14
|
-
function branchWith(ctx: ExtensionContext, pendingMessage: unknown): readonly unknown[] {
|
|
15
|
-
const branch = ctx.sessionManager.getBranch();
|
|
16
|
-
if (!pendingMessage || branch.some((entry) => entry.type === "message" && entry.message === pendingMessage)) return branch;
|
|
17
|
-
return [...branch, { type: "message", message: pendingMessage }];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The status is used in the TUI only. Turning the toggle off clears only this package's key.
|
|
22
|
-
* Values come only from the last account status and session records; `pendingMessage` is a response not stored yet.
|
|
23
|
-
*/
|
|
24
|
-
export function updateUsageStatus(ctx: ExtensionContext, provider: DevinProvider, settings: DevinSettings, pendingMessage?: unknown): void {
|
|
25
|
-
if (ctx.mode !== "tui") return;
|
|
26
|
-
const text = settings.read().showUsageStatus
|
|
27
|
-
? usageStatusText(provider.getAccountUsage(), sessionUsage(branchWith(ctx, pendingMessage)), (tone, segment) => ctx.ui.theme.fg(tone, segment)) : undefined;
|
|
28
|
-
ctx.ui.setStatus(USAGE_STATUS_KEY, text);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const onOff = (value: boolean) => value ? "on" : "off";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* `/devin` shows usage details and display settings. Settings are saved globally and applied immediately.
|
|
35
|
-
* The TUI gets a dedicated screen; other modes use arguments and notifications.
|
|
36
|
-
*/
|
|
37
|
-
export function registerDevinCommand(pi: ExtensionAPI, provider: DevinProvider, settings: DevinSettings, options: { now?: () => number } = {}): void {
|
|
38
|
-
const now = options.now ?? Date.now;
|
|
39
|
-
const apply = async (ctx: ExtensionCommandContext, next: DevinPreferences) => {
|
|
40
|
-
const previous = settings.read();
|
|
41
|
-
// 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.
|
|
42
|
-
settings.write(next);
|
|
43
|
-
if (previous.recommendedOnly !== next.recommendedOnly) {
|
|
44
|
-
provider.rebuildModels();
|
|
45
|
-
// Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
|
|
46
|
-
try {
|
|
47
|
-
await ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false });
|
|
48
|
-
} catch (error) {
|
|
49
|
-
ctx.ui.notify(`Settings were saved, but the model list could not be rebuilt: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
50
|
-
}
|
|
51
|
-
const model = ctx.model;
|
|
52
|
-
if (model?.provider === "devin" && !provider.getModels().some((item) => item.id === model.id)) {
|
|
53
|
-
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");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
updateUsageStatus(ctx, provider, settings);
|
|
57
|
-
};
|
|
58
|
-
pi.registerCommand(DEVIN_COMMAND_NAME, {
|
|
59
|
-
description: "Show Devin usage and change display settings (recommended models only, usage in the status line); saved globally",
|
|
60
|
-
handler: async (args, ctx) => {
|
|
61
|
-
const report = () => usageReport(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), provider.lifecycle.budget(), now());
|
|
62
|
-
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
63
|
-
if (tokens.length) {
|
|
64
|
-
const key = ARGUMENT_KEYS[tokens[0]];
|
|
65
|
-
if (tokens.length !== 2 || !key || (tokens[1] !== "on" && tokens[1] !== "off")) {
|
|
66
|
-
ctx.ui.notify(`Unknown argument. ${USAGE}`, "error");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const value = tokens[1] === "on";
|
|
70
|
-
await apply(ctx, { ...settings.read(), [key]: value });
|
|
71
|
-
ctx.ui.notify(`${PREFERENCE_ITEMS.find((item) => item.key === key)!.label}: ${onOff(value)} (saved globally)`, "info");
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
if (ctx.mode === "tui") {
|
|
75
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new DevinPanel({
|
|
76
|
-
theme, report, preferences: settings.read(), onChange: (next) => apply(ctx, next),
|
|
77
|
-
requestRender: () => tui.requestRender(), done: () => done(),
|
|
78
|
-
}));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const preferences = settings.read();
|
|
82
|
-
const summary = PREFERENCE_ITEMS.map((item) => `${item.label} ${onOff(preferences[item.key])}`).join(" · ");
|
|
83
|
-
ctx.ui.notify([...report().map((line) => line.text), `Settings: ${summary}`, USAGE].join("\n"), "info");
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
}
|
package/src/pi/devin-settings.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
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 models that have a variant 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
|
-
}
|
package/src/pi/extension.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
-
import { getAgentDir, type ExtensionAPI, type ExtensionContext } 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" | "onAccountUsage"> {
|
|
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
|
-
// Context of the current session, used to redraw the status when the quota changes outside event handlers.
|
|
28
|
-
let statusContext: ExtensionContext | undefined;
|
|
29
|
-
const refreshStatus = (context: ExtensionContext, pendingMessage?: unknown) => { if (preferences) updateUsageStatus(context, provider, preferences, pendingMessage); };
|
|
30
|
-
// The child Pi runs on the exact UID the lead chose, so it lists every exact UID and ignores the recommended filter.
|
|
31
|
-
const provider = createDevinProvider({
|
|
32
|
-
...providerOptions, fusion: isFusionChild ? false : () => settings.read(), listsAllExactModels: isFusionChild === true,
|
|
33
|
-
recommendedOnly: preferences ? () => preferences.read().recommendedOnly : undefined,
|
|
34
|
-
onAccountUsage: () => {
|
|
35
|
-
if (!statusContext) return;
|
|
36
|
-
try { refreshStatus(statusContext); } catch { /* The session was replaced or reloaded; its next session_start redraws. */ }
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
pi.registerProvider(provider);
|
|
40
|
-
if (preferences) registerDevinCommand(pi, provider, preferences);
|
|
41
|
-
const sidekick = isFusionChild ? undefined
|
|
42
|
-
: createSidekickTool(pi, provider, { agentDir: sidekickOptions?.agentDir ?? getAgentDir, runtime: sidekickOptions?.runtime });
|
|
43
|
-
if (sidekick) {
|
|
44
|
-
pi.registerTool(sidekick.definition);
|
|
45
|
-
registerFusionCommand(pi, provider, settings);
|
|
46
|
-
}
|
|
47
|
-
// Enables the sidekick only for the Fusion model and leaves other extensions' and the user's active tools alone.
|
|
48
|
-
const syncSidekick = (model: Model<any> | undefined) => {
|
|
49
|
-
if (!sidekick) return;
|
|
50
|
-
const active = pi.getActiveTools(), isActive = active.includes(SIDEKICK_TOOL_NAME);
|
|
51
|
-
const shouldBeActive = fusionOf(provider, model) !== undefined;
|
|
52
|
-
if (shouldBeActive && !isActive) pi.setActiveTools([...active, SIDEKICK_TOOL_NAME]);
|
|
53
|
-
else if (!shouldBeActive && isActive) pi.setActiveTools(active.filter((name) => name !== SIDEKICK_TOOL_NAME));
|
|
54
|
-
};
|
|
55
|
-
pi.on("model_select", (event) => { syncSidekick(event.model); });
|
|
56
|
-
pi.on("input", (event) => { sidekick?.onInput(event.source); });
|
|
57
|
-
pi.on("session_start", (_event, context) => {
|
|
58
|
-
if (context.modelRegistry.getRegisteredNativeProvider("devin") !== provider) {
|
|
59
|
-
provider.shutdown();
|
|
60
|
-
throw new Error("The Devin provider ID collides with another registration. Remove the duplicate extension and reload.");
|
|
61
|
-
}
|
|
62
|
-
provider.attachSession({
|
|
63
|
-
getSessionId: () => context.sessionManager.getSessionId(),
|
|
64
|
-
getBranch: () => context.sessionManager.getBranch(),
|
|
65
|
-
append: (data) => pi.appendEntry(DEVIN_STATE_ENTRY, data),
|
|
66
|
-
});
|
|
67
|
-
sidekick?.onSessionStart(context.sessionManager.getSessionId());
|
|
68
|
-
syncSidekick(context.model);
|
|
69
|
-
statusContext = context;
|
|
70
|
-
refreshStatus(context);
|
|
71
|
-
});
|
|
72
|
-
pi.on("message_end", (event, context) => {
|
|
73
|
-
if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context, event.message);
|
|
74
|
-
});
|
|
75
|
-
// Subagents and other sessions record quota in the shared store; any model's turn picks it up.
|
|
76
|
-
pi.on("turn_end", (_event, context) => { refreshStatus(context); });
|
|
77
|
-
pi.on("before_agent_start", () => { provider.lifecycle.begin("agent"); });
|
|
78
|
-
pi.on("agent_start", () => { provider.lifecycle.agentStarted(); });
|
|
79
|
-
// Host retry or overflow recovery can continue after agent_end.
|
|
80
|
-
pi.on("agent_settled", (_event, context) => { provider.lifecycle.finish(); refreshStatus(context); });
|
|
81
|
-
pi.on("session_before_compact", (event) => { provider.lifecycle.beforeCompaction(event.reason); });
|
|
82
|
-
pi.on("session_compact", (event) => { provider.lifecycle.afterCompaction(event.reason); });
|
|
83
|
-
pi.on("session_compact_failed", (event) => { provider.lifecycle.afterCompaction(event.reason); });
|
|
84
|
-
pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) provider.lifecycle.beforeTree(); });
|
|
85
|
-
pi.on("session_tree", (_event, context) => { provider.lifecycle.treeChanged(); refreshStatus(context); });
|
|
86
|
-
pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
|
|
87
|
-
pi.on("session_shutdown", () => { statusContext = undefined; sidekick?.reset(); provider.shutdown(); });
|
|
88
|
-
return provider;
|
|
89
|
-
}
|
package/src/pi/fusion-command.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import type { FusionSelection } from "./models.ts";
|
|
4
|
-
|
|
5
|
-
const SCHEMA_VERSION = 1;
|
|
6
|
-
|
|
7
|
-
/** Global store for the lead and sidekick chosen with `/devin-fusion`. Every session uses the same value. */
|
|
8
|
-
export interface FusionSettings {
|
|
9
|
-
read(): FusionSelection | undefined;
|
|
10
|
-
write(selection: FusionSelection): void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function fusionSettingsPath(agentDir: string): string {
|
|
14
|
-
return join(agentDir, "pi-devin", "fusion.json");
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Default store so SDK callers that register the extension directly, and tests, never write to the real agent directory. */
|
|
18
|
-
export function memoryFusionSettings(initial?: FusionSelection): FusionSettings {
|
|
19
|
-
let current = initial;
|
|
20
|
-
return { read: () => current, write: (selection) => { current = { ...selection }; } };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** An unreadable or malformed file reads as no saved value and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
24
|
-
export function fileFusionSettings(path: string): FusionSettings {
|
|
25
|
-
return {
|
|
26
|
-
read() {
|
|
27
|
-
try {
|
|
28
|
-
const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
29
|
-
// Legacy IDs (`family:…`, `uid:…`) are read as is. `effectiveFusion` maps them to the current catalog's IDs.
|
|
30
|
-
if (value.schemaVersion === SCHEMA_VERSION && typeof value.leadID === "string" && value.leadID.trim() !== ""
|
|
31
|
-
&& typeof value.sidekickID === "string" && value.sidekickID.trim() !== "") {
|
|
32
|
-
return { leadID: value.leadID, sidekickID: value.sidekickID };
|
|
33
|
-
}
|
|
34
|
-
} catch { /* A missing or corrupt file falls back to the default pair. */ }
|
|
35
|
-
return undefined;
|
|
36
|
-
},
|
|
37
|
-
write(selection) {
|
|
38
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
40
|
-
writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, leadID: selection.leadID, sidekickID: selection.sidekickID }, null, 2) + "\n");
|
|
41
|
-
renameSync(temporary, path);
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|