@shawnstack/quickforge 1.3.24 → 1.3.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 (57) hide show
  1. package/README.md +24 -18
  2. package/dist/assets/anthropic-BcnDL7hi.js +39 -0
  3. package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
  4. package/dist/assets/google-C2y985rW.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
  7. package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
  8. package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
  9. package/dist/assets/index-ZYbEKGUp.css +3 -0
  10. package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
  11. package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
  12. package/dist/assets/openai-completions-CHDluyXM.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-UtRriBXu.js +1 -0
  15. package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
  16. package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
  17. package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
  18. package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
  19. package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
  20. package/dist/index.html +4 -4
  21. package/package.json +4 -3
  22. package/server/agent-manager.mjs +162 -176
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +67 -9
  26. package/server/index.mjs +7 -0
  27. package/server/message-converters.mjs +79 -0
  28. package/server/plugins/loader.mjs +56 -0
  29. package/server/plugins/manifest.mjs +174 -0
  30. package/server/plugins/registry.mjs +304 -0
  31. package/server/project-config.mjs +53 -4
  32. package/server/routes/agent-profiles.mjs +1 -1
  33. package/server/routes/agent.mjs +1 -16
  34. package/server/routes/filesystem.mjs +18 -2
  35. package/server/routes/plugins.mjs +63 -0
  36. package/server/routes/project.mjs +2 -0
  37. package/server/routes/scheduled-tasks.mjs +1 -1
  38. package/server/routes/storage.mjs +66 -31
  39. package/server/routes/tools.mjs +12 -1
  40. package/server/session-utils.mjs +1 -1
  41. package/server/skills.mjs +64 -5
  42. package/server/storage.mjs +91 -8
  43. package/server/system-prompt.mjs +27 -5
  44. package/server/tool-wiring.mjs +113 -0
  45. package/server/utils/workspace.mjs +20 -1
  46. package/dist/assets/anthropic-BrbLtQkg.js +0 -39
  47. package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
  48. package/dist/assets/google-Bv6IeSRf.js +0 -1
  49. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  50. package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
  51. package/dist/assets/index-C4m48ndP.css +0 -3
  52. package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
  53. package/dist/assets/openai-completions-CihVV11E.js +0 -5
  54. package/dist/assets/openai-responses-BigEdUNS.js +0 -1
  55. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  56. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  57. /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
@@ -2,6 +2,8 @@ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
2
  import { readStore } from '../storage.mjs'
3
3
  import { toolHandlers, loadSkillToolContext } from '../tools/index.mjs'
4
4
  import { createSkillTools, workspaceTools } from '../tools/definitions.mjs'
5
+ import { createMcpToolDefinitions } from '../mcp/registry.mjs'
6
+ import { callPluginTool, createPluginToolDefinitions, isPluginToolName } from '../plugins/registry.mjs'
5
7
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
6
8
 
7
9
  const directRouteDisabledTools = new Set(['run_subagent'])
@@ -17,7 +19,9 @@ export async function handleGetTools(_req, res) {
17
19
  projectSkillNames: activeProject?.skills,
18
20
  workspaceRoot: activeProject?.path,
19
21
  })
20
- sendJson(res, 200, { tools: [...skillTools, ...workspaceTools] })
22
+ const pluginTools = await createPluginToolDefinitions(activeProject ? { workspaceRoot: activeProject.path, project: activeProject } : null)
23
+ const mcpTools = await createMcpToolDefinitions()
24
+ sendJson(res, 200, { tools: [...skillTools, ...workspaceTools, ...mcpTools, ...pluginTools] })
21
25
  }
22
26
 
23
27
  const workspaceToolNames = new Set(workspaceTools.map((tool) => tool.name))
@@ -69,6 +73,13 @@ export async function handleToolApi(req, res, url) {
69
73
  name = decodeSegment(parts[4])
70
74
  }
71
75
 
76
+ if (isPluginToolName(name)) {
77
+ const params = await readJsonBody(req)
78
+ const result = await callPluginTool(name, params || {}, context)
79
+ sendJson(res, 200, result)
80
+ return
81
+ }
82
+
72
83
  const handler = toolHandlers[name]
