mixdog 0.9.15 → 0.9.17
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/package.json +2 -1
- package/scripts/atomic-lock-tryonce-test.mjs +66 -0
- package/scripts/explore-bench.mjs +5 -4
- package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
- package/scripts/output-style-smoke.mjs +3 -3
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/scripts/termio-input-smoke.mjs +199 -0
- package/scripts/tool-efficiency-diag.mjs +88 -0
- package/scripts/tool-smoke.mjs +40 -24
- package/scripts/tui-frame-harness-shim.mjs +32 -0
- package/scripts/tui-frame-harness.mjs +306 -0
- package/src/agents/heavy-worker/AGENT.md +6 -7
- package/src/agents/worker/AGENT.md +6 -7
- package/src/lib/keychain-cjs.cjs +28 -11
- package/src/lib/rules-builder.cjs +6 -10
- package/src/mixdog-session-runtime.mjs +102 -20
- package/src/output-styles/simple.md +22 -24
- package/src/rules/agent/00-core.md +12 -11
- package/src/rules/agent/30-explorer.md +22 -21
- package/src/rules/lead/01-general.md +8 -8
- package/src/rules/lead/lead-brief.md +11 -12
- package/src/rules/shared/01-tool.md +25 -14
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +31 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +30 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +37 -19
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
- package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +118 -22
- package/src/runtime/agent/orchestrator/session/store.mjs +38 -25
- package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
- package/src/runtime/channels/backends/telegram.mjs +29 -9
- package/src/runtime/channels/lib/event-queue.mjs +6 -2
- package/src/runtime/channels/lib/memory-client.mjs +66 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
- package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
- package/src/runtime/memory/index.mjs +132 -8
- package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
- package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
- package/src/runtime/memory/lib/memory-embed.mjs +48 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
- package/src/runtime/memory/lib/memory.mjs +88 -1
- package/src/runtime/memory/lib/session-ingest.mjs +34 -3
- package/src/runtime/memory/lib/trace-store.mjs +52 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
- package/src/runtime/shared/atomic-file.mjs +110 -0
- package/src/runtime/shared/child-guardian.mjs +4 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/singleton-owner.mjs +26 -0
- package/src/runtime/shared/tool-execution-contract.mjs +25 -0
- package/src/runtime/shared/transcript-writer.mjs +49 -5
- package/src/session-runtime/config-helpers.mjs +7 -2
- package/src/session-runtime/cwd-plugins.mjs +6 -1
- package/src/session-runtime/effort.mjs +6 -1
- package/src/session-runtime/plugin-mcp.mjs +116 -12
- package/src/session-runtime/provider-models.mjs +47 -8
- package/src/session-runtime/settings-api.mjs +7 -1
- package/src/standalone/channel-worker.mjs +25 -3
- package/src/standalone/explore-tool.mjs +5 -5
- package/src/standalone/hook-bus/config.mjs +38 -2
- package/src/standalone/hook-bus/handlers.mjs +103 -11
- package/src/standalone/hook-bus.mjs +20 -6
- package/src/standalone/memory-runtime-proxy.mjs +40 -5
- package/src/tui/App.jsx +214 -30
- package/src/tui/app/core-memory-picker.mjs +6 -6
- package/src/tui/app/model-options.mjs +10 -4
- package/src/tui/app/settings-picker.mjs +5 -30
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/text-layout.mjs +57 -0
- package/src/tui/app/transcript-window.mjs +162 -2
- package/src/tui/app/use-mouse-input.mjs +60 -47
- package/src/tui/app/use-transcript-window.mjs +96 -17
- package/src/tui/components/PromptInput.jsx +18 -1
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/TextEntryPanel.jsx +97 -33
- package/src/tui/dist/index.mjs +856 -288
- package/src/tui/engine/tool-card-results.mjs +22 -5
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +41 -9
- package/src/tui/index.jsx +101 -7
- package/src/ui/statusline-segments.mjs +54 -36
- package/src/ui/statusline.mjs +141 -95
- package/src/workflows/default/WORKFLOW.md +27 -31
- package/vendor/ink/build/components/App.js +62 -17
- package/vendor/ink/build/ink.js +78 -3
- package/vendor/ink/build/input-parser.d.ts +9 -4
- package/vendor/ink/build/input-parser.js +45 -176
- package/vendor/ink/build/log-update.js +47 -2
- package/vendor/ink/build/termio-keypress.js +240 -0
- package/vendor/ink/build/termio-tokenize.js +253 -0
|
@@ -102,6 +102,11 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
102
102
|
let content = '';
|
|
103
103
|
let hasThinkingContent = false;
|
|
104
104
|
const contentBlockTypes = new Set();
|
|
105
|
+
// Ordered extended-thinking blocks, keyed by content_block index. Each
|
|
106
|
+
// holds the accumulated thinking text + signature exactly as received so
|
|
107
|
+
// it can be round-tripped verbatim on tool-continuation turns (required
|
|
108
|
+
// back on tool_use turns; empty thinking + signature is valid).
|
|
109
|
+
const thinkingBlocks = new Map();
|
|
105
110
|
let model = '';
|
|
106
111
|
let toolCalls = [];
|
|
107
112
|
let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
|
|
@@ -389,6 +394,26 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
389
394
|
inputJson: '',
|
|
390
395
|
});
|
|
391
396
|
}
|
|
397
|
+
if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
|
|
398
|
+
if (block.type === 'redacted_thinking') {
|
|
399
|
+
// Redacted blocks round-trip EXACTLY as
|
|
400
|
+
// {type:'redacted_thinking',data} — no thinking/
|
|
401
|
+
// signature fields (the API rejects the extras).
|
|
402
|
+
// `data` carries the opaque payload verbatim.
|
|
403
|
+
thinkingBlocks.set(event.index, {
|
|
404
|
+
type: 'redacted_thinking',
|
|
405
|
+
data: typeof block.data === 'string' ? block.data : '',
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
// Seed an ordered thinking block; deltas below
|
|
409
|
+
// append text + signature into this same slot.
|
|
410
|
+
thinkingBlocks.set(event.index, {
|
|
411
|
+
type: 'thinking',
|
|
412
|
+
thinking: typeof block.thinking === 'string' ? block.thinking : '',
|
|
413
|
+
signature: typeof block.signature === 'string' ? block.signature : '',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
392
417
|
}
|
|
393
418
|
|
|
394
419
|
if (event.type === 'content_block_delta') {
|
|
@@ -433,6 +458,21 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
433
458
|
// tool_use) can be classified by the loop as
|
|
434
459
|
// synthesis-stalled rather than silent empty.
|
|
435
460
|
hasThinkingContent = true;
|
|
461
|
+
// Accumulate the block content in order so it can be
|
|
462
|
+
// returned intact and round-tripped on the next turn.
|
|
463
|
+
// A signature_delta may arrive before any thinking_delta
|
|
464
|
+
// seeded the slot (display-omitted models emit only a
|
|
465
|
+
// signature) — lazily create it.
|
|
466
|
+
let tb = thinkingBlocks.get(event.index);
|
|
467
|
+
if (!tb) {
|
|
468
|
+
tb = { type: 'thinking', thinking: '', signature: '' };
|
|
469
|
+
thinkingBlocks.set(event.index, tb);
|
|
470
|
+
}
|
|
471
|
+
if (delta.type === 'thinking_delta') {
|
|
472
|
+
tb.thinking += delta.thinking || '';
|
|
473
|
+
} else {
|
|
474
|
+
tb.signature += delta.signature || '';
|
|
475
|
+
}
|
|
436
476
|
try { onStreamDelta?.(); } catch {}
|
|
437
477
|
}
|
|
438
478
|
if (delta?.type === 'input_json_delta') {
|
|
@@ -577,6 +617,15 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
577
617
|
stopReason,
|
|
578
618
|
hasThinkingContent,
|
|
579
619
|
contentBlockTypes: Array.from(contentBlockTypes),
|
|
620
|
+
// Ordered extended-thinking blocks (verbatim thinking text +
|
|
621
|
+
// signature) for round-tripping on tool-continuation turns. Emitted
|
|
622
|
+
// in content_block index order. Empty thinking + signature is a
|
|
623
|
+
// valid block (display-omitted models) and is kept intact.
|
|
624
|
+
thinkingBlocks: thinkingBlocks.size
|
|
625
|
+
? [...thinkingBlocks.entries()]
|
|
626
|
+
.sort((a, b) => a[0] - b[0])
|
|
627
|
+
.map(([, b]) => b)
|
|
628
|
+
: undefined,
|
|
580
629
|
};
|
|
581
630
|
} finally {
|
|
582
631
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { loadConfig } from '../config.mjs';
|
|
3
|
-
import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
|
|
3
|
+
import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
|
|
4
4
|
import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
|
|
5
5
|
import { traceAgentUsage } from '../agent-trace.mjs';
|
|
6
6
|
import {
|
|
@@ -310,6 +310,10 @@ function nativeAnthropicTools(opts) {
|
|
|
310
310
|
}
|
|
311
311
|
function deferredAnthropicTools(activeTools, opts) {
|
|
312
312
|
if (opts?.session?.deferredNativeTools !== true) return [];
|
|
313
|
+
// Guard against an all-deferred tools array — the API rejects it with
|
|
314
|
+
// `400: At least one tool must have defer_loading=false` (iteration-cap
|
|
315
|
+
// final turn sends tools: []). See anthropic-oauth.mjs counterpart.
|
|
316
|
+
if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
|
|
313
317
|
const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
|
|
314
318
|
const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
|
|
315
319
|
return catalog
|
|
@@ -333,6 +337,14 @@ function toAnthropicMessages(messages) {
|
|
|
333
337
|
content = m.assistantBlocks.slice();
|
|
334
338
|
} else {
|
|
335
339
|
content = [];
|
|
340
|
+
// Adaptive-thinking round-trip: prior-turn thinking blocks are
|
|
341
|
+
// REQUIRED back, unmodified (signature intact; empty thinking
|
|
342
|
+
// field allowed), and MUST precede tool_use blocks.
|
|
343
|
+
if (Array.isArray(m.thinkingBlocks)) {
|
|
344
|
+
for (const tb of m.thinkingBlocks) {
|
|
345
|
+
if (tb && typeof tb === 'object') content.push(tb);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
336
348
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
337
349
|
for (const tc of m.toolCalls) {
|
|
338
350
|
content.push({
|
|
@@ -366,6 +378,12 @@ function toAnthropicMessages(messages) {
|
|
|
366
378
|
}
|
|
367
379
|
continue;
|
|
368
380
|
}
|
|
381
|
+
// Claude Code parity: fold a user text turn that directly follows a
|
|
382
|
+
// tool_result turn into that tool_result's content (empty end_turn
|
|
383
|
+
// livelock prevention; see foldUserTextIntoToolResultTail).
|
|
384
|
+
if (m.role === 'user' && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
369
387
|
result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
|
|
370
388
|
}
|
|
371
389
|
return sanitizeAnthropicContentPairs(result);
|
|
@@ -662,6 +680,12 @@ export class AnthropicProvider {
|
|
|
662
680
|
contentBlockTypes: Array.isArray(parseResult.contentBlockTypes)
|
|
663
681
|
? parseResult.contentBlockTypes
|
|
664
682
|
: [],
|
|
683
|
+
// Round-trip adaptive-thinking blocks (verbatim thinking +
|
|
684
|
+
// signature) so the loop can store them and replay them before
|
|
685
|
+
// tool_use on the next turn. Matches anthropic-oauth's return.
|
|
686
|
+
thinkingBlocks: Array.isArray(parseResult.thinkingBlocks) && parseResult.thinkingBlocks.length
|
|
687
|
+
? parseResult.thinkingBlocks
|
|
688
|
+
: undefined,
|
|
665
689
|
usage: {
|
|
666
690
|
inputTokens: input,
|
|
667
691
|
outputTokens: output,
|
|
@@ -808,6 +832,16 @@ export class AnthropicProvider {
|
|
|
808
832
|
if (attemptIndex > 0 && firstAttemptError) {
|
|
809
833
|
try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
|
|
810
834
|
try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
|
|
835
|
+
// firstAttemptError (from an earlier retried attempt)
|
|
836
|
+
// is what actually propagates when live text was
|
|
837
|
+
// emitted this attempt — stamp the unsafe/partial
|
|
838
|
+
// flags onto IT too, otherwise upstream sees an
|
|
839
|
+
// unmarked error and retries, duplicating streamed
|
|
840
|
+
// output.
|
|
841
|
+
try {
|
|
842
|
+
firstAttemptError.liveTextEmitted = true;
|
|
843
|
+
firstAttemptError.unsafeToRetry = true;
|
|
844
|
+
} catch {}
|
|
811
845
|
throw firstAttemptError;
|
|
812
846
|
}
|
|
813
847
|
throw err;
|
|
@@ -47,22 +47,38 @@ export function geminiTruncatedStreamError(message) {
|
|
|
47
47
|
// stall after visible output was silently retried. Visible-text stalls also
|
|
48
48
|
// gain streamStalled + partialContent so the loop's partial-final path can
|
|
49
49
|
// keep the streamed output instead of dropping the turn.
|
|
50
|
-
export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null } = {}) {
|
|
50
|
+
export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null, sawFunctionCall = false } = {}) {
|
|
51
51
|
if (!err || typeof err !== 'object') return err;
|
|
52
52
|
const leaked = (textLeakGuard?.getLeakedToolCalls?.() || []).length > 0;
|
|
53
53
|
const visible = relayedText.length > 0;
|
|
54
|
+
// A native functionCall chunk (not a text-leaked one) is also an in-flight
|
|
55
|
+
// tool use — replaying would double-dispatch, and partial-final must NOT
|
|
56
|
+
// treat it as a clean no-tool summary.
|
|
57
|
+
const pendingTool = leaked || sawFunctionCall === true;
|
|
54
58
|
try {
|
|
55
59
|
if (visible) { err.liveTextEmitted = true; err.unsafeToRetry = true; }
|
|
56
|
-
if (
|
|
57
|
-
|
|
60
|
+
if (pendingTool) { err.emittedToolCall = true; err.unsafeToRetry = true; }
|
|
61
|
+
// TRUNCATED_STREAM EOF after visible output must also carry the
|
|
62
|
+
// partial-final stamps (streamStalled/partialContent), aligning with the
|
|
63
|
+
// compat streams — otherwise live output is dropped instead of kept.
|
|
64
|
+
if (visible && !leaked
|
|
65
|
+
&& (err.code === 'EGEMINITIMEOUT' || err.code === 'TRUNCATED_STREAM' || err.truncatedStream === true)) {
|
|
58
66
|
err.streamStalled = true;
|
|
59
67
|
if (typeof err.partialContent !== 'string') err.partialContent = relayedText;
|
|
60
|
-
if (err.pendingToolUse === undefined) err.pendingToolUse =
|
|
68
|
+
if (err.pendingToolUse === undefined) err.pendingToolUse = pendingTool;
|
|
61
69
|
}
|
|
62
70
|
} catch { /* best-effort */ }
|
|
63
71
|
return err;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
// True when a streamed Gemini chunk carries a native functionCall part (as
|
|
75
|
+
// opposed to a tool call leaked as plain text, tracked by textLeakGuard).
|
|
76
|
+
export function geminiChunkHasFunctionCall(chunk) {
|
|
77
|
+
const parts = chunk?.candidates?.[0]?.content?.parts;
|
|
78
|
+
if (!Array.isArray(parts)) return false;
|
|
79
|
+
return parts.some((p) => p && p.functionCall);
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/**
|
|
67
83
|
* Aggregate streamed GenerateContentResponse chunks into one response object
|
|
68
84
|
* (same shape as a non-streaming generateContent JSON body).
|
|
@@ -245,6 +261,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
245
261
|
let idleTimer = null;
|
|
246
262
|
let idleReject = null;
|
|
247
263
|
let relayedText = '';
|
|
264
|
+
let sawFunctionCall = false;
|
|
248
265
|
|
|
249
266
|
let firstByteTimer = setTimeout(() => {
|
|
250
267
|
try { reader.cancel('first byte timeout'); } catch {}
|
|
@@ -332,6 +349,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
332
349
|
}
|
|
333
350
|
allChunks.push(parsed);
|
|
334
351
|
try { onStreamDelta?.(); } catch {}
|
|
352
|
+
if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
|
|
335
353
|
if (onTextDelta || textLeakGuard) {
|
|
336
354
|
const t = geminiChunkText(parsed);
|
|
337
355
|
if (t) relayedText += t;
|
|
@@ -352,6 +370,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
352
370
|
}
|
|
353
371
|
allChunks.push(parsed);
|
|
354
372
|
try { onStreamDelta?.(); } catch {}
|
|
373
|
+
if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
|
|
355
374
|
if (onTextDelta || textLeakGuard) {
|
|
356
375
|
const t = geminiChunkText(parsed);
|
|
357
376
|
if (t) relayedText += t;
|
|
@@ -362,7 +381,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
362
381
|
}
|
|
363
382
|
}
|
|
364
383
|
} catch (err) {
|
|
365
|
-
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
384
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
|
|
366
385
|
} finally {
|
|
367
386
|
clearFirstByteTimer();
|
|
368
387
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -380,7 +399,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
|
|
|
380
399
|
try {
|
|
381
400
|
assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
|
|
382
401
|
} catch (err) {
|
|
383
|
-
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
402
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
|
|
384
403
|
}
|
|
385
404
|
return aggregated;
|
|
386
405
|
}
|
|
@@ -393,6 +412,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
393
412
|
let firstByteTimer = null;
|
|
394
413
|
let inFlightReject = null;
|
|
395
414
|
let relayedText = '';
|
|
415
|
+
let sawFunctionCall = false;
|
|
396
416
|
|
|
397
417
|
const armFirstByteTimer = () => {
|
|
398
418
|
if (firstByteTimer) clearTimeout(firstByteTimer);
|
|
@@ -504,6 +524,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
504
524
|
resetIdleTimer();
|
|
505
525
|
if (step.value) collectedChunks.push(step.value);
|
|
506
526
|
try { onStreamDelta?.(); } catch {}
|
|
527
|
+
if (!sawFunctionCall && geminiChunkHasFunctionCall(step.value)) sawFunctionCall = true;
|
|
507
528
|
if (onTextDelta || textLeakGuard) {
|
|
508
529
|
const t = geminiChunkText(step.value);
|
|
509
530
|
if (t) relayedText += t;
|
|
@@ -519,7 +540,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
519
540
|
const reason = signal.reason;
|
|
520
541
|
throw reason instanceof Error ? reason : new Error(`${label} aborted`);
|
|
521
542
|
}
|
|
522
|
-
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
543
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
|
|
523
544
|
} finally {
|
|
524
545
|
clearFirstByteTimer();
|
|
525
546
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -559,7 +580,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
559
580
|
try {
|
|
560
581
|
assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
|
|
561
582
|
} catch (err) {
|
|
562
|
-
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
|
|
583
|
+
throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
|
|
563
584
|
}
|
|
564
585
|
return raw;
|
|
565
586
|
}
|
|
@@ -68,7 +68,7 @@ const DEFAULT_MODEL = MODELS[0].id;
|
|
|
68
68
|
const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
69
69
|
// Bump when the on-disk cache shape changes so stale-shape entries are
|
|
70
70
|
// discarded instead of misread.
|
|
71
|
-
const GEMINI_MODEL_CACHE_SCHEMA_VERSION =
|
|
71
|
+
const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 2;
|
|
72
72
|
|
|
73
73
|
// De-dupes concurrent force-refreshes so they share one HTTP round-trip,
|
|
74
74
|
// mirroring anthropic-oauth's _modelRefreshInFlight.
|
|
@@ -155,6 +155,10 @@ export class GeminiProvider {
|
|
|
155
155
|
const cfg = freshConfig.providers?.gemini;
|
|
156
156
|
const newKey = cfg?.apiKey || process.env.GEMINI_API_KEY;
|
|
157
157
|
if (newKey) {
|
|
158
|
+
// Keep this.config in sync so REST/cache paths (which read the
|
|
159
|
+
// key via _getApiKey() → this.config.apiKey) don't keep using
|
|
160
|
+
// the stale key after a rotation; genAI alone is not enough.
|
|
161
|
+
this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
|
|
158
162
|
this.genAI = new GoogleGenerativeAI(newKey);
|
|
159
163
|
}
|
|
160
164
|
} catch { /* best effort */ }
|
|
@@ -871,10 +875,11 @@ export class GeminiProvider {
|
|
|
871
875
|
if (!res.ok) throw new Error(`gemini list_models ${res.status}`);
|
|
872
876
|
const data = await res.json();
|
|
873
877
|
const items = Array.isArray(data?.models) ? data.models : [];
|
|
874
|
-
// Filter to Gemini family; skip embedding/imagen
|
|
878
|
+
// Filter to Gemini family; skip embedding/imagen and non-coding SKUs
|
|
879
|
+
// (robotics, computer-use) that the chat picker should never show.
|
|
875
880
|
const normalized = items
|
|
876
881
|
.filter(m => (m?.name || '').includes('gemini'))
|
|
877
|
-
.filter(m => !/embedding|aqa|imagen/.test(m?.name || ''))
|
|
882
|
+
.filter(m => !/embedding|aqa|imagen|robotics|computer-use/.test(m?.name || ''))
|
|
878
883
|
.map(m => {
|
|
879
884
|
const id = (m.name || '').replace(/^models\//, '');
|
|
880
885
|
const family = /flash-lite/.test(id) ? 'gemini-flash-lite'
|
|
@@ -886,8 +891,11 @@ export class GeminiProvider {
|
|
|
886
891
|
display: m.displayName || id,
|
|
887
892
|
family,
|
|
888
893
|
provider: 'gemini',
|
|
889
|
-
|
|
890
|
-
|
|
894
|
+
// No fabricated fallbacks: when the API omits limits leave
|
|
895
|
+
// them null so enrichModels can fill from the catalog and
|
|
896
|
+
// the picker shows no context label instead of a fake 1M.
|
|
897
|
+
contextWindow: m.inputTokenLimit || null,
|
|
898
|
+
outputTokens: m.outputTokenLimit || null,
|
|
891
899
|
tier: 'version',
|
|
892
900
|
latest: false,
|
|
893
901
|
description: m.description || '',
|
|
@@ -250,7 +250,7 @@ function saveTokens(tokens) {
|
|
|
250
250
|
expires_at: tokens.expires_at || 0,
|
|
251
251
|
token_endpoint: tokens.token_endpoint || null,
|
|
252
252
|
user_id: tokens.user_id || tokens.userId || _identityFromAccessToken(tokens.access_token).user_id || undefined,
|
|
253
|
-
}, { lock: true, fsyncDir: true });
|
|
253
|
+
}, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
function _scrubTokens(text) {
|
|
@@ -41,6 +41,7 @@ const NON_CHAT_RE = new RegExp(
|
|
|
41
41
|
'dall[-_]?e', 'sora', 'imagen', 'imagine', 'veo', 'kling', 'runway',
|
|
42
42
|
'realtime', 'transcribe', 'transcription', 'diarize',
|
|
43
43
|
'guard', 'safety', 'classifier',
|
|
44
|
+
'robotics', 'computer[-_]?use',
|
|
44
45
|
].join('|') +
|
|
45
46
|
')([-_/\\s]|$)',
|
|
46
47
|
'i',
|
|
@@ -64,6 +65,15 @@ const NON_LLM_ID_PATTERNS = [
|
|
|
64
65
|
/^gpt-audio(\b|-)/i, // gpt-audio, gpt-audio-mini, gpt-audio-1.5
|
|
65
66
|
/^gpt-realtime(\b|-)/i, // gpt-realtime*, gpt-realtime-mini
|
|
66
67
|
/-tts(\b|-)/i, // any *-tts SKU
|
|
68
|
+
// Purpose-specific SKUs that report mode:'chat' but are never useful for a
|
|
69
|
+
// coding agent (embodied/robotics, browser computer-use, audio-native,
|
|
70
|
+
// customtools picker-duplicate variants).
|
|
71
|
+
/(^|-)robotics(\b|-)/i, // gemini-robotics-er-*
|
|
72
|
+
/computer[-_]?use/i, // gemini-2.5-computer-use-preview-*
|
|
73
|
+
/native[-_]?audio/i, // gemini-2.5-flash-native-audio-*
|
|
74
|
+
/-customtools(\b|-|$)/i, // gemini-3.1-pro-preview-customtools
|
|
75
|
+
/^codex-auto-review$/i, // Codex backend auto-review model, not a picker choice
|
|
76
|
+
/^grok-build$/i, // proxy alias duplicating grok-build-0.1 (exact — 0.1 stays)
|
|
67
77
|
];
|
|
68
78
|
|
|
69
79
|
function _isNonLlmId(lid) {
|
|
@@ -89,6 +99,7 @@ const LEGACY_PATTERNS = [
|
|
|
89
99
|
/^claude-(1|2|3|instant)(\b|-|\.)/i,
|
|
90
100
|
// Gemini legacy — gemini-1*, gemini-pro. gemini-2/3 not matched.
|
|
91
101
|
/^gemini-1(\b|-|\.)/i,
|
|
102
|
+
/^gemini-2\.0(\b|-|\.)/i,
|
|
92
103
|
/^gemini-pro(\b|-)/i,
|
|
93
104
|
// Grok legacy — grok-1/2/3 and grok-beta. The digit boundary stops grok-3
|
|
94
105
|
// from catching grok-4.20-beta.
|
|
@@ -669,6 +669,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
669
669
|
} else if (event.response.status === 'incomplete') {
|
|
670
670
|
const reason = incompleteReasonFromResponsesEvent(event);
|
|
671
671
|
if (isMaxOutputIncompleteReason(reason)) {
|
|
672
|
+
// Max-output cutoff with a tool call still in flight means
|
|
673
|
+
// the function-call arguments were truncated — do NOT mark
|
|
674
|
+
// this a clean completion, or partial args surface as a
|
|
675
|
+
// successful tool call. Treat as unsafe/partial instead.
|
|
676
|
+
if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
|
|
677
|
+
const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
|
|
678
|
+
err.streamStalled = true;
|
|
679
|
+
err.pendingToolUse = true;
|
|
680
|
+
err.partialContent = state.content || '';
|
|
681
|
+
throw err;
|
|
682
|
+
}
|
|
672
683
|
state.completed = true;
|
|
673
684
|
state.stopReason = 'length';
|
|
674
685
|
state.completedResponse = event.response || state.completedResponse;
|
|
@@ -686,6 +697,13 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
686
697
|
case 'response.incomplete': {
|
|
687
698
|
const reason = incompleteReasonFromResponsesEvent(event);
|
|
688
699
|
if (isMaxOutputIncompleteReason(reason)) {
|
|
700
|
+
if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
|
|
701
|
+
const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
|
|
702
|
+
err.streamStalled = true;
|
|
703
|
+
err.pendingToolUse = true;
|
|
704
|
+
err.partialContent = state.content || '';
|
|
705
|
+
throw err;
|
|
706
|
+
}
|
|
689
707
|
state.completed = true;
|
|
690
708
|
state.stopReason = 'length';
|
|
691
709
|
state.completedResponse = event.response || state.completedResponse;
|
|
@@ -113,7 +113,7 @@ async function _resolveCodexClientVersion() {
|
|
|
113
113
|
return CODEX_CLIENT_VERSION_FLOOR;
|
|
114
114
|
}
|
|
115
115
|
const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
116
|
-
const CODEX_MODEL_CACHE_SCHEMA_VERSION =
|
|
116
|
+
const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
|
|
117
117
|
const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
118
118
|
|
|
119
119
|
const _codexModelCache = makeModelCache({
|
|
@@ -587,6 +587,10 @@ export async function _streamResponse({
|
|
|
587
587
|
// Partial-final recovery: attach streamed partial state so
|
|
588
588
|
// a wedged FINAL no-tool summary can be accepted as partial-final
|
|
589
589
|
// success by the loop. pendingToolUse gates out mid-flight tools.
|
|
590
|
+
// Fold the held leak-guard tail into `content` FIRST so the
|
|
591
|
+
// partial snapshot below keeps legitimate trailing text; finish()
|
|
592
|
+
// then skips flushLeak (terminalError set) without losing it.
|
|
593
|
+
flushLeak();
|
|
590
594
|
try {
|
|
591
595
|
terminalError.partialContent = content;
|
|
592
596
|
terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
|
|
@@ -631,9 +635,14 @@ export async function _streamResponse({
|
|
|
631
635
|
};
|
|
632
636
|
const finish = () => {
|
|
633
637
|
logReasoningDeltaSuppression();
|
|
638
|
+
// On a terminal error we must NOT flush buffered text/tool calls:
|
|
639
|
+
// finish() rejects below, so flushing would emit partial output the
|
|
640
|
+
// caller then never consumes as a clean result (double-render/
|
|
641
|
+
// double-dispatch risk). The partial-final path reads partialContent
|
|
642
|
+
// off the error instead. Only flush the held-back tail on success.
|
|
643
|
+
if (!terminalError) flushLeak();
|
|
634
644
|
// Flush any partial-sentinel tail held back mid-stream so
|
|
635
645
|
// legitimate trailing text is never lost (streamed-text path).
|
|
636
|
-
flushLeak();
|
|
637
646
|
cleanup();
|
|
638
647
|
if (terminalError) { reject(terminalError); return; }
|
|
639
648
|
resolve({
|
|
@@ -694,6 +703,12 @@ export async function _streamResponse({
|
|
|
694
703
|
// first-meaningful watchdog (keepalive/metadata frames do
|
|
695
704
|
// NOT reach this case, so they never clear it).
|
|
696
705
|
clearFirstMeaningfulWatchdog();
|
|
706
|
+
// Arm the semantic idle/stall timer from response.created
|
|
707
|
+
// too: it otherwise only arms on the first meaningful delta
|
|
708
|
+
// (bumpSemanticIdle), so a created+keepalive-only stream that
|
|
709
|
+
// never emits a delta would stall unbounded until the outer
|
|
710
|
+
// watchdog. Arming here bounds that gap.
|
|
711
|
+
resetSemanticIdle();
|
|
697
712
|
break;
|
|
698
713
|
case 'response.output_text.delta':
|
|
699
714
|
try {
|
|
@@ -748,6 +763,10 @@ export async function _streamResponse({
|
|
|
748
763
|
// response.output_item.done.
|
|
749
764
|
_toolInFlight = true;
|
|
750
765
|
}
|
|
766
|
+
// Item lifecycle is genuine progress: reset the semantic-idle
|
|
767
|
+
// timer so long server-side tool latency after item-added
|
|
768
|
+
// (before any arg delta) is not mistaken for a silent stall.
|
|
769
|
+
resetSemanticIdle();
|
|
751
770
|
break;
|
|
752
771
|
case 'response.function_call_arguments.delta':
|
|
753
772
|
_toolInFlight = true;
|
|
@@ -826,6 +845,9 @@ export async function _streamResponse({
|
|
|
826
845
|
if (event.item?.type === 'custom_tool_call') {
|
|
827
846
|
pushCustomToolCall(event.item);
|
|
828
847
|
}
|
|
848
|
+
// Item-done is genuine lifecycle progress — reset semantic
|
|
849
|
+
// idle so latency before the next item/args does not stall.
|
|
850
|
+
resetSemanticIdle();
|
|
829
851
|
break;
|
|
830
852
|
case 'response.completed': {
|
|
831
853
|
const completedServiceTier = event.response?.service_tier || event.response?.serviceTier || '';
|
|
@@ -1048,7 +1070,13 @@ export async function _streamResponse({
|
|
|
1048
1070
|
try { onStreamDelta?.(); } catch {}
|
|
1049
1071
|
bumpSemanticIdle();
|
|
1050
1072
|
}
|
|
1051
|
-
//
|
|
1073
|
+
// response.in_progress is a server lifecycle heartbeat during
|
|
1074
|
+
// long tool/generation latency — reset semantic idle so it is
|
|
1075
|
+
// not counted as silence (resetIdle already ran at the top).
|
|
1076
|
+
else if (event.type === 'response.in_progress') {
|
|
1077
|
+
resetSemanticIdle();
|
|
1078
|
+
}
|
|
1079
|
+
// Other trace-only events fall through.
|
|
1052
1080
|
break;
|
|
1053
1081
|
}
|
|
1054
1082
|
};
|
|
@@ -134,6 +134,25 @@ function _scopedDependencyRoots(toolName, args, cwd) {
|
|
|
134
134
|
}
|
|
135
135
|
if (rawPaths.length > 0) {
|
|
136
136
|
for (const p of rawPaths) add(p);
|
|
137
|
+
// `glob` results are gated on the pattern's static (non-magic) prefix,
|
|
138
|
+
// not just cwd/path root — a pattern like "src/**/*.mjs" must register
|
|
139
|
+
// "src", not just cwd, or edits under src/ that are not directly under
|
|
140
|
+
// the given path root will not invalidate the cached glob result.
|
|
141
|
+
if (toolName === 'glob' && canonicalArgs && typeof canonicalArgs.pattern !== 'undefined') {
|
|
142
|
+
const patterns = [];
|
|
143
|
+
_collectPathValues(canonicalArgs.pattern, patterns);
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
if (typeof pattern !== 'string' || !_hasGlobMagic(pattern)) continue;
|
|
146
|
+
const patternRoot = _extractGlobRoot(pattern);
|
|
147
|
+
if (_pathIsAbs(patternRoot)) {
|
|
148
|
+
add(patternRoot);
|
|
149
|
+
} else if (rawPaths.length > 0) {
|
|
150
|
+
for (const p of rawPaths) add(join(p, patternRoot));
|
|
151
|
+
} else {
|
|
152
|
+
add(patternRoot);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
137
156
|
} else if (cwd && typeof cwd === 'string') {
|
|
138
157
|
add(cwd);
|
|
139
158
|
}
|
|
@@ -22,11 +22,11 @@ export {
|
|
|
22
22
|
export const SUMMARY_PREFIX = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.';
|
|
23
23
|
export const SUMMARY_OUTPUT_TOKENS = 4_096;
|
|
24
24
|
// Unified context-share rule: every derived "how much of the model context
|
|
25
|
-
// may this budget take" ratio uses ONE number —
|
|
25
|
+
// may this budget take" ratio uses ONE number — 10%. Consumers:
|
|
26
26
|
// - compact target budget (loop/compact-policy.mjs COMPACT_TARGET_RATIO)
|
|
27
27
|
// - recall-fasttrack injection cap (loop.mjs recallTokenCap)
|
|
28
28
|
// Keep them in lockstep; do not fork per-consumer ratios without a decision.
|
|
29
|
-
export const CONTEXT_SHARE_RATIO = 0.
|
|
29
|
+
export const CONTEXT_SHARE_RATIO = 0.10;
|
|
30
30
|
// Floor for the recall-injection cap so small-context models still get a
|
|
31
31
|
// usable recall slice (cap = max(floor, contextWindow * CONTEXT_SHARE_RATIO)).
|
|
32
32
|
export const RECALL_TOKEN_CAP_FLOOR_TOKENS = 2_048;
|
|
@@ -672,7 +672,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
|
|
|
672
672
|
};
|
|
673
673
|
// Recall injection room is capped separately from the mandatory
|
|
674
674
|
// (system + preserved tail) budget: recall text never gets more than
|
|
675
|
-
// opts.recallTokenCap (
|
|
675
|
+
// opts.recallTokenCap (10% of context window, floor 2048 tokens, set by
|
|
676
676
|
// the caller); the remaining budget stays reserved for live
|
|
677
677
|
// conversation. Only applied when the cap is a positive finite number,
|
|
678
678
|
// preserving current uncapped behavior otherwise.
|
|
@@ -253,21 +253,43 @@ export function fitCompactionPrompt(input, targetTokens) {
|
|
|
253
253
|
const buildReduced = (k) => {
|
|
254
254
|
const kept = k > 0 ? head.slice(head.length - k) : [];
|
|
255
255
|
const omitted = head.length - kept.length;
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
256
|
+
const finalize = (stubHead) => {
|
|
257
|
+
let inp = { ...baseNoFacts, head: stubHead };
|
|
258
|
+
// Also shrink/drop a prior <previous-summary> (same as the normal
|
|
259
|
+
// fitAt path) — a large prior summary can keep the prompt over
|
|
260
|
+
// budget even at K=0. fitPreviousSummaryForCompactionPrompt is a
|
|
261
|
+
// no-op when there is no previousSummary, so this is safe for the
|
|
262
|
+
// summary-less case.
|
|
263
|
+
if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
|
|
264
|
+
const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
|
|
265
|
+
if (!fitted) return null;
|
|
266
|
+
inp = fitted;
|
|
267
|
+
if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
|
|
268
|
+
}
|
|
269
|
+
return buildCompactionPrompt(inp, 0);
|
|
270
|
+
};
|
|
271
|
+
if (omitted <= 0) return finalize(kept);
|
|
272
|
+
// The omitted head messages never reappear in the session afterward
|
|
273
|
+
// (the caller replaces the whole head with the produced summary), so
|
|
274
|
+
// a bare "[N older messages omitted]" stub used to discard their
|
|
275
|
+
// content with zero trace. Prefer a compact per-message digest line
|
|
276
|
+
// for each omitted message so at least a sliver of detail survives
|
|
277
|
+
// into the summary input; only fall back to the count-only stub if
|
|
278
|
+
// even the digest cannot fit the emergency budget, preserving the
|
|
279
|
+
// original guarantee that this reduction always finds a fit.
|
|
280
|
+
const digestLines = head.slice(0, omitted).map((m, i) => {
|
|
281
|
+
const role = m?.role || 'unknown';
|
|
282
|
+
const text = truncateMiddle(extractText(m).trim(), 30);
|
|
283
|
+
return text ? `${i + 1}. ${role}: ${text}` : `${i + 1}. ${role}`;
|
|
284
|
+
});
|
|
285
|
+
const digestStub = {
|
|
286
|
+
role: 'user',
|
|
287
|
+
content: [`[${omitted} older messages compacted to a digest below]`, ...digestLines].join('\n'),
|
|
288
|
+
};
|
|
289
|
+
const withDigest = finalize([digestStub, ...kept]);
|
|
290
|
+
if (withDigest) return withDigest;
|
|
291
|
+
const countStub = { role: 'user', content: `[${omitted} older messages omitted]` };
|
|
292
|
+
return finalize([countStub, ...kept]);
|
|
271
293
|
};
|
|
272
294
|
let lo = 0;
|
|
273
295
|
let hi = head.length;
|