@shawnstack/quickforge 1.3.13 → 1.3.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnstack/quickforge",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
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",
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { Agent } from '@mariozechner/pi-agent-core'
4
4
  import { streamSimpleWithAiHttpLogging } from './ai-http-logger.mjs'
5
- import { toolHandlers, loadSkillToolContext } from './tools/index.mjs'
5
+ import { toolHandlers, loadSkillToolContext, abortRunningCommand } from './tools/index.mjs'
6
6
  import { createSkillTools, workspaceTools } from './tools/definitions.mjs'
7
7
  import { callMcpTool, createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
8
8
  import { projectContextFromId, readProjectConfig } from './project-config.mjs'
@@ -15,6 +15,10 @@ import {
15
15
  parseCompactArgs,
16
16
  saveCompactBackup,
17
17
  } from './conversation-compaction.mjs'
18
+ import {
19
+ buildAutoCompactLoopMessages,
20
+ maybeAutoCompactSession,
21
+ } from './auto-compaction.mjs'
18
22
  import {
19
23
  handleInternalCommand,
20
24
  parseInternalCommandInvocation,
@@ -47,12 +51,13 @@ function wrapToolDefinition(definition, context, toolPermissions) {
47
51
 
48
52
  const startedAt = Date.now()
49
53
  const startedAtPerf = performance.now()
50
- const result = await handler(params || {}, context, { signal, onUpdate })
54
+ const result = await handler(params || {}, context, { signal, onUpdate, toolCallId: _toolCallId })
51
55
  const finishedAt = Date.now()
52
56
  const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
57
+ const details = mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs })
53
58
  return {
54
59
  content: [{ type: 'text', text: result.content }],
55
- details: mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs }),
60
+ details: isPlainObject(details) ? { ...details, toolCallId: _toolCallId } : details,
56
61
  }
57
62
  },
58
63
  }
@@ -137,6 +142,7 @@ const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
137
142
  const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command'])
138
143
  const safeReadTools = new Set(['read_file', 'grep_files'])
139
144
  const pendingApprovals = new Map() // toolCallId → { resolve, reject, sessionId, toolName, args, timeout }
145
+ const pendingAutoCompactApprovals = new Map() // approvalId → { resolve, reject, sessionId, timeout }
140
146
 
141
147
  function createCommandToolPermissions(session) {
142
148
  return (toolName) => {
@@ -220,6 +226,61 @@ function createApprovalPromise(session, toolCallId, toolName, args) {
220
226
  })
221
227
  }
222
228
 