73
84
  if (!handler || directRouteDisabledTools.has(name)) {
74
85
  const error = new Error(`Unknown tool: ${name}`)
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
2
  import { buildInstructionsPayload } from './project-config.mjs'
3
3
  import { composeSystemPrompt } from './system-prompt.mjs'
4
4
  import { listSubagentProfiles } from './agent-profiles.mjs'
package/server/skills.mjs CHANGED
@@ -2,9 +2,12 @@ import { existsSync, promises as fs } from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { dataDir } from './storage.mjs'
5
+ import { getEnabledPluginSkillSources } from './plugins/registry.mjs'
5
6
 
6
7
  const userSkillsDir = path.join(dataDir, 'skills')
7
8
  const sharedUserSkillsDir = path.join(os.homedir(), '.agents', 'skills')
9
+ const claudeUserSkillsDir = path.join(os.homedir(), '.claude', 'skills')
10
+ const opencodeUserSkillsDir = path.join(os.homedir(), '.opencode', 'skills')
8
11
  const defaultEntry = 'SKILL.md'
9
12
  const resourceDirs = ['scripts', 'references', 'assets']
10
13
  const maxResourceFiles = 200
@@ -312,6 +315,14 @@ function projectSharedSkillsDir(workspaceRoot) {
312
315
  return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.agents', 'skills') : ''
313
316
  }
314
317
 
318
+ function projectClaudeSkillsDir(workspaceRoot) {
319
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.claude', 'skills') : ''
320
+ }
321
+
322
+ function projectOpencodeSkillsDir(workspaceRoot) {
323
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.opencode', 'skills') : ''
324
+ }
325
+
315
326
  async function loadSkillsFromSources(sources) {
316
327
  const skillsByName = new Map()
317
328
 
@@ -335,6 +346,30 @@ async function loadSkillsFromSources(sources) {
335
346
  })
336
347
  }
337
348
 
349
+ async function loadSkillsFromExplicitSources(sources) {
350
+ const skillsByName = new Map()
351
+
352
+ for (const source of sources) {
353
+ const candidateDirs = [source.dir, ...(await listSkillDirectories(source.dir))]
354
+ for (const skillDir of candidateDirs) {
355
+ try {
356
+ const skill = await loadSkillDirectory(skillDir, source.name)
357
+ if (!skill) continue
358
+ if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
359
+ skillsByName.set(skill.name, skill)
360
+ } catch (error) {
361
+ console.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
362
+ }
363
+ }
364
+ }
365
+
366
+ return [...skillsByName.values()].sort((a, b) => {
367
+ const left = (a.displayName || a.name).toLowerCase()
368
+ const right = (b.displayName || b.name).toLowerCase()
369
+ return left.localeCompare(right)
370
+ })
371
+ }
372
+
338
373
  function searchDirsForList(value) {
339
374
  return value.length === 1 ? value[0] : value.slice()
340
375
  }
@@ -372,27 +407,49 @@ export function mergeSkills(...skillLists) {
372
407
  }
373
408
 
374
409
  export const skillSearchPaths = {
375
- global: searchDirsForList([sharedUserSkillsDir, userSkillsDir]),
376
- project: ['<project>/.agents/skills', '<project>/.quickforge/skills'],
410
+ global: searchDirsForList([claudeUserSkillsDir, opencodeUserSkillsDir, sharedUserSkillsDir, userSkillsDir]),
411
+ project: ['<project>/.claude/skills', '<project>/.opencode/skills', '<project>/.agents/skills', '<project>/.quickforge/skills'],
377
412
  }
378
413
 
379
414
  export function projectSkillSearchPaths(workspaceRoot) {
380
415
  if (!workspaceRoot) return skillSearchPaths.project.slice()
381
- return searchDirsForList([projectSharedSkillsDir(workspaceRoot), projectClientSkillsDir(workspaceRoot)])
416
+ return searchDirsForList([
417
+ projectClaudeSkillsDir(workspaceRoot),
418
+ projectOpencodeSkillsDir(workspaceRoot),
419
+ projectSharedSkillsDir(workspaceRoot),
420
+ projectClientSkillsDir(workspaceRoot),
421
+ '<enabled-plugin>/skills',
422
+ ])
423
+ }
424
+
425
+ async function loadPluginSkills(workspaceRoot) {
426
+ if (!workspaceRoot) return []
427
+ const sources = await getEnabledPluginSkillSources({ workspaceRoot })
428
+ return loadSkillsFromExplicitSources(sources.map((source) => ({ dir: source.dir, name: source.source })))
382
429
  }
383
430
 
