@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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -2
  3. package/index.ts +16 -0
  4. package/package.json +62 -3
  5. package/proto/README.md +78 -0
  6. package/proto/buf/validate/validate.proto +453 -0
  7. package/proto/cel/expr/checked.proto +109 -0
  8. package/proto/cel/expr/eval.proto +44 -0
  9. package/proto/cel/expr/explain.proto +22 -0
  10. package/proto/cel/expr/syntax.proto +119 -0
  11. package/proto/cel/expr/value.proto +47 -0
  12. package/proto/connectext/grpc/status/v1/status.proto +21 -0
  13. package/proto/errorspb/errors.proto +58 -0
  14. package/proto/errorspb/hintdetail.proto +9 -0
  15. package/proto/errorspb/markers.proto +12 -0
  16. package/proto/errorspb/tags.proto +14 -0
  17. package/proto/errorspb/testing.proto +8 -0
  18. package/proto/exa/analytics_pb/analytics.proto +190 -0
  19. package/proto/exa/api_server_pb/api_server.proto +2590 -0
  20. package/proto/exa/auth_pb/auth.proto +21 -0
  21. package/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +81 -0
  22. package/proto/exa/browser_preview_pb/browser_preview.proto +26 -0
  23. package/proto/exa/bug_checker_pb/bug_checker.proto +24 -0
  24. package/proto/exa/cascade_plugins_pb/cascade_plugins.proto +264 -0
  25. package/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  26. package/proto/exa/chat_pb/chat.proto +452 -0
  27. package/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  28. package/proto/exa/codeium_common_pb/browser_context.proto +31 -0
  29. package/proto/exa/codeium_common_pb/codeium_common.proto +4183 -0
  30. package/proto/exa/context_module_pb/context_module.proto +174 -0
  31. package/proto/exa/cortex_pb/cortex.proto +3288 -0
  32. package/proto/exa/dev_pb/dev.proto +28 -0
  33. package/proto/exa/diff_action_pb/diff_action.proto +76 -0
  34. package/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +106 -0
  35. package/proto/exa/eval_pb/eval.proto +1318 -0
  36. package/proto/exa/extension_server_pb/extension_server.proto +555 -0
  37. package/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  38. package/proto/exa/index_pb/index.proto +469 -0
  39. package/proto/exa/knowledge_base_pb/knowledge_base.proto +146 -0
  40. package/proto/exa/language_server_pb/language_server.proto +2435 -0
  41. package/proto/exa/model_management_pb/model_management.proto +184 -0
  42. package/proto/exa/opensearch_clients_pb/opensearch_clients.proto +495 -0
  43. package/proto/exa/product_analytics_pb/product_analytics.proto +39 -0
  44. package/proto/exa/prompt_pb/prompt.proto +97 -0
  45. package/proto/exa/reactive_component_pb/reactive_component.proto +98 -0
  46. package/proto/exa/seat_management_pb/seat_management.proto +2910 -0
  47. package/proto/exa/tokenizer_pb/tokenizer.proto +39 -0
  48. package/proto/exa/trainer_pb/config.proto +643 -0
  49. package/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  50. package/proto/exa/trust_pb/trust.proto +192 -0
  51. package/proto/exa/user_analytics_pb/user_analytics.proto +752 -0
  52. package/proto/google/api/expr/v1alpha1/checked.proto +108 -0
  53. package/proto/google/api/expr/v1alpha1/eval.proto +38 -0
  54. package/proto/google/api/expr/v1alpha1/explain.proto +22 -0
  55. package/proto/google/api/expr/v1alpha1/syntax.proto +126 -0
  56. package/proto/google/api/expr/v1alpha1/value.proto +47 -0
  57. package/proto/google/protobuf/any.proto +15 -0
  58. package/proto/google/protobuf/cpp_features.proto +22 -0
  59. package/proto/google/protobuf/descriptor.proto +473 -0
  60. package/proto/google/protobuf/duration.proto +16 -0
  61. package/proto/google/protobuf/empty.proto +14 -0
  62. package/proto/google/protobuf/go_features.proto +31 -0
  63. package/proto/google/protobuf/struct.proto +34 -0
  64. package/proto/google/protobuf/timestamp.proto +16 -0
  65. package/proto/google/protobuf/wrappers.proto +47 -0
  66. package/proto/google/rpc/status.proto +18 -0
  67. package/proto/grpc/binlog/v1/binarylog.proto +89 -0
  68. package/proto/io/prometheus/client/metrics.proto +103 -0
  69. package/src/core/auth/access.ts +6 -0
  70. package/src/core/auth/index.ts +2 -0
  71. package/src/core/auth/login.ts +177 -0
  72. package/src/core/auth/rest-exchange.ts +35 -0
  73. package/src/core/client.ts +153 -0
  74. package/src/core/index.ts +12 -0
  75. package/src/core/inference/chat.ts +229 -0
  76. package/src/core/inference/images.ts +24 -0
  77. package/src/core/inference/messages.ts +34 -0
  78. package/src/core/inference/retry.ts +68 -0
  79. package/src/core/inference/state.ts +8 -0
  80. package/src/core/models/access.ts +7 -0
  81. package/src/core/models/catalog.ts +35 -0
  82. package/src/core/models/index.ts +4 -0
  83. package/src/core/models/select.ts +43 -0
  84. package/src/core/models/variants.ts +163 -0
  85. package/src/core/protocol/device.ts +73 -0
  86. package/src/core/protocol/fingerprint.ts +59 -0
  87. package/src/core/protocol/http.ts +109 -0
  88. package/src/core/protocol/protobuf.ts +112 -0
  89. package/src/core/protocol/transport.ts +501 -0
  90. package/src/core/types.ts +115 -0
  91. package/src/pi/auth.ts +134 -0
  92. package/src/pi/devin-command.ts +76 -0
  93. package/src/pi/devin-panel.ts +77 -0
  94. package/src/pi/devin-settings.ts +50 -0
  95. package/src/pi/extension.ts +80 -0
  96. package/src/pi/fusion-command.ts +83 -0
  97. package/src/pi/fusion-picker.ts +171 -0
  98. package/src/pi/fusion-settings.ts +44 -0
  99. package/src/pi/lifecycle.ts +217 -0
  100. package/src/pi/messages.ts +107 -0
  101. package/src/pi/models.ts +378 -0
  102. package/src/pi/provider.ts +241 -0
  103. package/src/pi/sidekick-report.ts +42 -0
  104. package/src/pi/sidekick-runtime.ts +287 -0
  105. package/src/pi/sidekick.ts +190 -0
  106. package/src/pi/stream.ts +124 -0
  107. package/src/pi/usage.ts +153 -0