229
+ function createAutoCompactApprovalPromise(session, details = {}) {
230
+ if (!session) return Promise.resolve(false)
231
+ const approvalId = randomUUID()
232
+ return new Promise((resolve, reject) => {
233
+ let settled = false
234
+ const timeout = setTimeout(() => {
235
+ if (settled) return
236
+ settled = true
237
+ pendingAutoCompactApprovals.delete(approvalId)
238
+ resolve(false)
239
+ }, APPROVAL_TIMEOUT_MS)
240
+
241
+ const cleanup = () => {
242
+ clearTimeout(timeout)
243
+ if (settled) return
244
+ settled = true
245
+ pendingAutoCompactApprovals.delete(approvalId)
246
+ }
247
+
248
+ const signal = session.agent.signal
249
+ if (signal) {
250
+ if (signal.aborted) {
251
+ cleanup()
252
+ reject(new Error('Run aborted'))
253
+ return
254
+ }
255
+ const onAbort = () => {
256
+ cleanup()
257
+ reject(new Error('Run aborted'))
258
+ }
259
+ signal.addEventListener('abort', onAbort, { once: true })
260
+ }
261
+
262
+ pendingAutoCompactApprovals.set(approvalId, {
263
+ resolve: (approved) => {
264
+ cleanup()
265
+ resolve(approved === true)
266
+ },
267
+ reject: (err) => {
268
+ cleanup()
269
+ reject(err)
270
+ },
271
+ sessionId: session.sessionId,
272
+ })
273
+
274
+ emitSessionEvent(session, {
275
+ type: 'auto_compact_approval_required',
276
+ approvalId,
277
+ usage: details.usage,
278
+ thresholdPercent: details.settings?.thresholdPercent,
279
+ keepRecentTurns: details.settings?.keepRecentTurns,
280
+ })
281
+ })
282
+ }
283
+
223
284
  function assistantTextMessage(text, model) {
224
285
  return {
225
286
  role: 'assistant',
@@ -294,10 +355,13 @@ function addToolTimingToEvent(session, event) {
294
355
 
295
356
  function updateSessionMessages(session, messages) {
296
357
  session.agent.state.messages = messages
297
- const compacted = compactedContextMessages(messages)
298
- if (compacted.length < messages.length) {
299
- session.agent.state.messages = compacted
300
- }
358
+ }
359
+
360
+ function resetSessionCompaction(session) {
361
+ session.contextCompaction = null
362
+ session.lastAutoCompactAt = null
363
+ session.lastTransformedContextMessages = null
364
+ session.autoCompacting = false
301
365
  }
302
366
 
303
367
  function finishManualSessionRun(session, status, errorMessage) {
@@ -464,6 +528,7 @@ async function clearSession(session) {
464
528
  }
465
529
 
466
530
  updateSessionMessages(session, [])
531
+ resetSessionCompaction(session)
467
532
  session.status = 'idle'
468
533
  session.startedAt = null
469
534
  session.finishedAt = new Date().toISOString()
@@ -576,8 +641,19 @@ function compactedContextMessages(messages) {
576
641
  return index >= 0 ? messages.slice(index) : messages
577
642
  }
578
643
 
579
- function transformAgentContext(messages, commandPrompt) {
580
- return applyActiveCommandPrompt(compactedContextMessages(messages), commandPrompt)
644
+ async function transformSessionContext(session, messages, signal) {
645
+ await maybeAutoCompactSession({
646
+ session,
647
+ messages,
648
+ signal,
649
+ emitSessionEvent,
650
+ persistSession,
651
+ logger,
652
+ confirmAutoCompact: createAutoCompactApprovalPromise,
653
+ })
654
+ const transformedMessages = buildAutoCompactLoopMessages(session, messages)
655
+ session.lastTransformedContextMessages = transformedMessages
656
+ return applyActiveCommandPrompt(compactedContextMessages(transformedMessages), session?.activeCommandPrompt)
581
657
  }
582
658
 
583
659
  export const agentEvents = new EventEmitter()
@@ -633,6 +709,7 @@ export async function createAgent(sessionId, config = {}) {
633
709
  systemPrompt = null,
634
710
  title = 'New chat',
635
711
  createdAt = new Date().toISOString(),
712
+ contextCompaction = null,
636
713
  } = config
637
714
 
638
715
  // Resolve project context for tool calls
@@ -704,9 +781,9 @@ export async function createAgent(sessionId, config = {}) {
704
781
  sessionId,
705
782
  convertToLlm: serverConvertToLlm,
706
783
  onPayload: (payload) => {
707
- restoreReasoningContentInPayload(payload, agent.state.messages, agent.state.model)
784
+ restoreReasoningContentInPayload(payload, session?.lastTransformedContextMessages || agent.state.messages, agent.state.model)
708
785
  },
709
- transformContext: (messages) => transformAgentContext(messages, session?.activeCommandPrompt),
786
+ transformContext: (messages, signal) => transformSessionContext(session, messages, signal),
710
787
  beforeToolCall: async (context) => {
711
788
  const toolName = context.toolCall?.name
712
789
  const toolCallId = context.toolCall?.id
@@ -756,6 +833,10 @@ export async function createAgent(sessionId, config = {}) {
756
833
  titleGenerated: false,
757
834
  toolTimings: new Map(),
758
835
  getApiKey,
836
+ contextCompaction,
837
+ lastTransformedContextMessages: null,
838
+ autoCompacting: false,
839
+ lastAutoCompactAt: null,
759
840
  /** Track active SSE connections. Only one SSE stream allowed per session to prevent
760
841
  * connection-pool exhaustion when two browser tabs load the same session. */
761
842
  sseConnected: false,
@@ -816,8 +897,8 @@ export async function createAgent(sessionId, config = {}) {
816
897
  * Persist session data to storage.
817
898
  */
818
899
  async function persistSession(session) {
819
- const { sessionId, agent, scope, projectId, title, createdAt, status, startedAt, finishedAt, model, thinkingLevel, yoloMode } = session
820
- const messages = compactedContextMessages(agent.state.messages)
900
+ const { sessionId, agent, scope, projectId, title, createdAt, status, startedAt, finishedAt, model, thinkingLevel, yoloMode, contextCompaction } = session
901
+ const messages = agent.state.messages
821
902
 
822
903
  if (messages.length === 0) {
823
904
  try {
@@ -847,6 +928,7 @@ async function persistSession(session) {
847
928
  taskStatus: status,
848
929
  taskStartedAt: startedAt,
849
930
  taskFinishedAt: finishedAt,
931
+ contextCompaction: contextCompaction || undefined,
850
932
  }
851
933
 
852
934
  // Calculate usage
@@ -894,6 +976,13 @@ async function persistSession(session) {
894
976
  taskStatus: status,
895
977
  taskStartedAt: startedAt,
896
978
  taskFinishedAt: finishedAt,
979
+ contextCompaction: contextCompaction ? {
980
+ compactedAt: contextCompaction.compactedAt,
981
+ compactedUpToIndex: contextCompaction.compactedUpToIndex,
982
+ keepRecentTurns: contextCompaction.keepRecentTurns,
983
+ thresholdPercent: contextCompaction.thresholdPercent,
984
+ usageBefore: contextCompaction.usageBefore,
985
+ } : undefined,
897
986
  }
898
987
 
899
988
  // Write to storage atomically (read-modify-write within queue)
@@ -947,6 +1036,7 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
947
1036
 
948
1037
  const nextMessages = messages.slice(0, rollbackIndex)
949
1038
  updateSessionMessages(session, nextMessages)
1039
+ resetSessionCompaction(session)
950
1040
  session.status = 'idle'
951
1041
  session.finishedAt = new Date().toISOString()
952
1042
  await persistSession(session)
@@ -971,6 +1061,7 @@ export async function replaceSessionMessages(sessionId, messages) {
971
1061
  throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes before rolling back.'), { statusCode: 409 })
972
1062
  }
973
1063
  updateSessionMessages(session, Array.isArray(messages) ? messages : [])
1064
+ resetSessionCompaction(session)
974
1065
  session.status = 'idle'
975
1066
  session.finishedAt = new Date().toISOString()
976
1067
  await persistSession(session)
@@ -1083,6 +1174,12 @@ export async function abortRun(sessionId) {
1083
1174
  approval.reject(new Error('Run aborted'))
1084
1175
  }
1085
1176
  }
1177
+ for (const [approvalId, approval] of pendingAutoCompactApprovals) {
1178
+ if (approval.sessionId === sessionId) {
1179
+ approval.reject(new Error('Run aborted'))
1180
+ pendingAutoCompactApprovals.delete(approvalId)
1181
+ }
1182
+ }
1086
1183
 
1087
1184
  session.agent.abort()
1088
1185
  await session.agent.waitForIdle()
@@ -1160,6 +1257,7 @@ export function getSessionState(sessionId) {
1160
1257
  finishedAt: session.finishedAt,
1161
1258
  tools: session.agent.state.tools,
1162
1259
  messages: session.agent.state.messages,
1260
+ contextCompaction: session.contextCompaction,
1163
1261
  isStreaming: session.agent.state.isStreaming,
1164
1262
  errorMessage: session.agent.state.errorMessage,
1165
1263
  }
@@ -1255,6 +1353,7 @@ export async function restoreAgent(sessionId) {
1255
1353
  messages: sessionData.messages || [],
1256
1354
  title: sessionData.title || 'New chat',
1257
1355
  createdAt: sessionData.createdAt,
1356
+ contextCompaction: sessionData.contextCompaction || null,
1258
1357
  })
1259
1358
  } catch (err) {
1260
1359
  logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
@@ -1286,6 +1385,36 @@ export function rejectToolCall(sessionId, toolCallId) {
1286
1385
  return { rejected: true, toolCallId }
1287
1386
  }
1288
1387
 
1388
+ export function approveAutoCompact(sessionId, approvalId) {
1389
+ const approval = pendingAutoCompactApprovals.get(approvalId)
1390
+ if (!approval || approval.sessionId !== sessionId) {
1391
+ throw Object.assign(new Error('No pending auto compact approval for this session'), { statusCode: 404 })
1392
+ }
1393
+ approval.resolve(true)
1394
+ return { approved: true, approvalId }
1395
+ }
1396
+
1397
+ export function rejectAutoCompact(sessionId, approvalId) {
1398
+ const approval = pendingAutoCompactApprovals.get(approvalId)
1399
+ if (!approval || approval.sessionId !== sessionId) {
1400
+ throw Object.assign(new Error('No pending auto compact approval for this session'), { statusCode: 404 })
1401
+ }
1402
+ approval.resolve(false)
1403
+ return { rejected: true, approvalId }
1404
+ }
1405
+
1406
+ export function abortToolCall(sessionId, toolCallId) {
1407
+ const session = agentSessions.get(sessionId)
1408
+ if (!session) {
1409
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 })
1410
+ }
1411
+ const aborted = abortRunningCommand(toolCallId)
1412
+ if (!aborted) {
1413
+ throw Object.assign(new Error('No running command for this tool call'), { statusCode: 404 })
1414
+ }
1415
+ return { aborted: true, toolCallId }
1416
+ }
1417
+
1289
1418
  /**
1290
1419
  * List all active sessions.
1291
1420
  */
@@ -0,0 +1,249 @@
1
+ import { readStore } from './storage.mjs'
2
+ import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
3
+
4
+ export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
5
+
6
+ export const DEFAULT_AUTO_COMPACT_SETTINGS = {
7
+ enabled: false,
8
+ thresholdPercent: 80,
9
+ keepRecentTurns: 2,
10
+ minSourceChars: 1600,
11
+ requireConfirmation: true,
12
+ }
13
+
14
+ const AUTO_COMPACT_MIN_INTERVAL_MS = 30_000
15
+
16
+ function clampNumber(value, fallback, min, max) {
17
+ const parsed = Number(value)
18
+ if (!Number.isFinite(parsed)) return fallback
19
+ return Math.min(max, Math.max(min, Math.round(parsed)))
20
+ }
21
+
22
+ export function normalizeAutoCompactSettings(value) {
23
+ if (!value || typeof value !== 'object') return { ...DEFAULT_AUTO_COMPACT_SETTINGS }
24
+ return {
25
+ enabled: value.enabled === true,
26
+ thresholdPercent: clampNumber(value.thresholdPercent, DEFAULT_AUTO_COMPACT_SETTINGS.thresholdPercent, 50, 95),
27
+ keepRecentTurns: clampNumber(value.keepRecentTurns, DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns, 1, 20),
28
+ minSourceChars: clampNumber(value.minSourceChars, DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars, 0, 200000),
29
+ requireConfirmation: value.requireConfirmation !== false,
30
+ }
31
+ }
32
+
33
+ export async function readAutoCompactSettings() {
34
+ const settings = await readStore('settings')
35
+ return normalizeAutoCompactSettings(settings?.[AUTO_COMPACT_SETTINGS_KEY])
36
+ }
37
+
38
+ function safeJson(value) {
39
+ try {
40
+ return JSON.stringify(value)
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
46
+ function estimateTextTokens(value) {
47
+ const text = String(value || '')
48
+ if (!text) return 0
49
+ const cjkChars = text.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0
50
+ const otherChars = Math.max(0, text.length - cjkChars)
51
+ return Math.ceil(cjkChars + otherChars / 3.5)
52
+ }
53
+
54
+ function contentToText(content) {
55
+ if (typeof content === 'string') return content
56
+ if (!Array.isArray(content)) return ''
57
+ return content.map((block) => {
58
+ if (!block || typeof block !== 'object') return ''
59
+ if (block.type === 'text') return block.text || ''
60
+ if (block.type === 'thinking') return block.thinking || ''
61
+ if (block.type === 'image') return `[image:${block.mimeType || 'unknown'}]`
62
+ if (block.type === 'toolCall') return `[toolCall:${block.name || 'unknown'}] ${safeJson(block.arguments)}`
63
+ return safeJson(block)
64
+ }).filter(Boolean).join('\n')
65
+ }
66
+
67
+ function estimateMessageTokens(message) {
68
+ if (!message || typeof message !== 'object') return 0
69
+ const parts = [message.role || '', contentToText(message.content)]
70
+ if (message.toolName) parts.push(message.toolName)
71
+ if (message.toolCallId) parts.push(message.toolCallId)
72
+ if (message.details !== undefined) parts.push(safeJson(message.details))
73
+ if (message.attachments !== undefined) parts.push(safeJson(message.attachments))
74
+ return estimateTextTokens(parts.join('\n'))
75
+ }
76
+
77
+ function estimateMessagesTokens(messages) {
78
+ return (Array.isArray(messages) ? messages : []).reduce((total, message) => total + estimateMessageTokens(message), 0)
79
+ }
80
+
81
+ function estimateMessagesChars(messages) {
82
+ return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
83
+ if (!message || typeof message !== 'object') return total
84
+ return total + [message.role || '', contentToText(message.content), safeJson(message.details), safeJson(message.attachments)].join('\n').length
85
+ }, 0)
86
+ }
87
+
88
+ export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
89
+ const contextWindow = Number(model?.contextWindow) || 0
90
+ const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
91
+ const inputTokens =
92
+ estimateTextTokens(systemPrompt) +
93
+ estimateMessagesTokens(messages) +
94
+ estimateTextTokens(safeJson(tools))
95
+ const totalTokens = inputTokens + reservedOutputTokens
96
+ const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
97
+ return { inputTokens, reservedOutputTokens, totalTokens, contextWindow, percent }
98
+ }
99
+
100
+ function isUserMessage(message) {
101
+ return message?.role === 'user' || message?.role === 'user-with-attachments'
102
+ }
103
+
104
+ function tailStartForRecentTurns(messages, keepRecentTurns) {
105
+ const source = Array.isArray(messages) ? messages : []
106
+ let seenUserTurns = 0
107
+ for (let index = source.length - 1; index >= 0; index--) {
108
+ if (!isUserMessage(source[index])) continue
109
+ seenUserTurns += 1
110
+ if (seenUserTurns >= keepRecentTurns) return index
111
+ }
112
+ return 0
113
+ }
114
+
115
+ function compactSummaryText(message) {
116
+ const content = message?.content
117
+ const text = typeof content === 'string'
118
+ ? content
119
+ : Array.isArray(content)
120
+ ? content.filter((block) => block?.type === 'text').map((block) => block.text || '').join('\n')
121
+ : ''
122
+ const match = text.match(/<compact_summary>\s*([\s\S]*?)\s*<\/compact_summary>/)
123
+ return match?.[1]?.trim() || text.trim()
124
+ }
125
+
126
+ function userTextMessage(text) {
127
+ return {
128
+ role: 'user',
129
+ content: [{ type: 'text', text }],
130
+ timestamp: Date.now(),
131
+ }
132
+ }
133
+
134
+ function buildCompactionSourceMessages(session, messages, tailStart) {
135
+ const source = []
136
+ const previousSummary = session.contextCompaction?.summaryMessage
137
+ if (previousSummary) {
138
+ source.push(userTextMessage([
139
+ 'Existing rolling compact summary from earlier conversation history:',
140
+ '',
141
+ '<compact_summary>',
142
+ compactSummaryText(previousSummary),
143
+ '</compact_summary>',
144
+ ].join('\n')))
145
+ }
146
+ source.push(...messages.slice(session.contextCompaction?.compactedUpToIndex || 0, tailStart))
147
+ return source
148
+ }
149
+
150
+ export function buildAutoCompactLoopMessages(session, messages) {
151
+ const summaryMessage = session?.contextCompaction?.summaryMessage
152
+ 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)]
156
+ }
157
+
158
+ export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
159
+ if (!session || session.autoCompacting) return { compacted: false }
160
+ const settings = await readAutoCompactSettings()
161
+ if (!settings.enabled) return { compacted: false }
162
+ if (signal?.aborted) return { compacted: false }
163
+
164
+ const loopMessages = buildAutoCompactLoopMessages(session, messages)
165
+ const usage = estimateContextUsage({
166
+ systemPrompt: session.agent.state.systemPrompt,
167
+ messages: loopMessages,
168
+ tools: session.agent.state.tools,
169
+ model: session.model,
170
+ })
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
+ }
184
+
185
+ const now = Date.now()
186
+ if (session.lastAutoCompactAt && now - session.lastAutoCompactAt < AUTO_COMPACT_MIN_INTERVAL_MS) {
187
+ return { compacted: false, usage, reason: 'recently_compacted' }
188
+ }
189
+ if (session.contextCompaction?.sourceMessageCount && messages.length <= session.contextCompaction.sourceMessageCount + 2) {
190
+ return { compacted: false, usage, reason: 'not_enough_new_messages' }
191
+ }
192
+
193
+ const tailStart = tailStartForRecentTurns(messages, settings.keepRecentTurns)
194
+ const sourceMessages = buildCompactionSourceMessages(session, messages, tailStart)
195
+ if (sourceMessages.length < 2 || estimateMessagesChars(sourceMessages) < settings.minSourceChars) {
196
+ return { compacted: false, usage, reason: 'not_enough_history' }
197
+ }
198
+
199
+ session.autoCompacting = true
200
+ try {
201
+ const result = await compactConversation({
202
+ messages: sourceMessages,
203
+ model: session.model,
204
+ thinkingLevel: session.thinkingLevel,
205
+ getApiKey: session.getApiKey,
206
+ keepTurns: 0,
207
+ })
208
+
209
+ if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
210
+
211
+ await saveCompactBackup(session.sessionId, sourceMessages)
212
+ const summaryMessage = userTextMessage([
213
+ 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
214
+ '',
215
+ '<compact_summary>',
216
+ result.summary,
217
+ '</compact_summary>',
218
+ ].join('\n'))
219
+ session.contextCompaction = {
220
+ summaryMessage,
221
+ compactedUpToIndex: tailStart,
222
+ compactedAt: new Date().toISOString(),
223
+ keepRecentTurns: settings.keepRecentTurns,
224
+ sourceMessageCount: messages.length,
225
+ usageBefore: usage,
226
+ thresholdPercent: settings.thresholdPercent,
227
+ }
228
+ session.lastAutoCompactAt = now
229
+ await persistSession(session)
230
+ emitSessionEvent(session, {
231
+ type: 'auto_compact_completed',
232
+ usage,
233
+ thresholdPercent: settings.thresholdPercent,
234
+ contextCompaction: session.contextCompaction,
235
+ })
236
+ emitSessionEvent(session, {
237
+ type: 'messages_replaced',
238
+ reason: 'auto_compact',
239
+ messages,
240
+ contextCompaction: session.contextCompaction,
241
+ })
242
+ return { compacted: true, usage }
243
+ } catch (error) {
244
+ logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })
245
+ return { compacted: false, usage, reason: 'error', error }
246
+ } finally {
247
+ session.autoCompacting = false
248
+ }
249
+ }
@@ -20,6 +20,9 @@ import {
20
20
  updateSessionThinkingLevel,
21
21
  approveToolCall,
22
22
  rejectToolCall,
23
+ approveAutoCompact,
24
+ rejectAutoCompact,
25
+ abortToolCall,
23
26
  replaceSessionMessages,
24
27
  rollbackSessionMessages,
25
28
  agentEvents,
@@ -225,6 +228,30 @@ export async function handleAgentApi(req, res, url) {
225
228
  return
226
229
  }
227
230
 
231
+ // POST /api/agents/:sessionId/approve-auto-compact — approve a pending automatic context compaction
232
+ if (req.method === 'POST' && subPath === 'approve-auto-compact') {
233
+ const body = await readJsonBody(req)
234
+ const result = approveAutoCompact(sessionId, body?.approvalId)
235
+ sendJson(res, 200, result)
236
+ return
237
+ }
238
+
239
+ // POST /api/agents/:sessionId/reject-auto-compact — skip a pending automatic context compaction
240
+ if (req.method === 'POST' && subPath === 'reject-auto-compact') {
241
+ const body = await readJsonBody(req)
242
+ const result = rejectAutoCompact(sessionId, body?.approvalId)
243
+ sendJson(res, 200, result)
244
+ return
245
+ }
246
+
247
+ // POST /api/agents/:sessionId/abort-tool — abort a running run_command tool call
248
+ if (req.method === 'POST' && subPath === 'abort-tool') {
249
+ const body = await readJsonBody(req)
250
+ const result = abortToolCall(sessionId, body?.toolCallId)
251
+ sendJson(res, 200, result)
252
+ return
253
+ }
254
+
228
255
  const error = new Error('Not found')
229
256
  error.statusCode = 404
230
257
  throw error
@@ -68,7 +68,7 @@ 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
- timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to 60.', default: 60 })),
71
+ timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to 600 (10 minutes).', default: 600 })),
72
72
  }),
73
73
  executionMode: 'sequential',
74
74
  },