@shawnstack/quickforge 1.3.16 → 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.
Files changed (36) hide show
  1. package/README.md +10 -10
  2. package/dist/assets/{anthropic-CTuvxFTI.js → anthropic-Ck2DxOfr.js} +1 -1
  3. package/dist/assets/{azure-openai-responses-Bbdz_9hw.js → azure-openai-responses-DIoz5q4Z.js} +1 -1
  4. package/dist/assets/{google-Dwuw-aUO.js → google-Dau-4ve_.js} +1 -1
  5. package/dist/assets/{google-gemini-cli-CUAh97VN.js → google-gemini-cli-DttMmbGb.js} +1 -1
  6. package/dist/assets/{google-vertex-I8vqzEMR.js → google-vertex-BeukMl44.js} +1 -1
  7. package/dist/assets/{index-t6ITXfOr.css → index-DgJVElbv.css} +1 -1
  8. package/dist/assets/{index-DC0sfTYJ.js → index-Dm7aEWvT.js} +462 -444
  9. package/dist/assets/{mistral-BFoKj0LS.js → mistral-DxhS4Wkn.js} +1 -1
  10. package/dist/assets/{openai-codex-responses-BowpWoQz.js → openai-codex-responses-X3sTzNAa.js} +1 -1
  11. package/dist/assets/{openai-completions-DgCq7TXk.js → openai-completions-CRB9Vm0w.js} +1 -1
  12. package/dist/assets/{openai-responses-Bj3ccKY7.js → openai-responses-DXluu3oi.js} +1 -1
  13. package/dist/assets/{openai-responses-shared-B1BV7Hco.js → openai-responses-shared-f_P3e1nz.js} +1 -1
  14. package/dist/index.html +2 -2
  15. package/node_modules/@babel/runtime/package.json +1 -1
  16. package/node_modules/undici/lib/api/api-request.js +1 -1
  17. package/node_modules/undici/lib/cache/sqlite-cache-store.js +3 -3
  18. package/node_modules/undici/lib/core/connect.js +16 -0
  19. package/node_modules/undici/lib/core/request.js +17 -2
  20. package/node_modules/undici/lib/core/socks5-client.js +10 -5
  21. package/node_modules/undici/lib/core/socks5-utils.js +17 -22
  22. package/node_modules/undici/lib/dispatcher/client-h1.js +64 -20
  23. package/node_modules/undici/lib/dispatcher/client.js +6 -2
  24. package/node_modules/undici/lib/dispatcher/h2c-client.js +1 -1
  25. package/node_modules/undici/lib/mock/mock-call-history.js +15 -15
  26. package/node_modules/undici/lib/util/cache.js +8 -7
  27. package/node_modules/undici/lib/web/fetch/formdata-parser.js +17 -6
  28. package/node_modules/undici/lib/web/fetch/index.js +5 -2
  29. package/node_modules/undici/lib/web/webidl/index.js +5 -5
  30. package/node_modules/undici/lib/web/websocket/stream/websocketstream.js +1 -7
  31. package/node_modules/undici/package.json +1 -1
  32. package/package.json +1 -1
  33. package/server/agent-manager.mjs +10 -1
  34. package/server/auto-compaction.mjs +96 -21
  35. package/server/tools/definitions.mjs +2 -0
  36. package/server/tools/index.mjs +211 -83
@@ -235,9 +235,13 @@ class Client extends DispatcherBase {
235
235
  ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
236
236
  ...connect
237
237
  })
238
- } else if (socketPath != null) {
238
+ } else {
239
239
  const customConnect = connect
240
- connect = (opts, callback) => customConnect({ ...opts, socketPath }, callback)
240
+ connect = (opts, callback) => customConnect({
241
+ ...opts,
242
+ ...(socketPath != null ? { socketPath } : null),
243
+ ...(allowH2 != null ? { allowH2 } : null)
244
+ }, callback)
241
245
  }
242
246
 
243
247
  this[kUrl] = util.parseOrigin(url)
