@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,322 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { feature } from 'bun:bundle'
|
|
3
|
+
import { queryHaiku } from '../../services/api/claude.js'
|
|
4
|
+
import type { ToolUseContext } from '../../Tool.js'
|
|
5
|
+
import type { Message } from '../../types/message.js'
|
|
6
|
+
import { isEnvTruthy } from '../../utils/envUtils.js'
|
|
7
|
+
import { logForDebugging } from '../../utils/debug.js'
|
|
8
|
+
import {
|
|
9
|
+
recordContextCollapseCommit,
|
|
10
|
+
recordContextCollapseSnapshot,
|
|
11
|
+
} from '../../utils/sessionStorage.js'
|
|
12
|
+
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
|
13
|
+
import { getEffectiveContextWindowSize } from '../compact/autoCompact.js'
|
|
14
|
+
import {
|
|
15
|
+
type CommittedCollapse,
|
|
16
|
+
estimateTokens,
|
|
17
|
+
freshStore,
|
|
18
|
+
getStats as getStatsImpl,
|
|
19
|
+
getStore,
|
|
20
|
+
makeSummaryContent,
|
|
21
|
+
nextCollapseId,
|
|
22
|
+
notify,
|
|
23
|
+
projectView,
|
|
24
|
+
renderMessageForSummary,
|
|
25
|
+
type StagedSpan,
|
|
26
|
+
} from './operations.js'
|
|
27
|
+
|
|
28
|
+
export { projectView } from './operations.js'
|
|
29
|
+
export { restoreFromEntries } from './persist.js'
|
|
30
|
+
|
|
31
|
+
// Fractions of the effective context window. Each has an env override
|
|
32
|
+
// (CLAUDE_CONTEXT_COLLAPSE_<NAME>) for tuning and tests.
|
|
33
|
+
function fraction(name: string, fallback: number): number {
|
|
34
|
+
const v = Number(process.env[`CLAUDE_CONTEXT_COLLAPSE_${name}`])
|
|
35
|
+
return Number.isFinite(v) && v > 0 && v <= 1 ? v : fallback
|
|
36
|
+
}
|
|
37
|
+
function integer(name: string, fallback: number): number {
|
|
38
|
+
const v = Number(process.env[`CLAUDE_CONTEXT_COLLAPSE_${name}`])
|
|
39
|
+
return Number.isInteger(v) && v >= 0 ? v : fallback
|
|
40
|
+
}
|
|
41
|
+
const SPAWN_FRACTION = fraction('SPAWN_FRACTION', 0.5) // start summarizing the oldest span above this
|
|
42
|
+
const COMMIT_FRACTION = fraction('COMMIT_FRACTION', 0.75) // swap staged spans for placeholders above this
|
|
43
|
+
const PROTECTED_TAIL_FRACTION = fraction('PROTECTED_TAIL_FRACTION', 0.25) // never touch the most recent share of the context
|
|
44
|
+
const SPAN_TARGET_FRACTION = fraction('SPAN_TARGET_FRACTION', 0.3) // a span summarizes about this much of the view
|
|
45
|
+
const MIN_SPAWN_INTERVAL_TOKENS = integer('MIN_SPAWN_INTERVAL_TOKENS', 6_000) // growth needed between two spawns
|
|
46
|
+
const MIN_SPAN_MESSAGES = integer('MIN_SPAN_MESSAGES', 4)
|
|
47
|
+
const MAX_SUMMARY_INPUT_CHARS = 60_000
|
|
48
|
+
const EMPTY_SPAWN_WARNING_AFTER = 3
|
|
49
|
+
|
|
50
|
+
export function initContextCollapse(): void {
|
|
51
|
+
const store = getStore()
|
|
52
|
+
store.enabled =
|
|
53
|
+
feature('CONTEXT_COLLAPSE') &&
|
|
54
|
+
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CONTEXT_COLLAPSE) &&
|
|
55
|
+
process.env.CLAUDE_CONTEXT_COLLAPSE !== '0'
|
|
56
|
+
notify()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isContextCollapseEnabled(): boolean {
|
|
60
|
+
return feature('CONTEXT_COLLAPSE') && getStore().enabled
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resetContextCollapse(): void {
|
|
64
|
+
const store = getStore()
|
|
65
|
+
const { enabled, listeners } = store
|
|
66
|
+
Object.assign(store, freshStore(), { enabled, listeners })
|
|
67
|
+
notify()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getStats(): ReturnType<typeof getStatsImpl> {
|
|
71
|
+
return getStatsImpl()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function subscribe(listener: () => void): () => void {
|
|
75
|
+
const store = getStore()
|
|
76
|
+
store.listeners.add(listener)
|
|
77
|
+
return () => {
|
|
78
|
+
store.listeners.delete(listener)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isMainThread(querySource: string, toolUseContext: ToolUseContext): boolean {
|
|
83
|
+
if ((toolUseContext as { agentId?: unknown }).agentId) return false
|
|
84
|
+
return querySource === 'repl_main_thread' || querySource === 'sdk'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isRealUserTurn(message: Message): boolean {
|
|
88
|
+
if (message.type !== 'user') return false
|
|
89
|
+
if ((message as { isMeta?: boolean }).isMeta) return false
|
|
90
|
+
const content = (message as { message: { content: unknown } }).message.content
|
|
91
|
+
if (!Array.isArray(content)) return true
|
|
92
|
+
return !(content as Array<{ type: string }>).some(b => b.type === 'tool_result')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isPlaceholder(message: Message): boolean {
|
|
96
|
+
const content = (message as { message?: { content?: unknown } }).message?.content
|
|
97
|
+
return typeof content === 'string' && content.startsWith('<collapsed id="')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Picks the oldest span worth summarizing: starts at the first real user
|
|
102
|
+
* turn after the last placeholder, ends right before a later real user turn,
|
|
103
|
+
* covers about SPAN_TARGET_FRACTION of the view and stays out of the
|
|
104
|
+
* protected tail.
|
|
105
|
+
*/
|
|
106
|
+
function chooseSpan(view: Message[], totalTokens: number): { start: number; end: number } | null {
|
|
107
|
+
let tailTokens = 0
|
|
108
|
+
let protectedFrom = view.length
|
|
109
|
+
for (let i = view.length - 1; i >= 0; i--) {
|
|
110
|
+
tailTokens += estimateTokens([view[i]!])
|
|
111
|
+
if (tailTokens >= totalTokens * PROTECTED_TAIL_FRACTION) {
|
|
112
|
+
protectedFrom = i
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Back protectedFrom up to a real user turn so spans end on turn boundaries
|
|
117
|
+
while (protectedFrom < view.length && protectedFrom > 0 && !isRealUserTurn(view[protectedFrom]!)) protectedFrom--
|
|
118
|
+
|
|
119
|
+
let start = 0
|
|
120
|
+
for (let i = 0; i < protectedFrom; i++) {
|
|
121
|
+
if (isPlaceholder(view[i]!)) start = i + 1
|
|
122
|
+
}
|
|
123
|
+
while (start < protectedFrom && !isRealUserTurn(view[start]!)) start++
|
|
124
|
+
if (start >= protectedFrom) return null
|
|
125
|
+
|
|
126
|
+
const target = totalTokens * SPAN_TARGET_FRACTION
|
|
127
|
+
let acc = 0
|
|
128
|
+
let end = -1
|
|
129
|
+
for (let i = start; i < protectedFrom; i++) {
|
|
130
|
+
acc += estimateTokens([view[i]!])
|
|
131
|
+
if (i + 1 < protectedFrom && isRealUserTurn(view[i + 1]!)) {
|
|
132
|
+
end = i
|
|
133
|
+
if (acc >= target) break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (end < 0) end = protectedFrom - 1
|
|
137
|
+
if (end - start + 1 < MIN_SPAN_MESSAGES) return null
|
|
138
|
+
return { start, end }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function spanAlreadyStaged(store: ReturnType<typeof getStore>, startUuid: string): boolean {
|
|
142
|
+
return store.staged.some(s => s.startUuid === startUuid)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function spawnSummarizer(
|
|
146
|
+
view: Message[],
|
|
147
|
+
span: { start: number; end: number },
|
|
148
|
+
toolUseContext: ToolUseContext,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const store = getStore()
|
|
151
|
+
store.spawning = true
|
|
152
|
+
store.health.totalSpawns++
|
|
153
|
+
notify()
|
|
154
|
+
const messages = view.slice(span.start, span.end + 1)
|
|
155
|
+
let transcript = messages.map(renderMessageForSummary).join('\n\n')
|
|
156
|
+
if (transcript.length > MAX_SUMMARY_INPUT_CHARS) {
|
|
157
|
+
transcript = transcript.slice(0, MAX_SUMMARY_INPUT_CHARS) + '\n…(truncated)'
|
|
158
|
+
}
|
|
159
|
+
const controller = new AbortController()
|
|
160
|
+
try {
|
|
161
|
+
const result = await queryHaiku({
|
|
162
|
+
systemPrompt: asSystemPrompt([
|
|
163
|
+
'You condense a slice of a coding-assistant conversation so it can be replaced in the context window. Keep every fact that later work could depend on: file paths, function and variable names, commands run and their outcomes, decisions and their reasons, errors seen, open questions, user preferences. Drop chit-chat, repeated tool output and dead ends (one line each). Write in compact prose or bullets, third person, no preamble.',
|
|
164
|
+
]),
|
|
165
|
+
userPrompt: `Summarize this conversation slice:\n\n${transcript}`,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
options: {
|
|
168
|
+
querySource: 'compact',
|
|
169
|
+
agents: [],
|
|
170
|
+
isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession,
|
|
171
|
+
hasAppendSystemPrompt: false,
|
|
172
|
+
mcpTools: [],
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
const summary = result.message.content
|
|
176
|
+
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
177
|
+
.map(b => b.text)
|
|
178
|
+
.join('\n')
|
|
179
|
+
.trim()
|
|
180
|
+
if (!summary) {
|
|
181
|
+
store.health.totalEmptySpawns++
|
|
182
|
+
if (store.health.totalEmptySpawns >= EMPTY_SPAWN_WARNING_AFTER) {
|
|
183
|
+
store.health.emptySpawnWarningEmitted = true
|
|
184
|
+
}
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
store.health.totalEmptySpawns = 0
|
|
188
|
+
store.health.emptySpawnWarningEmitted = false
|
|
189
|
+
const staged: StagedSpan = {
|
|
190
|
+
startUuid: messages[0]!.uuid,
|
|
191
|
+
endUuid: messages[messages.length - 1]!.uuid,
|
|
192
|
+
summary,
|
|
193
|
+
risk: 0,
|
|
194
|
+
stagedAt: Date.now(),
|
|
195
|
+
}
|
|
196
|
+
store.staged.push(staged)
|
|
197
|
+
void recordContextCollapseSnapshot({
|
|
198
|
+
staged: store.staged,
|
|
199
|
+
armed: store.armed,
|
|
200
|
+
lastSpawnTokens: store.lastSpawnTokens,
|
|
201
|
+
}).catch(() => {})
|
|
202
|
+
logForDebugging(`[context-collapse] staged span of ${messages.length} messages (${summary.length} chars)`)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
store.health.totalErrors++
|
|
205
|
+
store.health.lastError = err instanceof Error ? err.message : String(err)
|
|
206
|
+
logForDebugging(`[context-collapse] summarizer failed: ${store.health.lastError}`)
|
|
207
|
+
} finally {
|
|
208
|
+
store.spawning = false
|
|
209
|
+
notify()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function commitStaged(view: Message[]): { view: Message[]; committed: number } {
|
|
214
|
+
const store = getStore()
|
|
215
|
+
let committed = 0
|
|
216
|
+
const remaining: StagedSpan[] = []
|
|
217
|
+
for (const s of store.staged) {
|
|
218
|
+
const first = view.findIndex(m => m.uuid === s.startUuid)
|
|
219
|
+
const last = view.findIndex(m => m.uuid === s.endUuid)
|
|
220
|
+
if (first < 0 || last < first) {
|
|
221
|
+
// span no longer exists in the view (compacted or snipped meanwhile)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const collapseId = nextCollapseId()
|
|
225
|
+
const commit: CommittedCollapse = {
|
|
226
|
+
collapseId,
|
|
227
|
+
summaryUuid: randomUUID(),
|
|
228
|
+
summaryContent: makeSummaryContent(collapseId, s.summary),
|
|
229
|
+
summary: s.summary,
|
|
230
|
+
firstArchivedUuid: s.startUuid,
|
|
231
|
+
lastArchivedUuid: s.endUuid,
|
|
232
|
+
archived: view.slice(first, last + 1),
|
|
233
|
+
}
|
|
234
|
+
store.commits.push(commit)
|
|
235
|
+
committed++
|
|
236
|
+
void recordContextCollapseCommit({
|
|
237
|
+
collapseId: commit.collapseId,
|
|
238
|
+
summaryUuid: commit.summaryUuid,
|
|
239
|
+
summaryContent: commit.summaryContent,
|
|
240
|
+
summary: commit.summary,
|
|
241
|
+
firstArchivedUuid: commit.firstArchivedUuid,
|
|
242
|
+
lastArchivedUuid: commit.lastArchivedUuid,
|
|
243
|
+
}).catch(() => {})
|
|
244
|
+
}
|
|
245
|
+
store.staged = remaining
|
|
246
|
+
if (committed > 0) {
|
|
247
|
+
void recordContextCollapseSnapshot({
|
|
248
|
+
staged: store.staged,
|
|
249
|
+
armed: store.armed,
|
|
250
|
+
lastSpawnTokens: store.lastSpawnTokens,
|
|
251
|
+
}).catch(() => {})
|
|
252
|
+
notify()
|
|
253
|
+
}
|
|
254
|
+
return { view: projectView(view), committed }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Called on every main-thread query. Projects committed collapses, commits
|
|
259
|
+
* staged ones when the context is high, and kicks off a background
|
|
260
|
+
* summarizer when it is time to prepare the next span.
|
|
261
|
+
*/
|
|
262
|
+
export async function applyCollapsesIfNeeded(
|
|
263
|
+
messages: Message[],
|
|
264
|
+
toolUseContext: ToolUseContext,
|
|
265
|
+
querySource: string,
|
|
266
|
+
): Promise<{ messages: Message[] }> {
|
|
267
|
+
if (!isContextCollapseEnabled() || !isMainThread(querySource, toolUseContext)) {
|
|
268
|
+
return { messages: projectView(messages) }
|
|
269
|
+
}
|
|
270
|
+
const store = getStore()
|
|
271
|
+
let view = projectView(messages)
|
|
272
|
+
const window = getEffectiveContextWindowSize(toolUseContext.options.mainLoopModel)
|
|
273
|
+
let tokens = estimateTokens(view)
|
|
274
|
+
|
|
275
|
+
if (store.staged.length > 0 && tokens >= window * COMMIT_FRACTION) {
|
|
276
|
+
const result = commitStaged(view)
|
|
277
|
+
view = result.view
|
|
278
|
+
tokens = estimateTokens(view)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
store.armed &&
|
|
283
|
+
!store.spawning &&
|
|
284
|
+
tokens >= window * SPAWN_FRACTION &&
|
|
285
|
+
tokens - store.lastSpawnTokens >= MIN_SPAWN_INTERVAL_TOKENS
|
|
286
|
+
) {
|
|
287
|
+
const span = chooseSpan(view, tokens)
|
|
288
|
+
if (span && !spanAlreadyStaged(store, view[span.start]!.uuid)) {
|
|
289
|
+
store.lastSpawnTokens = tokens
|
|
290
|
+
void spawnSummarizer(view, span, toolUseContext)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { messages: view }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Overflow recovery: the request was too long, so swap every staged span for
|
|
298
|
+
* its placeholder now and let the caller retry.
|
|
299
|
+
*/
|
|
300
|
+
export function recoverFromOverflow(
|
|
301
|
+
messages: Message[],
|
|
302
|
+
_querySource: string,
|
|
303
|
+
): { messages: Message[]; committed: number } {
|
|
304
|
+
if (!isContextCollapseEnabled()) return { messages, committed: 0 }
|
|
305
|
+
const result = commitStaged(projectView(messages))
|
|
306
|
+
return { messages: result.view, committed: result.committed }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A prompt-too-long error is withheld from the conversation (and handled by
|
|
311
|
+
* recoverFromOverflow) only when there is staged material to commit.
|
|
312
|
+
*/
|
|
313
|
+
export function isWithheldPromptTooLong(
|
|
314
|
+
message: Message,
|
|
315
|
+
isPromptTooLongMessage: (m: Message) => boolean,
|
|
316
|
+
querySource: string,
|
|
317
|
+
): boolean {
|
|
318
|
+
if (!isContextCollapseEnabled()) return false
|
|
319
|
+
if (querySource !== 'repl_main_thread' && querySource !== 'sdk') return false
|
|
320
|
+
if (getStore().staged.length === 0) return false
|
|
321
|
+
return isPromptTooLongMessage(message)
|
|
322
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Message } from '../../types/message.js'
|
|
2
|
+
import { createUserMessage } from '../../utils/messages.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Context collapse: spans of older messages are summarized in the
|
|
6
|
+
* background ("staged") and later swapped for a placeholder ("committed").
|
|
7
|
+
* The store is process-wide on globalThis because several entry points load
|
|
8
|
+
* this module through the CJS require shim and would otherwise get copies.
|
|
9
|
+
*/
|
|
10
|
+
export type CommittedCollapse = {
|
|
11
|
+
collapseId: string
|
|
12
|
+
summaryUuid: string
|
|
13
|
+
summaryContent: string
|
|
14
|
+
summary: string
|
|
15
|
+
firstArchivedUuid: string
|
|
16
|
+
lastArchivedUuid: string
|
|
17
|
+
/** Original messages, filled lazily by projectView on first match. */
|
|
18
|
+
archived: Message[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StagedSpan = {
|
|
22
|
+
startUuid: string
|
|
23
|
+
endUuid: string
|
|
24
|
+
summary: string
|
|
25
|
+
risk: number
|
|
26
|
+
stagedAt: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CollapseHealth = {
|
|
30
|
+
totalSpawns: number
|
|
31
|
+
totalErrors: number
|
|
32
|
+
totalEmptySpawns: number
|
|
33
|
+
lastError?: string
|
|
34
|
+
emptySpawnWarningEmitted: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CollapseStore = {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
commits: CommittedCollapse[]
|
|
40
|
+
staged: StagedSpan[]
|
|
41
|
+
armed: boolean
|
|
42
|
+
lastSpawnTokens: number
|
|
43
|
+
spawning: boolean
|
|
44
|
+
idCounter: number
|
|
45
|
+
health: CollapseHealth
|
|
46
|
+
listeners: Set<() => void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getStore(): CollapseStore {
|
|
50
|
+
const g = globalThis as unknown as { __xelaContextCollapse?: CollapseStore }
|
|
51
|
+
if (!g.__xelaContextCollapse) {
|
|
52
|
+
g.__xelaContextCollapse = freshStore()
|
|
53
|
+
}
|
|
54
|
+
return g.__xelaContextCollapse
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function freshStore(): CollapseStore {
|
|
58
|
+
return {
|
|
59
|
+
enabled: false,
|
|
60
|
+
commits: [],
|
|
61
|
+
staged: [],
|
|
62
|
+
armed: true,
|
|
63
|
+
lastSpawnTokens: 0,
|
|
64
|
+
spawning: false,
|
|
65
|
+
idCounter: 0,
|
|
66
|
+
health: {
|
|
67
|
+
totalSpawns: 0,
|
|
68
|
+
totalErrors: 0,
|
|
69
|
+
totalEmptySpawns: 0,
|
|
70
|
+
emptySpawnWarningEmitted: false,
|
|
71
|
+
},
|
|
72
|
+
listeners: new Set(),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function notify(): void {
|
|
77
|
+
for (const l of getStore().listeners) {
|
|
78
|
+
try {
|
|
79
|
+
l()
|
|
80
|
+
} catch {
|
|
81
|
+
// listener errors must not break the query loop
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function nextCollapseId(): string {
|
|
87
|
+
const store = getStore()
|
|
88
|
+
store.idCounter += 1
|
|
89
|
+
return String(store.idCounter).padStart(16, '0')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function makeSummaryContent(collapseId: string, summary: string): string {
|
|
93
|
+
return `<collapsed id="${collapseId}">\n${summary}\n</collapsed>`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function makePlaceholder(commit: CommittedCollapse): Message {
|
|
97
|
+
return createUserMessage({
|
|
98
|
+
content: commit.summaryContent,
|
|
99
|
+
isMeta: true,
|
|
100
|
+
uuid: commit.summaryUuid as never,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replays the commit log on a message list: each committed span, when both
|
|
106
|
+
* boundaries are present, is replaced by its placeholder. Pure; safe to run
|
|
107
|
+
* on every query.
|
|
108
|
+
*/
|
|
109
|
+
export function projectView<T extends Message>(messages: T[]): T[] {
|
|
110
|
+
const store = getStore()
|
|
111
|
+
if (store.commits.length === 0) return messages
|
|
112
|
+
let view: Message[] = messages
|
|
113
|
+
for (const commit of store.commits) {
|
|
114
|
+
const first = view.findIndex(m => m.uuid === commit.firstArchivedUuid)
|
|
115
|
+
if (first < 0) continue
|
|
116
|
+
const last = view.findIndex(m => m.uuid === commit.lastArchivedUuid)
|
|
117
|
+
if (last < first) continue
|
|
118
|
+
if (commit.archived.length === 0) {
|
|
119
|
+
commit.archived = view.slice(first, last + 1)
|
|
120
|
+
}
|
|
121
|
+
view = [...view.slice(0, first), makePlaceholder(commit), ...view.slice(last + 1)]
|
|
122
|
+
}
|
|
123
|
+
return view as T[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function estimateTokens(messages: readonly Message[]): number {
|
|
127
|
+
let total = 0
|
|
128
|
+
for (const m of messages) total += estimateMessageTokens(m)
|
|
129
|
+
return total
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function estimateMessageTokens(message: Message): number {
|
|
133
|
+
const m = message as { message?: { content?: unknown }; content?: unknown }
|
|
134
|
+
const content = m.message?.content ?? m.content ?? ''
|
|
135
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content)
|
|
136
|
+
return Math.ceil(text.length / 4) + 8
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getStats(): {
|
|
140
|
+
collapsedSpans: number
|
|
141
|
+
stagedSpans: number
|
|
142
|
+
collapsedMessages: number
|
|
143
|
+
health: CollapseHealth
|
|
144
|
+
} {
|
|
145
|
+
const store = getStore()
|
|
146
|
+
return {
|
|
147
|
+
collapsedSpans: store.commits.length,
|
|
148
|
+
stagedSpans: store.staged.length,
|
|
149
|
+
collapsedMessages: store.commits.reduce((n, c) => n + c.archived.length, 0),
|
|
150
|
+
health: store.health,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Plain-text rendering of a message for the summarizer prompt. */
|
|
155
|
+
export function renderMessageForSummary(message: Message): string {
|
|
156
|
+
const m = message as {
|
|
157
|
+
type: string
|
|
158
|
+
message?: { role?: string; content?: unknown }
|
|
159
|
+
content?: unknown
|
|
160
|
+
}
|
|
161
|
+
const role = m.type === 'assistant' ? 'Assistant' : m.type === 'user' ? 'User' : m.type
|
|
162
|
+
const content = m.message?.content ?? m.content ?? ''
|
|
163
|
+
if (typeof content === 'string') return `${role}: ${content}`
|
|
164
|
+
if (!Array.isArray(content)) return `${role}: ${JSON.stringify(content)}`
|
|
165
|
+
const parts: string[] = []
|
|
166
|
+
for (const block of content as Array<Record<string, unknown>>) {
|
|
167
|
+
switch (block.type) {
|
|
168
|
+
case 'text':
|
|
169
|
+
parts.push(String(block.text ?? ''))
|
|
170
|
+
break
|
|
171
|
+
case 'tool_use':
|
|
172
|
+
parts.push(`[tool ${String(block.name)} ${JSON.stringify(block.input).slice(0, 400)}]`)
|
|
173
|
+
break
|
|
174
|
+
case 'tool_result': {
|
|
175
|
+
const c = block.content
|
|
176
|
+
const text = typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => (x as { text?: string }).text ?? '').join('\n') : ''
|
|
177
|
+
parts.push(`[result ${text.slice(0, 1500)}${text.length > 1500 ? '…' : ''}]`)
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
case 'thinking':
|
|
181
|
+
break
|
|
182
|
+
default:
|
|
183
|
+
parts.push(`[${String(block.type)}]`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return `${role}: ${parts.join('\n')}`
|
|
187
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextCollapseCommitEntry,
|
|
3
|
+
ContextCollapseSnapshotEntry,
|
|
4
|
+
} from '../../types/logs.js'
|
|
5
|
+
import { freshStore, getStore, notify } from './operations.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rebuilds the store from transcript entries on resume. Commits replay in
|
|
9
|
+
* order with an empty archive (projectView fills it lazily); the snapshot
|
|
10
|
+
* restores the staged queue and the spawn clock. Always resets first so a
|
|
11
|
+
* resume into a session with no entries drops the previous session's state.
|
|
12
|
+
*/
|
|
13
|
+
export function restoreFromEntries(
|
|
14
|
+
commits: ContextCollapseCommitEntry[],
|
|
15
|
+
snapshot: ContextCollapseSnapshotEntry | undefined,
|
|
16
|
+
): void {
|
|
17
|
+
const store = getStore()
|
|
18
|
+
const enabled = store.enabled
|
|
19
|
+
const listeners = store.listeners
|
|
20
|
+
Object.assign(store, freshStore(), { enabled, listeners })
|
|
21
|
+
for (const c of commits) {
|
|
22
|
+
store.commits.push({
|
|
23
|
+
collapseId: c.collapseId,
|
|
24
|
+
summaryUuid: c.summaryUuid,
|
|
25
|
+
summaryContent: c.summaryContent,
|
|
26
|
+
summary: c.summary,
|
|
27
|
+
firstArchivedUuid: c.firstArchivedUuid,
|
|
28
|
+
lastArchivedUuid: c.lastArchivedUuid,
|
|
29
|
+
archived: [],
|
|
30
|
+
})
|
|
31
|
+
const n = Number(c.collapseId)
|
|
32
|
+
if (Number.isFinite(n) && n > store.idCounter) store.idCounter = n
|
|
33
|
+
}
|
|
34
|
+
if (snapshot) {
|
|
35
|
+
store.staged = snapshot.staged.map(s => ({ ...s }))
|
|
36
|
+
store.armed = snapshot.armed
|
|
37
|
+
store.lastSpawnTokens = snapshot.lastSpawnTokens
|
|
38
|
+
}
|
|
39
|
+
notify()
|
|
40
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getAutoMemPath,
|
|
27
27
|
isAutoMemoryEnabled,
|
|
28
28
|
isAutoMemPath,
|
|
29
|
+
isExtractGateOpen,
|
|
29
30
|
} from '../../memdir/paths.js'
|
|
30
31
|
import type { Tool } from '../../Tool.js'
|
|
31
32
|
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
|
@@ -376,9 +377,17 @@ export function initExtractMemories(): void {
|
|
|
376
377
|
// process already-committed work that should not be throttled.
|
|
377
378
|
if (!isTrailingRun) {
|
|
378
379
|
turnsSinceLastExtraction++
|
|
380
|
+
// Local backend: the extraction is a full model call on the same GPU
|
|
381
|
+
// as the conversation, so default to every 3 turns
|
|
382
|
+
// (CLAUDE_CODE_MEMORY_EXTRACT_EVERY overrides).
|
|
383
|
+
const localEvery = process.env.OPENAI_API_KEY
|
|
384
|
+
? Math.max(1, Number(process.env.CLAUDE_CODE_MEMORY_EXTRACT_EVERY) || 3)
|
|
385
|
+
: null
|
|
379
386
|
if (
|
|
380
387
|
turnsSinceLastExtraction <
|
|
381
|
-
(
|
|
388
|
+
(localEvery ??
|
|
389
|
+
getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ??
|
|
390
|
+
1)
|
|
382
391
|
) {
|
|
383
392
|
return
|
|
384
393
|
}
|
|
@@ -533,7 +542,7 @@ export function initExtractMemories(): void {
|
|
|
533
542
|
return
|
|
534
543
|
}
|
|
535
544
|
|
|
536
|
-
if (!
|
|
545
|
+
if (!isExtractGateOpen()) {
|
|
537
546
|
if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
|
|
538
547
|
hasLoggedGateFailure = true
|
|
539
548
|
logEvent('tengu_extract_memories_gate_disabled', {})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { feature } from 'bun:bundle'
|
|
2
|
+
import { isEnvTruthy } from '../../utils/envUtils.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Skill search replaces the full skill listing in the system prompt with
|
|
6
|
+
* per-turn "skills relevant to your task" suggestions. Local-only here: the
|
|
7
|
+
* index is lexical, over the skills installed on this machine.
|
|
8
|
+
*/
|
|
9
|
+
export function isSkillSearchEnabled(): boolean {
|
|
10
|
+
if (!feature('EXPERIMENTAL_SKILL_SEARCH')) return false
|
|
11
|
+
return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_SKILL_SEARCH)
|
|
12
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Command } from '../../commands.js'
|
|
2
|
+
|
|
3
|
+
export type SkillIndexEntry = {
|
|
4
|
+
name: string
|
|
5
|
+
description: string
|
|
6
|
+
/** lowercased tokens of name + description, with a name-weight prefix */
|
|
7
|
+
tokens: Map<string, number>
|
|
8
|
+
loadedFrom?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SkillHit = { name: string; description: string; score: number }
|
|
12
|
+
|
|
13
|
+
const STOP = new Set(
|
|
14
|
+
'the a an and or of to in on for with is are be this that it as at by from into your you my our we i me do does how what which when where can should would could please make use using about need want like just also then than over under not no yes if else all any some more most very really'.split(
|
|
15
|
+
' ',
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export function tokenize(text: string): string[] {
|
|
20
|
+
return text
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9àâîșşțţăéèêë\-_/\s]/g, ' ')
|
|
23
|
+
.split(/[\s/_\-]+/)
|
|
24
|
+
.map(t => t.trim())
|
|
25
|
+
.filter(t => t.length > 2 && !STOP.has(t))
|
|
26
|
+
.map(stem)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Tiny stemmer: enough to match "testing" with "tests" and "deploying" with "deploy"
|
|
30
|
+
function stem(word: string): string {
|
|
31
|
+
return word
|
|
32
|
+
.replace(/(ations?|ing|ers?|ed|es|s)$/u, (m, _g, offset) => (offset > 3 ? '' : m))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let cache: { key: string; index: SkillIndexEntry[] } | null = null
|
|
36
|
+
|
|
37
|
+
function indexKey(commands: Command[]): string {
|
|
38
|
+
return commands.map(c => c.name).join('|')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Lexical index over prompt-type commands (skills). Memoized per command set. */
|
|
42
|
+
export function getSkillIndex(commands: Command[]): SkillIndexEntry[] {
|
|
43
|
+
const key = indexKey(commands)
|
|
44
|
+
if (cache && cache.key === key) return cache.index
|
|
45
|
+
const index: SkillIndexEntry[] = []
|
|
46
|
+
for (const c of commands) {
|
|
47
|
+
if (c.type !== 'prompt') continue
|
|
48
|
+
if ((c as { isHidden?: boolean }).isHidden) continue
|
|
49
|
+
const description = c.description ?? ''
|
|
50
|
+
const tokens = new Map<string, number>()
|
|
51
|
+
for (const t of tokenize(c.name.replace(/[:]/g, ' '))) tokens.set(t, (tokens.get(t) ?? 0) + 2)
|
|
52
|
+
for (const t of tokenize(description)) tokens.set(t, (tokens.get(t) ?? 0) + 1)
|
|
53
|
+
index.push({
|
|
54
|
+
name: c.name,
|
|
55
|
+
description,
|
|
56
|
+
tokens,
|
|
57
|
+
loadedFrom: (c as { loadedFrom?: string }).loadedFrom ?? (c as { source?: string }).source,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
cache = { key, index }
|
|
61
|
+
return index
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function clearSkillIndexCache(): void {
|
|
65
|
+
cache = null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scores skills against a query: sum of matched token weights, scaled by
|
|
70
|
+
* inverse document frequency so generic words ("code", "file") count less.
|
|
71
|
+
*/
|
|
72
|
+
export function searchSkills(
|
|
73
|
+
query: string,
|
|
74
|
+
commands: Command[],
|
|
75
|
+
options: { limit?: number; minScore?: number; exclude?: Set<string> } = {},
|
|
76
|
+
): SkillHit[] {
|
|
77
|
+
const index = getSkillIndex(commands)
|
|
78
|
+
const q = tokenize(query)
|
|
79
|
+
if (q.length === 0 || index.length === 0) return []
|
|
80
|
+
const df = new Map<string, number>()
|
|
81
|
+
for (const entry of index) for (const t of entry.tokens.keys()) df.set(t, (df.get(t) ?? 0) + 1)
|
|
82
|
+
const n = index.length
|
|
83
|
+
const hits: SkillHit[] = []
|
|
84
|
+
for (const entry of index) {
|
|
85
|
+
if (options.exclude?.has(entry.name)) continue
|
|
86
|
+
let score = 0
|
|
87
|
+
for (const t of new Set(q)) {
|
|
88
|
+
const w = entry.tokens.get(t)
|
|
89
|
+
if (!w) continue
|
|
90
|
+
const idf = Math.log(1 + n / (df.get(t) ?? 1))
|
|
91
|
+
score += w * idf
|
|
92
|
+
}
|
|
93
|
+
if (score > 0) hits.push({ name: entry.name, description: entry.description, score })
|
|
94
|
+
}
|
|
95
|
+
hits.sort((a, b) => b.score - a.score)
|
|
96
|
+
const minScore = options.minScore ?? 3
|
|
97
|
+
return hits.filter(h => h.score >= minScore).slice(0, options.limit ?? 5)
|
|
98
|
+
}
|