@@ -0,0 +1,171 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { decodeKittyPrintable, fuzzyFilter, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { FusionChoices, FusionSelection } from "./models.ts";
4
+
5
+ export type PickerTheme = Pick<Theme, "fg" | "bold">;
6
+ interface Item { id: string; name: string; recommended?: true }
7
+
8
+ const VISIBLE_ROWS = 10;
9
+ /** Below this width, only the focused column is drawn instead of two side by side. */
10
+ const MIN_TWO_COLUMN_WIDTH = 60;
11
+ const SEPARATOR = " │ ";
12
+ const HELP = "tab switch column · ↑↓ move · type to search · ↵ save · esc cancel";
13
+
14
+ class PickerColumn {
15
+ readonly title: string;
16
+ readonly currentID: string | undefined;
17
+ readonly items: Item[];
18
+ query = "";
19
+ selectedID: string | undefined;
20
+ private offset = 0;
21
+
22
+ constructor(title: string, items: readonly Item[], currentID: string | undefined) {
23
+ this.title = title;
24
+ this.currentID = currentID;
25
+ // Recommended items come first; catalog order is kept within each group.
26
+ this.items = [...items.filter((item) => item.recommended), ...items.filter((item) => !item.recommended)];
27
+ this.selectedID = (this.items.find((item) => item.id === currentID) ?? this.items[0])?.id;
28
+ }
29
+
30
+ visible(): Item[] {
31
+ return this.query.trim() ? fuzzyFilter(this.items, this.query.trim(), (item) => `${item.name} ${item.id}`) : this.items;
32
+ }
33
+
34
+ selected(): Item | undefined {
35
+ return this.visible().find((item) => item.id === this.selectedID);
36
+ }
37
+
38
+ /** If the search hides the selected item, the selection moves to the first match. */
39
+ setQuery(query: string): void {
40
+ this.query = query;
41
+ const visible = this.visible();
42
+ if (!visible.some((item) => item.id === this.selectedID)) this.selectedID = visible[0]?.id ?? this.selectedID;
43
+ this.offset = 0;
44
+ }
45
+
46
+ move(delta: number): void {
47
+ const visible = this.visible();
48
+ if (!visible.length) return;
49
+ const index = Math.max(0, visible.findIndex((item) => item.id === this.selectedID));
50
+ const next = Math.abs(delta) === 1 ? (index + delta + visible.length) % visible.length : Math.min(visible.length - 1, Math.max(0, index + delta));
51
+ this.selectedID = visible[next].id;
52
+ }
53
+
54
+ /** Scrolls so the selected item is visible, then returns the visible rows. */
55
+ rows(count: number): { items: Item[]; total: number } {
56
+ const visible = this.visible();
57
+ const index = visible.findIndex((item) => item.id === this.selectedID);
58
+ if (index >= 0 && index < this.offset) this.offset = index;
59
+ if (index >= this.offset + count) this.offset = index - count + 1;
60
+ this.offset = Math.max(0, Math.min(this.offset, Math.max(0, visible.length - count)));
61
+ return { items: visible.slice(this.offset, this.offset + count), total: visible.length };
62
+ }
63
+ }
64
+
65
+ export interface FusionPickerOptions {
66
+ choices: FusionChoices;
67
+ current: FusionSelection | undefined;
68
+ theme: PickerTheme;
69
+ requestRender: () => void;
70
+ done: (selection: FusionSelection | undefined) => void;
71
+ }
72
+
73
+ /** TUI picker for `/devin-fusion`. Choose the lead and sidekick columns on one screen and save both with Enter. */
74
+ export class FusionPicker {
75
+ private readonly columns: [PickerColumn, PickerColumn];
76
+ private focus: 0 | 1 = 0;
77
+ private notice: string | undefined;
78
+ private cached: { width: number; lines: string[] } | undefined;
79
+ private readonly options: FusionPickerOptions;
80
+
81
+ constructor(options: FusionPickerOptions) {
82
+ this.options = options;
83
+ this.columns = [
84
+ new PickerColumn("LEAD", options.choices.leads, options.current?.leadID),
85
+ new PickerColumn("SIDEKICK", options.choices.sidekicks, options.current?.sidekickID),
86
+ ];
87
+ }
88
+
89
+ invalidate(): void { this.cached = undefined; }
90
+
91
+ private changed(): void { this.invalidate(); this.options.requestRender(); }
92
+
93
+ handleInput(data: string): void {
94
+ const column = this.columns[this.focus];
95
+ this.notice = undefined;
96
+ if (matchesKey(data, "ctrl+c")) return this.options.done(undefined);
97
+ if (matchesKey(data, "escape")) {
98
+ // While searching, the first esc only clears the search.
99
+ if (column.query) { column.setQuery(""); return this.changed(); }
100
+ return this.options.done(undefined);
101
+ }
102
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab") || matchesKey(data, "left") || matchesKey(data, "right")) {
103
+ this.focus = this.focus === 0 ? 1 : 0;
104
+ return this.changed();
105
+ }
106
+ if (matchesKey(data, "up")) { column.move(-1); return this.changed(); }
107
+ if (matchesKey(data, "down")) { column.move(1); return this.changed(); }
108
+ if (matchesKey(data, "pageUp")) { column.move(-VISIBLE_ROWS); return this.changed(); }
109
+ if (matchesKey(data, "pageDown")) { column.move(VISIBLE_ROWS); return this.changed(); }
110
+ if (matchesKey(data, "enter")) {
111
+ const [lead, sidekick] = this.columns.map((item) => item.selected());
112
+ if (lead && sidekick) return this.options.done({ leadID: lead.id, sidekickID: sidekick.id });
113
+ this.notice = `Pick a model in the ${lead ? "sidekick" : "lead"} column. Press esc to clear the search.`;
114
+ return this.changed();
115
+ }
116
+ if (matchesKey(data, "backspace")) {
117
+ if (column.query) { column.setQuery(column.query.slice(0, -1)); this.changed(); }
118
+ return;
119
+ }
120
+ // The Kitty keyboard protocol sends characters as CSI-u sequences. Other input counts as text only when it has no control characters.
121
+ const text = decodeKittyPrintable(data) ?? (/^[^\p{Cc}]+$/u.test(data) ? data : undefined);
122
+ if (text) { column.setQuery(column.query + text); this.changed(); }
123
+ }
124
+
125
+ render(width: number): string[] {
126
+ if (this.cached?.width === width) return this.cached.lines;
127
+ const { theme } = this.options;
128
+ const name = (column: PickerColumn, id: string | undefined) => column.items.find((item) => item.id === id)?.name ?? "none";
129
+ const lines = [
130
+ theme.fg("accent", theme.bold("Devin Fusion")) + theme.fg("muted", " · choose the lead and sidekick on one screen"),
131
+ theme.fg("muted", `Current: ${this.options.current ? `${name(this.columns[0], this.options.current.leadID)} + ${name(this.columns[1], this.options.current.sidekickID)}` : "no saved pair"}`),
132
+ "",
133
+ ];
134
+ if (width >= MIN_TWO_COLUMN_WIDTH) {
135
+ const columnWidth = Math.floor((width - SEPARATOR.length) / 2);
136
+ const [left, right] = this.columns.map((column, index) => this.renderColumn(column, index === this.focus, columnWidth));
137
+ const separator = theme.fg("borderMuted", SEPARATOR);
138
+ for (let index = 0; index < left.length; index++) lines.push(left[index] + separator + right[index]);
139
+ } else {
140
+ const tabs = this.columns.map((column, index) => index === this.focus ? theme.fg("accent", theme.bold(`[${column.title}]`)) : theme.fg("muted", column.title)).join(" ");
141
+ lines.push(tabs, ...this.renderColumn(this.columns[this.focus], true, width));
142
+ }
143
+ const [lead, sidekick] = this.columns.map((column) => column.selected()?.name ?? "—");
144
+ lines.push("", theme.fg("accent", `Selected: ${lead} + ${sidekick}`), this.notice ? theme.fg("warning", this.notice) : theme.fg("dim", HELP));
145
+ const result = lines.map((line) => truncateToWidth(line, width));
146
+ this.cached = { width, lines: result };
147
+ return result;
148
+ }
149
+
150
+ private renderColumn(column: PickerColumn, isFocused: boolean, width: number): string[] {
151
+ const { theme } = this.options;
152
+ const { items, total } = column.rows(VISIBLE_ROWS);
153
+ const count = `${total}/${column.items.length}`;
154
+ const query = column.query ? ` search: ${column.query}${isFocused ? "▏" : ""}` : isFocused ? " (type to search)" : "";
155
+ const header = truncateToWidth(`${isFocused ? "▸ " : " "}${column.title} ${count}${query}`, width, "…", true);
156
+ const lines = [isFocused ? theme.fg("accent", theme.bold(header)) : theme.fg("muted", header)];
157
+ for (let index = 0; index < VISIBLE_ROWS; index++) {
158
+ const item = items[index];
159
+ if (!item) {
160
+ const empty = index === 0 && total === 0 ? " no matching models" : "";
161
+ lines.push(theme.fg("dim", truncateToWidth(empty, width, "…", true)));
162
+ continue;
163
+ }
164
+ const isSelected = item.id === column.selectedID;
165
+ const marks = `${isSelected ? "›" : " "}${item.id === column.currentID ? "●" : " "}${item.recommended ? "★" : " "} `;
166
+ const cell = truncateToWidth(marks + item.name, width, "…", true);
167
+ lines.push(isSelected && isFocused ? theme.fg("accent", theme.bold(cell)) : isSelected ? cell : theme.fg("muted", cell));
168
+ }
169
+ return lines;
170
+ }
171
+ }
@@ -0,0 +1,44 @@
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
+ }
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { awaitWithSignal } from "../core/protocol/transport.ts";
3
+
4
+ export const DEVIN_STATE_ENTRY = "pi-devin.state.v1";
5
+ export type ExecutionKind = "agent" | "pending" | "manual" | "tree" | "direct" | "recovered";
6
+ export interface DevinSessionState {
7
+ version: 1;
8
+ sessionId: string;
9
+ ownerHash?: string;
10
+ cascadeId: string;
11
+ trajectoryId: string;
12
+ nextStepIndex: number;
13
+ execution?: { id: string; used: number; settled: boolean; kind: ExecutionKind };
14
+ }
15
+ export interface SessionBinding {
16
+ getSessionId(): string;
17
+ getBranch(): readonly { type: string; customType?: string; data?: unknown }[];
18
+ append(data: DevinSessionState): void;
19
+ }
20
+ interface Execution {
21
+ data: NonNullable<DevinSessionState["execution"]>;
22
+ controller: AbortController;
23
+ tail: Promise<void>;
24
+ cleanups: Set<() => void>;
25
+ activeRequests: number;
26
+ hasStarted: boolean;
27
+ nextIsRecovery: boolean;
28
+ hasCheckpoint: boolean;
29
+ }
30
+ export interface RequestLease {
31
+ signal: AbortSignal;
32
+ executionId: string;
33
+ cascadeId: string;
34
+ trajectoryId: string;
35
+ sessionId: string;
36
+ stepIndex: number;
37
+ isAuxiliary: boolean;
38
+ isRecovery: boolean;
39
+ consume(): void;
40
+ addCleanup(cleanup: () => void): void;
41
+ release(): void;
42
+ }
43
+ export class ModelBudgetError extends Error {
44
+ constructor(limit: number) { super(`Used all ${limit} Devin model HTTP requests in the budget.`); this.name = "ModelBudgetError"; }
45
+ }
46
+
47
+ /** Value of `PI_DEVIN_MAX_MODEL_CALLS`. Defaults to 8; anything other than an integer from 1 to 100 stops the extension from loading. */
48
+ export function modelCallLimit(value: string | undefined): number | undefined {
49
+ if (value === undefined || value === "") return undefined;
50
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 100) throw new Error("PI_DEVIN_MAX_MODEL_CALLS must be an integer from 1 to 100.");
51
+ return Number(value);
52
+ }
53
+ const cancelled = () => new DOMException("The previous Devin run ended.", "AbortError");
54
+ const isUUID = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
55
+
56
+ function restoreState(binding: SessionBinding): DevinSessionState {
57
+ const sessionId = binding.getSessionId();
58
+ const entry = [...binding.getBranch()].reverse().find((entry) => entry.type === "custom" && entry.customType === DEVIN_STATE_ENTRY);
59
+ if (!entry) return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
60
+ const value = entry.data as DevinSessionState | undefined;
61
+ if (!value || value.version !== 1 || typeof value.sessionId !== "string" || !isUUID(value.cascadeId) || !isUUID(value.trajectoryId)
62
+ || !Number.isSafeInteger(value.nextStepIndex) || value.nextStepIndex < 0 || value.nextStepIndex > 2_147_483_647
63
+ || (value.ownerHash !== undefined && !/^[a-f0-9]{64}$/.test(value.ownerHash))
64
+ || (value.execution !== undefined && (!isUUID(value.execution.id) || !Number.isSafeInteger(value.execution.used)
65
+ || value.execution.used < 0 || value.execution.used > 100 || typeof value.execution.settled !== "boolean"))) {
66
+ throw new Error("The Devin session state is corrupt or an unsupported version. Start a new session.");
67
+ }
68
+ if (value.sessionId !== sessionId) return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
69
+ return structuredClone(value);
70
+ }
71
+
72
+ /** One instance owns one Pi runtime. It never builds its own tool or retry loop. */
73
+ export class DevinLifecycle {
74
+ private binding?: SessionBinding;
75
+ private state: DevinSessionState = { version: 1, sessionId: randomUUID(), cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
76
+ private execution?: Execution;
77
+ private isClosed = false;
78
+ private isAuxiliary = false;
79
+ readonly maxModelCalls: number;
80
+ constructor(maxModelCalls = 8) {
81
+ if (!Number.isSafeInteger(maxModelCalls) || maxModelCalls < 1 || maxModelCalls > 100) throw new Error("The Devin request budget must be between 1 and 100.");
82
+ this.maxModelCalls = maxModelCalls;
83
+ }
84
+ snapshot(): DevinSessionState { return structuredClone(this.state); }
85
+ /** Model requests of the running or last finished run. used is 0 when this session has no run yet. */
86
+ budget(): { used: number; limit: number; isActive: boolean } {
87
+ const execution = this.state.execution;
88
+ return { used: execution?.used ?? 0, limit: this.maxModelCalls, isActive: execution !== undefined && !execution.settled };
89
+ }
90
+ private checkpoint(): void {
91
+ if (this.binding && this.binding.getSessionId() !== this.state.sessionId) throw cancelled();
92
+ this.binding?.append(structuredClone(this.state));
93
+ }
94
+ private activate(data: NonNullable<DevinSessionState["execution"]>, hasCheckpoint = false): Execution {
95
+ this.state.execution = data;
96
+ return this.execution = { data, controller: new AbortController(), tail: Promise.resolve(), cleanups: new Set(), activeRequests: 0, hasStarted: false, nextIsRecovery: false, hasCheckpoint };
97
+ }
98
+ attach(binding: SessionBinding): void {
99
+ this.abort(cancelled());
100
+ this.binding = binding;
101
+ this.state = restoreState(binding);
102
+ this.execution = undefined; this.isClosed = false; this.isAuxiliary = false;
103
+ if (this.state.execution && !this.state.execution.settled) {
104
+ this.activate({ ...this.state.execution, kind: "recovered" }, true);
105
+ }
106
+ }
107
+ begin(kind: ExecutionKind): void {
108
+ if (this.isClosed) throw cancelled();
109
+ if (this.execution && !this.execution.data.settled) {
110
+ if (kind === "agent" && ["pending", "recovered"].includes(this.execution.data.kind)) this.execution.data.kind = "agent";
111
+ else if (kind === "agent" && ["manual", "tree", "direct"].includes(this.execution.data.kind) && this.execution.activeRequests === 0) this.finish();
112
+ else return; // Retries and tool follow-up calls do not get a new budget.
113
+ if (this.execution) return;
114
+ }
115
+ this.activate({ id: randomUUID(), used: 0, settled: false, kind });
116
+ }
117
+ agentStarted(): void {
118
+ if (!this.execution) this.begin("agent");
119
+ this.execution!.nextIsRecovery = this.execution!.hasStarted;
120
+ this.execution!.hasStarted = true;
121
+ }
122
+ beforeCompaction(reason: "manual" | "threshold" | "overflow"): void {
123
+ this.begin(reason === "manual" ? "manual" : "pending");
124
+ this.isAuxiliary = true;
125
+ }
126
+ afterCompaction(reason: "manual" | "threshold" | "overflow"): void {
127
+ this.isAuxiliary = false;
128
+ if (reason === "manual" && this.execution?.data.kind === "manual") this.finish();
129
+ }
130
+ beforeTree(): void { this.begin("tree"); this.isAuxiliary = true; }
131
+ finish(): void {
132
+ if (!this.execution) return;
133
+ this.execution.data.settled = true;
134
+ this.abort(cancelled());
135
+ if (this.execution.hasCheckpoint) this.checkpoint();
136
+ this.execution = undefined; this.isAuxiliary = false;
137
+ }
138
+ abort(reason: unknown): void {
139
+ const active = this.execution;
140
+ if (!active) return;
141
+ if (!active.controller.signal.aborted) active.controller.abort(reason);
142
+ for (const cleanup of active.cleanups) { try { cleanup(); } catch { /* A cleanup error must not skip releasing other resources. */ } }
143
+ }
144
+ shutdown(): void {
145
+ if (this.isClosed) return;
146
+ this.abort(cancelled());
147
+ // An unfinished budget stays in the checkpoint so a reload cannot reset the limit.
148
+ this.isClosed = true;
149
+ }
150
+ treeChanged(): void {
151
+ if (!this.binding) return;
152
+ this.abort(cancelled());
153
+ this.state = restoreState(this.binding);
154
+ this.state.trajectoryId = randomUUID(); this.state.nextStepIndex = 0;
155
+ this.state.execution = undefined; this.execution = undefined; this.isAuxiliary = false;
156
+ if (this.state.ownerHash) this.checkpoint();
157
+ }
158
+ async acquire(ownerHash: string, callerSignal?: AbortSignal): Promise<RequestLease> {
159
+ if (this.isClosed) throw cancelled();
160
+ callerSignal?.throwIfAborted();
161
+ if (!/^[a-f0-9]{64}$/.test(ownerHash)) throw new Error("A verified Devin account scope is required.");
162
+ if (this.state.ownerHash && this.state.ownerHash !== ownerHash) throw new Error("This Pi session is bound to a different Devin account or endpoint. Start a new session.");
163
+ this.state.ownerHash = ownerHash;
164
+ if (!this.execution) this.begin("direct");
165
+ const active = this.execution!, state = this.state;
166
+ const signal = AbortSignal.any([active.controller.signal, ...(callerSignal ? [callerSignal] : [])]);
167
+ const previous = active.tail;
168
+ let releaseQueue!: () => void;
169
+ const completed = new Promise<void>((resolve) => { releaseQueue = resolve; });
170
+ active.tail = previous.then(() => completed);
171
+ let hasReleased = false, hasAcquired = false;
172
+ const ownCleanups = new Set<() => void>();
173
+ const clean = () => { for (const cleanup of ownCleanups) { try { cleanup(); } catch {} } };
174
+ const assertCurrent = () => {
175
+ signal.throwIfAborted();
176
+ if (this.execution !== active || this.state !== state || this.isClosed || (this.binding && this.binding.getSessionId() !== state.sessionId)) throw cancelled();
177
+ };
178
+ const release = () => {
179
+ if (hasReleased) return;
180
+ hasReleased = true;
181
+ signal.removeEventListener("abort", clean);
182
+ for (const cleanup of ownCleanups) { active.cleanups.delete(cleanup); try { cleanup(); } catch {} }
183
+ if (hasAcquired) active.activeRequests--;
184
+ releaseQueue();
185
+ };
186
+ try {
187
+ await awaitWithSignal(previous, signal);
188
+ assertCurrent();
189
+ if (active.data.used >= this.maxModelCalls) throw new ModelBudgetError(this.maxModelCalls);
190
+ if (state.nextStepIndex >= 2_147_483_647) throw new Error("The Devin step number limit was reached. Start a new session.");
191
+ active.activeRequests++; hasAcquired = true;
192
+ const stepIndex = state.nextStepIndex++;
193
+ active.hasCheckpoint = true;
194
+ this.checkpoint();
195
+ signal.addEventListener("abort", clean, { once: true });
196
+ const isAuxiliary = this.isAuxiliary;
197
+ const isRecovery = !isAuxiliary && active.nextIsRecovery;
198
+ if (!isAuxiliary) active.nextIsRecovery = false;
199
+ return {
200
+ signal, executionId: active.data.id, cascadeId: state.cascadeId, trajectoryId: state.trajectoryId,
201
+ sessionId: state.sessionId, stepIndex,
202
+ isAuxiliary, isRecovery,
203
+ consume: () => {
204
+ assertCurrent();
205
+ if (active.data.used >= this.maxModelCalls) { const error = new ModelBudgetError(this.maxModelCalls); this.abort(error); throw error; }
206
+ active.data.used++; this.checkpoint();
207
+ },
208
+ addCleanup: (cleanup) => {
209
+ let hasCleaned = false;
210
+ const once = () => { if (!hasCleaned) { hasCleaned = true; cleanup(); } };
211
+ ownCleanups.add(once); active.cleanups.add(once); if (signal.aborted) once();
212
+ },
213
+ release,
214
+ };
215
+ } catch (error) { release(); throw error; }
216
+ }
217
+ }
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai";
3
+ import { imageFromBase64, MAX_IMAGES_BYTES } from "../core/inference/images.ts";
4
+ import { object, requiredString, type ChatMessage, type ChatTool, type ModelInfo } from "../core/types.ts";
5
+ import { DEVIN_API } from "./models.ts";
6
+
7
+ const REPLAY_PREFIX = "devin:v1:";
8
+ const replayStrings = ["signature", "signatureType", "geminiThoughtSignature", "outputId", "thinkingId", "phase", "modelUid"] as const;
9
+ type ReplayMetadata = Pick<ChatMessage, typeof replayStrings[number] | "thinkingRedacted">;
10
+
11
+ export function encodeReplayMetadata(message: ChatMessage): string {
12
+ const metadata: ReplayMetadata = {};
13
+ for (const key of replayStrings) if (message[key] !== undefined) metadata[key] = message[key];
14
+ if (message.thinkingRedacted !== undefined) metadata.thinkingRedacted = message.thinkingRedacted;
15
+ return REPLAY_PREFIX + JSON.stringify(metadata);
16
+ }
17
+ function replayMetadata(value: string | undefined): ReplayMetadata | undefined {
18
+ if (!value?.startsWith(REPLAY_PREFIX)) return undefined;
19
+ let parsed: Record<string, unknown>;
20
+ try { parsed = object(JSON.parse(value.slice(REPLAY_PREFIX.length)), "Devin replay metadata"); }
21
+ catch { throw new Error("Invalid Devin response replay metadata."); }
22
+ const result: ReplayMetadata = {};
23
+ for (const key of replayStrings) {
24
+ if (parsed[key] !== undefined && typeof parsed[key] !== "string") throw new Error("Unexpected Devin response replay metadata format.");
25
+ if (typeof parsed[key] === "string") result[key] = parsed[key];
26
+ }
27
+ if (parsed.thinkingRedacted !== undefined) {
28
+ if (typeof parsed.thinkingRedacted !== "boolean") throw new Error("Invalid Devin redacted thinking metadata.");
29
+ result.thinkingRedacted = parsed.thinkingRedacted;
30
+ }
31
+ return result;
32
+ }
33
+
34
+ export function attachReplayMetadata(output: AssistantMessage, message: ChatMessage): void {
35
+ const signature = encodeReplayMetadata(message);
36
+ for (const block of output.content) {
37
+ if (block.type === "thinking") block.thinkingSignature = signature;
38
+ else if (block.type === "text") block.textSignature = signature;
39
+ else block.thoughtSignature = signature;
40
+ }
41
+ }
42
+
43
+ export function toDevinTranscript(context: TranscriptContext, model: ModelInfo): { messages: ChatMessage[]; tools: ChatTool[]; systemPrompt: string } {
44
+ const declarations = getCurrentTools(context.messages);
45
+ if (declarations.length && !model.supportsToolCalls) throw new Error("This model does not support the current Pi tool declarations.");
46
+ const tools = declarations.map((tool) => {
47
+ if (tool.constrainedSampling && (tool.constrainedSampling.type === "grammar" || tool.constrainedSampling.strict === "require")) {
48
+ throw new Error(`Devin does not support this tool's forced sampling format: ${tool.name}`);
49
+ }
50
+ return { name: tool.name, description: tool.description, jsonSchemaString: JSON.stringify(tool.parameters) };
51
+ });
52
+ const failedCalls = new Set(context.messages.flatMap((message) => message.role === "assistant" && ["error", "aborted"].includes(message.stopReason)
53
+ ? message.content.filter((block) => block.type === "toolCall").map((block) => block.id) : []));
54
+ const messages: ChatMessage[] = [];
55
+ for (const message of context.messages) {
56
+ if (message.role === "system") continue;
57
+ if (message.role === "assistant") {
58
+ if (message.stopReason === "error" || message.stopReason === "aborted") continue;
59
+ if (message.stopReason === "pending") throw new Error("An unfinished assistant response cannot be sent as Devin history.");
60
+ const own = message.provider === "devin" && message.api === DEVIN_API;
61
+ const result: ChatMessage = { messageId: message.responseId ?? randomUUID(), source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
62
+ let replay: ReplayMetadata | undefined;
63
+ for (const block of message.content) {
64
+ if (block.type === "text") result.prompt += block.text;
65
+ else if (block.type === "thinking") {
66
+ if (!block.redacted) result.thinking = (result.thinking ?? "") + block.thinking;
67
+ else result.thinkingRedacted = true;
68
+ } else {
69
+ result.toolCalls ??= [];
70
+ result.toolCalls.push({ id: requiredString(block.id, "tool call id"), name: block.namespace ? `${block.namespace}.${block.name}` : block.name,
71
+ argumentsJson: JSON.stringify(object(block.arguments, "tool arguments")) });
72
+ }
73
+ if (own) {
74
+ const metadata = replayMetadata(block.type === "text" ? block.textSignature : block.type === "thinking" ? block.thinkingSignature : block.thoughtSignature);
75
+ if (metadata) {
76
+ if (replay && JSON.stringify(replay) !== JSON.stringify(metadata)) throw new Error("Devin replay metadata differs within one response.");
77
+ replay = metadata;
78
+ }
79
+ }
80
+ }
81
+ Object.assign(result, replay);
82
+ if (own && message.providerThinkingLevel) result.modelUid = message.providerThinkingLevel;
83
+ if (result.thinkingRedacted) result.thinking = "";
84
+ messages.push(result);
85
+ continue;
86
+ }
87
+ if (message.role === "toolResult" && failedCalls.has(message.toolCallId)) continue;
88
+ const result: ChatMessage = {
89
+ messageId: randomUUID(), source: message.role === "toolResult" ? "CHAT_MESSAGE_SOURCE_TOOL" : "CHAT_MESSAGE_SOURCE_USER", prompt: "",
90
+ ...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolResultIsError: message.isError } : {}),
91
+ };
92
+ let imageBytes = 0;
93
+ const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] : message.content;
94
+ for (const block of content) {
95
+ if (block.type === "text") result.prompt += block.text;
96
+ else {
97
+ if (!model.supportsImages) throw new Error("This model does not support images in history or tool results.");
98
+ const converted = imageFromBase64(block.data, block.mimeType);
99
+ imageBytes += converted.sizeBytes;
100
+ if (imageBytes > MAX_IMAGES_BYTES) throw new Error(`Images in one message must total at most ${MAX_IMAGES_BYTES} bytes.`);
101
+ (result.images ??= []).push(converted.image);
102
+ }
103
+ }
104
+ messages.push(result);
105
+ }
106
+ return { messages, tools, systemPrompt: getCurrentSystemPrompt(context.messages) };
107
+ }