@electerm/electerm-react 5.5.35 → 5.5.56

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 (40) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/common/constants.js +1 -0
  3. package/client/common/parse-quick-connect.js +1 -0
  4. package/client/common/parse-widget-log.js +57 -0
  5. package/client/components/ai/agent-tool-call-card.jsx +7 -3
  6. package/client/components/ai/agent.js +176 -33
  7. package/client/components/ai/ai-auto-compress.js +119 -0
  8. package/client/components/ai/ai-chat-history-item.jsx +125 -40
  9. package/client/components/ai/ai-chat.jsx +67 -19
  10. package/client/components/ai/ai-compress.js +95 -0
  11. package/client/components/ai/ai-config.jsx +29 -16
  12. package/client/components/ai/ai-config.styl +20 -0
  13. package/client/components/ai/ai-context.js +1 -1
  14. package/client/components/ai/ai-output.jsx +1 -6
  15. package/client/components/ai/ai-presets.js +0 -15
  16. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  17. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  18. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  19. package/client/components/bookmark-form/config/ssh.js +1 -0
  20. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  21. package/client/components/file-transfer/transfer.jsx +3 -119
  22. package/client/components/main/main.jsx +1 -0
  23. package/client/components/session/session-control.jsx +35 -2
  24. package/client/components/session/session-control.styl +5 -0
  25. package/client/components/session/session.jsx +13 -0
  26. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  27. package/client/components/sftp/file-item.jsx +0 -13
  28. package/client/components/terminal/mixins/term-touch.js +18 -0
  29. package/client/components/widgets/widget-control.jsx +4 -2
  30. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  31. package/client/components/widgets/widget-instance.jsx +30 -13
  32. package/client/components/widgets/widget-instances.jsx +3 -1
  33. package/client/components/widgets/widgets-list.jsx +8 -0
  34. package/client/components/widgets/widgets.styl +100 -0
  35. package/client/store/common.js +81 -23
  36. package/client/store/init-state.js +7 -0
  37. package/client/store/load-data.js +1 -0
  38. package/client/store/widgets.js +23 -5
  39. package/package.json +1 -1
  40. package/client/components/file-transfer/zip.js +0 -42
