@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.
Files changed (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -0,0 +1,130 @@
1
+ import { Agent as HTTPAgent, request as httpRequest, type IncomingMessage } from "node:http";
2
+ import { Agent as HTTPSAgent, request as httpsRequest } from "node:https";
3
+ import { pipeline, Readable } from "node:stream";
4
+ import type { ConnectionOptions } from "node:tls";
5
+ import { createGunzip } from "node:zlib";
6
+ import { Effect, type Scope } from "effect";
7
+ import { HttpClient, HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/http";
8
+ import { DevinHttpResponse } from "./response.ts";
9
+
10
+ export interface HTTPTransportOptions {
11
+ /** Trusts a user-supplied CA without disabling certificate or host verification. */
12
+ ca?: ConnectionOptions["ca"];
13
+ }
14
+
15
+ export function transportError(request: HttpClientRequest.HttpClientRequest, cause: unknown): HttpClientError.HttpClientError {
16
+ return new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, cause }) });
17
+ }
18
+
19
+ /** The request body as the transports send it: bytes or text, never a stream. */
20
+ export function requestBody(request: HttpClientRequest.HttpClientRequest): Uint8Array | string | undefined {
21
+ const body = request.body;
22
+ if (body._tag === "Uint8Array") return body.body;
23
+ if (body._tag === "Raw" && (typeof body.body === "string" || body.body instanceof Uint8Array)) return body.body;
24
+ return undefined;
25
+ }
26
+
27
+ function nodeResponse(incoming: IncomingMessage): Response | Error {
28
+ const responseHeaders = new Headers();
29
+ for (const [name, value] of Object.entries(incoming.headers)) {
30
+ for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) responseHeaders.append(name, entry);
31
+ }
32
+ const status = incoming.statusCode ?? 502;
33
+ const hasNoBody = [204, 205, 304].includes(status);
34
+ const encoding = responseHeaders.get("content-encoding")?.trim().toLowerCase();
35
+ let stream: Readable = incoming;
36
+ if (hasNoBody) incoming.resume();
37
+ else if (encoding && encoding !== "identity") {
38
+ if (encoding !== "gzip") {
39
+ // An encoding problem in an error body must not hide an already received 401/403/429 status.
40
+ if (status >= 200 && status < 300) return new Error("Unsupported HTTP response compression.");
41
+ } else {
42
+ const decoded = createGunzip();
43
+ // Propagates consumer cancellation and compression errors to the original socket and keeps backpressure.
44
+ pipeline(incoming, decoded, () => {});
45
+ stream = decoded;
46
+ responseHeaders.delete("content-encoding");
47
+ responseHeaders.delete("content-length");
48
+ }
49
+ }
50
+ try {
51
+ return new Response(hasNoBody ? null : Readable.toWeb(stream) as ReadableStream<Uint8Array>, { status, headers: responseHeaders });
52
+ } catch (error) {
53
+ return error instanceof Error ? error : new Error(String(error));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * HTTP/1.1-only `HttpClient` over `node:http`. Owns a pool for the scope's lifetime and verifies TLS certificates and hosts.
59
+ * The 5000ms socket timeout spells out the previous Node global agent setting as a PoC policy;
60
+ * it is not the overall RPC deadline. It does not claim to match the CLI's TLS or pool policy.
61
+ */
62
+ export function makeNodeHttpClient(options: HTTPTransportOptions = {}): Effect.Effect<HttpClient.HttpClient, never, Scope.Scope> {
63
+ return Effect.gen(function* () {
64
+ const pool = yield* Effect.acquireRelease(Effect.sync(() => ({
65
+ isClosed: false,
66
+ http: new HTTPAgent({ keepAlive: true, timeout: 5000 }),
67
+ https: new HTTPSAgent({
68
+ keepAlive: true, timeout: 5000, rejectUnauthorized: true, minVersion: "TLSv1.2",
69
+ ALPNProtocols: ["http/1.1"], ...(options.ca === undefined ? {} : { ca: options.ca }),
70
+ }),
71
+ })), (pool) => Effect.sync(() => {
72
+ pool.isClosed = true;
73
+ pool.http.destroy();
74
+ pool.https.destroy();
75
+ }));
76
+
77
+ const send = (request: HttpClientRequest.HttpClientRequest): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> => Effect.suspend(() => {
78
+ if (pool.isClosed) return Effect.fail(transportError(request, new Error("The HTTP transport is closed.")));
79
+ if (request.method !== "POST" && request.method !== "GET") return Effect.fail(transportError(request, new Error("This transport supports only URL GET/POST without redirects.")));
80
+ let url: URL;
81
+ try { url = new URL(request.url); } catch (error) { return Effect.fail(transportError(request, error)); }
82
+ if (!["https:", "http:"].includes(url.protocol)) return Effect.fail(transportError(request, new Error("Unsupported HTTP protocol.")));
83
+ const raw = requestBody(request);
84
+ const body = raw === undefined ? undefined : Buffer.from(raw);
85
+ if (request.method === "POST" && !body) return Effect.fail(transportError(request, new Error("A POST body must be a string or a byte array.")));
86
+ if (request.method === "GET" && body) return Effect.fail(transportError(request, new Error("A GET body is not supported.")));
87
+ let headers: Record<string, string>;
88
+ try {
89
+ // Validates names and values the same way fetch does before anything is sent.
90
+ const validated = new Headers(Object.entries(request.headers));
91
+ headers = {};
92
+ for (const [name, value] of validated) if (name !== "host" && name !== "content-length") headers[name] = value;
93
+ } catch (error) { return Effect.fail(transportError(request, error)); }
94
+ headers.host = url.host;
95
+ if (body) headers["content-length"] = String(body.byteLength);
96
+ const isHTTPS = url.protocol === "https:";
97
+ return Effect.callback<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>((resume) => {
98
+ let isSettled = false;
99
+ const fail = (cause: unknown) => {
100
+ if (isSettled) return;
101
+ isSettled = true;
102
+ resume(Effect.fail(transportError(request, cause)));
103
+ };
104
+ const outgoing = (isHTTPS ? httpsRequest : httpRequest)(url, {
105
+ method: request.method, headers, agent: isHTTPS ? pool.https : pool.http,
106
+ }, (incoming) => {
107
+ if (isSettled) { incoming.destroy(); return; }
108
+ const response = nodeResponse(incoming);
109
+ if (response instanceof Error) { incoming.destroy(); fail(response); return; }
110
+ isSettled = true;
111
+ resume(Effect.succeed(new DevinHttpResponse(request, response, () => { incoming.destroy(); outgoing.destroy(); })));
112
+ });
113
+ outgoing.on("error", fail);
114
+ outgoing.once("upgrade", (_response, socket) => {
115
+ socket.destroy();
116
+ fail(new Error("HTTP upgrade is not supported."));
117
+ });
118
+ outgoing.removeHeader("connection");
119
+ outgoing.end(body);
120
+ return Effect.sync(() => {
121
+ if (isSettled) return;
122
+ isSettled = true;
123
+ outgoing.destroy();
124
+ });
125
+ });
126
+ });
127
+
128
+ return HttpClient.makeWith<HttpClientError.HttpClientError, never, HttpClientError.HttpClientError, never>((request) => Effect.flatMap(request, send), Effect.succeed);
129
+ });
130
+ }
@@ -0,0 +1,99 @@
1
+ import { Effect, Inspectable, Option, Stream } from "effect";
2
+ import { pipeArguments } from "effect/Pipeable";
3
+ import { HttpClientError, HttpClientResponse, HttpIncomingMessage, type HttpClientRequest } from "effect/http";
4
+
5
+ const ignore = () => {};
6
+
7
+ /**
8
+ * A web `Response` seen through Effect's `HttpClientResponse`. Unlike `HttpClientResponse.fromWeb`, ending its body early
9
+ * never waits for an uncooperative `cancel()`, and the transport is stopped only while the body is still in flight,
10
+ * so a fully read keep-alive connection stays reusable.
11
+ */
12
+ export class DevinHttpResponse extends Inspectable.Class implements HttpClientResponse.HttpClientResponse {
13
+ readonly [HttpClientResponse.TypeId] = HttpClientResponse.TypeId;
14
+ readonly [HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId;
15
+ readonly request: HttpClientRequest.HttpClientRequest;
16
+ readonly source: Response;
17
+ private readonly web: HttpClientResponse.HttpClientResponse;
18
+ private readonly abortTransport: () => void;
19
+ private isBodySettled = false;
20
+
21
+ constructor(request: HttpClientRequest.HttpClientRequest, source: Response, abortTransport: () => void) {
22
+ super();
23
+ this.request = request;
24
+ this.source = source;
25
+ this.abortTransport = abortTransport;
26
+ this.web = HttpClientResponse.fromWeb(request, source);
27
+ }
28
+
29
+ get url() { return this.web.url; }
30
+ get status() { return this.source.status; }
31
+ get headers() { return this.web.headers; }
32
+ get cookies() { return this.web.cookies; }
33
+ get remoteAddress() { return Option.none<string>(); }
34
+ get formData() { return this.web.formData; }
35
+ get json() { return this.web.json; }
36
+ get text() { return this.web.text; }
37
+ get urlParamsBody() { return this.web.urlParamsBody; }
38
+ get arrayBuffer() { return this.web.arrayBuffer; }
39
+
40
+ /** Stops an unread or partly read body. Responses that arrive after their request ended are discarded the same way. */
41
+ get discard(): Effect.Effect<void> {
42
+ return Effect.sync(() => this.abandon(undefined));
43
+ }
44
+
45
+ private abandon(reader: ReadableStreamDefaultReader<Uint8Array> | undefined): void {
46
+ if (this.isBodySettled) return;
47
+ this.isBodySettled = true;
48
+ try {
49
+ if (reader) { void reader.cancel().catch(ignore); reader.releaseLock(); }
50
+ else if (this.source.body && !this.source.body.locked) void this.source.body.cancel().catch(ignore);
51
+ } finally { this.abortTransport(); }
52
+ }
53
+
54
+ get stream(): Stream.Stream<Uint8Array, HttpClientError.HttpClientError> {
55
+ return Stream.suspend(() => {
56
+ const body = this.source.body;
57
+ if (!body || this.isBodySettled) {
58
+ return Stream.fail(new HttpClientError.HttpClientError({
59
+ reason: new HttpClientError.EmptyBodyError({ request: this.request, response: this, description: "The response has no body." }),
60
+ }));
61
+ }
62
+ const reader = body.getReader();
63
+ const iterator: AsyncIterator<Uint8Array> = {
64
+ next: async () => {
65
+ try {
66
+ const item = await reader.read();
67
+ if (item.done && !this.isBodySettled) { this.isBodySettled = true; reader.releaseLock(); }
68
+ return item.done ? { done: true, value: undefined } : item;
69
+ } catch (error) {
70
+ this.abandon(reader);
71
+ throw error;
72
+ }
73
+ },
74
+ // Called when the consumer stops early. Cancellation is started but never awaited.
75
+ return: async () => {
76
+ this.abandon(reader);
77
+ return { done: true, value: undefined };
78
+ },
79
+ };
80
+ return Stream.fromAsyncIterable({ [Symbol.asyncIterator]: () => iterator }, (cause) => new HttpClientError.HttpClientError({
81
+ reason: new HttpClientError.DecodeError({ request: this.request, response: this, cause }),
82
+ }));
83
+ });
84
+ }
85
+
86
+ toJSON(): unknown {
87
+ return HttpIncomingMessage.inspect(this, { _id: "HttpClientResponse", request: this.request.toJSON(), status: this.status });
88
+ }
89
+
90
+ pipe() {
91
+ // biome-ignore lint/complexity/noArguments: Pipeable forwards the call's arguments.
92
+ return pipeArguments(this, arguments);
93
+ }
94
+ }
95
+
96
+ /** Discards the body of a response this package's transports produced. */
97
+ export function discardResponse(response: HttpClientResponse.HttpClientResponse): Effect.Effect<void> {
98
+ return response instanceof DevinHttpResponse ? response.discard : Effect.void;
99
+ }
@@ -1,109 +1,49 @@
1
- import { Agent as HTTPAgent, request as httpRequest } from "node:http";
2
- import { Agent as HTTPSAgent, request as httpsRequest } from "node:https";
3
- import { Readable, pipeline } from "node:stream";
4
- import type { ConnectionOptions } from "node:tls";
5
- import { createGunzip } from "node:zlib";
1
+ import { Effect, Stream } from "effect";
2
+ import type { HttpClientError, HttpClientResponse } from "effect/http";
3
+ import { DevinError, toDevinFailure, type DevinFailure } from "../errors.ts";
4
+ import { failWith } from "../validation.ts";
6
5
 
7
- export interface HTTPTransportOptions {
8
- /** Trusts a user-supplied CA without disabling certificate or host verification. */
9
- ca?: ConnectionOptions["ca"];
6
+ export { discardResponse } from "./http/response.ts";
7
+ export { makeFetchHttpClient } from "./http/fetch-client.ts";
8
+ export { makeNodeHttpClient, type HTTPTransportOptions } from "./http/node-client.ts";
9
+
10
+ /** Keeps the transport cause (message and Node error code) so callers can tell network failures apart. */
11
+ export function fromHttpClientError(error: HttpClientError.HttpClientError): DevinFailure {
12
+ const reason = error.reason;
13
+ switch (reason._tag) {
14
+ case "TransportError":
15
+ case "DecodeError":
16
+ case "EncodeError":
17
+ case "InvalidUrlError": return toDevinFailure(reason.cause ?? reason.description ?? error.message);
18
+ case "EmptyBodyError": return new DevinError({ message: reason.description ?? "The response has no body." });
19
+ case "StatusCodeError": return new DevinError({ message: reason.description ?? error.message });
20
+ }
10
21
  }
11
22
 
12
- export interface HTTPTransport {
13
- fetch: typeof fetch;
14
- close(): void;
23
+ /** The response body as chunks, failing as Devin failures. */
24
+ export function responseChunks(response: HttpClientResponse.HttpClientResponse): Stream.Stream<Uint8Array, DevinFailure> {
25
+ return Stream.mapError(response.stream, fromHttpClientError);
15
26
  }
16
27
 
17
- /**
18
- * HTTP/1.1-only transport. Owns a per-client pool and verifies TLS certificates and hosts.
19
- * The 5000ms socket timeout spells out the previous Node global agent setting as a PoC policy;
20
- * it is not the overall RPC deadline. It does not claim to match the CLI's TLS or pool policy.
21
- */
22
- export function createHTTPTransport(options: HTTPTransportOptions = {}): HTTPTransport {
23
- const httpAgent = new HTTPAgent({ keepAlive: true, timeout: 5000 });
24
- const httpsAgent = new HTTPSAgent({
25
- keepAlive: true, timeout: 5000, rejectUnauthorized: true, minVersion: "TLSv1.2",
26
- ALPNProtocols: ["http/1.1"], ...(options.ca === undefined ? {} : { ca: options.ca }),
28
+ export function readBoundedBytes(response: HttpClientResponse.HttpClientResponse, maxBytes: number): Effect.Effect<Buffer, DevinFailure> {
29
+ return Effect.suspend(() => {
30
+ let bytes = 0;
31
+ const chunks: Uint8Array[] = [];
32
+ return responseChunks(response).pipe(
33
+ Stream.runForEach((chunk) => {
34
+ bytes += chunk.byteLength;
35
+ if (bytes > maxBytes) return failWith("The response size limit was exceeded.");
36
+ chunks.push(chunk);
37
+ return Effect.void;
38
+ }),
39
+ Effect.map(() => Buffer.concat(chunks)),
40
+ );
27
41
  });
28
- let isClosed = false;
29
- const fetchHTTP: typeof fetch = async (input, init) => {
30
- if (isClosed) throw new Error("The HTTP transport is closed.");
31
- if (!(typeof input === "string" || input instanceof URL) || !["POST", "GET"].includes(init?.method ?? "") || init?.redirect !== "error") {
32
- throw new Error("This transport supports only URL GET/POST without redirects.");
33
- }
34
- const url = new URL(input);
35
- if (!["https:", "http:"].includes(url.protocol)) throw new Error("Unsupported HTTP protocol.");
36
- const body = typeof init.body === "string" ? Buffer.from(init.body)
37
- : init.body instanceof Uint8Array ? Buffer.from(init.body) : undefined;
38
- if (init.method === "POST" && !body) throw new Error("A POST body must be a string or a byte array.");
39
- if (init.method === "GET" && init.body != null) throw new Error("A GET body is not supported.");
40
- const validated = new Headers(init.headers);
41
- const entries = init.headers instanceof Headers ? [...init.headers.entries()]
42
- : Array.isArray(init.headers) ? init.headers : Object.entries(init.headers ?? {});
43
- const headers: Record<string, string> = {};
44
- for (const [name] of entries) {
45
- const key = name.toLowerCase();
46
- if (key !== "host" && key !== "content-length") headers[key] = validated.get(name)!;
47
- }
48
- headers.host = url.host;
49
- if (body) headers["content-length"] = String(body.byteLength);
50
- return new Promise<Response>((resolve, reject) => {
51
- const isHTTPS = url.protocol === "https:";
52
- const send = isHTTPS ? httpsRequest : httpRequest;
53
- const request = send(url, {
54
- method: init.method, headers, signal: init.signal ?? undefined,
55
- agent: isHTTPS ? httpsAgent : httpAgent,
56
- }, (incoming) => {
57
- try {
58
- const responseHeaders = new Headers();
59
- for (const [name, value] of Object.entries(incoming.headers)) {
60
- for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) responseHeaders.append(name, entry);
61
- }
62
- const status = incoming.statusCode ?? 502;
63
- const hasNoBody = [204, 205, 304].includes(status);
64
- const encoding = responseHeaders.get("content-encoding")?.trim().toLowerCase();
65
- let stream: Readable = incoming;
66
- if (hasNoBody) incoming.resume();
67
- else if (encoding && encoding !== "identity") {
68
- if (encoding !== "gzip") {
69
- // An encoding problem in an error body must not hide an already received 401/403/429 status.
70
- if (status >= 200 && status < 300) throw new Error("Unsupported HTTP response compression.");
71
- } else {
72
- const decoded = createGunzip();
73
- // Propagates consumer cancellation and compression errors to the original socket and keeps backpressure.
74
- pipeline(incoming, decoded, () => {});
75
- stream = decoded;
76
- responseHeaders.delete("content-encoding");
77
- responseHeaders.delete("content-length");
78
- }
79
- }
80
- resolve(new Response(hasNoBody ? null : Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
81
- status, headers: responseHeaders,
82
- }));
83
- } catch (error) {
84
- incoming.destroy();
85
- reject(error);
86
- }
87
- });
88
- request.once("error", reject);
89
- request.once("upgrade", (_response, socket) => {
90
- socket.destroy();
91
- reject(new Error("HTTP upgrade is not supported."));
92
- });
93
- request.removeHeader("connection");
94
- request.end(body);
95
- });
96
- };
97
- return {
98
- fetch: fetchHTTP,
99
- close() {
100
- if (isClosed) return;
101
- isClosed = true;
102
- httpAgent.destroy();
103
- httpsAgent.destroy();
104
- },
105
- };
106
42
  }
107
43
 
108
- /** Default transport for existing direct callers. ConnectClient owns its own pool. */
109
- export const requestHTTP: typeof fetch = createHTTPTransport().fetch;
44
+ export function readBoundedJSON(response: HttpClientResponse.HttpClientResponse, maxBytes: number): Effect.Effect<unknown, DevinFailure> {
45
+ return Effect.flatMap(readBoundedBytes(response, maxBytes), (bytes) => Effect.try({
46
+ try: () => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown,
47
+ catch: () => new DevinError({ message: "The server did not return valid JSON." }),
48
+ }));
49
+ }
@@ -1,20 +1,21 @@
1
1
  import { readdirSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { Effect } from "effect";
4
5
  import protobuf from "protobufjs";
5
- import { object, type JsonObject } from "../types.ts";
6
+ import { DevinError, toDevinError } from "../errors.ts";
7
+ import type { JsonObject } from "../types.ts";
8
+ import { failWith, object } from "../validation.ts";
6
9
 
7
10
  export const CHAT_METHOD = "exa.api_server_pb.ApiServerService/GetChatMessage";
8
11
 
9
12
  export interface MessageCodec {
10
- encode(value: JsonObject): Uint8Array;
11
- decode(bytes: Uint8Array): JsonObject;
13
+ encode(value: JsonObject): Effect.Effect<Uint8Array, DevinError>;
14
+ decode(bytes: Uint8Array): Effect.Effect<JsonObject, DevinError>;
12
15
  }
13
16
 
14
- let root: protobuf.Root | undefined;
15
- const camelCase = (name: string) => name.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
16
-
17
17
  const PROTO_ROOT = fileURLToPath(new URL("../../../proto/", import.meta.url));
18
+ const camelCase = (name: string) => name.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
18
19
 
19
20
  function protoSources(directory: string, out: string[] = []): string[] {
20
21
  for (const entry of readdirSync(directory)) {
@@ -25,87 +26,105 @@ function protoSources(directory: string, out: string[] = []): string[] {
25
26
  return out;
26
27
  }
27
28
 
28
- function getRoot(): protobuf.Root {
29
- if (!root) {
30
- // The schema is the shipped `proto/` source tree, parsed directly. The four definitions this
31
- // package added from its own protocol analysis are marked in the sources, so nothing is
32
- // patched in at runtime. keepCase keeps the declared snake_case names the DTO boundary uses.
33
- root = new protobuf.Root();
29
+ let loadedRoot: protobuf.Root | undefined;
30
+
31
+ /**
32
+ * The schema is the shipped `proto/` source tree, parsed once per process. The four definitions this
33
+ * package added from its own protocol analysis are marked in the sources, so nothing is patched in at
34
+ * runtime. keepCase keeps the declared snake_case names the DTO boundary uses.
35
+ */
36
+ const schemaRoot: Effect.Effect<protobuf.Root, DevinError> = Effect.suspend(() => loadedRoot ? Effect.succeed(loadedRoot) : Effect.try({
37
+ try: () => {
38
+ const root = new protobuf.Root();
34
39
  root.resolvePath = (_origin: string, target: string) => isAbsolute(target) ? target : join(PROTO_ROOT, target);
35
40
  root.loadSync(protoSources(PROTO_ROOT), { keepCase: true });
36
41
  root.resolveAll();
37
- }
38
- return root;
39
- }
42
+ loadedRoot = root;
43
+ return root;
44
+ },
45
+ catch: toDevinError,
46
+ }));
40
47
 
41
48
  /** Converts between camelCase DTOs and the original snake_case at the boundary; user keys inside maps stay unchanged. */
42
- function convertFields(type: protobuf.Type, value: JsonObject, isEncoding: boolean, path: string): JsonObject {
43
- const result: [string, unknown][] = [];
44
- for (const [name, entry] of Object.entries(value)) {
45
- if (entry === undefined || entry === null) continue;
46
- const field = isEncoding ? type.fieldsArray.find((candidate) => camelCase(candidate.name) === name)
47
- : Object.hasOwn(type.fields, name) ? type.fields[name] : undefined;
48
- if (!field) throw new Error(`Field not in the protobuf schema: ${path}.${name}`);
49
- const convert = (item: unknown): unknown => {
50
- if (field.resolvedType instanceof protobuf.Type) {
51
- return convertFields(field.resolvedType, object(item, `${path}.${name}`), isEncoding, `${path}.${name}`);
52
- }
53
- // Prevents fromObject from silently turning an invalid enum into 0.
54
- if (isEncoding && field.resolvedType instanceof protobuf.Enum) {
55
- const isValid = typeof item === "string" ? Object.hasOwn(field.resolvedType.values, item)
56
- : typeof item === "number" && Object.hasOwn(field.resolvedType.valuesById, item);
57
- if (!isValid) throw new Error(`Unknown protobuf enum: ${path}.${name}`);
58
- }
59
- return item;
60
- };
61
- let converted: unknown;
62
- if (field.map) converted = Object.fromEntries(Object.entries(object(entry, `${path}.${name}`)).map(([key, item]) => [key, convert(item)]));
63
- else if (field.repeated) {
64
- if (!Array.isArray(entry)) throw new Error(`Field requires an array: ${path}.${name}`);
65
- converted = entry.map(convert);
66
- } else converted = convert(entry);
67
- result.push([isEncoding ? field.name : camelCase(field.name), converted]);
68
- }
69
- return Object.fromEntries(result);
49
+ function convertFields(type: protobuf.Type, value: JsonObject, isEncoding: boolean, path: string): Effect.Effect<JsonObject, DevinError> {
50
+ return Effect.gen(function* () {
51
+ const result: [string, unknown][] = [];
52
+ for (const [name, entry] of Object.entries(value)) {
53
+ if (entry === undefined || entry === null) continue;
54
+ const field = isEncoding ? type.fieldsArray.find((candidate) => camelCase(candidate.name) === name)
55
+ : Object.hasOwn(type.fields, name) ? type.fields[name] : undefined;
56
+ if (!field) return yield* failWith(`Field not in the protobuf schema: ${path}.${name}`);
57
+ const label = `${path}.${name}`;
58
+ const convert = (item: unknown): Effect.Effect<unknown, DevinError> => {
59
+ if (field.resolvedType instanceof protobuf.Type) {
60
+ const nested = field.resolvedType;
61
+ return object(item, label).pipe(Effect.flatMap((fields) => convertFields(nested, fields, isEncoding, label)));
62
+ }
63
+ // Prevents fromObject from silently turning an invalid enum into 0.
64
+ if (isEncoding && field.resolvedType instanceof protobuf.Enum) {
65
+ const values = field.resolvedType;
66
+ const isValid = typeof item === "string" ? Object.hasOwn(values.values, item)
67
+ : typeof item === "number" && Object.hasOwn(values.valuesById, item);
68
+ if (!isValid) return failWith(`Unknown protobuf enum: ${label}`);
69
+ }
70
+ return Effect.succeed(item);
71
+ };
72
+ let converted: unknown;
73
+ if (field.map) {
74
+ const entries = Object.entries(yield* object(entry, label));
75
+ converted = Object.fromEntries(yield* Effect.forEach(entries, ([key, item]) => Effect.map(convert(item), (mapped) => [key, mapped] as const)));
76
+ } else if (field.repeated) {
77
+ if (!Array.isArray(entry)) return yield* failWith(`Field requires an array: ${label}`);
78
+ converted = yield* Effect.forEach(entry, convert);
79
+ } else converted = yield* convert(entry);
80
+ result.push([isEncoding ? field.name : camelCase(field.name), converted]);
81
+ }
82
+ return Object.fromEntries(result);
83
+ });
70
84
  }
71
85
 
72
86
  function messageCodec(typeName: string): MessageCodec {
73
- const getType = () => getRoot().lookupType(typeName);
87
+ const messageType = Effect.flatMap(schemaRoot, (root) => Effect.try({ try: () => root.lookupType(typeName), catch: toDevinError }));
74
88
  return {
75
- encode(value) {
76
- const type = getType();
77
- const message = type.fromObject(convertFields(type, value, true, typeName));
89
+ encode: (value) => Effect.gen(function* () {
90
+ const type = yield* messageType;
91
+ const fields = yield* convertFields(type, value, true, typeName);
92
+ const message = yield* Effect.try({ try: () => type.fromObject(fields), catch: toDevinError });
78
93
  const error = type.verify(message);
79
- if (error) throw new Error(`Invalid protobuf input: ${error}`);
80
- return type.encode(message).finish();
81
- },
82
- decode(bytes) {
83
- const type = getType();
84
- try {
85
- const decoded = object(type.toObject(type.decode(bytes), {
86
- longs: String, enums: String, bytes: String, defaults: false,
87
- }), typeName);
88
- return convertFields(type, decoded, false, typeName);
89
- } catch {
90
- throw new Error(`Invalid protobuf response: ${typeName}`);
91
- }
92
- },
94
+ if (error) return yield* failWith(`Invalid protobuf input: ${error}`);
95
+ return yield* Effect.try({ try: () => type.encode(message).finish(), catch: toDevinError });
96
+ }),
97
+ decode: (bytes) => Effect.flatMap(messageType, (type) => Effect.gen(function* () {
98
+ const decoded = yield* Effect.try({
99
+ try: () => type.toObject(type.decode(bytes), { longs: String, enums: String, bytes: String, defaults: false }),
100
+ catch: toDevinError,
101
+ });
102
+ return yield* convertFields(type, yield* object(decoded, typeName), false, typeName);
103
+ }).pipe(Effect.mapError(() => new DevinError({ message: `Invalid protobuf response: ${typeName}` })))),
93
104
  };
94
105
  }
95
106
 
96
- export function unaryCodecs(method: string): { request: MessageCodec; response: MessageCodec } {
97
- const split = method.lastIndexOf("/");
98
- if (split <= 0) throw new Error("A valid RPC path is required.");
99
- const service = getRoot().lookupService(method.slice(0, split));
100
- const definition = service.methods[method.slice(split + 1)];
101
- definition?.resolve();
102
- if (!definition?.resolvedRequestType || !definition.resolvedResponseType || definition.responseStream || definition.requestStream) {
103
- throw new Error(`Unsupported unary RPC: ${method}`);
104
- }
105
- return {
106
- request: messageCodec(definition.resolvedRequestType.fullName),
107
- response: messageCodec(definition.resolvedResponseType.fullName),
108
- };
107
+ export function unaryCodecs(method: string): Effect.Effect<{ request: MessageCodec; response: MessageCodec }, DevinError> {
108
+ return Effect.gen(function* () {
109
+ const split = method.lastIndexOf("/");
110
+ if (split <= 0) return yield* failWith("A valid RPC path is required.");
111
+ const root = yield* schemaRoot;
112
+ const definition = yield* Effect.try({
113
+ try: () => {
114
+ const found = root.lookupService(method.slice(0, split)).methods[method.slice(split + 1)];
115
+ found?.resolve();
116
+ return found;
117
+ },
118
+ catch: toDevinError,
119
+ });
120
+ if (!definition?.resolvedRequestType || !definition.resolvedResponseType || definition.responseStream || definition.requestStream) {
121
+ return yield* failWith(`Unsupported unary RPC: ${method}`);
122
+ }
123
+ return {
124
+ request: messageCodec(definition.resolvedRequestType.fullName),
125
+ response: messageCodec(definition.resolvedResponseType.fullName),
126
+ };
127
+ });
109
128
  }
110
129
 
111
130
  export const chatRequestCodec = messageCodec("exa.api_server_pb.GetChatMessageRequest");
@@ -0,0 +1,33 @@
1
+ import { Predicate } from "effect";
2
+ import { Cancelled, DevinError, RequestTimeout, RPCError, type DevinFailure } from "./errors.ts";
3
+ import type { JsonObject } from "./types.ts";
4
+
5
+ export function safeError(error: unknown, secrets: readonly string[] = []): string {
6
+ let text = error instanceof Error ? error.message : String(error);
7
+ for (const secret of secrets) if (secret) text = text.split(secret).join("[REDACTED]");
8
+ return text.replace(/devin-session-token\$[^\s"'<>]+/g, "[REDACTED]")
9
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").slice(0, 800);
10
+ }
11
+
12
+ export function requestSecrets(body: JsonObject, token?: string): string[] {
13
+ const metadata = Predicate.isObject(body.metadata) ? body.metadata as JsonObject : {};
14
+ // f is ciphertext that contains the API token. Treat it as sensitive as the token itself.
15
+ const uppercaseFingerprint = Predicate.isString(metadata.f) ? metadata.f.toUpperCase() : undefined;
16
+ return [token, metadata.apiKey, metadata.f, uppercaseFingerprint, body.apiKey, body.modelAssignmentJwt, body.code, body.authorizationCode, body.codeVerifier]
17
+ .filter((value): value is string => Predicate.isString(value) && value.length > 0);
18
+ }
19
+
20
+ /** The same failure with tokens, codes and other secrets removed from its message. */
21
+ export function redactFailure(failure: DevinFailure, secrets: readonly string[]): DevinFailure {
22
+ switch (failure._tag) {
23
+ case "RPCError": return new RPCError({
24
+ code: safeError(failure.code, secrets), message: safeError(failure, secrets),
25
+ httpStatus: failure.httpStatus, retryAfterMs: failure.retryAfterMs,
26
+ });
27
+ case "DevinError": return new DevinError({
28
+ message: safeError(failure, secrets), code: failure.code === undefined ? undefined : safeError(failure.code, secrets),
29
+ });
30
+ case "Cancelled": return new Cancelled({ message: safeError(failure, secrets) });
31
+ case "RequestTimeout": return new RequestTimeout({ message: safeError(failure, secrets) });
32
+ }
33
+ }
@@ -0,0 +1,15 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { ChildProcessSpawner } from "effect/process";
3
+ import { collectDeviceIdentity, nodeDeviceSystem, type DeviceIdentity } from "../protocol/device.ts";
4
+
5
+ export interface DeviceIdentitySourceShape {
6
+ /** Reads the device values the request fingerprint is built from. Never fails: unreadable values become their documented empty value. */
7
+ readonly collect: Effect.Effect<DeviceIdentity>;
8
+ }
9
+
10
+ /** Where request fingerprints get their device values. Each fingerprint provider collects once and keeps the result. */
11
+ export class DeviceIdentitySource extends Context.Service<DeviceIdentitySource, DeviceIdentitySourceShape>()("pi-devin/DeviceIdentitySource") {}
12
+
13
+ /** Reads this machine through the process and network APIs. */
14
+ export const NodeDeviceIdentitySource: Layer.Layer<DeviceIdentitySource, never, ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(DeviceIdentitySource,
15
+ Effect.map(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ({ collect: collectDeviceIdentity(nodeDeviceSystem(spawner)) })));