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.
Files changed (46) hide show
  1. package/dist/Agent.d.ts +45 -19
  2. package/dist/Agent.d.ts.map +1 -1
  3. package/dist/Agent.js +172 -66
  4. package/dist/Agent.js.map +1 -1
  5. package/dist/Codex.d.ts +6 -1
  6. package/dist/Codex.d.ts.map +1 -1
  7. package/dist/Codex.js +16 -3
  8. package/dist/Codex.js.map +1 -1
  9. package/dist/GithubCopilot.d.ts +11 -0
  10. package/dist/GithubCopilot.d.ts.map +1 -0
  11. package/dist/GithubCopilot.js +14 -0
  12. package/dist/GithubCopilot.js.map +1 -0
  13. package/dist/GithubCopilotAuth.d.ts +57 -0
  14. package/dist/GithubCopilotAuth.d.ts.map +1 -0
  15. package/dist/GithubCopilotAuth.js +218 -0
  16. package/dist/GithubCopilotAuth.js.map +1 -0
  17. package/dist/GithubCopilotAuth.test.d.ts +2 -0
  18. package/dist/GithubCopilotAuth.test.d.ts.map +1 -0
  19. package/dist/GithubCopilotAuth.test.js +267 -0
  20. package/dist/GithubCopilotAuth.test.js.map +1 -0
  21. package/dist/OutputFormatter.d.ts +2 -1
  22. package/dist/OutputFormatter.d.ts.map +1 -1
  23. package/dist/OutputFormatter.js +8 -2
  24. package/dist/OutputFormatter.js.map +1 -1
  25. package/dist/ScriptExtraction.d.ts +34 -0
  26. package/dist/ScriptExtraction.d.ts.map +1 -0
  27. package/dist/ScriptExtraction.js +68 -0
  28. package/dist/ScriptExtraction.js.map +1 -0
  29. package/dist/ScriptExtraction.test.d.ts +2 -0
  30. package/dist/ScriptExtraction.test.d.ts.map +1 -0
  31. package/dist/ScriptExtraction.test.js +64 -0
  32. package/dist/ScriptExtraction.test.js.map +1 -0
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +3 -1
  38. package/src/Agent.ts +247 -87
  39. package/src/Codex.ts +18 -3
  40. package/src/GithubCopilot.ts +14 -0
  41. package/src/GithubCopilotAuth.test.ts +469 -0
  42. package/src/GithubCopilotAuth.ts +441 -0
  43. package/src/OutputFormatter.ts +11 -4
  44. package/src/ScriptExtraction.test.ts +96 -0
  45. package/src/ScriptExtraction.ts +75 -0
  46. 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
+ }
@@ -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${chalk.dim(output.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.catch((finished) =>
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
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,11 @@ export * as AgentTools from "./AgentTools.ts"
13
13
  */
14
14
  export * as Codex from "./Codex.ts"
15
15
 
16
+ /**
17
+ * @since 1.0.0
18
+ */
19
+ export * as GithubCopilot from "./GithubCopilot.ts"
20
+
16
21
  /**
17
22
  * @since 1.0.0
18
23
  */