@shawnstack/quickforge 1.3.17 → 1.3.18

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # 速构 QuickForge
2
2
 
3
3
  <p align="center">
4
- <img alt="Version" src="https://img.shields.io/badge/version-1.3.17-blue" />
4
+ <img alt="Version" src="https://img.shields.io/badge/version-1.3.18-blue" />
5
5
  <img alt="License" src="https://img.shields.io/badge/license-MIT-green" />
6
6
  <img alt="Node" src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" />
7
7
  <img alt="React" src="https://img.shields.io/badge/react-19-61DAFB?logo=react" />
@@ -65,7 +65,7 @@ QuickForge 的工具能力很直接,因此也需要谨慎使用:
65
65
  #### 从 npm 安装
66
66
 
67
67
  ```bash
68
- npm install -g @shawnstack/quickforge@1.3.17
68
+ npm install -g @shawnstack/quickforge@1.3.18
69
69
  qf
70
70
 
71
71
  # CLI 工具
@@ -79,17 +79,17 @@ qf update
79
79
  当前版本的离线包:
80
80
 
81
81
  ```text
82
- package-offline/shawnstack-quickforge-1.3.17.tgz
82
+ package-offline/shawnstack-quickforge-1.3.18.tgz
83
83
  ```
84
84
 
85
85
  在安装了 Node.js 20+ 和 npm 的机器上执行:
86
86
 
87
87
  ```bash
88
- npm install -g ./package-offline/shawnstack-quickforge-1.3.17.tgz
88
+ npm install -g ./package-offline/shawnstack-quickforge-1.3.18.tgz
89
89
  qf
90
90
  ```
91
91
 
92
- 该包由 `v1.3.17` 标签生成,包含离线安装所需的运行时依赖。
92
+ 该包由 `v1.3.18` 标签生成,包含离线安装所需的运行时依赖。
93
93
 
94
94
  ### 本地开发
95
95
 
@@ -228,7 +228,7 @@ QuickForge intentionally exposes powerful local capabilities, so the boundaries
228
228
  #### npm
229
229
 
230
230
  ```bash
231
- npm install -g @shawnstack/quickforge@1.3.17
231
+ npm install -g @shawnstack/quickforge@1.3.18
232
232
  qf
233
233
 
234
234
  # CLI utilities
@@ -239,20 +239,20 @@ qf update
239
239
 
240
240
  #### Offline tarball
241
241
 
242
- The offline release package for `v1.3.17` is:
242
+ The offline release package for `v1.3.18` is:
243
243
 
244
244
  ```text
245
- package-offline/shawnstack-quickforge-1.3.17.tgz
245
+ package-offline/shawnstack-quickforge-1.3.18.tgz
246
246
  ```
247
247
 
248
248
  Install it on a machine with Node.js 20+ and npm:
249
249
 
250
250
  ```bash
251
- npm install -g ./package-offline/shawnstack-quickforge-1.3.17.tgz
251
+ npm install -g ./package-offline/shawnstack-quickforge-1.3.18.tgz
252
252
  qf
253
253
  ```
254
254
 
255
- The package was generated from tag `v1.3.17` and includes bundled runtime dependencies for offline installation.
255
+ The package was generated from tag `v1.3.18` and includes bundled runtime dependencies for offline installation.
256
256
 
257
257
  ### Local development
258
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.3.17",
3
+ "version": "1.3.18",
4
4
  "description": "AI chat application with YOLO-mode local workspace tools. React + Vite + Tailwind CSS frontend, local Node.js storage server.",
