@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-forge/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "imports": {
5
5
  "#~/*.js": {
6
6
  "__vibe-forge__": {
@@ -3,4 +3,4 @@ import type { Adapter } from './type'
3
3
  export * from './loader'
4
4
  export * from './type'
5
5
 
6
- export const defineAdapter = (adapter: Adapter) => adapter
6
+ export const defineAdapter = <T extends Adapter>(adapter: T): Adapter => adapter
@@ -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
@@ -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,
@@ -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 skills = await loader.loadDefaultSkills()
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
- targetSkills.push(...await loader.loadSkills(attributes.skills))
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(cwd, ctxId, sessionId, env?.LOG_PREFIX ?? '')
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__: 'debug' | 'info' | 'warn' | 'error'
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
- __VF_PROJECT_AI_SERVER_LOG_LEVEL__ as ServerEnv['__VF_PROJECT_AI_SERVER_LOG_LEVEL__'],
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
+ }
@@ -33,5 +33,7 @@ export type PluginConfig =
33
33
  | Record<string, Record<string, unknown>>
34
34
  | Partial<PluginMap>
35
35
 
36
+ export * from './call'
36
37
  export * from './loader'
38
+ export * from './runtime'
37
39
  export * from './type'
@@ -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 date = new Date()
19
- // 年-月-日-小时 作为一级目录名
20
- const dateDir = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}-${date.getHours()}`
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
- cwd,
24
- `.ai${logPrefix}/logs/${taskId}/${dateDir}/${sessionId ?? 'default'}.log.md`
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?: string[]
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(rules: string[]) {
146
- return loadLocalDocuments<Rule>(
147
- await this.scan(rules)
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
- }