@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.
- package/README.md +1 -1
- package/bin/quickforge.mjs +72 -7
- package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-D93MV5Cx.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/index-DoraECXN.js +3187 -0
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-_z7sua8J.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +14 -13
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +322 -32
- package/server/project-config.mjs +80 -31
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +46 -10
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +58 -10
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +247 -6
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +30 -0
- package/server/utils/response.mjs +8 -1
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-4mZt9XEq.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-Bq6VHkyY.js +0 -3048
- package/dist/assets/index-D7uXa1RT.css +0 -3
- package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
- package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /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, '&')
|
|
215
|
+
.replace(/"/g, '"')
|
|
216
|
+
.replace(/</g, '<')
|
|
217
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|