@shawnstack/quickforge 1.1.0 → 1.2.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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/index-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +14 -13
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +322 -32
  27. package/server/project-config.mjs +80 -31
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +46 -10
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +58 -10
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +247 -6
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +30 -0
  52. package/server/utils/response.mjs +8 -1
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,302 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { streamSimple } from '@mariozechner/pi-ai'
4
+ import { cacheDir } from './storage.mjs'
5
+
6
+ export const DEFAULT_COMPACT_KEEP_TURNS = 0
7
+ const MAX_COMPACT_KEEP_TURNS = 20
8
+ const MIN_SUMMARY_SOURCE_CHARS = 1600
9
+
10
+ export const COMPACT_SYSTEM_PROMPT = `你是 QuickForge 的“历史对话压缩器”。你的任务是把一段较长的 AI 助手对话压缩成后续模型继续工作所需的最小充分上下文。
11
+
12
+ 目标:
13
+ - 显著减少 token 占用。
14
+ - 保留后续继续完成任务必须知道的信息。
15
+ - 不添加历史中没有出现过的新事实。
16
+ - 不输出推理过程,不输出无关解释。
17
+ - 不保留闲聊、重复确认、已被否定的方案,除非它们解释了当前决策。
18
+ - 不泄露或复述密钥、Token、密码、个人隐私;如果历史中出现敏感信息,用 [REDACTED] 替代。
19
+ - 如果存在不确定信息,明确标注“待确认”。
20
+
21
+ 必须保留的信息:
22
+ 1. 用户最终目标和当前任务状态。
23
+ 2. 已确认的需求、限制条件、偏好和不做范围。
24
+ 3. 重要决策、方案取舍和原因。
25
+ 4. 已检查过的仓库路径、文件、函数、配置项、命令和结果。
26
+ 5. 已完成的代码/文件改动、验证结果、失败尝试和当前问题。
27
+ 6. 尚未完成的 TODO、风险、阻塞点、待确认问题。
28
+ 7. 最近上下文中对下一步行动有直接影响的细节。
29
+
30
+ 输出要求:
31
+ - 使用与原对话主要语言一致的语言。
32
+ - 使用 Markdown。
33
+ - 尽量简洁,但不要牺牲关键事实。
34
+ - 不要说“这是摘要”之外的寒暄。
35
+ - 不要包含完整原文,除非原文是关键命令、路径、错误信息或验收标准。
36
+ - 如果没有足够历史可压缩,输出“无足够历史需要压缩”。
37
+
38
+ 固定输出结构:
39
+
40
+ # Compact Conversation Summary
41
+
42
+ ## Current Goal
43
+ - ...
44
+
45
+ ## Confirmed Requirements
46
+ - ...
47
+
48
+ ## Important Context
49
+ - ...
50
+
51
+ ## Repository / Files / Commands
52
+ - ...
53
+
54
+ ## Completed Work
55
+ - ...
56
+
57
+ ## Validation Results
58
+ - ...
59
+
60
+ ## Pending Work
61
+ - ...
62
+
63
+ ## Risks / Open Questions
64
+ - ...
65
+
66
+ ## User Preferences
67
+ - ...`
68
+
69
+ function normalizeKeepTurns(value) {
70
+ const parsed = Number(value)
71
+ if (!Number.isFinite(parsed)) return DEFAULT_COMPACT_KEEP_TURNS
72
+ return Math.min(MAX_COMPACT_KEEP_TURNS, Math.max(0, Math.floor(parsed)))
73
+ }
74
+
75
+ function isUserMessage(message) {
76
+ return message?.role === 'user' || message?.role === 'user-with-attachments'
77
+ }
78
+
79
+ function truncateMiddle(value, maxLength) {
80
+ const text = String(value ?? '')
81
+ if (text.length <= maxLength) return text
82
+ const headLength = Math.floor(maxLength * 0.45)
83
+ const tailLength = Math.max(0, maxLength - headLength)
84
+ return `${text.slice(0, headLength)}\n\n...[truncated ${text.length - maxLength} chars]...\n\n${text.slice(-tailLength)}`
85
+ }
86
+
87
+ function safeJson(value, maxLength = 3000) {
88
+ try {
89
+ return truncateMiddle(JSON.stringify(value, null, 2), maxLength)
90
+ } catch {
91
+ return ''
92
+ }
93
+ }
94
+
95
+ export function redactSensitive(value) {
96
+ return String(value ?? '')
97
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, 'sk-[REDACTED]')
98
+ .replace(/\bAKIA[0-9A-Z]{12,}\b/g, 'AKIA[REDACTED]')
99
+ .replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, 'AIza[REDACTED]')
100
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, 'Bearer [REDACTED]')
101
+ .replace(/\b(api[_-]?key|token|password|passwd|secret)\b\s*[:=]\s*[^\s`'"<>]{8,}/gi, '$1=[REDACTED]')
102
+ }
103
+
104
+ function contentToText(content) {
105
+ if (typeof content === 'string') return content
106
+ if (!Array.isArray(content)) return ''
107
+
108
+ const parts = []
109
+ for (const block of content) {
110
+ if (!block || typeof block !== 'object') continue
111
+ if (block.type === 'text' && typeof block.text === 'string') {
112
+ parts.push(block.text)
113
+ } else if (block.type === 'image') {
114
+ parts.push(`[image omitted: ${block.mimeType || 'unknown mime'}]`)
115
+ } else if (block.type === 'toolCall') {
116
+ parts.push(`[tool call: ${block.name || 'unknown'} ${safeJson(block.arguments, 2000)}]`)
117
+ }
118
+ }
119
+ return parts.join('\n')
120
+ }
121
+
122
+ function attachmentsSummary(message) {
123
+ if (!message?.attachments) return ''
124
+ if (!Array.isArray(message.attachments)) return '[attachments omitted]'
125
+ return `[attachments omitted: ${message.attachments.length}]`
126
+ }
127
+
128
+ function formatMessageForTranscript(message, index) {
129
+ const role = message?.role || 'unknown'
130
+ const bodyParts = []
131
+ const text = contentToText(message?.content)
132
+ if (text.trim()) bodyParts.push(truncateMiddle(text.trim(), 10000))
133
+
134
+ const attachments = attachmentsSummary(message)
135
+ if (attachments) bodyParts.push(attachments)
136
+
137
+ if (role === 'toolResult' && message?.details !== undefined) {
138
+ bodyParts.push(`[tool result details]\n${safeJson(message.details, 4000)}`)
139
+ }
140
+
141
+ const body = bodyParts.join('\n\n').trim() || '[empty]'
142
+ return redactSensitive(`### Message ${index + 1}: ${role}\n${body}`)
143
+ }
144
+
145
+ function transcriptCharLimit(model) {
146
+ const contextWindow = Number(model?.contextWindow) || 128000
147
+ return Math.min(160000, Math.max(12000, Math.floor(contextWindow * 1.6)))
148
+ }
149
+
150
+ function buildTranscript(messages, model) {
151
+ const text = messages.map(formatMessageForTranscript).join('\n\n')
152
+ return truncateMiddle(text, transcriptCharLimit(model))
153
+ }
154
+
155
+ function approximateMessageChars(message) {
156
+ let total = String(message?.role || '').length
157
+ total += contentToText(message?.content).length
158
+ if (message?.attachments) total += safeJson(message.attachments, 1000).length
159
+ if (message?.toolName) total += String(message.toolName).length
160
+ if (message?.toolCallId) total += String(message.toolCallId).length
161
+ if (message?.details) total += safeJson(message.details, 1000).length
162
+ return total
163
+ }
164
+
165
+ function approximateMessagesChars(messages) {
166
+ return messages.reduce((total, message) => total + approximateMessageChars(message), 0)
167
+ }
168
+
169
+ function assistantText(message) {
170
+ const content = message?.content
171
+ if (typeof content === 'string') return content.trim()
172
+ if (!Array.isArray(content)) return ''
173
+ return content
174
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
175
+ .map((block) => block.text)
176
+ .join('\n\n')
177
+ .trim()
178
+ }
179
+
180
+ export function parseCompactArgs(rawArgs = '') {
181
+ const text = String(rawArgs || '').trim()
182
+ if (!text) return { keepTurns: DEFAULT_COMPACT_KEEP_TURNS }
183
+
184
+ const tokens = text.split(/\s+/)
185
+ const options = { keepTurns: DEFAULT_COMPACT_KEEP_TURNS }
186
+ const unsupported = []
187
+
188
+ for (const token of tokens) {
189
+ const keepMatch = token.match(/^keep=(\d+)$/i)
190
+ if (keepMatch) {
191
+ options.keepTurns = normalizeKeepTurns(keepMatch[1])
192
+ continue
193
+ }
194
+ unsupported.push(token)
195
+ }
196
+
197
+ if (unsupported.length > 0) options.unsupported = unsupported
198
+ return options
199
+ }
200
+
201
+ export function splitMessagesForCompaction(messages, options = {}) {
202
+ const keepTurns = normalizeKeepTurns(options.keepTurns)
203
+ const sourceMessages = Array.isArray(messages) ? messages : []
204
+
205
+ if (keepTurns <= 0) {
206
+ return {
207
+ keepTurns,
208
+ compactRange: sourceMessages.slice(),
209
+ recentTail: [],
210
+ tailStart: sourceMessages.length,
211
+ }
212
+ }
213
+
214
+ let seenUserTurns = 0
215
+ let tailStart = sourceMessages.length
216
+ for (let index = sourceMessages.length - 1; index >= 0; index--) {
217
+ if (!isUserMessage(sourceMessages[index])) continue
218
+ seenUserTurns += 1
219
+ if (seenUserTurns >= keepTurns) {
220
+ tailStart = index
221
+ break
222
+ }
223
+ }
224
+
225
+ if (seenUserTurns < keepTurns) tailStart = 0
226
+
227
+ return {
228
+ keepTurns,
229
+ compactRange: sourceMessages.slice(0, tailStart),
230
+ recentTail: sourceMessages.slice(tailStart),
231
+ tailStart,
232
+ }
233
+ }
234
+
235
+ export async function compactConversation({ messages, model, thinkingLevel, getApiKey, keepTurns = DEFAULT_COMPACT_KEEP_TURNS }) {
236
+ if (!model?.provider) throw new Error('No model configured for conversation compaction.')
237
+
238
+ const split = splitMessagesForCompaction(messages, { keepTurns })
239
+ const transcript = buildTranscript(split.compactRange, model)
240
+
241
+ if (split.compactRange.length === 0 || (split.compactRange.length < 4 && transcript.length < MIN_SUMMARY_SOURCE_CHARS)) {
242
+ return {
243
+ skipped: true,
244
+ reason: 'not_enough_history',
245
+ keepTurns: split.keepTurns,
246
+ compactedCount: split.compactRange.length,
247
+ keptCount: split.recentTail.length,
248
+ originalCount: Array.isArray(messages) ? messages.length : 0,
249
+ }
250
+ }
251
+
252
+ const userPrompt = `下面是即将被压缩替代的对话历史。只基于这段历史生成摘要。\n\n<conversation_to_compact>\n${transcript}\n</conversation_to_compact>`
253
+ const modelMaxTokens = Number(model.maxTokens) || 4096
254
+ const maxTokens = Math.max(512, Math.min(modelMaxTokens, 4096))
255
+ const apiKey = getApiKey ? await getApiKey(model.provider) : undefined
256
+ const stream = streamSimple(
257
+ model,
258
+ {
259
+ systemPrompt: COMPACT_SYSTEM_PROMPT,
260
+ messages: [{ role: 'user', content: userPrompt, timestamp: Date.now() }],
261
+ tools: [],
262
+ },
263
+ {
264
+ apiKey,
265
+ maxTokens,
266
+ temperature: 0.2,
267
+ reasoning: thinkingLevel === 'off' ? undefined : 'low',
268
+ maxRetryDelayMs: 60000,
269
+ },
270
+ )
271
+
272
+ const summaryMessage = await stream.result()
273
+ const summary = redactSensitive(assistantText(summaryMessage))
274
+ if (!summary) throw new Error('Conversation compaction returned an empty summary.')
275
+
276
+ return {
277
+ skipped: false,
278
+ summary,
279
+ keepTurns: split.keepTurns,
280
+ compactedCount: split.compactRange.length,
281
+ keptCount: split.recentTail.length,
282
+ originalCount: messages.length,
283
+ recentTail: split.recentTail,
284
+ originalApproxChars: approximateMessagesChars(messages),
285
+ finalApproxChars: summary.length + approximateMessagesChars(split.recentTail),
286
+ }
287
+ }
288
+
289
+ function safePathSegment(value) {
290
+ return String(value || 'session')
291
+ .replace(/[^A-Za-z0-9._-]/g, '_')
292
+ .slice(0, 120) || 'session'
293
+ }
294
+
295
+ export async function saveCompactBackup(sessionId, messages) {
296
+ const backupDir = path.join(cacheDir, 'conversations', 'compact-backups', safePathSegment(sessionId))
297
+ await fs.mkdir(backupDir, { recursive: true })
298
+ const createdAt = new Date().toISOString()
299
+ const backupFile = path.join(backupDir, `${createdAt.replace(/[:.]/g, '-')}.json`)
300
+ await fs.writeFile(backupFile, `${JSON.stringify({ sessionId, createdAt, reason: 'compact', messages }, null, 2)}\n`, 'utf8')
301
+ return backupFile
302
+ }
@@ -0,0 +1,344 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const commandsRelativeDir = '.ai/commands'
5
+ const commandNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
6
+
7
+ function normalizeCommandName(value) {
8
+ const name = String(value || '').trim().toLowerCase()
9
+ return commandNamePattern.test(name) ? name : null
10
+ }
11
+
12
+ function commandDirectory(workspaceRoot) {
13
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), commandsRelativeDir) : null
14
+ }
15
+
16
+ function parseFrontmatter(text) {
17
+ const normalized = String(text || '').replace(/^\uFEFF/, '')
18
+ const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
19
+ if (!match) return { metadata: {}, body: normalized.trim() }
20
+ return {
21
+ metadata: parseSimpleYamlMap(match[1]),
22
+ body: match[2].trim(),
23
+ }
24
+ }
25
+
26
+ function stripInlineComment(value) {
27
+ const trimmed = value.trim()
28
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
29
+ const index = trimmed.indexOf(' #')
30
+ return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
31
+ }
32
+
33
+ function parseYamlScalar(value) {
34
+ const trimmed = stripInlineComment(String(value ?? ''))
35
+ if (!trimmed) return ''
36
+ if (trimmed === 'true') return true
37
+ if (trimmed === 'false') return false
38
+ if (
39
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
40
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
41
+ ) {
42
+ return trimmed
43
+ .slice(1, -1)
44
+ .replace(/\\"/g, '"')
45
+ .replace(/\\'/g, "'")
46
+ }
47
+ return trimmed
48
+ }
49
+
50
+ function parseSimpleYamlMap(text) {
51
+ const result = {}
52
+ for (const line of String(text || '').split(/\r?\n/)) {
53
+ const trimmed = line.trim()
54
+ if (!trimmed || trimmed.startsWith('#') || /^\s/.test(line)) continue
55
+
56
+ const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
57
+ if (!match) continue
58
+ const [, key, rawValue = ''] = match
59
+ result[key] = parseYamlScalar(rawValue)
60
+ }
61
+ return result
62
+ }
63
+
64
+ function firstString(...values) {
65
+ for (const value of values) {
66
+ if (typeof value === 'string' && value.trim()) return value.trim()
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ function firstOptionalBoolean(...values) {
72
+ for (const value of values) {
73
+ if (value === true || value === false) return value
74
+ if (typeof value === 'string') {
75
+ const normalized = value.trim().toLowerCase()
76
+ if (normalized === 'true') return true
77
+ if (normalized === 'false') return false
78
+ }
79
+ }
80
+ return undefined
81
+ }
82
+
83
+ function commandFromFile(file, text) {
84
+ const parsed = parseFrontmatter(text)
85
+ if (!parsed.body) return null
86
+
87
+ const fallbackName = normalizeCommandName(path.basename(file, '.md'))
88
+ const declaredName = normalizeCommandName(parsed.metadata.name)
89
+ const name = declaredName || fallbackName
90
+ if (!name) return null
91
+
92
+ return {
93
+ name,
94
+ description: firstString(parsed.metadata.description),
95
+ argumentHint: firstString(parsed.metadata['argument-hint'], parsed.metadata.argument_hint),
96
+ allowEdit: firstOptionalBoolean(
97
+ parsed.metadata.allow_edit,
98
+ parsed.metadata['allow-edit'],
99
+ parsed.metadata.allowEdit,
100
+ ),
101
+ allowCommands: firstOptionalBoolean(
102
+ parsed.metadata.allow_commands,
103
+ parsed.metadata['allow-commands'],
104
+ parsed.metadata.allowCommands,
105
+ ),
106
+ body: parsed.body,
107
+ filePath: file,
108
+ relativePath: path.relative(path.dirname(path.dirname(file)), file).replace(/\\/g, '/'),
109
+ }
110
+ }
111
+
112
+ export function parseSlashInvocationText(text) {
113
+ const normalized = String(text || '').trim()
114
+ const match = normalized.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)(?:\s+([\s\S]*))?$/)
115
+ if (!match) return null
116
+
117
+ const name = normalizeCommandName(match[1])
118
+ if (!name) return null
119
+
120
+ return {
121
+ name,
122
+ arguments: (match[2] || '').trim(),
123
+ }
124
+ }
125
+
126
+ export function textFromUserMessage(message) {
127
+ if (typeof message === 'string') return message
128
+ if (!message || typeof message !== 'object') return ''
129
+
130
+ const content = message.content
131
+ if (typeof content === 'string') return content
132
+ if (!Array.isArray(content)) return ''
133
+
134
+ return content
135
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
136
+ .map((block) => block.text)
137
+ .join('\n')
138
+ }
139
+
140
+ export async function listProjectCommands(workspaceRoot) {
141
+ const dir = commandDirectory(workspaceRoot)
142
+ if (!dir) return []
143
+
144
+ let entries
145
+ try {
146
+ entries = await fs.readdir(dir, { withFileTypes: true })
147
+ } catch (error) {
148
+ if (error?.code === 'ENOENT') return []
149
+ throw error
150
+ }
151
+
152
+ const commands = []
153
+ for (const entry of entries) {
154
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue
155
+ const file = path.join(dir, entry.name)
156
+ try {
157
+ const command = commandFromFile(file, await fs.readFile(file, 'utf8'))
158
+ if (command) commands.push(command)
159
+ } catch (error) {
160
+ console.warn(`Failed to load custom command ${file}:`, error.message || error)
161
+ }
162
+ }
163
+
164
+ const byName = new Map()
165
+ for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
166
+ byName.set(command.name, command)
167
+ }
168
+ return [...byName.values()]
169
+ }
170
+
171
+ export async function findProjectCommand(workspaceRoot, commandName) {
172
+ const name = normalizeCommandName(commandName)
173
+ if (!name) return null
174
+ const commands = await listProjectCommands(workspaceRoot)
175
+ return commands.find((command) => command.name === name) || null
176
+ }
177
+
178
+ export async function resolveCustomCommandInvocation(message, workspaceRoot) {
179
+ const invocation = parseSlashInvocationText(textFromUserMessage(message))
180
+ if (!invocation) return null
181
+
182
+ const command = await findProjectCommand(workspaceRoot, invocation.name)
183
+ if (!command) return null
184
+
185
+ const expandedBody = command.body.replace(/\$ARGUMENTS/g, invocation.arguments)
186
+ const permissions = {
187
+ allowEdit: command.allowEdit !== false,
188
+ allowCommands: command.allowCommands !== false,
189
+ }
190
+
191
+ return {
192
+ command,
193
+ arguments: invocation.arguments,
194
+ permissions,
195
+ systemPrompt: formatCommandSystemPrompt(command, invocation.arguments, expandedBody),
196
+ }
197
+ }
198
+
199
+ function formatCommandSystemPrompt(command, args, expandedBody) {
200
+ const argsBlock = args || '(none)'
201
+ return `<custom_command_invocation name="${escapeXml(command.name)}" source="${escapeXml(command.relativePath)}">
202
+ This custom command applies only to the current user request. Follow it unless it conflicts with higher-priority system, safety, user, or project instructions.
203
+
204
+ Arguments:
205
+ ${argsBlock}
206
+
207
+ Instructions:
208
+ ${expandedBody}
209
+ </custom_command_invocation>`
210
+ }
211
+
212
+ function escapeXml(value) {
213
+ return String(value ?? '')
214
+ .replace(/&/g, '&amp;')
215
+ .replace(/"/g, '&quot;')
216
+ .replace(/</g, '&lt;')
217
+ .replace(/>/g, '&gt;')
218
+ }
219
+
220
+ export function parseInternalCommandInvocation(message) {
221
+ const text = textFromUserMessage(message).trim()
222
+ if (/^\/commands(?:\s+.*)?$/i.test(text)) return { type: 'list' }
223
+ if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
224
+ if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
225
+
226
+ const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
227
+ if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
228
+
229
+ const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
230
+ if (createMatch) {
231
+ const name = normalizeCommandName(createMatch[1])
232
+ return name ? { type: 'new', name } : { type: 'invalid-name', name: createMatch[1] }
233
+ }
234
+
235
+ return null
236
+ }
237
+
238
+ export async function handleInternalCommand(invocation, workspaceRoot) {
239
+ if (!invocation) return null
240
+
241
+ if (invocation.type === 'compact') {
242
+ return { compact: true, args: invocation.args || '' }
243
+ }
244
+
245
+ if (invocation.type === 'clear') {
246
+ return { clear: true }
247
+ }
248
+
249
+ if (invocation.type === 'invalid-clear-args') {
250
+ return 'Usage: /clear'
251
+ }
252
+
253
+ if (!workspaceRoot) {
254
+ return 'Custom commands require an active project chat.'
255
+ }
256
+
257
+ if (invocation.type === 'list') {
258
+ return formatCommandList(await listProjectCommands(workspaceRoot))
259
+ }
260
+
261
+ if (invocation.type === 'new') {
262
+ return createCommandTemplate(workspaceRoot, invocation.name)
263
+ }
264
+
265
+ if (invocation.type === 'invalid-name') {
266
+ return `Invalid command name: ${invocation.name}\n\nUse lowercase letters, numbers, and hyphens, for example: review or fix-bug.`
267
+ }
268
+
269
+ return null
270
+ }
271
+
272
+ function formatPermission(value) {
273
+ return value === false ? 'false' : 'true'
274
+ }
275
+
276
+ function formatCommandList(commands) {
277
+ if (commands.length === 0) {
278
+ return [
279
+ 'No project custom commands found.',
280
+ '',
281
+ 'Create one with:',
282
+ '```text',
283
+ '/command new review',
284
+ '```',
285
+ '',
286
+ 'Or add Markdown files under `.ai/commands/`, for example `.ai/commands/review.md`.',
287
+ ].join('\n')
288
+ }
289
+
290
+ const rows = commands.map((command) => {
291
+ const hint = command.argumentHint ? ` ${command.argumentHint}` : ''
292
+ const description = command.description ? ` — ${command.description}` : ''
293
+ const permissions = `allow_edit=${formatPermission(command.allowEdit)} allow_commands=${formatPermission(command.allowCommands)}`
294
+ return `- \`/${command.name}${hint}\`${description} (${permissions})`
295
+ })
296
+
297
+ return [
298
+ 'Project custom commands:',
299
+ '',
300
+ ...rows,
301
+ '',
302
+ 'Command files live in `.ai/commands/*.md`. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
303
+ ].join('\n')
304
+ }
305
+
306
+ async function createCommandTemplate(workspaceRoot, name) {
307
+ const commandName = normalizeCommandName(name)
308
+ if (!commandName) {
309
+ return `Invalid command name: ${name}\n\nUse lowercase letters, numbers, and hyphens.`
310
+ }
311
+
312
+ const dir = commandDirectory(workspaceRoot)
313
+ const file = path.join(dir, `${commandName}.md`)
314
+ try {
315
+ await fs.mkdir(dir, { recursive: true })
316
+ await fs.writeFile(file, commandTemplate(commandName), { encoding: 'utf8', flag: 'wx' })
317
+ } catch (error) {
318
+ if (error?.code === 'EEXIST') {
319
+ return `Custom command already exists: ${commandsRelativeDir}/${commandName}.md`
320
+ }
321
+ throw error
322
+ }
323
+
324
+ return [
325
+ `Created custom command: ${commandsRelativeDir}/${commandName}.md`,
326
+ '',
327
+ `Run it with: /${commandName} your arguments`,
328
+ ].join('\n')
329
+ }
330
+
331
+ function commandTemplate(name) {
332
+ return `---
333
+ name: ${name}
334
+ description: Describe when to use this command.
335
+ argument-hint: "[arguments]"
336
+ allow_edit: false
337
+ allow_commands: false
338
+ ---
339
+
340
+ 请根据以下参数执行任务:
341
+
342
+ $ARGUMENTS
343
+ `
344
+ }