@@ -15,7 +15,7 @@ class H2CClient extends Client {
15
15
  )
16
16
  }
17
17
 
18
- const { connect, maxConcurrentStreams, pipelining, ...opts } =
18
+ const { maxConcurrentStreams, pipelining, ...opts } =
19
19
  clientOpts ?? {}
20
20
  let defaultMaxConcurrentStreams = 100
21
21
  let defaultPipelining = 100
@@ -3,14 +3,14 @@
3
3
  const { kMockCallHistoryAddLog } = require('./mock-symbols')
4
4
  const { InvalidArgumentError } = require('../core/errors')
5
5
 
6
- function handleFilterCallsWithOptions (criteria, options, handler, store) {
6
+ function handleFilterCallsWithOptions (criteria, options, handler, store, allLogs) {
7
7
  switch (options.operator) {
8
8
  case 'OR':
9
- store.push(...handler(criteria))
9
+ store.push(...handler(criteria, allLogs))
10
10
 
11
11
  return store
12
12
  case 'AND':
13
- return handler.call({ logs: store }, criteria)
13
+ return handler(criteria, store)
14
14
  default:
15
15
  // guard -- should never happens because buildAndValidateFilterCallsOptions is called before
16
16
  throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'')
@@ -35,14 +35,14 @@ function buildAndValidateFilterCallsOptions (options = {}) {
35
35
  }
36
36
 
37
37
  function makeFilterCalls (parameterName) {
38
- return (parameterValue) => {
38
+ return (parameterValue, logs) => {
39
39
  if (typeof parameterValue === 'string' || parameterValue == null) {
40
- return this.logs.filter((log) => {
40
+ return logs.filter((log) => {
41
41
  return log[parameterName] === parameterValue
42
42
  })
43
43
  }
44
44
  if (parameterValue instanceof RegExp) {
45
- return this.logs.filter((log) => {
45
+ return logs.filter((log) => {
46
46
  return parameterValue.test(log[parameterName])
47
47
  })
48
48
  }
@@ -175,30 +175,30 @@ class MockCallHistory {
175
175
 
176
176
  const finalOptions = { operator: 'OR', ...buildAndValidateFilterCallsOptions(options) }
177
177
 
178
- let maybeDuplicatedLogsFiltered = []
178
+ let maybeDuplicatedLogsFiltered = finalOptions.operator === 'AND' ? this.logs : []
179
179
  if ('protocol' in criteria) {
180
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered)
180
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered, this.logs)
181
181
  }
182
182
  if ('host' in criteria) {
183
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered)
183
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered, this.logs)
184
184
  }
185
185
  if ('port' in criteria) {
186
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered)
186
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered, this.logs)
187
187
  }
188
188
  if ('origin' in criteria) {
189
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered)
189
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered, this.logs)
190
190
  }
191
191
  if ('path' in criteria) {
192
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered)
192
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered, this.logs)
193
193
  }
194
194
  if ('hash' in criteria) {
195
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered)
195
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered, this.logs)
196
196
  }
197
197
  if ('fullUrl' in criteria) {
198
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered)
198
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered, this.logs)
199
199
  }
200
200
  if ('method' in criteria) {
201
- maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered)
201
+ maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered, this.logs)
202
202
  }
203
203
 
204
204
  const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)]
