@shawnstack/quickforge 1.4.0 → 1.5.0
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 +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-CiCXOLb5.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +283 -45
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/approval-store.mjs +13 -1
- package/server/auto-compaction.mjs +111 -112
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/context-usage.mjs +108 -0
- package/server/custom-commands.mjs +157 -28
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/mcp/registry.mjs +40 -0
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +13 -2
- package/server/routes/channels.mjs +145 -0
- package/server/routes/mcp.mjs +7 -1
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +34 -4
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/shared-conversation.mjs +1 -1
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +30 -2
- package/server/subagents.mjs +8 -6
- package/server/system-prompt.mjs +3 -2
- package/server/tools/definitions.mjs +19 -1
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
- package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
- package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
- package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
- package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
- package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
- package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
- package/dist/assets/icons-Dsc5yL3l.js +0 -1
- package/dist/assets/index-CPAWYhzz.css +0 -3
- package/dist/assets/index-YTL26wyJ.js +0 -814
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
estimateContextTokens,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
shouldCompact,
|
|
5
|
+
} from '@earendil-works/pi-agent-core'
|
|
6
|
+
|
|
7
|
+
function safeJson(value) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(value)
|
|
10
|
+
} catch {
|
|
11
|
+
return ''
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeMessageForTokenEstimate(message) {
|
|
16
|
+
if (!message || typeof message !== 'object') return message
|
|
17
|
+
if (message.role !== 'user-with-attachments') return message
|
|
18
|
+
|
|
19
|
+
const content = typeof message.content === 'string'
|
|
20
|
+
? [{ type: 'text', text: message.content }]
|
|
21
|
+
: Array.isArray(message.content)
|
|
22
|
+
? [...message.content]
|
|
23
|
+
: []
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(message.attachments)) {
|
|
26
|
+
for (const attachment of message.attachments) {
|
|
27
|
+
if (attachment?.type === 'image' && attachment.content) {
|
|
28
|
+
content.push({ type: 'image', data: attachment.content, mimeType: attachment.mimeType })
|
|
29
|
+
} else if (attachment?.type === 'document' && attachment.extractedText) {
|
|
30
|
+
content.push({ type: 'text', text: `\n\n[Document: ${attachment.fileName || 'Untitled'}]\n${attachment.extractedText}` })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ...message, role: 'user', content }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeMessagesForTokenEstimate(messages) {
|
|
39
|
+
return (Array.isArray(messages) ? messages : []).map(normalizeMessageForTokenEstimate)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textTokens(text) {
|
|
43
|
+
if (!text) return 0
|
|
44
|
+
return estimateTokens({ role: 'user', content: String(text), timestamp: 0 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function localMessagesTokens(messages) {
|
|
48
|
+
return normalizeMessagesForTokenEstimate(messages).reduce((total, message) => total + estimateTokens(message), 0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
52
|
+
const contextWindow = Number(model?.contextWindow) || 0
|
|
53
|
+
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
54
|
+
const normalizedMessages = normalizeMessagesForTokenEstimate(messages)
|
|
55
|
+
const coreEstimate = estimateContextTokens(normalizedMessages)
|
|
56
|
+
const systemPromptTokens = textTokens(systemPrompt)
|
|
57
|
+
const toolsTokens = textTokens(safeJson(tools))
|
|
58
|
+
const messagesTokens = localMessagesTokens(normalizedMessages)
|
|
59
|
+
const estimatedInputTokens = systemPromptTokens + messagesTokens + toolsTokens
|
|
60
|
+
const providerBasedContextTokens = Math.max(0, Number(coreEstimate.usageTokens) || 0) > 0
|
|
61
|
+
? Math.max(0, Number(coreEstimate.tokens) || 0)
|
|
62
|
+
: 0
|
|
63
|
+
const inputTokens = providerBasedContextTokens > 0
|
|
64
|
+
? Math.max(estimatedInputTokens, providerBasedContextTokens)
|
|
65
|
+
: estimatedInputTokens
|
|
66
|
+
const totalTokens = inputTokens + reservedOutputTokens
|
|
67
|
+
const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
|
|
68
|
+
const inputTokenSource = providerBasedContextTokens > 0
|
|
69
|
+
? providerBasedContextTokens >= estimatedInputTokens ? 'provider' : 'mixed'
|
|
70
|
+
: 'estimated'
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
inputTokens,
|
|
74
|
+
estimatedInputTokens,
|
|
75
|
+
knownInputTokens: providerBasedContextTokens,
|
|
76
|
+
providerContextTokens: providerBasedContextTokens,
|
|
77
|
+
inputTokenSource,
|
|
78
|
+
reservedOutputTokens,
|
|
79
|
+
totalTokens,
|
|
80
|
+
contextWindow,
|
|
81
|
+
percent,
|
|
82
|
+
breakdown: {
|
|
83
|
+
systemPromptTokens,
|
|
84
|
+
messagesTokens,
|
|
85
|
+
toolsTokens,
|
|
86
|
+
reservedOutputTokens,
|
|
87
|
+
providerUsageTokens: Math.max(0, Number(coreEstimate.usageTokens) || 0),
|
|
88
|
+
trailingTokens: Math.max(0, Number(coreEstimate.trailingTokens) || 0),
|
|
89
|
+
lastUsageIndex: coreEstimate.lastUsageIndex,
|
|
90
|
+
localEstimatedContextTokens: estimatedInputTokens,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function shouldCompactContextByPercent(usage, thresholdPercent) {
|
|
96
|
+
const contextWindow = Number(usage?.contextWindow) || 0
|
|
97
|
+
const totalTokens = Math.max(0, Number(usage?.totalTokens) || 0)
|
|
98
|
+
const threshold = Math.min(100, Math.max(0, Number(thresholdPercent) || 0))
|
|
99
|
+
if (!contextWindow) return false
|
|
100
|
+
|
|
101
|
+
const thresholdTokens = Math.ceil(contextWindow * threshold / 100)
|
|
102
|
+
const reserveTokens = Math.min(contextWindow, Math.max(0, contextWindow - thresholdTokens + 1))
|
|
103
|
+
return shouldCompact(totalTokens, contextWindow, {
|
|
104
|
+
enabled: true,
|
|
105
|
+
reserveTokens,
|
|
106
|
+
keepRecentTokens: 0,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -1,11 +1,64 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { getEnabledPluginCommandSources } from './plugins/registry.mjs'
|
|
4
|
+
import { userCommandsDir } from './storage.mjs'
|
|
4
5
|
|
|
5
6
|
const commandsRelativeDirs = ['.claude/commands', '.opencode/commands', '.ai/commands']
|
|
6
7
|
const commandsRelativeDir = '.ai/commands'
|
|
7
8
|
const commandNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Centralized metadata for all built-in slash commands.
|
|
12
|
+
* This is the single source of truth used by /help output.
|
|
13
|
+
* Front-end i18n descriptions (i18n.ts) should be kept in sync with the
|
|
14
|
+
* `description` values here.
|
|
15
|
+
*/
|
|
16
|
+
const builtinCommandCatalog = [
|
|
17
|
+
{
|
|
18
|
+
name: 'plan',
|
|
19
|
+
description: 'Create a plan first; this turn cannot edit files or run commands.',
|
|
20
|
+
argumentHint: '[task]',
|
|
21
|
+
permissionNote: 'read-only',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'review',
|
|
25
|
+
description: 'Review pending code changes before commit; this turn cannot edit files.',
|
|
26
|
+
argumentHint: '[scope]',
|
|
27
|
+
permissionNote: 'no edits',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'summary',
|
|
31
|
+
description: 'Create a new chat with this conversation summarized to reduce context usage.',
|
|
32
|
+
argumentHint: '',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'compact',
|
|
36
|
+
description: 'Compact this conversation context in place using the same rolling summary as auto-compaction.',
|
|
37
|
+
argumentHint: '',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'clear',
|
|
41
|
+
description: 'Clear the current chat history and context without calling the model.',
|
|
42
|
+
argumentHint: '',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'commands',
|
|
46
|
+
description: 'List custom commands (project, user-level, and plugin).',
|
|
47
|
+
argumentHint: '',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'command new',
|
|
51
|
+
description: 'Create a project custom command template.',
|
|
52
|
+
argumentHint: '<name>',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'help',
|
|
56
|
+
aliases: ['?'],
|
|
57
|
+
description: 'Show available commands and their usage.',
|
|
58
|
+
argumentHint: '',
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
9
62
|
function normalizeCommandName(value) {
|
|
10
63
|
const name = String(value || '').trim().toLowerCase()
|
|
11
64
|
return commandNamePattern.test(name) ? name : null
|
|
@@ -103,7 +156,7 @@ function firstOptionalBoolean(...values) {
|
|
|
103
156
|
return undefined
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
function commandFromFile(file, text, options = {}) {
|
|
159
|
+
export function commandFromFile(file, text, options = {}) {
|
|
107
160
|
const parsed = parseFrontmatter(text)
|
|
108
161
|
if (!parsed.body) return null
|
|
109
162
|
|
|
@@ -229,13 +282,27 @@ async function listPluginCommands(workspaceRoot) {
|
|
|
229
282
|
return commands
|
|
230
283
|
}
|
|
231
284
|
|
|
285
|
+
async function listUserCommands() {
|
|
286
|
+
return listCommandsFromDirectory(userCommandsDir, {
|
|
287
|
+
source: 'user',
|
|
288
|
+
relativeRoot: '~/.quickforge/commands',
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
232
292
|
export async function listProjectCommands(workspaceRoot, commandDir) {
|
|
233
293
|
const byName = new Map()
|
|
234
294
|
|
|
295
|
+
// 1. Plugin commands (lowest priority)
|
|
235
296
|
for (const command of (await listPluginCommands(workspaceRoot)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
236
297
|
byName.set(command.name, command)
|
|
237
298
|
}
|
|
238
299
|
|
|
300
|
+
// 2. User-level commands (~/.quickforge/commands/) — override plugins, overridden by project
|
|
301
|
+
for (const command of (await listUserCommands()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
302
|
+
byName.set(command.name, command)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 3. Project directories: .claude → .opencode → .ai → configured (highest priority)
|
|
239
306
|
for (const dir of commandDirectories(workspaceRoot, commandDir)) {
|
|
240
307
|
const commands = await listCommandsFromDirectory(dir)
|
|
241
308
|
for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -297,6 +364,7 @@ function escapeXml(value) {
|
|
|
297
364
|
|
|
298
365
|
export function parseInternalCommandInvocation(message) {
|
|
299
366
|
const text = textFromUserMessage(message).trim()
|
|
367
|
+
if (/^\/(?:help|\?)(?:\s+.*)?$/i.test(text)) return { type: 'help' }
|
|
300
368
|
if (/^\/commands(?:\s+.*)?$/i.test(text)) return { type: 'list' }
|
|
301
369
|
if (/^\/clear\s*$/i.test(text)) return { type: 'clear' }
|
|
302
370
|
if (/^\/clear(?:\s+[\s\S]+)$/i.test(text)) return { type: 'invalid-clear-args' }
|
|
@@ -310,6 +378,9 @@ export function parseInternalCommandInvocation(message) {
|
|
|
310
378
|
const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
|
|
311
379
|
if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
|
|
312
380
|
|
|
381
|
+
const summaryMatch = text.match(/^\/summary(?:\s+([\s\S]*))?$/i)
|
|
382
|
+
if (summaryMatch) return { type: 'summary', args: (summaryMatch[1] || '').trim() }
|
|
383
|
+
|
|
313
384
|
const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
|
|
314
385
|
if (createMatch) {
|
|
315
386
|
const name = normalizeCommandName(createMatch[1])
|
|
@@ -326,6 +397,10 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
326
397
|
return { compact: true, args: invocation.args || '' }
|
|
327
398
|
}
|
|
328
399
|
|
|
400
|
+
if (invocation.type === 'summary') {
|
|
401
|
+
return { summary: true, args: invocation.args || '' }
|
|
402
|
+
}
|
|
403
|
+
|
|
329
404
|
if (invocation.type === 'plan') {
|
|
330
405
|
if (!invocation.args) return 'Usage: /plan <task>'
|
|
331
406
|
return { plan: true, args: invocation.args }
|
|
@@ -344,14 +419,19 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
344
419
|
return 'Usage: /clear'
|
|
345
420
|
}
|
|
346
421
|
|
|
347
|
-
if (
|
|
348
|
-
return
|
|
422
|
+
if (invocation.type === 'help') {
|
|
423
|
+
return formatHelpText(await listProjectCommands(workspaceRoot, commandDir))
|
|
349
424
|
}
|
|
350
425
|
|
|
426
|
+
// /commands and /help work without a project (user-level commands are global)
|
|
351
427
|
if (invocation.type === 'list') {
|
|
352
428
|
return formatCommandList(await listProjectCommands(workspaceRoot, commandDir))
|
|
353
429
|
}
|
|
354
430
|
|
|
431
|
+
if (!workspaceRoot) {
|
|
432
|
+
return 'Custom commands require an active project chat.'
|
|
433
|
+
}
|
|
434
|
+
|
|
355
435
|
if (invocation.type === 'new') {
|
|
356
436
|
return createCommandTemplate(workspaceRoot, invocation.name)
|
|
357
437
|
}
|
|
@@ -367,17 +447,17 @@ function formatPermission(value) {
|
|
|
367
447
|
return value === false ? 'false' : 'true'
|
|
368
448
|
}
|
|
369
449
|
|
|
370
|
-
function formatCommandList(commands) {
|
|
450
|
+
export function formatCommandList(commands) {
|
|
371
451
|
if (commands.length === 0) {
|
|
372
452
|
return [
|
|
373
|
-
'No
|
|
453
|
+
'No custom commands found.',
|
|
374
454
|
'',
|
|
375
455
|
'Create one with:',
|
|
376
456
|
'```text',
|
|
377
457
|
'/command new review',
|
|
378
458
|
'```',
|
|
379
459
|
'',
|
|
380
|
-
'Or add Markdown files under `.claude/commands/`, `.opencode/commands/`,
|
|
460
|
+
'Or add Markdown files under `~/.quickforge/commands/` (user-level), or `.claude/commands/`, `.opencode/commands/`, `.ai/commands/` (project-level), for example `.ai/commands/review.md`.',
|
|
381
461
|
].join('\n')
|
|
382
462
|
}
|
|
383
463
|
|
|
@@ -389,37 +469,59 @@ function formatCommandList(commands) {
|
|
|
389
469
|
})
|
|
390
470
|
|
|
391
471
|
return [
|
|
392
|
-
'
|
|
472
|
+
'Custom commands:',
|
|
393
473
|
'',
|
|
394
474
|
...rows,
|
|
395
475
|
'',
|
|
396
|
-
'Command files live in `.claude/commands/*.md`, `.opencode/commands/*.md`, `.ai/commands/*.md`, or configured directories. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
|
|
476
|
+
'Command files live in `~/.quickforge/commands/*.md` (user-level), `.claude/commands/*.md`, `.opencode/commands/*.md`, `.ai/commands/*.md`, or configured directories. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
|
|
397
477
|
].join('\n')
|
|
398
478
|
}
|
|
399
479
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
480
|
+
function formatBuiltinCommandRows() {
|
|
481
|
+
return builtinCommandCatalog.map((cmd) => {
|
|
482
|
+
const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : ''
|
|
483
|
+
const aliases = cmd.aliases?.length
|
|
484
|
+
? ` (alias: ${cmd.aliases.map((alias) => `/${alias}`).join(', ')})`
|
|
485
|
+
: ''
|
|
486
|
+
const perm = cmd.permissionNote ? ` \[${cmd.permissionNote}\]` : ''
|
|
487
|
+
return `- \`/${cmd.name}${hint}\`${aliases} — ${cmd.description}${perm}`
|
|
488
|
+
})
|
|
489
|
+
}
|
|
405
490
|
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
491
|
+
export function formatHelpText(customCommands = []) {
|
|
492
|
+
const sections = [
|
|
493
|
+
'QuickForge command reference',
|
|
494
|
+
'',
|
|
495
|
+
'Built-in commands:',
|
|
496
|
+
'',
|
|
497
|
+
...formatBuiltinCommandRows(),
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
if (customCommands.length > 0) {
|
|
501
|
+
sections.push('', formatCommandList(customCommands))
|
|
502
|
+
} else {
|
|
503
|
+
sections.push(
|
|
504
|
+
'',
|
|
505
|
+
'No custom commands found. Add Markdown files under `~/.quickforge/commands/` (user-level) or `.claude/commands/`, `.opencode/commands/`, `.ai/commands/` (project-level).',
|
|
506
|
+
)
|
|
416
507
|
}
|
|
417
508
|
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
509
|
+
return sections.join('\n')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function createCommandTemplate(workspaceRoot, name) {
|
|
513
|
+
const result = await createCommandFile(workspaceRoot, name)
|
|
514
|
+
if (result.ok) {
|
|
515
|
+
return [
|
|
516
|
+
`Created custom command: ${result.relativePath}`,
|
|
517
|
+
'',
|
|
518
|
+
`Run it with: /${result.name} your arguments`,
|
|
519
|
+
].join('\n')
|
|
520
|
+
}
|
|
521
|
+
if (result.reason === 'exists') {
|
|
522
|
+
return `Custom command already exists: ${commandsRelativeDir}/${result.name}.md`
|
|
523
|
+
}
|
|
524
|
+
return `Invalid command name: ${name}\n\nUse lowercase letters, numbers, and hyphens.`
|
|
423
525
|
}
|
|
424
526
|
|
|
425
527
|
function commandTemplate(name) {
|
|
@@ -436,3 +538,30 @@ allow_commands: false
|
|
|
436
538
|
$ARGUMENTS
|
|
437
539
|
`
|
|
438
540
|
}
|
|
541
|
+
|
|
542
|
+
export async function createCommandFile(workspaceRoot, name) {
|
|
543
|
+
const commandName = normalizeCommandName(name)
|
|
544
|
+
if (!commandName) {
|
|
545
|
+
return { ok: false, reason: 'invalid' }
|
|
546
|
+
}
|
|
547
|
+
const dir = commandDirectory(workspaceRoot)
|
|
548
|
+
if (!dir) {
|
|
549
|
+
return { ok: false, reason: 'no-project' }
|
|
550
|
+
}
|
|
551
|
+
const file = path.join(dir, `${commandName}.md`)
|
|
552
|
+
try {
|
|
553
|
+
await fs.mkdir(dir, { recursive: true })
|
|
554
|
+
await fs.writeFile(file, commandTemplate(commandName), { encoding: 'utf8', flag: 'wx' })
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error?.code === 'EEXIST') {
|
|
557
|
+
return { ok: false, reason: 'exists', name: commandName, filePath: file }
|
|
558
|
+
}
|
|
559
|
+
throw error
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
name: commandName,
|
|
564
|
+
filePath: file,
|
|
565
|
+
relativePath: `${commandsRelativeDir}/${commandName}.md`,
|
|
566
|
+
}
|
|
567
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
function leadingIndent(line) {
|
|
2
|
+
const match = String(line || '').match(/^\s*/)
|
|
3
|
+
return match ? match[0].length : 0
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function stripInlineComment(value) {
|
|
7
|
+
const trimmed = String(value ?? '').trim()
|
|
8
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
|
|
9
|
+
const index = trimmed.indexOf(' #')
|
|
10
|
+
return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseYamlScalar(value, options = {}) {
|
|
14
|
+
const trimmed = stripInlineComment(value)
|
|
15
|
+
if (!trimmed) return ''
|
|
16
|
+
if (options.booleans !== false) {
|
|
17
|
+
const normalized = trimmed.toLowerCase()
|
|
18
|
+
if (normalized === 'true') return true
|
|
19
|
+
if (normalized === 'false') return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
24
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
return trimmed
|
|
27
|
+
.slice(1, -1)
|
|
28
|
+
.replace(/\\"/g, '"')
|
|
29
|
+
.replace(/\\'/g, "'")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return trimmed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectIndentedBlock(lines, startIndex, parentIndent) {
|
|
36
|
+
const block = []
|
|
37
|
+
let index = startIndex
|
|
38
|
+
while (index < lines.length) {
|
|
39
|
+
const line = lines[index]
|
|
40
|
+
if (!line.trim()) {
|
|
41
|
+
block.push(line)
|
|
42
|
+
index++
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (leadingIndent(line) <= parentIndent) break
|
|
46
|
+
block.push(line)
|
|
47
|
+
index++
|
|
48
|
+
}
|
|
49
|
+
return { block, nextIndex: index }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBlockScalar(lines, style) {
|
|
53
|
+
const nonEmpty = lines.filter((line) => line.trim())
|
|
54
|
+
const minIndent = nonEmpty.length
|
|
55
|
+
? Math.min(...nonEmpty.map((line) => leadingIndent(line)))
|
|
56
|
+
: 0
|
|
57
|
+
const unindented = lines.map((line) => line.slice(Math.min(minIndent, line.length)))
|
|
58
|
+
return style === '>'
|
|
59
|
+
? unindented.join(' ').replace(/\s+/g, ' ').trim()
|
|
60
|
+
: unindented.join('\n').trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseSimpleYamlMap(text, options = {}) {
|
|
64
|
+
const result = {}
|
|
65
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
66
|
+
let index = 0
|
|
67
|
+
|
|
68
|
+
while (index < lines.length) {
|
|
69
|
+
const line = lines[index]
|
|
70
|
+
const trimmed = line.trim()
|
|
71
|
+
if (!trimmed || trimmed.startsWith('#') || leadingIndent(line) > 0) {
|
|
72
|
+
index++
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
77
|
+
if (!match) {
|
|
78
|
+
index++
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [, key, rawValue = ''] = match
|
|
83
|
+
const value = rawValue.trim()
|
|
84
|
+
|
|
85
|
+
if (value === '|' || value === '>') {
|
|
86
|
+
const { block, nextIndex } = collectIndentedBlock(lines, index + 1, 0)
|
|
87
|
+
result[key] = parseBlockScalar(block, value)
|
|
88
|
+
index = nextIndex
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (value) {
|
|
93
|
+
result[key] = parseYamlScalar(value, options)
|
|
94
|
+
index++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nested = {}
|
|
99
|
+
let nestedIndex = index + 1
|
|
100
|
+
while (nestedIndex < lines.length) {
|
|
101
|
+
const nestedLine = lines[nestedIndex]
|
|
102
|
+
const nestedTrimmed = nestedLine.trim()
|
|
103
|
+
if (!nestedTrimmed || nestedTrimmed.startsWith('#')) {
|
|
104
|
+
nestedIndex++
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const indent = leadingIndent(nestedLine)
|
|
109
|
+
if (indent <= 0) break
|
|
110
|
+
|
|
111
|
+
const nestedMatch = nestedLine.slice(indent).match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
112
|
+
if (!nestedMatch) {
|
|
113
|
+
nestedIndex++
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [, nestedKey, nestedRawValue = ''] = nestedMatch
|
|
118
|
+
nested[nestedKey] = parseYamlScalar(nestedRawValue.trim(), options)
|
|
119
|
+
nestedIndex++
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result[key] = Object.keys(nested).length ? nested : ''
|
|
123
|
+
index = Object.keys(nested).length ? nestedIndex : index + 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseFrontmatter(text, options = {}) {
|
|
130
|
+
const normalized = String(text || '').replace(/^\uFEFF/, '')
|
|
131
|
+
const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
|
|
132
|
+
if (!match) {
|
|
133
|
+
return options.requireFrontmatter ? null : { metadata: {}, frontmatter: '', body: normalized.trim() }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
metadata: parseSimpleYamlMap(match[1], options),
|
|
137
|
+
frontmatter: match[1],
|
|
138
|
+
body: match[2].trim(),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function firstString(...values) {
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function firstOptionalBoolean(...values) {
|
|
150
|
+
for (const value of values) {
|
|
151
|
+
if (value === true || value === false) return value
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const normalized = value.trim().toLowerCase()
|
|
154
|
+
if (normalized === 'true') return true
|
|
155
|
+
if (normalized === 'false') return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function splitDelimitedList(value) {
|
|
162
|
+
if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean)
|
|
163
|
+
return String(value || '')
|
|
164
|
+
.split(',')
|
|
165
|
+
.map((item) => item.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
}
|
package/server/index.mjs
CHANGED
|
@@ -27,13 +27,17 @@ import { handleMcpApi } from './routes/mcp.mjs'
|
|
|
27
27
|
import { handlePluginsApi } from './routes/plugins.mjs'
|
|
28
28
|
import { handleWorkspaceApi, handleGitApi } from './routes/workspace.mjs'
|
|
29
29
|
import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
|
|
30
|
+
import { handleChannelsApi } from './routes/channels.mjs'
|
|
31
|
+
import { handleModelsApi } from './routes/models.mjs'
|
|
30
32
|
import { serveStatic } from './routes/static.mjs'
|
|
31
33
|
import { logger, flushLogger } from './utils/logger.mjs'
|
|
34
|
+
import { getPackageInfo, checkForUpdates, installLatestVersion } from './utils/package-update.mjs'
|
|
32
35
|
import { installAiHttpLogger } from './ai-http-logger.mjs'
|
|
33
36
|
import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
|
|
34
37
|
import { parseCookies } from './share-store.mjs'
|
|
35
38
|
import { lanAccessCookieName, verifyLanAccessToken } from './lan-access-store.mjs'
|
|
36
39
|
import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
|
|
40
|
+
import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
|
|
37
41
|
import { shutdownMcpConnections } from './mcp/registry.mjs'
|
|
38
42
|
import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
|
|
39
43
|
|
|
@@ -54,8 +58,9 @@ if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_R
|
|
|
54
58
|
const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
|
|
55
59
|
const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
|
|
56
60
|
let restartInProgress = false
|
|
61
|
+
let updateInProgress = false
|
|
57
62
|
|
|
58
|
-
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR ||
|
|
63
|
+
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
|
|
59
64
|
installAiHttpLogger()
|
|
60
65
|
|
|
61
66
|
function getRestartSupport() {
|
|
@@ -139,6 +144,7 @@ async function performRestart() {
|
|
|
139
144
|
stopVite()
|
|
140
145
|
await shutdownAgentManager()
|
|
141
146
|
await shutdownMcpConnections()
|
|
147
|
+
await shutdownChannels()
|
|
142
148
|
shutdownTerminalSessions()
|
|
143
149
|
await closeHttpServer()
|
|
144
150
|
process.exit(0)
|
|
@@ -169,6 +175,29 @@ async function requestRestart() {
|
|
|
169
175
|
return { ok: true, restarting: true, bootId }
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
async function updateQuickForge() {
|
|
179
|
+
if (updateInProgress) {
|
|
180
|
+
const error = new Error('Update already in progress')
|
|
181
|
+
error.statusCode = 423
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
updateInProgress = true
|
|
186
|
+
try {
|
|
187
|
+
const update = await checkForUpdates(projectRoot)
|
|
188
|
+
if (!update.updateAvailable) {
|
|
189
|
+
return { ...update, ok: true, updated: false }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logger.info(`Updating QuickForge from ${update.currentVersion} to ${update.latestVersion}.`)
|
|
193
|
+
await installLatestVersion(update.name, { cwd: projectRoot })
|
|
194
|
+
logger.info('QuickForge update completed.')
|
|
195
|
+
return { ...update, ok: true, updated: true }
|
|
196
|
+
} finally {
|
|
197
|
+
updateInProgress = false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// --- Route dispatching ---
|
|
173
202
|
async function handleApi(req, res, url) {
|
|
174
203
|
const pathname = url.pathname
|
|
@@ -216,6 +245,12 @@ async function handleApi(req, res, url) {
|
|
|
216
245
|
return
|
|
217
246
|
}
|
|
218
247
|
|
|
248
|
+
// Custom model management (connection test)
|
|
249
|
+
if (pathname === '/api/models/test-connection') {
|
|
250
|
+
await handleModelsApi(req, res, url)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
// Skills
|
|
220
255
|
if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
|
|
221
256
|
await handleSkillsApi(req, res, url)
|
|
@@ -228,6 +263,14 @@ async function handleApi(req, res, url) {
|
|
|
228
263
|
return
|
|
229
264
|
}
|
|
230
265
|
|
|
266
|
+
// Channels
|
|
267
|
+
if (pathname === '/api/channels' || pathname.startsWith('/api/channels/')) {
|
|
268
|
+
await handleChannelsApi(req, res, url, {
|
|
269
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
270
|
+
})
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
231
274
|
// Plugins
|
|
232
275
|
if (pathname === '/api/plugins' || pathname.startsWith('/api/plugins/')) {
|
|
233
276
|
await handlePluginsApi(req, res, url)
|
|
@@ -241,7 +284,7 @@ async function handleApi(req, res, url) {
|
|
|
241
284
|
}
|
|
242
285
|
|
|
243
286
|
// Project workspace inspector routes
|
|
244
|
-
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
|
|
287
|
+
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname.startsWith('/api/workspace/preview/')) {
|
|
245
288
|
await handleWorkspaceApi(req, res, url)
|
|
246
289
|
return
|
|
247
290
|
}
|
|
@@ -288,10 +331,14 @@ async function handleApi(req, res, url) {
|
|
|
288
331
|
}
|
|
289
332
|
|
|
290
333
|
// System routes
|
|
291
|
-
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell') {
|
|
334
|
+
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell' || pathname === '/api/system/about' || pathname === '/api/system/update/check' || pathname === '/api/system/update') {
|
|
292
335
|
await handleSystemApi(req, res, url, {
|
|
293
336
|
getSystemStatus,
|
|
294
337
|
requestRestart,
|
|
338
|
+
getPackageInfo: () => getPackageInfo(projectRoot),
|
|
339
|
+
checkForUpdates: () => checkForUpdates(projectRoot),
|
|
340
|
+
updateQuickForge,
|
|
341
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
295
342
|
getTerminalShellSetting: readTerminalShellSetting,
|
|
296
343
|
updateTerminalShellSetting,
|
|
297
344
|
getTerminalShellConfig: readTerminalShellConfig,
|
|
@@ -532,6 +579,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
532
579
|
})
|
|
533
580
|
|
|
534
581
|
await ensureStorage()
|
|
582
|
+
initializeChannels({ projectRoot })
|
|
535
583
|
await resetStaleTaskStatuses()
|
|
536
584
|
await initializeActiveProject()
|
|
537
585
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
@@ -577,6 +625,7 @@ async function gracefulShutdown(signal) {
|
|
|
577
625
|
stopVite()
|
|
578
626
|
await shutdownAgentManager()
|
|
579
627
|
await shutdownMcpConnections()
|
|
628
|
+
await shutdownChannels()
|
|
580
629
|
shutdownTerminalSessions()
|
|
581
630
|
flushLogger()
|
|
582
631
|
process.exit(0)
|