@@ -62,6 +62,7 @@ export const sshBookmarkSchema = {
62
62
  enableSftp: z.boolean().optional().describe('Enable sftp, default is true'),
63
63
  useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'),
64
64
  sshAgent: z.string().optional().describe('SSH agent path'),
65
+ agentForward: z.boolean().optional().describe('Enable SSH agent forwarding (like ssh -A), requires SSH agent, default is false'),
65
66
  serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
66
67
  cipher: z.array(z.string()).optional().describe('Cipher list'),
67
68
  compress: z.array(z.string()).optional().describe('Compression algorithms'),
@@ -302,6 +302,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
302
302
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
303
303
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
304
304
  export const aiChatModeLsKey = 'ai-chat-mode'
305
+ export const aiAutoCompressLsKey = 'ai-auto-compress'
305
306
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
306
307
  export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
307
308
  export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
@@ -53,6 +53,7 @@ const TYPE_DEFAULT_VALUES = {
53
53
  enableSsh: true,
54
54
  enableSftp: true,
55
55
  useSshAgent: true,
56
+ agentForward: false,
56
57
  authType: 'password',
57
58
  term: 'xterm-256color',
58
59
  encode: 'utf-8',
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parse a widget log file into the two streams the widget panel shows.
3
+ *
4
+ * The writer is src/app/widgets/instance-log.js; the format is one entry per
5
+ * line, pipe separated:
6
+ * <iso time>|log|<level>|<message>
7
+ * <iso time>|conn|<ok|fail|info>|<type>|<from>|<message>
8
+ * A message may contain pipes, so everything after the last known separator is
9
+ * taken as the message.
10
+ */
11
+
12
+ const CONN_STATE = {
13
+ ok: true,
14
+ fail: false,
15
+ info: null
16
+ }
17
+
18
+ export function parseWidgetLog (text) {
19
+ const logs = []
20
+ const events = []
21
+ for (const line of String(text || '').split('\n')) {
22
+ if (!line) {
23
+ continue
24
+ }
25
+ const parts = line.split('|')
26
+ if (parts.length < 4) {
27
+ continue
28
+ }
29
+ const ts = Date.parse(parts[0])
30
+ if (!ts) {
31
+ continue
32
+ }
33
+ if (parts[1] === 'conn') {
34
+ if (parts.length < 6) {
35
+ continue
36
+ }
37
+ events.push({
38
+ ts,
39
+ ok: Object.prototype.hasOwnProperty.call(CONN_STATE, parts[2])
40
+ ? CONN_STATE[parts[2]]
41
+ : null,
42
+ type: parts[3],
43
+ from: parts[4] === '-' ? '' : parts[4],
44
+ msg: parts.slice(5).join('|')
45
+ })
46
+ } else {
47
+ logs.push({
48
+ ts,
49
+ level: parts[2],
50
+ msg: parts.slice(3).join('|')
51
+ })
52
+ }
53
+ }
54
+ return { logs, events }
55
+ }
56
+
57
+ export default parseWidgetLog
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, memo } from 'react'
2
2
  import { Tag } from 'antd'
3
3
  import {
4
4
  CaretDownOutlined,
@@ -34,7 +34,11 @@ function formatResult (result) {
34
34
  }
35
35
  }
36
36
 
37
- export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
37
+ // memo()'d on the entry object: `formatResult` JSON.parses the whole tool
38
+ // result on every render, and an agent turn can hold dozens of these. The
39
+ // agent loop swaps in a new entry object when a call finishes
40
+ // (runAgentLoop), so an unchanged card keeps its identity and is skipped.
41
+ export default memo(function AgentToolCallCard ({ toolCall, autoCollapse }) {
38
42
  const [expanded, setExpanded] = useState(toolCall.status === 'running')
39
43
  const { name, args, status, result } = toolCall
40
44
  const Icon = toolIcons[name] || CodeOutlined
@@ -110,4 +114,4 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
110
114
  )}
111
115
  </div>
112
116
  )
113
- }
117
+ })
@@ -1,9 +1,31 @@
1
1
  import { agentTools, executeToolCall } from './agent-tools'
2
2
  import { appendMandatoryGuardrails } from './ai-guardrails'
3
- import { buildAgentMessages, summarizeContext } from './ai-context'
3
+ import { buildAgentMessages, summarizeContext, CONTEXT_DANGER_PERCENT } from './ai-context'
4
+ import {
5
+ shouldAutoCompress,
6
+ canCompact,
7
+ applySummary
8
+ } from './ai-auto-compress'
9
+ import {
10
+ autoCompressEnabled,
11
+ summarizeMessages,
12
+ autoCompressSession
13
+ } from './ai-compress'
14
+ import uid from '../../common/uid'
4
15
 
5
16
  const MAX_ITERATIONS = 150
6
17
 