384
431
  export async function loadGlobalSkills() {
385
432
  return loadSkillsFromSources([
433
+ { dir: claudeUserSkillsDir, name: 'user-claude' },
434
+ { dir: opencodeUserSkillsDir, name: 'user-opencode' },
386
435
  { dir: sharedUserSkillsDir, name: 'user-shared' },
387
436
  { dir: userSkillsDir, name: 'user' },
388
437
  ])
389
438
  }
390
439
 
391
440
  export async function loadProjectSkills(workspaceRoot) {
392
- return loadSkillsFromSources([
441
+ const pluginSkills = await loadPluginSkills(workspaceRoot)
442
+ const projectSkills = await loadSkillsFromSources([
443
+ { dir: projectClaudeSkillsDir(workspaceRoot), name: 'project-claude' },
444
+ { dir: projectOpencodeSkillsDir(workspaceRoot), name: 'project-opencode' },
393
445
  { dir: projectSharedSkillsDir(workspaceRoot), name: 'project-shared' },
394
446
  { dir: projectClientSkillsDir(workspaceRoot), name: 'project' },
395
447
  ])
448
+ return mergeSkills(projectSkills, pluginSkills).sort((a, b) => {
449
+ const left = (a.displayName || a.name).toLowerCase()
450
+ const right = (b.displayName || b.name).toLowerCase()
451
+ return left.localeCompare(right)
452
+ })
396
453
  }
397
454
 
398
455
  export async function loadSkills() {
@@ -442,7 +499,9 @@ export async function loadSelectedGlobalSkills(skillNames) {
442
499
  }
443
500
 
444
501
  export async function loadSelectedProjectSkills(skillNames, workspaceRoot) {
445
- return selectSkills(skillNames, await loadProjectSkills(workspaceRoot))
502
+ const skills = await loadProjectSkills(workspaceRoot)
503
+ const pluginSkills = skills.filter((skill) => String(skill.source || '').startsWith('plugin:'))
504
+ return mergeSkills(pluginSkills, selectSkills(skillNames, skills))
446
505
  }
447
506
 
448
507
  export async function loadSelectedSkills(skillNames) {
@@ -48,6 +48,7 @@ export const stores = new Set([
48
48
  'settings',
49
49
  'provider-keys',
50
50
  'custom-providers',
51
+ 'plugins',
51
52
  'sessions',
52
53
  'sessions-metadata',
53
54
  'scheduled-tasks',
@@ -62,13 +63,25 @@ export const stores = new Set([
62
63
  const sessionBucketIndex = new Map()
63
64
  let bucketIndexBuilt = false
64
65
 
65
- const configStores = new Set(['settings', 'provider-keys', 'custom-providers'])
66
+ // Monotonic in-process revisions for cache invalidation in route-level indexes.
67
+ const storeRevisions = new Map()
68
+
69
+ function bumpStoreRevision(storeName) {
70
+ storeRevisions.set(storeName, (storeRevisions.get(storeName) || 0) + 1)
71
+ }
72
+
73
+ export function getStoreRevision(storeName) {
74
+ return storeRevisions.get(storeName) || 0
75
+ }
76
+
77
+ const configStores = new Set(['settings', 'provider-keys', 'custom-providers', 'plugins'])
66
78
  const sessionStores = new Set(['sessions', 'sessions-metadata'])
67
79
 
68
80
  const configStoreSections = {
69
81
  settings: ['app', 'settings'],
70
82
  'provider-keys': ['credentials', 'providerKeys'],
71
83
  'custom-providers': ['providers', 'customProviders'],
84
+ plugins: ['extensions', 'plugins'],
72
85
  }
73
86
 
74
87
  export function getDataDir() {
@@ -96,11 +109,6 @@ export function configFile() {
96
109
  return quickForgeConfigFile
97
110
  }
98
111
 
99
- // Compatibility export for older modules/imports. Project config now lives inside config/config.json -> projects.
100
- export function projectConfigFile() {
101
- return quickForgeConfigFile
102
- }
103
-
104
112
  function legacyFlatStoreFile(storeName) {
105
113
  return path.join(storageDir, `${storeName}.json`)
106
114
  }
@@ -143,6 +151,9 @@ function defaultConfig() {
143
151
  credentials: {
144
152
  providerKeys: {},
145
153
  },
154
+ extensions: {
155
+ plugins: {},
156
+ },
146
157
  projects: defaultProjectConfig(),
147
158
  }
148
159
  }
@@ -206,6 +217,13 @@ function normalizeConfig(value) {
206
217
  ? input.credentials.providerKeys
207
218
  : base.credentials.providerKeys,
208
219
  },
220
+ extensions: {
221
+ ...(input.extensions && typeof input.extensions === 'object' ? input.extensions : {}),
222
+ plugins:
223
+ input.extensions?.plugins && typeof input.extensions.plugins === 'object'
224
+ ? input.extensions.plugins
225
+ : base.extensions.plugins,
226
+ },
209
227
  projects: normalizeProjectConfig(input.projects),
210
228
  }
211
229
  }
@@ -370,6 +388,34 @@ async function readAllSessionValues() {
370
388
  return result
371
389
  }
372
390
 
391
+ function sessionMetadataQueueName(bucket) {
392
+ return bucket.scope === 'project' ? `sessions-metadata:${bucket.projectId}` : 'sessions-metadata:global'
393
+ }
394
+
395
+ function sameSessionBucket(left, right) {
396
+ if (!left || !right) return false
397
+ return left.scope === right.scope && (left.projectId || undefined) === (right.projectId || undefined)
398
+ }
399
+
400
+ function updateSessionMetadataBucketIndex(bucket, previousData, nextData) {
401
+ const ids = new Set([
402
+ ...Object.keys(previousData || {}),
403
+ ...Object.keys(nextData || {}),
404
+ ])
405
+
406
+ for (const sessionId of ids) {
407
+ const meta = nextData?.[sessionId]
408
+ if (meta && typeof meta === 'object') {
409
+ sessionBucketIndex.set(sessionId, sessionBucket(meta))
410
+ continue
411
+ }
412
+
413
+ if (sameSessionBucket(sessionBucketIndex.get(sessionId), bucket)) {
414
+ sessionBucketIndex.delete(sessionId)
415
+ }
416
+ }
417
+ }
418
+
373
419
  async function writeSessionValueFile(sessionId, value) {
374
420
  await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
375
421
  // Keep in-memory index current
@@ -497,6 +543,15 @@ async function writeSessionStore(storeName, data) {
497
543
  filesToWrite.add(sessionStoreFile(storeName, bucket))
498
544
  }
499
545
 
546
+ const previousByFile = new Map()
547
+ if (storeName === 'sessions-metadata') {
548
+ await Promise.all(
549
+ [...filesToWrite].map(async (file) => {
550
+ previousByFile.set(file, await readJsonFile(file, {}))
551
+ }),
552
+ )
553
+ }
554
+
500
555
  await Promise.all(
501
556
  [...filesToWrite].map(async (file) => {
502
557
  const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
@@ -506,9 +561,14 @@ async function writeSessionStore(storeName, data) {
506
561
 
507
562
  // Keep in-memory bucket index current for metadata writes
508
563
  if (storeName === 'sessions-metadata') {
509
- for (const [sessionId, meta] of Object.entries(data || {})) {
510
- if (meta && typeof meta === 'object') sessionBucketIndex.set(sessionId, sessionBucket(meta))
564
+ for (const file of filesToWrite) {
565
+ const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
566
+ const bucket = bucketEntry?.bucket ?? (file === sessionStoreFile(storeName, { scope: 'global' })
567
+ ? { scope: 'global' }
568
+ : { scope: 'project', projectId: path.basename(path.dirname(file)) })
569
+ updateSessionMetadataBucketIndex(bucket, previousByFile.get(file) ?? {}, bucketEntry?.data ?? {})
511
570
  }
571
+ bumpStoreRevision(storeName)
512
572
  }
513
573
  }
514
574
 
@@ -607,6 +667,29 @@ export async function atomicUpdate(storeName, updateFn) {
607
667
  })
608
668
  }
609
669
 
670
+ /**
671
+ * Atomically read-modify-write the scoped sessions metadata file within its serialized write queue.
672
+ *
673
+ * @param {string} scope
674
+ * @param {string|null|undefined} projectId
675
+ * @param {(data: object) => object} updateFn — receives current scoped metadata, returns updated metadata
676
+ * @returns {Promise<object>} the updated scoped metadata
677
+ */
678
+ export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
679
+ const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
680
+ const file = sessionStoreFile('sessions-metadata', bucket)
681
+ return enqueueWrite(sessionMetadataQueueName(bucket), async () => {
682
+ await ensureStorage()
683
+ const data = await readJsonFile(file, {})
684
+ const previousData = { ...data }
685
+ const updated = updateFn(data)
686
+ await writeJsonAtomic(file, updated)
687
+ updateSessionMetadataBucketIndex(bucket, previousData, updated)
688
+ bumpStoreRevision('sessions-metadata')
689
+ return updated
690
+ })
691
+ }
692
+
610
693
  /**
611
694
  * Atomically read-modify-write the project config within the config queue.
612
695
  */
@@ -27,6 +27,10 @@ function escapeXml(value) {
27
27
  .replace(/>/g, '&gt;')
28
28
  }
29
29
 
30
+ function escapeAttribute(value) {
31
+ return escapeXml(value).replace(/"/g, '&quot;')
32
+ }
33
+
30
34
  function formatAllowedTools(value) {
31
35
  if (Array.isArray(value)) return value.join(', ')
32
36
  return value
@@ -83,6 +87,22 @@ ${skillParts.join('\n')}
83
87
  </available_skills>`)
84
88
  }
85
89
 
90
+ function appendInstructionSources(parts, tagName, sources, fallback) {
91
+ const instructionSources = Array.isArray(sources)
92
+ ? sources.filter((source) => source?.content)
93
+ : []
94
+
95
+ if (instructionSources.length > 0) {
96
+ for (const source of instructionSources) {
97
+ const sourceAttribute = source.source ? ` source="${escapeAttribute(source.source)}"` : ''
98
+ parts.push(`\n<${tagName}${sourceAttribute}>\n${source.content}\n</${tagName}>`)
99
+ }
100
+ return
101
+ }
102
+
103
+ if (fallback) parts.push(`\n<${tagName}>\n${fallback}\n</${tagName}>`)
104
+ }
105
+
86
106
  export function composeSystemPrompt(instructions = {}) {
87
107
  const parts = [BASE_SYSTEM_PROMPT]
88
108
 
@@ -103,12 +123,14 @@ ${lines.join('\n')}
103
123
  }
104
124
  }
105
125
 
106
- if (instructions.global) {
107
- parts.push(`\n<user_instructions>\n${instructions.global}\n</user_instructions>`)
108
- }
126
+ appendInstructionSources(parts, 'user_instructions', instructions.globalSources, instructions.global)
127
+ appendInstructionSources(parts, 'project_instructions', instructions.projectSources, instructions.project)
109
128
 
110
- if (instructions.project) {
111
- parts.push(`\n<project_instructions>\n${instructions.project}\n</project_instructions>`)
129
+ if (instructions.globalSources?.length || instructions.projectSources?.length) {
130
+ parts.push(`
131
+ <instruction_precedence>
132
+ When instructions from different sources conflict, prefer project-specific instructions over global instructions, and prefer QuickForge project instructions over external compatibility instructions.
133
+ </instruction_precedence>`)
112
134
  }
113
135
 
114
136
  const skills = Array.isArray(instructions.skills)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tool definition wrapping and execution wiring.
3
+ *
4
+ * Wraps raw tool definitions with execution handlers that inject
5
+ * timing metadata, permission checks, and context.
6
+ */
7
+
8
+ import { toolHandlers } from './tools/index.mjs'
9
+ import { callMcpTool } from './mcp/registry.mjs'
10
+ import { callPluginTool } from './plugins/registry.mjs'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export function isPlainObject(value) {
17
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
18
+ }
19
+
20
+ export function mergeQuickForgeTiming(details, timing) {
21
+ if (!isPlainObject(details)) return { quickforgeTiming: timing }
22
+ return { ...details, quickforgeTiming: timing }
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Tool wrappers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export function wrapToolDefinition(definition, context, toolPermissions) {
30
+ const handler = toolHandlers[definition.name]
31
+ if (!handler) throw new Error(`Missing handler for tool: ${definition.name}`)
32
+ return {
33
+ ...definition,
34
+ execute: async (_toolCallId, params, signal, onUpdate) => {
35
+ if (toolPermissions) {
36
+ const permissionError = toolPermissions(definition.name)
37
+ if (permissionError) throw new Error(permissionError)
38
+ }
39
+
40
+ const startedAt = Date.now()
41
+ const startedAtPerf = performance.now()
42
+ const result = await handler(params || {}, context, { signal, onUpdate, toolCallId: _toolCallId })
43
+ const finishedAt = Date.now()
44
+ const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
45
+ const details = mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs })
46
+ return {
47
+ content: [{ type: 'text', text: result.content }],
48
+ details: isPlainObject(details) ? { ...details, toolCallId: _toolCallId } : details,
49
+ }
50
+ },
51
+ }
52
+ }
53
+
54
+ export function wrapMcpToolDefinition(definition, toolPermissions) {
55
+ return {
56
+ ...definition,
57
+ execute: async (_toolCallId, params) => {
58
+ if (toolPermissions) {
59
+ const permissionError = toolPermissions(definition.name)
60
+ if (permissionError) throw new Error(permissionError)
61
+ }
62
+
63
+ const startedAt = Date.now()
64
+ const startedAtPerf = performance.now()
65
+ const result = await callMcpTool(definition.name, params || {})
66
+ const finishedAt = Date.now()
67
+ const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
68
+ if (result.isError) {
69
+ throw new Error(result.content || `MCP tool failed: ${definition.name}`)
70
+ }
71
+ return {
72
+ content: [{ type: 'text', text: result.content }],
73
+ details: mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs }),
74
+ }
75
+ },
76
+ }
77
+ }
78
+
79
+ export function wrapPluginToolDefinition(definition, context, toolPermissions) {
80
+ return {
81
+ ...definition,
82
+ execute: async (_toolCallId, params, signal, onUpdate) => {
83
+ if (toolPermissions) {
84
+ const permissionError = toolPermissions(definition.name)
85
+ if (permissionError) throw new Error(permissionError)
86
+ }
87
+
88
+ const startedAt = Date.now()
89
+ const startedAtPerf = performance.now()
90
+ const result = await callPluginTool(definition.name, params || {}, { ...context, signal, onUpdate, toolCallId: _toolCallId })
91
+ const finishedAt = Date.now()
92
+ const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
93
+ if (result.isError) {
94
+ throw new Error(result.content || `Plugin tool failed: ${definition.name}`)
95
+ }
96
+ return {
97
+ content: [{ type: 'text', text: result.content }],
98
+ details: mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs }),
99
+ }
100
+ },
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Skill context
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export function sessionSkillsContext(session) {
109
+ return {
110
+ globalSkillNames: session.globalSkillNames,
111
+ projectSkillNames: session.projectSkillNames,
112
+ }
113
+ }
@@ -131,21 +131,40 @@ export async function assertDirectory(dir) {
131
131
  }
132
132
  }
133
133
 
134
+ const SIZE_SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-ssr', '.vite', '.cache', '.next', '.nuxt', '__pycache__', '.venv', 'venv'])
135
+ const directorySizeCache = new Map()
136
+ const DIRECTORY_SIZE_CACHE_TTL_MS = 10_000
137
+
134
138
  export async function directorySize(dir) {
135
139
  try {
140
+ const now = Date.now()
141
+ const cached = directorySizeCache.get(dir)
142
+ if (cached && now - cached.ts < DIRECTORY_SIZE_CACHE_TTL_MS) return cached.size
143
+
136
144
  const entries = await fs.readdir(dir, { withFileTypes: true })
137
145
  const sizes = await Promise.all(entries.map(async (entry) => {
146
+ if (entry.isDirectory() && SIZE_SKIP_DIRS.has(entry.name)) return 0
138
147
  const full = path.join(dir, entry.name)
139
148
  if (entry.isDirectory()) return directorySize(full)
140
149
  const stat = await fs.stat(full)
141
150
  return stat.size
142
151
  }))
143
- return sizes.reduce((sum, value) => sum + value, 0)
152
+ const size = sizes.reduce((sum, value) => sum + value, 0)
153
+ directorySizeCache.set(dir, { size, ts: now })
154
+ return size
144
155
  } catch {
145
156
  return 0
146
157
  }
147
158
  }
148
159
 
160
+ export function invalidateDirectorySizeCache(dir) {
161
+ if (dir) {
162
+ directorySizeCache.delete(dir)
163
+ } else {
164
+ directorySizeCache.clear()
165
+ }
166
+ }
167
+
149
168
  export function shouldSkipSearchDir(name) {
150
169
  return ['.git', 'node_modules', 'dist', 'dist-ssr', '.vite'].includes(name)
151
170
  }