Skip to content

Commit 3849822

Browse files
authored
refactor(skill): effectify SkillService as scoped service (#17849)
1 parent e9a17e4 commit 3849822

File tree

5 files changed

+318
-239
lines changed

5 files changed

+318
-239
lines changed

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@openrouter/ai-sdk-provider": "1.5.4",
9696
"@opentui/core": "0.1.87",
9797
"@opentui/solid": "0.1.87",
98+
"@effect/platform-node": "4.0.0-beta.31",
9899
"@parcel/watcher": "2.5.1",
99100
"@pierre/diffs": "catalog:",
100101
"@solid-primitives/event-bus": "1.1.2",

packages/opencode/src/effect/instances.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { VcsService } from "@/project/vcs"
99
import { FileTimeService } from "@/file/time"
1010
import { FormatService } from "@/format"
1111
import { FileService } from "@/file"
12+
import { SkillService } from "@/skill/skill"
1213
import { Instance } from "@/project/instance"
1314

1415
export { InstanceContext } from "./instance-context"
@@ -22,6 +23,7 @@ export type InstanceServices =
2223
| FileTimeService
2324
| FormatService
2425
| FileService
26+
| SkillService
2527

2628
function lookup(directory: string) {
2729
const project = Instance.project
@@ -35,6 +37,7 @@ function lookup(directory: string) {
3537
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
3638
Layer.fresh(FormatService.layer),
3739
Layer.fresh(FileService.layer),
40+
Layer.fresh(SkillService.layer),
3841
).pipe(Layer.provide(ctx))
3942
}
4043

Lines changed: 105 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,118 @@
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"
44
import { Global } from "../global"
5-
import { Filesystem } from "../util/filesystem"
5+
import { Log } from "../util/log"
6+
import { withTransientReadRetry } from "@/util/effect-http-client"
67

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+
}) {}
912

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+
}) {}
1716

18-
export function dir() {
19-
return path.join(Global.Path.cache, "skills")
20-
}
17+
const skillConcurrency = 4
18+
const fileConcurrency = 8
2119

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[]>
3723
}
24+
}
3825

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+
)
6457
})
6558

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+
),
8976
)
9077

91-
const md = path.join(root, "SKILL.md")
92-
if (await Filesystem.exists(md)) result.push(root)
93-
}),
94-
)
78+
if (!data) return []
9579

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+
)
98118
}

0 commit comments

Comments
 (0)