@xelauvas/xela-cli 0.3.1 → 0.4.0

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 (137) hide show
  1. package/bin/xela.js +0 -12
  2. package/local/README.md +112 -0
  3. package/local/start-qwen.sh +47 -0
  4. package/local/start-tunnel.sh +36 -0
  5. package/local/xela-local.sh +139 -0
  6. package/package.json +9 -5
  7. package/run.sh +33 -0
  8. package/src/Tool.ts +1 -1
  9. package/src/_shims/_generated_stubs/_universal_stub.mjs +168 -0
  10. package/src/_shims/_generated_stubs/stub_346f96741420f62caece15b196e233c6.mjs +19 -0
  11. package/src/_shims/_generated_stubs/stub_4b79af2a6ac1af5a53a55fd666b2bbb4.mjs +3 -0
  12. package/src/_shims/_generated_stubs/stub_94345ddeadacc184ff895597ec5abf71.mjs +6 -0
  13. package/src/_shims/_generated_stubs/stub_99fa0bc272643c549d8a2ec6d578436f.mjs +3 -0
  14. package/src/_shims/_generated_stubs/stub_e3451b131fcaf272a2a75270b9a427a3.mjs +9 -0
  15. package/src/_shims/_generated_stubs/text_stub.mjs +1 -0
  16. package/src/_shims/bun_bundle.js +79 -4
  17. package/src/_shims/cjs_stub.cjs +32 -10
  18. package/src/_shims/loader.js +104 -5
  19. package/src/_shims/openai_adapter.ts +446 -230
  20. package/src/_shims/register.js +16 -3
  21. package/src/assistant/gate.ts +15 -0
  22. package/src/assistant/index.ts +89 -0
  23. package/src/bootstrap/state.ts +6 -1
  24. package/src/bridge/peerSessions.ts +18 -0
  25. package/src/cli/print.ts +5 -0
  26. package/src/commands/assistant/assistant.ts +17 -0
  27. package/src/commands/assistant/index.ts +11 -0
  28. package/src/commands/brief.ts +3 -1
  29. package/src/commands/buddy/buddy.ts +88 -0
  30. package/src/commands/buddy/index.ts +12 -0
  31. package/src/commands/model/model.tsx +8 -23
  32. package/src/commands/peers/index.ts +11 -0
  33. package/src/commands/peers/peers.ts +24 -0
  34. package/src/commands/proactive/proactive.ts +64 -0
  35. package/src/commands/proactive.ts +12 -0
  36. package/src/commands/workflows/index.ts +12 -0
  37. package/src/commands/workflows/workflows.ts +36 -0
  38. package/src/commands.ts +4 -8
  39. package/src/components/Messages.tsx +1 -1
  40. package/src/components/ModelPicker.tsx +4 -34
  41. package/src/components/Spinner.tsx +54 -1
  42. package/src/components/StructuredDiff/colorDiff.ts +3 -1
  43. package/src/components/messages/SnipBoundaryMessage.tsx +15 -0
  44. package/src/components/messages/UserCrossSessionMessage.tsx +26 -0
  45. package/src/components/permissions/PermissionRequest.tsx +4 -2
  46. package/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +96 -0
  47. package/src/constants/prompts.ts +26 -0
  48. package/src/ink/terminal.ts +9 -1
  49. package/src/jobs/classifier.ts +103 -0
  50. package/src/memdir/paths.ts +13 -1
  51. package/src/proactive/index.ts +158 -0
  52. package/src/proactive/useProactive.ts +78 -0
  53. package/src/screens/REPL.tsx +28 -8
  54. package/src/services/api/claude.ts +6 -1
  55. package/src/services/compact/autoCompact.ts +18 -4
  56. package/src/services/compact/cachedMCConfig.ts +1 -0
  57. package/src/services/compact/cachedMicrocompact.ts +113 -0
  58. package/src/services/compact/compact.ts +22 -2
  59. package/src/services/compact/reactiveCompact.ts +251 -0
  60. package/src/services/compact/snipCompact.ts +282 -0
  61. package/src/services/compact/snipProjection.ts +50 -0
  62. package/src/services/contextCollapse/index.ts +322 -0
  63. package/src/services/contextCollapse/operations.ts +187 -0
  64. package/src/services/contextCollapse/persist.ts +40 -0
  65. package/src/services/extractMemories/extractMemories.ts +11 -2
  66. package/src/services/skillSearch/featureCheck.ts +12 -0
  67. package/src/services/skillSearch/localSearch.ts +98 -0
  68. package/src/services/skillSearch/prefetch.ts +125 -0
  69. package/src/services/skillSearch/remoteSkillLoader.ts +9 -0
  70. package/src/services/skillSearch/remoteSkillState.ts +21 -0
  71. package/src/services/skillSearch/signals.ts +7 -0
  72. package/src/services/skillSearch/telemetry.ts +9 -0
  73. package/src/setup.ts +10 -5
  74. package/src/skills/bundled/dream.ts +31 -0
  75. package/src/skills/bundled/hunter.ts +45 -0
  76. package/src/skills/bundled/index.ts +10 -23
  77. package/src/skills/bundled/runSkillGenerator.ts +61 -0
  78. package/src/skills/mcpSkills.ts +101 -0
  79. package/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +301 -0
  80. package/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +273 -0
  81. package/src/tasks.ts +22 -8
  82. package/src/tools/AgentTool/runAgent.ts +11 -0
  83. package/src/tools/BriefTool/BriefTool.ts +2 -0
  84. package/src/tools/CtxInspectTool/CtxInspectTool.ts +102 -0
  85. package/src/tools/DiscoverSkillsTool/DiscoverSkillsTool.ts +78 -0
  86. package/src/tools/DiscoverSkillsTool/prompt.ts +3 -0
  87. package/src/tools/ListPeersTool/ListPeersTool.ts +89 -0
  88. package/src/tools/MCPTool/MCPTool.ts +6 -0
  89. package/src/tools/MonitorTool/MonitorTool.ts +273 -0
  90. package/src/tools/MonitorTool/UI.tsx +25 -0
  91. package/src/tools/MonitorTool/events.ts +84 -0
  92. package/src/tools/MonitorTool/prompt.ts +45 -0
  93. package/src/tools/OverflowTestTool/OverflowTestTool.ts +73 -0
  94. package/src/tools/PushNotificationTool/PushNotificationTool.ts +119 -0
  95. package/src/tools/ReviewArtifactTool/ReviewArtifactTool.ts +93 -0
  96. package/src/tools/ReviewArtifactTool/prompt.ts +5 -0
  97. package/src/tools/SendUserFileTool/SendUserFileTool.ts +87 -0
  98. package/src/tools/SendUserFileTool/prompt.ts +7 -0
  99. package/src/tools/SleepTool/SleepTool.ts +126 -0
  100. package/src/tools/SnipTool/SnipTool.ts +90 -0
  101. package/src/tools/SnipTool/prompt.ts +5 -0
  102. package/src/tools/SubscribePRTool/SubscribePRTool.ts +115 -0
  103. package/src/tools/TerminalCaptureTool/TerminalCaptureTool.ts +129 -0
  104. package/src/tools/TerminalCaptureTool/prompt.ts +8 -0
  105. package/src/tools/ToolSearchTool/prompt.ts +1 -1
  106. package/src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts +99 -0
  107. package/src/tools/WebBrowserTool/WebBrowserPanel.tsx +10 -0
  108. package/src/tools/WebBrowserTool/WebBrowserTool.ts +190 -0
  109. package/src/tools/WebBrowserTool/prompt.ts +16 -0
  110. package/src/tools/WorkflowTool/UI.tsx +27 -0
  111. package/src/tools/WorkflowTool/WorkflowTool.ts +401 -0
  112. package/src/tools/WorkflowTool/bundled/index.ts +88 -0
  113. package/src/tools/WorkflowTool/constants.ts +7 -0
  114. package/src/tools/WorkflowTool/createWorkflowCommand.ts +84 -0
  115. package/src/tools/WorkflowTool/prompt.ts +38 -0
  116. package/src/tools/WorkflowTool/runtime.ts +331 -0
  117. package/src/tools.ts +36 -31
  118. package/src/utils/betas.ts +5 -0
  119. package/src/utils/context.ts +9 -0
  120. package/src/utils/conversationRecovery.ts +1 -1
  121. package/src/utils/forkedAgent.ts +4 -0
  122. package/src/utils/logoV2Utils.ts +7 -3
  123. package/src/utils/messageQueueManager.ts +23 -9
  124. package/src/utils/model/model.ts +10 -9
  125. package/src/utils/model/modelOptions.ts +0 -21
  126. package/src/utils/model/validateModel.ts +0 -19
  127. package/src/utils/permissions/permissionSetup.ts +5 -0
  128. package/src/utils/permissions/permissions.ts +25 -0
  129. package/src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt +38 -0
  130. package/src/utils/permissions/yolo-classifier-prompts/permissions_external.txt +25 -0
  131. package/src/utils/permissions/yoloClassifier.ts +28 -6
  132. package/src/utils/plugins/officialMarketplaceStartupCheck.ts +9 -0
  133. package/src/utils/systemThemeWatcher.ts +62 -0
  134. package/src/utils/udsClient.ts +115 -0
  135. package/src/utils/udsMessaging.ts +157 -0
  136. package/src/utils/yaml.ts +251 -5
  137. package/src/utils/model/providerModels.ts +0 -168
