@soederpop/luca 0.0.23 → 0.0.26

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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +6 -1
  3. package/assistants/codingAssistant/hooks.ts +0 -1
  4. package/assistants/lucaExpert/CORE.md +37 -0
  5. package/assistants/lucaExpert/hooks.ts +9 -0
  6. package/assistants/lucaExpert/tools.ts +177 -0
  7. package/commands/build-bootstrap.ts +41 -1
  8. package/docs/TABLE-OF-CONTENTS.md +0 -1
  9. package/docs/apis/clients/rest.md +5 -5
  10. package/docs/apis/features/agi/assistant.md +1 -1
  11. package/docs/apis/features/agi/conversation-history.md +6 -7
  12. package/docs/apis/features/agi/conversation.md +1 -1
  13. package/docs/apis/features/agi/semantic-search.md +1 -1
  14. package/docs/bootstrap/CLAUDE.md +1 -1
  15. package/docs/bootstrap/SKILL.md +7 -3
  16. package/docs/bootstrap/templates/luca-cli.ts +5 -0
  17. package/docs/mcp/readme.md +1 -1
  18. package/docs/tutorials/00-bootstrap.md +18 -0
  19. package/package.json +2 -2
  20. package/scripts/stamp-build.sh +12 -0
  21. package/scripts/test-docs-reader.ts +10 -0
  22. package/src/agi/container.server.ts +8 -5
  23. package/src/agi/features/assistant.ts +210 -55
  24. package/src/agi/features/assistants-manager.ts +138 -66
  25. package/src/agi/features/conversation.ts +46 -14
  26. package/src/agi/features/docs-reader.ts +166 -0
  27. package/src/agi/features/openapi.ts +1 -1
  28. package/src/agi/features/skills-library.ts +257 -313
  29. package/src/bootstrap/generated.ts +8163 -6
  30. package/src/cli/build-info.ts +4 -0
  31. package/src/cli/cli.ts +2 -1
  32. package/src/command.ts +75 -0
  33. package/src/commands/bootstrap.ts +16 -1
  34. package/src/commands/describe.ts +29 -1089
  35. package/src/commands/eval.ts +6 -1
  36. package/src/commands/sandbox-mcp.ts +17 -7
  37. package/src/container-describer.ts +1098 -0
  38. package/src/container.ts +11 -0
  39. package/src/helper.ts +56 -2
  40. package/src/introspection/generated.agi.ts +1684 -799
  41. package/src/introspection/generated.node.ts +964 -572
  42. package/src/introspection/generated.web.ts +9 -1
  43. package/src/node/container.ts +1 -1
  44. package/src/node/features/content-db.ts +268 -13
  45. package/src/node/features/fs.ts +18 -0
  46. package/src/node/features/git.ts +90 -0
  47. package/src/node/features/grep.ts +1 -1
  48. package/src/node/features/proc.ts +1 -0
  49. package/src/node/features/tts.ts +1 -1
  50. package/src/node/features/vm.ts +48 -0
  51. package/src/scaffolds/generated.ts +2 -2
  52. package/src/server.ts +40 -0
  53. package/src/servers/express.ts +2 -0
  54. package/src/servers/mcp.ts +1 -0
  55. package/src/servers/socket.ts +2 -0
  56. package/assistants/architect/CORE.md +0 -3
  57. package/assistants/architect/hooks.ts +0 -3
  58. package/assistants/architect/tools.ts +0 -10
  59. package/docs/apis/features/agi/skills-library.md +0 -234
  60. package/docs/reports/assistant-bugs.md +0 -38
  61. package/docs/reports/attach-pattern-usage.md +0 -18
  62. package/docs/reports/code-audit-results.md +0 -391
  63. package/docs/reports/console-hmr-design.md +0 -170
  64. package/docs/reports/helper-semantic-search.md +0 -72
  65. package/docs/reports/introspection-audit-tasks.md +0 -378
  66. package/docs/reports/luca-mcp-improvements.md +0 -128
  67. 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 { Collection, defineModel } from 'contentbase'
