@xelauvas/xela-cli 0.3.0 → 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.
- package/local/README.md +112 -0
- package/local/start-qwen.sh +47 -0
- package/local/start-tunnel.sh +36 -0
- package/local/xela-local.sh +139 -0
- package/package.json +19 -5
- package/run.sh +33 -0
- package/src/Tool.ts +1 -1
- package/src/_shims/_generated_stubs/_universal_stub.mjs +168 -0
- package/src/_shims/_generated_stubs/stub_346f96741420f62caece15b196e233c6.mjs +19 -0
- package/src/_shims/_generated_stubs/stub_4b79af2a6ac1af5a53a55fd666b2bbb4.mjs +3 -0
- package/src/_shims/_generated_stubs/stub_94345ddeadacc184ff895597ec5abf71.mjs +6 -0
- package/src/_shims/_generated_stubs/stub_99fa0bc272643c549d8a2ec6d578436f.mjs +3 -0
- package/src/_shims/_generated_stubs/stub_e3451b131fcaf272a2a75270b9a427a3.mjs +9 -0
- package/src/_shims/_generated_stubs/text_stub.mjs +1 -0
- package/src/_shims/bun_bundle.js +79 -4
- package/src/_shims/cjs_stub.cjs +32 -10
- package/src/_shims/loader.js +104 -5
- package/src/_shims/openai_adapter.ts +446 -230
- package/src/_shims/register.js +16 -3
- package/src/assistant/gate.ts +15 -0
- package/src/assistant/index.ts +89 -0
- package/src/bootstrap/state.ts +6 -1
- package/src/bridge/peerSessions.ts +18 -0
- package/src/cli/print.ts +5 -0
- package/src/commands/assistant/assistant.ts +17 -0
- package/src/commands/assistant/index.ts +11 -0
- package/src/commands/brief.ts +3 -1
- package/src/commands/buddy/buddy.ts +88 -0
- package/src/commands/buddy/index.ts +12 -0
- package/src/commands/model/model.tsx +8 -23
- package/src/commands/peers/index.ts +11 -0
- package/src/commands/peers/peers.ts +24 -0
- package/src/commands/proactive/proactive.ts +64 -0
- package/src/commands/proactive.ts +12 -0
- package/src/commands/workflows/index.ts +12 -0
- package/src/commands/workflows/workflows.ts +36 -0
- package/src/commands.ts +4 -8
- package/src/components/Messages.tsx +1 -1
- package/src/components/ModelPicker.tsx +4 -34
- package/src/components/Spinner.tsx +54 -1
- package/src/components/StructuredDiff/colorDiff.ts +3 -1
- package/src/components/messages/SnipBoundaryMessage.tsx +15 -0
- package/src/components/messages/UserCrossSessionMessage.tsx +26 -0
- package/src/components/permissions/PermissionRequest.tsx +4 -2
- package/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +96 -0
- package/src/constants/prompts.ts +26 -0
- package/src/ink/terminal.ts +9 -1
- package/src/jobs/classifier.ts +103 -0
- package/src/memdir/paths.ts +13 -1
- package/src/proactive/index.ts +158 -0
- package/src/proactive/useProactive.ts +78 -0
- package/src/screens/REPL.tsx +28 -8
- package/src/services/api/claude.ts +6 -1
- package/src/services/compact/autoCompact.ts +18 -4
- package/src/services/compact/cachedMCConfig.ts +1 -0
- package/src/services/compact/cachedMicrocompact.ts +113 -0
- package/src/services/compact/compact.ts +22 -2
- package/src/services/compact/reactiveCompact.ts +251 -0
- package/src/services/compact/snipCompact.ts +282 -0
- package/src/services/compact/snipProjection.ts +50 -0
- package/src/services/contextCollapse/index.ts +322 -0
- package/src/services/contextCollapse/operations.ts +187 -0
- package/src/services/contextCollapse/persist.ts +40 -0
- package/src/services/extractMemories/extractMemories.ts +11 -2
- package/src/services/skillSearch/featureCheck.ts +12 -0
- package/src/services/skillSearch/localSearch.ts +98 -0
- package/src/services/skillSearch/prefetch.ts +125 -0
- package/src/services/skillSearch/remoteSkillLoader.ts +9 -0
- package/src/services/skillSearch/remoteSkillState.ts +21 -0
- package/src/services/skillSearch/signals.ts +7 -0
- package/src/services/skillSearch/telemetry.ts +9 -0
- package/src/setup.ts +10 -5
- package/src/skills/bundled/dream.ts +31 -0
- package/src/skills/bundled/hunter.ts +45 -0
- package/src/skills/bundled/index.ts +10 -23
- package/src/skills/bundled/runSkillGenerator.ts +61 -0
- package/src/skills/mcpSkills.ts +101 -0
- package/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +301 -0
- package/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +273 -0
- package/src/tasks.ts +22 -8
- package/src/tools/AgentTool/runAgent.ts +11 -0
- package/src/tools/BriefTool/BriefTool.ts +2 -0
- package/src/tools/CtxInspectTool/CtxInspectTool.ts +102 -0
- package/src/tools/DiscoverSkillsTool/DiscoverSkillsTool.ts +78 -0
- package/src/tools/DiscoverSkillsTool/prompt.ts +3 -0
- package/src/tools/ListPeersTool/ListPeersTool.ts +89 -0
- package/src/tools/MCPTool/MCPTool.ts +6 -0
- package/src/tools/MonitorTool/MonitorTool.ts +273 -0
- package/src/tools/MonitorTool/UI.tsx +25 -0
- package/src/tools/MonitorTool/events.ts +84 -0
- package/src/tools/MonitorTool/prompt.ts +45 -0
- package/src/tools/OverflowTestTool/OverflowTestTool.ts +73 -0
- package/src/tools/PushNotificationTool/PushNotificationTool.ts +119 -0
- package/src/tools/ReviewArtifactTool/ReviewArtifactTool.ts +93 -0
- package/src/tools/ReviewArtifactTool/prompt.ts +5 -0
- package/src/tools/SendUserFileTool/SendUserFileTool.ts +87 -0
- package/src/tools/SendUserFileTool/prompt.ts +7 -0
- package/src/tools/SleepTool/SleepTool.ts +126 -0
- package/src/tools/SnipTool/SnipTool.ts +90 -0
- package/src/tools/SnipTool/prompt.ts +5 -0
- package/src/tools/SubscribePRTool/SubscribePRTool.ts +115 -0
- package/src/tools/TerminalCaptureTool/TerminalCaptureTool.ts +129 -0
- package/src/tools/TerminalCaptureTool/prompt.ts +8 -0
- package/src/tools/ToolSearchTool/prompt.ts +1 -1
- package/src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts +99 -0
- package/src/tools/WebBrowserTool/WebBrowserPanel.tsx +10 -0
- package/src/tools/WebBrowserTool/WebBrowserTool.ts +190 -0
- package/src/tools/WebBrowserTool/prompt.ts +16 -0
- package/src/tools/WorkflowTool/UI.tsx +27 -0
- package/src/tools/WorkflowTool/WorkflowTool.ts +401 -0
- package/src/tools/WorkflowTool/bundled/index.ts +88 -0
- package/src/tools/WorkflowTool/constants.ts +7 -0
- package/src/tools/WorkflowTool/createWorkflowCommand.ts +84 -0
- package/src/tools/WorkflowTool/prompt.ts +38 -0
- package/src/tools/WorkflowTool/runtime.ts +331 -0
- package/src/tools.ts +36 -31
- package/src/utils/betas.ts +5 -0
- package/src/utils/context.ts +9 -0
- package/src/utils/conversationRecovery.ts +1 -1
- package/src/utils/forkedAgent.ts +4 -0
- package/src/utils/logoV2Utils.ts +7 -3
- package/src/utils/messageQueueManager.ts +23 -9
- package/src/utils/model/model.ts +10 -9
- package/src/utils/model/modelOptions.ts +0 -21
- package/src/utils/model/validateModel.ts +0 -19
- package/src/utils/permissions/permissionSetup.ts +5 -0
- package/src/utils/permissions/permissions.ts +25 -0
- package/src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt +38 -0
- package/src/utils/permissions/yolo-classifier-prompts/permissions_external.txt +25 -0
- package/src/utils/permissions/yoloClassifier.ts +28 -6
- package/src/utils/plugins/officialMarketplaceStartupCheck.ts +9 -0
- package/src/utils/systemThemeWatcher.ts +62 -0
- package/src/utils/udsClient.ts +115 -0
- package/src/utils/udsMessaging.ts +157 -0
- package/src/utils/yaml.ts +251 -5
- 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
|
+
}
|