18
+ // A summary request that failed (provider hiccup, rate limit) must not be
19
+ // retried on every following iteration -- that would turn a full window into a
20
+ // call per tool result. Only try again once the estimate has grown by this
21
+ // share of the window.
22
+ const AUTO_COMPRESS_RETRY_GROWTH_PERCENT = 5
23
+
24
+ // Which loop currently owns `store.agentRunning`. A stopped run keeps
25
+ // unwinding for a moment (its last request has to settle), and it must not
26
+ // clear the flag of a run the user started in the meantime.
27
+ let activeRunId = null
28
+
7
29
  function buildAgentSystemPrompt (config) {
8
30
  const lang = config.languageAI || window.store.getLangName()
9
31
  const baseRole = config.roleAI || 'You are a helpful assistant.'
@@ -27,14 +49,10 @@ Reply in ${lang} language.`)
27
49
  }
28
50
 
29
51
  function updateChatEntry (chatEntry, updates) {
30
- const index = window.store.aiChatHistory.findIndex(i => i.id === chatEntry.id)
31
- if (index !== -1) {
32
- Object.assign(window.store.aiChatHistory[index], updates)
33
- window.store.aiChatHistory = [...window.store.aiChatHistory]
34
- }
52
+ window.store.updateAiHistoryEntry(chatEntry.id, updates)
35
53
  }
36
54
 
37
- async function callBackendAIchatWithTools (messages, config) {
55
+ async function callBackendAIchatWithTools (messages, config, requestId) {
38
56
  return window.pre.runGlobalAsync(
39
57
  'AIchatWithTools',
40
58
  messages,
@@ -44,7 +62,11 @@ async function callBackendAIchatWithTools (messages, config) {
44
62
  config.apiKeyAI,
45
63
  config.proxyAI,
46
64
  agentTools,
47
- config.authHeaderNameAI
65
+ config.authHeaderNameAI,
66
+ // `format` stays undefined (the main process detects it); `requestId` is
67
+ // the last argument so that exact request can be cancelled on stop.
68
+ undefined,
69
+ requestId
48
70
  )
49
71
  }
50
72
 
@@ -67,7 +89,16 @@ function publishContext (messages, config, usage) {
67
89
  }
68
90
 
69
91
  export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
92
+ const runId = uid()
93
+ activeRunId = runId
70
94
  window.store.agentRunning = true
95
+ const isAborted = () => !!(abortRef && abortRef.current)
96
+ // Auto compression bookkeeping, read by the finally block as well: how many
97
+ // times this run compacted itself, whether it failed on the way, and the
98
+ // size at the last summary attempt (AUTO_COMPRESS_RETRY_GROWTH_PERCENT)
99
+ let autoCompressCount = 0
100
+ let autoCompressAttemptTokens = 0
101
+ let runErrored = false
71
102
  try {
72
103
  const messages = buildAgentMessages({
73
104
  systemPrompt: buildAgentSystemPrompt(config),
@@ -78,6 +109,13 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
78
109
  let accumulatedContent = ''
79
110
  let lastUsage = null
80
111
 
112
+ const markStopped = () => {
113
+ setIsStreaming(false)
114
+ updateChatEntry(chatEntry, {
115
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
116
+ })
117
+ }
118
+
81
119
  setIsStreaming(true)
82
120
  updateChatEntry(chatEntry, {
83
121
  toolCalls: [],
@@ -85,21 +123,79 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
85
123
  })
86
124
 
87
125
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
88
- if (abortRef && abortRef.current) {
89
- setIsStreaming(false)
90
- updateChatEntry(chatEntry, {
91
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
92
- })
126
+ if (isAborted()) {
127
+ markStopped()
93
128
  return
94
129
  }
95
130
 
96
131
  publishContext(messages, config, lastUsage)
97
- const result = await callBackendAIchatWithTools(messages, config)
132
+
133
+ // Auto compression, when the popover toggle is on: the request about to
134
+ // go out carries the whole conversation plus a tool result per step, and
135
+ // past ~90% of the window the provider rejects it outright. Replacing the
136
+ // conversation with a summary first keeps the run alive; the stored
137
+ // session is compacted at the end (autoCompressSession), because that is
138
+ // what the next turn starts from.
139
+ const info = window.store.aiContextInfo
140
+ // A failed summary is only worth retrying once the context has grown by
141
+ // this much -- but never more than the trigger itself, or a threshold
142
+ // below it would let the retry floor decide when to compress.
143
+ const growthPercent = Math.min(
144
+ AUTO_COMPRESS_RETRY_GROWTH_PERCENT,
145
+ CONTEXT_DANGER_PERCENT
146
+ )
147
+ const grewEnough = info &&
148
+ info.tokens - autoCompressAttemptTokens >=
149
+ (info.windowSize || 0) * growthPercent / 100
150
+ if (
151
+ autoCompressEnabled() &&
152
+ canCompact(messages) &&
153
+ shouldAutoCompress(info) &&
154
+ grewEnough
155
+ ) {
156
+ autoCompressAttemptTokens = info.tokens
157
+ // Shown before the request rather than after: summarizing a nearly full
158
+ // window takes a while, and this badge is the only sign of why the run
159
+ // is waiting. Taken back if the summary comes back unusable.
160
+ updateChatEntry(chatEntry, { autoCompressCount: autoCompressCount + 1 })
161
+ const summary = await summarizeMessages(messages, config)
162
+ if (isAborted()) {
163
+ markStopped()
164
+ return
165
+ }
166
+ if (summary) {
167
+ applySummary(messages, summary)
168
+ autoCompressCount++
169
+ lastUsage = null
170
+ publishContext(messages, config, null)
171
+ } else {
172
+ updateChatEntry(chatEntry, { autoCompressCount })
173
+ }
174
+ }
175
+
176
+ const requestId = uid()
177
+ if (abortRef) {
178
+ abortRef.requestId = requestId
179
+ }
180
+ const result = await callBackendAIchatWithTools(messages, config, requestId)
181
+ if (abortRef && abortRef.requestId === requestId) {
182
+ abortRef.requestId = null
183
+ }
184
+
185
+ // Re-check after the await, before anything from the answer is used:
186
+ // stopping cancels the request, and neither the partial answer nor the
187
+ // resulting error belongs in the transcript.
188
+ if (isAborted()) {
189
+ markStopped()
190
+ return
191
+ }
192
+
98
193
  if (result.usage) {
99
194
  lastUsage = result.usage
100
195
  }
101
196
 
102
197
  if (result.error) {
198
+ runErrored = true
103
199
  setIsStreaming(false)
104
200
  updateChatEntry(chatEntry, {
105
201
  response: accumulatedContent + `\n\n**Error:** ${result.error}`
@@ -109,6 +205,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
109
205
 
110
206
  const assistantMessage = result.message
111
207
  if (!assistantMessage) {
208
+ runErrored = true
112
209
  setIsStreaming(false)
113
210
  updateChatEntry(chatEntry, {
114
211
  response: accumulatedContent || 'No response from AI.'
@@ -135,11 +232,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
135
232
  }
136
233
 
137
234
  for (const toolCall of assistantMessage.tool_calls) {
138
- if (abortRef && abortRef.current) {
139
- setIsStreaming(false)
140
- updateChatEntry(chatEntry, {
141
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
142
- })
235
+ if (isAborted()) {
236
+ markStopped()
143
237
  return
144
238
  }
145
239
 
@@ -150,28 +244,32 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
150
244
  args = {}
151
245
  }
152
246
 
153
- const toolEntry = {
247
+ toolCallsLog.push({
154
248
  id: toolCall.id,
155
249
  name: toolCall.function.name,
156
250
  args,
157
251
  status: 'running',
158
252
  result: null
159
- }
160
- toolCallsLog.push(toolEntry)
253
+ })
161
254
  updateChatEntry(chatEntry, {
162
255
  toolCalls: [...toolCallsLog]
163
256
  })
164
257
 
165
- let toolResult
258
+ let finished
166
259
  try {
167
- toolResult = await executeToolCall(toolCall.function.name, args)
168
- toolEntry.status = 'completed'
169
- toolEntry.result = toolResult
260
+ const toolResult = await executeToolCall(toolCall.function.name, args)
261
+ finished = { status: 'completed', result: toolResult }
170
262
  } catch (err) {
171
- toolEntry.status = 'error'
172
- toolEntry.result = err.message
263
+ finished = { status: 'error', result: err.message }
173
264
  }
174
265
 
266
+ // Swap in a new entry object instead of mutating the running one: the
267
+ // tool call card is memo()'d on that object's identity, so an in-place
268
+ // mutation would leave the card showing "running" forever.
269
+ toolCallsLog[toolCallsLog.length - 1] = {
270
+ ...toolCallsLog[toolCallsLog.length - 1],
271
+ ...finished
272
+ }
175
273
  updateChatEntry(chatEntry, {
176
274
  toolCalls: [...toolCallsLog]
177
275
  })
@@ -179,7 +277,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
179
277
  messages.push({
180
278
  role: 'tool',
181
279
  tool_call_id: toolCall.id,
182
- content: toolEntry.result
280
+ content: finished.result
183
281
  })
184
282
  }
185
283
  }
@@ -189,9 +287,54 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
189
287
  response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
190
288
  })
191
289
  } finally {
192
- window.store.agentRunning = false
193
- // hand the panel back to the session based estimate: tool results are not
194
- // carried into the next turn, so the agent figure stops being meaningful
195
- window.store.aiContextInfo = null
290
+ if (abortRef) {
291
+ abortRef.requestId = null
292
+ }
293
+ // Only the run that still owns the flag may clear it -- a stopped run
294
+ // unwinds a moment later and must not unlock a newer one.
295
+ const ownsRun = activeRunId === runId
296
+ if (ownsRun) {
297
+ activeRunId = null
298
+ window.store.agentRunning = false
299
+ // hand the panel back to the session based estimate: tool results are not
300
+ // carried into the next turn, so the agent figure stops being meaningful
301
+ window.store.aiContextInfo = null
302
+ }
303
+ // A run that compacted its live conversation leaves the stored session
304
+ // untouched, so the next turn would start from the full history again and
305
+ // pay for another summary. Compact the session as well -- once, after the
306
+ // run, when the turns it summarizes are complete. Skipped for a stopped or
307
+ // failed run: those transcripts are not a conversation worth keeping.
308
+ if (ownsRun && autoCompressCount > 0 && !runErrored && !isAborted()) {
309
+ await autoCompressSession(chatEntry.chatSessionId, config)
310
+ }
311
+ }
312
+ }
313
+
314
+ // Stop the running agent loop, called by the panel's stop button.
315
+ //
316
+ // The loop itself only looks at `abortRef` between iterations, so on its own
317
+ // that flag does nothing while a request is in flight -- and the panel keeps
318
+ // the composer locked for as long as the provider takes to answer (which is
319
+ // forever if it never does, these requests carry no timeout). So this also
320
+ // cancels the in-flight request and releases the composer right away; the loop
321
+ // then unwinds on its own and marks the entry as stopped.
322
+ export async function stopAgentRun (abortRef) {
323
+ if (abortRef) {
324
+ abortRef.current = true
325
+ }
326
+ const requestId = abortRef && abortRef.requestId
327
+ if (abortRef) {
328
+ abortRef.requestId = null
329
+ }
330
+ activeRunId = null
331
+ window.store.agentRunning = false
332
+ window.store.aiContextInfo = null
333
+ if (requestId) {
334
+ try {
335
+ await window.pre.runGlobalAsync('abortAIRequest', requestId)
336
+ } catch (error) {
337
+ console.error('Error aborting agent request:', error)
338
+ }
196
339
  }
197
340
  }
@@ -0,0 +1,119 @@
1
+ // Automatic context compression, shared by agent mode and ask mode.
2
+ //
3
+ // A request is not just the conversation: agent runs append the assistant's
4
+ // tool calls and their results on every iteration, ask turns append the answer
5
+ // of every turn. Either way the request that leaves the machine grows past what
6
+ // the session transcript suggests, and once it approaches the model's window
7
+ // the provider starts rejecting it outright, which kills the run mid-way. The
8
+ // fix is the same one the manual Compress button performs -- replace the
9
+ // history with a summary -- except it has to happen before the request that
10
+ // would overflow.
11
+ //
12
+ // Pure module (no window / store access) so the decision and the rewrite can
13
+ // be unit tested, and so the manual and the automatic path share one summary
14
+ // prompt and one message shape. The request that asks for a summary lives in
15
+ // ai-compress.js.
16
+
17
+ import { CONTEXT_DANGER_PERCENT } from './ai-context.js'
18
+
19
+ // Sent as the closing user turn when asking for a summary. Shared with the
20
+ // manual path (store.compressChatSession) so both produce the same thing.
21
+ export const COMPRESS_SUMMARY_PROMPT = 'Please summarize the above conversation concisely. Include key information, decisions, context, and any important details that would be needed to continue this conversation effectively.'
22
+
23
+ // `info` is the object summarizeContext returns. Percent is null/absent when
24
+ // the window size is unknown, which must not read as "0, no compression
25
+ // needed" -- there is simply nothing to decide on.
26
+ //
27
+ // The threshold is the danger level from ai-context.js, read here rather than
28
+ // copied into a constant of this module: the same value is what turns the
29
+ // context indicator red, and a live read cannot be left behind in a stale copy
30
+ // of this file when that value is edited (which is how "I set it to 5 and it
31
+ // still did not compress" happens).
32
+ export function shouldAutoCompress (info, percent = CONTEXT_DANGER_PERCENT) {
33
+ if (!info) {
34
+ return false
35
+ }
36
+ const p = info.percent
37
+ if (p === null || p === undefined || !isFinite(p)) {
38
+ return false
39
+ }
40
+ return p >= percent
41
+ }
42
+
43
+ // There has to be something a summary can replace. A fresh request is just the
44
+ // system prompt plus the prompt being sent; compacting that would only re-word
45
+ // the question, so a model whose tool schemas alone fill the window is left
46
+ // alone rather than sent a summary request on every turn.
47
+ export function canCompact (messages) {
48
+ return Array.isArray(messages) && messages.length > 2
49
+ }
50
+
51
+ // An ask-mode request ends with the prompt being answered, because
52
+ // buildSessionMessages appends each turn's prompt after the earlier ones.
53
+ // Compaction must keep that last message out of the summary: it is the live
54
+ // question, and folding it into "here is a summary of our previous
55
+ // conversation" would ask the model to answer a question it only sees quoted
56
+ // back at it.
57
+ export function splitPendingTurn (messages) {
58
+ if (!Array.isArray(messages)) {
59
+ return { context: [], pending: null }
60
+ }
61
+ const last = messages[messages.length - 1]
62
+ if (last && last.role === 'user') {
63
+ return { context: messages.slice(0, -1), pending: last }
64
+ }
65
+ return { context: messages, pending: null }
66
+ }
67
+
68
+ // How a summary re-enters the conversation. Deliberately the same pair
69
+ // buildSessionMessages emits for a compressed session entry, so a live loop
70
+ // that compacted itself and a session compressed between turns read
71
+ // identically to the model.
72
+ export function summaryMessages (summary) {
73
+ return [
74
+ {
75
+ role: 'user',
76
+ content: `Here is a summary of our previous conversation for context:\n\n${summary}`
77
+ },
78
+ {
79
+ role: 'assistant',
80
+ content: 'Understood. I will use this context as we continue.'
81
+ }
82
+ ]
83
+ }
84
+
85
+ // Compact `messages` in place: the system prompt stays (it carries the agent
86
+ // instructions and guardrails), everything the summary covers is dropped.
87
+ // In place because the agent loop holds this exact array for its next request.
88
+ export function applySummary (messages, summary) {
89
+ if (!Array.isArray(messages) || !summary) {
90
+ return messages
91
+ }
92
+ const system = messages.find(m => m && m.role === 'system')
93
+ messages.length = 0
94
+ if (system) {
95
+ messages.push(system)
96
+ }
97
+ messages.push(...summaryMessages(summary))
98
+ return messages
99
+ }
100
+
101
+ // The ask-mode counterpart, returning a new list: the memoized session messages
102
+ // this turn was built from must not be modified, and the live prompt has to be
103
+ // re-attached after the summary.
104
+ export function compactAskMessages (messages, summary) {
105
+ if (!Array.isArray(messages) || !summary) {
106
+ return messages
107
+ }
108
+ const { pending } = splitPendingTurn(messages)
109
+ const system = messages.find(m => m && m.role === 'system')
110
+ const next = []
111
+ if (system) {
112
+ next.push(system)
113
+ }
114
+ next.push(...summaryMessages(summary))
115
+ if (pending) {
116
+ next.push(pending)
117
+ }
118
+ return next
119
+ }