@soederpop/luca 0.0.23 → 0.0.25
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/AGENTS.md +1 -1
- package/CLAUDE.md +6 -1
- package/assistants/codingAssistant/hooks.ts +0 -1
- package/assistants/lucaExpert/CORE.md +37 -0
- package/assistants/lucaExpert/hooks.ts +9 -0
- package/assistants/lucaExpert/tools.ts +177 -0
- package/commands/build-bootstrap.ts +41 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -1
- package/docs/apis/clients/rest.md +5 -5
- package/docs/apis/features/agi/assistant.md +1 -1
- package/docs/apis/features/agi/conversation-history.md +6 -7
- package/docs/apis/features/agi/conversation.md +1 -1
- package/docs/apis/features/agi/semantic-search.md +1 -1
- package/docs/bootstrap/CLAUDE.md +1 -1
- package/docs/bootstrap/SKILL.md +7 -3
- package/docs/bootstrap/templates/luca-cli.ts +5 -0
- package/docs/mcp/readme.md +1 -1
- package/docs/tutorials/00-bootstrap.md +18 -0
- package/package.json +2 -2
- package/scripts/stamp-build.sh +12 -0
- package/scripts/test-docs-reader.ts +10 -0
- package/src/agi/container.server.ts +8 -5
- package/src/agi/features/assistant.ts +208 -55
- package/src/agi/features/assistants-manager.ts +138 -66
- package/src/agi/features/conversation.ts +46 -14
- package/src/agi/features/docs-reader.ts +142 -0
- package/src/agi/features/openapi.ts +1 -1
- package/src/agi/features/skills-library.ts +257 -313
- package/src/bootstrap/generated.ts +8163 -6
- package/src/cli/build-info.ts +4 -0
- package/src/cli/cli.ts +2 -1
- package/src/commands/bootstrap.ts +16 -1
- package/src/commands/eval.ts +6 -1
- package/src/commands/sandbox-mcp.ts +17 -7
- package/src/helper.ts +56 -2
- package/src/introspection/generated.agi.ts +2409 -1608
- package/src/introspection/generated.node.ts +902 -594
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/container.ts +1 -1
- package/src/node/features/content-db.ts +251 -13
- package/src/node/features/git.ts +90 -0
- package/src/node/features/grep.ts +1 -1
- package/src/node/features/proc.ts +1 -0
- package/src/node/features/tts.ts +1 -1
- package/src/node/features/vm.ts +48 -0
- package/src/scaffolds/generated.ts +2 -2
- package/assistants/architect/CORE.md +0 -3
- package/assistants/architect/hooks.ts +0 -3
- package/assistants/architect/tools.ts +0 -10
- package/docs/apis/features/agi/skills-library.md +0 -234
- package/docs/reports/assistant-bugs.md +0 -38
- package/docs/reports/attach-pattern-usage.md +0 -18
- package/docs/reports/code-audit-results.md +0 -391
- package/docs/reports/console-hmr-design.md +0 -170
- package/docs/reports/helper-semantic-search.md +0 -72
- package/docs/reports/introspection-audit-tasks.md +0 -378
- package/docs/reports/luca-mcp-improvements.md +0 -128
- package/test-integration/skills-library.test.ts +0 -157
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import os from 'os'
|
|
4
|
-
import fs from 'fs/promises'
|
|
5
|
-
import yaml from 'js-yaml'
|
|
6
|
-
import { kebabCase } from 'lodash-es'
|
|
7
2
|
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
8
3
|
import { type AvailableFeatures, Feature } from '@soederpop/luca/feature'
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
4
|
+
import { parse } from 'contentbase'
|
|
5
|
+
import type { DocsReader } from './docs-reader.js'
|
|
11
6
|
|
|
12
7
|
declare module '@soederpop/luca/feature' {
|
|
13
8
|
interface AvailableFeatures {
|
|
@@ -15,84 +10,56 @@ declare module '@soederpop/luca/feature' {
|
|
|
15
10
|
}
|
|
16
11
|
}
|
|
17
12
|
|
|
18
|
-
export interface
|
|
19
|
-
/** Skill name from frontmatter */
|
|
13
|
+
export interface SkillInfo {
|
|
14
|
+
/** Skill name derived from folder name or frontmatter */
|
|
20
15
|
name: string
|
|
21
|
-
/**
|
|
16
|
+
/** Description from frontmatter */
|
|
22
17
|
description: string
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
/** Which
|
|
28
|
-
|
|
29
|
-
/** Directory/path id within its collection */
|
|
30
|
-
pathId: string
|
|
18
|
+
/** Absolute path to the skill folder (dirname of SKILL.md) */
|
|
19
|
+
path: string
|
|
20
|
+
/** Absolute path to SKILL.md */
|
|
21
|
+
skillFilePath: string
|
|
22
|
+
/** Which location this skill was found in */
|
|
23
|
+
locationPath: string
|
|
31
24
|
/** All frontmatter metadata */
|
|
32
25
|
meta: Record<string, unknown>
|
|
33
26
|
}
|
|
34
27
|
|
|
35
|
-
const SkillMetaSchema = z.object({
|
|
36
|
-
name: z.string().describe('Unique name identifier for the skill'),
|
|
37
|
-
description: z.string().describe('What the skill does and when to use it'),
|
|
38
|
-
version: z.string().optional().describe('Skill version'),
|
|
39
|
-
tags: z.array(z.string()).optional().describe('Tags for categorization'),
|
|
40
|
-
author: z.string().optional().describe('Skill author'),
|
|
41
|
-
license: z.string().optional().describe('Skill license'),
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
const SkillModel = defineModel('Skill', {
|
|
45
|
-
meta: SkillMetaSchema as any,
|
|
46
|
-
match: (doc: { id: string; meta: Record<string, unknown> }) =>
|
|
47
|
-
doc.id.endsWith('/SKILL') || doc.id === 'SKILL',
|
|
48
|
-
})
|
|
49
|
-
|
|
50
28
|
export const SkillsLibraryStateSchema = FeatureStateSchema.extend({
|
|
51
|
-
loaded: z.boolean().describe('Whether
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
29
|
+
loaded: z.boolean().describe('Whether skill locations have been scanned'),
|
|
30
|
+
locations: z.array(z.string()).describe('Tracked skill location folder paths'),
|
|
31
|
+
skillCount: z.number().describe('Total number of discovered skills'),
|
|
32
|
+
skills: z.record(z.string(), z.any()).describe('Discovered skills keyed by name'),
|
|
55
33
|
})
|
|
56
34
|
|
|
57
35
|
export const SkillsLibraryOptionsSchema = FeatureOptionsSchema.extend({
|
|
58
|
-
|
|
59
|
-
projectSkillsPath: z.string().optional().describe('Path to project-level skills directory'),
|
|
60
|
-
/** Path to user-level skills directory. Defaults to ~/.luca/skills. */
|
|
61
|
-
userSkillsPath: z.string().optional().describe('Path to user-level global skills directory'),
|
|
36
|
+
configPath: z.string().optional().describe('Override path for skills.json (defaults to ~/.luca/skills.json)'),
|
|
62
37
|
})
|
|
63
38
|
|
|
64
39
|
export const SkillsLibraryEventsSchema = FeatureEventsSchema.extend({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
skillRemoved: z.tuple([z.string().describe('The name of the removed skill')]).describe('Fired after a skill is deleted'),
|
|
40
|
+
started: z.tuple([]).describe('Fired after all skill locations have been scanned'),
|
|
41
|
+
locationAdded: z.tuple([z.string().describe('The absolute path of the added location')]).describe('Fired when a new skill location is registered'),
|
|
42
|
+
skillDiscovered: z.tuple([z.any().describe('The SkillInfo object')]).describe('Fired when a skill is discovered during scanning'),
|
|
69
43
|
}).describe('SkillsLibrary events')
|
|
70
44
|
|
|
71
45
|
export type SkillsLibraryState = z.infer<typeof SkillsLibraryStateSchema>
|
|
72
46
|
export type SkillsLibraryOptions = z.infer<typeof SkillsLibraryOptionsSchema>
|
|
73
47
|
|
|
74
48
|
/**
|
|
75
|
-
* Manages
|
|
76
|
-
* Project-level skills live in .claude/skills/ and user-level skills live in ~/.luca/skills/.
|
|
77
|
-
* Skills can be discovered, searched, created, updated, and removed at runtime.
|
|
49
|
+
* Manages a registry of skill locations — folders containing SKILL.md files.
|
|
78
50
|
*
|
|
79
|
-
*
|
|
51
|
+
* Persists known locations to ~/.luca/skills.json and scans them on start.
|
|
52
|
+
* Each skill folder can be opened as a DocsReader for AI-assisted Q&A.
|
|
53
|
+
* Exposes tools for assistant integration via assistant.use(skillsLibrary).
|
|
80
54
|
*
|
|
55
|
+
* @extends Feature
|
|
81
56
|
* @example
|
|
82
57
|
* ```typescript
|
|
83
|
-
* const
|
|
84
|
-
* await
|
|
85
|
-
*
|
|
86
|
-
* //
|
|
87
|
-
* const
|
|
88
|
-
* const matches = skills.search('code review')
|
|
89
|
-
*
|
|
90
|
-
* // Create a new skill
|
|
91
|
-
* await skills.create({
|
|
92
|
-
* name: 'summarize',
|
|
93
|
-
* description: 'Summarize a document',
|
|
94
|
-
* body: '## Instructions\nRead the document and produce a concise summary.'
|
|
95
|
-
* })
|
|
58
|
+
* const lib = container.feature('skillsLibrary')
|
|
59
|
+
* await lib.start()
|
|
60
|
+
* await lib.addLocation('~/.claude/skills')
|
|
61
|
+
* lib.list() // => SkillInfo[]
|
|
62
|
+
* const reader = lib.createSkillReader('my-skill')
|
|
96
63
|
* ```
|
|
97
64
|
*/
|
|
98
65
|
export class SkillsLibrary extends Feature<SkillsLibraryState, SkillsLibraryOptions> {
|
|
@@ -103,326 +70,303 @@ export class SkillsLibrary extends Feature<SkillsLibraryState, SkillsLibraryOpti
|
|
|
103
70
|
|
|
104
71
|
static { Feature.register(this, 'skillsLibrary') }
|
|
105
72
|
|
|
106
|
-
|
|
107
|
-
|
|
73
|
+
/** Tools for assistant integration via assistant.use(skillsLibrary). */
|
|
74
|
+
static tools: Record<string, { schema: z.ZodType; handler?: Function }> = {
|
|
75
|
+
searchAvailableSkills: {
|
|
76
|
+
schema: z.object({
|
|
77
|
+
query: z.string().optional().describe('Optional search term to filter skills by name or description'),
|
|
78
|
+
}).describe('Search for available skills in the library. Returns matching skill names and descriptions.'),
|
|
79
|
+
},
|
|
80
|
+
loadSkill: {
|
|
81
|
+
schema: z.object({
|
|
82
|
+
skillName: z.string().describe('The name of the skill to load'),
|
|
83
|
+
}).describe('Load a skill by name and return its full SKILL.md content and metadata.'),
|
|
84
|
+
},
|
|
85
|
+
askSkillBasedQuestion: {
|
|
86
|
+
schema: z.object({
|
|
87
|
+
skillName: z.string().describe('The name of the skill to query'),
|
|
88
|
+
question: z.string().describe('The question to ask about the skill'),
|
|
89
|
+
}).describe('Ask a question about a specific skill using AI-assisted document reading.'),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
108
92
|
|
|
109
|
-
/** @returns Default state
|
|
93
|
+
/** @returns Default state. */
|
|
110
94
|
override get initialState(): SkillsLibraryState {
|
|
111
95
|
return {
|
|
112
96
|
...super.initialState,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
97
|
+
started: false,
|
|
98
|
+
locations: [],
|
|
99
|
+
skillCount: 0,
|
|
100
|
+
skills: {},
|
|
117
101
|
}
|
|
118
102
|
}
|
|
119
103
|
|
|
120
|
-
/**
|
|
121
|
-
get
|
|
122
|
-
|
|
123
|
-
const rootPath =
|
|
124
|
-
this.options.projectSkillsPath ||
|
|
125
|
-
(this.container as any).paths.resolve('.claude', 'skills')
|
|
126
|
-
this._projectCollection = new Collection({ rootPath, extensions: ['md'] })
|
|
127
|
-
this._projectCollection.register(SkillModel)
|
|
128
|
-
return this._projectCollection
|
|
104
|
+
/** Discovered skills keyed by name. */
|
|
105
|
+
get skills(): Record<string, SkillInfo> {
|
|
106
|
+
return (this.state.get('skills') || {}) as Record<string, SkillInfo>
|
|
129
107
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (this._userCollection) return this._userCollection
|
|
134
|
-
const rootPath =
|
|
135
|
-
this.options.userSkillsPath || path.resolve(os.homedir(), '.luca', 'skills')
|
|
136
|
-
this._userCollection = new Collection({ rootPath, extensions: ['md'] })
|
|
137
|
-
this._userCollection.register(SkillModel)
|
|
138
|
-
return this._userCollection
|
|
108
|
+
|
|
109
|
+
get availableSkills() {
|
|
110
|
+
return Object.keys(this.skills)
|
|
139
111
|
}
|
|
112
|
+
|
|
113
|
+
get skillsTable() : Record<string, string> {
|
|
114
|
+
const skills = this.skills
|
|
140
115
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
116
|
+
return Object.fromEntries(
|
|
117
|
+
Object.keys(skills).map((name) => [name, this.skills[name]!.description])
|
|
118
|
+
)
|
|
144
119
|
}
|
|
145
120
|
|
|
146
|
-
/**
|
|
147
|
-
get
|
|
148
|
-
|
|
121
|
+
/** Resolved path to the skills.json config file. */
|
|
122
|
+
get configPath(): string {
|
|
123
|
+
if (this.options.configPath) return this.options.configPath
|
|
124
|
+
const { os, paths } = this.container
|
|
125
|
+
return paths.resolve(os.homedir, '.luca', 'skills.json')
|
|
149
126
|
}
|
|
150
127
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
128
|
+
/** Whether the library has been loaded. */
|
|
129
|
+
get isStarted(): boolean {
|
|
130
|
+
return !!this.state.get('started')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Expand ~ to home directory in a path. */
|
|
134
|
+
private expandHome(p: string): string {
|
|
135
|
+
return p.replace(/^\~/, this.container.os.homedir)
|
|
136
|
+
}
|
|
159
137
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
// Directory doesn't exist yet - zero project skills
|
|
164
|
-
}
|
|
138
|
+
/** Read the persisted config, creating it if it doesn't exist. */
|
|
139
|
+
private readConfig(): { locations: string[] } {
|
|
140
|
+
const { fs } = this.container
|
|
165
141
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
142
|
+
if (!fs.exists(this.configPath)) {
|
|
143
|
+
const defaultConfig = { locations: [] as string[] }
|
|
144
|
+
this.writeConfig(defaultConfig)
|
|
145
|
+
return defaultConfig
|
|
170
146
|
}
|
|
171
147
|
|
|
172
|
-
this.
|
|
173
|
-
this.state.set('loaded', true)
|
|
174
|
-
this.emit('loaded')
|
|
175
|
-
return this
|
|
148
|
+
return fs.readJson(this.configPath)
|
|
176
149
|
}
|
|
177
150
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
list(): SkillEntry[] {
|
|
184
|
-
const projectSkills = this.listFromCollection(this.projectCollection, 'project')
|
|
185
|
-
const userSkills = this.listFromCollection(this.userCollection, 'user')
|
|
186
|
-
return [...projectSkills, ...userSkills]
|
|
151
|
+
/** Write the config back to disk. */
|
|
152
|
+
private writeConfig(config: { locations: string[] }): void {
|
|
153
|
+
const { fs, os, paths } = this.container
|
|
154
|
+
fs.mkdirp(paths.resolve(os.homedir, '.luca'))
|
|
155
|
+
fs.writeJson(this.configPath, config, 2)
|
|
187
156
|
}
|
|
188
157
|
|
|
189
158
|
/**
|
|
190
|
-
*
|
|
159
|
+
* Start the skills library: read config, scan all locations.
|
|
191
160
|
*
|
|
192
|
-
* @
|
|
193
|
-
* @returns {SkillEntry | undefined} The skill entry, or undefined if not found
|
|
161
|
+
* @returns This instance for chaining
|
|
194
162
|
*/
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
163
|
+
async start(): Promise<SkillsLibrary> {
|
|
164
|
+
if (this.isStarted) return this
|
|
165
|
+
|
|
166
|
+
const { uniq } = this.container.utils.lodash
|
|
167
|
+
const config = this.readConfig()
|
|
168
|
+
const configLocations = config.locations.map(l => this.expandHome(l))
|
|
169
|
+
const allLocations = uniq([
|
|
170
|
+
...configLocations,
|
|
171
|
+
(this.container as any).paths.resolve((this.container as any).os.homedir, '.claude', 'skills'),
|
|
172
|
+
(this.container as any).paths.resolve((this.container as any).cwd, '.claude', 'skills')
|
|
173
|
+
]).filter(Boolean).filter(l => (this.container as any).fs.exists(l))
|
|
174
|
+
this.state.set('locations', allLocations)
|
|
175
|
+
|
|
176
|
+
for (const loc of allLocations) {
|
|
177
|
+
await this.scanLocation(loc)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.state.set('started', true)
|
|
181
|
+
this.state.set('skillCount', Object.keys(this.skills).length)
|
|
182
|
+
this.emit('started')
|
|
183
|
+
|
|
184
|
+
return this
|
|
198
185
|
}
|
|
199
186
|
|
|
200
187
|
/**
|
|
201
|
-
*
|
|
188
|
+
* Add a new skill location folder and scan it for skills.
|
|
202
189
|
*
|
|
203
|
-
* @param
|
|
204
|
-
* @returns {SkillEntry[]} Matching skills
|
|
190
|
+
* @param locationPath - Path to a directory containing skill subfolders with SKILL.md
|
|
205
191
|
*/
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
192
|
+
async addLocation(locationPath: string): Promise<void> {
|
|
193
|
+
const resolved = this.expandHome(locationPath)
|
|
194
|
+
const current = this.state.get('locations') as string[]
|
|
195
|
+
|
|
196
|
+
if (current.includes(resolved)) return
|
|
197
|
+
|
|
198
|
+
const updated = [...current, resolved]
|
|
199
|
+
this.state.set('locations', updated)
|
|
200
|
+
|
|
201
|
+
// Persist — store the original (unexpanded) path for portability
|
|
202
|
+
const config = this.readConfig()
|
|
203
|
+
if (!config.locations.includes(locationPath)) {
|
|
204
|
+
config.locations.push(locationPath)
|
|
205
|
+
this.writeConfig(config)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await this.scanLocation(resolved)
|
|
209
|
+
this.state.set('skillCount', Object.keys(this.skills).length)
|
|
210
|
+
this.emit('locationAdded', resolved)
|
|
212
211
|
}
|
|
213
212
|
|
|
214
213
|
/**
|
|
215
|
-
*
|
|
214
|
+
* Remove a skill location and its skills from the library.
|
|
216
215
|
*
|
|
217
|
-
* @param
|
|
218
|
-
* @returns {SkillEntry | undefined} The skill entry
|
|
216
|
+
* @param locationPath - The location path to remove
|
|
219
217
|
*/
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
async removeLocation(locationPath: string): Promise<void> {
|
|
219
|
+
const resolved = this.expandHome(locationPath)
|
|
220
|
+
const current = this.state.get('locations') as string[]
|
|
221
|
+
this.state.set('locations', current.filter(l => l !== resolved))
|
|
222
|
+
|
|
223
|
+
// Remove skills from this location
|
|
224
|
+
const remaining: Record<string, SkillInfo> = {}
|
|
225
|
+
for (const [name, info] of Object.entries(this.skills)) {
|
|
226
|
+
if (info.locationPath !== resolved) {
|
|
227
|
+
remaining[name] = info
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.state.set('skills', remaining)
|
|
231
|
+
this.state.set('skillCount', Object.keys(remaining).length)
|
|
232
|
+
|
|
233
|
+
// Persist
|
|
234
|
+
const config = this.readConfig()
|
|
235
|
+
config.locations = config.locations.filter(l => this.expandHome(l) !== resolved)
|
|
236
|
+
this.writeConfig(config)
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
/**
|
|
225
|
-
*
|
|
226
|
-
* Maintains the directory-per-skill structure (skill-name/SKILL.md).
|
|
240
|
+
* Scan a location folder for skill subfolders containing SKILL.md.
|
|
227
241
|
*
|
|
228
|
-
* @param
|
|
229
|
-
* @param {'project' | 'user'} target - Which collection to write to (default: 'project')
|
|
230
|
-
* @returns {Promise<SkillEntry>} The created skill entry
|
|
242
|
+
* @param locationPath - Absolute path to scan
|
|
231
243
|
*/
|
|
232
|
-
async
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
raw: content,
|
|
264
|
-
source: target,
|
|
265
|
-
pathId,
|
|
266
|
-
meta: { name: skill.name, description: skill.description, ...skill.meta },
|
|
244
|
+
async scanLocation(locationPath: string): Promise<void> {
|
|
245
|
+
const { fs, paths } = this.container
|
|
246
|
+
if (!fs.exists(locationPath)) return
|
|
247
|
+
|
|
248
|
+
const entries = fs.readdirSync(locationPath)
|
|
249
|
+
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
const skillDir = paths.resolve(locationPath, entry)
|
|
252
|
+
const skillFile = paths.resolve(skillDir, 'SKILL.md')
|
|
253
|
+
|
|
254
|
+
if (!fs.exists(skillFile)) continue
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const parsed = await parse(skillFile)
|
|
258
|
+
const meta = (parsed.meta || {}) as Record<string, unknown>
|
|
259
|
+
const name = entry
|
|
260
|
+
|
|
261
|
+
const info: SkillInfo = {
|
|
262
|
+
name,
|
|
263
|
+
description: (meta.description as string) || '',
|
|
264
|
+
path: skillDir,
|
|
265
|
+
skillFilePath: skillFile,
|
|
266
|
+
locationPath,
|
|
267
|
+
meta,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.state.set('skills', { ...this.skills, [name]: info })
|
|
271
|
+
this.emit('skillDiscovered', info)
|
|
272
|
+
} catch {
|
|
273
|
+
// Skip unparseable skill files
|
|
274
|
+
}
|
|
267
275
|
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Return all discovered skills. */
|
|
279
|
+
list(): SkillInfo[] {
|
|
280
|
+
return Object.values(this.skills)
|
|
281
|
+
}
|
|
268
282
|
|
|
269
|
-
|
|
270
|
-
|
|
283
|
+
/** Find a skill by name. */
|
|
284
|
+
find(skillName: string): SkillInfo | undefined {
|
|
285
|
+
return this.skills[skillName]
|
|
271
286
|
}
|
|
272
287
|
|
|
273
288
|
/**
|
|
274
|
-
*
|
|
289
|
+
* Create a DocsReader for a skill's folder, enabling AI-assisted Q&A.
|
|
275
290
|
*
|
|
276
|
-
* @param
|
|
277
|
-
* @
|
|
278
|
-
* @returns {Promise<SkillEntry>} The updated skill entry
|
|
291
|
+
* @param skillName - Name of the skill to create a reader for
|
|
292
|
+
* @returns A DocsReader instance rooted at the skill's folder
|
|
279
293
|
*/
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
description?: string
|
|
284
|
-
body?: string
|
|
285
|
-
meta?: Record<string, unknown>
|
|
286
|
-
}
|
|
287
|
-
): Promise<SkillEntry> {
|
|
288
|
-
const existing = this.find(name)
|
|
289
|
-
if (!existing) throw new Error(`Skill "${name}" not found`)
|
|
290
|
-
|
|
291
|
-
const collection =
|
|
292
|
-
existing.source === 'project' ? this.projectCollection : this.userCollection
|
|
293
|
-
|
|
294
|
-
const newMeta = { ...existing.meta, ...updates.meta }
|
|
295
|
-
if (updates.description) newMeta.description = updates.description
|
|
296
|
-
|
|
297
|
-
const frontmatter = (yaml.dump(newMeta) as string).trim()
|
|
298
|
-
const body = updates.body ?? existing.body
|
|
299
|
-
const content = `---\n${frontmatter}\n---\n\n${body}`
|
|
300
|
-
|
|
301
|
-
await collection.saveItem(existing.pathId, { content, extension: '.md' })
|
|
302
|
-
await collection.load({ refresh: true })
|
|
303
|
-
this.updateCounts()
|
|
304
|
-
|
|
305
|
-
const entry: SkillEntry = {
|
|
306
|
-
name: existing.name,
|
|
307
|
-
description: updates.description ?? existing.description,
|
|
308
|
-
body,
|
|
309
|
-
raw: content,
|
|
310
|
-
source: existing.source,
|
|
311
|
-
pathId: existing.pathId,
|
|
312
|
-
meta: newMeta,
|
|
313
|
-
}
|
|
294
|
+
createSkillReader(skillName: string): DocsReader {
|
|
295
|
+
const skill = this.find(skillName)
|
|
296
|
+
if (!skill) throw new Error(`Skill "${skillName}" not found in the library`)
|
|
314
297
|
|
|
315
|
-
this.
|
|
316
|
-
return entry
|
|
298
|
+
return this.container.feature('docsReader', { contentDb: skill.path })
|
|
317
299
|
}
|
|
318
300
|
|
|
319
301
|
/**
|
|
320
|
-
*
|
|
302
|
+
* Create a tmp directory containing symlinked/copied skill folders by name,
|
|
303
|
+
* suitable for passing to claude --add-dir.
|
|
321
304
|
*
|
|
322
|
-
* @param
|
|
323
|
-
* @returns
|
|
305
|
+
* @param skillNames - Array of skill names to include
|
|
306
|
+
* @returns Absolute path to the created directory
|
|
324
307
|
*/
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
308
|
+
ensureFolderCreatedWithSkillsByName(skillNames: string[]): string {
|
|
309
|
+
const { fs, paths, os } = this.container
|
|
310
|
+
const hash = this.container.utils.hashObject(skillNames.sort())
|
|
311
|
+
const dir = paths.resolve(os.tmpdir, 'luca-skills', hash)
|
|
328
312
|
|
|
329
|
-
|
|
330
|
-
existing.source === 'project' ? this.projectCollection : this.userCollection
|
|
313
|
+
if (fs.exists(dir)) return dir
|
|
331
314
|
|
|
332
|
-
|
|
315
|
+
fs.mkdirp(dir)
|
|
333
316
|
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
317
|
+
for (const name of skillNames) {
|
|
318
|
+
const skill = this.find(name)
|
|
319
|
+
if (!skill) throw new Error(`Skill "${name}" not found in the library`)
|
|
320
|
+
|
|
321
|
+
const dest = paths.resolve(dir, name)
|
|
322
|
+
if (!fs.exists(dest)) {
|
|
323
|
+
fs.copy(skill.path, dest)
|
|
324
|
+
}
|
|
342
325
|
}
|
|
343
326
|
|
|
344
|
-
|
|
345
|
-
this.updateCounts()
|
|
346
|
-
this.emit('skillRemoved', existing.name)
|
|
347
|
-
return true
|
|
327
|
+
return dir
|
|
348
328
|
}
|
|
349
329
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
parameters: {
|
|
365
|
-
type: 'object',
|
|
366
|
-
properties: {},
|
|
367
|
-
},
|
|
368
|
-
}
|
|
330
|
+
// --- Tool handlers for assistant.use(skillsLibrary) ---
|
|
331
|
+
|
|
332
|
+
/** Search available skills, optionally filtered by a query string. */
|
|
333
|
+
async searchAvailableSkills({ query }: { query?: string } = {}): Promise<string> {
|
|
334
|
+
if (!this.isStarted) await this.start()
|
|
335
|
+
|
|
336
|
+
let skills = this.list()
|
|
337
|
+
|
|
338
|
+
if (query) {
|
|
339
|
+
const q = query.toLowerCase()
|
|
340
|
+
skills = skills.filter(s =>
|
|
341
|
+
s.name.toLowerCase().includes(q) ||
|
|
342
|
+
s.description.toLowerCase().includes(q)
|
|
343
|
+
)
|
|
369
344
|
}
|
|
370
345
|
|
|
371
|
-
return
|
|
346
|
+
if (skills.length === 0) return 'No skills found.'
|
|
347
|
+
|
|
348
|
+
return skills.map(s => `- **${s.name}**: ${s.description || '(no description)'}\n Path: ${s.path}`).join('\n')
|
|
372
349
|
}
|
|
373
350
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
*
|
|
378
|
-
* @returns {string} Markdown listing, or empty string if no skills
|
|
379
|
-
*/
|
|
380
|
-
toSystemPromptBlock(): string {
|
|
381
|
-
const skills = this.list()
|
|
382
|
-
if (skills.length === 0) return ''
|
|
351
|
+
/** Load a skill's full SKILL.md content and metadata. */
|
|
352
|
+
async loadSkill({ skillName }: { skillName: string }): Promise<string> {
|
|
353
|
+
if (!this.isStarted) await this.start()
|
|
383
354
|
|
|
384
|
-
const
|
|
385
|
-
return
|
|
386
|
-
}
|
|
355
|
+
const skill = this.find(skillName)
|
|
356
|
+
if (!skill) return `Skill "${skillName}" not found.`
|
|
387
357
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
private listFromCollection(
|
|
391
|
-
collection: Collection,
|
|
392
|
-
source: 'project' | 'user'
|
|
393
|
-
): SkillEntry[] {
|
|
394
|
-
if (!(collection as any).loaded) return []
|
|
395
|
-
|
|
396
|
-
const entries: SkillEntry[] = []
|
|
397
|
-
for (const pathId of collection.available) {
|
|
398
|
-
if (!pathId.endsWith('/SKILL') && pathId !== 'SKILL') continue
|
|
399
|
-
|
|
400
|
-
const item = collection.items.get(pathId)!
|
|
401
|
-
entries.push({
|
|
402
|
-
name: (item.meta.name as string) || pathId.split('/')[0] || pathId,
|
|
403
|
-
description: (item.meta.description as string) || '',
|
|
404
|
-
body: item.content,
|
|
405
|
-
raw: item.raw,
|
|
406
|
-
source,
|
|
407
|
-
pathId,
|
|
408
|
-
meta: item.meta,
|
|
409
|
-
})
|
|
410
|
-
}
|
|
358
|
+
const content = this.container.fs.readFile(skill.skillFilePath)
|
|
411
359
|
|
|
412
|
-
return
|
|
360
|
+
return `# Skill: ${skill.name}\n\n**Description:** ${skill.description}\n**Path:** ${skill.path}\n\n---\n\n${content}`
|
|
413
361
|
}
|
|
414
362
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
)
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
projectSkillCount: projectCount,
|
|
423
|
-
userSkillCount: userCount,
|
|
424
|
-
totalSkillCount: projectCount + userCount,
|
|
425
|
-
})
|
|
363
|
+
/** Ask a question about a specific skill using a DocsReader. */
|
|
364
|
+
async askSkillBasedQuestion({ skillName, question }: { skillName: string; question: string }): Promise<string> {
|
|
365
|
+
if (!this.isStarted) await this.start()
|
|
366
|
+
|
|
367
|
+
const reader = this.createSkillReader(skillName)
|
|
368
|
+
const answer = await reader.ask(question)
|
|
369
|
+
return answer
|
|
426
370
|
}
|
|
427
371
|
}
|
|
428
372
|
|