@vibe-forge/workspace-assets 2.0.1 → 2.0.2
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/__tests__/__snapshots__/workspace-assets-rich.snapshot.json +1 -1
- package/__tests__/adapter-asset-plan.spec.ts +65 -67
- package/__tests__/bundle.spec.ts +138 -213
- package/__tests__/prompt-builders.spec.ts +39 -0
- package/package.json +4 -4
- package/src/bundle-internal.ts +3 -18
- package/src/bundle.ts +0 -2
- package/src/prompt-builders.ts +4 -0
- package/src/prompt-selection.ts +2 -2
- package/src/selection-internal.ts +2 -2
- package/src/skill-dependencies.ts +16 -16
- package/src/skill-registry.ts +329 -0
- package/src/task-tool-guidance.ts +15 -0
- package/src/workspace-prompt.ts +4 -0
- package/vibe-forge-workspace-assets-2.0.2.tgz +0 -0
- package/__tests__/skill-dependencies-cli.spec.ts +0 -175
- package/src/configured-skills.ts +0 -99
- package/src/skills-cli-dependency.ts +0 -208
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateSkillsRoutePrompt,
|
|
10
10
|
generateSpecRoutePrompt
|
|
11
11
|
} from '#~/prompt-builders.js'
|
|
12
|
+
import { generateWorkspaceRoutePrompt } from '#~/workspace-prompt.js'
|
|
12
13
|
|
|
13
14
|
describe('workspace asset prompt builders', () => {
|
|
14
15
|
it('builds skill prompts with stable names, descriptions, and relative paths', () => {
|
|
@@ -175,12 +176,50 @@ describe('workspace asset prompt builders', () => {
|
|
|
175
176
|
expect(prompt).toContain('reviewer: 负责代码审查')
|
|
176
177
|
expect(prompt).toContain('`VibeForge.StartTasks`')
|
|
177
178
|
expect(prompt).toContain('`VibeForge.GetTaskInfo`')
|
|
179
|
+
expect(prompt).toContain('Task tool guide:')
|
|
180
|
+
expect(prompt).toContain('After starting a task')
|
|
181
|
+
expect(prompt).toContain('10 most recent log entries')
|
|
182
|
+
expect(prompt).toContain('`logLimit`')
|
|
183
|
+
expect(prompt).toContain('`logOrder`')
|
|
184
|
+
expect(prompt).toContain('`VibeForge.SendTaskMessage`')
|
|
185
|
+
expect(prompt).toContain('`{ taskId, message, mode }`')
|
|
186
|
+
expect(prompt).toContain('Choose `mode: "direct"`')
|
|
187
|
+
expect(prompt).toContain('Choose `mode: "steer"`')
|
|
188
|
+
expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
|
|
189
|
+
expect(prompt).toContain('Do not use it for ordinary follow-up instructions or queued steer turns')
|
|
190
|
+
expect(prompt).toContain('If a task is `completed` or `failed`')
|
|
178
191
|
expect(prompt).toContain('`wait`')
|
|
179
192
|
expect(prompt).not.toContain('run-tasks')
|
|
180
193
|
expect(prompt).not.toContain('需要关注变更风险')
|
|
181
194
|
expect(prompt).not.toContain('hidden')
|
|
182
195
|
})
|
|
183
196
|
|
|
197
|
+
it('builds workspace routes with managed task guidance', () => {
|
|
198
|
+
const cwd = '/tmp/project'
|
|
199
|
+
|
|
200
|
+
const prompt = generateWorkspaceRoutePrompt(cwd, [
|
|
201
|
+
{
|
|
202
|
+
id: 'billing',
|
|
203
|
+
cwd: join(cwd, 'packages/billing'),
|
|
204
|
+
path: join(cwd, '.ai/workspaces/billing.md'),
|
|
205
|
+
description: 'Billing workspace'
|
|
206
|
+
}
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
expect(prompt).toContain('The project includes the following registered workspaces')
|
|
210
|
+
expect(prompt).toContain('Identifier: billing')
|
|
211
|
+
expect(prompt).toContain('`VibeForge.StartTasks`')
|
|
212
|
+
expect(prompt).toContain('type: "workspace"')
|
|
213
|
+
expect(prompt).toContain('Task tool guide:')
|
|
214
|
+
expect(prompt).toContain('`VibeForge.GetTaskInfo`')
|
|
215
|
+
expect(prompt).toContain('`VibeForge.ListTasks`')
|
|
216
|
+
expect(prompt).toContain('`VibeForge.SendTaskMessage`')
|
|
217
|
+
expect(prompt).toContain('`VibeForge.SubmitTaskInput`')
|
|
218
|
+
expect(prompt).toContain('Choose `mode: "direct"`')
|
|
219
|
+
expect(prompt).toContain('Choose `mode: "steer"`')
|
|
220
|
+
expect(prompt).toContain('Do not directly edit files inside a registered workspace')
|
|
221
|
+
})
|
|
222
|
+
|
|
184
223
|
it('builds skill route prompts without preloading content', () => {
|
|
185
224
|
const cwd = '/tmp/project'
|
|
186
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-forge/workspace-assets",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Workspace asset resolution and adapter asset planning for Vibe Forge",
|
|
5
5
|
"imports": {
|
|
6
6
|
"#~/*.js": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"fast-glob": "^3.3.3",
|
|
31
31
|
"front-matter": "^4.0.2",
|
|
32
32
|
"js-yaml": "^4.1.1",
|
|
33
|
-
"@vibe-forge/
|
|
34
|
-
"@vibe-forge/types": "^2.0.
|
|
35
|
-
"@vibe-forge/
|
|
33
|
+
"@vibe-forge/definition-core": "^2.0.0",
|
|
34
|
+
"@vibe-forge/types": "^2.0.2",
|
|
35
|
+
"@vibe-forge/utils": "^2.0.3"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/js-yaml": "^4.0.9"
|
package/src/bundle-internal.ts
CHANGED
|
@@ -9,12 +9,7 @@ import {
|
|
|
9
9
|
resolveDefaultVibeForgeMcpServerConfig
|
|
10
10
|
} from '@vibe-forge/config'
|
|
11
11
|
import type { Config, Definition, Entity, PluginConfig, WorkspaceAsset, WorkspaceAssetKind } from '@vibe-forge/types'
|
|
12
|
-
import {
|
|
13
|
-
isLegacySkillsConfig,
|
|
14
|
-
resolveProjectAiBaseDir,
|
|
15
|
-
resolveProjectAiEntitiesDir,
|
|
16
|
-
resolveRelativePath
|
|
17
|
-
} from '@vibe-forge/utils'
|
|
12
|
+
import { resolveProjectAiBaseDir, resolveProjectAiEntitiesDir, resolveRelativePath } from '@vibe-forge/utils'
|
|
18
13
|
import { listManagedPluginInstalls, toManagedPluginConfig } from '@vibe-forge/utils/managed-plugin'
|
|
19
14
|
import {
|
|
20
15
|
flattenPluginInstances,
|
|
@@ -33,7 +28,6 @@ import {
|
|
|
33
28
|
resolveSkillIdentifier,
|
|
34
29
|
resolveSpecIdentifier
|
|
35
30
|
} from '@vibe-forge/definition-core'
|
|
36
|
-
import { ensureConfiguredProjectSkills } from './configured-skills'
|
|
37
31
|
import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
|
|
38
32
|
import { resolveConfiguredWorkspaceAssets } from './workspaces'
|
|
39
33
|
|
|
@@ -159,8 +153,8 @@ const warnInvalidHomeSkillRoot = (root: string) => {
|
|
|
159
153
|
|
|
160
154
|
const resolveHomeBridgeConfig = (configs: [Config?, Config?]) => {
|
|
161
155
|
const [config, userConfig] = configs
|
|
162
|
-
const projectHomeBridge =
|
|
163
|
-
const userHomeBridge =
|
|
156
|
+
const projectHomeBridge = config?.skills?.homeBridge
|
|
157
|
+
const userHomeBridge = userConfig?.skills?.homeBridge
|
|
164
158
|
|
|
165
159
|
return {
|
|
166
160
|
enabled: userHomeBridge?.enabled ?? projectHomeBridge?.enabled ?? true,
|
|
@@ -532,8 +526,6 @@ export async function collectWorkspaceAssets(params: {
|
|
|
532
526
|
plugins?: PluginConfig
|
|
533
527
|
overlaySource?: string
|
|
534
528
|
includeManagedPlugins?: boolean
|
|
535
|
-
syncConfiguredSkills?: boolean
|
|
536
|
-
updateConfiguredSkills?: boolean
|
|
537
529
|
useDefaultVibeForgeMcpServer?: boolean
|
|
538
530
|
}): Promise<{
|
|
539
531
|
assets: WorkspaceAsset[]
|
|
@@ -552,13 +544,6 @@ export async function collectWorkspaceAssets(params: {
|
|
|
552
544
|
workspaces: Array<Extract<WorkspaceAsset, { kind: 'workspace' }>>
|
|
553
545
|
}> {
|
|
554
546
|
const [config, userConfig] = params.configs ?? await loadWorkspaceConfig(params.cwd)
|
|
555
|
-
if (params.syncConfiguredSkills === true) {
|
|
556
|
-
await ensureConfiguredProjectSkills({
|
|
557
|
-
configs: [config, userConfig],
|
|
558
|
-
updateInstalledSkills: params.updateConfiguredSkills,
|
|
559
|
-
workspaceFolder: params.cwd
|
|
560
|
-
})
|
|
561
|
-
}
|
|
562
547
|
const managedPluginConfigs = params.includeManagedPlugins === false
|
|
563
548
|
? undefined
|
|
564
549
|
: toManagedPluginConfig(await listManagedPluginInstalls(params.cwd))
|
package/src/bundle.ts
CHANGED
|
@@ -8,8 +8,6 @@ export async function resolveWorkspaceAssetBundle(params: {
|
|
|
8
8
|
plugins?: PluginConfig
|
|
9
9
|
overlaySource?: string
|
|
10
10
|
includeManagedPlugins?: boolean
|
|
11
|
-
syncConfiguredSkills?: boolean
|
|
12
|
-
updateConfiguredSkills?: boolean
|
|
13
11
|
useDefaultVibeForgeMcpServer?: boolean
|
|
14
12
|
}): Promise<WorkspaceAssetBundle> {
|
|
15
13
|
const collected = await collectWorkspaceAssets(params)
|
package/src/prompt-builders.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
import type { Definition, Entity, Rule, Skill, Spec } from '@vibe-forge/types'
|
|
8
8
|
import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
|
|
9
9
|
|
|
10
|
+
import { buildManagedTaskToolGuidance } from './task-tool-guidance'
|
|
11
|
+
|
|
10
12
|
const toMarkdownBlockquote = (content: string) => (
|
|
11
13
|
content
|
|
12
14
|
.trim()
|
|
@@ -165,6 +167,7 @@ export const generateSpecRoutePrompt = (
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
export const generateEntitiesRoutePrompt = (entities: Definition<Entity>[]) => {
|
|
170
|
+
const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
|
|
168
171
|
return (
|
|
169
172
|
'<system-prompt>\n' +
|
|
170
173
|
'The project includes the following entities:\n' +
|
|
@@ -179,6 +182,7 @@ export const generateEntitiesRoutePrompt = (entities: Definition<Entity>[]) => {
|
|
|
179
182
|
.join('')
|
|
180
183
|
}\n` +
|
|
181
184
|
`When solving user problems, you may specify entities through \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.StartTasks\` as needed and have them coordinate multiple entity types to complete the work; use \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.GetTaskInfo\` and \`wait\` to track progress.\n` +
|
|
185
|
+
`${taskToolGuidance}\n` +
|
|
182
186
|
'</system-prompt>\n'
|
|
183
187
|
)
|
|
184
188
|
}
|
package/src/prompt-selection.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
resolveSelectedSkillAssetsWithDependencies,
|
|
36
36
|
toDocumentDefinitions
|
|
37
37
|
} from './selection-internal'
|
|
38
|
-
import {
|
|
38
|
+
import { expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
|
|
39
39
|
import { generateWorkspaceRoutePrompt } from './workspace-prompt'
|
|
40
40
|
|
|
41
41
|
export async function resolvePromptAssetSelection(params: {
|
|
@@ -164,7 +164,7 @@ export async function resolvePromptAssetSelection(params: {
|
|
|
164
164
|
resolveNamedAssets(effectiveBundle.skills, excludedRefs, targetInstancePath).map(asset => asset.id)
|
|
165
165
|
)
|
|
166
166
|
|
|
167
|
-
const expandedTargetSkills = await
|
|
167
|
+
const expandedTargetSkills = await expandSkillAssetDependenciesWithRegistry({
|
|
168
168
|
allAssets: effectiveBundle.assets,
|
|
169
169
|
configs: effectiveBundle.configs ?? [undefined, undefined],
|
|
170
170
|
cwd: effectiveBundle.cwd,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
isRemoteRuleReference,
|
|
27
27
|
parseScopedReference
|
|
28
28
|
} from '@vibe-forge/definition-core'
|
|
29
|
-
import { expandSkillAssetDependencies,
|
|
29
|
+
import { expandSkillAssetDependencies, expandSkillAssetDependenciesWithRegistry } from './skill-dependencies'
|
|
30
30
|
|
|
31
31
|
type DocumentAssetKind = Extract<WorkspaceAssetKind, 'rule' | 'spec' | 'entity' | 'skill'>
|
|
32
32
|
type DocumentAsset<TDefinition> = Extract<WorkspaceAsset, { kind: DocumentAssetKind }> & {
|
|
@@ -538,7 +538,7 @@ export const resolveSelectedSkillAssetsWithDependencies = async (
|
|
|
538
538
|
const excluded = new Set(
|
|
539
539
|
resolveNamedAssets(bundle.skills, selection?.exclude).map(asset => asset.id)
|
|
540
540
|
)
|
|
541
|
-
return await
|
|
541
|
+
return await expandSkillAssetDependenciesWithRegistry({
|
|
542
542
|
allAssets: bundle.assets,
|
|
543
543
|
configs: bundle.configs ?? [undefined, undefined],
|
|
544
544
|
cwd: bundle.cwd,
|
|
@@ -8,7 +8,7 @@ import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/type
|
|
|
8
8
|
import { resolveRelativePath } from '@vibe-forge/utils'
|
|
9
9
|
|
|
10
10
|
import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
|
|
11
|
-
import {
|
|
11
|
+
import { installRegistrySkillDependency } from './skill-registry'
|
|
12
12
|
|
|
13
13
|
type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
|
|
14
14
|
|
|
@@ -16,6 +16,7 @@ export interface NormalizedSkillDependency {
|
|
|
16
16
|
ref: string
|
|
17
17
|
name: string
|
|
18
18
|
source?: string
|
|
19
|
+
registry?: string
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
interface DependencyExpansionParams {
|
|
@@ -118,7 +119,7 @@ const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> =
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
const
|
|
122
|
+
const createRegistrySkillAsset = (params: {
|
|
122
123
|
cwd: string
|
|
123
124
|
definition: Definition<Skill>
|
|
124
125
|
}) => {
|
|
@@ -148,15 +149,12 @@ const parseStringDependency = (value: string): NormalizedSkillDependency => {
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
153
|
-
sourcePathSegments.length >= 3 &&
|
|
154
|
-
sourcePathSegments.every(segment => !segment.includes(' '))
|
|
155
|
-
) {
|
|
152
|
+
const sourcePathMatch = ref.match(/^([^/\s]+\/[^/\s]+)\/([^/\s]+)$/)
|
|
153
|
+
if (sourcePathMatch != null) {
|
|
156
154
|
return {
|
|
157
155
|
ref,
|
|
158
|
-
source:
|
|
159
|
-
name:
|
|
156
|
+
source: sourcePathMatch[1],
|
|
157
|
+
name: sourcePathMatch[2]
|
|
160
158
|
}
|
|
161
159
|
}
|
|
162
160
|
|
|
@@ -179,7 +177,8 @@ export const normalizeSkillDependency = (value: unknown): NormalizedSkillDepende
|
|
|
179
177
|
return {
|
|
180
178
|
ref: source == null ? name : `${source}@${name}`,
|
|
181
179
|
name,
|
|
182
|
-
...(source == null ? {} : { source })
|
|
180
|
+
...(source == null ? {} : { source }),
|
|
181
|
+
...(asNonEmptyString(record.registry) == null ? {} : { registry: asNonEmptyString(record.registry) })
|
|
183
182
|
}
|
|
184
183
|
}
|
|
185
184
|
|
|
@@ -243,7 +242,7 @@ export const expandSkillAssetDependencies = (
|
|
|
243
242
|
return selected
|
|
244
243
|
}
|
|
245
244
|
|
|
246
|
-
export const
|
|
245
|
+
export const expandSkillAssetDependenciesWithRegistry = async (
|
|
247
246
|
params: DependencyExpansionParams
|
|
248
247
|
) => {
|
|
249
248
|
const selected: SkillAsset[] = []
|
|
@@ -261,16 +260,16 @@ export const expandSkillAssetDependenciesWithRemoteResolution = async (
|
|
|
261
260
|
dependency: NormalizedSkillDependency,
|
|
262
261
|
currentInstancePath?: string
|
|
263
262
|
) => {
|
|
264
|
-
const fetchKey = dependency.ref
|
|
263
|
+
const fetchKey = `${dependency.registry ?? ''}:${dependency.ref}`
|
|
265
264
|
if (!fetchedDependencyRefs.has(fetchKey)) {
|
|
266
265
|
fetchedDependencyRefs.add(fetchKey)
|
|
267
|
-
const installed = await
|
|
266
|
+
const installed = await installRegistrySkillDependency({
|
|
268
267
|
cwd: params.cwd,
|
|
269
268
|
configs: params.configs,
|
|
270
269
|
dependency
|
|
271
270
|
})
|
|
272
271
|
const definition = await parseFrontmatterSkill(installed.skillPath)
|
|
273
|
-
const dependencyAsset =
|
|
272
|
+
const dependencyAsset = createRegistrySkillAsset({
|
|
274
273
|
cwd: params.cwd,
|
|
275
274
|
definition
|
|
276
275
|
})
|
|
@@ -335,13 +334,14 @@ export const expandSkillAssetDependenciesWithRemoteResolution = async (
|
|
|
335
334
|
const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
|
|
336
335
|
if (
|
|
337
336
|
localOrBridgedDependency != null &&
|
|
338
|
-
dependency.source == null
|
|
337
|
+
dependency.source == null &&
|
|
338
|
+
dependency.registry == null
|
|
339
339
|
) {
|
|
340
340
|
return localOrBridgedDependency
|
|
341
341
|
}
|
|
342
342
|
throw error
|
|
343
343
|
}) ?? (
|
|
344
|
-
dependency.source == null
|
|
344
|
+
dependency.source == null && dependency.registry == null
|
|
345
345
|
? localOrBridgedDependency
|
|
346
346
|
: undefined
|
|
347
347
|
)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- registry install logic keeps URL resolution, locking, and cache writes together */
|
|
2
|
+
import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
6
|
+
|
|
7
|
+
import type { Config } from '@vibe-forge/types'
|
|
8
|
+
import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
|
|
9
|
+
|
|
10
|
+
import type { NormalizedSkillDependency } from './skill-dependencies'
|
|
11
|
+
|
|
12
|
+
interface RegistryOptions {
|
|
13
|
+
enabled: boolean
|
|
14
|
+
searchBaseUrl: string
|
|
15
|
+
downloadBaseUrl: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RegistrySearchSkill {
|
|
19
|
+
id?: string
|
|
20
|
+
name?: string
|
|
21
|
+
skillId?: string
|
|
22
|
+
source?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RegistryDownloadFile {
|
|
26
|
+
path?: string
|
|
27
|
+
contents?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RegistryDownloadResponse {
|
|
31
|
+
files?: RegistryDownloadFile[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SKILL_REGISTRY_URL = 'https://skills.sh'
|
|
35
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
36
|
+
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
|
37
|
+
const INSTALL_LOCK_RETRY_MS = 100
|
|
38
|
+
|
|
39
|
+
const asNonEmptyString = (value: unknown) => (
|
|
40
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '')
|
|
44
|
+
|
|
45
|
+
const toSkillSlug = (value: string) => (
|
|
46
|
+
value
|
|
47
|
+
.trim()
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[\s_]+/g, '-')
|
|
50
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
51
|
+
.replace(/-+/g, '-')
|
|
52
|
+
.replace(/^-|-$/g, '')
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const toCacheSegment = (value: string) => (
|
|
56
|
+
value
|
|
57
|
+
.trim()
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
60
|
+
.replace(/^-+|-+$/g, '') || 'registry'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const resolveConfiguredRegistry = (
|
|
64
|
+
projectConfig: Config | undefined,
|
|
65
|
+
userConfig: Config | undefined
|
|
66
|
+
) => userConfig?.skills?.registry ?? projectConfig?.skills?.registry
|
|
67
|
+
|
|
68
|
+
const resolveRegistryOptions = (params: {
|
|
69
|
+
configs: [Config?, Config?]
|
|
70
|
+
registry?: string
|
|
71
|
+
}): RegistryOptions => {
|
|
72
|
+
const [projectConfig, userConfig] = params.configs
|
|
73
|
+
const configuredRegistry = params.registry ?? resolveConfiguredRegistry(projectConfig, userConfig)
|
|
74
|
+
const envSearchBaseUrl = asNonEmptyString(process.env.SKILLS_API_URL)
|
|
75
|
+
const envDownloadBaseUrl = asNonEmptyString(process.env.SKILLS_DOWNLOAD_URL)
|
|
76
|
+
|
|
77
|
+
if (typeof configuredRegistry === 'string' && configuredRegistry.trim() !== '') {
|
|
78
|
+
const baseUrl = normalizeBaseUrl(configuredRegistry.trim())
|
|
79
|
+
return {
|
|
80
|
+
enabled: true,
|
|
81
|
+
searchBaseUrl: baseUrl,
|
|
82
|
+
downloadBaseUrl: baseUrl
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (configuredRegistry != null && typeof configuredRegistry === 'object') {
|
|
87
|
+
const baseUrl = normalizeBaseUrl(
|
|
88
|
+
asNonEmptyString(configuredRegistry.url) ?? DEFAULT_SKILL_REGISTRY_URL
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
enabled: configuredRegistry.enabled !== false,
|
|
92
|
+
searchBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.searchUrl) ?? baseUrl),
|
|
93
|
+
downloadBaseUrl: normalizeBaseUrl(asNonEmptyString(configuredRegistry.downloadUrl) ?? baseUrl)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const baseUrl = normalizeBaseUrl(envSearchBaseUrl ?? DEFAULT_SKILL_REGISTRY_URL)
|
|
98
|
+
return {
|
|
99
|
+
enabled: true,
|
|
100
|
+
searchBaseUrl: baseUrl,
|
|
101
|
+
downloadBaseUrl: normalizeBaseUrl(envDownloadBaseUrl ?? baseUrl)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fetchJson = async <T>(url: string): Promise<T> => {
|
|
106
|
+
const response = await fetch(url, {
|
|
107
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
108
|
+
})
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`Request failed with status ${response.status}`)
|
|
111
|
+
}
|
|
112
|
+
return await response.json() as T
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parseSourceAndSlugFromId = (id: string | undefined) => {
|
|
116
|
+
if (id == null) return undefined
|
|
117
|
+
const segments = id.split('/').filter(Boolean)
|
|
118
|
+
if (segments.length < 3) return undefined
|
|
119
|
+
return {
|
|
120
|
+
source: `${segments[0]}/${segments[1]}`,
|
|
121
|
+
slug: segments.slice(2).join('/')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pickSearchResult = (
|
|
126
|
+
skills: RegistrySearchSkill[],
|
|
127
|
+
name: string
|
|
128
|
+
) => {
|
|
129
|
+
const slug = toSkillSlug(name)
|
|
130
|
+
return skills.find(skill => (
|
|
131
|
+
skill.skillId === slug ||
|
|
132
|
+
skill.name === name ||
|
|
133
|
+
toSkillSlug(skill.name ?? '') === slug ||
|
|
134
|
+
parseSourceAndSlugFromId(skill.id)?.slug === slug
|
|
135
|
+
)) ?? skills[0]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const resolveRegistrySkillTarget = async (
|
|
139
|
+
dependency: NormalizedSkillDependency,
|
|
140
|
+
registry: RegistryOptions
|
|
141
|
+
) => {
|
|
142
|
+
if (dependency.source != null) {
|
|
143
|
+
return {
|
|
144
|
+
source: dependency.source,
|
|
145
|
+
slug: toSkillSlug(dependency.name)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const searchUrl = `${registry.searchBaseUrl}/api/search?q=${encodeURIComponent(dependency.name)}&limit=10`
|
|
150
|
+
const searchResult = await fetchJson<{
|
|
151
|
+
skills?: RegistrySearchSkill[]
|
|
152
|
+
}>(searchUrl)
|
|
153
|
+
const skills = Array.isArray(searchResult.skills) ? searchResult.skills : []
|
|
154
|
+
const picked = pickSearchResult(skills, dependency.name)
|
|
155
|
+
if (picked == null) {
|
|
156
|
+
throw new Error(`Skill ${dependency.name} was not found in ${registry.searchBaseUrl}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const parsed = parseSourceAndSlugFromId(picked.id)
|
|
160
|
+
const source = asNonEmptyString(picked.source) ?? parsed?.source
|
|
161
|
+
const slug = asNonEmptyString(picked.skillId) ?? parsed?.slug ?? toSkillSlug(picked.name ?? dependency.name)
|
|
162
|
+
if (source == null || slug === '') {
|
|
163
|
+
throw new Error(`Registry search result for ${dependency.name} did not include a source and skill id`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
source,
|
|
168
|
+
slug
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const toSafeRelativePath = (filePath: string) => {
|
|
173
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
174
|
+
if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
175
|
+
throw new Error(`Unsafe skill dependency file path ${filePath}`)
|
|
176
|
+
}
|
|
177
|
+
return normalized
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const assertInside = (root: string, target: string) => {
|
|
181
|
+
const relativePath = relative(root, target)
|
|
182
|
+
if (
|
|
183
|
+
relativePath === '' ||
|
|
184
|
+
(
|
|
185
|
+
relativePath !== '..' &&
|
|
186
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
187
|
+
!isAbsolute(relativePath)
|
|
188
|
+
)
|
|
189
|
+
) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`Skill dependency file resolves outside ${root}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pathExists = async (path: string) => {
|
|
196
|
+
try {
|
|
197
|
+
await access(path)
|
|
198
|
+
return true
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
|
|
205
|
+
const start = Date.now()
|
|
206
|
+
await mkdir(dirname(lockDir), { recursive: true })
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
try {
|
|
210
|
+
await mkdir(lockDir)
|
|
211
|
+
break
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
214
|
+
if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
|
|
215
|
+
throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
|
|
216
|
+
}
|
|
217
|
+
await delay(INSTALL_LOCK_RETRY_MS)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
return await callback()
|
|
223
|
+
} finally {
|
|
224
|
+
await rm(lockDir, { recursive: true, force: true })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const buildInstallDir = (params: {
|
|
229
|
+
cwd: string
|
|
230
|
+
registry: RegistryOptions
|
|
231
|
+
source: string
|
|
232
|
+
slug: string
|
|
233
|
+
}) => {
|
|
234
|
+
let registryKey = params.registry.downloadBaseUrl
|
|
235
|
+
try {
|
|
236
|
+
const parsed = new URL(params.registry.downloadBaseUrl)
|
|
237
|
+
registryKey = `${parsed.hostname}${parsed.pathname}`
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return resolveProjectSharedCachePath(
|
|
242
|
+
params.cwd,
|
|
243
|
+
process.env,
|
|
244
|
+
'skill-dependencies',
|
|
245
|
+
toCacheSegment(registryKey),
|
|
246
|
+
...params.source.split('/').map(toCacheSegment),
|
|
247
|
+
toCacheSegment(params.slug)
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const installRegistrySkillDependency = async (params: {
|
|
252
|
+
cwd: string
|
|
253
|
+
configs: [Config?, Config?]
|
|
254
|
+
dependency: NormalizedSkillDependency
|
|
255
|
+
}) => {
|
|
256
|
+
const registry = resolveRegistryOptions({
|
|
257
|
+
configs: params.configs,
|
|
258
|
+
registry: params.dependency.registry
|
|
259
|
+
})
|
|
260
|
+
if (!registry.enabled) {
|
|
261
|
+
throw new Error(`Skill dependency registry is disabled; cannot install ${params.dependency.ref}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const target = await resolveRegistrySkillTarget(params.dependency, registry)
|
|
265
|
+
const [owner, repo] = target.source.split('/')
|
|
266
|
+
if (owner == null || repo == null || owner === '' || repo === '') {
|
|
267
|
+
throw new Error(`Skill dependency source ${target.source} must use owner/repo format`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const downloadUrl = `${registry.downloadBaseUrl}/api/download/${encodeURIComponent(owner)}/${
|
|
271
|
+
encodeURIComponent(repo)
|
|
272
|
+
}/${encodeURIComponent(target.slug)}`
|
|
273
|
+
const installDir = buildInstallDir({
|
|
274
|
+
cwd: params.cwd,
|
|
275
|
+
registry,
|
|
276
|
+
source: target.source,
|
|
277
|
+
slug: target.slug
|
|
278
|
+
})
|
|
279
|
+
const skillPath = resolve(installDir, 'SKILL.md')
|
|
280
|
+
|
|
281
|
+
return await withInstallLock(`${installDir}.lock`, async () => {
|
|
282
|
+
if (await pathExists(skillPath)) {
|
|
283
|
+
return {
|
|
284
|
+
installDir,
|
|
285
|
+
skillPath
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const response = await fetchJson<RegistryDownloadResponse>(downloadUrl)
|
|
290
|
+
const files = Array.isArray(response.files) ? response.files : []
|
|
291
|
+
if (files.length === 0) {
|
|
292
|
+
throw new Error(`Skill dependency ${params.dependency.ref} did not include any files`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const tempDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
296
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
297
|
+
await mkdir(tempDir, { recursive: true })
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
let hasSkillMd = false
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const filePath = asNonEmptyString(file.path)
|
|
303
|
+
if (filePath == null || typeof file.contents !== 'string') continue
|
|
304
|
+
const relativePath = toSafeRelativePath(filePath)
|
|
305
|
+
if (relativePath.toLowerCase() === 'skill.md') hasSkillMd = true
|
|
306
|
+
|
|
307
|
+
const targetPath = resolve(tempDir, relativePath)
|
|
308
|
+
assertInside(tempDir, targetPath)
|
|
309
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
310
|
+
await writeFile(targetPath, file.contents, 'utf8')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!hasSkillMd) {
|
|
314
|
+
throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await rm(installDir, { recursive: true, force: true })
|
|
318
|
+
await rename(tempDir, installDir)
|
|
319
|
+
} catch (error) {
|
|
320
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
321
|
+
throw error
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
installDir,
|
|
326
|
+
skillPath
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const buildManagedTaskToolGuidance = (serverName: string) => {
|
|
2
|
+
return [
|
|
3
|
+
'Task tool guide:',
|
|
4
|
+
`- Use \`${serverName}.StartTasks\` to start a new child task when the work should run in a separate entity or workspace, or when it needs to continue independently from the current turn.`,
|
|
5
|
+
`- After starting a task, use \`${serverName}.GetTaskInfo\` with \`{ taskId }\` to inspect one task. It is also the right tool when a task seems stalled, failed, or might be waiting for input.`,
|
|
6
|
+
'- By default, `GetTaskInfo` returns the 10 most recent log entries in descending order, so newer entries appear earlier in the `logs` array. Pass `logLimit` to inspect a different number of recent logs, and set `logOrder` to `"asc"` when you want the selected log window in oldest-to-newest order.',
|
|
7
|
+
`- Use \`${serverName}.ListTasks\` with the same \`logLimit\` and \`logOrder\` fields when you need to find a taskId or inspect multiple tasks at once.`,
|
|
8
|
+
`- Use \`${serverName}.SendTaskMessage\` with \`{ taskId, message, mode }\` when you want to continue the same task without starting a replacement task.`,
|
|
9
|
+
'- Choose `mode: "direct"` when the task is `running` and the new instruction should be delivered immediately into the current task.',
|
|
10
|
+
'- Choose `mode: "steer"` when the task should finish its current run naturally first, and the follow-up should be queued for the same task afterward.',
|
|
11
|
+
`- Use \`${serverName}.SubmitTaskInput\` only when \`${serverName}.GetTaskInfo\` or \`${serverName}.ListTasks\` shows \`pendingInput\` or \`pendingInteraction\`, or the task status is \`waiting_input\`. Do not use it for ordinary follow-up instructions or queued steer turns.`,
|
|
12
|
+
'- If a task is `completed` or `failed`, start a new task instead of trying to continue the old one.',
|
|
13
|
+
'- When a task is still making progress, use `wait` between checks instead of repeatedly restarting it.'
|
|
14
|
+
].join('\n')
|
|
15
|
+
}
|
package/src/workspace-prompt.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { WorkspaceDefinitionPayload } from '@vibe-forge/types'
|
|
2
2
|
import { CANONICAL_VIBE_FORGE_MCP_SERVER_NAME, resolvePromptPath } from '@vibe-forge/utils'
|
|
3
3
|
|
|
4
|
+
import { buildManagedTaskToolGuidance } from './task-tool-guidance'
|
|
5
|
+
|
|
4
6
|
export const generateWorkspaceRoutePrompt = (
|
|
5
7
|
cwd: string,
|
|
6
8
|
workspaces: WorkspaceDefinitionPayload[]
|
|
7
9
|
) => {
|
|
8
10
|
if (workspaces.length === 0) return ''
|
|
11
|
+
const taskToolGuidance = buildManagedTaskToolGuidance(CANONICAL_VIBE_FORGE_MCP_SERVER_NAME)
|
|
9
12
|
|
|
10
13
|
const workspaceList = workspaces
|
|
11
14
|
.map((workspace) => {
|
|
@@ -24,6 +27,7 @@ export const generateWorkspaceRoutePrompt = (
|
|
|
24
27
|
`${workspaceList}\n` +
|
|
25
28
|
`When a user request targets one of these workspaces, start a child task with \`${CANONICAL_VIBE_FORGE_MCP_SERVER_NAME}.StartTasks\` using \`type: "workspace"\` and \`name\` set to the workspace identifier. ` +
|
|
26
29
|
'Do not directly edit files inside a registered workspace from the current session unless the user explicitly asks this session to work in that directory.\n' +
|
|
30
|
+
`${taskToolGuidance}\n` +
|
|
27
31
|
'</system-prompt>\n'
|
|
28
32
|
)
|
|
29
33
|
}
|
|
Binary file
|