@@ -0,0 +1,251 @@
1
+ import { feature } from 'bun:bundle'
2
+ import type { QuerySource } from '../../constants/querySource.js'
3
+ import type { AssistantMessage, Message } from '../../types/message.js'
4
+ import { logForDebugging } from '../../utils/debug.js'
5
+ import { isEnvTruthy } from '../../utils/envUtils.js'
6
+ import type { CacheSafeParams } from '../../utils/forkedAgent.js'
7
+ import type { CompactionResult } from './compact.js'
8
+
9
+ // This module is loaded with require() from the query loop, so it must not
10
+ // statically import the compaction/hook graph (it reaches ripgrep.ts, which
11
+ // cannot be loaded as CJS). Everything heavy is imported when first used.
12
+ async function deps() {
13
+ const [compact, hooks, errors, errorsUtil, log, sessionMemory, warning, micro, cleanup] =
14
+ await Promise.all([
15
+ import('./compact.js'),
16
+ import('../../utils/hooks.js'),
17
+ import('../api/errors.js'),
18
+ import('../../utils/errors.js'),
19
+ import('../../utils/log.js'),
20
+ import('../SessionMemory/sessionMemoryUtils.js'),
21
+ import('./compactWarningState.js'),
22
+ import('./microCompact.js'),
23
+ import('./postCompactCleanup.js'),
24
+ ])
25
+ return { compact, hooks, errors, errorsUtil, log, sessionMemory, warning, micro, cleanup }
26
+ }
27
+
28
+ const PROMPT_TOO_LONG_PREFIX = 'Prompt is too long'
29
+
30
+ function isPromptTooLongMessage(msg: AssistantMessage): boolean {
31
+ if (!msg.isApiErrorMessage) return false
32
+ const content = msg.message.content
33
+ return (
34
+ Array.isArray(content) &&
35
+ content.some(b => b.type === 'text' && b.text.startsWith(PROMPT_TOO_LONG_PREFIX))
36
+ )
37
+ }
38
+
39
+ function isMediaSizeErrorMessage(msg: AssistantMessage): boolean {
40
+ const raw = msg.errorDetails
41
+ if (msg.isApiErrorMessage !== true || typeof raw !== 'string') return false
42
+ return (
43
+ (raw.includes('image exceeds') && raw.includes('maximum')) ||
44
+ (raw.includes('image dimensions exceed') && raw.includes('many-image')) ||
45
+ /maximum of \d+ PDF pages/.test(raw)
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Reactive compaction: instead of (or in addition to) compacting ahead of a
51
+ * threshold, recover when the API actually rejects a request as too long.
52
+ * The query loop withholds the prompt-too-long / media-size error from the
53
+ * stream, calls `tryReactiveCompact`, and retries the turn on the compacted
54
+ * conversation. `/compact` can also be routed through this path
55
+ * (`isReactiveOnlyMode`).
56
+ */
57
+
58
+ export type ReactiveCompactOutcome =
59
+ | { ok: true; result: CompactionResult }
60
+ | {
61
+ ok: false
62
+ reason:
63
+ | 'too_few_groups'
64
+ | 'aborted'
65
+ | 'exhausted'
66
+ | 'error'
67
+ | 'media_unstrippable'
68
+ }
69
+
70
+ export function isReactiveCompactEnabled(): boolean {
71
+ if (!feature('REACTIVE_COMPACT')) return false
72
+ if (isEnvTruthy(process.env.DISABLE_COMPACT)) return false
73
+ if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_REACTIVE_COMPACT)) return false
74
+ return true
75
+ }
76
+
77
+ /**
78
+ * When set, /compact summarizes through the reactive path (no microcompact
79
+ * pre-pass, auto-compact threshold ignored). Off by default.
80
+ */
81
+ export function isReactiveOnlyMode(): boolean {
82
+ return (
83
+ isReactiveCompactEnabled() &&
84
+ isEnvTruthy(process.env.CLAUDE_CODE_REACTIVE_COMPACT_ONLY)
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Should this streamed message be held back from the caller so the loop can
90
+ * try compaction first? Only prompt-too-long API errors qualify.
91
+ */
92
+ export function isWithheldPromptTooLong(
93
+ msg: Message | { type: string } | undefined,
94
+ ): msg is AssistantMessage {
95
+ if (!isReactiveCompactEnabled()) return false
96
+ if (!msg || msg.type !== 'assistant') return false
97
+ const m = msg as AssistantMessage
98
+ return m.isApiErrorMessage === true && isPromptTooLongMessage(m)
99
+ }
100
+
101
+ /** Image / PDF / many-image rejections that stripping media can fix. */
102
+ export function isWithheldMediaSizeError(
103
+ msg: Message | { type: string } | undefined,
104
+ ): msg is AssistantMessage {
105
+ if (!isReactiveCompactEnabled()) return false
106
+ if (!msg || msg.type !== 'assistant') return false
107
+ return isMediaSizeErrorMessage(msg as AssistantMessage)
108
+ }
109
+
110
+ function hasMediaBlocks(messages: Message[]): boolean {
111
+ return messages.some(
112
+ m =>
113
+ m.type === 'user' &&
114
+ Array.isArray(m.message.content) &&
115
+ m.message.content.some(
116
+ b =>
117
+ b.type === 'image' ||
118
+ (b.type === 'document' &&
119
+ typeof (b as { source?: { type?: string } }).source?.type === 'string'),
120
+ ),
121
+ )
122
+ }
123
+
124
+ function conversationGroups(messages: Message[]): number {
125
+ // A "group" is one user→assistant exchange; compaction needs at least two
126
+ // so that a summary carries any information the tail does not.
127
+ return messages.filter(m => m.type === 'user' && !('isMeta' in m && m.isMeta)).length
128
+ }
129
+
130
+ /**
131
+ * Summarize `messages` in response to a rejected request. For media errors
132
+ * the images/PDFs are replaced with placeholders first so the summary request
133
+ * itself is not rejected for the same reason. compactConversation already
134
+ * handles a summary request that is itself too long (head truncation retry)
135
+ * and runs the PostCompact hooks.
136
+ */
137
+ export async function reactiveCompactOnPromptTooLong(
138
+ messages: Message[],
139
+ cacheSafeParams: CacheSafeParams,
140
+ options: {
141
+ customInstructions?: string
142
+ trigger: 'manual' | 'auto'
143
+ mediaError?: boolean
144
+ },
145
+ ): Promise<ReactiveCompactOutcome> {
146
+ const { compact, errorsUtil, log } = await deps()
147
+ const context = cacheSafeParams.toolUseContext
148
+ const signal = context.abortController.signal
149
+ if (conversationGroups(messages) < 2) {
150
+ return { ok: false, reason: 'too_few_groups' }
151
+ }
152
+ let input = messages
153
+ if (options.mediaError) {
154
+ if (!hasMediaBlocks(messages)) {
155
+ return { ok: false, reason: 'media_unstrippable' }
156
+ }
157
+ input = compact.stripImagesFromMessages(messages)
158
+ }
159
+ try {
160
+ const result = await compact.compactConversation(
161
+ input,
162
+ context,
163
+ { ...cacheSafeParams, forkContextMessages: input },
164
+ true,
165
+ options.customInstructions,
166
+ options.trigger === 'auto',
167
+ )
168
+ return { ok: true, result }
169
+ } catch (error) {
170
+ if (signal.aborted) return { ok: false, reason: 'aborted' }
171
+ if (errorsUtil.hasExactErrorMessage(error, compact.ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) {
172
+ return { ok: false, reason: 'too_few_groups' }
173
+ }
174
+ const text = error instanceof Error ? error.message : String(error)
175
+ if (/prompt is too long/i.test(text)) {
176
+ return { ok: false, reason: 'exhausted' }
177
+ }
178
+ log.logError(error as Error)
179
+ return { ok: false, reason: 'error' }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Entry point used by the query loop after a withheld 413 / media error.
185
+ * Returns the compaction result to retry with, or null when recovery must
186
+ * not run (already tried this turn, aborted, or we are inside a compaction
187
+ * request ourselves) or failed.
188
+ */
189
+ export async function tryReactiveCompact(params: {
190
+ hasAttempted: boolean
191
+ querySource: QuerySource
192
+ aborted: boolean
193
+ messages: Message[]
194
+ cacheSafeParams: CacheSafeParams
195
+ }): Promise<CompactionResult | null> {
196
+ const { hasAttempted, querySource, aborted, messages, cacheSafeParams } =
197
+ params
198
+ if (!isReactiveCompactEnabled()) return null
199
+ if (hasAttempted || aborted) return null
200
+ if (querySource === 'compact' || querySource === 'session_memory') return null
201
+
202
+ const lastMessage = messages.at(-1)
203
+ const mediaError =
204
+ lastMessage?.type === 'assistant' &&
205
+ isMediaSizeErrorMessage(lastMessage as AssistantMessage)
206
+ // The rejected attempt's error message (if the caller left it in) carries
207
+ // nothing worth summarizing.
208
+ const toCompact =
209
+ lastMessage?.type === 'assistant' &&
210
+ (lastMessage as AssistantMessage).isApiErrorMessage
211
+ ? messages.slice(0, -1)
212
+ : messages
213
+
214
+ const { compact, hooks, log, sessionMemory, warning, micro, cleanup } = await deps()
215
+ const context = cacheSafeParams.toolUseContext
216
+ const signal = context.abortController.signal
217
+ context.onCompactProgress?.({ type: 'hooks_start', hookType: 'pre_compact' })
218
+ let customInstructions: string | undefined
219
+ try {
220
+ const hookResult = await hooks.executePreCompactHooks(
221
+ { trigger: 'auto', customInstructions: null },
222
+ signal,
223
+ )
224
+ customInstructions = compact.mergeHookInstructions(
225
+ undefined,
226
+ hookResult.newCustomInstructions,
227
+ )
228
+ } catch (error) {
229
+ log.logError(error as Error)
230
+ }
231
+
232
+ logForDebugging(
233
+ `[reactiveCompact] ${mediaError ? 'media-size' : 'prompt-too-long'} recovery on ${toCompact.length} messages (${querySource})`,
234
+ )
235
+ const outcome = await reactiveCompactOnPromptTooLong(
236
+ toCompact,
237
+ cacheSafeParams,
238
+ { customInstructions, trigger: 'auto', mediaError },
239
+ )
240
+ if (!outcome.ok) {
241
+ logForDebugging(`[reactiveCompact] gave up: ${outcome.reason}`)
242
+ context.onCompactProgress?.({ type: 'compact_end' })
243
+ return null
244
+ }
245
+
246
+ sessionMemory.setLastSummarizedMessageId(undefined)
247
+ cleanup.runPostCompactCleanup(querySource)
248
+ warning.suppressCompactWarning()
249
+ micro.resetMicrocompactState()
250
+ return outcome.result
251
+ }
@@ -0,0 +1,282 @@
1
+ import { randomUUID } from 'crypto'
2
+ import { feature } from 'bun:bundle'
3
+ import { SNIP_TOOL_NAME } from '../../tools/SnipTool/prompt.js'
4
+ import type { Message } from '../../types/message.js'
5
+ import { isEnvTruthy } from '../../utils/envUtils.js'
6
+ import { deriveShortMessageId } from '../../utils/messages.js'
7
+ import {
8
+ collectSnippedUuids,
9
+ isSnipBoundaryMessage,
10
+ projectSnippedView,
11
+ SNIP_BOUNDARY_SUBTYPE,
12
+ type SnipBoundaryMessage,
13
+ } from './snipProjection.js'
14
+
15
+ // Turns at the end of the conversation that a snip can never reach
16
+ const PROTECTED_TAIL_USER_TURNS = 2
17
+ // Growth (estimated tokens) between context-efficiency nudges
18
+ const NUDGE_INTERVAL_TOKENS = 10_000
19
+ // Below this total the conversation is small enough that nudging is noise
20
+ const NUDGE_MIN_TOTAL_TOKENS = 30_000
21
+
22
+ export const SNIP_NUDGE_TEXT =
23
+ `Context check: the conversation has grown. If earlier messages are no longer useful ` +
24
+ `(large tool output you already acted on, dead-end attempts, resolved sub-tasks), remove them with the ${SNIP_TOOL_NAME} tool: ` +
25
+ `pass the [id:...] tag of the first and last message of the range. Do this only when it clearly helps; never snip what you may still need.`
26
+
27
+ export type SnipResult = {
28
+ messages: Message[]
29
+ tokensFreed: number
30
+ boundaryMessage?: SnipBoundaryMessage
31
+ executed: boolean
32
+ }
33
+
34
+ export function isSnipRuntimeEnabled(): boolean {
35
+ if (!feature('HISTORY_SNIP')) return false
36
+ return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_SNIP)
37
+ }
38
+
39
+ /** Snip markers are not used by this implementation (the tool call itself is the marker). */
40
+ export function isSnipMarkerMessage(_message: Message): boolean {
41
+ return false
42
+ }
43
+
44
+ export function estimateMessageTokens(message: Message): number {
45
+ const m = message as { message?: { content?: unknown }; content?: unknown }
46
+ const content = m.message?.content ?? m.content ?? ''
47
+ const text = typeof content === 'string' ? content : JSON.stringify(content)
48
+ return Math.ceil(text.length / 4) + 8
49
+ }
50
+
51
+ type SnipRequest = {
52
+ toolUseId: string
53
+ startId: string
54
+ endId: string
55
+ reason?: string
56
+ /** index of the assistant message that issued the Snip call */
57
+ callerIndex: number
58
+ }
59
+
60
+ function findSnipRequests(
61
+ messages: readonly Message[],
62
+ alreadyApplied: Set<string>,
63
+ ): SnipRequest[] {
64
+ const requests: SnipRequest[] = []
65
+ messages.forEach((m, index) => {
66
+ if (m.type !== 'assistant') return
67
+ const content = (m as { message: { content: unknown } }).message.content
68
+ if (!Array.isArray(content)) return
69
+ for (const block of content as Array<{
70
+ type: string
71
+ id?: string
72
+ name?: string
73
+ input?: { start_id?: string; end_id?: string; reason?: string }
74
+ }>) {
75
+ if (block.type !== 'tool_use' || block.name !== SNIP_TOOL_NAME || !block.id) continue
76
+ if (alreadyApplied.has(block.id)) continue
77
+ const start = block.input?.start_id?.trim()
78
+ const end = block.input?.end_id?.trim()
79
+ if (!start || !end) continue
80
+ requests.push({
81
+ toolUseId: block.id,
82
+ startId: start.replace(/^\[?id:/, '').replace(/\]$/, ''),
83
+ endId: end.replace(/^\[?id:/, '').replace(/\]$/, ''),
84
+ reason: block.input?.reason,
85
+ callerIndex: index,
86
+ })
87
+ }
88
+ })
89
+ return requests
90
+ }
91
+
92
+ function toolUseIdsOf(message: Message): string[] {
93
+ if (message.type !== 'assistant') return []
94
+ const content = (message as { message: { content: unknown } }).message.content
95
+ if (!Array.isArray(content)) return []
96
+ return (content as Array<{ type: string; id?: string }>)
97
+ .filter(b => b.type === 'tool_use' && b.id)
98
+ .map(b => b.id!)
99
+ }
100
+
101
+ function toolResultIdsOf(message: Message): string[] {
102
+ if (message.type !== 'user') return []
103
+ const content = (message as { message: { content: unknown } }).message.content
104
+ if (!Array.isArray(content)) return []
105
+ return (content as Array<{ type: string; tool_use_id?: string }>)
106
+ .filter(b => b.type === 'tool_result' && b.tool_use_id)
107
+ .map(b => b.tool_use_id!)
108
+ }
109
+
110
+ function isRealUserTurn(message: Message): boolean {
111
+ if (message.type !== 'user') return false
112
+ if ((message as { isMeta?: boolean }).isMeta) return false
113
+ return toolResultIdsOf(message).length === 0
114
+ }
115
+
116
+ /**
117
+ * Resolves one Snip request to the set of uuids to remove: the tagged range,
118
+ * closed over tool_use/tool_result pairs so the API never sees a half pair,
119
+ * and clipped before the protected tail.
120
+ */
121
+ function resolveRange(
122
+ view: readonly Message[],
123
+ req: SnipRequest,
124
+ ): { uuids: Set<string>; error?: string } {
125
+ const indexById = new Map<string, number>()
126
+ view.forEach((m, i) => {
127
+ if (m.type === 'user' || m.type === 'assistant') {
128
+ indexById.set(deriveShortMessageId(m.uuid), i)
129
+ }
130
+ })
131
+ let start = indexById.get(req.startId)
132
+ let end = indexById.get(req.endId)
133
+ if (start === undefined || end === undefined) {
134
+ return { uuids: new Set(), error: `unknown message id ${start === undefined ? req.startId : req.endId}` }
135
+ }
136
+ if (start > end) [start, end] = [end, start]
137
+
138
+ // Protected tail: the last N real user turns and everything after them
139
+ let protectedFrom = view.length
140
+ let turns = 0
141
+ for (let i = view.length - 1; i >= 0; i--) {
142
+ if (isRealUserTurn(view[i]!)) {
143
+ turns++
144
+ if (turns === PROTECTED_TAIL_USER_TURNS) {
145
+ protectedFrom = i
146
+ break
147
+ }
148
+ }
149
+ }
150
+ // The Snip call itself (and its result) must survive, otherwise the model
151
+ // forgets it already snipped and repeats the request
152
+ protectedFrom = Math.min(protectedFrom, req.callerIndex)
153
+ if (end >= protectedFrom) end = protectedFrom - 1
154
+ if (end < start) return { uuids: new Set(), error: 'range is entirely inside the protected recent turns' }
155
+
156
+ const uuids = new Set<string>()
157
+ for (let i = start; i <= end; i++) uuids.add(view[i]!.uuid)
158
+
159
+ // Close over tool pairs
160
+ const useToUuid = new Map<string, string>()
161
+ const resultToUuid = new Map<string, string>()
162
+ for (const m of view) {
163
+ for (const id of toolUseIdsOf(m)) useToUuid.set(id, m.uuid)
164
+ for (const id of toolResultIdsOf(m)) resultToUuid.set(id, m.uuid)
165
+ }
166
+ let changed = true
167
+ while (changed) {
168
+ changed = false
169
+ for (const m of view) {
170
+ if (!uuids.has(m.uuid)) continue
171
+ for (const id of toolUseIdsOf(m)) {
172
+ const r = resultToUuid.get(id)
173
+ if (r && !uuids.has(r)) { uuids.add(r); changed = true }
174
+ }
175
+ for (const id of toolResultIdsOf(m)) {
176
+ const u = useToUuid.get(id)
177
+ if (u && !uuids.has(u)) { uuids.add(u); changed = true }
178
+ }
179
+ }
180
+ }
181
+ // Pair closure must not reach into the protected tail
182
+ for (let i = protectedFrom; i < view.length; i++) uuids.delete(view[i]!.uuid)
183
+ return { uuids }
184
+ }
185
+
186
+ /**
187
+ * Applies pending Snip requests. Returns the model-facing message list plus
188
+ * (at most) one new boundary describing what was removed this time. With
189
+ * `force` the caller is replaying on its own store (QueryEngine) and wants
190
+ * the full projected list back even when nothing new was snipped.
191
+ */
192
+ export function snipCompactIfNeeded(
193
+ messages: Message[],
194
+ options: { force?: boolean } = {},
195
+ ): SnipResult {
196
+ if (!isSnipRuntimeEnabled()) {
197
+ return { messages, tokensFreed: 0, executed: false }
198
+ }
199
+ const applied = new Set<string>()
200
+ for (const m of messages) {
201
+ if (isSnipBoundaryMessage(m)) for (const id of m.snipToolUseIds) applied.add(id)
202
+ }
203
+ const view = projectSnippedView(messages)
204
+ const requests = findSnipRequests(view, applied)
205
+ if (requests.length === 0) {
206
+ const projected = options.force ? view : messages
207
+ return { messages: projected, tokensFreed: 0, executed: options.force ? view.length !== messages.length : false }
208
+ }
209
+
210
+ const removed = new Set<string>()
211
+ const handled: string[] = []
212
+ const reasons: string[] = []
213
+ for (const req of requests) {
214
+ const { uuids, error } = resolveRange(view, req)
215
+ handled.push(req.toolUseId)
216
+ if (error) continue
217
+ for (const u of uuids) removed.add(u)
218
+ if (req.reason) reasons.push(req.reason)
219
+ }
220
+ if (removed.size === 0) {
221
+ // Nothing removable; still record the tool uses so they are not retried
222
+ const boundary = makeBoundary([], handled, 0, reasons[0])
223
+ return { messages: options.force ? [...view, boundary] : [...messages, boundary], tokensFreed: 0, boundaryMessage: boundary, executed: true }
224
+ }
225
+ let tokensFreed = 0
226
+ for (const m of view) if (removed.has(m.uuid)) tokensFreed += estimateMessageTokens(m)
227
+ const boundary = makeBoundary([...removed], handled, tokensFreed, reasons[0])
228
+ const base = options.force ? messages : messages
229
+ const next = projectSnippedView([...base, boundary])
230
+ return { messages: next, tokensFreed, boundaryMessage: boundary, executed: true }
231
+ }
232
+
233
+ function makeBoundary(
234
+ snippedUuids: string[],
235
+ snipToolUseIds: string[],
236
+ tokensFreed: number,
237
+ reason?: string,
238
+ ): SnipBoundaryMessage {
239
+ const n = snippedUuids.length
240
+ const content =
241
+ n === 0
242
+ ? 'Snip: nothing removed (range was inside the protected recent turns or unknown)'
243
+ : `Snipped ${n} ${n === 1 ? 'message' : 'messages'} (~${tokensFreed.toLocaleString()} tokens)${reason ? `: ${reason}` : ''}`
244
+ return {
245
+ type: 'system',
246
+ subtype: SNIP_BOUNDARY_SUBTYPE,
247
+ content,
248
+ isMeta: false,
249
+ timestamp: new Date().toISOString(),
250
+ uuid: randomUUID(),
251
+ level: 'info',
252
+ snippedUuids,
253
+ snipToolUseIds,
254
+ tokensFreed,
255
+ reason,
256
+ } as SnipBoundaryMessage
257
+ }
258
+
259
+ /**
260
+ * Nudge pacing: once the context grew by NUDGE_INTERVAL_TOKENS since the last
261
+ * nudge, snip boundary or compact boundary, remind the model the tool exists.
262
+ */
263
+ export function shouldNudgeForSnips(messages: readonly Message[]): boolean {
264
+ if (!isSnipRuntimeEnabled()) return false
265
+ const snipped = collectSnippedUuids(messages)
266
+ let total = 0
267
+ let sinceAnchor = 0
268
+ for (const m of messages) {
269
+ if (snipped.has(m.uuid)) continue
270
+ const isAnchor =
271
+ isSnipBoundaryMessage(m) ||
272
+ (m.type === 'system' && (m as { subtype?: string }).subtype === 'compact_boundary') ||
273
+ (m.type === 'user' &&
274
+ (m as { isMeta?: boolean }).isMeta &&
275
+ JSON.stringify((m as { message: { content: unknown } }).message.content).includes(SNIP_NUDGE_TEXT.slice(0, 40)))
276
+ const t = estimateMessageTokens(m)
277
+ total += t
278
+ if (isAnchor) sinceAnchor = 0
279
+ else sinceAnchor += t
280
+ }
281
+ return total >= NUDGE_MIN_TOTAL_TOKENS && sinceAnchor >= NUDGE_INTERVAL_TOKENS
282
+ }
@@ -0,0 +1,50 @@
1
+ import type { Message } from '../../types/message.js'
2
+
3
+ export const SNIP_BOUNDARY_SUBTYPE = 'snip_boundary'
4
+
5
+ /**
6
+ * Boundary left behind by a snip. It is a system message so the REPL shows
7
+ * it (SnipBoundaryMessage) and normalizeMessagesForAPI drops it; it carries
8
+ * the removed uuids so the projection needs no module state and survives a
9
+ * session restore (the boundary lives in the transcript).
10
+ */
11
+ export type SnipBoundaryMessage = Message & {
12
+ type: 'system'
13
+ subtype: typeof SNIP_BOUNDARY_SUBTYPE
14
+ content: string
15
+ snippedUuids: string[]
16
+ snipToolUseIds: string[]
17
+ tokensFreed: number
18
+ reason?: string
19
+ }
20
+
21
+ export function isSnipBoundaryMessage(
22
+ message: Message | undefined | null,
23
+ ): message is SnipBoundaryMessage {
24
+ return (
25
+ !!message &&
26
+ message.type === 'system' &&
27
+ (message as { subtype?: string }).subtype === SNIP_BOUNDARY_SUBTYPE
28
+ )
29
+ }
30
+
31
+ /** All uuids removed by every boundary present in the list. */
32
+ export function collectSnippedUuids(messages: readonly Message[]): Set<string> {
33
+ const snipped = new Set<string>()
34
+ for (const m of messages) {
35
+ if (isSnipBoundaryMessage(m)) {
36
+ for (const u of m.snippedUuids) snipped.add(u)
37
+ }
38
+ }
39
+ return snipped
40
+ }
41
+
42
+ /**
43
+ * The model-facing view: drops every message a boundary snipped. Cheap on
44
+ * the hot path when nothing was snipped (one pass, no allocation).
45
+ */
46
+ export function projectSnippedView<T extends Message>(messages: T[]): T[] {
47
+ const snipped = collectSnippedUuids(messages)
48
+ if (snipped.size === 0) return messages
49
+ return messages.filter(m => !snipped.has(m.uuid))
50
+ }