@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.
- package/README.md +24 -18
- package/dist/assets/anthropic-BcnDL7hi.js +39 -0
- package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
- package/dist/assets/google-C2y985rW.js +1 -0
- package/dist/assets/google-shared-Cqjw1plk.js +11 -0
- package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
- package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
- package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
- package/dist/assets/index-ZYbEKGUp.css +3 -0
- package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
- package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
- package/dist/assets/openai-completions-CHDluyXM.js +5 -0
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
- package/dist/assets/openai-responses-UtRriBXu.js +1 -0
- package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
- package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
- package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
- package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
- package/dist/index.html +4 -4
- package/package.json +4 -3
- package/server/agent-manager.mjs +162 -176
- package/server/ai-http-logger.mjs +20 -5
- package/server/approval-store.mjs +63 -0
- package/server/custom-commands.mjs +67 -9
- package/server/index.mjs +7 -0
- package/server/message-converters.mjs +79 -0
- package/server/plugins/loader.mjs +56 -0
- package/server/plugins/manifest.mjs +174 -0
- package/server/plugins/registry.mjs +304 -0
- package/server/project-config.mjs +53 -4
- package/server/routes/agent-profiles.mjs +1 -1
- package/server/routes/agent.mjs +1 -16
- package/server/routes/filesystem.mjs +18 -2
- package/server/routes/plugins.mjs +63 -0
- package/server/routes/project.mjs +2 -0
- package/server/routes/scheduled-tasks.mjs +1 -1
- package/server/routes/storage.mjs +66 -31
- package/server/routes/tools.mjs +12 -1
- package/server/session-utils.mjs +1 -1
- package/server/skills.mjs +64 -5
- package/server/storage.mjs +91 -8
- package/server/system-prompt.mjs +27 -5
- package/server/tool-wiring.mjs +113 -0
- package/server/utils/workspace.mjs +20 -1
- package/dist/assets/anthropic-BrbLtQkg.js +0 -39
- package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
- package/dist/assets/google-Bv6IeSRf.js +0 -1
- package/dist/assets/google-shared-CLc4ziON.js +0 -11
- package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
- package/dist/assets/index-C4m48ndP.css +0 -3
- package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
- package/dist/assets/openai-completions-CihVV11E.js +0 -5
- package/dist/assets/openai-responses-BigEdUNS.js +0 -1
- package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
- /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
- /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
package/server/routes/tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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}`)
|
package/server/session-utils.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamSimple } from '@
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/server/storage.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
510
|
-
|
|
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
|
*/
|
package/server/system-prompt.mjs
CHANGED
|
@@ -27,6 +27,10 @@ function escapeXml(value) {
|
|
|
27
27
|
.replace(/>/g, '>')
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function escapeAttribute(value) {
|
|
31
|
+
return escapeXml(value).replace(/"/g, '"')
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
126
|
+
appendInstructionSources(parts, 'user_instructions', instructions.globalSources, instructions.global)
|
|
127
|
+
appendInstructionSources(parts, 'project_instructions', instructions.projectSources, instructions.project)
|
|
109
128
|
|
|
110
|
-
if (instructions.
|
|
111
|
-
parts.push(
|
|
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
|
-
|
|
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
|
}
|