@shawnstack/quickforge 1.1.0 → 1.2.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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/index-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +14 -13
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +322 -32
  27. package/server/project-config.mjs +80 -31
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +46 -10
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +58 -10
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +247 -6
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +30 -0
  52. package/server/utils/response.mjs +8 -1
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -8,8 +8,17 @@ export const stores = new Set([
8
8
  'custom-providers',
9
9
  'sessions',
10
10
  'sessions-metadata',
11
+ 'scheduled-tasks',
11
12
  ])
12
13
 
14
+ // --- In-memory session bucket index ---
15
+ // Avoids O(n) directory scanning in findSessionBucket() by caching
16
+ // sessionId → { scope, projectId } lookups. Populated lazily on first
17
+ // lookup and kept up-to-date by write/delete paths.
18
+ /** @type {Map<string, { scope: string, projectId?: string }>} */
19
+ const sessionBucketIndex = new Map()
20
+ let bucketIndexBuilt = false
21
+
13
22
  const configStores = new Set(['settings', 'provider-keys', 'custom-providers'])
14
23
  const sessionStores = new Set(['sessions', 'sessions-metadata'])
15
24
 
@@ -73,6 +82,7 @@ function legacyNestedProjectConfigFile() {
73
82
  function defaultProjectConfig() {
74
83
  return {
75
84
  activeProjectId: null,
85
+ globalSkills: [],
76
86
  projects: [],
77
87
  }
78
88
  }
@@ -94,11 +104,33 @@ function defaultConfig() {
94
104
  }
95
105
  }
96
106
 
107
+ function normalizeStringArray(value) {
108
+ if (!Array.isArray(value)) return []
109
+ const result = []
110
+ const seen = new Set()
111
+ for (const item of value) {
112
+ if (typeof item !== 'string') continue
113
+ const text = item.trim()
114
+ if (!text || seen.has(text)) continue
115
+ seen.add(text)
116
+ result.push(text)
117
+ }
118
+ return result
119
+ }
120
+
97
121
  function normalizeProjectConfig(value) {
98
- if (!value || typeof value !== 'object' || !Array.isArray(value.projects)) return defaultProjectConfig()
122
+ const base = defaultProjectConfig()
123
+ if (!value || typeof value !== 'object') return base
124
+ const projects = Array.isArray(value.projects)
125
+ ? value.projects.map((project) => ({
126
+ ...project,
127
+ skills: normalizeStringArray(project?.skills),
128
+ }))
129
+ : base.projects
99
130
  return {
100
- activeProjectId: typeof value.activeProjectId === 'string' ? value.activeProjectId : null,
101
- projects: value.projects,
131
+ activeProjectId: typeof value.activeProjectId === 'string' ? value.activeProjectId : base.activeProjectId,
132
+ globalSkills: normalizeStringArray(value.globalSkills),
133
+ projects,
102
134
  }
103
135
  }
104
136
 
@@ -169,10 +201,24 @@ function sessionStoreFile(storeName, bucket) {
169
201
  return path.join(storageDir, 'conversations', 'global', `${storeName}.json`)
170
202
  }
171
203
 
