clanka 0.0.4 → 0.0.5
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/dist/Agent.d.ts +45 -19
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +172 -66
- package/dist/Agent.js.map +1 -1
- package/dist/Codex.d.ts +6 -1
- package/dist/Codex.d.ts.map +1 -1
- package/dist/Codex.js +16 -3
- package/dist/Codex.js.map +1 -1
- package/dist/GithubCopilot.d.ts +11 -0
- package/dist/GithubCopilot.d.ts.map +1 -0
- package/dist/GithubCopilot.js +14 -0
- package/dist/GithubCopilot.js.map +1 -0
- package/dist/GithubCopilotAuth.d.ts +57 -0
- package/dist/GithubCopilotAuth.d.ts.map +1 -0
- package/dist/GithubCopilotAuth.js +218 -0
- package/dist/GithubCopilotAuth.js.map +1 -0
- package/dist/GithubCopilotAuth.test.d.ts +2 -0
- package/dist/GithubCopilotAuth.test.d.ts.map +1 -0
- package/dist/GithubCopilotAuth.test.js +267 -0
- package/dist/GithubCopilotAuth.test.js.map +1 -0
- package/dist/OutputFormatter.d.ts +2 -1
- package/dist/OutputFormatter.d.ts.map +1 -1
- package/dist/OutputFormatter.js +8 -2
- package/dist/OutputFormatter.js.map +1 -1
- package/dist/ScriptExtraction.d.ts +34 -0
- package/dist/ScriptExtraction.d.ts.map +1 -0
- package/dist/ScriptExtraction.js +68 -0
- package/dist/ScriptExtraction.js.map +1 -0
- package/dist/ScriptExtraction.test.d.ts +2 -0
- package/dist/ScriptExtraction.test.d.ts.map +1 -0
- package/dist/ScriptExtraction.test.js +64 -0
- package/dist/ScriptExtraction.test.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/Agent.ts +247 -87
- package/src/Codex.ts +18 -3
- package/src/GithubCopilot.ts +14 -0
- package/src/GithubCopilotAuth.test.ts +469 -0
- package/src/GithubCopilotAuth.ts +441 -0
- package/src/OutputFormatter.ts +11 -4
- package/src/ScriptExtraction.test.ts +96 -0
- package/src/ScriptExtraction.ts +75 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
Console,
|
|
6
|
+
Effect,
|
|
7
|
+
flow,
|
|
8
|
+
Layer,
|
|
9
|
+
Option,
|
|
10
|
+
Schedule,
|
|
11
|
+
Schema,
|
|
12
|
+
Semaphore,
|
|
13
|
+
ServiceMap,
|
|
14
|
+
} from "effect"
|
|
15
|
+
import {
|
|
16
|
+
HttpClient,
|
|
17
|
+
HttpClientRequest,
|
|
18
|
+
HttpClientResponse,
|
|
19
|
+
} from "effect/unstable/http"
|
|
20
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
21
|
+
|
|
22
|
+
export const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
|
23
|
+
export const ISSUER = "https://github.com"
|
|
24
|
+
export const API_URL = "https://api.githubcopilot.com"
|
|
25
|
+
export const DEVICE_VERIFICATION_URL = `${ISSUER}/login/device`
|
|
26
|
+
export const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
|
|
27
|
+
export const STORE_PREFIX = "github-copilot.auth/"
|
|
28
|
+
export const STORE_TOKEN_KEY = "token"
|
|
29
|
+
export const OPENAI_INTENT_HEADER = "Openai-Intent"
|
|
30
|
+
export const COPILOT_VISION_REQUEST_HEADER = "Copilot-Vision-Request"
|
|
31
|
+
export const INITIATOR_HEADER = "x-initiator"
|
|
32
|
+
export const DEFAULT_OPENAI_INTENT = "conversation-edits"
|
|
33
|
+
export const DEFAULT_USER_AGENT = "clanka"
|
|
34
|
+
|
|
35
|
+
const DEVICE_CODE_URL = "/login/device/code"
|
|
36
|
+
const ACCESS_TOKEN_URL = "/login/oauth/access_token"
|
|
37
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 5
|
|
38
|
+
|
|
39
|
+
export class TokenData extends Schema.Class<TokenData>(
|
|
40
|
+
"clanka/GithubCopilotAuth/TokenData",
|
|
41
|
+
)({
|
|
42
|
+
access: Schema.String,
|
|
43
|
+
expires: Schema.Number,
|
|
44
|
+
}) {
|
|
45
|
+
isExpired(): boolean {
|
|
46
|
+
return this.expires > 0 && this.expires < Date.now()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class GithubCopilotAuthError extends Schema.TaggedErrorClass<GithubCopilotAuthError>()(
|
|
51
|
+
"GithubCopilotAuthError",
|
|
52
|
+
{
|
|
53
|
+
reason: Schema.Literal("DeviceFlowFailed"),
|
|
54
|
+
message: Schema.String,
|
|
55
|
+
cause: Schema.optional(Schema.Defect),
|
|
56
|
+
},
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
const DeviceCodeResponseSchema = Schema.Struct({
|
|
60
|
+
device_code: Schema.String,
|
|
61
|
+
user_code: Schema.String,
|
|
62
|
+
verification_uri: Schema.String,
|
|
63
|
+
interval: Schema.optional(Schema.Number),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const AccessTokenResponseSchema = Schema.Struct({
|
|
67
|
+
access_token: Schema.optional(Schema.String),
|
|
68
|
+
error: Schema.optional(Schema.String),
|
|
69
|
+
interval: Schema.optional(Schema.Number),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export interface DeviceCodeData {
|
|
73
|
+
readonly deviceCode: string
|
|
74
|
+
readonly userCode: string
|
|
75
|
+
readonly verificationUri: string
|
|
76
|
+
readonly intervalMs: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface CopilotRequestMetadata {
|
|
80
|
+
readonly isAgent: boolean
|
|
81
|
+
readonly isVision: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const normalizePollInterval = (interval?: number): number =>
|
|
85
|
+
Math.max(interval ?? DEFAULT_POLL_INTERVAL_SECONDS, 1) * 1_000
|
|
86
|
+
|
|
87
|
+
const deviceFlowError = (message: string, cause?: unknown) =>
|
|
88
|
+
new GithubCopilotAuthError({
|
|
89
|
+
reason: "DeviceFlowFailed",
|
|
90
|
+
message,
|
|
91
|
+
...(cause === undefined ? {} : { cause }),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
95
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
96
|
+
|
|
97
|
+
const parseRequestJsonBody = (
|
|
98
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
99
|
+
): Option.Option<unknown> => {
|
|
100
|
+
if (request.body._tag !== "Uint8Array") {
|
|
101
|
+
return Option.none()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return Option.some(JSON.parse(new TextDecoder().decode(request.body.body)))
|
|
106
|
+
} catch {
|
|
107
|
+
return Option.none()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const getRequestMetadataFromMessages = (
|
|
112
|
+
messages: ReadonlyArray<unknown>,
|
|
113
|
+
): CopilotRequestMetadata => {
|
|
114
|
+
const last = messages[messages.length - 1]
|
|
115
|
+
const isAgent = !isRecord(last) || last["role"] !== "user"
|
|
116
|
+
const isVision = messages.some((message) => {
|
|
117
|
+
if (!isRecord(message) || !Array.isArray(message["content"])) {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return message["content"].some(
|
|
122
|
+
(part) => isRecord(part) && part["type"] === "image_url",
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return { isAgent, isVision }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const getRequestMetadataFromInput = (
|
|
130
|
+
input: ReadonlyArray<unknown>,
|
|
131
|
+
): CopilotRequestMetadata => {
|
|
132
|
+
const last = input[input.length - 1]
|
|
133
|
+
const isAgent = !isRecord(last) || last["role"] !== "user"
|
|
134
|
+
const isVision = input.some((item) => {
|
|
135
|
+
if (!isRecord(item) || !Array.isArray(item["content"])) {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return item["content"].some(
|
|
140
|
+
(part) => isRecord(part) && part["type"] === "input_image",
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return { isAgent, isVision }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const getCopilotRequestMetadata = (
|
|
148
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
149
|
+
): CopilotRequestMetadata =>
|
|
150
|
+
Option.match(parseRequestJsonBody(request), {
|
|
151
|
+
onNone: () => ({
|
|
152
|
+
isAgent: false,
|
|
153
|
+
isVision: false,
|
|
154
|
+
}),
|
|
155
|
+
onSome: (body) => {
|
|
156
|
+
if (!isRecord(body)) {
|
|
157
|
+
return {
|
|
158
|
+
isAgent: false,
|
|
159
|
+
isVision: false,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const messages = body["messages"]
|
|
164
|
+
if (Array.isArray(messages)) {
|
|
165
|
+
return getRequestMetadataFromMessages(messages)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const input = body["input"]
|
|
169
|
+
if (Array.isArray(input)) {
|
|
170
|
+
return getRequestMetadataFromInput(input)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
isAgent: false,
|
|
175
|
+
isVision: false,
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const applyCopilotHeaders = (
|
|
181
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
182
|
+
token: TokenData,
|
|
183
|
+
): HttpClientRequest.HttpClientRequest => {
|
|
184
|
+
const metadata = getCopilotRequestMetadata(request)
|
|
185
|
+
const authenticatedRequest = request.pipe(
|
|
186
|
+
HttpClientRequest.bearerToken(token.access),
|
|
187
|
+
HttpClientRequest.setHeader("User-Agent", DEFAULT_USER_AGENT),
|
|
188
|
+
HttpClientRequest.setHeader(OPENAI_INTENT_HEADER, DEFAULT_OPENAI_INTENT),
|
|
189
|
+
HttpClientRequest.setHeader(
|
|
190
|
+
INITIATOR_HEADER,
|
|
191
|
+
metadata.isAgent ? "agent" : "user",
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (!metadata.isVision) {
|
|
196
|
+
return authenticatedRequest
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return authenticatedRequest.pipe(
|
|
200
|
+
HttpClientRequest.setHeader(COPILOT_VISION_REQUEST_HEADER, "true"),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const toTokenData = (accessToken: string): TokenData =>
|
|
205
|
+
new TokenData({
|
|
206
|
+
access: accessToken,
|
|
207
|
+
expires: 0,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
export const toGithubCopilotAuthKeyValueStore = (
|
|
211
|
+
store: KeyValueStore.KeyValueStore,
|
|
212
|
+
) => KeyValueStore.prefix(store, STORE_PREFIX)
|
|
213
|
+
|
|
214
|
+
export const toTokenStore = (store: KeyValueStore.KeyValueStore) =>
|
|
215
|
+
KeyValueStore.toSchemaStore(
|
|
216
|
+
toGithubCopilotAuthKeyValueStore(store),
|
|
217
|
+
TokenData,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
export class GithubCopilotAuth extends ServiceMap.Service<
|
|
221
|
+
GithubCopilotAuth,
|
|
222
|
+
{
|
|
223
|
+
readonly get: Effect.Effect<TokenData, GithubCopilotAuthError>
|
|
224
|
+
readonly authenticate: Effect.Effect<TokenData, GithubCopilotAuthError>
|
|
225
|
+
readonly logout: Effect.Effect<void>
|
|
226
|
+
}
|
|
227
|
+
>()("clanka/GithubCopilotAuth") {
|
|
228
|
+
static readonly make = Effect.gen(function* () {
|
|
229
|
+
const tokenStore = toTokenStore(yield* KeyValueStore.KeyValueStore)
|
|
230
|
+
const httpClient = (yield* HttpClient.HttpClient).pipe(
|
|
231
|
+
HttpClient.mapRequest(
|
|
232
|
+
flow(
|
|
233
|
+
HttpClientRequest.prependUrl(ISSUER),
|
|
234
|
+
HttpClientRequest.acceptJson,
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
HttpClient.filterStatusOk,
|
|
238
|
+
HttpClient.retryTransient({
|
|
239
|
+
times: 5,
|
|
240
|
+
schedule: Schedule.exponential(150).pipe(
|
|
241
|
+
Schedule.either(Schedule.spaced(5000)),
|
|
242
|
+
),
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
const semaphore = Semaphore.makeUnsafe(1)
|
|
246
|
+
|
|
247
|
+
let currentToken = yield* tokenStore.get(STORE_TOKEN_KEY).pipe(
|
|
248
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
249
|
+
Console.warn(
|
|
250
|
+
`Failed to decode persisted GitHub Copilot token, clearing it: ${error.message}`,
|
|
251
|
+
).pipe(
|
|
252
|
+
Effect.andThen(tokenStore.remove(STORE_TOKEN_KEY)),
|
|
253
|
+
Effect.as(Option.none()),
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
Effect.orDie,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const saveToken = (token: TokenData) =>
|
|
260
|
+
Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token)).pipe(
|
|
261
|
+
Effect.tap(() =>
|
|
262
|
+
Effect.sync(() => {
|
|
263
|
+
currentToken = Option.some(token)
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
Effect.as(token),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const clearToken = Effect.orDie(tokenStore.remove(STORE_TOKEN_KEY)).pipe(
|
|
270
|
+
Effect.tap(() =>
|
|
271
|
+
Effect.sync(() => {
|
|
272
|
+
currentToken = Option.none()
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const requestDeviceCode = Effect.fn("GithubCopilotAuth.requestDeviceCode")(
|
|
278
|
+
function* (): Effect.fn.Return<DeviceCodeData, GithubCopilotAuthError> {
|
|
279
|
+
const response = yield* HttpClientRequest.post(DEVICE_CODE_URL).pipe(
|
|
280
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
281
|
+
client_id: CLIENT_ID,
|
|
282
|
+
scope: "read:user",
|
|
283
|
+
}),
|
|
284
|
+
httpClient.execute,
|
|
285
|
+
Effect.mapError((cause) =>
|
|
286
|
+
deviceFlowError(
|
|
287
|
+
"Failed to request a GitHub Copilot device authorization code",
|
|
288
|
+
cause,
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
const payload = yield* HttpClientResponse.schemaBodyJson(
|
|
294
|
+
DeviceCodeResponseSchema,
|
|
295
|
+
)(response).pipe(
|
|
296
|
+
Effect.mapError((cause) =>
|
|
297
|
+
deviceFlowError(
|
|
298
|
+
"Failed to decode the GitHub Copilot device authorization response",
|
|
299
|
+
cause,
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
deviceCode: payload.device_code,
|
|
306
|
+
userCode: payload.user_code,
|
|
307
|
+
verificationUri: payload.verification_uri,
|
|
308
|
+
intervalMs: normalizePollInterval(payload.interval),
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const pollAccessToken = Effect.fn("GithubCopilotAuth.pollAccessToken")(
|
|
314
|
+
function* (
|
|
315
|
+
deviceCode: DeviceCodeData,
|
|
316
|
+
): Effect.fn.Return<TokenData, GithubCopilotAuthError> {
|
|
317
|
+
const request = HttpClientRequest.post(ACCESS_TOKEN_URL).pipe(
|
|
318
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
319
|
+
client_id: CLIENT_ID,
|
|
320
|
+
device_code: deviceCode.deviceCode,
|
|
321
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
let delayMs = deviceCode.intervalMs
|
|
326
|
+
|
|
327
|
+
while (true) {
|
|
328
|
+
const response = yield* request.pipe(
|
|
329
|
+
httpClient.execute,
|
|
330
|
+
Effect.mapError((cause) =>
|
|
331
|
+
deviceFlowError(
|
|
332
|
+
"Failed to poll the GitHub Copilot device authorization token",
|
|
333
|
+
cause,
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const payload = yield* HttpClientResponse.schemaBodyJson(
|
|
339
|
+
AccessTokenResponseSchema,
|
|
340
|
+
)(response).pipe(
|
|
341
|
+
Effect.mapError((cause) =>
|
|
342
|
+
deviceFlowError(
|
|
343
|
+
"Failed to decode the GitHub Copilot access token response",
|
|
344
|
+
cause,
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (
|
|
350
|
+
payload.access_token !== undefined &&
|
|
351
|
+
payload.access_token !== ""
|
|
352
|
+
) {
|
|
353
|
+
return toTokenData(payload.access_token)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (payload.error === "authorization_pending") {
|
|
357
|
+
yield* Effect.sleep(delayMs + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (payload.error === "slow_down") {
|
|
362
|
+
delayMs = normalizePollInterval(payload.interval) + 5_000
|
|
363
|
+
yield* Effect.sleep(delayMs + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (payload.error !== undefined && payload.error !== "") {
|
|
368
|
+
return yield* deviceFlowError(
|
|
369
|
+
`GitHub Copilot device authorization failed: ${payload.error}`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
yield* Effect.sleep(delayMs + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
const authenticateWithDeviceFlow = Effect.gen(function* () {
|
|
379
|
+
const deviceCode = yield* requestDeviceCode()
|
|
380
|
+
yield* Console.log(
|
|
381
|
+
`Open ${deviceCode.verificationUri} and enter code: ${deviceCode.userCode}`,
|
|
382
|
+
)
|
|
383
|
+
return yield* pollAccessToken(deviceCode)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
const authenticateNoLock = Effect.uninterruptibleMask((restore) =>
|
|
387
|
+
Effect.gen(function* () {
|
|
388
|
+
const token = yield* restore(authenticateWithDeviceFlow)
|
|
389
|
+
return yield* saveToken(token)
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const getNoLock = Effect.uninterruptibleMask((restore) =>
|
|
394
|
+
Effect.gen(function* () {
|
|
395
|
+
if (Option.isSome(currentToken) && !currentToken.value.isExpired()) {
|
|
396
|
+
return currentToken.value
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (Option.isSome(currentToken)) {
|
|
400
|
+
yield* clearToken
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const token = yield* restore(authenticateWithDeviceFlow)
|
|
404
|
+
return yield* saveToken(token)
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return GithubCopilotAuth.of({
|
|
409
|
+
get: semaphore.withPermit(getNoLock),
|
|
410
|
+
authenticate: semaphore.withPermit(authenticateNoLock),
|
|
411
|
+
logout: semaphore.withPermit(Effect.uninterruptible(clearToken)),
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
static readonly layer = Layer.effect(
|
|
416
|
+
GithubCopilotAuth,
|
|
417
|
+
GithubCopilotAuth.make,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
static readonly layerClientNoDeps = Layer.effect(
|
|
421
|
+
HttpClient.HttpClient,
|
|
422
|
+
Effect.gen(function* () {
|
|
423
|
+
const auth = yield* GithubCopilotAuth
|
|
424
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
425
|
+
|
|
426
|
+
const injectAuthHeaders = (
|
|
427
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
428
|
+
): Effect.Effect<HttpClientRequest.HttpClientRequest> =>
|
|
429
|
+
auth.get.pipe(
|
|
430
|
+
Effect.map((token) => applyCopilotHeaders(request, token)),
|
|
431
|
+
Effect.orDie,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return httpClient.pipe(HttpClient.mapRequestEffect(injectAuthHeaders))
|
|
435
|
+
}),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
static readonly layerClient = this.layerClientNoDeps.pipe(
|
|
439
|
+
Layer.provide(GithubCopilotAuth.layer),
|
|
440
|
+
)
|
|
441
|
+
}
|
package/src/OutputFormatter.ts
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
import { Stream } from "effect"
|
|
5
5
|
import { type Output, AgentFinished } from "./Agent.ts"
|
|
6
6
|
import chalk from "chalk"
|
|
7
|
+
import type { AiError } from "effect/unstable/ai"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @since 1.0.0
|
|
10
11
|
* @category Models
|
|
11
12
|
*/
|
|
12
13
|
export type OutputFormatter<E = never, R = never> = (
|
|
13
|
-
stream: Stream.Stream<Output, AgentFinished>,
|
|
14
|
-
) => Stream.Stream<string, E, R>
|
|
14
|
+
stream: Stream.Stream<Output, AgentFinished | AiError.AiError>,
|
|
15
|
+
) => Stream.Stream<string, AiError.AiError | E, R>
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @since 1.0.0
|
|
@@ -48,9 +49,15 @@ ${output.summary}\n\n`
|
|
|
48
49
|
return "\n\n"
|
|
49
50
|
}
|
|
50
51
|
case "ScriptStart": {
|
|
51
|
-
return `${prefix}${chalkScriptHeading(`${scriptIcon} Executing script`)}\n\n
|
|
52
|
+
return `${prefix}${chalkScriptHeading(`${scriptIcon} Executing script`)}\n\n`
|
|
53
|
+
}
|
|
54
|
+
case "ScriptDelta": {
|
|
55
|
+
return chalk.dim(output.delta)
|
|
52
56
|
}
|
|
53
57
|
case "ScriptEnd": {
|
|
58
|
+
return "\n\n"
|
|
59
|
+
}
|
|
60
|
+
case "ScriptOutput": {
|
|
54
61
|
const lines = output.output.split("\n")
|
|
55
62
|
const truncated =
|
|
56
63
|
lines.length > 20
|
|
@@ -60,7 +67,7 @@ ${output.summary}\n\n`
|
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
}),
|
|
63
|
-
Stream.
|
|
70
|
+
Stream.catchTag("AgentFinished", (finished) =>
|
|
64
71
|
Stream.succeed(
|
|
65
72
|
`\n${chalk.bold.green(`${doneIcon} Task complete:`)}\n\n${finished.summary}`,
|
|
66
73
|
),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { extractScript } from "./ScriptExtraction.ts"
|
|
3
|
+
|
|
4
|
+
describe("extractScript", () => {
|
|
5
|
+
it("returns the full string when there are no code blocks", () => {
|
|
6
|
+
const markdown = [
|
|
7
|
+
"This is some text.",
|
|
8
|
+
"",
|
|
9
|
+
"There are no fenced code blocks here.",
|
|
10
|
+
].join("\n")
|
|
11
|
+
|
|
12
|
+
expect(extractScript(markdown)).toBe(markdown)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("extracts a single fenced code block", () => {
|
|
16
|
+
expect(
|
|
17
|
+
extractScript(
|
|
18
|
+
[
|
|
19
|
+
"Before",
|
|
20
|
+
"",
|
|
21
|
+
"```ts",
|
|
22
|
+
'console.log("Hello, world!")',
|
|
23
|
+
"```",
|
|
24
|
+
"",
|
|
25
|
+
"After",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
),
|
|
28
|
+
).toBe('console.log("Hello, world!")')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("concatenates multiple fenced code blocks", () => {
|
|
32
|
+
expect(
|
|
33
|
+
extractScript(
|
|
34
|
+
[
|
|
35
|
+
"Before",
|
|
36
|
+
"",
|
|
37
|
+
"```js",
|
|
38
|
+
'console.log("Hello, world!")',
|
|
39
|
+
"```",
|
|
40
|
+
"",
|
|
41
|
+
"Between",
|
|
42
|
+
"",
|
|
43
|
+
"```",
|
|
44
|
+
'console.log("Goodbye, world!")',
|
|
45
|
+
"```",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
),
|
|
48
|
+
).toBe(
|
|
49
|
+
['console.log("Hello, world!")', 'console.log("Goodbye, world!")'].join(
|
|
50
|
+
"\n\n",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("supports longer fences", () => {
|
|
56
|
+
expect(
|
|
57
|
+
extractScript(
|
|
58
|
+
["````md", "```ts", 'console.log("nested")', "```", "````"].join("\n"),
|
|
59
|
+
),
|
|
60
|
+
).toBe(["```ts", 'console.log("nested")', "```"].join("\n"))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("supports empty fenced code blocks", () => {
|
|
64
|
+
expect(extractScript(["```ts", "```"].join("\n"))).toBe("")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("supports unclosed fenced code blocks", () => {
|
|
68
|
+
expect(
|
|
69
|
+
extractScript(["before", "", "```ts", "const answer = 42"].join("\n")),
|
|
70
|
+
).toBe("const answer = 42")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("supports closing fences longer than the opening fence", () => {
|
|
74
|
+
expect(
|
|
75
|
+
extractScript(["```ts", "const answer = 42", "````"].join("\n")),
|
|
76
|
+
).toBe("const answer = 42")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("preserves CRLF output when extracting multiple blocks", () => {
|
|
80
|
+
expect(
|
|
81
|
+
extractScript(
|
|
82
|
+
[
|
|
83
|
+
"Before",
|
|
84
|
+
"",
|
|
85
|
+
"```ts",
|
|
86
|
+
"const a = 1",
|
|
87
|
+
"```",
|
|
88
|
+
"",
|
|
89
|
+
"```ts",
|
|
90
|
+
"const b = 2",
|
|
91
|
+
"```",
|
|
92
|
+
].join("\r\n"),
|
|
93
|
+
),
|
|
94
|
+
).toBe(["const a = 1", "const b = 2"].join("\r\n\r\n"))
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If the given string contains code blocks, extract them all and concatenate
|
|
3
|
+
* them together.
|
|
4
|
+
*
|
|
5
|
+
* If there are no code blocks, return the full string as is.
|
|
6
|
+
*
|
|
7
|
+
* For example, given the following string:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* This is some text.
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* console.log("Hello, world!");
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* More text here.
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* console.log("Goodbye, world!");
|
|
20
|
+
* ```
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The function should return the following string:
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* console.log("Hello, world!");
|
|
27
|
+
*
|
|
28
|
+
* console.log("Goodbye, world!");
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @since 1.0.0
|
|
32
|
+
*/
|
|
33
|
+
export const extractScript = (markdown: string): string => {
|
|
34
|
+
const newLine = markdown.includes("\r\n") ? "\r\n" : "\n"
|
|
35
|
+
const separator = newLine + newLine
|
|
36
|
+
const blocks: Array<string> = []
|
|
37
|
+
const lines = markdown.split(/\r?\n/)
|
|
38
|
+
|
|
39
|
+
let current: Array<string> | undefined
|
|
40
|
+
let marker: "`" | "~" | undefined
|
|
41
|
+
let openingLength = 0
|
|
42
|
+
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (current === undefined) {
|
|
45
|
+
const opening = line.match(/^ {0,3}(`{3,}|~{3,})[^\r\n]*$/)
|
|
46
|
+
if (opening) {
|
|
47
|
+
current = []
|
|
48
|
+
marker = opening[1]![0] as "`" | "~"
|
|
49
|
+
openingLength = opening[1]!.length
|
|
50
|
+
}
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const closing = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)
|
|
55
|
+
if (
|
|
56
|
+
closing &&
|
|
57
|
+
closing[1]![0] === marker &&
|
|
58
|
+
closing[1]!.length >= openingLength
|
|
59
|
+
) {
|
|
60
|
+
blocks.push(current.join(newLine))
|
|
61
|
+
current = undefined
|
|
62
|
+
marker = undefined
|
|
63
|
+
openingLength = 0
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current.push(line)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (current !== undefined) {
|
|
71
|
+
blocks.push(current.join(newLine))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return blocks.length === 0 ? markdown : blocks.join(separator)
|
|
75
|
+
}
|