@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.
Files changed (136) hide show
  1. package/local/README.md +112 -0
  2. package/local/start-qwen.sh +47 -0
  3. package/local/start-tunnel.sh +36 -0
  4. package/local/xela-local.sh +139 -0
  5. package/package.json +19 -5
  6. package/run.sh +33 -0
  7. package/src/Tool.ts +1 -1
  8. package/src/_shims/_generated_stubs/_universal_stub.mjs +168 -0
  9. package/src/_shims/_generated_stubs/stub_346f96741420f62caece15b196e233c6.mjs +19 -0
  10. package/src/_shims/_generated_stubs/stub_4b79af2a6ac1af5a53a55fd666b2bbb4.mjs +3 -0
  11. package/src/_shims/_generated_stubs/stub_94345ddeadacc184ff895597ec5abf71.mjs +6 -0
  12. package/src/_shims/_generated_stubs/stub_99fa0bc272643c549d8a2ec6d578436f.mjs +3 -0
  13. package/src/_shims/_generated_stubs/stub_e3451b131fcaf272a2a75270b9a427a3.mjs +9 -0
  14. package/src/_shims/_generated_stubs/text_stub.mjs +1 -0
  15. package/src/_shims/bun_bundle.js +79 -4
  16. package/src/_shims/cjs_stub.cjs +32 -10
  17. package/src/_shims/loader.js +104 -5
  18. package/src/_shims/openai_adapter.ts +446 -230
  19. package/src/_shims/register.js +16 -3
  20. package/src/assistant/gate.ts +15 -0
  21. package/src/assistant/index.ts +89 -0
  22. package/src/bootstrap/state.ts +6 -1
  23. package/src/bridge/peerSessions.ts +18 -0
  24. package/src/cli/print.ts +5 -0
  25. package/src/commands/assistant/assistant.ts +17 -0
  26. package/src/commands/assistant/index.ts +11 -0
  27. package/src/commands/brief.ts +3 -1
  28. package/src/commands/buddy/buddy.ts +88 -0
  29. package/src/commands/buddy/index.ts +12 -0
  30. package/src/commands/model/model.tsx +8 -23
  31. package/src/commands/peers/index.ts +11 -0
  32. package/src/commands/peers/peers.ts +24 -0
  33. package/src/commands/proactive/proactive.ts +64 -0
  34. package/src/commands/proactive.ts +12 -0
  35. package/src/commands/workflows/index.ts +12 -0
  36. package/src/commands/workflows/workflows.ts +36 -0
  37. package/src/commands.ts +4 -8
  38. package/src/components/Messages.tsx +1 -1
  39. package/src/components/ModelPicker.tsx +4 -34
  40. package/src/components/Spinner.tsx +54 -1
  41. package/src/components/StructuredDiff/colorDiff.ts +3 -1
  42. package/src/components/messages/SnipBoundaryMessage.tsx +15 -0
  43. package/src/components/messages/UserCrossSessionMessage.tsx +26 -0
  44. package/src/components/permissions/PermissionRequest.tsx +4 -2
  45. package/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +96 -0
  46. package/src/constants/prompts.ts +26 -0
  47. package/src/ink/terminal.ts +9 -1
  48. package/src/jobs/classifier.ts +103 -0
  49. package/src/memdir/paths.ts +13 -1
  50. package/src/proactive/index.ts +158 -0
  51. package/src/proactive/useProactive.ts +78 -0
  52. package/src/screens/REPL.tsx +28 -8
  53. package/src/services/api/claude.ts +6 -1
  54. package/src/services/compact/autoCompact.ts +18 -4
  55. package/src/services/compact/cachedMCConfig.ts +1 -0
  56. package/src/services/compact/cachedMicrocompact.ts +113 -0
  57. package/src/services/compact/compact.ts +22 -2
  58. package/src/services/compact/reactiveCompact.ts +251 -0
  59. package/src/services/compact/snipCompact.ts +282 -0
  60. package/src/services/compact/snipProjection.ts +50 -0
  61. package/src/services/contextCollapse/index.ts +322 -0
  62. package/src/services/contextCollapse/operations.ts +187 -0
  63. package/src/services/contextCollapse/persist.ts +40 -0
  64. package/src/services/extractMemories/extractMemories.ts +11 -2
  65. package/src/services/skillSearch/featureCheck.ts +12 -0
  66. package/src/services/skillSearch/localSearch.ts +98 -0
  67. package/src/services/skillSearch/prefetch.ts +125 -0
  68. package/src/services/skillSearch/remoteSkillLoader.ts +9 -0
  69. package/src/services/skillSearch/remoteSkillState.ts +21 -0
  70. package/src/services/skillSearch/signals.ts +7 -0
  71. package/src/services/skillSearch/telemetry.ts +9 -0
  72. package/src/setup.ts +10 -5
  73. package/src/skills/bundled/dream.ts +31 -0
  74. package/src/skills/bundled/hunter.ts +45 -0
  75. package/src/skills/bundled/index.ts +10 -23
  76. package/src/skills/bundled/runSkillGenerator.ts +61 -0
  77. package/src/skills/mcpSkills.ts +101 -0
  78. package/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +301 -0
  79. package/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +273 -0
  80. package/src/tasks.ts +22 -8
  81. package/src/tools/AgentTool/runAgent.ts +11 -0
  82. package/src/tools/BriefTool/BriefTool.ts +2 -0
  83. package/src/tools/CtxInspectTool/CtxInspectTool.ts +102 -0
  84. package/src/tools/DiscoverSkillsTool/DiscoverSkillsTool.ts +78 -0
  85. package/src/tools/DiscoverSkillsTool/prompt.ts +3 -0
  86. package/src/tools/ListPeersTool/ListPeersTool.ts +89 -0
  87. package/src/tools/MCPTool/MCPTool.ts +6 -0
  88. package/src/tools/MonitorTool/MonitorTool.ts +273 -0
  89. package/src/tools/MonitorTool/UI.tsx +25 -0
  90. package/src/tools/MonitorTool/events.ts +84 -0
  91. package/src/tools/MonitorTool/prompt.ts +45 -0
  92. package/src/tools/OverflowTestTool/OverflowTestTool.ts +73 -0
  93. package/src/tools/PushNotificationTool/PushNotificationTool.ts +119 -0
  94. package/src/tools/ReviewArtifactTool/ReviewArtifactTool.ts +93 -0
  95. package/src/tools/ReviewArtifactTool/prompt.ts +5 -0
  96. package/src/tools/SendUserFileTool/SendUserFileTool.ts +87 -0
  97. package/src/tools/SendUserFileTool/prompt.ts +7 -0
  98. package/src/tools/SleepTool/SleepTool.ts +126 -0
  99. package/src/tools/SnipTool/SnipTool.ts +90 -0
  100. package/src/tools/SnipTool/prompt.ts +5 -0
  101. package/src/tools/SubscribePRTool/SubscribePRTool.ts +115 -0
  102. package/src/tools/TerminalCaptureTool/TerminalCaptureTool.ts +129 -0
  103. package/src/tools/TerminalCaptureTool/prompt.ts +8 -0
  104. package/src/tools/ToolSearchTool/prompt.ts +1 -1
  105. package/src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts +99 -0
  106. package/src/tools/WebBrowserTool/WebBrowserPanel.tsx +10 -0
  107. package/src/tools/WebBrowserTool/WebBrowserTool.ts +190 -0
  108. package/src/tools/WebBrowserTool/prompt.ts +16 -0
  109. package/src/tools/WorkflowTool/UI.tsx +27 -0
  110. package/src/tools/WorkflowTool/WorkflowTool.ts +401 -0
  111. package/src/tools/WorkflowTool/bundled/index.ts +88 -0
  112. package/src/tools/WorkflowTool/constants.ts +7 -0
  113. package/src/tools/WorkflowTool/createWorkflowCommand.ts +84 -0
  114. package/src/tools/WorkflowTool/prompt.ts +38 -0
  115. package/src/tools/WorkflowTool/runtime.ts +331 -0
  116. package/src/tools.ts +36 -31
  117. package/src/utils/betas.ts +5 -0
  118. package/src/utils/context.ts +9 -0
  119. package/src/utils/conversationRecovery.ts +1 -1
  120. package/src/utils/forkedAgent.ts +4 -0
  121. package/src/utils/logoV2Utils.ts +7 -3
  122. package/src/utils/messageQueueManager.ts +23 -9
  123. package/src/utils/model/model.ts +10 -9
  124. package/src/utils/model/modelOptions.ts +0 -21
  125. package/src/utils/model/validateModel.ts +0 -19
  126. package/src/utils/permissions/permissionSetup.ts +5 -0
  127. package/src/utils/permissions/permissions.ts +25 -0
  128. package/src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt +38 -0
  129. package/src/utils/permissions/yolo-classifier-prompts/permissions_external.txt +25 -0
  130. package/src/utils/permissions/yoloClassifier.ts +28 -6
  131. package/src/utils/plugins/officialMarketplaceStartupCheck.ts +9 -0
  132. package/src/utils/systemThemeWatcher.ts +62 -0
  133. package/src/utils/udsClient.ts +115 -0
  134. package/src/utils/udsMessaging.ts +157 -0
  135. package/src/utils/yaml.ts +251 -5
  136. 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
- (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1)
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 (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
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
+ }