5
5
  "keywords": [
6
6
  "ai",
@@ -68,6 +68,8 @@ export const workspaceTools = [
68
68
  description: 'Run a shell command in the project bound to this chat. Use this for lint, build, tests, git status, and diagnostics.',
69
69
  parameters: Type.Object({
70
70
  command: Type.String({ description: 'Command to execute in the workspace.' }),
71
+ timeoutMs: Type.Optional(Type.Number({ description: 'Command timeout in milliseconds. Defaults to 30 minutes and is clamped to the supported range.', default: 1800000 })),
72
+ description: Type.Optional(Type.String({ description: 'Short explanation of why this command is being run.' })),
71
73
  }),
72
74
  executionMode: 'sequential',
73
75
  },
@@ -1,8 +1,9 @@
1
- import { promises as fs } from 'node:fs'
1
+ import { createWriteStream, promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
4
  import { createRequire } from 'node:module'
5
5
  import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
6
+ import { logsDir } from '../storage.mjs'
6
7
  import { createTextDiff } from '../utils/text-diff.mjs'
7
8
  import {
8
9
  formatSkillActivation,
@@ -476,63 +477,63 @@ export async function toolWriteFile(params, context) {
476
477
  }
477
478
  }
478
479
 
479
- // --- edit_file ---
480
- function countOccurrences(text, needle) {
481
- if (!needle) return 0
482
- let count = 0
483
- let index = 0
484
- while ((index = text.indexOf(needle, index)) !== -1) {
485
- count++
486
- index += needle.length
487
- }
488
- return count
489
- }
490
-
491
- function detectLineEnding(text) {
492
- return text.includes('\r\n') ? '\r\n' : '\n'
493
- }
494
-
495
- function normalizeLineEndings(text) {
496
- return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
497
- }
498
-
499
- function convertToLineEnding(text, ending) {
500
- const normalized = normalizeLineEndings(text)
501
- return ending === '\r\n' ? normalized.replaceAll('\n', '\r\n') : normalized
502
- }
503
-
504
- export async function toolEditFile(params, context) {
505
- const file = resolveWorkspacePath(params?.path, context)
506
- await assertSafeWorkspacePath(file, context)
507
-
508
- const rawOldText = String(params?.oldText ?? '')
509
- const rawNewText = String(params?.newText ?? '')
510
- const text = await fs.readFile(file, 'utf8')
511
- const lineEnding = detectLineEnding(text)
512
- const oldText = convertToLineEnding(rawOldText, lineEnding)
513
- const newText = convertToLineEnding(rawNewText, lineEnding)
514
- const count = countOccurrences(text, oldText)
515
-
516
- if (count !== 1) {
517
- const rawCount = oldText === rawOldText ? count : countOccurrences(text, rawOldText)
518
- const suffix = rawCount !== count ? ` after normalizing line endings; raw match count was ${rawCount}` : ''
519
- const error = new Error(`oldText must match exactly once; found ${count} matches${suffix}`)
520
- error.statusCode = 400
521
- throw error
522
- }
523
-
524
- const nextText = text.replace(oldText, newText)
525
- const relativePath = toWorkspaceRelative(file, context)
526
- const diff = createTextDiff(text, nextText, relativePath)
527
-
528
- await fs.writeFile(file, nextText, 'utf8')
529
-
530
- return {
531
- content: `Edited ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
532
- details: { path: relativePath, project: context?.project, replaced: count, diff },
533
- }
534
- }
535
-
480
+ // --- edit_file ---
481
+ function countOccurrences(text, needle) {
482
+ if (!needle) return 0
483
+ let count = 0
484
+ let index = 0
485
+ while ((index = text.indexOf(needle, index)) !== -1) {
486
+ count++
487
+ index += needle.length
488
+ }
489
+ return count
490
+ }
491
+
492
+ function detectLineEnding(text) {
493
+ return text.includes('\r\n') ? '\r\n' : '\n'
494
+ }
495
+
496
+ function normalizeLineEndings(text) {
497
+ return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
498
+ }
499
+
500
+ function convertToLineEnding(text, ending) {
501
+ const normalized = normalizeLineEndings(text)
502
+ return ending === '\r\n' ? normalized.replaceAll('\n', '\r\n') : normalized
503
+ }
504
+
505
+ export async function toolEditFile(params, context) {
506
+ const file = resolveWorkspacePath(params?.path, context)
507
+ await assertSafeWorkspacePath(file, context)
508
+
509
+ const rawOldText = String(params?.oldText ?? '')
510
+ const rawNewText = String(params?.newText ?? '')
511
+ const text = await fs.readFile(file, 'utf8')
512
+ const lineEnding = detectLineEnding(text)
513
+ const oldText = convertToLineEnding(rawOldText, lineEnding)
514
+ const newText = convertToLineEnding(rawNewText, lineEnding)
515
+ const count = countOccurrences(text, oldText)
516
+
517
+ if (count !== 1) {
518
+ const rawCount = oldText === rawOldText ? count : countOccurrences(text, rawOldText)
519
+ const suffix = rawCount !== count ? ` after normalizing line endings; raw match count was ${rawCount}` : ''
520
+ const error = new Error(`oldText must match exactly once; found ${count} matches${suffix}`)
521
+ error.statusCode = 400
522
+ throw error
523
+ }
524
+
525
+ const nextText = text.replace(oldText, newText)
526
+ const relativePath = toWorkspaceRelative(file, context)
527
+ const diff = createTextDiff(text, nextText, relativePath)
528
+
529
+ await fs.writeFile(file, nextText, 'utf8')
530
+
531
+ return {
532
+ content: `Edited ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
533
+ details: { path: relativePath, project: context?.project, replaced: count, diff },
534
+ }
535
+ }
536
+
536
537
  // --- run_command ---
537
538
  function activeSkillsForContext(context) {
538
539
  return mergeSkills(context?.globalSkills, context?.projectSkills)
@@ -587,6 +588,29 @@ export async function toolReadSkillResource(params, context) {
587
588
  }
588
589
 
589
590
  // --- run_command ---
591
+ const DEFAULT_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
592
+ const MIN_RUN_COMMAND_TIMEOUT_MS = 1000
593
+ const MAX_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
594
+ const COMMAND_TAIL_LINES = 100
595
+
596
+ function formatDurationMs(durationMs = 0) {
597
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000))
598
+ const minutes = Math.floor(totalSeconds / 60)
599
+ const seconds = totalSeconds % 60
600
+ if (minutes > 0) return `${minutes}m ${seconds}s (${durationMs}ms)`
601
+ return `${seconds}s (${durationMs}ms)`
602
+ }
603
+
604
+ function tailText(current, chunk, maxLines = COMMAND_TAIL_LINES) {
605
+ const lines = (current + chunk).split(/\r?\n/)
606
+ if (lines.length <= maxLines) return lines.join('\n')
607
+ return lines.slice(lines.length - maxLines).join('\n')
608
+ }
609
+
610
+ function tailLabel(name, truncated) {
611
+ return truncated ? `${name} (last ${COMMAND_TAIL_LINES} lines):` : `${name}:`
612
+ }
613
+
590
614
  function commandStatus(meta = {}) {
591
615
  if (meta.running) return 'Status: running'
592
616
  const flags = [
@@ -598,16 +622,46 @@ function commandStatus(meta = {}) {
598
622
  }
599
623
 
600
624
  function formatCommandOutput(command, stdout, stderr, meta = {}) {
601
- return [
625
+ const lines = [
602
626
  `Command: ${command}`,
603
- commandStatus(meta),
604
- '',
605
- 'STDOUT:',
606
- stdout || '(empty)',
627
+ ]
628
+ if (meta.description) lines.push(`Description: ${meta.description}`)
629
+ lines.push(commandStatus(meta))
630
+ if (typeof meta.durationMs === 'number') lines.push(`Duration: ${formatDurationMs(meta.durationMs)}`)
631
+ if (typeof meta.timeoutMs === 'number') lines.push(`Timeout: ${formatDurationMs(meta.timeoutMs)}`)
632
+ if (meta.cwd) lines.push(`CWD: ${meta.cwd}`)
633
+ if (meta.outputFile) lines.push(`Full output: ${meta.outputFile}`)
634
+ if (meta.outputTruncated) lines.push(`Output mode: showing the last ${COMMAND_TAIL_LINES} lines; full output is saved to the log file.`)
635
+ if (meta.logError) lines.push(`Log warning: ${meta.logError}`)
636
+ lines.push('', tailLabel('STDOUT', meta.stdoutTruncated), stdout || '(empty)', '', tailLabel('STDERR', meta.stderrTruncated), stderr || '(empty)')
637
+ return lines.join('\n')
638
+ }
639
+
640
+ function safeLogFilePart(value, fallback) {
641
+ const text = String(value || '').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80)
642
+ return text || fallback
643
+ }
644
+
645
+ async function createCommandLogStream(command, { cwd, description, toolCallId } = {}) {
646
+ const dir = path.join(logsDir, 'commands')
647
+ await fs.mkdir(dir, { recursive: true })
648
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
649
+ const fileName = `${timestamp}_${safeLogFilePart(toolCallId, 'command')}.log`
650
+ const outputFile = path.join(dir, fileName)
651
+ const stream = createWriteStream(outputFile, { flags: 'wx' })
652
+ stream.write([
653
+ `Command: ${command}`,
654
+ description ? `Description: ${description}` : null,
655
+ `CWD: ${cwd}`,
656
+ `Started at: ${new Date().toISOString()}`,
607
657
  '',
608
- 'STDERR:',
609
- stderr || '(empty)',
610
- ].join('\n')
658
+ ].filter(Boolean).join('\n'))
659
+ return { stream, outputFile }
660
+ }
661
+
662
+ function writeCommandLog(stream, source, chunk) {
663
+ stream.write(`\n[${source} ${new Date().toISOString()}]\n`)
664
+ stream.write(chunk)
611
665
  }
612
666
 
613
667
  function killProcessTree(child, signal = 'SIGTERM') {
@@ -641,8 +695,6 @@ export function abortRunningCommand(toolCallId) {
641
695
  return true
642
696
  }
643
697
 
644
- const RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
645
-
646
698
  export async function toolRunCommand(params, context, runtime = {}) {
647
699
  const command = String(params?.command || '')
648
700
  if (!command.trim()) {
@@ -651,12 +703,34 @@ export async function toolRunCommand(params, context, runtime = {}) {
651
703
  throw error
652
704
  }
653
705
 
654
- const timeoutMs = RUN_COMMAND_TIMEOUT_MS
706
+ const description = String(params?.description || '').trim().slice(0, 500)
707
+ const timeoutMs = clampNumber(params?.timeoutMs, DEFAULT_RUN_COMMAND_TIMEOUT_MS, MIN_RUN_COMMAND_TIMEOUT_MS, MAX_RUN_COMMAND_TIMEOUT_MS)
655
708
  const cwd = getToolWorkspaceRoot(context)
709
+ const startedAt = Date.now()
656
710
 
657
711
  if (runtime.signal?.aborted) {
658
- const content = formatCommandOutput(command, '', 'Command aborted before start.', { aborted: true })
659
- return { content: truncateText(content), details: { command, project: context?.project, cwd, aborted: true } }
712
+ const content = formatCommandOutput(command, '', 'Command aborted before start.', {
713
+ aborted: true,
714
+ cwd,
715
+ description,
716
+ timeoutMs,
717
+ durationMs: 0,
718
+ })
719
+ return { content: truncateText(content), details: { command, description, project: context?.project, cwd, timeoutMs, durationMs: 0, aborted: true } }
720
+ }
721
+
722
+ let logStream = null
723
+ let outputFile = null
724
+ let logError = null
725
+ try {
726
+ const log = await createCommandLogStream(command, { cwd, description, toolCallId: runtime.toolCallId })
727
+ logStream = log.stream
728
+ outputFile = log.outputFile
729
+ logStream.on('error', (error) => {
730
+ logError = error?.message || 'Failed to write command log.'
731
+ })
732
+ } catch (error) {
733
+ logError = error?.message || 'Failed to create command log.'
660
734
  }
661
735
 
662
736
  return new Promise((resolve) => {
@@ -670,6 +744,8 @@ export async function toolRunCommand(params, context, runtime = {}) {
670
744
 
671
745
  let stdout = ''
672
746
  let stderr = ''
747
+ let stdoutTruncated = false
748
+ let stderrTruncated = false
673
749
  let timedOut = false
674
750
  let aborted = false
675
751
  let settled = false
@@ -685,21 +761,65 @@ export async function toolRunCommand(params, context, runtime = {}) {
685
761
  runtime.signal?.removeEventListener?.('abort', onAbort)
686
762
  }
687
763
 
764
+ const commonDetails = (extra = {}) => {
765
+ const now = Date.now()
766
+ return {
767
+ command,
768
+ description,
769
+ project: context?.project,
770
+ cwd,
771
+ timeoutMs,
772
+ outputFile,
773
+ stdout,
774
+ stderr,
775
+ stdoutTruncated,
776
+ stderrTruncated,
777
+ outputTruncated: stdoutTruncated || stderrTruncated,
778
+ durationMs: now - startedAt,
779
+ toolCallId: runtime.toolCallId,
780
+ logError,
781
+ ...extra,
782
+ }
783
+ }
784
+
785
+ const resolveAfterLogClose = (result) => {
786
+ if (!logStream) {
787
+ resolve(result)
788
+ return
789
+ }
790
+ const details = result.details || {}
791
+ logStream.write([
792
+ '',
793
+ '',
794
+ `[quickforge ${new Date().toISOString()}]`,
795
+ `Exit code: ${details.code ?? 'unknown'}${details.signal ? `, signal: ${details.signal}` : ''}`,
796
+ `Duration: ${formatDurationMs(details.durationMs)}`,
797
+ `Timed out: ${Boolean(details.timedOut)}`,
798
+ `Aborted: ${Boolean(details.aborted)}`,
799
+ 'Command finished.',
800
+ '',
801
+ ].join('\n'))
802
+ logStream.end(() => resolve(result))
803
+ }
804
+
688
805
  const finish = ({ code = null, signal = null, error = null } = {}) => {
689
806
  if (settled) return
690
807
  flushUpdate()
691
808
  settled = true
692
809
  cleanup()
810
+ const durationMs = Date.now() - startedAt
693
811
  if (error) {
694
- resolve({
812
+ const details = commonDetails({ error: error.message, aborted, timedOut, durationMs })
813
+ resolveAfterLogClose({
695
814
  isError: true,
696
- content: truncateText(`Error running command: ${error.message}`),
697
- details: { command, project: context?.project, cwd, error: error.message, aborted, timedOut },
815
+ content: truncateText(formatCommandOutput(command, stdout, `Error running command: ${error.message}\n${stderr}`.trim(), details)),
816
+ details,
698
817
  })
699
818
  return
700
819
  }
701
- const content = formatCommandOutput(command, stdout, stderr, { code, signal, timedOut, aborted })
702
- resolve({ content: truncateText(content), details: { command, project: context?.project, cwd, code, signal, timedOut, aborted } })
820
+ const details = commonDetails({ code, signal, timedOut, aborted, durationMs })
821
+ const content = formatCommandOutput(command, stdout, stderr, details)
822
+ resolveAfterLogClose({ content: truncateText(content), details })
703
823
  }
704
824
 
705
825
  const stopChild = (reason) => {
@@ -718,15 +838,16 @@ export async function toolRunCommand(params, context, runtime = {}) {
718
838
  finish({ signal: 'SIGTERM' })
719
839
  }
720
840
 
721
- const runningDetails = () => ({ command, project: context?.project, cwd, running: true, stdout, stderr, toolCallId: runtime.toolCallId })
841
+ const runningDetails = () => commonDetails({ running: true })
722
842
 
723
843
  const emitUpdate = () => {
724
844
  updateTimer = null
725
845
  if (settled || !updatePending) return
726
846
  updatePending = false
847
+ const details = runningDetails()
727
848
  runtime.onUpdate?.({
728
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
729
- details: runningDetails(),
849
+ content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, details)) }],
850
+ details,
730
851
  })
731
852
  }
732
853
  const flushUpdate = () => {
@@ -736,9 +857,10 @@ export async function toolRunCommand(params, context, runtime = {}) {
736
857
  }
737
858
  if (!updatePending) return
738
859
  updatePending = false
860
+ const details = runningDetails()
739
861
  runtime.onUpdate?.({
740
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
741
- details: runningDetails(),
862
+ content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, details)) }],
863
+ details,
742
864
  })
743
865
  }
744
866
  const scheduleUpdate = () => {
@@ -755,12 +877,18 @@ export async function toolRunCommand(params, context, runtime = {}) {
755
877
 
756
878
  child.stdout.on('data', (chunk) => {
757
879
  if (settled) return
758
- stdout = truncateText(stdout + chunk.toString())
880
+ const text = chunk.toString()
881
+ stdoutTruncated = stdoutTruncated || (stdout + text).split(/\r?\n/).length > COMMAND_TAIL_LINES
882
+ stdout = tailText(stdout, text)
883
+ if (logStream && !logError) writeCommandLog(logStream, 'stdout', text)
759
884
  scheduleUpdate()
760
885
  })
761
886
  child.stderr.on('data', (chunk) => {
762
887
  if (settled) return
763
- stderr = truncateText(stderr + chunk.toString())
888
+ const text = chunk.toString()
889
+ stderrTruncated = stderrTruncated || (stderr + text).split(/\r?\n/).length > COMMAND_TAIL_LINES
890
+ stderr = tailText(stderr, text)
891
+ if (logStream && !logError) writeCommandLog(logStream, 'stderr', text)
764
892
  scheduleUpdate()
765
893
  })
766
894
  child.on('close', (code, signal) => {