@shawnstack/quickforge 1.3.30 → 1.4.1
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/dist/assets/AgentProfilesPage-CNK5PxA3.js +1 -0
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +217 -0
- package/dist/assets/PluginsPage-BCu1Ept0.js +1 -0
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +2 -0
- package/dist/assets/SharedConversationPage-55vX9sqe.js +1 -0
- package/dist/assets/TerminalDock-DLN_pLkJ.js +2 -0
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +6 -0
- package/dist/assets/{icons-BVM5--R9.js → icons-BWtivFsx.js} +1 -1
- package/dist/assets/index-CxOHP41X.css +3 -0
- package/dist/assets/index-Dcf73EL8.js +895 -0
- package/dist/assets/logger-B65Akg8A.js +1 -0
- package/dist/assets/monaco-evITXh-m.js +11 -0
- package/dist/assets/pi-ai-Cx633yhb.js +134 -0
- package/dist/assets/pi-web-ui-CBet4bMl.js +2770 -0
- package/dist/assets/plugin-api-YfYj_Bd7.js +1 -0
- package/dist/assets/{react-vendor-DAoL5p8_.js → react-vendor-Mthyt1p4.js} +1 -1
- package/dist/assets/rolldown-runtime-DWdDZTNf.js +1 -0
- package/dist/assets/xterm-5XDrJ343.js +36 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/index.html +8 -5
- package/package.json +1 -1
- package/server/agent-manager.mjs +189 -31
- package/server/approval-store.mjs +13 -1
- package/server/auto-compaction.mjs +63 -72
- package/server/context-usage.mjs +108 -0
- package/server/custom-commands.mjs +145 -28
- package/server/index.mjs +13 -0
- package/server/mcp/registry.mjs +40 -0
- package/server/routes/agent.mjs +20 -1
- package/server/routes/mcp.mjs +7 -1
- package/server/routes/project.mjs +32 -2
- package/server/routes/shared-conversation.mjs +1 -1
- package/server/storage.mjs +32 -19
- package/server/subagents.mjs +8 -6
- package/server/system-prompt.mjs +2 -2
- package/server/tools/definitions.mjs +1 -1
- package/server/utils/logger.mjs +0 -2
- package/dist/assets/anthropic-DYkQmon0.js +0 -39
- package/dist/assets/azure-openai-responses-B1_ZuuCX.js +0 -1
- package/dist/assets/github-copilot-headers-CMb2BbzT.js +0 -1
- package/dist/assets/google-Bx1PGUtS.js +0 -1
- package/dist/assets/google-shared-Cqjw1plk.js +0 -11
- package/dist/assets/google-vertex-1iRQw75f.js +0 -1
- package/dist/assets/hash-kZ2KD_no.js +0 -1
- package/dist/assets/headers-5EYI0_pl.js +0 -1
- package/dist/assets/index-CQq-kPng.js +0 -3837
- package/dist/assets/index-D0c0FMPa.css +0 -3
- package/dist/assets/mistral-B1j5S2k5.js +0 -44
- package/dist/assets/openai-Bf1npfRy.js +0 -16
- package/dist/assets/openai-codex-responses-BJKEqst-.js +0 -7
- package/dist/assets/openai-completions-B_cU49Pc.js +0 -5
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +0 -1
- package/dist/assets/openai-responses-DgGY16ph.js +0 -1
- package/dist/assets/openai-responses-shared-J1-i-goZ.js +0 -12
- package/dist/assets/openrouter-BVaMghZV.js +0 -1
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +0 -1
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +0 -1
- package/dist/assets/transform-messages-Dhj_4OTw.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}
|
package/dist/index.html
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="QuickForge" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<title>速构 QuickForge</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-Dcf73EL8.js"></script>
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/pi-ai-Cx633yhb.js">
|
|
16
17
|
<link rel="modulepreload" crossorigin href="/assets/lit-vendor-Dr3cpBGF.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/pi-web-ui-CBet4bMl.js">
|
|
17
19
|
<link rel="modulepreload" crossorigin href="/assets/css-utils-rkE68RDy.js">
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/icons-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/assets/react-vendor-
|
|
20
|
-
<link rel="
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/assets/icons-BWtivFsx.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/assets/react-vendor-Mthyt1p4.js">
|
|
22
|
+
<link rel="modulepreload" crossorigin href="/assets/logger-B65Akg8A.js">
|
|
23
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CxOHP41X.css">
|
|
21
24
|
</head>
|
|
22
25
|
<body>
|
|
23
26
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/agent-manager.mjs
CHANGED
|
@@ -35,7 +35,6 @@ import { omitDetailsForLlm, serverConvertToLlm, messageText, lastAssistantText }
|
|
|
35
35
|
import { isPlainObject, mergeQuickForgeTiming, wrapToolDefinition, wrapMcpToolDefinition, wrapPluginToolDefinition, sessionSkillsContext } from './tool-wiring.mjs'
|
|
36
36
|
import {
|
|
37
37
|
APPROVAL_TIMEOUT_MS,
|
|
38
|
-
commandRestrictedTools,
|
|
39
38
|
safeReadTools,
|
|
40
39
|
pendingApprovals,
|
|
41
40
|
pendingAutoCompactApprovals,
|
|
@@ -196,19 +195,14 @@ function createApprovalPromise(session, toolCallId, toolName, args, source) {
|
|
|
196
195
|
source,
|
|
197
196
|
})
|
|
198
197
|
|
|
199
|
-
|
|
200
|
-
// The global SSE handler (/api/agents/events) only listens to `agentEvents`,
|
|
201
|
-
// so events emitted only on session.eventBus never reach the client.
|
|
202
|
-
const approvalEvent = {
|
|
198
|
+
emitSessionEvent(session, {
|
|
203
199
|
type: 'tool_approval_required',
|
|
204
200
|
sessionId: session.sessionId,
|
|
205
201
|
toolCallId,
|
|
206
202
|
toolName,
|
|
207
203
|
args,
|
|
208
204
|
source,
|
|
209
|
-
}
|
|
210
|
-
session.eventBus.emit('agent_event', approvalEvent)
|
|
211
|
-
agentEvents.emit('agent_event', approvalEvent)
|
|
205
|
+
})
|
|
212
206
|
})
|
|
213
207
|
}
|
|
214
208
|
|
|
@@ -312,11 +306,18 @@ function estimateTokenReduction(originalChars, finalChars) {
|
|
|
312
306
|
return Math.max(0, Math.min(99, Math.round(((originalChars - finalChars) / originalChars) * 100)))
|
|
313
307
|
}
|
|
314
308
|
|
|
309
|
+
function nextSessionStateVersion(session) {
|
|
310
|
+
const current = Number.isFinite(session?.stateVersion) ? session.stateVersion : 0
|
|
311
|
+
session.stateVersion = current + 1
|
|
312
|
+
return session.stateVersion
|
|
313
|
+
}
|
|
314
|
+
|
|
315
315
|
function emitSessionEvent(session, event) {
|
|
316
|
+
const stateVersion = nextSessionStateVersion(session)
|
|
316
317
|
const enrichedEvent = (event?.type === 'message_end' || event?.type === 'agent_end' || event?.type === 'messages_replaced' || event?.type === 'auto_compact_completed')
|
|
317
318
|
&& event.contextUsage === undefined
|
|
318
|
-
? { ...event, contextUsage: getSessionContextUsage(session) }
|
|
319
|
-
: event
|
|
319
|
+
? { ...event, contextUsage: getSessionContextUsage(session), stateVersion }
|
|
320
|
+
: { ...event, stateVersion }
|
|
320
321
|
session.eventBus.emit('agent_event', enrichedEvent)
|
|
321
322
|
agentEvents.emit('agent_event', { sessionId: session.sessionId, ...enrichedEvent })
|
|
322
323
|
}
|
|
@@ -543,9 +544,59 @@ async function clearSession(session) {
|
|
|
543
544
|
return { sessionId: session.sessionId, status: session.status, cleared: true }
|
|
544
545
|
}
|
|
545
546
|
|
|
546
|
-
|
|
547
|
+
const QUICKFORGE_COMMAND_DETAILS_KEY = 'quickforgeCommand'
|
|
548
|
+
|
|
549
|
+
function normalizedPromptCommand(command) {
|
|
550
|
+
return command?.type === 'plan' ? { type: 'plan' } : null
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function objectDetails(message) {
|
|
554
|
+
const details = message?.details
|
|
555
|
+
return details && typeof details === 'object' && !Array.isArray(details) ? details : {}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function promptCommandFromMessage(message) {
|
|
559
|
+
return normalizedPromptCommand(objectDetails(message)[QUICKFORGE_COMMAND_DETAILS_KEY])
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function messageWithPromptCommand(message, command) {
|
|
563
|
+
const normalized = normalizedPromptCommand(command)
|
|
564
|
+
if (!normalized || !message || typeof message !== 'object') return message
|
|
565
|
+
return {
|
|
566
|
+
...message,
|
|
567
|
+
details: {
|
|
568
|
+
...objectDetails(message),
|
|
569
|
+
[QUICKFORGE_COMMAND_DETAILS_KEY]: normalized,
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function internalInvocationForPromptCommand(userMessage, command) {
|
|
575
|
+
const normalized = normalizedPromptCommand(command)
|
|
576
|
+
if (normalized?.type === 'plan') {
|
|
577
|
+
// Derive the task from the message text. Strip a leading "/plan" so that
|
|
578
|
+
// toggling plan mode while typing "/plan <task>" yields the clean task —
|
|
579
|
+
// matching the slash-command parse path and avoiding a redundant prefix.
|
|
580
|
+
const raw = messageText(userMessage).trim()
|
|
581
|
+
const planPrefix = raw.match(/^\/plan(?:\s+([\s\S]*))?$/i)
|
|
582
|
+
return { type: 'plan', args: planPrefix ? (planPrefix[1] || '').trim() : raw }
|
|
583
|
+
}
|
|
584
|
+
return parseInternalCommandInvocation(userMessage)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function planCommandState(userMessage, args) {
|
|
588
|
+
return {
|
|
589
|
+
userMessage: messageWithPromptCommand(userMessage, { type: 'plan' }),
|
|
590
|
+
commandPrompt: formatPlanCommandPrompt(args),
|
|
591
|
+
permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
|
|
592
|
+
commandName: 'plan',
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function resolveCommandState(session, userMessage, promptCommand = null) {
|
|
597
|
+
const command = normalizedPromptCommand(promptCommand) || promptCommandFromMessage(userMessage)
|
|
547
598
|
const internalResponse = await handleInternalCommand(
|
|
548
|
-
|
|
599
|
+
internalInvocationForPromptCommand(userMessage, command),
|
|
549
600
|
session.projectContext?.workspaceRoot,
|
|
550
601
|
session.projectContext?.project?.commandDir,
|
|
551
602
|
)
|
|
@@ -553,12 +604,7 @@ async function resolveCommandState(session, userMessage) {
|
|
|
553
604
|
if (internalResponse?.clear) return { clear: internalResponse }
|
|
554
605
|
if (internalResponse?.compact) return { compact: internalResponse }
|
|
555
606
|
if (internalResponse?.plan) {
|
|
556
|
-
return
|
|
557
|
-
userMessage,
|
|
558
|
-
commandPrompt: formatPlanCommandPrompt(internalResponse.args),
|
|
559
|
-
permissions: { allowEdit: false, allowCommands: false, allowSubagents: true },
|
|
560
|
-
commandName: 'plan',
|
|
561
|
-
}
|
|
607
|
+
return planCommandState(userMessage, internalResponse.args)
|
|
562
608
|
}
|
|
563
609
|
if (internalResponse?.review) {
|
|
564
610
|
return {
|
|
@@ -569,7 +615,22 @@ async function resolveCommandState(session, userMessage) {
|
|
|
569
615
|
}
|
|
570
616
|
}
|
|
571
617
|
|
|
572
|
-
if (!session.projectContext?.workspaceRoot)
|
|
618
|
+
if (!session.projectContext?.workspaceRoot) {
|
|
619
|
+
// Even without a project, user-level custom commands (~/.quickforge/commands/) are available
|
|
620
|
+
const invocation = await resolveCustomCommandInvocation(
|
|
621
|
+
userMessage,
|
|
622
|
+
null,
|
|
623
|
+
session.projectContext?.project?.commandDir,
|
|
624
|
+
)
|
|
625
|
+
if (!invocation) return { userMessage }
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
userMessage,
|
|
629
|
+
commandPrompt: invocation.systemPrompt,
|
|
630
|
+
permissions: invocation.permissions,
|
|
631
|
+
commandName: invocation.command.name,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
573
634
|
|
|
574
635
|
const invocation = await resolveCustomCommandInvocation(
|
|
575
636
|
userMessage,
|
|
@@ -1138,6 +1199,7 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1138
1199
|
activeCommandPrompt: null,
|
|
1139
1200
|
eventBus,
|
|
1140
1201
|
idleTimer: null,
|
|
1202
|
+
persistTimer: null,
|
|
1141
1203
|
titleGenerated: false,
|
|
1142
1204
|
toolTimings: new Map(),
|
|
1143
1205
|
getApiKey,
|
|
@@ -1145,6 +1207,7 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1145
1207
|
agentProfile: agentProfile ? agentProfileSnapshot(agentProfile) : null,
|
|
1146
1208
|
lastTransformedContextMessages: null,
|
|
1147
1209
|
autoCompacting: false,
|
|
1210
|
+
stateVersion: 0,
|
|
1148
1211
|
lastAutoCompactAt: null,
|
|
1149
1212
|
lastAutoCompactRejected: null,
|
|
1150
1213
|
/** Track active SSE connections. Only one SSE stream allowed per session to prevent
|
|
@@ -1159,8 +1222,20 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1159
1222
|
// complete session history. Replace with the authoritative full state
|
|
1160
1223
|
// before forwarding to clients.
|
|
1161
1224
|
const timedEvent = addToolTimingToEvent(session, event)
|
|
1162
|
-
const
|
|
1163
|
-
?
|
|
1225
|
+
const eventEndStatus = event.type === 'agent_end'
|
|
1226
|
+
? session.agent.signal?.aborted
|
|
1227
|
+
? 'aborted'
|
|
1228
|
+
: session.agent.state.errorMessage
|
|
1229
|
+
? 'error'
|
|
1230
|
+
: 'idle'
|
|
1231
|
+
: undefined
|
|
1232
|
+
const forwardEvent = timedEvent.type === 'agent_end'
|
|
1233
|
+
? {
|
|
1234
|
+
...timedEvent,
|
|
1235
|
+
...(timedEvent.messages ? { messages: agent.state.messages } : {}),
|
|
1236
|
+
status: eventEndStatus,
|
|
1237
|
+
...(session.agent.state.errorMessage && timedEvent.errorMessage === undefined ? { errorMessage: session.agent.state.errorMessage } : {}),
|
|
1238
|
+
}
|
|
1164
1239
|
: timedEvent
|
|
1165
1240
|
|
|
1166
1241
|
// Forward all events to the session event bus and the global bus.
|
|
@@ -1178,22 +1253,21 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1178
1253
|
}
|
|
1179
1254
|
|
|
1180
1255
|
if (event.type === 'agent_end') {
|
|
1181
|
-
session.status = session.agent.state.errorMessage ? 'error' : 'idle'
|
|
1256
|
+
session.status = eventEndStatus || (session.agent.state.errorMessage ? 'error' : 'idle')
|
|
1182
1257
|
session.finishedAt = new Date().toISOString()
|
|
1183
1258
|
session.toolTimings?.clear()
|
|
1184
1259
|
resetIdleTimer(session)
|
|
1185
1260
|
|
|
1186
|
-
// Persist after run ends
|
|
1187
|
-
|
|
1261
|
+
// Persist after run ends. Flush any debounced write so the final state is durable.
|
|
1262
|
+
flushSessionPersist(session).catch((err) =>
|
|
1188
1263
|
logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId }),
|
|
1189
1264
|
)
|
|
1190
1265
|
}
|
|
1191
1266
|
|
|
1192
1267
|
if (event.type === 'message_end') {
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
)
|
|
1268
|
+
// Debounced persist for crash recovery; coalesces the many message_end
|
|
1269
|
+
// events within a single run into infrequent full-session writes.
|
|
1270
|
+
scheduleSessionPersist(session)
|
|
1197
1271
|
}
|
|
1198
1272
|
})
|
|
1199
1273
|
|
|
@@ -1337,6 +1411,44 @@ async function persistSession(session) {
|
|
|
1337
1411
|
}
|
|
1338
1412
|
|
|
1339
1413
|
export async function persistSessionState(session) {
|
|
1414
|
+
await flushSessionPersist(session)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Coalesce fire-and-forget session persists during a run.
|
|
1419
|
+
*
|
|
1420
|
+
* persistSession() serializes the ENTIRE session (all messages) on every call,
|
|
1421
|
+
* and the agent event loop calls it on agent_start / each message_end / agent_end.
|
|
1422
|
+
* Within a single run these events fire many times (one per assistant turn +
|
|
1423
|
+
* tool result), so writing on each one makes cumulative disk I/O O(n^2) as a
|
|
1424
|
+
* conversation grows. These message_end call sites are fire-and-forget
|
|
1425
|
+
* (crash-recovery only), so we debounce them into at most one write per
|
|
1426
|
+
* PERSIST_DEBOUNCE_MS. Run boundaries (agent_end) and explicit persists cancel
|
|
1427
|
+
* the pending timer and write the current state immediately, so the final
|
|
1428
|
+
* state is always durable.
|
|
1429
|
+
*/
|
|
1430
|
+
const PERSIST_DEBOUNCE_MS = 400
|
|
1431
|
+
|
|
1432
|
+
function scheduleSessionPersist(session) {
|
|
1433
|
+
if (session.persistTimer) return
|
|
1434
|
+
session.persistTimer = setTimeout(() => {
|
|
1435
|
+
session.persistTimer = null
|
|
1436
|
+
persistSession(session).catch((err) =>
|
|
1437
|
+
logger.error(`Failed to persist session ${session.sessionId}:`, err, { sessionId: session.sessionId }),
|
|
1438
|
+
)
|
|
1439
|
+
}, PERSIST_DEBOUNCE_MS).unref?.()
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Cancel any pending debounced write and persist the current state immediately.
|
|
1444
|
+
* Used at run boundaries (agent_end) and by explicit persistSessionState() so
|
|
1445
|
+
* the final state is always durable regardless of a pending timer.
|
|
1446
|
+
*/
|
|
1447
|
+
async function flushSessionPersist(session) {
|
|
1448
|
+
if (session.persistTimer) {
|
|
1449
|
+
clearTimeout(session.persistTimer)
|
|
1450
|
+
session.persistTimer = null
|
|
1451
|
+
}
|
|
1340
1452
|
await persistSession(session)
|
|
1341
1453
|
}
|
|
1342
1454
|
|
|
@@ -1399,7 +1511,7 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
|
|
|
1399
1511
|
* Send a user message to the agent and start the agent loop.
|
|
1400
1512
|
* Returns immediately; events are streamed via the event bus.
|
|
1401
1513
|
*/
|
|
1402
|
-
export async function runPrompt(sessionId, message, selectedCapabilities = []) {
|
|
1514
|
+
export async function runPrompt(sessionId, message, selectedCapabilities = [], promptCommand = null) {
|
|
1403
1515
|
let session = agentSessions.get(sessionId)
|
|
1404
1516
|
if (!session) {
|
|
1405
1517
|
session = await restoreAgent(sessionId)
|
|
@@ -1418,7 +1530,7 @@ export async function runPrompt(sessionId, message, selectedCapabilities = []) {
|
|
|
1418
1530
|
const initialUserMessage = typeof message === 'string'
|
|
1419
1531
|
? { role: 'user', content: message, timestamp: new Date().toISOString() }
|
|
1420
1532
|
: message
|
|
1421
|
-
const commandState = await resolveCommandState(session, initialUserMessage)
|
|
1533
|
+
const commandState = await resolveCommandState(session, initialUserMessage, promptCommand)
|
|
1422
1534
|
const userMessage = commandState.userMessage ?? initialUserMessage
|
|
1423
1535
|
|
|
1424
1536
|
if (commandState.textResponse) {
|
|
@@ -1516,14 +1628,27 @@ export async function continueSession(sessionId) {
|
|
|
1516
1628
|
throw Object.assign(new Error('Cannot continue: no user message found.'), { statusCode: 400 })
|
|
1517
1629
|
}
|
|
1518
1630
|
|
|
1519
|
-
const
|
|
1631
|
+
const lastUserMessage = messages[lastUserIndex]
|
|
1632
|
+
const commandState = await resolveCommandState(session, lastUserMessage)
|
|
1633
|
+
const continuedUserMessage = commandState.userMessage ?? lastUserMessage
|
|
1634
|
+
const trimmedMessages = messages.slice(0, lastUserIndex).concat(continuedUserMessage)
|
|
1520
1635
|
updateSessionMessages(session, trimmedMessages)
|
|
1521
1636
|
resetSessionCompaction(session)
|
|
1522
1637
|
|
|
1523
1638
|
resetIdleTimer(session)
|
|
1639
|
+
session.activeCommandName = commandState.commandName ?? null
|
|
1640
|
+
session.activeCommandPermissions = commandState.permissions ?? null
|
|
1641
|
+
session.activeCommandPrompt = commandState.commandPrompt ?? null
|
|
1642
|
+
session.activeCapabilityPrompt = null
|
|
1643
|
+
|
|
1524
1644
|
session.agent.continue().catch((err) => {
|
|
1525
1645
|
logger.error(`Agent continue error for session ${sessionId}:`, err, { sessionId })
|
|
1526
1646
|
emitSessionEvent(session, { type: 'error', error: err.message || 'Unknown error' })
|
|
1647
|
+
}).finally(() => {
|
|
1648
|
+
session.activeCommandName = null
|
|
1649
|
+
session.activeCommandPermissions = null
|
|
1650
|
+
session.activeCommandPrompt = null
|
|
1651
|
+
session.activeCapabilityPrompt = null
|
|
1527
1652
|
})
|
|
1528
1653
|
|
|
1529
1654
|
return { sessionId, status: 'running' }
|
|
@@ -1562,6 +1687,7 @@ export async function abortRun(sessionId) {
|
|
|
1562
1687
|
)
|
|
1563
1688
|
const event = {
|
|
1564
1689
|
type: 'agent_end',
|
|
1690
|
+
status: 'aborted',
|
|
1565
1691
|
messages: session.agent.state.messages,
|
|
1566
1692
|
}
|
|
1567
1693
|
emitSessionEvent(session, event)
|
|
@@ -1631,6 +1757,7 @@ export function getSessionState(sessionId) {
|
|
|
1631
1757
|
title: session.title,
|
|
1632
1758
|
createdAt: session.createdAt,
|
|
1633
1759
|
lastModified: session.lastModified,
|
|
1760
|
+
stateVersion: session.stateVersion || 0,
|
|
1634
1761
|
status: session.status,
|
|
1635
1762
|
startedAt: session.startedAt,
|
|
1636
1763
|
finishedAt: session.finishedAt,
|
|
@@ -1643,6 +1770,33 @@ export function getSessionState(sessionId) {
|
|
|
1643
1770
|
}
|
|
1644
1771
|
}
|
|
1645
1772
|
|
|
1773
|
+
/**
|
|
1774
|
+
* Get a lightweight status snapshot for SSE-first state recovery.
|
|
1775
|
+
*/
|
|
1776
|
+
export function getSessionStatus(sessionId) {
|
|
1777
|
+
const session = agentSessions.get(sessionId)
|
|
1778
|
+
if (!session) return null
|
|
1779
|
+
|
|
1780
|
+
const messages = session.agent.state.messages || []
|
|
1781
|
+
const lastMessage = messages[messages.length - 1]
|
|
1782
|
+
return {
|
|
1783
|
+
sessionId: session.sessionId,
|
|
1784
|
+
scope: session.scope,
|
|
1785
|
+
projectId: session.projectId,
|
|
1786
|
+
title: session.title,
|
|
1787
|
+
createdAt: session.createdAt,
|
|
1788
|
+
lastModified: session.lastModified,
|
|
1789
|
+
stateVersion: session.stateVersion || 0,
|
|
1790
|
+
status: session.status,
|
|
1791
|
+
startedAt: session.startedAt,
|
|
1792
|
+
finishedAt: session.finishedAt,
|
|
1793
|
+
isStreaming: session.agent.state.isStreaming,
|
|
1794
|
+
errorMessage: session.agent.state.errorMessage,
|
|
1795
|
+
messageCount: messages.length,
|
|
1796
|
+
lastMessageTimestamp: lastMessage?.timestamp ?? null,
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1646
1800
|
/**
|
|
1647
1801
|
* Try to claim the SSE slot for a session. Returns true if acquired, false if
|
|
1648
1802
|
* another tab already holds the SSE connection for this session.
|
|
@@ -1689,6 +1843,10 @@ export async function destroyAgent(sessionId) {
|
|
|
1689
1843
|
logger.info(`Destroying session ${sessionId} (status: ${session.status})`, { sessionId, status: session.status })
|
|
1690
1844
|
|
|
1691
1845
|
if (session.idleTimer) clearTimeout(session.idleTimer)
|
|
1846
|
+
if (session.persistTimer) {
|
|
1847
|
+
clearTimeout(session.persistTimer)
|
|
1848
|
+
session.persistTimer = null
|
|
1849
|
+
}
|
|
1692
1850
|
session.toolTimings?.clear()
|
|
1693
1851
|
|
|
1694
1852
|
try {
|
|
@@ -24,6 +24,14 @@ export const commandRestrictedTools = new Set([
|
|
|
24
24
|
'run_subagent',
|
|
25
25
|
])
|
|
26
26
|
|
|
27
|
+
export const planAllowedTools = new Set([
|
|
28
|
+
'read_file',
|
|
29
|
+
'grep_files',
|
|
30
|
+
'activate_skill',
|
|
31
|
+
'read_skill_resource',
|
|
32
|
+
'run_subagent',
|
|
33
|
+
])
|
|
34
|
+
|
|
27
35
|
export const safeReadTools = new Set([
|
|
28
36
|
'read_file',
|
|
29
37
|
'grep_files',
|
|
@@ -45,7 +53,11 @@ export const pendingAutoCompactApprovals = new Map()
|
|
|
45
53
|
|
|
46
54
|
export function commandToolPermissionError(session, toolName) {
|
|
47
55
|
const permissions = session?.activeCommandPermissions
|
|
48
|
-
if (!permissions
|
|
56
|
+
if (!permissions) return null
|
|
57
|
+
if (session?.activeCommandName === 'plan' && !planAllowedTools.has(toolName)) {
|
|
58
|
+
return `Command /plan is read-only and cannot use ${toolName}.`
|
|
59
|
+
}
|
|
60
|
+
if (!commandRestrictedTools.has(toolName)) return null
|
|
49
61
|
if (toolName === 'run_command' && permissions.allowCommands === false) {
|
|
50
62
|
return `Command /${session.activeCommandName} does not allow running shell commands.`
|
|
51
63
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readStore } from './storage.mjs'
|
|
2
2
|
import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
|
|
3
|
+
import { estimateContextUsage, shouldCompactContextByPercent } from './context-usage.mjs'
|
|
3
4
|
|
|
4
5
|
export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
|
|
5
6
|
|
|
@@ -44,14 +45,6 @@ function safeJson(value) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function estimateTextTokens(value) {
|
|
48
|
-
const text = String(value || '')
|
|
49
|
-
if (!text) return 0
|
|
50
|
-
const cjkChars = text.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0
|
|
51
|
-
const otherChars = Math.max(0, text.length - cjkChars)
|
|
52
|
-
return Math.ceil(cjkChars + otherChars / 3.5)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
function contentToText(content) {
|
|
56
49
|
if (typeof content === 'string') return content
|
|
57
50
|
if (!Array.isArray(content)) return ''
|
|
@@ -65,19 +58,6 @@ function contentToText(content) {
|
|
|
65
58
|
}).filter(Boolean).join('\n')
|
|
66
59
|
}
|
|
67
60
|
|
|
68
|
-
function estimateMessageTokens(message) {
|
|
69
|
-
if (!message || typeof message !== 'object') return 0
|
|
70
|
-
const parts = [message.role || '', contentToText(message.content)]
|
|
71
|
-
if (message.toolName) parts.push(message.toolName)
|
|
72
|
-
if (message.toolCallId) parts.push(message.toolCallId)
|
|
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
61
|
function estimateMessagesChars(messages) {
|
|
82
62
|
return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
|
|
83
63
|
if (!message || typeof message !== 'object') return total
|
|
@@ -85,50 +65,6 @@ function estimateMessagesChars(messages) {
|
|
|
85
65
|
}, 0)
|
|
86
66
|
}
|
|
87
67
|
|
|
88
|
-
function messageTimestampMs(message) {
|
|
89
|
-
const timestamp = message?.timestamp
|
|
90
|
-
if (typeof timestamp === 'number') return timestamp
|
|
91
|
-
if (typeof timestamp === 'string') {
|
|
92
|
-
const parsed = Date.parse(timestamp)
|
|
93
|
-
return Number.isNaN(parsed) ? 0 : parsed
|
|
94
|
-
}
|
|
95
|
-
return 0
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function latestCompactTimestampMs(session) {
|
|
99
|
-
return messageTimestampMs(session?.contextCompaction?.summaryMessage)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function latestKnownInputTokens(messages, sinceTimestamp = 0) {
|
|
103
|
-
let latestTimestamp = -1
|
|
104
|
-
let latestInput = 0
|
|
105
|
-
for (const message of Array.isArray(messages) ? messages : []) {
|
|
106
|
-
if (message?.role !== 'assistant' || !message.usage) continue
|
|
107
|
-
const timestamp = messageTimestampMs(message)
|
|
108
|
-
if (sinceTimestamp > 0 && timestamp <= sinceTimestamp) continue
|
|
109
|
-
if (timestamp < latestTimestamp) continue
|
|
110
|
-
const input = Math.max(0, Number(message.usage.input ?? message.usage.totalTokens) || 0)
|
|
111
|
-
if (input <= 0) continue
|
|
112
|
-
latestTimestamp = timestamp
|
|
113
|
-
latestInput = input
|
|
114
|
-
}
|
|
115
|
-
return latestInput
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function estimateContextUsage({ systemPrompt, messages, tools, model, knownInputTokens = 0 }) {
|
|
119
|
-
const contextWindow = Number(model?.contextWindow) || 0
|
|
120
|
-
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
121
|
-
const estimatedInputTokens =
|
|
122
|
-
estimateTextTokens(systemPrompt) +
|
|
123
|
-
estimateMessagesTokens(messages) +
|
|
124
|
-
estimateTextTokens(safeJson(tools))
|
|
125
|
-
const knownInput = Math.max(0, Number(knownInputTokens) || 0)
|
|
126
|
-
const inputTokens = Math.max(estimatedInputTokens, knownInput)
|
|
127
|
-
const totalTokens = inputTokens + reservedOutputTokens
|
|
128
|
-
const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
|
|
129
|
-
return { inputTokens, estimatedInputTokens, knownInputTokens: knownInput, reservedOutputTokens, totalTokens, contextWindow, percent }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
68
|
function isUserMessage(message) {
|
|
133
69
|
return message?.role === 'user' || message?.role === 'user-with-attachments'
|
|
134
70
|
}
|
|
@@ -220,17 +156,74 @@ export function estimateSessionContextUsage(session, messages = session?.agent?.
|
|
|
220
156
|
const sourceMessages = Array.isArray(messages) ? messages : []
|
|
221
157
|
const contextWindow = Number(session.model?.contextWindow) || 0
|
|
222
158
|
if (sourceMessages.length === 0) {
|
|
223
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
inputTokens: 0,
|
|
161
|
+
estimatedInputTokens: 0,
|
|
162
|
+
knownInputTokens: 0,
|
|
163
|
+
inputTokenSource: 'estimated',
|
|
164
|
+
reservedOutputTokens: 0,
|
|
165
|
+
totalTokens: 0,
|
|
166
|
+
contextWindow,
|
|
167
|
+
percent: 0,
|
|
168
|
+
isCompacted: false,
|
|
169
|
+
originalMessageCount: 0,
|
|
170
|
+
effectiveMessageCount: 0,
|
|
171
|
+
breakdown: {
|
|
172
|
+
systemPromptTokens: 0,
|
|
173
|
+
messagesTokens: 0,
|
|
174
|
+
toolsTokens: 0,
|
|
175
|
+
reservedOutputTokens: 0,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
224
178
|
}
|
|
179
|
+
|
|
180
|
+
// Cache by input identity. Context usage delegates message token estimation
|
|
181
|
+
// to pi-agent-core and JSON-stringifies the full tools array, but its
|
|
182
|
+
// inputs (messages, model, systemPrompt, tools, contextCompaction) are stable
|
|
183
|
+
// within a run and only change on discrete events (message_end, tool result,
|
|
184
|
+
// compaction). Reference equality makes the cache check essentially free, so
|
|
185
|
+
// the repeated calls from emitSessionEvent() on message_end/agent_end/etc.
|
|
186
|
+
// only recompute when something actually changed.
|
|
187
|
+
const lastMessage = sourceMessages[sourceMessages.length - 1]
|
|
188
|
+
const cacheKey = {
|
|
189
|
+
messages,
|
|
190
|
+
messagesLength: sourceMessages.length,
|
|
191
|
+
lastMessage,
|
|
192
|
+
model: session.model,
|
|
193
|
+
systemPrompt: session.agent.state.systemPrompt,
|
|
194
|
+
tools: session.agent.state.tools,
|
|
195
|
+
contextCompaction: session.contextCompaction,
|
|
196
|
+
}
|
|
197
|
+
const cached = session._contextUsageCache
|
|
198
|
+
if (
|
|
199
|
+
cached &&
|
|
200
|
+
cached.key.messages === cacheKey.messages &&
|
|
201
|
+
cached.key.messagesLength === cacheKey.messagesLength &&
|
|
202
|
+
cached.key.lastMessage === cacheKey.lastMessage &&
|
|
203
|
+
cached.key.model === cacheKey.model &&
|
|
204
|
+
cached.key.systemPrompt === cacheKey.systemPrompt &&
|
|
205
|
+
cached.key.tools === cacheKey.tools &&
|
|
206
|
+
cached.key.contextCompaction === cacheKey.contextCompaction
|
|
207
|
+
) {
|
|
208
|
+
return cached.value
|
|
209
|
+
}
|
|
210
|
+
|
|
225
211
|
const loopMessages = buildAutoCompactLoopMessages(session, sourceMessages)
|
|
226
|
-
const
|
|
227
|
-
return estimateContextUsage({
|
|
212
|
+
const value = estimateContextUsage({
|
|
228
213
|
systemPrompt: session.agent.state.systemPrompt,
|
|
229
214
|
messages: loopMessages,
|
|
230
215
|
tools: session.agent.state.tools,
|
|
231
216
|
model: session.model,
|
|
232
|
-
knownInputTokens,
|
|
233
217
|
})
|
|
218
|
+
value.isCompacted = loopMessages !== sourceMessages
|
|
219
|
+
value.originalMessageCount = sourceMessages.length
|
|
220
|
+
value.effectiveMessageCount = loopMessages.length
|
|
221
|
+
if (session.contextCompaction?.summaryMessage) {
|
|
222
|
+
value.compactedUpToIndex = Math.min(sourceMessages.length, Math.max(0, Number(session.contextCompaction.compactedUpToIndex) || 0))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
session._contextUsageCache = { key: cacheKey, value }
|
|
226
|
+
return value
|
|
234
227
|
}
|
|
235
228
|
|
|
236
229
|
export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
|
|
@@ -240,16 +233,14 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
|
|
|
240
233
|
if (signal?.aborted) return { compacted: false, reason: 'aborted' }
|
|
241
234
|
|
|
242
235
|
const loopMessages = buildAutoCompactLoopMessages(session, messages)
|
|
243
|
-
const knownInputTokens = latestKnownInputTokens(messages, latestCompactTimestampMs(session))
|
|
244
236
|
const usage = estimateContextUsage({
|
|
245
237
|
systemPrompt: session.agent.state.systemPrompt,
|
|
246
238
|
messages: loopMessages,
|
|
247
239
|
tools: session.agent.state.tools,
|
|
248
240
|
model: session.model,
|
|
249
|
-
knownInputTokens,
|
|
250
241
|
})
|
|
251
242
|
if (!usage.contextWindow) return { compacted: false, usage, reason: 'missing_context_window' }
|
|
252
|
-
if (usage
|
|
243
|
+
if (!shouldCompactContextByPercent(usage, settings.thresholdPercent)) return { compacted: false, usage, reason: 'below_threshold' }
|
|
253
244
|
if (shouldSuppressAfterRejection(session, messages, usage)) return { compacted: false, usage, reason: 'user_rejected_recently' }
|
|
254
245
|
|
|
255
246
|
const now = Date.now()
|