@vibe-forge/core 0.6.0 → 0.7.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.
- package/package.json +1 -1
- package/src/adapter/index.ts +1 -1
- package/src/adapter/loader.ts +5 -3
- package/src/adapter/type.ts +1 -6
- package/src/config/types.ts +6 -0
- package/src/controllers/task/generate-adapter-query-options.ts +128 -6
- package/src/controllers/task/prepare.ts +9 -1
- package/src/controllers/task/run.ts +3 -1
- package/src/env.ts +31 -3
- package/src/hooks/call.ts +74 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/runtime.ts +139 -0
- package/src/utils/create-logger.ts +26 -11
- package/src/utils/definition-loader.ts +128 -6
- package/src/utils/api.ts +0 -32
package/package.json
CHANGED
package/src/adapter/index.ts
CHANGED
package/src/adapter/loader.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Adapter } from './type'
|
|
2
2
|
|
|
3
|
+
const resolveAdapterPackageName = (type: string) => (
|
|
4
|
+
type.startsWith('@') ? type : `@vibe-forge/adapter-${type}`
|
|
5
|
+
)
|
|
6
|
+
|
|
3
7
|
export const loadAdapter = async (type: string) =>
|
|
4
8
|
(
|
|
5
9
|
// eslint-disable-next-line ts/no-require-imports
|
|
6
|
-
require(
|
|
7
|
-
type.startsWith('@') ? type : `@vibe-forge/adapter-${type}`
|
|
8
|
-
)
|
|
10
|
+
require(resolveAdapterPackageName(type))
|
|
9
11
|
).default as Adapter
|
package/src/adapter/type.ts
CHANGED
|
@@ -87,10 +87,6 @@ export interface AdapterQueryOptions {
|
|
|
87
87
|
onEvent: (event: AdapterOutputEvent) => void
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export interface AdapterInitOptions {
|
|
91
|
-
force?: boolean
|
|
92
|
-
}
|
|
93
|
-
|
|
94
90
|
export interface AdapterSession {
|
|
95
91
|
kill: () => void
|
|
96
92
|
emit: (event: AdapterEvent) => void
|
|
@@ -99,8 +95,7 @@ export interface AdapterSession {
|
|
|
99
95
|
|
|
100
96
|
export interface Adapter {
|
|
101
97
|
init?: (
|
|
102
|
-
ctx: AdapterCtx
|
|
103
|
-
options: AdapterInitOptions
|
|
98
|
+
ctx: AdapterCtx
|
|
104
99
|
) => Promise<void>
|
|
105
100
|
query: (
|
|
106
101
|
ctx: AdapterCtx,
|
package/src/config/types.ts
CHANGED
|
@@ -178,6 +178,12 @@ export interface Config {
|
|
|
178
178
|
* 自定义对话风格。通过指定提示词约束对话风格。
|
|
179
179
|
*/
|
|
180
180
|
customInstructions?: string
|
|
181
|
+
/**
|
|
182
|
+
* 是否注入 Vibe Forge 自动生成的默认系统提示词
|
|
183
|
+
* (例如 rules / skills / entities / specs 生成的提示词)。
|
|
184
|
+
* 默认为 true。
|
|
185
|
+
*/
|
|
186
|
+
injectDefaultSystemPrompt?: boolean
|
|
181
187
|
}
|
|
182
188
|
/**
|
|
183
189
|
* 插件配置
|
|
@@ -1,24 +1,122 @@
|
|
|
1
1
|
import process from 'node:process'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
2
3
|
|
|
3
4
|
import type { AdapterQueryOptions } from '#~/adapter/type.js'
|
|
4
5
|
import { DefinitionLoader } from '#~/utils/definition-loader.js'
|
|
5
|
-
import type { Definition, Filter, Skill } from '#~/utils/definition-loader.js'
|
|
6
|
+
import type { Definition, Entity, Filter, Skill, Spec } from '#~/utils/definition-loader.js'
|
|
7
|
+
|
|
8
|
+
const filterSkills = (
|
|
9
|
+
skills: Definition<Skill>[],
|
|
10
|
+
selection?: AdapterQueryOptions['skills']
|
|
11
|
+
) => {
|
|
12
|
+
if (selection == null) return skills
|
|
13
|
+
|
|
14
|
+
const include = selection.include != null && selection.include.length > 0
|
|
15
|
+
? new Set(selection.include)
|
|
16
|
+
: undefined
|
|
17
|
+
const exclude = new Set(selection.exclude ?? [])
|
|
18
|
+
|
|
19
|
+
return skills.filter((skill) => {
|
|
20
|
+
const name = basename(dirname(skill.path))
|
|
21
|
+
return (include == null || include.has(name)) && !exclude.has(name)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dedupeSkills = (skills: Definition<Skill>[]) => {
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
return skills.filter((skill) => {
|
|
28
|
+
if (seen.has(skill.path)) return false
|
|
29
|
+
seen.add(skill.path)
|
|
30
|
+
return true
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type SkillSelectionInput =
|
|
35
|
+
| AdapterQueryOptions['skills']
|
|
36
|
+
| Entity['skills']
|
|
37
|
+
| Spec['skills']
|
|
38
|
+
|
|
39
|
+
const toNormalizedSkillSelection = (
|
|
40
|
+
selection?: SkillSelectionInput
|
|
41
|
+
): AdapterQueryOptions['skills'] | undefined => {
|
|
42
|
+
if (selection == null) return undefined
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(selection)) {
|
|
45
|
+
return selection.length > 0
|
|
46
|
+
? {
|
|
47
|
+
include: selection
|
|
48
|
+
}
|
|
49
|
+
: undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if ('type' in selection && Array.isArray(selection.list)) {
|
|
53
|
+
const list = selection.list.filter((item): item is string => typeof item === 'string')
|
|
54
|
+
return selection.type === 'include'
|
|
55
|
+
? {
|
|
56
|
+
include: list
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
exclude: list
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return selection
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const mergeSkillSelections = (
|
|
67
|
+
...selections: Array<SkillSelectionInput | undefined>
|
|
68
|
+
): AdapterQueryOptions['skills'] | undefined => {
|
|
69
|
+
let include: Set<string> | undefined
|
|
70
|
+
const exclude = new Set<string>()
|
|
71
|
+
|
|
72
|
+
for (const selection of selections) {
|
|
73
|
+
const normalized = toNormalizedSkillSelection(selection)
|
|
74
|
+
if (normalized == null) continue
|
|
75
|
+
|
|
76
|
+
if (normalized.include != null && normalized.include.length > 0) {
|
|
77
|
+
const current = new Set(normalized.include)
|
|
78
|
+
include = include == null
|
|
79
|
+
? current
|
|
80
|
+
: new Set([...include].filter(item => current.has(item)))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const item of normalized.exclude ?? []) {
|
|
84
|
+
exclude.add(item)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (include == null && exclude.size === 0) return undefined
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
include: include == null ? undefined : [...include],
|
|
92
|
+
exclude: exclude.size === 0 ? undefined : [...exclude]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const getIncludedSkillNames = (selection?: SkillSelectionInput): string[] => {
|
|
97
|
+
const normalized = toNormalizedSkillSelection(selection)
|
|
98
|
+
return normalized?.include ?? []
|
|
99
|
+
}
|
|
6
100
|
|
|
7
101
|
export async function generateAdapterQueryOptions(
|
|
8
102
|
type: 'spec' | 'entity' | undefined,
|
|
9
103
|
name?: string,
|
|
10
|
-
cwd: string = process.cwd()
|
|
104
|
+
cwd: string = process.cwd(),
|
|
105
|
+
input?: {
|
|
106
|
+
skills?: AdapterQueryOptions['skills']
|
|
107
|
+
}
|
|
11
108
|
) {
|
|
12
109
|
const loader = new DefinitionLoader(cwd)
|
|
13
110
|
const options: Partial<AdapterQueryOptions> = {}
|
|
14
111
|
const systemPromptParts: string[] = []
|
|
112
|
+
let effectiveSkillSelection = toNormalizedSkillSelection(input?.skills)
|
|
15
113
|
|
|
16
114
|
// 1. 获取数据
|
|
17
115
|
// 1.1 获取默认数据
|
|
18
116
|
const entities = type !== 'entity'
|
|
19
117
|
? await loader.loadDefaultEntities()
|
|
20
118
|
: []
|
|
21
|
-
const
|
|
119
|
+
const defaultSkills = await loader.loadDefaultSkills()
|
|
22
120
|
const rules = await loader.loadDefaultRules()
|
|
23
121
|
const specs = await loader.loadDefaultSpecs()
|
|
24
122
|
|
|
@@ -42,7 +140,9 @@ export async function generateAdapterQueryOptions(
|
|
|
42
140
|
// always load spec or entity tagged rules
|
|
43
141
|
rules.push(
|
|
44
142
|
...(
|
|
45
|
-
await loader.loadRules(attributes.rules
|
|
143
|
+
await loader.loadRules(attributes.rules, {
|
|
144
|
+
baseDir: dirname(data.path)
|
|
145
|
+
})
|
|
46
146
|
).map((rule) => ({
|
|
47
147
|
...rule,
|
|
48
148
|
attributes: {
|
|
@@ -56,18 +156,40 @@ export async function generateAdapterQueryOptions(
|
|
|
56
156
|
if (
|
|
57
157
|
attributes.skills
|
|
58
158
|
) {
|
|
59
|
-
|
|
159
|
+
effectiveSkillSelection = mergeSkillSelections(
|
|
160
|
+
input?.skills,
|
|
161
|
+
attributes.skills
|
|
162
|
+
)
|
|
163
|
+
targetSkills.push(
|
|
164
|
+
...filterSkills(
|
|
165
|
+
await loader.loadSkills(getIncludedSkillNames(attributes.skills)),
|
|
166
|
+
effectiveSkillSelection
|
|
167
|
+
)
|
|
168
|
+
)
|
|
60
169
|
}
|
|
61
170
|
|
|
62
171
|
targetBody = body
|
|
63
172
|
targetToolsFilter = attributes.tools
|
|
64
173
|
targetMcpServersFilter = attributes.mcpServers
|
|
65
174
|
}
|
|
175
|
+
const skills = filterSkills(defaultSkills, effectiveSkillSelection)
|
|
176
|
+
let selectedSkillsPrompt: Definition<Skill>[] = []
|
|
177
|
+
if (input?.skills?.include != null && input.skills.include.length > 0) {
|
|
178
|
+
selectedSkillsPrompt = dedupeSkills(
|
|
179
|
+
filterSkills(
|
|
180
|
+
await loader.loadSkills(input.skills.include),
|
|
181
|
+
effectiveSkillSelection
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
}
|
|
66
185
|
|
|
67
186
|
// 2. 基于数据生成上下文
|
|
68
187
|
// 2.1 加载关联上下文
|
|
69
188
|
systemPromptParts.push(loader.generateRulesPrompt(rules))
|
|
70
|
-
systemPromptParts.push(loader.generateSkillsPrompt(targetSkills))
|
|
189
|
+
systemPromptParts.push(loader.generateSkillsPrompt(dedupeSkills(targetSkills)))
|
|
190
|
+
systemPromptParts.push(loader.generateSkillsPrompt(
|
|
191
|
+
selectedSkillsPrompt.filter(skill => !targetSkills.some(target => target.path === skill.path))
|
|
192
|
+
))
|
|
71
193
|
// 2.2 加载上下文路由
|
|
72
194
|
systemPromptParts.push(loader.generateEntitiesRoutePrompt(entities))
|
|
73
195
|
systemPromptParts.push(loader.generateSkillsRoutePrompt(skills))
|
|
@@ -6,6 +6,8 @@ import { getCache, setCache } from '@vibe-forge/core/utils/cache'
|
|
|
6
6
|
import { createLogger } from '@vibe-forge/core/utils/create-logger'
|
|
7
7
|
import { uuid } from '@vibe-forge/core/utils/uuid'
|
|
8
8
|
|
|
9
|
+
import { resolveServerLogLevel } from '#~/env.js'
|
|
10
|
+
|
|
9
11
|
import type { RunTaskOptions } from './type'
|
|
10
12
|
|
|
11
13
|
export const prepare = async (
|
|
@@ -36,7 +38,13 @@ export const prepare = async (
|
|
|
36
38
|
// 移除 NODE_OPTIONS 环境变量,防止干扰子进程的运行环境
|
|
37
39
|
NODE_OPTIONS: undefined
|
|
38
40
|
}
|
|
39
|
-
const logger = createLogger(
|
|
41
|
+
const logger = createLogger(
|
|
42
|
+
cwd,
|
|
43
|
+
ctxId,
|
|
44
|
+
sessionId,
|
|
45
|
+
env?.LOG_PREFIX ?? '',
|
|
46
|
+
resolveServerLogLevel(env)
|
|
47
|
+
)
|
|
40
48
|
|
|
41
49
|
const jsonVariables: Record<string, string | null | undefined> = {
|
|
42
50
|
...env,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AdapterCtx, AdapterOutputEvent, AdapterQueryOptions } from '#~/adapter/index.js'
|
|
2
2
|
import { loadAdapter } from '#~/adapter/index.js'
|
|
3
3
|
import type { ModelServiceConfig } from '#~/config.js'
|
|
4
|
+
import { callHook } from '#~/hooks/call.js'
|
|
4
5
|
import type { TaskDetail } from '#~/types.js'
|
|
5
|
-
import { callHook } from '#~/utils/api.js'
|
|
6
6
|
|
|
7
7
|
import { prepare } from './prepare'
|
|
8
8
|
import type { RunTaskOptions } from './type'
|
|
@@ -162,6 +162,8 @@ export const run = async (
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const adapter = await loadAdapter(adapterType)
|
|
165
|
+
await adapter.init?.(ctx)
|
|
166
|
+
|
|
165
167
|
const resolvedModel = resolveQueryModel({
|
|
166
168
|
config,
|
|
167
169
|
userConfig,
|
package/src/env.ts
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
1
|
import { env as processEnv } from 'node:process'
|
|
2
2
|
|
|
3
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
4
|
+
|
|
3
5
|
export interface ServerEnv {
|
|
4
6
|
__VF_PROJECT_AI_SERVER_HOST__: string
|
|
5
7
|
__VF_PROJECT_AI_SERVER_PORT__: number
|
|
6
8
|
__VF_PROJECT_AI_SERVER_WS_PATH__: string
|
|
7
9
|
__VF_PROJECT_AI_SERVER_DATA_DIR__: string
|
|
8
10
|
__VF_PROJECT_AI_SERVER_LOG_DIR__: string
|
|
9
|
-
__VF_PROJECT_AI_SERVER_LOG_LEVEL__:
|
|
11
|
+
__VF_PROJECT_AI_SERVER_LOG_LEVEL__: LogLevel
|
|
12
|
+
__VF_PROJECT_AI_SERVER_DEBUG__: boolean
|
|
10
13
|
__VF_PROJECT_AI_SERVER_ALLOW_CORS__: boolean
|
|
11
14
|
__VF_PROJECT_AI_CLIENT_MODE__?: 'dev' | 'static'
|
|
12
15
|
__VF_PROJECT_AI_CLIENT_BASE__?: string
|
|
13
16
|
__VF_PROJECT_AI_CLIENT_DIST_PATH__?: string
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const satisfies readonly LogLevel[]
|
|
20
|
+
|
|
21
|
+
export function normalizeLogLevel(value: unknown): LogLevel | undefined {
|
|
22
|
+
if (typeof value !== 'string') return undefined
|
|
23
|
+
const normalized = value.trim().toLowerCase()
|
|
24
|
+
return LOG_LEVELS.includes(normalized as LogLevel)
|
|
25
|
+
? normalized as LogLevel
|
|
26
|
+
: undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveServerLogLevel(
|
|
30
|
+
env: {
|
|
31
|
+
__VF_PROJECT_AI_SERVER_LOG_LEVEL__?: unknown
|
|
32
|
+
__VF_PROJECT_AI_SERVER_DEBUG__?: unknown
|
|
33
|
+
},
|
|
34
|
+
fallback: LogLevel = 'info'
|
|
35
|
+
): LogLevel {
|
|
36
|
+
if (env.__VF_PROJECT_AI_SERVER_DEBUG__ === true || env.__VF_PROJECT_AI_SERVER_DEBUG__ === 'true') {
|
|
37
|
+
return 'debug'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return normalizeLogLevel(env.__VF_PROJECT_AI_SERVER_LOG_LEVEL__) ?? fallback
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
export function loadEnv(): ServerEnv {
|
|
17
44
|
const {
|
|
18
45
|
__VF_PROJECT_AI_SERVER_HOST__ = 'localhost',
|
|
@@ -21,6 +48,7 @@ export function loadEnv(): ServerEnv {
|
|
|
21
48
|
__VF_PROJECT_AI_SERVER_DATA_DIR__ = '.data',
|
|
22
49
|
__VF_PROJECT_AI_SERVER_LOG_DIR__ = '.logs',
|
|
23
50
|
__VF_PROJECT_AI_SERVER_LOG_LEVEL__ = 'info',
|
|
51
|
+
__VF_PROJECT_AI_SERVER_DEBUG__,
|
|
24
52
|
__VF_PROJECT_AI_SERVER_ALLOW_CORS__,
|
|
25
53
|
__VF_PROJECT_AI_CLIENT_MODE__ = 'static',
|
|
26
54
|
__VF_PROJECT_AI_CLIENT_BASE__,
|
|
@@ -32,8 +60,8 @@ export function loadEnv(): ServerEnv {
|
|
|
32
60
|
__VF_PROJECT_AI_SERVER_WS_PATH__,
|
|
33
61
|
__VF_PROJECT_AI_SERVER_DATA_DIR__,
|
|
34
62
|
__VF_PROJECT_AI_SERVER_LOG_DIR__,
|
|
35
|
-
__VF_PROJECT_AI_SERVER_LOG_LEVEL__:
|
|
36
|
-
|
|
63
|
+
__VF_PROJECT_AI_SERVER_LOG_LEVEL__: normalizeLogLevel(__VF_PROJECT_AI_SERVER_LOG_LEVEL__) ?? 'info',
|
|
64
|
+
__VF_PROJECT_AI_SERVER_DEBUG__: __VF_PROJECT_AI_SERVER_DEBUG__ === 'true',
|
|
37
65
|
__VF_PROJECT_AI_SERVER_ALLOW_CORS__: __VF_PROJECT_AI_SERVER_ALLOW_CORS__ != null
|
|
38
66
|
? __VF_PROJECT_AI_SERVER_ALLOW_CORS__ === 'true'
|
|
39
67
|
: true,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
import type { HookInputs, HookOutputs } from './type'
|
|
7
|
+
|
|
8
|
+
export type HookEventName = keyof HookInputs
|
|
9
|
+
|
|
10
|
+
type HookInputPayload<K extends HookEventName> = Omit<HookInputs[K], 'hookEventName'>
|
|
11
|
+
|
|
12
|
+
const pickHookEnv = (env: Record<string, unknown>): Record<string, string> => {
|
|
13
|
+
const result: Record<string, string> = {}
|
|
14
|
+
for (const [key, value] of Object.entries(env)) {
|
|
15
|
+
if (typeof value === 'string') {
|
|
16
|
+
result[key] = value
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolveHookCliJs = () => {
|
|
23
|
+
try {
|
|
24
|
+
const pkgJsonPath = require.resolve('@vibe-forge/cli/package.json')
|
|
25
|
+
return path.resolve(path.dirname(pkgJsonPath), 'call-hook.js')
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error('Failed to resolve @vibe-forge/cli hook entry', { cause: error })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const callHook = async <K extends HookEventName>(
|
|
32
|
+
hookEventName: K,
|
|
33
|
+
input: HookInputPayload<K>,
|
|
34
|
+
env: Record<string, unknown> = process.env
|
|
35
|
+
): Promise<HookOutputs[K]> => {
|
|
36
|
+
const childEnv = pickHookEnv(env)
|
|
37
|
+
const child = spawn(process.execPath, [resolveHookCliJs()], {
|
|
38
|
+
cwd: typeof input.cwd === 'string' ? input.cwd : process.cwd(),
|
|
39
|
+
env: childEnv,
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const stdoutChunks: Buffer[] = []
|
|
44
|
+
const stderrChunks: Buffer[] = []
|
|
45
|
+
|
|
46
|
+
child.stdout.on('data', chunk => stdoutChunks.push(chunk))
|
|
47
|
+
child.stderr.on('data', chunk => stderrChunks.push(chunk))
|
|
48
|
+
|
|
49
|
+
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
50
|
+
child.once('error', reject)
|
|
51
|
+
child.once('close', code => resolve(code ?? 0))
|
|
52
|
+
child.stdin.end(JSON.stringify({
|
|
53
|
+
...input,
|
|
54
|
+
hookEventName
|
|
55
|
+
}))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim()
|
|
59
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
|
|
60
|
+
|
|
61
|
+
if (exitCode !== 0) {
|
|
62
|
+
throw new Error(`Failed to call hook: process exited with code ${exitCode}${stderr ? ` - ${stderr}` : ''}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (stdout === '') {
|
|
66
|
+
return { continue: true } as HookOutputs[K]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(stdout) as HookOutputs[K]
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(`Failed to parse hook output: ${stdout}${stderr ? `\nstderr: ${stderr}` : ''}`, { cause: error })
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
|
|
4
|
+
import { loadConfig, resetConfigCache } from '#~/config/load.js'
|
|
5
|
+
import { resolveServerLogLevel } from '#~/env.js'
|
|
6
|
+
import { createLogger } from '#~/utils/create-logger.js'
|
|
7
|
+
import { transformCamelKey } from '#~/utils/string-transform.js'
|
|
8
|
+
|
|
9
|
+
import type { HookInput, HookInputs, HookOutputCore, HookOutputs } from './type'
|
|
10
|
+
import type { HookContext, Plugin } from './index'
|
|
11
|
+
import { resolvePlugins } from './loader'
|
|
12
|
+
|
|
13
|
+
export const callPluginHook = async <K extends keyof HookInputs>(
|
|
14
|
+
eventName: K,
|
|
15
|
+
context: HookContext,
|
|
16
|
+
input: HookInputs[K],
|
|
17
|
+
plugins: Partial<Plugin>[] = []
|
|
18
|
+
): Promise<HookOutputs[K]> => {
|
|
19
|
+
const { logger } = context
|
|
20
|
+
const filteredPlugins = plugins.filter(
|
|
21
|
+
(
|
|
22
|
+
item
|
|
23
|
+
): item is
|
|
24
|
+
& {
|
|
25
|
+
name?: string
|
|
26
|
+
}
|
|
27
|
+
& {
|
|
28
|
+
[P in K]: NonNullable<Plugin[P]>
|
|
29
|
+
} => !!item && !!item[eventName]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
let index = 0
|
|
33
|
+
|
|
34
|
+
const next = async (): Promise<HookOutputs[K]> => {
|
|
35
|
+
if (index >= filteredPlugins.length) {
|
|
36
|
+
return { continue: true }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const currentPlugin = filteredPlugins[index]
|
|
40
|
+
const { name = '<anonymous>', [eventName]: hook } = currentPlugin
|
|
41
|
+
index++
|
|
42
|
+
|
|
43
|
+
const withNameLogger = {
|
|
44
|
+
...logger,
|
|
45
|
+
info: logger.info.bind(logger, `[plugin.${name}]`),
|
|
46
|
+
warn: logger.warn.bind(logger, `[plugin.${name}]`),
|
|
47
|
+
debug: logger.debug.bind(logger, `[plugin.${name}]`),
|
|
48
|
+
error: logger.error.bind(logger, `[plugin.${name}]`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return await hook(
|
|
53
|
+
{
|
|
54
|
+
...context,
|
|
55
|
+
logger: withNameLogger
|
|
56
|
+
},
|
|
57
|
+
input,
|
|
58
|
+
next
|
|
59
|
+
)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof Error && !error.name.includes('[plugin.')) {
|
|
62
|
+
error.name = `${error.name}[plugin.${name}]`
|
|
63
|
+
}
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return next()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const executeHookInput = async (
|
|
72
|
+
input: HookInput,
|
|
73
|
+
env: Record<string, string | null | undefined> = process.env
|
|
74
|
+
) => {
|
|
75
|
+
const workspaceFolder = env.__VF_PROJECT_WORKSPACE_FOLDER__ ?? input.cwd ?? process.env.HOME ?? '/'
|
|
76
|
+
const ctxId = env.__VF_PROJECT_AI_CTX_ID__ ?? input.sessionId ?? 'default'
|
|
77
|
+
const logPrefix = env.__VF_PROJECT_AI_LOG_PREFIX__ ?? ''
|
|
78
|
+
const loggerBase = createLogger(
|
|
79
|
+
workspaceFolder,
|
|
80
|
+
ctxId,
|
|
81
|
+
input.sessionId,
|
|
82
|
+
logPrefix,
|
|
83
|
+
resolveServerLogLevel(env)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const logger: typeof loggerBase = {
|
|
87
|
+
...loggerBase,
|
|
88
|
+
info: (...args) => loggerBase.info(`[${input.hookEventName}]`, ...args),
|
|
89
|
+
warn: (...args) => loggerBase.warn(`[${input.hookEventName}]`, ...args),
|
|
90
|
+
debug: (...args) => loggerBase.debug(`[${input.hookEventName}]`, ...args),
|
|
91
|
+
error: (...args) => loggerBase.error(`[${input.hookEventName}]`, ...args)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
resetConfigCache()
|
|
95
|
+
const jsonVariables = {
|
|
96
|
+
...env,
|
|
97
|
+
WORKSPACE_FOLDER: workspaceFolder,
|
|
98
|
+
__VF_PROJECT_WORKSPACE_FOLDER__: workspaceFolder
|
|
99
|
+
}
|
|
100
|
+
const [config, userConfig] = await loadConfig({ jsonVariables })
|
|
101
|
+
const plugins = [
|
|
102
|
+
...await resolvePlugins(config?.plugins ?? []),
|
|
103
|
+
...await resolvePlugins(userConfig?.plugins ?? [])
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return callPluginHook(
|
|
107
|
+
input.hookEventName,
|
|
108
|
+
{ logger },
|
|
109
|
+
input as HookInputs[typeof input.hookEventName],
|
|
110
|
+
plugins
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const readHookInput = async () => {
|
|
115
|
+
const stdoutBuffer = await new Promise<Buffer>((resolve) => {
|
|
116
|
+
const chunks: Buffer[] = []
|
|
117
|
+
process.stdin.on('data', chunk => chunks.push(chunk))
|
|
118
|
+
process.stdin.once('end', () => resolve(Buffer.concat(chunks)))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return transformCamelKey<HookInput>(
|
|
122
|
+
JSON.parse(stdoutBuffer.toString() || '{}')
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const runHookCli = async () => {
|
|
127
|
+
try {
|
|
128
|
+
const input = await readHookInput()
|
|
129
|
+
const result = await executeHookInput(input)
|
|
130
|
+
console.log(JSON.stringify(result))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.log(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
continue: false,
|
|
135
|
+
stopReason: `run hook error: ${String(error)}`
|
|
136
|
+
} satisfies HookOutputCore)
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createWriteStream, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import type { LogLevel } from '#~/env.js'
|
|
5
|
+
|
|
4
6
|
export interface Logger {
|
|
5
7
|
stream: NodeJS.WritableStream
|
|
6
8
|
info: (...args: unknown[]) => void
|
|
@@ -9,19 +11,29 @@ export interface Logger {
|
|
|
9
11
|
error: (...args: unknown[]) => void
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
15
|
+
debug: 10,
|
|
16
|
+
info: 20,
|
|
17
|
+
warn: 30,
|
|
18
|
+
error: 40
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export const createLogger = (
|
|
13
22
|
cwd: string,
|
|
14
23
|
taskId: string,
|
|
15
24
|
sessionId: string,
|
|
16
|
-
logPrefix = ''
|
|
25
|
+
logPrefix = '',
|
|
26
|
+
level: LogLevel = 'info'
|
|
17
27
|
) => {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
const normalizedSessionId = sessionId ?? 'default'
|
|
29
|
+
const taskDir = resolve(
|
|
30
|
+
cwd,
|
|
31
|
+
`.ai${logPrefix}/logs/${taskId}`
|
|
32
|
+
)
|
|
21
33
|
|
|
22
34
|
const loggerFilePath = resolve(
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
taskDir,
|
|
36
|
+
`${normalizedSessionId}.log.md`
|
|
25
37
|
)
|
|
26
38
|
// 默认日志文件不存在时,创建一个默认的日志文件
|
|
27
39
|
if (!existsSync(loggerFilePath)) {
|
|
@@ -30,7 +42,10 @@ export const createLogger = (
|
|
|
30
42
|
const loggerStream = createWriteStream(loggerFilePath, {
|
|
31
43
|
flags: 'a'
|
|
32
44
|
})
|
|
33
|
-
const createLog = (tag: string) => (...args: unknown[]) => {
|
|
45
|
+
const createLog = (tag: string, currentLevel: LogLevel) => (...args: unknown[]) => {
|
|
46
|
+
if (LOG_LEVEL_PRIORITY[currentLevel] < LOG_LEVEL_PRIORITY[level]) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
34
49
|
const msg = args
|
|
35
50
|
.map((arg) => {
|
|
36
51
|
if (typeof arg === 'string') {
|
|
@@ -66,9 +81,9 @@ export const createLogger = (
|
|
|
66
81
|
}
|
|
67
82
|
return {
|
|
68
83
|
stream: loggerStream,
|
|
69
|
-
info: createLog('I'),
|
|
70
|
-
warn: createLog('W'),
|
|
71
|
-
debug: createLog('D'),
|
|
72
|
-
error: createLog('E')
|
|
84
|
+
info: createLog('I', 'info'),
|
|
85
|
+
warn: createLog('W', 'warn'),
|
|
86
|
+
debug: createLog('D', 'debug'),
|
|
87
|
+
error: createLog('E', 'error')
|
|
73
88
|
}
|
|
74
89
|
}
|
|
@@ -36,6 +36,25 @@ export interface Spec {
|
|
|
36
36
|
tools?: Filter
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface LocalRuleReference {
|
|
40
|
+
type?: 'local'
|
|
41
|
+
path: string
|
|
42
|
+
desc?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RemoteRuleReference {
|
|
46
|
+
type: 'remote'
|
|
47
|
+
tags?: string[]
|
|
48
|
+
desc?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RuleReference = string | LocalRuleReference | RemoteRuleReference
|
|
52
|
+
|
|
53
|
+
export interface SkillSelection {
|
|
54
|
+
type: 'include' | 'exclude'
|
|
55
|
+
list: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
export interface Entity {
|
|
40
59
|
name?: string
|
|
41
60
|
always?: boolean
|
|
@@ -43,8 +62,8 @@ export interface Entity {
|
|
|
43
62
|
tags?: string[]
|
|
44
63
|
prompt?: string
|
|
45
64
|
promptPath?: string
|
|
46
|
-
rules?:
|
|
47
|
-
skills?: string[]
|
|
65
|
+
rules?: RuleReference[]
|
|
66
|
+
skills?: string[] | SkillSelection
|
|
48
67
|
mcpServers?: Filter
|
|
49
68
|
tools?: Filter
|
|
50
69
|
}
|
|
@@ -123,6 +142,66 @@ const resolveSpecIdentifier = (path: string, explicitName?: string) => {
|
|
|
123
142
|
return resolveDocumentName(path, explicitName, ['index.md'])
|
|
124
143
|
}
|
|
125
144
|
|
|
145
|
+
const toNonEmptyStringArray = (values: unknown): string[] => {
|
|
146
|
+
if (!Array.isArray(values)) return []
|
|
147
|
+
return values
|
|
148
|
+
.filter((value): value is string => typeof value === 'string')
|
|
149
|
+
.map(value => value.trim())
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isLocalRuleReference = (value: RuleReference): value is LocalRuleReference => {
|
|
154
|
+
return (
|
|
155
|
+
value != null &&
|
|
156
|
+
typeof value === 'object' &&
|
|
157
|
+
typeof value.path === 'string' &&
|
|
158
|
+
(value.type == null || value.type === 'local')
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const isRemoteRuleReference = (value: RuleReference): value is RemoteRuleReference => {
|
|
163
|
+
return (
|
|
164
|
+
value != null &&
|
|
165
|
+
typeof value === 'object' &&
|
|
166
|
+
value.type === 'remote'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const resolveRulePattern = (pattern: string, baseDir: string) => {
|
|
171
|
+
const trimmed = pattern.trim()
|
|
172
|
+
if (!trimmed) return undefined
|
|
173
|
+
if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
|
174
|
+
return normalizePath(resolve(baseDir, trimmed))
|
|
175
|
+
}
|
|
176
|
+
return trimmed
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const createRemoteRuleDefinition = (
|
|
180
|
+
rule: RemoteRuleReference,
|
|
181
|
+
index: number
|
|
182
|
+
): Definition<Rule> => {
|
|
183
|
+
const tags = toNonEmptyStringArray(rule.tags)
|
|
184
|
+
const desc = rule.desc?.trim() || (
|
|
185
|
+
tags.length > 0
|
|
186
|
+
? `远程知识库标签:${tags.join(', ')}`
|
|
187
|
+
: '远程知识库规则引用'
|
|
188
|
+
)
|
|
189
|
+
const bodyParts = [
|
|
190
|
+
desc,
|
|
191
|
+
tags.length > 0 ? `知识库标签:${tags.join(', ')}` : undefined,
|
|
192
|
+
'该规则来自远程知识库引用,不对应本地文件。'
|
|
193
|
+
].filter((value): value is string => Boolean(value))
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
path: `remote-rule-${index + 1}.md`,
|
|
197
|
+
body: bodyParts.join('\n'),
|
|
198
|
+
attributes: {
|
|
199
|
+
name: tags.length > 0 ? `remote:${tags.join(',')}` : `remote-rule-${index + 1}`,
|
|
200
|
+
description: desc
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
126
205
|
export class DefinitionLoader {
|
|
127
206
|
private readonly cwd: string
|
|
128
207
|
|
|
@@ -142,10 +221,53 @@ export class DefinitionLoader {
|
|
|
142
221
|
})
|
|
143
222
|
}
|
|
144
223
|
|
|
145
|
-
async loadRules(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
224
|
+
async loadRules(
|
|
225
|
+
rules: RuleReference[],
|
|
226
|
+
options?: {
|
|
227
|
+
baseDir?: string
|
|
228
|
+
}
|
|
229
|
+
) {
|
|
230
|
+
const baseDir = options?.baseDir ?? this.cwd
|
|
231
|
+
const definitions: Definition<Rule>[] = []
|
|
232
|
+
|
|
233
|
+
for (const [index, rule] of rules.entries()) {
|
|
234
|
+
if (typeof rule === 'string') {
|
|
235
|
+
const pattern = resolveRulePattern(rule, baseDir)
|
|
236
|
+
if (!pattern) continue
|
|
237
|
+
definitions.push(
|
|
238
|
+
...await loadLocalDocuments<Rule>(
|
|
239
|
+
await this.scan([pattern])
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (isRemoteRuleReference(rule)) {
|
|
246
|
+
definitions.push(createRemoteRuleDefinition(rule, index))
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!isLocalRuleReference(rule)) continue
|
|
251
|
+
|
|
252
|
+
const pattern = resolveRulePattern(rule.path, baseDir)
|
|
253
|
+
if (!pattern) continue
|
|
254
|
+
|
|
255
|
+
const docs = await loadLocalDocuments<Rule>(
|
|
256
|
+
await this.scan([pattern])
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
definitions.push(
|
|
260
|
+
...docs.map((doc) => ({
|
|
261
|
+
...doc,
|
|
262
|
+
attributes: {
|
|
263
|
+
...doc.attributes,
|
|
264
|
+
description: rule.desc?.trim() || doc.attributes.description
|
|
265
|
+
}
|
|
266
|
+
}))
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return definitions
|
|
149
271
|
}
|
|
150
272
|
async loadDefaultRules(): Promise<Definition<Rule>[]> {
|
|
151
273
|
return this.loadRules([
|
package/src/utils/api.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import process from 'node:process'
|
|
2
|
-
|
|
3
|
-
import type { HookInputs, HookOutputs } from '#~/hooks/type.js'
|
|
4
|
-
|
|
5
|
-
export type HookEventName = keyof HookInputs
|
|
6
|
-
|
|
7
|
-
type HookInputPayload<K extends HookEventName> = Omit<HookInputs[K], 'hookEventName'>
|
|
8
|
-
|
|
9
|
-
const host = process.env.__VF_PROJECT_AI_SERVER_HOST__ ?? 'localhost'
|
|
10
|
-
const port = process.env.__VF_PROJECT_AI_SERVER_PORT__ ?? '8787'
|
|
11
|
-
const baseUrl = `http://${host}:${port}`
|
|
12
|
-
|
|
13
|
-
export const callHook = async <K extends HookEventName>(
|
|
14
|
-
hookEventName: K,
|
|
15
|
-
input: HookInputPayload<K>,
|
|
16
|
-
env: Record<string, unknown> = process.env
|
|
17
|
-
): Promise<HookOutputs[K]> => {
|
|
18
|
-
const response = await fetch(`${baseUrl}/api/hooks/call`, {
|
|
19
|
-
method: 'POST',
|
|
20
|
-
headers: { 'Content-Type': 'application/json' },
|
|
21
|
-
body: JSON.stringify({
|
|
22
|
-
hookEventName,
|
|
23
|
-
input,
|
|
24
|
-
env
|
|
25
|
-
})
|
|
26
|
-
})
|
|
27
|
-
if (!response.ok) {
|
|
28
|
-
const errorText = await response.text()
|
|
29
|
-
throw new Error(`Failed to call hook: ${response.statusText} - ${errorText}`)
|
|
30
|
-
}
|
|
31
|
-
return response.json()
|
|
32
|
-
}
|