@buyong/pi-devin 0.1.4 → 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 +222 -0
- package/README.md +37 -41
- package/index.ts +6 -12
- package/package.json +4 -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 -145
- package/src/pi/devin-command.ts +0 -76
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -80
- 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 -379
- package/src/pi/provider.ts +0 -249
- 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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Effect, Layer, type ManagedRuntime } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import { SIDEKICK_REPORT_MESSAGE, SIDEKICK_TOOL_NAME } from "../../features/sidekick/prompts.ts";
|
|
5
|
+
import { SidekickHost, Sidekicks } from "../../features/sidekick/service.ts";
|
|
6
|
+
|
|
7
|
+
/** The lead Pi as the sidekick sees it. */
|
|
8
|
+
export const PiSidekickHost = (pi: ExtensionAPI) => Layer.succeed(SidekickHost, {
|
|
9
|
+
activeTools: Effect.sync(() => pi.getActiveTools()),
|
|
10
|
+
deliverReport: (report) => Effect.sync(() => {
|
|
11
|
+
pi.sendMessage({ customType: SIDEKICK_REPORT_MESSAGE, display: true, content: report.content, details: report.details }, { deliverAs: "followUp", triggerTurn: true });
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The lead's `devin_sidekick` tool. The sidekick runs as one child Pi process per lead session and uses the lead's active tools.
|
|
17
|
+
* By default it waits for the report; with `block: false`, or when a user message arrives while waiting, it moves to the background and delivers the report once.
|
|
18
|
+
*/
|
|
19
|
+
export function sidekickTool(runtime: ManagedRuntime.ManagedRuntime<Sidekicks, DevinError>): ToolDefinition {
|
|
20
|
+
const sidekicks = runtime.runSync(Sidekicks);
|
|
21
|
+
return {
|
|
22
|
+
name: SIDEKICK_TOOL_NAME,
|
|
23
|
+
label: "Sidekick",
|
|
24
|
+
description: "Delegate hands-on work to your persistent sidekick, a separate coding agent that shares this workspace and your tools. "
|
|
25
|
+
+ "Put the task context and done criteria in `message`. By default the call waits and returns the sidekick's report; "
|
|
26
|
+
+ "set `block` to false to continue while it works, and the report arrives later as a message. "
|
|
27
|
+
+ "Calling it while the sidekick is busy adds instructions to the running handoff.",
|
|
28
|
+
promptSnippet: "Delegate hands-on work to a persistent sidekick agent and get its report",
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
message: { type: "string", description: "Brief for the sidekick: context, the task, and done criteria." },
|
|
33
|
+
block: { type: "boolean", description: "Wait for the report (default true). Use false to keep working while the sidekick runs." },
|
|
34
|
+
},
|
|
35
|
+
required: ["message"],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
} as unknown as ToolDefinition["parameters"],
|
|
38
|
+
defaultActive: false,
|
|
39
|
+
executionMode: "sequential",
|
|
40
|
+
execute: (_toolCallId, params, signal, onUpdate, ctx) => runtime.runPromise(sidekicks.handoff({
|
|
41
|
+
params, model: ctx.model, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), isSessionSaved: Boolean(ctx.sessionManager.getSessionFile()), signal,
|
|
42
|
+
onProgress: (text, details) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Effect, Predicate } from "effect";
|
|
4
|
+
import { DevinError } from "../../../core/errors.ts";
|
|
5
|
+
import { imageFromBase64, MAX_IMAGES_BYTES } from "../../../core/inference/images.ts";
|
|
6
|
+
import type { ChatMessage, ChatTool, ModelInfo } from "../../../core/types.ts";
|
|
7
|
+
import { failWith, object, requiredString } from "../../../core/validation.ts";
|
|
8
|
+
import { DEVIN_API } from "../../features/catalog/constants.ts";
|
|
9
|
+
|
|
10
|
+
const REPLAY_PREFIX = "devin:v1:";
|
|
11
|
+
const replayStrings = ["signature", "signatureType", "geminiThoughtSignature", "outputId", "thinkingId", "phase", "modelUid"] as const;
|
|
12
|
+
type ReplayMetadata = Pick<ChatMessage, typeof replayStrings[number] | "thinkingRedacted">;
|
|
13
|
+
export interface DevinTranscript { messages: ChatMessage[]; tools: ChatTool[]; systemPrompt: string }
|
|
14
|
+
|
|
15
|
+
export function encodeReplayMetadata(message: ChatMessage): string {
|
|
16
|
+
const metadata: ReplayMetadata = {};
|
|
17
|
+
for (const key of replayStrings) if (message[key] !== undefined) metadata[key] = message[key];
|
|
18
|
+
if (message.thinkingRedacted !== undefined) metadata.thinkingRedacted = message.thinkingRedacted;
|
|
19
|
+
return REPLAY_PREFIX + JSON.stringify(metadata);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function replayMetadata(value: string | undefined): Effect.Effect<ReplayMetadata | undefined, DevinError> {
|
|
23
|
+
if (!value?.startsWith(REPLAY_PREFIX)) return Effect.succeed(undefined);
|
|
24
|
+
return Effect.gen(function* () {
|
|
25
|
+
const parsed = yield* Effect.try({ try: () => JSON.parse(value.slice(REPLAY_PREFIX.length)) as unknown, catch: () => undefined }).pipe(
|
|
26
|
+
Effect.flatMap((json) => object(json, "Devin replay metadata")),
|
|
27
|
+
Effect.mapError(() => new DevinError({ message: "Invalid Devin response replay metadata." })),
|
|
28
|
+
);
|
|
29
|
+
const result: ReplayMetadata = {};
|
|
30
|
+
for (const key of replayStrings) {
|
|
31
|
+
const field = parsed[key];
|
|
32
|
+
if (field !== undefined && !Predicate.isString(field)) return yield* failWith("Unexpected Devin response replay metadata format.");
|
|
33
|
+
if (Predicate.isString(field)) result[key] = field;
|
|
34
|
+
}
|
|
35
|
+
if (parsed.thinkingRedacted !== undefined) {
|
|
36
|
+
if (!Predicate.isBoolean(parsed.thinkingRedacted)) return yield* failWith("Invalid Devin redacted thinking metadata.");
|
|
37
|
+
result.thinkingRedacted = parsed.thinkingRedacted;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stores the native response metadata on every content block, so the next request can replay it exactly. */
|
|
44
|
+
export function attachReplayMetadata(output: AssistantMessage, message: ChatMessage): void {
|
|
45
|
+
const signature = encodeReplayMetadata(message);
|
|
46
|
+
for (const block of output.content) {
|
|
47
|
+
if (block.type === "thinking") block.thinkingSignature = signature;
|
|
48
|
+
else if (block.type === "text") block.textSignature = signature;
|
|
49
|
+
else block.thoughtSignature = signature;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
|
|
54
|
+
const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
|
|
55
|
+
const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
|
|
56
|
+
const SCHEMA_DATA = new Set(["enum", "const", "default", "examples", "example"]);
|
|
57
|
+
|
|
58
|
+
/** Devin's own tools give every schema a single `type`; its Gemini route rejects a list such as `["integer", "null"]`. */
|
|
59
|
+
function toDevinToolSchema(schema: unknown): unknown {
|
|
60
|
+
if (Array.isArray(schema)) return schema.map(toDevinToolSchema);
|
|
61
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
62
|
+
const result: Record<string, unknown> = {};
|
|
63
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
64
|
+
if (SCHEMA_DATA.has(key)) result[key] = value;
|
|
65
|
+
else if (SCHEMA_MAPS.has(key) && Predicate.isObject(value)) {
|
|
66
|
+
result[key] = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, toDevinToolSchema(child)]));
|
|
67
|
+
} else if (key === "type" && Array.isArray(value)) {
|
|
68
|
+
// Omitting the field still covers null; several remaining types become one branch each.
|
|
69
|
+
const types = value.filter((type) => type !== "null");
|
|
70
|
+
if (types.length === 1) result.type = types[0];
|
|
71
|
+
else if (types.length > 1 && !("anyOf" in schema)) result.anyOf = types.map((type) => ({ type }));
|
|
72
|
+
else result.type = value;
|
|
73
|
+
} else result[key] = toDevinToolSchema(value);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toolDeclarations(context: TranscriptContext, model: ModelInfo): Effect.Effect<ChatTool[], DevinError> {
|
|
79
|
+
return Effect.gen(function* () {
|
|
80
|
+
const declarations = getCurrentTools(context.messages);
|
|
81
|
+
if (declarations.length && !model.supportsToolCalls) return yield* failWith("This model does not support the current Pi tool declarations.");
|
|
82
|
+
const tools: ChatTool[] = [];
|
|
83
|
+
for (const tool of declarations) {
|
|
84
|
+
// Grammar sampling is optional; like Pi's providers without grammar tools, send the JSON schema instead.
|
|
85
|
+
if (tool.constrainedSampling && tool.constrainedSampling.type === "json_schema" && tool.constrainedSampling.strict === "require") {
|
|
86
|
+
return yield* failWith(`Devin does not support this tool's forced sampling format: ${tool.name}`);
|
|
87
|
+
}
|
|
88
|
+
tools.push({ name: tool.name, description: tool.description, jsonSchemaString: JSON.stringify(toDevinToolSchema(tool.parameters)) });
|
|
89
|
+
}
|
|
90
|
+
return tools;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type ContextMessage = TranscriptContext["messages"][number];
|
|
95
|
+
|
|
96
|
+
/** A successful assistant response with its own replay metadata, never a failed or unfinished one. */
|
|
97
|
+
function assistantMessage(message: Extract<ContextMessage, { role: "assistant" }>): Effect.Effect<ChatMessage, DevinError> {
|
|
98
|
+
return Effect.gen(function* () {
|
|
99
|
+
const own = message.provider === "devin" && message.api === DEVIN_API;
|
|
100
|
+
const result: ChatMessage = { messageId: message.responseId ?? randomUUID(), source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
|
|
101
|
+
let replay: ReplayMetadata | undefined;
|
|
102
|
+
for (const block of message.content) {
|
|
103
|
+
if (block.type === "text") result.prompt += block.text;
|
|
104
|
+
else if (block.type === "thinking") {
|
|
105
|
+
if (!block.redacted) result.thinking = (result.thinking ?? "") + block.thinking;
|
|
106
|
+
else result.thinkingRedacted = true;
|
|
107
|
+
} else {
|
|
108
|
+
result.toolCalls ??= [];
|
|
109
|
+
result.toolCalls.push({ id: yield* requiredString(block.id, "tool call id"), name: block.namespace ? `${block.namespace}.${block.name}` : block.name,
|
|
110
|
+
argumentsJson: JSON.stringify(yield* object(block.arguments, "tool arguments")) });
|
|
111
|
+
}
|
|
112
|
+
if (own) {
|
|
113
|
+
const metadata = yield* replayMetadata(block.type === "text" ? block.textSignature : block.type === "thinking" ? block.thinkingSignature : block.thoughtSignature);
|
|
114
|
+
if (metadata) {
|
|
115
|
+
if (replay && JSON.stringify(replay) !== JSON.stringify(metadata)) return yield* failWith("Devin replay metadata differs within one response.");
|
|
116
|
+
replay = metadata;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
Object.assign(result, replay);
|
|
121
|
+
if (own && message.providerThinkingLevel) result.modelUid = message.providerThinkingLevel;
|
|
122
|
+
if (result.thinkingRedacted) result.thinking = "";
|
|
123
|
+
return result;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A user message or tool result with its images, within the per-message image budget. */
|
|
128
|
+
function inputMessage(message: Exclude<ContextMessage, { role: "assistant" | "system" }>, model: ModelInfo): Effect.Effect<ChatMessage, DevinError> {
|
|
129
|
+
return Effect.gen(function* () {
|
|
130
|
+
const result: ChatMessage = {
|
|
131
|
+
messageId: randomUUID(), source: message.role === "toolResult" ? "CHAT_MESSAGE_SOURCE_TOOL" : "CHAT_MESSAGE_SOURCE_USER", prompt: "",
|
|
132
|
+
...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolResultIsError: message.isError } : {}),
|
|
133
|
+
};
|
|
134
|
+
let imageBytes = 0, isAfterPlaceholder = false;
|
|
135
|
+
const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] : message.content;
|
|
136
|
+
for (const block of content) {
|
|
137
|
+
if (block.type === "text") {
|
|
138
|
+
result.prompt += block.text; isAfterPlaceholder = false;
|
|
139
|
+
} else if (!model.supportsImages) {
|
|
140
|
+
// Like Pi's own providers: a text-only model gets a note instead, so earlier images do not stop the conversation.
|
|
141
|
+
if (!isAfterPlaceholder) result.prompt += message.role === "toolResult" ? NON_VISION_TOOL_IMAGE_PLACEHOLDER : NON_VISION_USER_IMAGE_PLACEHOLDER;
|
|
142
|
+
isAfterPlaceholder = true;
|
|
143
|
+
} else {
|
|
144
|
+
const converted = yield* imageFromBase64(block.data, block.mimeType);
|
|
145
|
+
imageBytes += converted.sizeBytes;
|
|
146
|
+
if (imageBytes > MAX_IMAGES_BYTES) return yield* failWith(`Images in one message must total at most ${MAX_IMAGES_BYTES} bytes.`);
|
|
147
|
+
(result.images ??= []).push(converted.image);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Pi history as Devin chat messages. Failed or aborted assistants and the results of their tool calls are not replayed. */
|
|
155
|
+
export function toDevinTranscript(context: TranscriptContext, model: ModelInfo): Effect.Effect<DevinTranscript, DevinError> {
|
|
156
|
+
return Effect.gen(function* () {
|
|
157
|
+
const tools = yield* toolDeclarations(context, model);
|
|
158
|
+
// Results follow their assistant, so only the calls of the latest failed assistant are skipped. A later failure that
|
|
159
|
+
// reuses an earlier call ID leaves the earlier result in place.
|
|
160
|
+
let failedCalls = new Set<string>();
|
|
161
|
+
const messages: ChatMessage[] = [];
|
|
162
|
+
for (const message of context.messages) {
|
|
163
|
+
if (message.role === "system") continue;
|
|
164
|
+
if (message.role === "assistant") {
|
|
165
|
+
const isFailed = message.stopReason === "error" || message.stopReason === "aborted";
|
|
166
|
+
failedCalls = new Set(isFailed ? message.content.filter((block) => block.type === "toolCall").map((block) => block.id) : []);
|
|
167
|
+
if (isFailed) continue;
|
|
168
|
+
if (message.stopReason === "pending") return yield* failWith("An unfinished assistant response cannot be sent as Devin history.");
|
|
169
|
+
messages.push(yield* assistantMessage(message));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (message.role === "toolResult" && failedCalls.has(message.toolCallId)) continue;
|
|
173
|
+
messages.push(yield* inputMessage(message, model));
|
|
174
|
+
}
|
|
175
|
+
return { messages, tools, systemPrompt: getCurrentSystemPrompt(context.messages) };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionRuntime } from "../../app/runtime.ts";
|
|
3
|
+
import type { DevinAPI } from "../../features/catalog/types.ts";
|
|
4
|
+
|
|
5
|
+
/** Pi's provider contract, and nothing else: extension features reach the services through the runtime. */
|
|
6
|
+
export interface DevinProvider extends Provider<DevinAPI> {
|
|
7
|
+
refreshModels: NonNullable<Provider<DevinAPI>["refreshModels"]>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A registered extension instance: the provider Pi holds and the runtime its handlers run on. */
|
|
11
|
+
export interface DevinExtension {
|
|
12
|
+
readonly provider: DevinProvider;
|
|
13
|
+
readonly runtime: ExtensionRuntime;
|
|
14
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import
|
|
2
|
+
import { Cause, Effect, Exit } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../../core/errors.ts";
|
|
4
|
+
import type { DevinPreferences } from "../../../features/settings/types.ts";
|
|
5
|
+
import type { UsageLine } from "../../../features/usage/types.ts";
|
|
3
6
|
import type { PickerTheme } from "./fusion-picker.ts";
|
|
4
|
-
import type { UsageLine } from "./usage.ts";
|
|
5
7
|
|
|
6
8
|
export const PREFERENCE_ITEMS: readonly { key: keyof DevinPreferences; label: string; detail: string }[] = [
|
|
7
9
|
{ key: "recommendedOnly", label: "Recommended models only", detail: "/model shows only models with a recommended variant and Devin Fusion" },
|
|
8
|
-
{ key: "showUsageStatus", label: "Usage in status line", detail: "shows quota and session tokens in the footer" },
|
|
10
|
+
{ key: "showUsageStatus", label: "Usage in status line", detail: "shows quota and session tokens in the footer while a Devin model is selected" },
|
|
9
11
|
];
|
|
10
12
|
const HELP = "↑↓ move · space/↵ toggle · esc close";
|
|
11
13
|
|
|
@@ -14,7 +16,7 @@ export interface DevinPanelOptions {
|
|
|
14
16
|
report: () => UsageLine[];
|
|
15
17
|
preferences: DevinPreferences;
|
|
16
18
|
/** Saves and applies on every toggle. On failure, shows a notice and reverts to the previous value. */
|
|
17
|
-
onChange: (next: DevinPreferences) =>
|
|
19
|
+
onChange: (next: DevinPreferences) => Effect.Effect<void, DevinError>;
|
|
18
20
|
requestRender: () => void;
|
|
19
21
|
done: () => void;
|
|
20
22
|
}
|
|
@@ -48,14 +50,14 @@ export class DevinPanel {
|
|
|
48
50
|
const key = PREFERENCE_ITEMS[this.cursor].key, previous = this.preferences;
|
|
49
51
|
this.preferences = { ...previous, [key]: !previous[key] };
|
|
50
52
|
this.isSaving = true; this.notice = undefined; this.changed();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} catch (error) {
|
|
53
|
+
const exit = await Effect.runPromiseExit(this.options.onChange(this.preferences));
|
|
54
|
+
if (Exit.isFailure(exit)) {
|
|
54
55
|
this.preferences = previous;
|
|
56
|
+
// A thrown host error is reported with its own message, like a failed save.
|
|
57
|
+
const error = Cause.squash(exit.cause);
|
|
55
58
|
this.notice = `Could not save: ${error instanceof Error ? error.message : String(error)}`;
|
|
56
|
-
} finally {
|
|
57
|
-
this.isSaving = false; this.changed();
|
|
58
59
|
}
|
|
60
|
+
this.isSaving = false; this.changed();
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
render(width: number): string[] {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { decodeKittyPrintable, fuzzyFilter, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { FusionChoices, FusionSelection } from "
|
|
3
|
+
import type { FusionChoices, FusionSelection } from "../../../features/catalog/types.ts";
|
|
4
4
|
|
|
5
5
|
export type PickerTheme = Pick<Theme, "fg" | "bold">;
|
|
6
6
|
interface Item { id: string; name: string; recommended?: true }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
4
|
+
import { Accounts } from "../../features/accounts/service.ts";
|
|
5
|
+
import { ModelCatalog } from "../../features/catalog/service.ts";
|
|
6
|
+
import { DisplaySettings } from "../../features/settings/service.ts";
|
|
7
|
+
import { UsageChanges } from "../../features/usage/changes.ts";
|
|
8
|
+
import { sessionUsage, usageStatusText } from "../../features/usage/report.ts";
|
|
9
|
+
import type { AccountsShape } from "../../features/accounts/types.ts";
|
|
10
|
+
import type { ModelCatalogShape } from "../../features/catalog/types.ts";
|
|
11
|
+
import type { AccountUsage } from "../../features/usage/types.ts";
|
|
12
|
+
|
|
13
|
+
/** The only status key this package uses. */
|
|
14
|
+
export const USAGE_STATUS_KEY = "devin-usage";
|
|
15
|
+
|
|
16
|
+
export interface UsageStatusShape {
|
|
17
|
+
/** Newest quota for the credential of the last model refresh. Makes no request. */
|
|
18
|
+
readonly accountUsage: Effect.Effect<AccountUsage | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Draws the status in the TUI only, while the session's current model is a Devin model, from the last account status and
|
|
21
|
+
* the session records. `pendingMessage` is a response Pi has not stored yet. Another provider's model or turning the toggle
|
|
22
|
+
* off clears only this package's key; `/devin` reports usage whatever the model.
|
|
23
|
+
*/
|
|
24
|
+
readonly update: (context: ExtensionContext, pendingMessage?: unknown) => Effect.Effect<void>;
|
|
25
|
+
/** The session whose status line follows quota changes that happen outside event handlers. */
|
|
26
|
+
readonly bind: (context: ExtensionContext | undefined) => Effect.Effect<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class UsageStatus extends Context.Service<UsageStatus, UsageStatusShape>()("pi-devin/UsageStatus") {}
|
|
30
|
+
|
|
31
|
+
/** Newest quota for the credential of the last model refresh: the account `/devin` and the status line show. Makes no request. */
|
|
32
|
+
export const currentAccountUsage = (catalog: ModelCatalogShape, accounts: AccountsShape): Effect.Effect<AccountUsage | undefined> =>
|
|
33
|
+
Effect.flatMap(catalog.currentScope, (scope) => scope ? accounts.usage(scope) : Effect.succeed(undefined));
|
|
34
|
+
|
|
35
|
+
/** Pi stores a message only after every `message_end` handler ran, so a just-finished response is counted explicitly. */
|
|
36
|
+
function branchWith(context: ExtensionContext, pendingMessage: unknown): readonly unknown[] {
|
|
37
|
+
const branch = context.sessionManager.getBranch();
|
|
38
|
+
if (!pendingMessage || branch.some((entry) => entry.type === "message" && entry.message === pendingMessage)) return branch;
|
|
39
|
+
return [...branch, { type: "message", message: pendingMessage }];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const UsageStatusLive = Layer.effect(UsageStatus, Effect.gen(function* () {
|
|
43
|
+
const config = yield* ExtensionConfig;
|
|
44
|
+
const settings = yield* DisplaySettings;
|
|
45
|
+
const accounts = yield* Accounts;
|
|
46
|
+
const catalog = yield* ModelCatalog;
|
|
47
|
+
const changes = yield* UsageChanges;
|
|
48
|
+
let bound: ExtensionContext | undefined;
|
|
49
|
+
const accountUsage = currentAccountUsage(catalog, accounts);
|
|
50
|
+
// The sidekick child shows no status line.
|
|
51
|
+
const update = (context: ExtensionContext, pendingMessage?: unknown) => config.isFusionChild ? Effect.void : Effect.gen(function* () {
|
|
52
|
+
if (context.mode !== "tui") return;
|
|
53
|
+
const isShown = context.model?.provider === "devin" && (yield* settings.read).showUsageStatus;
|
|
54
|
+
const text = isShown
|
|
55
|
+
? usageStatusText(yield* accountUsage, sessionUsage(branchWith(context, pendingMessage)), (tone, segment) => context.ui.theme.fg(tone, segment)) : undefined;
|
|
56
|
+
context.ui.setStatus(USAGE_STATUS_KEY, text);
|
|
57
|
+
});
|
|
58
|
+
// The session may be replaced or reloaded meanwhile; its next session_start redraws.
|
|
59
|
+
yield* changes.subscribe(Effect.suspend(() => bound ? update(bound) : Effect.void));
|
|
60
|
+
return {
|
|
61
|
+
accountUsage,
|
|
62
|
+
update,
|
|
63
|
+
bind: (context) => Effect.sync(() => { bound = context; }),
|
|
64
|
+
} satisfies UsageStatusShape;
|
|
65
|
+
}));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { DisplaySettings, DEFAULT_PREFERENCES, FusionSettings, isNotOlder, UsageStore } from "../../features/settings/service.ts";
|
|
4
|
+
import type { DisplaySettingsShape, FusionSettingsShape, UsageStoreShape } from "../../features/settings/types.ts";
|
|
5
|
+
import type { FusionSelection } from "../../features/catalog/types.ts";
|
|
6
|
+
import { readJsonFile, writeJsonFile } from "./json-file.ts";
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export const fusionSettingsPath = (agentDir: string) => join(agentDir, "pi-devin", "fusion.json");
|
|
11
|
+
export const devinSettingsPath = (agentDir: string) => join(agentDir, "pi-devin", "settings.json");
|
|
12
|
+
export const accountUsagePath = (agentDir: string) => join(agentDir, "pi-devin", "account-usage.json");
|
|
13
|
+
|
|
14
|
+
const ModelID = Schema.String.check(Schema.makeFilter((value: string) => value.trim() !== ""));
|
|
15
|
+
// Legacy IDs (`family:…`, `uid:…`) are read as is. `effectiveFusion` maps them to the current catalog's IDs.
|
|
16
|
+
const FusionFile = Schema.Struct({ schemaVersion: Schema.Literal(SCHEMA_VERSION), leadID: ModelID, sidekickID: ModelID });
|
|
17
|
+
const SettingsFile = Schema.Struct({ schemaVersion: Schema.Literal(SCHEMA_VERSION), recommendedOnly: Schema.Boolean, showUsageStatus: Schema.Boolean });
|
|
18
|
+
const Percent = Schema.optionalKey(Schema.Finite);
|
|
19
|
+
const UsageRecord = Schema.Struct({
|
|
20
|
+
schemaVersion: Schema.Literal(SCHEMA_VERSION),
|
|
21
|
+
scope: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
|
|
22
|
+
usage: Schema.Struct({
|
|
23
|
+
fetchedAtMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
|
24
|
+
billingStrategy: Schema.optionalKey(Schema.String),
|
|
25
|
+
dailyRemainingPercent: Percent,
|
|
26
|
+
weeklyRemainingPercent: Percent,
|
|
27
|
+
dailyResetAtMs: Percent,
|
|
28
|
+
weeklyResetAtMs: Percent,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** An unreadable or malformed file reads as no saved value and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
33
|
+
export function fileFusionSettings(path: string): FusionSettingsShape {
|
|
34
|
+
return {
|
|
35
|
+
read: Effect.map(readJsonFile(path, FusionFile), Option.match({
|
|
36
|
+
onNone: () => undefined,
|
|
37
|
+
onSome: (value): FusionSelection => ({ leadID: value.leadID, sidekickID: value.sidekickID }),
|
|
38
|
+
})),
|
|
39
|
+
write: (selection) => writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, leadID: selection.leadID, sidekickID: selection.sidekickID }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An unreadable or malformed file reads as the defaults and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
44
|
+
export function fileDisplaySettings(path: string): DisplaySettingsShape {
|
|
45
|
+
return {
|
|
46
|
+
read: Effect.map(readJsonFile(path, SettingsFile), Option.match({
|
|
47
|
+
onNone: () => ({ ...DEFAULT_PREFERENCES }),
|
|
48
|
+
onSome: (value) => ({ recommendedOnly: value.recommendedOnly, showUsageStatus: value.showUsageStatus }),
|
|
49
|
+
})),
|
|
50
|
+
write: (preferences) => writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, ...preferences }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An unreadable or malformed file reads as no record and is not repaired by reading. Writes are replaced atomically within the same directory. */
|
|
55
|
+
export function fileUsageStore(path: string): UsageStoreShape {
|
|
56
|
+
const readRecord = readJsonFile(path, UsageRecord);
|
|
57
|
+
return {
|
|
58
|
+
read: (scope) => Effect.map(readRecord, (record) => Option.isSome(record) && record.value.scope === scope ? { ...record.value.usage } : undefined),
|
|
59
|
+
write: (scope, usage) => Effect.flatMap(readRecord, (current) => Option.isSome(current) && current.value.scope === scope && !isNotOlder(current.value.usage, usage)
|
|
60
|
+
? Effect.void : writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, scope, usage })),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const FusionSettingsFile = (path: string) => Layer.succeed(FusionSettings, fileFusionSettings(path));
|
|
65
|
+
export const DisplaySettingsFile = (path: string) => Layer.succeed(DisplaySettings, fileDisplaySettings(path));
|
|
66
|
+
export const UsageStoreFile = (path: string) => Layer.succeed(UsageStore, fileUsageStore(path));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Effect, Option, Schema } from "effect";
|
|
4
|
+
import { toDevinError, type DevinError } from "../../../core/errors.ts";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Pi reads these small global files from synchronous callbacks, such as TUI rendering and model list rebuilds,
|
|
8
|
+
* and other Pi processes may change them at any time. They are therefore read fresh and synchronously on each use;
|
|
9
|
+
* Effect's FileSystem service is asynchronous only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The file's value when it exists, parses and matches the schema. A missing or malformed file is never repaired by reading. */
|
|
13
|
+
export function readJsonFile<S extends Schema.ConstraintDecoder<unknown>>(path: string, schema: S): Effect.Effect<Option.Option<S["Type"]>> {
|
|
14
|
+
const decode = Schema.decodeUnknownOption(schema);
|
|
15
|
+
return Effect.sync(() => {
|
|
16
|
+
try { return decode(JSON.parse(readFileSync(path, "utf8"))); } catch { return Option.none(); }
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Replaces the file atomically within its directory. */
|
|
21
|
+
export function writeJsonFile(path: string, value: unknown): Effect.Effect<void, DevinError> {
|
|
22
|
+
return Effect.try({
|
|
23
|
+
try: () => {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
26
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n");
|
|
27
|
+
renameSync(temporary, path);
|
|
28
|
+
},
|
|
29
|
+
catch: toDevinError,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface LoginSettings {
|
|
4
|
+
apiServerURL?: string;
|
|
5
|
+
webappURL?: string;
|
|
6
|
+
devinAPIURL?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Values the entry point chooses for one extension instance. Read by the services that need them. */
|
|
11
|
+
export interface ExtensionConfigShape {
|
|
12
|
+
/** Clock of quota timestamps and session check times. */
|
|
13
|
+
readonly now: () => number;
|
|
14
|
+
/**
|
|
15
|
+
* This Pi is the Fusion sidekick child: it lists every exact UID, builds no Fusion entry, ignores the recommended filter,
|
|
16
|
+
* and registers no sidekick tool, `/devin`, `/devin-fusion` or status line.
|
|
17
|
+
*/
|
|
18
|
+
readonly isFusionChild: boolean;
|
|
19
|
+
/** Endpoints and the time limit of `/login`. */
|
|
20
|
+
readonly login: LoginSettings;
|
|
21
|
+
/** Pi agent directory that holds sidekick session files. Read when a handoff starts. */
|
|
22
|
+
readonly agentDir: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ExtensionConfig extends Context.Service<ExtensionConfig, ExtensionConfigShape>()("pi-devin/ExtensionConfig") {}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Layer } from "effect";
|
|
3
|
+
import { NodeDeviceIdentitySource } from "../../core/services/device-identity.ts";
|
|
4
|
+
import { NodeHttpTransport, nodeTransport } from "../../core/services/http-transport.ts";
|
|
5
|
+
import { NodePlatformLayer } from "../../core/services/platform.ts";
|
|
6
|
+
import { accountUsagePath, devinSettingsPath, DisplaySettingsFile, fusionSettingsPath, FusionSettingsFile, UsageStoreFile } from "../adapters/storage/files.ts";
|
|
7
|
+
import { LoginTransport } from "../features/auth/login.ts";
|
|
8
|
+
import { DisplaySettings, FusionSettings, memoryDisplaySettings, memoryFusionSettings } from "../features/settings/service.ts";
|
|
9
|
+
import { NodeSidekickLauncher } from "../features/sidekick/launcher.ts";
|
|
10
|
+
import { ExtensionConfig } from "./config.ts";
|
|
11
|
+
import type { ExtensionEnvironment } from "./layer.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The environment Pi loads the extension with: Node's network, process and file services, and the global stores in Pi's agent directory.
|
|
15
|
+
* The sidekick child keeps no Fusion or display settings, but records its quota so the lead shows what the sidekick used.
|
|
16
|
+
*/
|
|
17
|
+
export function productionEnvironment(options: { isFusionChild: boolean }): Layer.Layer<ExtensionEnvironment> {
|
|
18
|
+
const agentDir = getAgentDir();
|
|
19
|
+
const settings = options.isFusionChild
|
|
20
|
+
? Layer.mergeAll(Layer.sync(FusionSettings, () => memoryFusionSettings()), Layer.sync(DisplaySettings, () => memoryDisplaySettings()))
|
|
21
|
+
: Layer.mergeAll(FusionSettingsFile(fusionSettingsPath(agentDir)), DisplaySettingsFile(devinSettingsPath(agentDir)));
|
|
22
|
+
return Layer.mergeAll(
|
|
23
|
+
Layer.succeed(ExtensionConfig, { isFusionChild: options.isFusionChild, now: Date.now, login: {}, agentDir: getAgentDir }),
|
|
24
|
+
NodeHttpTransport(),
|
|
25
|
+
Layer.succeed(LoginTransport, nodeTransport()),
|
|
26
|
+
settings,
|
|
27
|
+
UsageStoreFile(accountUsagePath(agentDir)),
|
|
28
|
+
NodeSidekickLauncher,
|
|
29
|
+
).pipe(Layer.provideMerge(Layer.mergeAll(NodeDeviceIdentitySource.pipe(Layer.provide(NodePlatformLayer)), NodePlatformLayer)));
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Layer, type FileSystem } from "effect";
|
|
2
|
+
import type { DeviceIdentitySource } from "../../core/services/device-identity.ts";
|
|
3
|
+
import type { HttpTransport } from "../../core/services/http-transport.ts";
|
|
4
|
+
import { UsageStatusLive } from "../adapters/pi/usage-status.ts";
|
|
5
|
+
import { AccountsLive } from "../features/accounts/service.ts";
|
|
6
|
+
import { CredentialGuardLive } from "../features/auth/guard.ts";
|
|
7
|
+
import { AccountLoginLive, type LoginTransport } from "../features/auth/login.ts";
|
|
8
|
+
import { AuthStateLive } from "../features/auth/state.ts";
|
|
9
|
+
import { RemoteCatalogLive } from "../features/catalog/remote.ts";
|
|
10
|
+
import { ModelCatalogLive } from "../features/catalog/service.ts";
|
|
11
|
+
import { DevinClientsLive } from "../features/clients/service.ts";
|
|
12
|
+
import { InferenceLive } from "../features/inference/service.ts";
|
|
13
|
+
import { ProviderLifetimeLive } from "../features/lifetime/service.ts";
|
|
14
|
+
import { RunsLive } from "../features/runs/service.ts";
|
|
15
|
+
import type { DisplaySettings, FusionSettings, UsageStore } from "../features/settings/service.ts";
|
|
16
|
+
import type { SidekickLauncher } from "../features/sidekick/launcher.ts";
|
|
17
|
+
import { SidekicksLive } from "../features/sidekick/service.ts";
|
|
18
|
+
import { UsageChangesLive } from "../features/usage/changes.ts";
|
|
19
|
+
import type { ExtensionConfig } from "./config.ts";
|
|
20
|
+
|
|
21
|
+
/** What the entry point provides for the provider: its values and external I/O. */
|
|
22
|
+
export type ProviderEnvironment = ExtensionConfig | HttpTransport | LoginTransport | DeviceIdentitySource | FusionSettings | DisplaySettings | UsageStore;
|
|
23
|
+
/** The provider environment plus what the extension's host features need. */
|
|
24
|
+
export type ExtensionEnvironment = ProviderEnvironment | SidekickLauncher | FileSystem.FileSystem;
|
|
25
|
+
|
|
26
|
+
/** State owners that need no other feature. */
|
|
27
|
+
const StateLive = Layer.mergeAll(ProviderLifetimeLive, AuthStateLive, UsageChangesLive, RunsLive);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Features behind Pi's provider contract. Each layer receives the ones below it, so dependencies only point downward:
|
|
31
|
+
* requests and sign-in use the catalog, which uses the guard and accounts, which use clients and the state owners.
|
|
32
|
+
*/
|
|
33
|
+
export const ProviderLive = Layer.mergeAll(InferenceLive, AccountLoginLive).pipe(
|
|
34
|
+
Layer.provideMerge(ModelCatalogLive),
|
|
35
|
+
Layer.provideMerge(RemoteCatalogLive),
|
|
36
|
+
Layer.provideMerge(CredentialGuardLive),
|
|
37
|
+
Layer.provideMerge(AccountsLive),
|
|
38
|
+
Layer.provideMerge(DevinClientsLive),
|
|
39
|
+
Layer.provideMerge(StateLive),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The provider and the features that need Pi's host: the Fusion sidekick and the usage status line. */
|
|
43
|
+
export const ExtensionLive = Layer.mergeAll(SidekicksLive, UsageStatusLive).pipe(Layer.provideMerge(ProviderLive));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Layer, ManagedRuntime } from "effect";
|
|
2
|
+
import type { DevinError } from "../../core/errors.ts";
|
|
3
|
+
import type { SidekickHost } from "../features/sidekick/service.ts";
|
|
4
|
+
import { ExtensionLive, ProviderLive, type ExtensionEnvironment, type ProviderEnvironment } from "./layer.ts";
|
|
5
|
+
|
|
6
|
+
export type ProviderServices = Layer.Success<typeof ProviderLive> | ProviderEnvironment;
|
|
7
|
+
export type ExtensionServices = Layer.Success<typeof ExtensionLive> | ExtensionEnvironment | SidekickHost;
|
|
8
|
+
export type ProviderRuntime = ManagedRuntime.ManagedRuntime<ProviderServices, DevinError>;
|
|
9
|
+
export type ExtensionRuntime = ManagedRuntime.ManagedRuntime<ExtensionServices, DevinError>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One runtime per extension instance. It is never disposed on `session_shutdown`: Pi may still call the same provider
|
|
13
|
+
* afterwards, and the next `session_start` reuses it with a new provider lifetime. Its layers hold no operating system
|
|
14
|
+
* resources; connection pools close with their clients and sidekick children with their sessions.
|
|
15
|
+
*/
|
|
16
|
+
export const makeProviderRuntime = (environment: Layer.Layer<ProviderEnvironment>): ProviderRuntime =>
|
|
17
|
+
ManagedRuntime.make(ProviderLive.pipe(Layer.provideMerge(environment)));
|
|
18
|
+
|
|
19
|
+
export const makeExtensionRuntime = (environment: Layer.Layer<ExtensionEnvironment>, host: Layer.Layer<SidekickHost>): ExtensionRuntime =>
|
|
20
|
+
ManagedRuntime.make(ExtensionLive.pipe(Layer.provideMerge(Layer.mergeAll(environment, host))));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Cancelled } from "../../core/errors.ts";
|
|
3
|
+
import { Accounts } from "../features/accounts/service.ts";
|
|
4
|
+
import { DevinClients } from "../features/clients/service.ts";
|
|
5
|
+
import { ProviderLifetime } from "../features/lifetime/service.ts";
|
|
6
|
+
import { Runs } from "../features/runs/service.ts";
|
|
7
|
+
import type { SessionBinding } from "../features/runs/types.ts";
|
|
8
|
+
|
|
9
|
+
/** Binds the provider to Pi's current session: a new provider lifetime if the last one ended, then the session's stored run state. Fails on a corrupt state. */
|
|
10
|
+
export const attachSession = (binding: SessionBinding) => Effect.gen(function* () {
|
|
11
|
+
yield* (yield* ProviderLifetime).renew;
|
|
12
|
+
yield* (yield* Runs).attach(binding);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stops the provider's work. The lifetime ends first, so running requests stop with its reason rather than an interruption;
|
|
17
|
+
* an unfinished run stays in the session's checkpoint. The extension instance and its runtime stay usable.
|
|
18
|
+
*/
|
|
19
|
+
export const shutdownProvider = Effect.gen(function* () {
|
|
20
|
+
const clients = yield* DevinClients;
|
|
21
|
+
yield* (yield* ProviderLifetime).end(new Cancelled({ message: "The Devin provider shut down." }));
|
|
22
|
+
yield* (yield* Runs).shutdown;
|
|
23
|
+
yield* clients.closeAll;
|
|
24
|
+
yield* clients.clearFingerprints;
|
|
25
|
+
yield* (yield* Accounts).clear;
|
|
26
|
+
});
|