10
- import type { ConversationTool } from './conversation'
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 SkillEntry {
19
- /** Skill name from frontmatter */
13
+ export interface SkillInfo {
14
+ /** Skill name derived from folder name or frontmatter */
20
15
  name: string
21
- /** Skill description from frontmatter */
16
+ /** Description from frontmatter */
22
17
  description: string
23
- /** Markdown body (instructions) */
24
- body: string
25
- /** Raw content including frontmatter */
26
- raw: string
27
- /** Which collection this came from */
28
- source: 'project' | 'user'
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 both collections have been loaded'),
52
- projectSkillCount: z.number().describe('Number of skills in the project collection'),
53
- userSkillCount: z.number().describe('Number of skills in the user-level collection'),
54
- totalSkillCount: z.number().describe('Total number of skills across both collections'),
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
- /** Path to project-level skills directory. Defaults to .claude/skills relative to container cwd. */
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
- loaded: z.tuple([]).describe('Fired after both project and user skill collections are loaded'),
66
- skillCreated: z.tuple([z.any().describe('The created SkillEntry object')]).describe('Fired after a new skill is written to disk'),
67
- skillUpdated: z.tuple([z.any().describe('The updated SkillEntry object')]).describe('Fired after an existing skill is updated'),
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 two contentbase collections of skills following the Claude Code SKILL.md format.
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
- * @extends Feature
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 skills = container.feature('skillsLibrary')
84
- * await skills.load()
85
- *
86
- * // List and search
87
- * const allSkills = skills.list()
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
- private _projectCollection?: Collection
107
- private _userCollection?: Collection
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 with loaded=false and zero skill counts across both collections. */
93
+ /** @returns Default state. */
110
94
  override get initialState(): SkillsLibraryState {
111
95
  return {
112
96
  ...super.initialState,
113
- loaded: false,
114
- projectSkillCount: 0,
115
- userSkillCount: 0,
116
- totalSkillCount: 0,
97
+ started: false,
98
+ locations: [],
99
+ skillCount: 0,
100
+ skills: {},
117
101
  }
118
102
  }
119
103
 
120
- /** Returns the project-level contentbase Collection, lazily initialized. */
121
- get projectCollection(): Collection {
122
- if (this._projectCollection) return this._projectCollection
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
- /** Returns the user-level contentbase Collection, lazily initialized. */
132
- get userCollection(): Collection {
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
- /** Whether the skills library has been loaded. */
142
- get isLoaded(): boolean {
143
- return !!this.state.get('loaded')
116
+ return Object.fromEntries(
117
+ Object.keys(skills).map((name) => [name, this.skills[name]!.description])
118
+ )
144
119
  }
145
120
 
146
- /** Array of all skill names across both collections. */
147
- get skillNames(): string[] {
148
- return this.list().map((s) => s.name)
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
- * Loads both project and user skill collections from disk.
153
- * Gracefully handles missing directories.
154
- *
155
- * @returns {Promise<SkillsLibrary>} This instance
156
- */
157
- async load(): Promise<SkillsLibrary> {
158
- if (this.isLoaded) return this
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
- try {
161
- await this.projectCollection.load()
162
- } catch {
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
- try {
167
- await this.userCollection.load()
168
- } catch {
169
- // Directory doesn't exist yet - zero user skills
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.updateCounts()
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
- * Lists all skills from both collections. Project skills come first.
180
- *
181
- * @returns {SkillEntry[]} All available skills
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
- * Finds a skill by name. Project skills take precedence over user skills.
159
+ * Start the skills library: read config, scan all locations.
191
160
  *
192
- * @param {string} name - The skill name to find (case-insensitive)
193
- * @returns {SkillEntry | undefined} The skill entry, or undefined if not found
161
+ * @returns This instance for chaining
194
162
  */
195
- find(name: string): SkillEntry | undefined {
196
- const lower = name.toLowerCase()
197
- return this.list().find((s) => s.name.toLowerCase() === lower)
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
- * Searches skills by substring match against name and description.
188
+ * Add a new skill location folder and scan it for skills.
202
189
  *
203
- * @param {string} query - The search query
204
- * @returns {SkillEntry[]} Matching skills
190
+ * @param locationPath - Path to a directory containing skill subfolders with SKILL.md
205
191
  */
206
- search(query: string): SkillEntry[] {
207
- const q = query.toLowerCase()
208
- return this.list().filter(
209
- (s) =>
210
- s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
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
- * Gets a skill by name. Alias for find().
214
+ * Remove a skill location and its skills from the library.
216
215
  *
217
- * @param {string} name - The skill name
218
- * @returns {SkillEntry | undefined} The skill entry
216
+ * @param locationPath - The location path to remove
219
217
  */
220
- getSkill(name: string): SkillEntry | undefined {
221
- return this.find(name)
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
- * Creates a new SKILL.md file in the specified collection.
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 {object} skill - The skill to create
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 create(
233
- skill: {
234
- name: string
235
- description: string
236
- body: string
237
- meta?: Record<string, unknown>
238
- },
239
- target: 'project' | 'user' = 'project'
240
- ): Promise<SkillEntry> {
241
- const collection =
242
- target === 'project' ? this.projectCollection : this.userCollection
243
-
244
- const frontmatter = (yaml.dump({
245
- name: skill.name,
246
- description: skill.description,
247
- ...skill.meta,
248
- }) as string).trim()
249
-
250
- const content = `---\n${frontmatter}\n---\n\n${skill.body}`
251
- const dirName = kebabCase(skill.name)
252
- const pathId = `${dirName}/SKILL`
253
-
254
- await fs.mkdir((collection as any).rootPath, { recursive: true })
255
- await collection.saveItem(pathId, { content, extension: '.md' })
256
- await collection.load({ refresh: true })
257
- this.updateCounts()
258
-
259
- const entry: SkillEntry = {
260
- name: skill.name,
261
- description: skill.description,
262
- body: skill.body,
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
- this.emit('skillCreated', entry)
270
- return entry
283
+ /** Find a skill by name. */
284
+ find(skillName: string): SkillInfo | undefined {
285
+ return this.skills[skillName]
271
286
  }
272
287
 
273
288
  /**
274
- * Updates an existing skill's content or metadata.
289
+ * Create a DocsReader for a skill's folder, enabling AI-assisted Q&A.
275
290
  *
276
- * @param {string} name - The skill name to update
277
- * @param {object} updates - Fields to update
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
- async update(
281
- name: string,
282
- updates: {
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.emit('skillUpdated', entry)
316
- return entry
298
+ return this.container.feature('docsReader', { contentDb: skill.path })
317
299
  }
318
300
 
319
301
  /**
320
- * Removes a skill by name, deleting its SKILL.md and cleaning up the directory.
302
+ * Create a tmp directory containing symlinked/copied skill folders by name,
303
+ * suitable for passing to claude --add-dir.
321
304
  *
322
- * @param {string} name - The skill name to remove
323
- * @returns {Promise<boolean>} Whether the skill was found and removed
305
+ * @param skillNames - Array of skill names to include
306
+ * @returns Absolute path to the created directory
324
307
  */
325
- async remove(name: string): Promise<boolean> {
326
- const existing = this.find(name)
327
- if (!existing) return false
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
- const collection =
330
- existing.source === 'project' ? this.projectCollection : this.userCollection
313
+ if (fs.exists(dir)) return dir
331
314
 
332
- await collection.deleteItem(existing.pathId)
315
+ fs.mkdirp(dir)
333
316
 
334
- const skillDir = path.resolve(
335
- (collection as any).rootPath,
336
- existing.pathId.split('/')[0]!
337
- )
338
- try {
339
- await fs.rm(skillDir, { recursive: true })
340
- } catch {
341
- // directory might have other files or already be gone
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
- await collection.load({ refresh: true })
345
- this.updateCounts()
346
- this.emit('skillRemoved', existing.name)
347
- return true
327
+ return dir
348
328
  }
349
329
 
350
- /**
351
- * Converts all skills into ConversationTool format for use with Conversation.
352
- * Each skill becomes a tool that returns its instruction body when invoked.
353
- *
354
- * @returns {Record<string, ConversationTool>} Tools keyed by sanitized skill name
355
- */
356
- toConversationTools(): Record<string, ConversationTool> {
357
- const tools: Record<string, ConversationTool> = {}
358
-
359
- for (const skill of this.list()) {
360
- const toolName = `skill_${skill.name.replace(/[^a-zA-Z0-9_]/g, '_')}`
361
- tools[toolName] = {
362
- handler: async () => skill.body,
363
- description: skill.description,
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 tools
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
- * Generates a markdown block listing all available skills with names and descriptions.
376
- * Suitable for injecting into a system prompt.
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 lines = skills.map((s) => `- **${s.name}**: ${s.description}`)
385
- return `## Available Skills\n\n${lines.join('\n')}`
386
- }
355
+ const skill = this.find(skillName)
356
+ if (!skill) return `Skill "${skillName}" not found.`
387
357
 
388
- // --- Private ---
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 entries
360
+ return `# Skill: ${skill.name}\n\n**Description:** ${skill.description}\n**Path:** ${skill.path}\n\n---\n\n${content}`
413
361
  }
414
362
 
415
- private updateCounts(): void {
416
- const projectCount = this.listFromCollection(
417
- this.projectCollection,
418
- 'project'
419
- ).length
420
- const userCount = this.listFromCollection(this.userCollection, 'user').length
421
- this.state.setState({
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