@shawnstack/quickforge 1.3.4 → 1.3.6

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.4-blue" />
4
+ <img alt="Version" src="https://img.shields.io/badge/version-1.3.6-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.4
68
+ npm install -g @shawnstack/quickforge@1.3.6
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.4.tgz
82
+ package-offline/shawnstack-quickforge-1.3.6.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.4.tgz
88
+ npm install -g ./package-offline/shawnstack-quickforge-1.3.6.tgz
89
89
  qf
90
90
  ```
91
91
 
92
- 该包由 `v1.3.4` 标签生成,包含离线安装所需的运行时依赖。
92
+ 该包由 `v1.3.6` 标签生成,包含离线安装所需的运行时依赖。
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.4
231
+ npm install -g @shawnstack/quickforge@1.3.6
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.4` is:
242
+ The offline release package for `v1.3.6` is:
243
243
 
244
244
  ```text
245
- package-offline/shawnstack-quickforge-1.3.4.tgz
245
+ package-offline/shawnstack-quickforge-1.3.6.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.4.tgz
251
+ npm install -g ./package-offline/shawnstack-quickforge-1.3.6.tgz
252
252
  qf
253
253
  ```
254
254
 
255
- The package was generated from tag `v1.3.4` and includes bundled runtime dependencies for offline installation.
255
+ The package was generated from tag `v1.3.6` 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.4",
3
+ "version": "1.3.6",
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",
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { Agent } from '@mariozechner/pi-agent-core'
4
- import { streamSimple } from '@mariozechner/pi-ai'
4
+ import { streamSimpleWithAiHttpLogging } from './ai-http-logger.mjs'
5
5
  import { toolHandlers, loadSkillToolContext } from './tools/index.mjs'
6
6
  import { createSkillTools, workspaceTools } from './tools/definitions.mjs'