204
+ function sessionDataDir(bucket) {
205
+ if (bucket.scope === 'project') {
206
+ assertSafePathSegment(bucket.projectId)
207
+ return path.join(storageDir, 'conversations', 'projects', bucket.projectId, 'sessions')
208
+ }
209
+ return path.join(storageDir, 'conversations', 'global', 'sessions')
210
+ }
211
+
212
+ function sessionDataFile(sessionId, bucket) {
213
+ assertSafePathSegment(sessionId)
214
+ return path.join(sessionDataDir(bucket), `${sessionId}.json`)
215
+ }
216
+
172
217
  export async function ensureProjectCache(projectId) {
173
218
  const safeProjectId = String(projectId || '')
174
219
  assertSafePathSegment(safeProjectId)
175
220
  const projectCacheDir = path.join(cacheDir, 'projects', safeProjectId)
221
+ const projectStorageDir = path.join(storageDir, 'conversations', 'projects', safeProjectId)
176
222
 
177
223
  await Promise.all([
178
224
  fs.mkdir(path.join(projectCacheDir, 'workspace', 'file-index'), { recursive: true }),
@@ -181,6 +227,8 @@ export async function ensureProjectCache(projectId) {
181
227
  fs.mkdir(path.join(projectCacheDir, 'llm', 'reasoning'), { recursive: true }),
182
228
  fs.mkdir(path.join(projectCacheDir, 'assets'), { recursive: true }),
183
229
  fs.mkdir(path.join(projectCacheDir, 'tmp'), { recursive: true }),
230
+ fs.mkdir(path.join(projectStorageDir, 'sessions'), { recursive: true }),
231
+ ensureJsonFile(path.join(projectStorageDir, 'sessions-metadata.json')),
184
232
  ])
185
233
 
186
234
  return projectCacheDir
@@ -194,7 +242,8 @@ async function ensureJsonFile(file, defaultValue = {}) {
194
242
  async function readJsonFile(file, defaultValue = {}) {
195
243
  try {
196
244
  const text = await fs.readFile(file, 'utf8')
197
- return text.trim() ? JSON.parse(text) : defaultValue
245
+ const json = text.trimStart()
246
+ return json ? JSON.parse(json) : defaultValue
198
247
  } catch (error) {
199
248
  if (error?.code === 'ENOENT') return defaultValue
200
249
  throw error
@@ -233,6 +282,132 @@ async function listProjectSessionFiles(storeName) {
233
282
  .map((entry) => path.join(projectsDir, entry.name, `${storeName}.json`))
234
283
  }
235
284
 
285
+ async function listProjectIds() {
286
+ const projectsDir = path.join(storageDir, 'conversations', 'projects')
287
+ let entries = []
288
+ try {
289
+ entries = await fs.readdir(projectsDir, { withFileTypes: true })
290
+ } catch (error) {
291
+ if (error?.code !== 'ENOENT') throw error
292
+ }
293
+
294
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
295
+ }
296
+
297
+ async function listSessionDataFiles(bucket) {
298
+ const dir = sessionDataDir(bucket)
299
+ let entries = []
300
+ try {
301
+ entries = await fs.readdir(dir, { withFileTypes: true })
302
+ } catch (error) {
303
+ if (error?.code !== 'ENOENT') throw error
304
+ }
305
+
306
+ return entries
307
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
308
+ .map((entry) => path.join(dir, entry.name))
309
+ }
310
+
311
+ async function readSessionValuesScoped(scope, projectId) {
312
+ const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
313
+ const files = await listSessionDataFiles(bucket)
314
+ const result = {}
315
+ for (const file of files) {
316
+ const value = await readJsonFile(file, null)
317
+ if (value?.id) result[value.id] = value
318
+ }
319
+ return result
320
+ }
321
+
322
+ async function readAllSessionValues() {
323
+ const result = await readSessionValuesScoped('global')
324
+ for (const projectId of await listProjectIds()) {
325
+ Object.assign(result, await readSessionValuesScoped('project', projectId))
326
+ }
327
+ return result
328
+ }
329
+
330
+ async function writeSessionValueFile(sessionId, value) {
331
+ await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
332
+ // Keep in-memory index current
333
+ if (value) sessionBucketIndex.set(sessionId, sessionBucket(value))
334
+ }
335
+
336
+ async function writeSessionValues(data) {
337
+ const nextIds = new Set(Object.keys(data || {}))
338
+ const existingFiles = [
339
+ ...(await listSessionDataFiles({ scope: 'global' })),
340
+ ...(await Promise.all(
341
+ (await listProjectIds()).map((projectId) => listSessionDataFiles({ scope: 'project', projectId })),
342
+ )).flat(),
343
+ ]
344
+
345
+ await Promise.all(
346
+ existingFiles.map(async (file) => {
347
+ const sessionId = path.basename(file, '.json')
348
+ if (!nextIds.has(sessionId)) {
349
+ await fs.rm(file, { force: true })
350
+ sessionBucketIndex.delete(sessionId)
351
+ }
352
+ }),
353
+ )
354
+
355
+ await Promise.all(
356
+ Object.entries(data || {}).map(([sessionId, value]) => writeSessionValueFile(sessionId, value)),
357
+ )
358
+ }
359
+
360
+ async function rebuildBucketIndex() {
361
+ sessionBucketIndex.clear()
362
+ // Global bucket
363
+ try {
364
+ const globalMeta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' }), {})
365
+ for (const [id, meta] of Object.entries(globalMeta)) {
366
+ if (meta && typeof meta === 'object') sessionBucketIndex.set(id, sessionBucket(meta))
367
+ }
368
+ } catch { /* ignore */ }
369
+ // Project buckets
370
+ for (const projectId of await listProjectIds()) {
371
+ try {
372
+ const meta = await readJsonFile(sessionStoreFile('sessions-metadata', { scope: 'project', projectId }), {})
373
+ for (const [id, entry] of Object.entries(meta)) {
374
+ if (entry && typeof entry === 'object') sessionBucketIndex.set(id, sessionBucket(entry))
375
+ }
376
+ } catch { /* ignore */ }
377
+ }
378
+ bucketIndexBuilt = true
379
+ }
380
+
381
+ export async function findSessionBucket(sessionId) {
382
+ if (!bucketIndexBuilt) {
383
+ await ensureStorage()
384
+ await rebuildBucketIndex()
385
+ }
386
+ return sessionBucketIndex.get(sessionId) ?? null
387
+ }
388
+
389
+ export async function readSessionValue(sessionId) {
390
+ const bucket = await findSessionBucket(sessionId)
391
+ if (!bucket) return null
392
+ return readJsonFile(sessionDataFile(sessionId, bucket), null)
393
+ }
394
+
395
+ export async function writeSessionValue(sessionId, value) {
396
+ return enqueueWrite('sessions', async () => {
397
+ await ensureStorage()
398
+ await writeSessionValueFile(sessionId, value)
399
+ })
400
+ }
401
+
402
+ export async function deleteSessionValue(sessionId) {
403
+ return enqueueWrite('sessions', async () => {
404
+ const bucket = await findSessionBucket(sessionId)
405
+ if (!bucket) return
406
+ await fs.rm(sessionDataFile(sessionId, bucket), { force: true })
407
+ sessionBucketIndex.delete(sessionId)
408
+ })
409
+ }
410
+
236
411
  async function listSessionStoreFiles(storeName) {
237
412
  return [
238
413
  sessionStoreFile(storeName, { scope: 'global' }),
@@ -241,6 +416,8 @@ async function listSessionStoreFiles(storeName) {
241
416
  }
242
417
 
243
418
  async function readSessionStore(storeName) {
419
+ if (storeName === 'sessions') return readAllSessionValues()
420
+
244
421
  const files = await listSessionStoreFiles(storeName)
245
422
  const result = {}
246
423
  for (const file of files) {
@@ -249,7 +426,20 @@ async function readSessionStore(storeName) {
249
426
  return result
250
427
  }
251
428
 
429
+ export async function readSessionStoreScoped(storeName, scope, projectId) {
430
+ await ensureStorage()
431
+ if (storeName === 'sessions') return readSessionValuesScoped(scope, projectId)
432
+
433
+ const file = sessionStoreFile(storeName, { scope, projectId })
434
+ return readJsonFile(file, {})
435
+ }
436
+
252
437
  async function writeSessionStore(storeName, data) {
438
+ if (storeName === 'sessions') {
439
+ await writeSessionValues(data)
440
+ return
441
+ }
442
+
253
443
  const buckets = new Map()
254
444
 
255
445
  for (const [key, value] of Object.entries(data || {})) {
@@ -270,6 +460,13 @@ async function writeSessionStore(storeName, data) {
270
460
  await writeJsonAtomic(file, bucketEntry?.data ?? {})
271
461
  }),
272
462
  )
463
+
464
+ // Keep in-memory bucket index current for metadata writes
465
+ if (storeName === 'sessions-metadata') {
466
+ for (const [sessionId, meta] of Object.entries(data || {})) {
467
+ if (meta && typeof meta === 'object') sessionBucketIndex.set(sessionId, sessionBucket(meta))
468
+ }
469
+ }
273
470
  }
274
471
 
275
472
  async function migrateLegacySessionStore(storeName) {
@@ -318,7 +515,6 @@ async function migrateUnifiedConfig() {
318
515
  await writeConfigFile(current)
319
516
 
320
517
  if (!existsSync(legacyStorageMigrationMarkerFile)) {
321
- await migrateLegacySessionStore('sessions')
322
518
  await migrateLegacySessionStore('sessions-metadata')
323
519
  await fs.mkdir(path.dirname(legacyStorageMigrationMarkerFile), { recursive: true })
324
520
  await fs.writeFile(legacyStorageMigrationMarkerFile, `${new Date().toISOString()}\n`, 'utf8')
@@ -339,6 +535,50 @@ function enqueueWrite(queueName, operation) {
339
535
  return next
340
536
  }
341
537
 
538
+ /**
539
+ * Atomically read-modify-write a store within its serialized write queue.
540
+ * Eliminates the race condition where concurrent read-modify-write operations
541
+ * from multiple browser tabs would overwrite each other.
542
+ *
543
+ * @param {string} storeName
544
+ * @param {(data: object) => object} updateFn — receives current data, returns updated data
545
+ * @returns {Promise<object>} the updated data
546
+ */
547
+ export async function atomicUpdate(storeName, updateFn) {
548
+ assertStore(storeName)
549
+ const queueName = configStores.has(storeName) ? 'config' : storeName
550
+ return enqueueWrite(queueName, async () => {
551
+ await ensureStorage()
552
+ if (configStores.has(storeName)) {
553
+ const config = await readConfigFile()
554
+ const data = configSection(config, storeName)
555
+ const updated = updateFn(data)
556
+ setConfigSection(config, storeName, updated)
557
+ await writeConfigFile(config)
558
+ return updated
559
+ }
560
+ const data = await readSessionStore(storeName)
561
+ const updated = updateFn(data)
562
+ await writeSessionStore(storeName, updated)
563
+ return updated
564
+ })
565
+ }
566
+
567
+ /**
568
+ * Atomically read-modify-write the project config within the config queue.
569
+ */
570
+ export async function atomicProjectConfigUpdate(updateFn) {
571
+ return enqueueWrite('config', async () => {
572
+ await ensureStorage()
573
+ const config = await readConfigFile()
574
+ const projectConfig = normalizeProjectConfig(config.projects)
575
+ const updated = updateFn(projectConfig)
576
+ config.projects = normalizeProjectConfig(updated)
577
+ await writeConfigFile(config)
578
+ return updated
579
+ })
580
+ }
581
+
342
582
  export async function ensureStorage() {
343
583
  await fs.mkdir(configDir, { recursive: true })
344
584
  await fs.mkdir(storageDir, { recursive: true })
@@ -348,6 +588,7 @@ export async function ensureStorage() {
348
588
  fs.mkdir(path.join(cacheDir, 'global', 'llm'), { recursive: true }),
349
589
  fs.mkdir(path.join(cacheDir, 'global', 'tmp'), { recursive: true }),
350
590
  fs.mkdir(path.join(cacheDir, 'projects'), { recursive: true }),
591
+ fs.mkdir(path.join(storageDir, 'conversations', 'global', 'sessions'), { recursive: true }),
351
592
  fs.mkdir(path.join(storageDir, 'conversations', 'projects'), { recursive: true }),
352
593
  ])
353
594
 
@@ -355,7 +596,7 @@ export async function ensureStorage() {
355
596
 
356
597
  await Promise.all([
357
598
  ensureJsonFile(quickForgeConfigFile, defaultConfig()),
358
- ...[...sessionStores].map((store) => ensureJsonFile(sessionStoreFile(store, { scope: 'global' }))),
599
+ ensureJsonFile(sessionStoreFile('sessions-metadata', { scope: 'global' })),
359
600
  ])
360
601
  }
361
602
 
@@ -0,0 +1,67 @@
1
+ export const BASE_SYSTEM_PROMPT = `You are a pragmatic coding assistant.
2
+
3
+ For project tasks:
4
+ - Inspect the workspace before changing files.
5
+ - Make minimal, focused changes.
6
+ - Prefer dedicated workspace tools for reading, editing, and searching files.
7
+ - If dedicated tools are unavailable or insufficient, use the shell/command tool.
8
+ - Use Python through the shell for reliable scripting, data processing, or file transformations.
9
+ - Stay within the current workspace unless the user explicitly asks otherwise.
10
+ - Verify changes with relevant tests, build, lint, or targeted checks.
11
+ - If no suitable tool is available, say so clearly.`
12
+
13
+ function escapeXml(value) {
14
+ return String(value ?? '')
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ }
19
+
20
+ function formatSkillCatalogItem(skill) {
21
+ const details = [
22
+ ` <name>${escapeXml(skill.name)}</name>`,
23
+ ` <description>${escapeXml(skill.description)}</description>`,
24
+ ]
25
+
26
+ if (skill.compatibility) details.push(` <compatibility>${escapeXml(skill.compatibility)}</compatibility>`)
27
+ if (skill.allowedTools) details.push(` <allowed_tools>${escapeXml(skill.allowedTools)}</allowed_tools>`)
28
+
29
+ return ` <skill>\n${details.join('\n')}\n </skill>`
30
+ }
31
+
32
+ function appendSkillsCatalog(parts, skills) {
33
+ if (!Array.isArray(skills) || skills.length === 0) return
34
+
35
+ const skillParts = skills.map(formatSkillCatalogItem)
36
+ parts.push(`
37
+ <available_skills>
38
+ The following Agent Skills provide specialized instructions for specific tasks. Use progressive disclosure: this catalog is available now, but full skill instructions are loaded only when needed.
39
+
40
+ When the user's task matches a skill description, call activate_skill with that skill's name before proceeding. If a loaded skill references bundled files under scripts/, references/, or assets/, call read_skill_resource with the skill name and the relative path. Do not assume resources are already loaded.
41
+
42
+ ${skillParts.join('\n')}
43
+ </available_skills>`)
44
+ }
45
+
46
+ export function composeSystemPrompt(instructions = {}) {
47
+ const parts = [BASE_SYSTEM_PROMPT]
48
+
49
+ if (instructions.global) {
50
+ parts.push(`\n<user_instructions>\n${instructions.global}\n</user_instructions>`)
51
+ }
52
+
53
+ if (instructions.project) {
54
+ parts.push(`\n<project_instructions>\n${instructions.project}\n</project_instructions>`)
55
+ }
56
+
57
+ const skills = Array.isArray(instructions.skills)
58
+ ? instructions.skills
59
+ : [
60
+ ...(Array.isArray(instructions.globalSkills) ? instructions.globalSkills : []),
61
+ ...(Array.isArray(instructions.projectSkills) ? instructions.projectSkills : []),
62
+ ]
63
+
64
+ appendSkillsCatalog(parts, skills)
65
+
66
+ return parts.join('\n')
67
+ }
@@ -0,0 +1,120 @@
1
+ import { Type } from 'typebox'
2
+ import { loadSelectedGlobalSkills, loadSelectedProjectSkills, mergeSkills } from '../skills.mjs'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Canonical workspace tool definitions.
6
+ // These are the single source of truth for tool metadata (name, label,
7
+ // description, parameters). Both the server agent-manager (which wraps them
8
+ // with execute handlers) and the GET /api/tools endpoint (which returns them
9
+ // as JSON) import from here.
10
+ //
11
+ // When adding a new tool, add its definition here. The agent-manager connects
12
+ // it to a handler, and the frontend can fetch definitions from /api/tools.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const workspaceTools = [
16
+ {
17
+ name: 'get_project_info',
18
+ label: 'Project info',
19
+ description: 'Get the project directory bound to this chat.',
20
+ parameters: Type.Object({}),
21
+ },
22
+ {
23
+ name: 'list_dir',
24
+ label: 'List directory',
25
+ description: 'List files and folders inside the project bound to this chat. Paths are relative to that project root.',
26
+ parameters: Type.Object({
27
+ path: Type.Optional(Type.String({ description: 'Directory path relative to the workspace root. Defaults to .', default: '.' })),
28
+ }),
29
+ },
30
+ {
31
+ name: 'read_file',
32
+ label: 'Read file',
33
+ description: 'Read a UTF-8 text file inside the project bound to this chat. Use offset and limit for large files.',
34
+ parameters: Type.Object({
35
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
36
+ offset: Type.Optional(Type.Number({ description: '1-based line offset.', default: 1 })),
37
+ limit: Type.Optional(Type.Number({ description: 'Maximum number of lines to return.', default: 200 })),
38
+ }),
39
+ },
40
+ {
41
+ name: 'grep_files',
42
+ label: 'Search files',
43
+ description: 'Search text in the project files bound to this chat. Returns matching file paths and line numbers.',
44
+ parameters: Type.Object({
45
+ query: Type.String({ description: 'Plain text or regular expression to search for.' }),
46
+ path: Type.Optional(Type.String({ description: 'Directory path relative to the workspace root. Defaults to .', default: '.' })),
47
+ regex: Type.Optional(Type.Boolean({ description: 'Treat query as a regular expression.', default: false })),
48
+ caseSensitive: Type.Optional(Type.Boolean({ description: 'Use case-sensitive matching.', default: false })),
49
+ limit: Type.Optional(Type.Number({ description: 'Maximum matches to return.', default: 200 })),
50
+ }),
51
+ },
52
+ {
53
+ name: 'write_file',
54
+ label: 'Write file',
55
+ description: 'Create or overwrite a UTF-8 text file inside the project bound to this chat.',
56
+ parameters: Type.Object({
57
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
58
+ content: Type.String({ description: 'Complete file content to write.' }),
59
+ }),
60
+ executionMode: 'sequential',
61
+ },
62
+ {
63
+ name: 'edit_file',
64
+ label: 'Edit file',
65
+ description: 'Edit a text file in the project bound to this chat by replacing exact text. oldText must match exactly once.',
66
+ parameters: Type.Object({
67
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
68
+ oldText: Type.String({ description: 'Exact existing text to replace. Must be unique in the file.' }),
69
+ newText: Type.String({ description: 'Replacement text.' }),
70
+ }),
71
+ executionMode: 'sequential',
72
+ },
73
+ {
74
+ name: 'run_command',
75
+ label: 'Run command',
76
+ description: 'Run a shell command in the project bound to this chat. Use this for lint, build, tests, git status, and diagnostics.',
77
+ parameters: Type.Object({
78
+ command: Type.String({ description: 'Command to execute in the workspace.' }),
79
+ timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to 60.', default: 60 })),
80
+ }),
81
+ executionMode: 'sequential',
82
+ },
83
+ ]
84
+
85
+ function activeSkillSchema(skills) {
86
+ const names = skills.map((skill) => skill.name).filter(Boolean)
87
+ return names.length ? Type.String({ enum: names }) : Type.String()
88
+ }
89
+
90
+ export async function createSkillTools(config = {}) {
91
+ const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
92
+ const projectSkills = config.workspaceRoot
93
+ ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
94
+ : []
95
+ const skills = mergeSkills(globalSkills, projectSkills)
96
+ if (skills.length === 0) return []
97
+
98
+ const skillNameSchema = activeSkillSchema(skills)
99
+ return [
100
+ {
101
+ name: 'activate_skill',
102
+ label: 'Activate skill',
103
+ description: 'Load the full instructions for an enabled Agent Skill when the current task matches its description.',
104
+ parameters: Type.Object({
105
+ name: skillNameSchema,
106
+ }),
107
+ },
108
+ {
109
+ name: 'read_skill_resource',
110
+ label: 'Read skill resource',
111
+ description: 'Read a text resource bundled with an activated Agent Skill. Paths are relative to that skill directory.',
112
+ parameters: Type.Object({
113
+ skill: skillNameSchema,
114
+ path: Type.String({ description: 'Relative path inside the skill directory, for example references/REFERENCE.md or scripts/helper.py.' }),
115
+ offset: Type.Optional(Type.Number({ description: '1-based line offset.', default: 1 })),
116
+ limit: Type.Optional(Type.Number({ description: 'Maximum number of lines to return.', default: 200 })),
117
+ }),
118
+ },
119
+ ]
120
+ }