@bubblebrain-ai/bubble 0.0.18 → 0.0.20
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/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
- package/dist/agent/internal-reminder-sanitizer.js +46 -0
- package/dist/agent.d.ts +9 -0
- package/dist/agent.js +305 -17
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +28 -0
- package/dist/hooks/config.d.ts +9 -0
- package/dist/hooks/config.js +278 -0
- package/dist/hooks/controller.d.ts +24 -0
- package/dist/hooks/controller.js +254 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/log.d.ts +14 -0
- package/dist/hooks/log.js +54 -0
- package/dist/hooks/runner.d.ts +5 -0
- package/dist/hooks/runner.js +225 -0
- package/dist/hooks/trust.d.ts +37 -0
- package/dist/hooks/trust.js +143 -0
- package/dist/hooks/types.d.ts +173 -0
- package/dist/hooks/types.js +46 -0
- package/dist/main.js +32 -0
- package/dist/memory/prompts.js +3 -1
- package/dist/model-catalog.js +2 -0
- package/dist/model-pricing.js +8 -0
- package/dist/network/chatgpt-transport.js +34 -9
- package/dist/network/provider-transport.d.ts +32 -0
- package/dist/network/provider-transport.js +265 -0
- package/dist/network/retry.d.ts +29 -0
- package/dist/network/retry.js +88 -0
- package/dist/network/system-proxy.d.ts +18 -0
- package/dist/network/system-proxy.js +175 -0
- package/dist/provider-anthropic.d.ts +1 -0
- package/dist/provider-anthropic.js +127 -52
- package/dist/provider-openai-codex.js +19 -29
- package/dist/session-log.js +3 -3
- package/dist/slash-commands/commands.js +84 -0
- package/dist/slash-commands/types.d.ts +2 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.js +4 -4
- package/dist/tui/display-history.d.ts +4 -3
- package/dist/tui/display-history.js +34 -57
- package/dist/tui/display-sanitizer.d.ts +3 -0
- package/dist/tui/display-sanitizer.js +38 -0
- package/dist/tui/paste-placeholder.d.ts +1 -0
- package/dist/tui/paste-placeholder.js +7 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +260 -155
- package/dist/tui/trace-groups.js +40 -4
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.js +2 -1
- package/dist/tui-ink/trace-groups.js +40 -4
- package/dist/tui-opentui/app.js +2 -1
- package/dist/tui-opentui/trace-groups.js +40 -4
- package/dist/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { getAvailableThinkingLevels, normalizeThinkingLevel } from "./provider-transform.js";
|
|
2
|
+
import { isProviderTransportError, normalizeProviderNetworkError, providerFetch } from "./network/provider-transport.js";
|
|
3
|
+
import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, retryAfterMsFromResponse, sleepBeforeRetry, } from "./network/retry.js";
|
|
2
4
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
3
5
|
const DEFAULT_MAX_TOKENS = 8192;
|
|
6
|
+
const ANTHROPIC_OPUS_LONG_OUTPUT_MAX_TOKENS = 128000;
|
|
7
|
+
const ANTHROPIC_LONG_OUTPUT_MAX_TOKENS = 64000;
|
|
4
8
|
const ANTHROPIC_PROMPT_CACHE_CONTROL = { type: "ephemeral" };
|
|
5
9
|
const MINIMAX_PROMPT_CACHE_MODELS = new Set([
|
|
6
10
|
"minimax-m2.7",
|
|
@@ -22,14 +26,14 @@ export function createAnthropicMessagesProvider(options) {
|
|
|
22
26
|
thinkingLevel: chatOptions.thinkingLevel,
|
|
23
27
|
stream: true,
|
|
24
28
|
});
|
|
25
|
-
const
|
|
29
|
+
const events = streamAnthropicEventsWithRetry(options, {
|
|
26
30
|
url: resolveAnthropicMessagesUrl(options.baseURL),
|
|
27
31
|
stream: true,
|
|
28
32
|
method: "POST",
|
|
29
33
|
body: JSON.stringify(body),
|
|
30
34
|
signal: chatOptions.abortSignal,
|
|
31
35
|
});
|
|
32
|
-
yield* translateAnthropicStream(
|
|
36
|
+
yield* translateAnthropicStream(events);
|
|
33
37
|
yield { type: "done" };
|
|
34
38
|
}
|
|
35
39
|
async function complete(messages, chatOptions) {
|
|
@@ -65,24 +69,37 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
|
|
|
65
69
|
cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
|
|
66
70
|
};
|
|
67
71
|
}
|
|
72
|
+
const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
|
|
68
73
|
const body = {
|
|
69
74
|
model: chatOptions.model,
|
|
70
|
-
max_tokens:
|
|
75
|
+
max_tokens: resolveAnthropicMaxTokens(options, chatOptions.model),
|
|
71
76
|
system: buildAnthropicSystem(system, enablePromptCache),
|
|
72
77
|
messages: anthropicMessages,
|
|
73
78
|
tools: tools && tools.length > 0 ? tools : undefined,
|
|
74
79
|
tool_choice: tools && tools.length > 0 ? { type: chatOptions.toolChoice ?? "auto" } : undefined,
|
|
75
80
|
stream: chatOptions.stream || undefined,
|
|
76
81
|
};
|
|
77
|
-
if (typeof chatOptions.temperature === "number"
|
|
82
|
+
if (typeof chatOptions.temperature === "number"
|
|
83
|
+
&& shouldSendTemperature(options, chatOptions.model, effectiveThinkingLevel)) {
|
|
78
84
|
body.temperature = chatOptions.temperature;
|
|
79
85
|
}
|
|
80
|
-
const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
|
|
81
86
|
if (effectiveThinkingLevel !== "off") {
|
|
82
87
|
body.thinking = { type: "adaptive" };
|
|
83
88
|
}
|
|
84
89
|
return body;
|
|
85
90
|
}
|
|
91
|
+
export function resolveAnthropicMaxTokens(options, model) {
|
|
92
|
+
if (!isOfficialAnthropicBaseUrl(options.baseURL)) {
|
|
93
|
+
return DEFAULT_MAX_TOKENS;
|
|
94
|
+
}
|
|
95
|
+
if (isFableModelWith128kOutput(model) || isOpusModelWith128kOutput(model)) {
|
|
96
|
+
return ANTHROPIC_OPUS_LONG_OUTPUT_MAX_TOKENS;
|
|
97
|
+
}
|
|
98
|
+
if (isSonnetOrHaikuModelWith64kOutput(model)) {
|
|
99
|
+
return ANTHROPIC_LONG_OUTPUT_MAX_TOKENS;
|
|
100
|
+
}
|
|
101
|
+
return DEFAULT_MAX_TOKENS;
|
|
102
|
+
}
|
|
86
103
|
function buildAnthropicSystem(system, enablePromptCache) {
|
|
87
104
|
if (!system)
|
|
88
105
|
return undefined;
|
|
@@ -352,27 +369,71 @@ export async function* readSseEvents(response) {
|
|
|
352
369
|
reader.releaseLock();
|
|
353
370
|
}
|
|
354
371
|
}
|
|
372
|
+
async function* streamAnthropicEventsWithRetry(options, request) {
|
|
373
|
+
const maxRetries = getProviderMaxRetries();
|
|
374
|
+
for (let attempt = 0;; attempt++) {
|
|
375
|
+
// Connection-level failures and retryable HTTP statuses are retried
|
|
376
|
+
// inside fetchAnthropicResponseWithRetry; an error thrown from it has
|
|
377
|
+
// already exhausted its budget, so it propagates without another loop.
|
|
378
|
+
const response = await fetchAnthropicResponseWithRetry(options, request);
|
|
379
|
+
let sawSseEvent = false;
|
|
380
|
+
try {
|
|
381
|
+
for await (const event of readSseEvents(response)) {
|
|
382
|
+
sawSseEvent = true;
|
|
383
|
+
yield event;
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
const normalized = normalizeAnthropicTransportError(error, request.url);
|
|
389
|
+
if (sawSseEvent) {
|
|
390
|
+
// Partial content already surfaced — only the agent loop can discard
|
|
391
|
+
// the half-built assistant message and safely re-issue the request.
|
|
392
|
+
if (!request.signal?.aborted && isProviderTransportError(error)) {
|
|
393
|
+
throw new ProviderStreamInterruptedError(normalized.message, { cause: normalized });
|
|
394
|
+
}
|
|
395
|
+
throw normalized;
|
|
396
|
+
}
|
|
397
|
+
if (request.signal?.aborted || attempt >= maxRetries || !isProviderTransportError(error)) {
|
|
398
|
+
throw normalized;
|
|
399
|
+
}
|
|
400
|
+
await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), request.signal);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
355
404
|
async function fetchAnthropicResponseWithRetry(options, request) {
|
|
356
|
-
const
|
|
357
|
-
let
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
405
|
+
const maxRetries = getProviderMaxRetries();
|
|
406
|
+
for (let attempt = 0;; attempt++) {
|
|
407
|
+
let response;
|
|
408
|
+
try {
|
|
409
|
+
response = await providerFetch(request.url, {
|
|
410
|
+
method: request.method,
|
|
411
|
+
headers: buildAnthropicHeaders(options, request.stream),
|
|
412
|
+
body: request.body,
|
|
413
|
+
signal: request.signal,
|
|
414
|
+
keepalive: false,
|
|
415
|
+
}, {
|
|
416
|
+
providerName: "Anthropic",
|
|
417
|
+
verboseEnvVar: "BUBBLE_ANTHROPIC_FETCH_VERBOSE",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
// No response received, so the request is safe to re-issue.
|
|
422
|
+
if (request.signal?.aborted || attempt >= maxRetries || !isProviderTransportError(error)) {
|
|
423
|
+
throw normalizeAnthropicTransportError(error, request.url);
|
|
424
|
+
}
|
|
425
|
+
await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), request.signal);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
365
428
|
if (response.ok)
|
|
366
429
|
return response;
|
|
367
430
|
const detail = await readAnthropicErrorDetail(response);
|
|
368
431
|
const error = new Error(`Anthropic Messages API error ${response.status}: ${detail || response.statusText}`);
|
|
369
|
-
|
|
370
|
-
if (attempt >= maxAttempts || !isRetryableMiniMaxAnthropicError(response.status, detail)) {
|
|
432
|
+
if (request.signal?.aborted || attempt >= maxRetries || !isRetryableAnthropicHttpError(response.status, detail)) {
|
|
371
433
|
throw error;
|
|
372
434
|
}
|
|
373
|
-
await sleepBeforeRetry(
|
|
435
|
+
await sleepBeforeRetry(computeRetryDelayMs(attempt + 1, { retryAfterMs: retryAfterMsFromResponse(response) }), request.signal);
|
|
374
436
|
}
|
|
375
|
-
throw lastError ?? new Error("Anthropic Messages API request failed");
|
|
376
437
|
}
|
|
377
438
|
function resolveAnthropicMessagesUrl(baseURL) {
|
|
378
439
|
const normalized = baseURL.trim().replace(/\/+$/, "");
|
|
@@ -403,42 +464,18 @@ async function readAnthropicErrorDetail(response) {
|
|
|
403
464
|
return response.statusText;
|
|
404
465
|
}
|
|
405
466
|
}
|
|
406
|
-
function
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|| status === 502
|
|
414
|
-
|| status === 503
|
|
415
|
-
|| status === 504
|
|
416
|
-
|| detail.includes("714 (1000)");
|
|
417
|
-
}
|
|
418
|
-
function getAnthropicRetryDelayMs() {
|
|
419
|
-
if (process.env.NODE_ENV === "test")
|
|
420
|
-
return 0;
|
|
421
|
-
return 800 + Math.floor(Math.random() * 700);
|
|
422
|
-
}
|
|
423
|
-
function sleepBeforeRetry(ms, signal) {
|
|
424
|
-
if (signal?.aborted) {
|
|
425
|
-
return Promise.reject(toAbortError(signal));
|
|
426
|
-
}
|
|
427
|
-
return new Promise((resolve, reject) => {
|
|
428
|
-
const timeout = setTimeout(resolve, ms);
|
|
429
|
-
signal?.addEventListener("abort", () => {
|
|
430
|
-
clearTimeout(timeout);
|
|
431
|
-
reject(toAbortError(signal));
|
|
432
|
-
}, { once: true });
|
|
467
|
+
function normalizeAnthropicTransportError(error, url) {
|
|
468
|
+
if (!isProviderTransportError(error)) {
|
|
469
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
470
|
+
}
|
|
471
|
+
return normalizeProviderNetworkError(error, {
|
|
472
|
+
providerName: "Anthropic",
|
|
473
|
+
input: url,
|
|
433
474
|
});
|
|
434
475
|
}
|
|
435
|
-
function
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return reason;
|
|
439
|
-
const error = new Error("Anthropic request retry aborted.");
|
|
440
|
-
error.name = "AbortError";
|
|
441
|
-
return error;
|
|
476
|
+
function isRetryableAnthropicHttpError(status, detail) {
|
|
477
|
+
// "714 (1000)" is a transient MiniMax backend error surfaced as a 500.
|
|
478
|
+
return isRetryableHttpStatus(status) || detail.includes("714 (1000)");
|
|
442
479
|
}
|
|
443
480
|
function parseSseEvent(raw) {
|
|
444
481
|
const dataLines = [];
|
|
@@ -573,6 +610,44 @@ function shouldEchoThinking(providerId) {
|
|
|
573
610
|
function shouldSendBearerAuth(options) {
|
|
574
611
|
return !isOfficialAnthropicBaseUrl(options.baseURL) || options.providerId?.startsWith("minimax") === true;
|
|
575
612
|
}
|
|
613
|
+
function shouldSendTemperature(options, model, thinkingLevel) {
|
|
614
|
+
if (!isOfficialAnthropicBaseUrl(options.baseURL))
|
|
615
|
+
return true;
|
|
616
|
+
if (thinkingLevel !== "off")
|
|
617
|
+
return false;
|
|
618
|
+
return !isOpusModelWithoutSamplingControls(model);
|
|
619
|
+
}
|
|
620
|
+
function isOpusModelWith128kOutput(model) {
|
|
621
|
+
return isClaudeFamilyVersionAtLeast(model, "opus", 4, 6);
|
|
622
|
+
}
|
|
623
|
+
function isFableModelWith128kOutput(model) {
|
|
624
|
+
return model.toLowerCase().startsWith("claude-fable-5");
|
|
625
|
+
}
|
|
626
|
+
function isSonnetOrHaikuModelWith64kOutput(model) {
|
|
627
|
+
const normalized = model.toLowerCase();
|
|
628
|
+
return normalized.startsWith("claude-sonnet-4-6")
|
|
629
|
+
|| normalized.startsWith("claude-haiku-4-5");
|
|
630
|
+
}
|
|
631
|
+
function isOpusModelWithoutSamplingControls(model) {
|
|
632
|
+
return isClaudeFamilyVersionAtLeast(model, "opus", 4, 7);
|
|
633
|
+
}
|
|
634
|
+
function isClaudeFamilyVersionAtLeast(model, family, minMajor, minMinor) {
|
|
635
|
+
const normalized = model.toLowerCase();
|
|
636
|
+
if (!normalized.startsWith(`claude-${family}-`))
|
|
637
|
+
return false;
|
|
638
|
+
const [, , majorSegment, minorSegment] = normalized.split("-");
|
|
639
|
+
const major = Number(majorSegment);
|
|
640
|
+
if (!Number.isFinite(major))
|
|
641
|
+
return false;
|
|
642
|
+
if (major > minMajor)
|
|
643
|
+
return true;
|
|
644
|
+
if (major < minMajor)
|
|
645
|
+
return false;
|
|
646
|
+
if (!minorSegment || minorSegment.length > 2)
|
|
647
|
+
return false;
|
|
648
|
+
const minor = Number(minorSegment);
|
|
649
|
+
return Number.isFinite(minor) && minor >= minMinor;
|
|
650
|
+
}
|
|
576
651
|
function isMiniMaxAnthropicEndpoint(options) {
|
|
577
652
|
const providerId = (options.providerId ?? "").toLowerCase();
|
|
578
653
|
if (providerId !== "minimax" && providerId !== "minimax-anthropic")
|
|
@@ -2,11 +2,10 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { listBuiltinModels } from "./model-catalog.js";
|
|
3
3
|
import { resolveProviderRequestConfig } from "./provider-transform.js";
|
|
4
4
|
import { chatGptFetch } from "./network/chatgpt-transport.js";
|
|
5
|
+
import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, sleepBeforeRetry, } from "./network/retry.js";
|
|
5
6
|
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
6
7
|
const OPENAI_BETA_RESPONSES = "responses=experimental";
|
|
7
8
|
const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
|
|
8
|
-
const CODEX_TRANSPORT_MAX_RETRIES = 2;
|
|
9
|
-
const CODEX_TRANSPORT_RETRY_BASE_DELAY_MS = 250;
|
|
10
9
|
// OpenAI gates new codex models server-side by client_version (each model carries a
|
|
11
10
|
// `minimal_client_version`). Track a recent real Codex CLI release; override via env
|
|
12
11
|
// when OpenAI lifts the gate again before we cut a new release.
|
|
@@ -221,6 +220,13 @@ export function createOpenAICodexProvider(options) {
|
|
|
221
220
|
return;
|
|
222
221
|
}
|
|
223
222
|
catch (error) {
|
|
223
|
+
if (sawParsedSseEvent
|
|
224
|
+
&& !chatOptions.abortSignal?.aborted
|
|
225
|
+
&& isTransientCodexTransportError(error)) {
|
|
226
|
+
// Partial content already surfaced — the agent loop discards the
|
|
227
|
+
// half-built assistant message and re-issues the whole request.
|
|
228
|
+
throw new ProviderStreamInterruptedError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
229
|
+
}
|
|
224
230
|
if (!shouldRetryCodexTransportError({
|
|
225
231
|
error,
|
|
226
232
|
attempt,
|
|
@@ -229,7 +235,7 @@ export function createOpenAICodexProvider(options) {
|
|
|
229
235
|
})) {
|
|
230
236
|
throw error;
|
|
231
237
|
}
|
|
232
|
-
await
|
|
238
|
+
await sleepBeforeRetry(computeRetryDelayMs(attempt + 1), chatOptions.abortSignal);
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
241
|
}
|
|
@@ -439,9 +445,17 @@ function shouldRetryCodexTransportError(input) {
|
|
|
439
445
|
return false;
|
|
440
446
|
if (input.sawParsedSseEvent)
|
|
441
447
|
return false;
|
|
442
|
-
if (input.attempt >=
|
|
448
|
+
if (input.attempt >= getProviderMaxRetries())
|
|
449
|
+
return false;
|
|
450
|
+
return isTransientCodexTransportError(input.error) || isRetryableCodexHttpError(input.error);
|
|
451
|
+
}
|
|
452
|
+
function isRetryableCodexHttpError(error) {
|
|
453
|
+
if (!(error instanceof Error))
|
|
454
|
+
return false;
|
|
455
|
+
const match = error.message.match(/^(\d{3}) status code/);
|
|
456
|
+
if (!match)
|
|
443
457
|
return false;
|
|
444
|
-
return
|
|
458
|
+
return isRetryableHttpStatus(Number(match[1]));
|
|
445
459
|
}
|
|
446
460
|
function isTransientCodexTransportError(error) {
|
|
447
461
|
const text = errorMessageChain(error).join("\n");
|
|
@@ -487,30 +501,6 @@ function errorMessageChain(error) {
|
|
|
487
501
|
}
|
|
488
502
|
return messages;
|
|
489
503
|
}
|
|
490
|
-
function codexRetryDelayMs(attempt) {
|
|
491
|
-
return CODEX_TRANSPORT_RETRY_BASE_DELAY_MS * Math.pow(3, attempt);
|
|
492
|
-
}
|
|
493
|
-
function sleepBeforeCodexRetry(ms, signal) {
|
|
494
|
-
if (signal?.aborted)
|
|
495
|
-
return Promise.reject(toAbortError(signal));
|
|
496
|
-
return new Promise((resolve, reject) => {
|
|
497
|
-
const onAbort = () => {
|
|
498
|
-
clearTimeout(timeout);
|
|
499
|
-
signal?.removeEventListener("abort", onAbort);
|
|
500
|
-
reject(toAbortError(signal));
|
|
501
|
-
};
|
|
502
|
-
const timeout = setTimeout(() => {
|
|
503
|
-
signal?.removeEventListener("abort", onAbort);
|
|
504
|
-
resolve();
|
|
505
|
-
}, ms);
|
|
506
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
function toAbortError(signal) {
|
|
510
|
-
if (signal?.reason instanceof Error)
|
|
511
|
-
return signal.reason;
|
|
512
|
-
return new DOMException(typeof signal?.reason === "string" ? signal.reason : "Aborted", "AbortError");
|
|
513
|
-
}
|
|
514
504
|
function buildBaseHeaders(accessToken, accountId, sessionId, extraHeaders) {
|
|
515
505
|
const headers = new Headers(extraHeaders);
|
|
516
506
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
package/dist/session-log.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sanitizeAssistantProviderMetadata, sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
|
|
1
|
+
import { sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
|
|
2
2
|
export class SessionLog {
|
|
3
3
|
entries = [];
|
|
4
4
|
load(lines) {
|
|
@@ -130,7 +130,7 @@ export class SessionLog {
|
|
|
130
130
|
role: "assistant",
|
|
131
131
|
content: sanitizeInternalReminderBlocks(entry.message.content),
|
|
132
132
|
reasoning: entry.message.reasoning !== undefined
|
|
133
|
-
?
|
|
133
|
+
? sanitizeInternalReasoningText(entry.message.reasoning)
|
|
134
134
|
: undefined,
|
|
135
135
|
providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(entry.message.providerMetadata)),
|
|
136
136
|
});
|
|
@@ -197,7 +197,7 @@ function normalizeMessageToEntries(message, id, timestamp) {
|
|
|
197
197
|
role: "assistant",
|
|
198
198
|
content: sanitizeInternalReminderBlocks(message.content),
|
|
199
199
|
reasoning: message.reasoning !== undefined
|
|
200
|
-
?
|
|
200
|
+
? sanitizeInternalReasoningText(message.reasoning)
|
|
201
201
|
: undefined,
|
|
202
202
|
model: message.model,
|
|
203
203
|
providerId: message.providerId,
|
|
@@ -6,6 +6,7 @@ import { parseRule } from "../permissions/rule.js";
|
|
|
6
6
|
import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
|
|
7
7
|
import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
|
|
8
8
|
import { buildSystemPrompt } from "../system-prompt.js";
|
|
9
|
+
import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
|
|
9
10
|
import { isThinkingLevel } from "../variant/thinking-level.js";
|
|
10
11
|
import { collectUsageStatsBundle, formatStatsText } from "../stats/usage.js";
|
|
11
12
|
import { buildMemoryPrompt, getMemoryStatus, isMemoryDisabled, resetMemory, searchMemory, } from "../memory/index.js";
|
|
@@ -49,6 +50,45 @@ function handlePermissionsMutation(sub, tokens, ctx) {
|
|
|
49
50
|
return `Rule not found in ${scope} ${list}: ${rule}`;
|
|
50
51
|
return `Removed from ${scope} ${list}: ${rule}`;
|
|
51
52
|
}
|
|
53
|
+
async function handleHooksCommand(args, ctx) {
|
|
54
|
+
const hooks = ctx.hookController;
|
|
55
|
+
if (!hooks)
|
|
56
|
+
return "Hooks controller is not attached to this session.";
|
|
57
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
58
|
+
const sub = tokens[0] ?? "status";
|
|
59
|
+
if (sub === "status" || sub === "list" || sub === "") {
|
|
60
|
+
return hooks.status();
|
|
61
|
+
}
|
|
62
|
+
if (sub === "reload") {
|
|
63
|
+
hooks.reload();
|
|
64
|
+
return `Reloaded hooks.\n\n${hooks.status()}`;
|
|
65
|
+
}
|
|
66
|
+
if (sub === "trust" && tokens[1] === "project") {
|
|
67
|
+
return hooks.trustProject();
|
|
68
|
+
}
|
|
69
|
+
if (sub === "untrust" && tokens[1] === "project") {
|
|
70
|
+
return hooks.untrustProject();
|
|
71
|
+
}
|
|
72
|
+
if (sub === "test") {
|
|
73
|
+
const event = tokens[1];
|
|
74
|
+
if (!isHookEventName(event)) {
|
|
75
|
+
return `Usage: /hooks test <event> [target]\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
|
|
76
|
+
}
|
|
77
|
+
return hooks.test(event, tokens.slice(2).join(" ") || undefined);
|
|
78
|
+
}
|
|
79
|
+
if (sub === "explain") {
|
|
80
|
+
const event = tokens[1];
|
|
81
|
+
if (!isHookEventName(event)) {
|
|
82
|
+
return `Usage: /hooks explain <event>\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
|
|
83
|
+
}
|
|
84
|
+
return hooks.explain(event);
|
|
85
|
+
}
|
|
86
|
+
if (sub === "logs") {
|
|
87
|
+
const limit = Number(tokens[1] ?? 20);
|
|
88
|
+
return hooks.logs(Number.isFinite(limit) ? limit : 20);
|
|
89
|
+
}
|
|
90
|
+
return "Usage: /hooks [status|reload|trust project|untrust project|test <event> [target]|explain <event>|logs [limit]]";
|
|
91
|
+
}
|
|
52
92
|
function persistSelectedModel(model, ctx) {
|
|
53
93
|
const userConfig = new UserConfig();
|
|
54
94
|
userConfig.setDefaultModel(model);
|
|
@@ -667,6 +707,13 @@ const builtinSlashCommandEntries = [
|
|
|
667
707
|
return lines.join("\n");
|
|
668
708
|
},
|
|
669
709
|
},
|
|
710
|
+
{
|
|
711
|
+
name: "hooks",
|
|
712
|
+
description: "Inspect and manage lifecycle hooks. Usage: /hooks [status|trust project|test <event>]",
|
|
713
|
+
async handler(args, ctx) {
|
|
714
|
+
return handleHooksCommand(args, ctx);
|
|
715
|
+
},
|
|
716
|
+
},
|
|
670
717
|
{
|
|
671
718
|
name: "lsp",
|
|
672
719
|
description: "Inspect or restart language servers. Usage: /lsp [status|diagnostics|restart]",
|
|
@@ -788,8 +835,33 @@ const builtinSlashCommandEntries = [
|
|
|
788
835
|
if (!ctx.sessionManager) {
|
|
789
836
|
return "Compaction requires session persistence. Start an interactive session first.";
|
|
790
837
|
}
|
|
838
|
+
const preHook = await ctx.hookController?.runEvent({
|
|
839
|
+
eventName: "PreCompact",
|
|
840
|
+
cwd: ctx.cwd,
|
|
841
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
842
|
+
agentRole: "driver",
|
|
843
|
+
target: "manual",
|
|
844
|
+
payload: {
|
|
845
|
+
kind: "manual",
|
|
846
|
+
messageCount: ctx.agent.messages.length,
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
if (preHook?.decision === "deny") {
|
|
850
|
+
return preHook.reason ?? `Compaction blocked by hook ${preHook.sourceHookId ?? "<unknown>"}.`;
|
|
851
|
+
}
|
|
791
852
|
const result = ctx.sessionManager.compact();
|
|
792
853
|
if (!result.compacted) {
|
|
854
|
+
await ctx.hookController?.runEvent({
|
|
855
|
+
eventName: "PostCompact",
|
|
856
|
+
cwd: ctx.cwd,
|
|
857
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
858
|
+
agentRole: "driver",
|
|
859
|
+
target: "manual",
|
|
860
|
+
payload: {
|
|
861
|
+
kind: "manual",
|
|
862
|
+
compacted: false,
|
|
863
|
+
},
|
|
864
|
+
});
|
|
793
865
|
return "Session is already compact enough.";
|
|
794
866
|
}
|
|
795
867
|
const systemMessage = ctx.agent.messages.find((message) => message.role === "system");
|
|
@@ -799,6 +871,18 @@ const builtinSlashCommandEntries = [
|
|
|
799
871
|
];
|
|
800
872
|
ctx.agent.resetContextUsageAnchor();
|
|
801
873
|
const dropped = result.droppedEntries ?? 0;
|
|
874
|
+
await ctx.hookController?.runEvent({
|
|
875
|
+
eventName: "PostCompact",
|
|
876
|
+
cwd: ctx.cwd,
|
|
877
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
878
|
+
agentRole: "driver",
|
|
879
|
+
target: "manual",
|
|
880
|
+
payload: {
|
|
881
|
+
kind: "manual",
|
|
882
|
+
compacted: true,
|
|
883
|
+
droppedEntries: dropped,
|
|
884
|
+
},
|
|
885
|
+
});
|
|
802
886
|
return `✓ Compaction complete · ${dropped} log entr${dropped === 1 ? "y" : "ies"} summarized`;
|
|
803
887
|
},
|
|
804
888
|
},
|
|
@@ -9,6 +9,7 @@ import type { McpManager } from "../mcp/manager.js";
|
|
|
9
9
|
import type { LspService } from "../lsp/index.js";
|
|
10
10
|
import type { MemoryScope } from "../memory/index.js";
|
|
11
11
|
import type { ThemeMode } from "../config.js";
|
|
12
|
+
import type { ExternalHookController } from "../hooks/controller.js";
|
|
12
13
|
export type SidebarMode = "auto" | "expanded" | "collapsed";
|
|
13
14
|
export interface SidebarCommandState {
|
|
14
15
|
mode: SidebarMode;
|
|
@@ -28,6 +29,7 @@ export interface SlashCommandContext {
|
|
|
28
29
|
skillRegistry: SkillRegistry;
|
|
29
30
|
bashAllowlist?: BashAllowlist;
|
|
30
31
|
settingsManager?: SettingsManager;
|
|
32
|
+
hookController?: ExternalHookController;
|
|
31
33
|
mcpManager?: McpManager;
|
|
32
34
|
lspService?: LspService;
|
|
33
35
|
flushMemory?: () => Promise<void>;
|
package/dist/tools/edit-apply.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSensitivePath } from "./sensitive-paths.js";
|
|
1
2
|
export class EditApplyError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
constructor(message, status = "no_match") {
|
|
@@ -6,6 +7,9 @@ export class EditApplyError extends Error {
|
|
|
6
7
|
this.name = "EditApplyError";
|
|
7
8
|
}
|
|
8
9
|
}
|
|
10
|
+
const CANDIDATE_EXCERPT_CONTEXT_LINES = 3;
|
|
11
|
+
const CANDIDATE_EXCERPT_MAX_LINES = 8;
|
|
12
|
+
const CANDIDATE_EXCERPT_MAX_CHARS = 1200;
|
|
9
13
|
function detectLineEnding(content) {
|
|
10
14
|
const crlf = content.indexOf("\r\n");
|
|
11
15
|
const lf = content.indexOf("\n");
|
|
@@ -234,19 +238,75 @@ function findBestLineHint(content, oldText) {
|
|
|
234
238
|
return undefined;
|
|
235
239
|
const contentLines = nonBlankLines(splitLines(content));
|
|
236
240
|
let best;
|
|
241
|
+
let tieCount = 0;
|
|
237
242
|
for (let i = 0; i < contentLines.length; i++) {
|
|
238
243
|
let score = 0;
|
|
239
244
|
for (let j = 0; j < oldLines.length && i + j < contentLines.length; j++) {
|
|
240
245
|
if (contentLines[i + j].normalized === oldLines[j])
|
|
241
246
|
score++;
|
|
242
247
|
}
|
|
243
|
-
if (!best || score > best.score)
|
|
248
|
+
if (!best || score > best.score) {
|
|
244
249
|
best = { index: i, score };
|
|
250
|
+
tieCount = 1;
|
|
251
|
+
}
|
|
252
|
+
else if (score === best.score) {
|
|
253
|
+
tieCount++;
|
|
254
|
+
}
|
|
245
255
|
}
|
|
246
256
|
if (!best || best.score === 0)
|
|
247
257
|
return undefined;
|
|
248
258
|
const startLine = contentLines[best.index].lineIndex + 1;
|
|
249
|
-
return
|
|
259
|
+
return {
|
|
260
|
+
startLine,
|
|
261
|
+
score: best.score,
|
|
262
|
+
total: oldLines.length,
|
|
263
|
+
lineIndex: contentLines[best.index].lineIndex,
|
|
264
|
+
tieCount,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function isHighConfidenceLineHint(hint) {
|
|
268
|
+
return hint.score >= 2 && hint.score / hint.total >= 0.5 && hint.tieCount === 1;
|
|
269
|
+
}
|
|
270
|
+
function formatLineHint(hint) {
|
|
271
|
+
if (hint.tieCount > 1) {
|
|
272
|
+
return `Closest ambiguous line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines, but ${hint.tieCount} candidates tied. Current bytes were not included because the candidate may be unrelated.`;
|
|
273
|
+
}
|
|
274
|
+
if (!isHighConfidenceLineHint(hint)) {
|
|
275
|
+
return `Closest low-confidence line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines. Current bytes were not included because the candidate may be unrelated.`;
|
|
276
|
+
}
|
|
277
|
+
return `Closest line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines.`;
|
|
278
|
+
}
|
|
279
|
+
function formatFence(content) {
|
|
280
|
+
let fence = "```";
|
|
281
|
+
while (content.includes(fence))
|
|
282
|
+
fence += "`";
|
|
283
|
+
return `${fence}\n${content}\n${fence}`;
|
|
284
|
+
}
|
|
285
|
+
function truncateExcerpt(excerpt) {
|
|
286
|
+
if (excerpt.length <= CANDIDATE_EXCERPT_MAX_CHARS)
|
|
287
|
+
return excerpt;
|
|
288
|
+
const marker = "\n...[truncated current candidate excerpt]";
|
|
289
|
+
return excerpt.slice(0, Math.max(0, CANDIDATE_EXCERPT_MAX_CHARS - marker.length)) + marker;
|
|
290
|
+
}
|
|
291
|
+
function formatCandidateExcerpt(content, hint) {
|
|
292
|
+
const lines = splitLines(content);
|
|
293
|
+
const startLineIndex = Math.max(0, hint.lineIndex - CANDIDATE_EXCERPT_CONTEXT_LINES);
|
|
294
|
+
const requestedEnd = Math.min(lines.length, hint.lineIndex + CANDIDATE_EXCERPT_CONTEXT_LINES + 1);
|
|
295
|
+
const endLineIndex = Math.min(requestedEnd, startLineIndex + CANDIDATE_EXCERPT_MAX_LINES);
|
|
296
|
+
const excerpt = truncateExcerpt(lines.slice(startLineIndex, endLineIndex).map((line) => line.text).join("\n"));
|
|
297
|
+
return [
|
|
298
|
+
`Current candidate excerpt (high confidence, current file lines ${startLineIndex + 1}-${endLineIndex}, not guaranteed target):`,
|
|
299
|
+
formatFence(excerpt),
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
302
|
+
function formatBestLineHint(content, hint, options) {
|
|
303
|
+
const lineHint = formatLineHint(hint);
|
|
304
|
+
if (!isHighConfidenceLineHint(hint))
|
|
305
|
+
return lineHint;
|
|
306
|
+
if (options?.path && isSensitivePath(options.path)) {
|
|
307
|
+
return `${lineHint}\nCurrent bytes were not included because this path is blocked by the sensitive-path read policy.`;
|
|
308
|
+
}
|
|
309
|
+
return `${lineHint}\n\n${formatCandidateExcerpt(content, hint)}`;
|
|
250
310
|
}
|
|
251
311
|
function matchEdit(content, edit, index, total, options) {
|
|
252
312
|
if (edit.oldText.length === 0) {
|
|
@@ -350,7 +410,7 @@ function matchEdit(content, edit, index, total, options) {
|
|
|
350
410
|
}
|
|
351
411
|
}
|
|
352
412
|
const hint = findBestLineHint(content, oldText);
|
|
353
|
-
const hintSuffix = hint ? `\n${hint}` : "";
|
|
413
|
+
const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
|
|
354
414
|
const recovery = [
|
|
355
415
|
"",
|
|
356
416
|
"How to recover:",
|
package/dist/tools/edit.js
CHANGED
|
@@ -65,12 +65,12 @@ export function createEditTool(cwd, approval, lsp, fileState) {
|
|
|
65
65
|
name: "edit",
|
|
66
66
|
effect: "write_direct",
|
|
67
67
|
requiresApproval: true,
|
|
68
|
-
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes
|
|
68
|
+
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
|
|
69
69
|
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
|
70
70
|
promptGuidelines: [
|
|
71
|
-
"Use edit for precise changes; edits[].oldText
|
|
72
|
-
"When changing multiple
|
|
73
|
-
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits
|
|
71
|
+
"Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
|
|
72
|
+
"When changing multiple small, clearly disjoint locations copied from the same fresh read, you may use one edit call with multiple entries in edits[]. Use separate smaller edit calls after re-reading when anchors are uncertain, stale, or likely to drift.",
|
|
73
|
+
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge only truly overlapping targets.",
|
|
74
74
|
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
|
|
75
75
|
],
|
|
76
76
|
parameters: {
|
|
@@ -2,7 +2,6 @@ import type { ToolResultMetadata, TokenUsage } from "../types.js";
|
|
|
2
2
|
export interface CompactionMeta {
|
|
3
3
|
turns: number;
|
|
4
4
|
messages: number;
|
|
5
|
-
tokensSaved: number;
|
|
6
5
|
summarySections: Array<{
|
|
7
6
|
label: string;
|
|
8
7
|
content: string;
|
|
@@ -10,11 +9,12 @@ export interface CompactionMeta {
|
|
|
10
9
|
contextWindow?: number;
|
|
11
10
|
compactedAt: number;
|
|
12
11
|
}
|
|
12
|
+
export type UserInputStatus = "queued" | "pending_steer";
|
|
13
13
|
export interface DisplayMessage {
|
|
14
14
|
role: "user" | "assistant" | "error";
|
|
15
15
|
content: string;
|
|
16
16
|
clientId?: string;
|
|
17
|
-
|
|
17
|
+
inputStatus?: UserInputStatus;
|
|
18
18
|
reasoning?: string;
|
|
19
19
|
toolCalls?: DisplayToolCall[];
|
|
20
20
|
parts?: DisplayMessagePart[];
|
|
@@ -53,11 +53,12 @@ export interface DisplayToolCall {
|
|
|
53
53
|
startedAt?: number;
|
|
54
54
|
completedAt?: number;
|
|
55
55
|
}
|
|
56
|
+
export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
|
|
57
|
+
export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
|
|
56
58
|
export declare function appendTextPart(parts: DisplayMessagePart[], content: string): void;
|
|
57
59
|
export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: DisplayToolCall): void;
|
|
58
60
|
export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
|
|
59
61
|
export declare function contentFromParts(parts: DisplayMessagePart[]): string;
|
|
60
62
|
export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
|
|
61
63
|
export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
|
|
62
|
-
export declare function truncateText(value: string, maxChars: number): string;
|
|
63
64
|
export declare function formatCompactNumber(n: number): string;
|