7
7
  import { callMcpTool, createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
@@ -699,7 +699,7 @@ export async function createAgent(sessionId, config = {}) {
699
699
  messages,
700
700
  tools,
701
701
  },
702
- streamFn: streamSimple,
702
+ streamFn: streamSimpleWithAiHttpLogging,
703
703
  getApiKey,
704
704
  sessionId,
705
705
  convertToLlm: serverConvertToLlm,
@@ -0,0 +1,208 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { streamSimple } from '@mariozechner/pi-ai'
6
+ import { logsDir } from './storage.mjs'
7
+
8
+ const PATCH_MARKER = Symbol.for('quickforge.aiHttpLogger.fetchPatched')
9
+ const ORIGINAL_FETCH = Symbol.for('quickforge.aiHttpLogger.originalFetch')
10
+ const enabledValues = new Set(['1', 'true', 'yes', 'on', 'full', 'raw'])
11
+ const aiHttpLogEnabled = enabledValues.has(String(process.env.QUICKFORGE_AI_HTTP_LOG || '').toLowerCase())
12
+ const aiHttpContext = new AsyncLocalStorage()
13
+
14
+ function currentLogFile() {
15
+ const date = new Date().toISOString().slice(0, 10)
16
+ return path.join(logsDir, `ai-http-${date}.jsonl`)
17
+ }
18
+
19
+ function writeAiHttpRecord(record) {
20
+ if (!aiHttpLogEnabled) return
21
+ try {
22
+ fs.mkdirSync(logsDir, { recursive: true })
23
+ fs.appendFile(currentLogFile(), `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`, () => {})
24
+ } catch {
25
+ // Keep AI calls working even when diagnostic logging fails.
26
+ }
27
+ }
28
+
29
+ function headersToRecord(headers) {
30
+ const result = {}
31
+ if (!headers) return result
32
+
33
+ try {
34
+ const iterable = typeof headers.entries === 'function' ? headers.entries() : Object.entries(headers)
35
+ for (const [key, value] of iterable) {
36
+ result[String(key)] = Array.isArray(value) ? value.join(', ') : String(value)
37
+ }
38
+ } catch {
39
+ // ignore malformed headers
40
+ }
41
+
42
+ return result
43
+ }
44
+
45
+ function isRequest(value) {
46
+ return typeof Request !== 'undefined' && value instanceof Request
47
+ }
48
+
49
+ function requestUrl(input) {
50
+ if (isRequest(input)) return input.url
51
+ if (input instanceof URL) return input.href
52
+ return String(input)
53
+ }
54
+
55
+ function requestMethod(input, init) {
56
+ return String(init?.method || (isRequest(input) ? input.method : 'GET')).toUpperCase()
57
+ }
58
+
59
+ function requestHeaders(input, init) {
60
+ return {
61
+ ...(isRequest(input) ? headersToRecord(input.headers) : {}),
62
+ ...headersToRecord(init?.headers),
63
+ }
64
+ }
65
+
66
+ async function bodyToText(body) {
67
+ if (body === undefined || body === null) return null
68
+ if (typeof body === 'string') return body
69
+ if (body instanceof URLSearchParams) return body.toString()
70
+ if (typeof Blob !== 'undefined' && body instanceof Blob) return body.text()
71
+ if (body instanceof ArrayBuffer) return Buffer.from(body).toString('utf8')
72
+ if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('utf8')
73
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
74
+ const entries = []
75
+ for (const [key, value] of body.entries()) {
76
+ entries.push([
77
+ key,
78
+ typeof value === 'string'
79
+ ? value
80
+ : { name: value.name, type: value.type, size: value.size },
81
+ ])
82
+ }
83
+ return JSON.stringify(entries)
84
+ }
85
+ if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
86
+ return '[ReadableStream body not captured to avoid consuming the request stream]'
87
+ }
88
+ if (typeof body === 'object') {
89
+ try {
90
+ return JSON.stringify(body)
91
+ } catch {
92
+ return String(body)
93
+ }
94
+ }
95
+ return String(body)
96
+ }
97
+
98
+ async function readRequestBody(input, init) {
99
+ if (init && Object.hasOwn(init, 'body')) return bodyToText(init.body)
100
+ if (!isRequest(input)) return null
101
+
102
+ try {
103
+ return await input.clone().text()
104
+ } catch (error) {
105
+ return `[request body capture failed: ${error instanceof Error ? error.message : String(error)}]`
106
+ }
107
+ }
108
+
109
+ async function logResponseBody(response, baseRecord) {
110
+ try {
111
+ const body = await response.clone().text()
112
+ writeAiHttpRecord({
113
+ ...baseRecord,
114
+ type: 'ai_http_response',
115
+ response: {
116
+ status: response.status,
117
+ statusText: response.statusText,
118
+ headers: headersToRecord(response.headers),
119
+ body,
120
+ },
121
+ })
122
+ } catch (error) {
123
+ writeAiHttpRecord({
124
+ ...baseRecord,
125
+ type: 'ai_http_response_capture_error',
126
+ response: {
127
+ status: response.status,
128
+ statusText: response.statusText,
129
+ headers: headersToRecord(response.headers),
130
+ },
131
+ error: error instanceof Error ? error.message : String(error),
132
+ })
133
+ }
134
+ }
135
+
136
+ async function loggedFetch(originalFetch, input, init) {
137
+ const context = aiHttpContext.getStore()
138
+ if (!context) return originalFetch(input, init)
139
+
140
+ const httpRequestId = randomUUID()
141
+ const startedAt = Date.now()
142
+ const method = requestMethod(input, init)
143
+ const url = requestUrl(input)
144
+ const baseRecord = {
145
+ traceId: context.traceId,
146
+ httpRequestId,
147
+ sessionId: context.sessionId,
148
+ purpose: context.purpose,
149
+ provider: context.provider,
150
+ api: context.api,
151
+ model: context.model,
152
+ method,
153
+ url,
154
+ }
155
+
156
+ writeAiHttpRecord({
157
+ ...baseRecord,
158
+ type: 'ai_http_request',
159
+ request: {
160
+ method,
161
+ url,
162
+ headers: requestHeaders(input, init),
163
+ body: await readRequestBody(input, init),
164
+ },
165
+ })
166
+
167
+ try {
168
+ const response = await originalFetch(input, init)
169
+ const durationMs = Date.now() - startedAt
170
+ void logResponseBody(response, { ...baseRecord, durationMs })
171
+ return response
172
+ } catch (error) {
173
+ writeAiHttpRecord({
174
+ ...baseRecord,
175
+ type: 'ai_http_error',
176
+ durationMs: Date.now() - startedAt,
177
+ error: error instanceof Error ? error.stack || error.message : String(error),
178
+ })
179
+ throw error
180
+ }
181
+ }
182
+
183
+ export function installAiHttpLogger() {
184
+ if (!aiHttpLogEnabled || typeof globalThis.fetch !== 'function') return
185
+ if (globalThis[PATCH_MARKER]) return
186
+
187
+ const originalFetch = globalThis.fetch.bind(globalThis)
188
+ globalThis[ORIGINAL_FETCH] = originalFetch
189
+ globalThis.fetch = (input, init) => loggedFetch(originalFetch, input, init)
190
+ globalThis[PATCH_MARKER] = true
191
+ }
192
+
193
+ export function streamSimpleWithAiHttpLogging(model, context, options = {}) {
194
+ if (!aiHttpLogEnabled) return streamSimple(model, context, options)
195
+
196
+ const traceContext = {
197
+ traceId: randomUUID(),
198
+ sessionId: options?.sessionId,
199
+ purpose: options?.metadata?.quickforgePurpose || 'chat',
200
+ provider: model?.provider,
201
+ api: model?.api,
202
+ model: model?.id,
203
+ }
204
+
205
+ return aiHttpContext.run(traceContext, () => streamSimple(model, context, options))
206
+ }
207
+
208
+ installAiHttpLogger()
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { streamSimple } from '@mariozechner/pi-ai'
3
+ import { streamSimpleWithAiHttpLogging } from './ai-http-logger.mjs'
4
4
  import { cacheDir } from './storage.mjs'
