|
1 | | -import path from "path" |
2 | | -import { mkdir } from "fs/promises" |
3 | | -import { Log } from "../util/log" |
| 1 | +import { NodeFileSystem, NodePath } from "@effect/platform-node" |
| 2 | +import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" |
| 3 | +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" |
4 | 4 | import { Global } from "../global" |
5 | | -import { Filesystem } from "../util/filesystem" |
| 5 | +import { Log } from "../util/log" |
| 6 | +import { withTransientReadRetry } from "@/util/effect-http-client" |
6 | 7 |
|
7 | | -export namespace Discovery { |
8 | | - const log = Log.create({ service: "skill-discovery" }) |
| 8 | +class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ |
| 9 | + name: Schema.String, |
| 10 | + files: Schema.Array(Schema.String), |
| 11 | +}) {} |
9 | 12 |
|
10 | | - type Index = { |
11 | | - skills: Array<{ |
12 | | - name: string |
13 | | - description: string |
14 | | - files: string[] |
15 | | - }> |
16 | | - } |
| 13 | +class Index extends Schema.Class<Index>("Index")({ |
| 14 | + skills: Schema.Array(IndexSkill), |
| 15 | +}) {} |
17 | 16 |
|
18 | | - export function dir() { |
19 | | - return path.join(Global.Path.cache, "skills") |
20 | | - } |
| 17 | +const skillConcurrency = 4 |
| 18 | +const fileConcurrency = 8 |
21 | 19 |
|
22 | | - async function get(url: string, dest: string): Promise<boolean> { |
23 | | - if (await Filesystem.exists(dest)) return true |
24 | | - return fetch(url) |
25 | | - .then(async (response) => { |
26 | | - if (!response.ok) { |
27 | | - log.error("failed to download", { url, status: response.status }) |
28 | | - return false |
29 | | - } |
30 | | - if (response.body) await Filesystem.writeStream(dest, response.body) |
31 | | - return true |
32 | | - }) |
33 | | - .catch((err) => { |
34 | | - log.error("failed to download", { url, err }) |
35 | | - return false |
36 | | - }) |
| 20 | +export namespace DiscoveryService { |
| 21 | + export interface Service { |
| 22 | + readonly pull: (url: string) => Effect.Effect<string[]> |
37 | 23 | } |
| 24 | +} |
38 | 25 |
|
39 | | - export async function pull(url: string): Promise<string[]> { |
40 | | - const result: string[] = [] |
41 | | - const base = url.endsWith("/") ? url : `${url}/` |
42 | | - const index = new URL("index.json", base).href |
43 | | - const cache = dir() |
44 | | - const host = base.slice(0, -1) |
45 | | - |
46 | | - log.info("fetching index", { url: index }) |
47 | | - const data = await fetch(index) |
48 | | - .then(async (response) => { |
49 | | - if (!response.ok) { |
50 | | - log.error("failed to fetch index", { url: index, status: response.status }) |
51 | | - return undefined |
52 | | - } |
53 | | - return response |
54 | | - .json() |
55 | | - .then((json) => json as Index) |
56 | | - .catch((err) => { |
57 | | - log.error("failed to parse index", { url: index, err }) |
58 | | - return undefined |
59 | | - }) |
60 | | - }) |
61 | | - .catch((err) => { |
62 | | - log.error("failed to fetch index", { url: index, err }) |
63 | | - return undefined |
| 26 | +export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()( |
| 27 | + "@opencode/SkillDiscovery", |
| 28 | +) { |
| 29 | + static readonly layer = Layer.effect( |
| 30 | + DiscoveryService, |
| 31 | + Effect.gen(function* () { |
| 32 | + const log = Log.create({ service: "skill-discovery" }) |
| 33 | + const fs = yield* FileSystem.FileSystem |
| 34 | + const path = yield* Path.Path |
| 35 | + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) |
| 36 | + const cache = path.join(Global.Path.cache, "skills") |
| 37 | + |
| 38 | + const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { |
| 39 | + if (yield* fs.exists(dest).pipe(Effect.orDie)) return true |
| 40 | + |
| 41 | + return yield* HttpClientRequest.get(url).pipe( |
| 42 | + http.execute, |
| 43 | + Effect.flatMap((res) => res.arrayBuffer), |
| 44 | + Effect.flatMap((body) => |
| 45 | + fs |
| 46 | + .makeDirectory(path.dirname(dest), { recursive: true }) |
| 47 | + .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))), |
| 48 | + ), |
| 49 | + Effect.as(true), |
| 50 | + Effect.catch((err) => |
| 51 | + Effect.sync(() => { |
| 52 | + log.error("failed to download", { url, err }) |
| 53 | + return false |
| 54 | + }), |
| 55 | + ), |
| 56 | + ) |
64 | 57 | }) |
65 | 58 |
|
66 | | - if (!data?.skills || !Array.isArray(data.skills)) { |
67 | | - log.warn("invalid index format", { url: index }) |
68 | | - return result |
69 | | - } |
70 | | - |
71 | | - const list = data.skills.filter((skill) => { |
72 | | - if (!skill?.name || !Array.isArray(skill.files)) { |
73 | | - log.warn("invalid skill entry", { url: index, skill }) |
74 | | - return false |
75 | | - } |
76 | | - return true |
77 | | - }) |
78 | | - |
79 | | - await Promise.all( |
80 | | - list.map(async (skill) => { |
81 | | - const root = path.join(cache, skill.name) |
82 | | - await Promise.all( |
83 | | - skill.files.map(async (file) => { |
84 | | - const link = new URL(file, `${host}/${skill.name}/`).href |
85 | | - const dest = path.join(root, file) |
86 | | - await mkdir(path.dirname(dest), { recursive: true }) |
87 | | - await get(link, dest) |
88 | | - }), |
| 59 | + const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { |
| 60 | + const base = url.endsWith("/") ? url : `${url}/` |
| 61 | + const index = new URL("index.json", base).href |
| 62 | + const host = base.slice(0, -1) |
| 63 | + |
| 64 | + log.info("fetching index", { url: index }) |
| 65 | + |
| 66 | + const data = yield* HttpClientRequest.get(index).pipe( |
| 67 | + HttpClientRequest.acceptJson, |
| 68 | + http.execute, |
| 69 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), |
| 70 | + Effect.catch((err) => |
| 71 | + Effect.sync(() => { |
| 72 | + log.error("failed to fetch index", { url: index, err }) |
| 73 | + return null |
| 74 | + }), |
| 75 | + ), |
89 | 76 | ) |
90 | 77 |
|
91 | | - const md = path.join(root, "SKILL.md") |
92 | | - if (await Filesystem.exists(md)) result.push(root) |
93 | | - }), |
94 | | - ) |
| 78 | + if (!data) return [] |
95 | 79 |
|
96 | | - return result |
97 | | - } |
| 80 | + const list = data.skills.filter((skill) => { |
| 81 | + if (!skill.files.includes("SKILL.md")) { |
| 82 | + log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) |
| 83 | + return false |
| 84 | + } |
| 85 | + return true |
| 86 | + }) |
| 87 | + |
| 88 | + const dirs = yield* Effect.forEach( |
| 89 | + list, |
| 90 | + (skill) => |
| 91 | + Effect.gen(function* () { |
| 92 | + const root = path.join(cache, skill.name) |
| 93 | + |
| 94 | + yield* Effect.forEach( |
| 95 | + skill.files, |
| 96 | + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), |
| 97 | + { concurrency: fileConcurrency }, |
| 98 | + ) |
| 99 | + |
| 100 | + const md = path.join(root, "SKILL.md") |
| 101 | + return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null |
| 102 | + }), |
| 103 | + { concurrency: skillConcurrency }, |
| 104 | + ) |
| 105 | + |
| 106 | + return dirs.filter((dir): dir is string => dir !== null) |
| 107 | + }) |
| 108 | + |
| 109 | + return DiscoveryService.of({ pull }) |
| 110 | + }), |
| 111 | + ) |
| 112 | + |
| 113 | + static readonly defaultLayer = DiscoveryService.layer.pipe( |
| 114 | + Layer.provide(FetchHttpClient.layer), |
| 115 | + Layer.provide(NodeFileSystem.layer), |
| 116 | + Layer.provide(NodePath.layer), |
| 117 | + ) |
98 | 118 | } |
0 commit comments