clanka 0.4.0 → 0.5.1
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 +2 -2
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js.map +1 -1
- package/dist/AgentExecutor.d.ts +10 -4
- package/dist/AgentExecutor.d.ts.map +1 -1
- package/dist/AgentExecutor.js +22 -7
- package/dist/AgentExecutor.js.map +1 -1
- package/dist/AgentSkills.d.ts +5 -2
- package/dist/AgentSkills.d.ts.map +1 -1
- package/dist/AgentSkills.js +23 -6
- package/dist/AgentSkills.js.map +1 -1
- package/dist/AgentSkills.test.js +154 -7
- package/dist/AgentSkills.test.js.map +1 -1
- package/dist/AgentTools.d.ts +32 -1
- package/dist/AgentTools.d.ts.map +1 -1
- package/dist/AgentTools.js +26 -10
- package/dist/AgentTools.js.map +1 -1
- package/package.json +1 -1
- package/src/Agent.ts +2 -1
- package/src/AgentExecutor.ts +50 -32
- package/src/AgentSkills.test.ts +336 -15
- package/src/AgentSkills.ts +26 -6
- package/src/AgentTools.ts +34 -10
package/src/AgentExecutor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
AgentTools,
|
|
11
11
|
AgentToolsWithSearch,
|
|
12
12
|
CurrentDirectory,
|
|
13
|
+
DirectoryChanger,
|
|
13
14
|
SubagentExecutor,
|
|
14
15
|
TaskCompleter,
|
|
15
16
|
} from "./AgentTools.ts"
|
|
@@ -100,12 +101,22 @@ export const makeLocal = Effect.fnUntraced(function* <
|
|
|
100
101
|
Toolkit extends Toolkit.Toolkit<infer T>
|
|
101
102
|
? Tool.HandlersFor<T> | Tool.HandlerServices<T[keyof T]>
|
|
102
103
|
: never,
|
|
103
|
-
CurrentDirectory | SubagentExecutor | TaskCompleter
|
|
104
|
+
CurrentDirectory | DirectoryChanger | SubagentExecutor | TaskCompleter
|
|
104
105
|
>
|
|
105
106
|
> {
|
|
106
107
|
const fs = yield* FileSystem.FileSystem
|
|
107
108
|
const pathService = yield* Path.Path
|
|
108
109
|
const renderer = yield* ToolkitRenderer
|
|
110
|
+
let currentDirectory = pathService.resolve(options.directory)
|
|
111
|
+
const changeDirectory = Effect.fnUntraced(function* (directory: string) {
|
|
112
|
+
const target = pathService.resolve(currentDirectory, directory)
|
|
113
|
+
const info = yield* fs.stat(target)
|
|
114
|
+
if (info.type !== "Directory") {
|
|
115
|
+
return yield* Effect.die(new Error(`Not a directory: ${target}`))
|
|
116
|
+
}
|
|
117
|
+
currentDirectory = target
|
|
118
|
+
return target
|
|
119
|
+
}, Effect.orDie)
|
|
109
120
|
const search = yield* Effect.serviceOption(SemanticSearch)
|
|
110
121
|
const hasSearch = Option.isSome(search)
|
|
111
122
|
const AllTools = Toolkit.merge(
|
|
@@ -142,7 +153,7 @@ export const makeLocal = Effect.fnUntraced(function* <
|
|
|
142
153
|
|
|
143
154
|
const taskServices = Context.empty().pipe(
|
|
144
155
|
Context.add(TaskCompleter, opts.onTaskComplete),
|
|
145
|
-
Context.add(
|
|
156
|
+
Context.add(DirectoryChanger, changeDirectory),
|
|
146
157
|
Context.add(SubagentExecutor, opts.onSubagent),
|
|
147
158
|
Context.add(Console.Console, console),
|
|
148
159
|
Context.add(References.CurrentLogAnnotations, {}),
|
|
@@ -165,12 +176,13 @@ export const makeLocal = Effect.fnUntraced(function* <
|
|
|
165
176
|
|
|
166
177
|
for (let i = 0; i < toolEntries.length; i++) {
|
|
167
178
|
const { name, handler, services } = toolEntries[i]!
|
|
168
|
-
const
|
|
169
|
-
Context.merge(services, taskServices),
|
|
170
|
-
)
|
|
179
|
+
const toolServices = Context.merge(services, taskServices)
|
|
171
180
|
|
|
172
181
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
173
182
|
sandbox[name] = function (params: any) {
|
|
183
|
+
const runFork = Effect.runForkWith(
|
|
184
|
+
Context.add(toolServices, CurrentDirectory, currentDirectory),
|
|
185
|
+
)
|
|
174
186
|
running++
|
|
175
187
|
const fiber = trackFiber(runFork(handler(params, {})))
|
|
176
188
|
return new Promise((resolve, reject) => {
|
|
@@ -213,6 +225,7 @@ export const makeLocal = Effect.fnUntraced(function* <
|
|
|
213
225
|
const skills = yield* AgentSkills.discover({
|
|
214
226
|
directory: options.directory,
|
|
215
227
|
homeDirectory: homeDirectory(),
|
|
228
|
+
hermesHome: Option.fromNullishOr(process.env.HERMES_HOME),
|
|
216
229
|
}).pipe(
|
|
217
230
|
Effect.provideService(FileSystem.FileSystem, fs),
|
|
218
231
|
Effect.provideService(Path.Path, pathService),
|
|
@@ -225,33 +238,38 @@ export const makeLocal = Effect.fnUntraced(function* <
|
|
|
225
238
|
})
|
|
226
239
|
}),
|
|
227
240
|
execute,
|
|
228
|
-
executeUnsafe: (opts) =>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
241
|
+
executeUnsafe: (opts) =>
|
|
242
|
+
Effect.suspend(() => {
|
|
243
|
+
const tool = tools.tools[opts.tool as keyof typeof tools.tools]
|
|
244
|
+
if (!tool) {
|
|
245
|
+
return Effect.die(new Error(`Unknown tool: ${opts.tool}`))
|
|
246
|
+
}
|
|
247
|
+
const handler = services.mapUnsafe.get(tool.id) as Tool.Handler<string>
|
|
248
|
+
if (!handler) {
|
|
249
|
+
return Effect.die(new Error(`Unknown tool: ${opts.tool}`))
|
|
250
|
+
}
|
|
234
251
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
252
|
+
const taskServices = handler.context.pipe(
|
|
253
|
+
Context.add(TaskCompleter, () => Effect.void),
|
|
254
|
+
Context.add(CurrentDirectory, currentDirectory),
|
|
255
|
+
Context.add(DirectoryChanger, changeDirectory),
|
|
256
|
+
Context.add(SubagentExecutor, () => Effect.succeed("")),
|
|
257
|
+
Context.add(References.CurrentLogAnnotations, {}),
|
|
258
|
+
Option.isSome(search)
|
|
259
|
+
? Context.add(SemanticSearch, search.value)
|
|
260
|
+
: identity,
|
|
261
|
+
)
|
|
244
262
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
263
|
+
const encodeSuccess = Schema.encodeUnknownEffect(
|
|
264
|
+
Schema.toCodecJson(tool.successSchema as Schema.Top),
|
|
265
|
+
)
|
|
266
|
+
return pipe(
|
|
267
|
+
handler.handler(opts.params, {}),
|
|
268
|
+
Effect.flatMap(encodeSuccess),
|
|
269
|
+
Effect.provideContext(taskServices),
|
|
270
|
+
Effect.orDie,
|
|
271
|
+
) as Effect.Effect<Schema.Json>
|
|
272
|
+
}),
|
|
255
273
|
})
|
|
256
274
|
})
|
|
257
275
|
|
|
@@ -319,7 +337,7 @@ export const layerLocal = <Toolkit extends Toolkit.Any = never>(options: {
|
|
|
319
337
|
Toolkit extends Toolkit.Toolkit<infer T>
|
|
320
338
|
? Tool.HandlersFor<T> | Tool.HandlerServices<T[keyof T]>
|
|
321
339
|
: never,
|
|
322
|
-
CurrentDirectory | SubagentExecutor | TaskCompleter
|
|
340
|
+
CurrentDirectory | DirectoryChanger | SubagentExecutor | TaskCompleter
|
|
323
341
|
>
|
|
324
342
|
> =>
|
|
325
343
|
Layer.effect(AgentExecutor, makeLocal(options)).pipe(
|
|
@@ -359,7 +377,7 @@ export const layerRpcServer = <Toolkit extends Toolkit.Any = never>(options: {
|
|
|
359
377
|
Toolkit extends Toolkit.Toolkit<infer T>
|
|
360
378
|
? Tool.HandlersFor<T> | Tool.HandlerServices<T[keyof T]>
|
|
361
379
|
: never,
|
|
362
|
-
CurrentDirectory | SubagentExecutor | TaskCompleter
|
|
380
|
+
CurrentDirectory | DirectoryChanger | SubagentExecutor | TaskCompleter
|
|
363
381
|
>
|
|
364
382
|
> =>
|
|
365
383
|
RpcServer.layer(Rpcs, {
|
package/src/AgentSkills.test.ts
CHANGED
|
@@ -40,6 +40,7 @@ const withRoots = <A, E, R>(
|
|
|
40
40
|
f: (roots: {
|
|
41
41
|
readonly project: string
|
|
42
42
|
readonly home: string
|
|
43
|
+
readonly hermes: string
|
|
43
44
|
}) => Effect.Effect<A, E, R>,
|
|
44
45
|
) =>
|
|
45
46
|
Effect.scoped(
|
|
@@ -49,9 +50,11 @@ const withRoots = <A, E, R>(
|
|
|
49
50
|
const base = yield* fs.makeTempDirectoryScoped()
|
|
50
51
|
const project = path.join(base, "project")
|
|
51
52
|
const home = path.join(base, "home")
|
|
53
|
+
const hermes = path.join(base, "hermes-home")
|
|
54
|
+
yield* fs.makeDirectory(hermes)
|
|
52
55
|
yield* fs.makeDirectory(project)
|
|
53
56
|
yield* fs.makeDirectory(home)
|
|
54
|
-
return yield* f({ project, home })
|
|
57
|
+
return yield* f({ project, home, hermes })
|
|
55
58
|
}),
|
|
56
59
|
)
|
|
57
60
|
|
|
@@ -61,7 +64,291 @@ const discover = (roots: { readonly project: string; readonly home: string }) =>
|
|
|
61
64
|
homeDirectory: Option.some(roots.home),
|
|
62
65
|
})
|
|
63
66
|
|
|
67
|
+
const writeHermesSkill = Effect.fnUntraced(function* (
|
|
68
|
+
root: string,
|
|
69
|
+
dir: string,
|
|
70
|
+
content: string,
|
|
71
|
+
) {
|
|
72
|
+
const fs = yield* FileSystem.FileSystem
|
|
73
|
+
const path = yield* Path.Path
|
|
74
|
+
const skillDir = path.join(root, "skills", dir)
|
|
75
|
+
yield* fs.makeDirectory(skillDir, { recursive: true })
|
|
76
|
+
const location = path.join(skillDir, "SKILL.md")
|
|
77
|
+
yield* fs.writeFileString(location, content)
|
|
78
|
+
return location
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const discoverWithHermes = (
|
|
82
|
+
roots: {
|
|
83
|
+
readonly project: string
|
|
84
|
+
readonly home: string
|
|
85
|
+
readonly hermes: string
|
|
86
|
+
},
|
|
87
|
+
hermesHome: Option.Option<string> = Option.some(roots.hermes),
|
|
88
|
+
) =>
|
|
89
|
+
AgentSkills.discover({
|
|
90
|
+
directory: roots.project,
|
|
91
|
+
homeDirectory: Option.some(roots.home),
|
|
92
|
+
hermesHome,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const withLocalExecutor = <A, E, R>(
|
|
96
|
+
roots: { readonly project: string; readonly home: string },
|
|
97
|
+
hermesHome: string | undefined,
|
|
98
|
+
effect: Effect.Effect<A, E, R>,
|
|
99
|
+
) =>
|
|
100
|
+
Effect.scoped(
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
yield* Effect.acquireRelease(
|
|
103
|
+
Effect.sync(() => {
|
|
104
|
+
const previous = {
|
|
105
|
+
HOME: process.env.HOME,
|
|
106
|
+
HERMES_HOME: process.env.HERMES_HOME,
|
|
107
|
+
}
|
|
108
|
+
process.env.HOME = roots.home
|
|
109
|
+
if (hermesHome === undefined) delete process.env.HERMES_HOME
|
|
110
|
+
else process.env.HERMES_HOME = hermesHome
|
|
111
|
+
return previous
|
|
112
|
+
}),
|
|
113
|
+
(previous) =>
|
|
114
|
+
Effect.sync(() => {
|
|
115
|
+
for (const key of ["HOME", "HERMES_HOME"] as const) {
|
|
116
|
+
if (previous[key] === undefined) delete process.env[key]
|
|
117
|
+
else process.env[key] = previous[key]
|
|
118
|
+
}
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
return yield* effect.pipe(
|
|
122
|
+
Effect.provide(
|
|
123
|
+
AgentExecutor.layerLocal({ directory: roots.project }).pipe(
|
|
124
|
+
Layer.provide(NodeHttpClient.layerUndici),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
|
|
64
131
|
describe("AgentSkills", () => {
|
|
132
|
+
it.effect("catalogs a flat HERMES_HOME skill with an absolute location", () =>
|
|
133
|
+
withRoots(
|
|
134
|
+
Effect.fnUntraced(function* (roots) {
|
|
135
|
+
const path = yield* Path.Path
|
|
136
|
+
const location = yield* writeHermesSkill(
|
|
137
|
+
roots.hermes,
|
|
138
|
+
"bound",
|
|
139
|
+
skillFile("bound", "Workspace skill"),
|
|
140
|
+
)
|
|
141
|
+
const skills = yield* discoverWithHermes(roots)
|
|
142
|
+
assert.deepStrictEqual(
|
|
143
|
+
skills.map(({ name, description, location }) => [
|
|
144
|
+
name,
|
|
145
|
+
description,
|
|
146
|
+
location,
|
|
147
|
+
]),
|
|
148
|
+
[["bound", "Workspace skill", location]],
|
|
149
|
+
)
|
|
150
|
+
assert.isTrue(path.isAbsolute(skills[0]!.location))
|
|
151
|
+
}),
|
|
152
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
for (const projectWins of [true, false]) {
|
|
156
|
+
it.effect(
|
|
157
|
+
projectWins
|
|
158
|
+
? "project skill overrides Hermes and user skills"
|
|
159
|
+
: "Hermes skill overrides a user skill",
|
|
160
|
+
() =>
|
|
161
|
+
withRoots(
|
|
162
|
+
Effect.fnUntraced(function* (roots) {
|
|
163
|
+
yield* writeSkill(roots.home, "shared", skillFile("shared", "User"))
|
|
164
|
+
const hermesLocation = yield* writeHermesSkill(
|
|
165
|
+
roots.hermes,
|
|
166
|
+
"shared",
|
|
167
|
+
skillFile("shared", "Hermes"),
|
|
168
|
+
)
|
|
169
|
+
const location = projectWins
|
|
170
|
+
? yield* writeSkill(
|
|
171
|
+
roots.project,
|
|
172
|
+
"shared",
|
|
173
|
+
skillFile("shared", "Project"),
|
|
174
|
+
)
|
|
175
|
+
: hermesLocation
|
|
176
|
+
const skills = yield* discoverWithHermes(roots)
|
|
177
|
+
assert.deepStrictEqual(
|
|
178
|
+
skills.map((skill) => [skill.description, skill.location]),
|
|
179
|
+
[[projectWins ? "Project" : "Hermes", location]],
|
|
180
|
+
)
|
|
181
|
+
}),
|
|
182
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const mode of ["unset", "empty", "missing skills"] as const) {
|
|
187
|
+
it.effect(`preserves existing discovery with ${mode} HERMES_HOME`, () =>
|
|
188
|
+
withRoots(
|
|
189
|
+
Effect.fnUntraced(function* (roots) {
|
|
190
|
+
const fs = yield* FileSystem.FileSystem
|
|
191
|
+
const path = yield* Path.Path
|
|
192
|
+
yield* writeSkill(roots.project, "proj", skillFile("proj", "Project"))
|
|
193
|
+
yield* writeSkill(roots.home, "usr", skillFile("usr", "User"))
|
|
194
|
+
// An unset input must not fall back to ~/.hermes.
|
|
195
|
+
yield* writeHermesSkill(
|
|
196
|
+
path.join(roots.home, ".hermes"),
|
|
197
|
+
"ignored",
|
|
198
|
+
skillFile("ignored", "Ignored"),
|
|
199
|
+
)
|
|
200
|
+
const reads: Array<string> = []
|
|
201
|
+
const skills = yield* discoverWithHermes(
|
|
202
|
+
roots,
|
|
203
|
+
mode === "unset"
|
|
204
|
+
? Option.none()
|
|
205
|
+
: Option.some(mode === "empty" ? "" : roots.hermes),
|
|
206
|
+
).pipe(
|
|
207
|
+
Effect.provideService(
|
|
208
|
+
FileSystem.FileSystem,
|
|
209
|
+
FileSystem.FileSystem.of({
|
|
210
|
+
...fs,
|
|
211
|
+
readDirectory: (directory) => {
|
|
212
|
+
reads.push(directory)
|
|
213
|
+
return fs.readDirectory(directory)
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
assert.deepStrictEqual(skills, yield* discover(roots))
|
|
219
|
+
if (mode !== "missing skills")
|
|
220
|
+
assert.deepStrictEqual(reads, [
|
|
221
|
+
path.join(roots.project, ".agents", "skills"),
|
|
222
|
+
path.join(roots.home, ".agents", "skills"),
|
|
223
|
+
])
|
|
224
|
+
}),
|
|
225
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
it.effect("only reads HERMES_HOME/skills/<dir>/SKILL.md", () =>
|
|
230
|
+
withRoots(
|
|
231
|
+
Effect.fnUntraced(function* (roots) {
|
|
232
|
+
yield* writeHermesSkill(
|
|
233
|
+
roots.hermes,
|
|
234
|
+
"outer/inner",
|
|
235
|
+
skillFile("nested", "Ignored"),
|
|
236
|
+
)
|
|
237
|
+
yield* writeHermesSkill(roots.hermes, "", skillFile("bare", "Ignored"))
|
|
238
|
+
yield* writeSkill(
|
|
239
|
+
roots.hermes,
|
|
240
|
+
"agents",
|
|
241
|
+
skillFile("agents", "Ignored"),
|
|
242
|
+
)
|
|
243
|
+
const location = yield* writeHermesSkill(
|
|
244
|
+
roots.hermes,
|
|
245
|
+
"real",
|
|
246
|
+
skillFile("real", "Found"),
|
|
247
|
+
)
|
|
248
|
+
const skills = yield* discoverWithHermes(roots)
|
|
249
|
+
assert.deepStrictEqual(
|
|
250
|
+
skills.map((skill) => [skill.name, skill.location]),
|
|
251
|
+
[["real", location]],
|
|
252
|
+
)
|
|
253
|
+
}),
|
|
254
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
it.effect(
|
|
258
|
+
"makeLocal exposes HERMES_HOME skills and reads their relative references",
|
|
259
|
+
() =>
|
|
260
|
+
withRoots(
|
|
261
|
+
Effect.fnUntraced(function* (roots) {
|
|
262
|
+
const fs = yield* FileSystem.FileSystem
|
|
263
|
+
const path = yield* Path.Path
|
|
264
|
+
const content =
|
|
265
|
+
skillFile("bound", "Workspace skill") +
|
|
266
|
+
"Read references/guide.md.\n"
|
|
267
|
+
const location = yield* writeHermesSkill(
|
|
268
|
+
roots.hermes,
|
|
269
|
+
"bound",
|
|
270
|
+
content,
|
|
271
|
+
)
|
|
272
|
+
const reference = path.join(
|
|
273
|
+
path.dirname(location),
|
|
274
|
+
"references",
|
|
275
|
+
"guide.md",
|
|
276
|
+
)
|
|
277
|
+
yield* fs.makeDirectory(path.dirname(reference))
|
|
278
|
+
yield* fs.writeFileString(
|
|
279
|
+
reference,
|
|
280
|
+
"Workspace reference instructions.",
|
|
281
|
+
)
|
|
282
|
+
yield* withLocalExecutor(
|
|
283
|
+
roots,
|
|
284
|
+
roots.hermes,
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const executor = yield* AgentExecutor.AgentExecutor
|
|
287
|
+
const capabilities = yield* executor.capabilities
|
|
288
|
+
assert.deepStrictEqual(
|
|
289
|
+
capabilities.skills.map((skill) => [
|
|
290
|
+
skill.name,
|
|
291
|
+
skill.location,
|
|
292
|
+
]),
|
|
293
|
+
[["bound", location]],
|
|
294
|
+
)
|
|
295
|
+
const discovered = capabilities.skills[0]!.location
|
|
296
|
+
const body = yield* executor.executeUnsafe({
|
|
297
|
+
tool: "readFile",
|
|
298
|
+
params: { path: discovered },
|
|
299
|
+
})
|
|
300
|
+
assert.include(String(body), "Read references/guide.md.")
|
|
301
|
+
const guide = yield* executor.executeUnsafe({
|
|
302
|
+
tool: "readFile",
|
|
303
|
+
params: {
|
|
304
|
+
path: path.resolve(
|
|
305
|
+
path.dirname(discovered),
|
|
306
|
+
"references/guide.md",
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
assert.include(String(guide), "Workspace reference instructions.")
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
}),
|
|
314
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
for (const hermesHome of [undefined, ""]) {
|
|
318
|
+
it.effect(
|
|
319
|
+
hermesHome === undefined
|
|
320
|
+
? "makeLocal leaves unset HERMES_HOME disabled"
|
|
321
|
+
: "makeLocal treats empty HERMES_HOME as unset",
|
|
322
|
+
() =>
|
|
323
|
+
withRoots(
|
|
324
|
+
Effect.fnUntraced(function* (roots) {
|
|
325
|
+
const path = yield* Path.Path
|
|
326
|
+
yield* writeHermesSkill(
|
|
327
|
+
path.join(roots.home, ".hermes"),
|
|
328
|
+
"ignored",
|
|
329
|
+
skillFile("ignored", "Ignored"),
|
|
330
|
+
)
|
|
331
|
+
const location = yield* writeSkill(
|
|
332
|
+
roots.project,
|
|
333
|
+
"proj",
|
|
334
|
+
skillFile("proj", "Project"),
|
|
335
|
+
)
|
|
336
|
+
const capabilities = yield* withLocalExecutor(
|
|
337
|
+
roots,
|
|
338
|
+
hermesHome,
|
|
339
|
+
AgentExecutor.AgentExecutor.pipe(
|
|
340
|
+
Effect.flatMap((executor) => executor.capabilities),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
assert.deepStrictEqual(
|
|
344
|
+
capabilities.skills.map((skill) => skill.location),
|
|
345
|
+
[location],
|
|
346
|
+
)
|
|
347
|
+
}),
|
|
348
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
65
352
|
for (const type of ["FIFO", "Socket", "Directory"] as const) {
|
|
66
353
|
it.effect(`skips a ${type} SKILL.md without attempting to read it`, () =>
|
|
67
354
|
withRoots(
|
|
@@ -456,7 +743,7 @@ body`,
|
|
|
456
743
|
it.effect("returns an empty catalog when nothing exists", () =>
|
|
457
744
|
withRoots(
|
|
458
745
|
Effect.fnUntraced(function* (roots) {
|
|
459
|
-
const skills = yield*
|
|
746
|
+
const skills = yield* discoverWithHermes(roots, Option.none())
|
|
460
747
|
assert.deepStrictEqual(skills, [])
|
|
461
748
|
assert.isTrue(Option.isNone(AgentSkills.renderCatalog(skills)))
|
|
462
749
|
}),
|
|
@@ -478,19 +765,11 @@ body`,
|
|
|
478
765
|
)
|
|
479
766
|
yield* writeSkill(roots.project, "broken", "---\nnope\n---")
|
|
480
767
|
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
AgentExecutor.layerLocal({ directory: roots.project }).pipe(
|
|
487
|
-
Layer.provide(NodeHttpClient.layerUndici),
|
|
488
|
-
),
|
|
489
|
-
),
|
|
490
|
-
Effect.ensuring(
|
|
491
|
-
Effect.sync(() => {
|
|
492
|
-
process.env.HOME = previousHome
|
|
493
|
-
}),
|
|
768
|
+
const capabilities = yield* withLocalExecutor(
|
|
769
|
+
roots,
|
|
770
|
+
undefined,
|
|
771
|
+
AgentExecutor.AgentExecutor.pipe(
|
|
772
|
+
Effect.flatMap((executor) => executor.capabilities),
|
|
494
773
|
),
|
|
495
774
|
)
|
|
496
775
|
assert.deepStrictEqual(
|
|
@@ -567,6 +846,48 @@ const systemPromptFor = (skills: ReadonlyArray<AgentSkills.Skill>) =>
|
|
|
567
846
|
)
|
|
568
847
|
|
|
569
848
|
describe("Agent skills catalog", () => {
|
|
849
|
+
it.effect(
|
|
850
|
+
"renders a Hermes skill in the system prompt with its absolute location",
|
|
851
|
+
() =>
|
|
852
|
+
withRoots(
|
|
853
|
+
Effect.fnUntraced(function* (roots) {
|
|
854
|
+
const location = yield* writeHermesSkill(
|
|
855
|
+
roots.hermes,
|
|
856
|
+
"bound",
|
|
857
|
+
skillFile("bound", "Workspace skill"),
|
|
858
|
+
)
|
|
859
|
+
const system = yield* systemPromptFor(
|
|
860
|
+
yield* discoverWithHermes(roots),
|
|
861
|
+
)
|
|
862
|
+
assert.include(system, "# Skills")
|
|
863
|
+
assert.include(system, "- bound: Workspace skill")
|
|
864
|
+
assert.include(system, "location: " + location)
|
|
865
|
+
assert.notInclude(system, "Instructions for bound.")
|
|
866
|
+
}),
|
|
867
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
it.effect(
|
|
871
|
+
"omits the catalog with HERMES_HOME pointing at an empty directory",
|
|
872
|
+
() =>
|
|
873
|
+
withRoots(
|
|
874
|
+
Effect.fnUntraced(function* (roots) {
|
|
875
|
+
const capabilities = yield* withLocalExecutor(
|
|
876
|
+
roots,
|
|
877
|
+
roots.hermes,
|
|
878
|
+
AgentExecutor.AgentExecutor.pipe(
|
|
879
|
+
Effect.flatMap((executor) => executor.capabilities),
|
|
880
|
+
),
|
|
881
|
+
)
|
|
882
|
+
assert.deepStrictEqual(capabilities.skills, [])
|
|
883
|
+
assert.notInclude(
|
|
884
|
+
yield* systemPromptFor(capabilities.skills),
|
|
885
|
+
"# Skills",
|
|
886
|
+
)
|
|
887
|
+
}),
|
|
888
|
+
).pipe(Effect.provide(NodeServices.layer)),
|
|
889
|
+
)
|
|
890
|
+
|
|
570
891
|
it.effect(
|
|
571
892
|
"escapes a newline in a catalog location without changing the filesystem path",
|
|
572
893
|
() =>
|
package/src/AgentSkills.ts
CHANGED
|
@@ -37,9 +37,11 @@ export class Skill extends Schema.Class<Skill>("Skill")({
|
|
|
37
37
|
}) {}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Scan project and user `.agents/skills/<dir>/SKILL.md` files
|
|
40
|
+
* Scan project and user `.agents/skills/<dir>/SKILL.md` files, plus
|
|
41
|
+
* `<hermesHome>/skills/<dir>/SKILL.md` when explicitly configured.
|
|
41
42
|
*
|
|
42
|
-
* Project names override
|
|
43
|
+
* Project names override Hermes names, which override user names.
|
|
44
|
+
* The first name wins within each scope; an empty Hermes home is ignored.
|
|
43
45
|
* Skip unreadable files, invalid frontmatter and missing or empty descriptions.
|
|
44
46
|
*
|
|
45
47
|
* @since 1.0.0
|
|
@@ -48,6 +50,7 @@ export class Skill extends Schema.Class<Skill>("Skill")({
|
|
|
48
50
|
export const discover: (options: {
|
|
49
51
|
readonly directory: string
|
|
50
52
|
readonly homeDirectory: Option.Option<string>
|
|
53
|
+
readonly hermesHome?: Option.Option<string>
|
|
51
54
|
}) => Effect.Effect<
|
|
52
55
|
ReadonlyArray<Skill>,
|
|
53
56
|
never,
|
|
@@ -57,14 +60,31 @@ export const discover: (options: {
|
|
|
57
60
|
const path = yield* Path.Path
|
|
58
61
|
|
|
59
62
|
const roots: Array<{ readonly root: string; readonly source: SkillSource }> =
|
|
60
|
-
[
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
root: path.resolve(options.directory, ".agents", "skills"),
|
|
66
|
+
source: "project",
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
if (
|
|
70
|
+
options.hermesHome &&
|
|
71
|
+
Option.isSome(options.hermesHome) &&
|
|
72
|
+
options.hermesHome.value !== ""
|
|
73
|
+
) {
|
|
74
|
+
roots.push({
|
|
75
|
+
root: path.resolve(options.hermesHome.value, "skills"),
|
|
76
|
+
source: "user",
|
|
77
|
+
})
|
|
78
|
+
}
|
|
61
79
|
if (Option.isSome(options.homeDirectory)) {
|
|
62
|
-
roots.push({
|
|
80
|
+
roots.push({
|
|
81
|
+
root: path.resolve(options.homeDirectory.value, ".agents", "skills"),
|
|
82
|
+
source: "user",
|
|
83
|
+
})
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
const byName = new Map<string, Skill>()
|
|
66
|
-
for (const { root, source } of roots) {
|
|
67
|
-
const skillsDir = path.resolve(root, ".agents", "skills")
|
|
87
|
+
for (const { root: skillsDir, source } of roots) {
|
|
68
88
|
const entries = yield* fs
|
|
69
89
|
.readDirectory(skillsDir)
|
|
70
90
|
.pipe(Effect.orElseSucceed(() => []))
|