5
5
 
6
6
  export const DEFAULT_COMPACT_KEEP_TURNS = 0
@@ -253,7 +253,7 @@ export async function compactConversation({ messages, model, thinkingLevel, getA
253
253
  const modelMaxTokens = Number(model.maxTokens) || 4096
254
254
  const maxTokens = Math.max(512, Math.min(modelMaxTokens, 4096))
255
255
  const apiKey = getApiKey ? await getApiKey(model.provider) : undefined
256
- const stream = streamSimple(
256
+ const stream = streamSimpleWithAiHttpLogging(
257
257
  model,
258
258
  {
259
259
  systemPrompt: COMPACT_SYSTEM_PROMPT,
@@ -266,6 +266,7 @@ export async function compactConversation({ messages, model, thinkingLevel, getA
266
266
  temperature: 0.2,
267
267
  reasoning: thinkingLevel === 'off' ? undefined : 'low',
268
268
  maxRetryDelayMs: 60000,
269
+ metadata: { quickforgePurpose: 'compact' },
269
270
  },
270
271
  )
271
272
 
package/server/index.mjs CHANGED
@@ -25,6 +25,7 @@ import { handleLanAccessApi, renderLanUnlockPage } from './routes/lan-access.mjs
25
25
  import { handleMcpApi } from './routes/mcp.mjs'
26
26
  import { serveStatic } from './routes/static.mjs'
27
27
  import { logger, flushLogger } from './utils/logger.mjs'
28
+ import { installAiHttpLogger } from './ai-http-logger.mjs'
28
29
  import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
29
30
  import { parseCookies } from './share-store.mjs'
30
31
  import { lanAccessCookieName, verifyLanAccessToken } from './lan-access-store.mjs'
@@ -50,6 +51,7 @@ const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
50
51
  let restartInProgress = false
51
52
 
52
53
  setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || projectRoot)
54
+ installAiHttpLogger()
53
55
 
54
56
  function getRestartSupport() {
55
57
  return { supported: true, reason: null }