@@ -18,7 +18,7 @@ function makeCacheKey (opts) {
18
18
 
19
19
  let fullPath = opts.path || '/'
20
20
 
21
- if (opts.query && !pathHasQueryOrFragment(opts.path)) {
21
+ if (opts.query && !pathHasQueryOrFragment(fullPath)) {
22
22
  fullPath = serializePathWithQuery(fullPath, opts.query)
23
23
  }
24
24
 
@@ -374,9 +374,11 @@ function assertCacheMethods (methods, name = 'CacheMethods') {
374
374
  * @returns {string}
375
375
  */
376
376
  function makeDeduplicationKey (cacheKey, excludeHeaders) {
377
- // Create a deterministic string key from the cache key
378
- // Include origin, method, path, and sorted headers
379
- let key = `${cacheKey.origin}:${cacheKey.method}:${cacheKey.path}`
377
+ // Use JSON.stringify to produce a collision-resistant key.
378
+ // Previous format used `:` and `=` delimiters without escaping, which
379
+ // allowed different header sets to produce identical keys (e.g.
380
+ // {a:"x:b=y"} vs {a:"x", b:"y"}). See: https://github.com/nodejs/undici/issues/5012
381
+ const headers = {}
380
382
 
381
383
  if (cacheKey.headers) {
382
384
  const sortedHeaders = Object.keys(cacheKey.headers).sort()
@@ -385,12 +387,11 @@ function makeDeduplicationKey (cacheKey, excludeHeaders) {
385
387
  if (excludeHeaders?.has(header.toLowerCase())) {
386
388
  continue
387
389
  }
388
- const value = cacheKey.headers[header]
389
- key += `:${header}=${Array.isArray(value) ? value.join(',') : value}`
390
+ headers[header] = cacheKey.headers[header]
390
391
  }
391
392
  }
392
393
 
393
- return key
394
+ return JSON.stringify([cacheKey.origin, cacheKey.method, cacheKey.path, headers])
394
395
  }
395
396
 
396
397
  module.exports = {
@@ -204,7 +204,7 @@ function multipartFormDataParser (input, mimeType) {
204
204
  * Parses content-disposition attributes (e.g., name="value" or filename*=utf-8''encoded)
205
205
  * @param {Buffer} input
206
206
  * @param {{ position: number }} position
207
- * @returns {{ name: string, value: string }}
207
+ * @returns {{ name: string, value: string, extended: boolean } | null}
208
208
  */
209
209
  function parseContentDispositionAttribute (input, position) {
210
210
  // Skip leading semicolon and whitespace
@@ -304,7 +304,7 @@ function parseContentDispositionAttribute (input, position) {
304
304
  value = decoder.decode(tokenValue)
305
305
  }
306
306
 
307
- return { name: attrNameStr, value }
307
+ return { name: attrNameStr, value, extended: isExtended }
308
308
  }
309
309
 
310
310
  /**
@@ -368,6 +368,9 @@ function parseMultipartFormDataHeaders (input, position) {
368
368
  switch (bufferToLowerCasedHeaderName(headerName)) {
369
369
  case 'content-disposition': {
370
370
  name = filename = null
371
+ // Track whether filename was set from the extended (RFC 5987) form so
372
+ // a subsequent legacy `filename` attribute does not override it.
373
+ let filenameIsExtended = false
371
374
 
372
375
  // Collect the disposition type (should be "form-data")
373
376
  const dispositionType = collectASequenceOfBytes(
@@ -383,8 +386,8 @@ function parseMultipartFormDataHeaders (input, position) {
383
386
  // Parse attributes recursively until CRLF
384
387
  while (
385
388
  position.position < input.length &&
386
- input[position.position] !== 0x0d &&
387
- input[position.position + 1] !== 0x0a
389
+ (input[position.position] !== 0x0d ||
390
+ input[position.position + 1] !== 0x0a)
388
391
  ) {
389
392
  const attribute = parseContentDispositionAttribute(input, position)
390
393
 
@@ -395,7 +398,15 @@ function parseMultipartFormDataHeaders (input, position) {
395
398
  if (attribute.name === 'name') {
396
399
  name = attribute.value
397
400
  } else if (attribute.name === 'filename') {
398
- filename = attribute.value
401
+ // Per RFC 5987 §4.1, when both legacy and extended forms of the
402
+ // same parameter are present, the extended (filename*) form takes
403
+ // precedence regardless of the order they appear in.
404
+ if (attribute.extended) {
405
+ filename = attribute.value
406
+ filenameIsExtended = true
407
+ } else if (!filenameIsExtended) {
408
+ filename = attribute.value
409
+ }
399
410
  }
400
411
  }
401
412
 
@@ -448,7 +459,7 @@ function parseMultipartFormDataHeaders (input, position) {
448
459
 
449
460
  // 2.9. If position does not point to a sequence of bytes starting with 0x0D 0x0A
450
461
  // (CR LF), return failure. Otherwise, advance position by 2 (past the newline).
451
- if (input[position.position] !== 0x0d && input[position.position + 1] !== 0x0a) {
462
+ if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
452
463
  throw parsingError('expected CRLF')
453
464
  } else {
454
465
  position.position += 2
@@ -1030,7 +1030,7 @@ function fetchFinale (fetchParams, response) {
1030
1030
  let responseStatus = 0
1031
1031
 
1032
1032
  // 7. If fetchParams’s request’s mode is not "navigate" or response’s has-cross-origin-redirects is false:
1033
- if (fetchParams.request.mode !== 'navigator' || !response.hasCrossOriginRedirects) {
1033
+ if (fetchParams.request.mode !== 'navigate' || !response.hasCrossOriginRedirects) {
1034
1034
  // 1. Set responseStatus to response’s status.
1035
1035
  responseStatus = response.status
1036
1036
 
@@ -1433,7 +1433,10 @@ async function httpNetworkOrCacheFetch (
1433
1433
  // 8. If contentLengthHeaderValue is non-null, then append
1434
1434
  // `Content-Length`/contentLengthHeaderValue to httpRequest’s header
1435
1435
  // list.
1436
- if (contentLengthHeaderValue != null) {
1436
+ if (
1437
+ contentLengthHeaderValue != null &&
1438
+ !httpRequest.headersList.contains('content-length', true)
1439
+ ) {
1437
1440
  httpRequest.headersList.append('content-length', contentLengthHeaderValue, true)
1438
1441
  }
1439
1442
 
@@ -190,10 +190,10 @@ webidl.util.ConvertToInt = function (V, bitLength, signedness, flags) {
190
190
  } else {
191
191
  // 3. Otherwise:
192
192
 
193
- // 1. Let lowerBound be -2^bitLength − 1.
194
- lowerBound = Math.pow(-2, bitLength) - 1
193
+ // 1. Let lowerBound be -2^(bitLength − 1).
194
+ lowerBound = -Math.pow(2, bitLength - 1)
195
195
 
196
- // 2. Let upperBound be 2^bitLength − 1 − 1.
196
+ // 2. Let upperBound be 2^(bitLength − 1) − 1.
197
197
  upperBound = Math.pow(2, bitLength - 1) - 1
198
198
  }
199
199
 
@@ -272,9 +272,9 @@ webidl.util.ConvertToInt = function (V, bitLength, signedness, flags) {
272
272
  // 10. Set x to x modulo 2^bitLength.
273
273
  x = x % Math.pow(2, bitLength)
274
274
 
275
- // 11. If signedness is "signed" and x ≥ 2^bitLength − 1,
275
+ // 11. If signedness is "signed" and x ≥ 2^(bitLength − 1),
276
276
  // then return x − 2^bitLength.
277
- if (signedness === 'signed' && x >= Math.pow(2, bitLength) - 1) {
277
+ if (signedness === 'signed' && x >= Math.pow(2, bitLength - 1)) {
278
278
  return x - Math.pow(2, bitLength)
279
279
  }
280
280
 
@@ -284,12 +284,6 @@ class WebSocketStream {
284
284
  start: (controller) => {
285
285
  this.#readableStreamController = controller
286
286
  },
287
- pull (controller) {
288
- let chunk
289
- while (controller.desiredSize > 0 && (chunk = response.socket.read()) !== null) {
290
- controller.enqueue(chunk)
291
- }
292
- },
293
287
  cancel: (reason) => this.#cancel(reason)
294
288
  })
295
289
 
@@ -338,7 +332,7 @@ class WebSocketStream {
338
332
  try {
339
333
  chunk = utf8Decode(data)
340
334
  } catch {
341
- failWebsocketConnection(this.#handler, 'Received invalid UTF-8 in text frame.')
335
+ failWebsocketConnection(this.#handler, 1007, 'Received invalid UTF-8 in text frame.')
342
336
  return
343
337
  }
344
338
  } else if (type === opcodes.BINARY) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.25.0",
3
+ "version": "7.26.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.3.16",
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",
@@ -360,6 +360,7 @@ function updateSessionMessages(session, messages) {
360
360
  function resetSessionCompaction(session) {
361
361
  session.contextCompaction = null
362
362
  session.lastAutoCompactAt = null
363
+ session.lastAutoCompactRejected = null
363
364
  session.lastTransformedContextMessages = null
364
365
  session.autoCompacting = false
365
366
  }
@@ -642,7 +643,7 @@ function compactedContextMessages(messages) {
642
643
  }
643
644
 
644
645
  async function transformSessionContext(session, messages, signal) {
645
- await maybeAutoCompactSession({
646
+ const autoCompactResult = await maybeAutoCompactSession({
646
647
  session,
647
648
  messages,
648
649
  signal,
@@ -651,6 +652,13 @@ async function transformSessionContext(session, messages, signal) {
651
652
  logger,
652
653
  confirmAutoCompact: createAutoCompactApprovalPromise,
653
654
  })
655
+ if (!autoCompactResult.compacted && autoCompactResult.usage && autoCompactResult.reason && autoCompactResult.reason !== 'below_threshold') {
656
+ logger.info(`Auto compact skipped for session ${session.sessionId}: ${autoCompactResult.reason}`, {
657
+ sessionId: session.sessionId,
658
+ reason: autoCompactResult.reason,
659
+ usage: autoCompactResult.usage,
660
+ })
661
+ }
654
662
  const transformedMessages = buildAutoCompactLoopMessages(session, messages)
655
663
  session.lastTransformedContextMessages = transformedMessages
656
664
  return applyActiveCommandPrompt(compactedContextMessages(transformedMessages), session?.activeCommandPrompt)
@@ -837,6 +845,7 @@ export async function createAgent(sessionId, config = {}) {
837
845
  lastTransformedContextMessages: null,
838
846
  autoCompacting: false,
839
847
  lastAutoCompactAt: null,
848
+ lastAutoCompactRejected: null,
840
849
  /** Track active SSE connections. Only one SSE stream allowed per session to prevent
841
850
  * connection-pool exhaustion when two browser tabs load the same session. */
842
851
  sseConnected: false,
@@ -12,6 +12,7 @@ export const DEFAULT_AUTO_COMPACT_SETTINGS = {
12
12
  }
13
13
 
14
14
  const AUTO_COMPACT_MIN_INTERVAL_MS = 30_000
15
+ const AUTO_COMPACT_REJECTION_SUPPRESS_MS = 10 * 60_000
15
16
 
16
17
  function clampNumber(value, fallback, min, max) {
17
18
  const parsed = Number(value)
@@ -85,16 +86,48 @@ function estimateMessagesChars(messages) {
85
86
  }, 0)
86
87
  }
87
88
 
88
- export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
89
+ function messageTimestampMs(message) {
90
+ const timestamp = message?.timestamp
91
+ if (typeof timestamp === 'number') return timestamp
92
+ if (typeof timestamp === 'string') {
93
+ const parsed = Date.parse(timestamp)
94
+ return Number.isNaN(parsed) ? 0 : parsed
95
+ }
96
+ return 0
97
+ }
98
+
99
+ function latestCompactTimestampMs(session) {
100
+ return messageTimestampMs(session?.contextCompaction?.summaryMessage)
101
+ }
102
+
103
+ function latestKnownInputTokens(messages, sinceTimestamp = 0) {
104
+ let latestTimestamp = -1
105
+ let latestInput = 0
106
+ for (const message of Array.isArray(messages) ? messages : []) {
107
+ if (message?.role !== 'assistant' || !message.usage) continue
108
+ const timestamp = messageTimestampMs(message)
109
+ if (sinceTimestamp > 0 && timestamp <= sinceTimestamp) continue
110
+ if (timestamp < latestTimestamp) continue
111
+ const input = Math.max(0, Number(message.usage.input ?? message.usage.totalTokens) || 0)
112
+ if (input <= 0) continue
113
+ latestTimestamp = timestamp
114
+ latestInput = input
115
+ }
116
+ return latestInput
117
+ }
118
+
119
+ export function estimateContextUsage({ systemPrompt, messages, tools, model, knownInputTokens = 0 }) {
89
120
  const contextWindow = Number(model?.contextWindow) || 0
90
121
  const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
91
- const inputTokens =
122
+ const estimatedInputTokens =
92
123
  estimateTextTokens(systemPrompt) +
93
124
  estimateMessagesTokens(messages) +
94
125
  estimateTextTokens(safeJson(tools))
126
+ const knownInput = Math.max(0, Number(knownInputTokens) || 0)
127
+ const inputTokens = Math.max(estimatedInputTokens, knownInput)
95
128
  const totalTokens = inputTokens + reservedOutputTokens
96
129
  const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
97
- return { inputTokens, reservedOutputTokens, totalTokens, contextWindow, percent }
130
+ return { inputTokens, estimatedInputTokens, knownInputTokens: knownInput, reservedOutputTokens, totalTokens, contextWindow, percent }
98
131
  }
99
132
 
100
133
  function isUserMessage(message) {
@@ -112,6 +145,34 @@ function tailStartForRecentTurns(messages, keepRecentTurns) {
112
145
  return 0
113
146
  }
114
147
 
148
+ function shouldSuppressAfterRejection(session, messages, usage) {
149
+ const rejection = session?.lastAutoCompactRejected
150
+ if (!rejection) return false
151
+ const now = Date.now()
152
+ const rejectedAt = Number(rejection.rejectedAt) || 0
153
+ if (rejectedAt <= 0 || now - rejectedAt > AUTO_COMPACT_REJECTION_SUPPRESS_MS) return false
154
+
155
+ const rejectedMessageCount = Number(rejection.messageCount) || 0
156
+ const currentMessageCount = Array.isArray(messages) ? messages.length : 0
157
+ if (currentMessageCount >= rejectedMessageCount + 3) return false
158
+
159
+ const rejectedPercent = Number(rejection.percent) || 0
160
+ const currentPercent = Number(usage?.percent) || 0
161
+ return currentPercent <= rejectedPercent + 5
162
+ }
163
+
164
+ function markAutoCompactRejected(session, messages, usage) {
165
+ session.lastAutoCompactRejected = {
166
+ rejectedAt: Date.now(),
167
+ messageCount: Array.isArray(messages) ? messages.length : 0,
168
+ percent: Number(usage?.percent) || 0,
169
+ }
170
+ }
171
+
172
+ function clearAutoCompactRejected(session) {
173
+ session.lastAutoCompactRejected = null
174
+ }
175
+
115
176
  function compactSummaryText(message) {
116
177
  const content = message?.content
117
178
  const text = typeof content === 'string'
@@ -150,37 +211,29 @@ function buildCompactionSourceMessages(session, messages, tailStart) {
150
211
  export function buildAutoCompactLoopMessages(session, messages) {
151
212
  const summaryMessage = session?.contextCompaction?.summaryMessage
152
213
  if (!summaryMessage) return messages
153
- const keepRecentTurns = session.contextCompaction.keepRecentTurns || DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns
154
- const tailStart = tailStartForRecentTurns(messages, keepRecentTurns)
155
- return [summaryMessage, ...messages.slice(tailStart)]
214
+ const source = Array.isArray(messages) ? messages : []
215
+ const compactedUpToIndex = Math.min(source.length, Math.max(0, Number(session.contextCompaction?.compactedUpToIndex) || 0))
216
+ return [summaryMessage, ...source.slice(compactedUpToIndex)]
156
217
  }
157
218
 
158
219
  export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
159
220
  if (!session || session.autoCompacting) return { compacted: false }
160
221
  const settings = await readAutoCompactSettings()
161
- if (!settings.enabled) return { compacted: false }
162
- if (signal?.aborted) return { compacted: false }
222
+ if (!settings.enabled) return { compacted: false, reason: 'disabled' }
223
+ if (signal?.aborted) return { compacted: false, reason: 'aborted' }
163
224
 
164
225
  const loopMessages = buildAutoCompactLoopMessages(session, messages)
226
+ const knownInputTokens = latestKnownInputTokens(messages, latestCompactTimestampMs(session))
165
227
  const usage = estimateContextUsage({
166
228
  systemPrompt: session.agent.state.systemPrompt,
167
229
  messages: loopMessages,
168
230
  tools: session.agent.state.tools,
169
231
  model: session.model,
232
+ knownInputTokens,
170
233
  })
171
- if (!usage.contextWindow || usage.percent < settings.thresholdPercent) return { compacted: false, usage }
172
-
173
- emitSessionEvent?.(session, {
174
- type: 'auto_compact_threshold_reached',
175
- usage,
176
- thresholdPercent: settings.thresholdPercent,
177
- requireConfirmation: settings.requireConfirmation,
178
- })
179
-
180
- if (settings.requireConfirmation) {
181
- const approved = await confirmAutoCompact?.(session, { usage, settings })
182
- if (!approved || signal?.aborted) return { compacted: false, usage, reason: approved === false ? 'user_rejected' : 'confirmation_unavailable' }
183
- }
234
+ if (!usage.contextWindow) return { compacted: false, usage, reason: 'missing_context_window' }
235
+ if (usage.percent < settings.thresholdPercent) return { compacted: false, usage, reason: 'below_threshold' }
236
+ if (shouldSuppressAfterRejection(session, messages, usage)) return { compacted: false, usage, reason: 'user_rejected_recently' }
184
237
 
185
238
  const now = Date.now()
186
239
  if (session.lastAutoCompactAt && now - session.lastAutoCompactAt < AUTO_COMPACT_MIN_INTERVAL_MS) {
@@ -196,6 +249,21 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
196
249
  return { compacted: false, usage, reason: 'not_enough_history' }
197
250
  }
198
251
 
252
+ emitSessionEvent?.(session, {
253
+ type: 'auto_compact_threshold_reached',
254
+ usage,
255
+ thresholdPercent: settings.thresholdPercent,
256
+ requireConfirmation: settings.requireConfirmation,
257
+ })
258
+
259
+ if (settings.requireConfirmation) {
260
+ const approved = await confirmAutoCompact?.(session, { usage, settings })
261
+ if (!approved || signal?.aborted) {
262
+ if (approved === false) markAutoCompactRejected(session, messages, usage)
263
+ return { compacted: false, usage, reason: approved === false ? 'user_rejected' : 'confirmation_unavailable' }
264
+ }
265
+ }
266
+
199
267
  session.autoCompacting = true
200
268
  try {
201
269
  const result = await compactConversation({
@@ -225,6 +293,7 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
225
293
  usageBefore: usage,
226
294
  thresholdPercent: settings.thresholdPercent,
227
295
  }
296
+ clearAutoCompactRejected(session)
228
297
  session.lastAutoCompactAt = now
229
298
  await persistSession(session)
230
299
  emitSessionEvent(session, {
@@ -242,6 +311,12 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
242
311
  return { compacted: true, usage }
243
312
  } catch (error) {
244
313
  logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })
314
+ emitSessionEvent?.(session, {
315
+ type: 'auto_compact_failed',
316
+ usage,
317
+ thresholdPercent: settings.thresholdPercent,
318
+ error: error?.message || String(error),
319
+ })
245
320
  return { compacted: false, usage, reason: 'error', error }
246
321
  } finally {
247
322
  session.autoCompacting = false
@@ -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
  },