mixdog 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -23
- package/package.json +33 -27
- package/scripts/_test-folder-dialog.mjs +30 -0
- package/scripts/agent-parallel-smoke.mjs +388 -0
- package/scripts/agent-tag-reuse-smoke.mjs +183 -0
- package/scripts/background-task-meta-smoke.mjs +38 -0
- package/scripts/boot-smoke.mjs +52 -9
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/compact-active-turn-test.mjs +68 -0
- package/scripts/compact-smoke.mjs +859 -129
- package/scripts/compact-trigger-migration-smoke.mjs +187 -0
- package/scripts/fix-brief-fn.mjs +35 -0
- package/scripts/fix-format-tool-surface.mjs +24 -0
- package/scripts/fix-tool-exec-visible.mjs +42 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/hook-bus-test.mjs +330 -0
- package/scripts/lead-workflow-smoke.mjs +33 -39
- package/scripts/live-worker-smoke.mjs +43 -37
- package/scripts/llm-trace-summary.mjs +315 -0
- package/scripts/memory-meta-concurrency-test.mjs +20 -0
- package/scripts/output-style-smoke.mjs +56 -15
- package/scripts/parent-abort-link-test.mjs +44 -0
- package/scripts/patch-agent-brief.mjs +48 -0
- package/scripts/patch-app.mjs +21 -0
- package/scripts/patch-app2.mjs +18 -0
- package/scripts/patch-dist-brief.mjs +96 -0
- package/scripts/patch-tool-exec.mjs +70 -0
- package/scripts/pretool-ask-runtime-test.mjs +54 -0
- package/scripts/provider-toolcall-test.mjs +376 -0
- package/scripts/reactive-compact-persist-smoke.mjs +124 -0
- package/scripts/sanitize-tool-pairs-test.mjs +260 -0
- package/scripts/session-context-bench.mjs +344 -0
- package/scripts/session-ingest-smoke.mjs +177 -0
- package/scripts/set-effort-config-test.mjs +41 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -0
- package/scripts/smoke-runtime-negative.sh +97 -0
- package/scripts/smoke.mjs +25 -0
- package/scripts/tool-result-hook-test.mjs +48 -0
- package/scripts/tool-smoke.mjs +1223 -95
- package/scripts/toolcall-args-test.mjs +150 -0
- package/scripts/tui-background-failure-smoke.mjs +73 -0
- package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
- package/src/agents/debugger/AGENT.md +8 -0
- package/src/agents/explore/AGENT.md +4 -0
- package/src/agents/heavy-worker/AGENT.md +9 -3
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/agents/scheduler-task/AGENT.md +12 -0
- package/src/agents/scheduler-task/agent.json +6 -0
- package/src/agents/webhook-handler/AGENT.md +12 -0
- package/src/agents/webhook-handler/agent.json +6 -0
- package/src/agents/worker/AGENT.md +9 -3
- package/src/app.mjs +77 -3
- package/src/defaults/hidden-roles.json +17 -12
- package/src/headless-role.mjs +117 -0
- package/src/help.mjs +30 -0
- package/src/hooks/lib/permission-evaluator.cjs +11 -475
- package/src/lib/keychain-cjs.cjs +9 -1
- package/src/lib/mixdog-debug.cjs +0 -7
- package/src/lib/rules-builder.cjs +240 -96
- package/src/lib/text-utils.cjs +1 -1
- package/src/mixdog-session-runtime.mjs +2325 -450
- package/src/output-styles/default.md +12 -28
- package/src/output-styles/extreme-simple.md +9 -6
- package/src/output-styles/simple.md +22 -9
- package/src/repl.mjs +118 -59
- package/src/rules/agent/00-common.md +15 -0
- package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
- package/src/rules/agent/30-explorer.md +22 -0
- package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
- package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
- package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
- package/src/rules/lead/01-general.md +9 -5
- package/src/rules/lead/04-workflow.md +51 -12
- package/src/rules/lead/lead-tool.md +6 -0
- package/src/rules/shared/01-tool.md +12 -1
- package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
- package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
- package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
- package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
- package/src/runtime/agent/orchestrator/config.mjs +174 -55
- package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
- package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
- package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
- package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
- package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
- package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
- package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
- package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
- package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
- package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
- package/src/runtime/channels/index.mjs +152 -24
- package/src/runtime/channels/lib/scheduler.mjs +18 -14
- package/src/runtime/channels/lib/session-discovery.mjs +3 -2
- package/src/runtime/channels/lib/tool-format.mjs +0 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
- package/src/runtime/channels/lib/webhook.mjs +1 -1
- package/src/runtime/channels/tool-defs.mjs +29 -29
- package/src/runtime/memory/index.mjs +635 -107
- package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
- package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
- package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
- package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
- package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
- package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
- package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
- package/src/runtime/memory/lib/memory.mjs +88 -9
- package/src/runtime/memory/lib/model-profile.mjs +1 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
- package/src/runtime/memory/lib/session-ingest.mjs +194 -0
- package/src/runtime/memory/lib/trace-store.mjs +96 -51
- package/src/runtime/memory/tool-defs.mjs +46 -37
- package/src/runtime/search/index.mjs +102 -466
- package/src/runtime/search/lib/web-tools.mjs +45 -25
- package/src/runtime/search/tool-defs.mjs +16 -23
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +4 -3
- package/src/runtime/shared/background-tasks.mjs +122 -11
- package/src/runtime/shared/child-spawn-gate.mjs +145 -0
- package/src/runtime/shared/config.mjs +7 -4
- package/src/runtime/shared/err-text.mjs +131 -4
- package/src/runtime/shared/llm/cost.mjs +2 -2
- package/src/runtime/shared/llm/http-agent.mjs +23 -7
- package/src/runtime/shared/llm/index.mjs +34 -11
- package/src/runtime/shared/llm/usage-log.mjs +4 -4
- package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
- package/src/runtime/shared/singleton-owner.mjs +104 -0
- package/src/runtime/shared/tool-execution-contract.mjs +199 -20
- package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
- package/src/runtime/shared/tool-surface.mjs +624 -98
- package/src/runtime/shared/user-data-guard.mjs +0 -2
- package/src/standalone/agent-task-status.mjs +203 -0
- package/src/standalone/agent-task-status.test.mjs +76 -0
- package/src/standalone/agent-tool.mjs +1913 -0
- package/src/standalone/channel-worker.mjs +370 -14
- package/src/standalone/explore-tool.mjs +165 -70
- package/src/standalone/folder-dialog.mjs +314 -0
- package/src/standalone/hook-bus.mjs +898 -22
- package/src/standalone/memory-runtime-proxy.mjs +320 -0
- package/src/standalone/projects.mjs +226 -0
- package/src/standalone/provider-admin.mjs +41 -24
- package/src/standalone/seeds.mjs +2 -69
- package/src/standalone/usage-dashboard.mjs +96 -8
- package/src/tui/App.jsx +4798 -2153
- package/src/tui/components/AnsiText.jsx +39 -28
- package/src/tui/components/ContextPanel.jsx +87 -29
- package/src/tui/components/Markdown.jsx +43 -77
- package/src/tui/components/MarkdownTable.jsx +9 -184
- package/src/tui/components/Message.jsx +28 -11
- package/src/tui/components/Picker.jsx +95 -56
- package/src/tui/components/PromptInput.jsx +367 -239
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +27 -21
- package/src/tui/components/Spinner.jsx +67 -38
- package/src/tui/components/StatusLine.jsx +606 -38
- package/src/tui/components/TextEntryPanel.jsx +128 -9
- package/src/tui/components/ToolExecution.jsx +617 -368
- package/src/tui/components/TurnDone.jsx +3 -3
- package/src/tui/components/UsagePanel.jsx +3 -5
- package/src/tui/components/tool-output-format.mjs +365 -0
- package/src/tui/components/tool-output-format.test.mjs +220 -0
- package/src/tui/dist/index.mjs +8915 -2418
- package/src/tui/engine-runtime-notification.test.mjs +115 -0
- package/src/tui/engine-tool-result-text.test.mjs +75 -0
- package/src/tui/engine.mjs +1455 -279
- package/src/tui/figures.mjs +21 -40
- package/src/tui/index.jsx +75 -31
- package/src/tui/input-editing.mjs +25 -0
- package/src/tui/markdown/format-token.mjs +511 -68
- package/src/tui/markdown/format-token.test.mjs +216 -0
- package/src/tui/markdown/render-ansi.mjs +94 -0
- package/src/tui/markdown/render-ansi.test.mjs +108 -0
- package/src/tui/markdown/stream-fence.mjs +34 -0
- package/src/tui/markdown/stream-fence.test.mjs +26 -0
- package/src/tui/markdown/table-layout.mjs +250 -0
- package/src/tui/paste-attachments.mjs +0 -7
- package/src/tui/spinner-verbs.mjs +1 -2
- package/src/tui/statusline-ansi-bridge.mjs +172 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
- package/src/tui/theme.mjs +746 -24
- package/src/tui/time-format.mjs +1 -1
- package/src/tui/transcript-tool-failures.mjs +67 -0
- package/src/tui/transcript-tool-failures.test.mjs +111 -0
- package/src/ui/ansi.mjs +1 -2
- package/src/ui/markdown.mjs +85 -26
- package/src/ui/markdown.test.mjs +70 -0
- package/src/ui/model-display.mjs +121 -0
- package/src/ui/session-stats.mjs +44 -0
- package/src/ui/statusline-context-label.test.mjs +15 -0
- package/src/ui/statusline.mjs +386 -178
- package/src/ui/tool-card.mjs +3 -16
- package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
- package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
- package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
- package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
- package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
- package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +12 -5
- package/src/workflows/default/workflow.json +0 -1
- package/src/workflows/solo/WORKFLOW.md +15 -0
- package/src/workflows/solo/workflow.json +7 -0
- package/vendor/ink/build/output.js +6 -1
- package/src/agents/scheduler-task.md +0 -3
- package/src/agents/web-researcher/AGENT.md +0 -3
- package/src/agents/web-researcher/agent.json +0 -6
- package/src/agents/webhook-handler.md +0 -3
- package/src/rules/bridge/00-common.md +0 -5
- package/src/rules/bridge/30-explorer.md +0 -4
- package/src/rules/lead/00-tool-lead.md +0 -5
- package/src/rules/shared/00-language.md +0 -3
- package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
- package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
- package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
- package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
- package/src/standalone/bridge-tool.mjs +0 -1414
- package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
|
@@ -7,11 +7,27 @@ import {
|
|
|
7
7
|
} from './context-utils.mjs';
|
|
8
8
|
|
|
9
9
|
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.';
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// Default auto-compact trigger sits below the effective compact boundary by a
|
|
11
|
+
// compaction buffer (10% of boundary, capped at MAX_COMPACTION_BUFFER_RATIO).
|
|
12
|
+
// That headroom lets semantic compact run before the transcript is already at the
|
|
13
|
+
// hard limit (zero buffer caused overflow_failed with no room to summarize).
|
|
14
|
+
// Operators may still set compaction.bufferTokens / bufferPercent / bufferRatio,
|
|
15
|
+
// to tune headroom. Telemetry-persisted bufferTokens/bufferRatio of zero is not
|
|
16
|
+
// operator config; loop/manager strip it and reapply this default (see
|
|
17
|
+
// compactBufferConfigForBoundary).
|
|
18
|
+
export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
|
|
19
|
+
export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
12
20
|
export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
|
|
13
21
|
export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
|
|
14
22
|
export const SUMMARY_OUTPUT_TOKENS = 4_096;
|
|
23
|
+
// Minimum room the generated summary needs after the mandatory (system +
|
|
24
|
+
// preserved tail) cost is accounted for. When the configured target budget is
|
|
25
|
+
// smaller than the mandatory cost (e.g. the preserved recent turn carries a
|
|
26
|
+
// large tool result), the compaction MUST still proceed: the old head is the
|
|
27
|
+
// part being summarized away, so dropping it already shrinks the transcript.
|
|
28
|
+
// Refusing with "exceeds budget" here is what surfaced as auto-clear / overflow
|
|
29
|
+
// compact failures. Floor the working budget to mandatory + this room instead.
|
|
30
|
+
export const COMPACT_SUMMARY_MIN_ROOM_TOKENS = 4_000;
|
|
15
31
|
|
|
16
32
|
const TOOL_CALL_ARGS_MAX_CHARS = 260;
|
|
17
33
|
const TOOL_CALL_FACT_ARGS_MAX_CHARS = 140;
|
|
@@ -20,6 +36,50 @@ const TOOL_ARG_STRING_MAX_CHARS = 360;
|
|
|
20
36
|
const TOOL_ARG_ARRAY_MAX_ITEMS = 8;
|
|
21
37
|
const TOOL_ARG_MAX_DEPTH = 4;
|
|
22
38
|
const SENSITIVE_TOOL_ARG_KEY_RE = /(?:^|[_-])(?:api[_-]?key|authorization|auth|cookie|credential|passwd|password|refresh[_-]?token|secret|token)(?:$|[_-])/i;
|
|
39
|
+
// Word alternation for raw-string (non-JSON) secret redaction. Mirrors the
|
|
40
|
+
// keys in SENSITIVE_TOOL_ARG_KEY_RE and the session-ingest redactor so a raw
|
|
41
|
+
// tool-call argument string like `authorization: Bearer abc.def` or
|
|
42
|
+
// `password="abc def"` never reaches preserved facts or the compaction prompt.
|
|
43
|
+
const SENSITIVE_TOOL_ARG_KEY_WORD = '(?:api[_-]?key|authorization|auth|cookie|credential|passwd|password|refresh[_-]?token|secret|token)';
|
|
44
|
+
// Full key matcher: the sensitive WORD may carry a prefix and/or suffix segment
|
|
45
|
+
// joined by `_`/`-` so prefixed variants like `access_token`, `access-token`,
|
|
46
|
+
// `x-api-key`, and `bearer_token` are matched as whole keys (not just the bare
|
|
47
|
+
// word at key start). Prefix/suffix are bounded by a `_`/`-` separator so the
|
|
48
|
+
// word stays at an identifier boundary.
|
|
49
|
+
const SENSITIVE_TOOL_ARG_KEY_FULL = `(?:[A-Za-z0-9_-]*[_-])?${SENSITIVE_TOOL_ARG_KEY_WORD}(?:[_-][A-Za-z0-9_-]*)?`;
|
|
50
|
+
|
|
51
|
+
// Redact `key: value` / `key=value` secret pairs inside a raw (non-JSON)
|
|
52
|
+
// string. Consumes the WHOLE value after the key — spaces, `Bearer `/`Basic `
|
|
53
|
+
// scheme words, quoted values with internal spaces, and `;`-separated cookie
|
|
54
|
+
// pairs — so no secret fragment survives. Kept local to compact-core to avoid a
|
|
55
|
+
// cross-module dependency on the memory lib; logic matches session-ingest's
|
|
56
|
+
// redactRawArgString.
|
|
57
|
+
function redactRawSecretString(text) {
|
|
58
|
+
const value = String(text ?? '');
|
|
59
|
+
if (!value) return value;
|
|
60
|
+
const keyRe = new RegExp(`((?:^|[\\s,{(])["']?${SENSITIVE_TOOL_ARG_KEY_FULL}["']?\\s*[:=]\\s*)`, 'gi');
|
|
61
|
+
let out = '';
|
|
62
|
+
let last = 0;
|
|
63
|
+
let match;
|
|
64
|
+
while ((match = keyRe.exec(value)) !== null) {
|
|
65
|
+
const prefixEnd = match.index + match[0].length;
|
|
66
|
+
out += value.slice(last, prefixEnd);
|
|
67
|
+
let i = prefixEnd;
|
|
68
|
+
const quote = value[i] === '"' || value[i] === "'" ? value[i] : '';
|
|
69
|
+
if (quote) {
|
|
70
|
+
i += 1;
|
|
71
|
+
while (i < value.length && value[i] !== quote) i += 1;
|
|
72
|
+
if (i < value.length) i += 1; // include closing quote
|
|
73
|
+
} else {
|
|
74
|
+
while (i < value.length && !/[,)}\n]/.test(value[i])) i += 1;
|
|
75
|
+
}
|
|
76
|
+
out += '[redacted]';
|
|
77
|
+
last = i;
|
|
78
|
+
keyRe.lastIndex = i;
|
|
79
|
+
}
|
|
80
|
+
out += value.slice(last);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
23
83
|
|
|
24
84
|
function sha16(value) {
|
|
25
85
|
const text = typeof value === 'string' ? value : JSON.stringify(value ?? null);
|
|
@@ -62,7 +122,10 @@ function truncateMiddle(text, maxChars) {
|
|
|
62
122
|
function normalizeToolArgValue(value, key = '', depth = 0) {
|
|
63
123
|
if (SENSITIVE_TOOL_ARG_KEY_RE.test(String(key || ''))) return '[redacted]';
|
|
64
124
|
if (typeof value === 'bigint') return String(value);
|
|
65
|
-
|
|
125
|
+
// Defense-in-depth: a non-sensitive KEY can still carry a secret embedded
|
|
126
|
+
// in its string VALUE (e.g. a freeform `headers` string). Redact raw
|
|
127
|
+
// key:value secret pairs before truncating.
|
|
128
|
+
if (typeof value === 'string') return truncateMiddle(redactRawSecretString(value), TOOL_ARG_STRING_MAX_CHARS);
|
|
66
129
|
if (!value || typeof value !== 'object') return value ?? null;
|
|
67
130
|
if (depth >= TOOL_ARG_MAX_DEPTH) return Array.isArray(value) ? `[array:${value.length}]` : '[object]';
|
|
68
131
|
if (Array.isArray(value)) {
|
|
@@ -86,12 +149,15 @@ function stableToolArgJson(value) {
|
|
|
86
149
|
if (/^[\[{]/.test(text)) {
|
|
87
150
|
try { return JSON.stringify(normalizeToolArgValue(JSON.parse(text))); } catch { /* keep raw */ }
|
|
88
151
|
}
|
|
89
|
-
|
|
152
|
+
// Non-JSON raw string: redact secret key:value pairs before truncating
|
|
153
|
+
// so toolCallSummary / preserved facts / compaction prompt metadata
|
|
154
|
+
// never leak `authorization: Bearer ...`, passwords, cookies, tokens.
|
|
155
|
+
return truncateMiddle(redactRawSecretString(text), TOOL_ARG_STRING_MAX_CHARS);
|
|
90
156
|
}
|
|
91
157
|
try {
|
|
92
158
|
return JSON.stringify(normalizeToolArgValue(value));
|
|
93
159
|
} catch {
|
|
94
|
-
return truncateMiddle(String(value || ''), TOOL_ARG_STRING_MAX_CHARS);
|
|
160
|
+
return truncateMiddle(redactRawSecretString(String(value || '')), TOOL_ARG_STRING_MAX_CHARS);
|
|
95
161
|
}
|
|
96
162
|
}
|
|
97
163
|
|
|
@@ -127,15 +193,6 @@ function toolResultId(m) {
|
|
|
127
193
|
return m?.role === 'tool' && m.toolCallId ? ` tool_result=${m.toolCallId}` : '';
|
|
128
194
|
}
|
|
129
195
|
|
|
130
|
-
function lineForMessage(m, index, perMessageChars) {
|
|
131
|
-
const role = m?.role || 'unknown';
|
|
132
|
-
const text = truncateMiddle(extractText(m).trim(), perMessageChars);
|
|
133
|
-
const meta = `${toolCallSummary(m, toolCallArgBudget(perMessageChars))}${toolResultId(m)}`;
|
|
134
|
-
return text
|
|
135
|
-
? `${index + 1}. ${role}${meta}: ${text}`
|
|
136
|
-
: `${index + 1}. ${role}${meta}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
196
|
function compactHeader(oldHistory) {
|
|
140
197
|
const encoded = JSON.stringify(oldHistory ?? []);
|
|
141
198
|
return [
|
|
@@ -144,64 +201,168 @@ function compactHeader(oldHistory) {
|
|
|
144
201
|
];
|
|
145
202
|
}
|
|
146
203
|
|
|
147
|
-
function buildSummaryContent(oldHistory, perMessageChars, factsText = '') {
|
|
148
|
-
const lines = compactHeader(oldHistory);
|
|
149
|
-
if (factsText) {
|
|
150
|
-
lines.push('', factsText);
|
|
151
|
-
}
|
|
152
|
-
if (oldHistory.length > 0) {
|
|
153
|
-
lines.push('timeline:');
|
|
154
|
-
for (let i = 0; i < oldHistory.length; i += 1) {
|
|
155
|
-
lines.push(lineForMessage(oldHistory[i], i, perMessageChars));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return lines.join('\n');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
204
|
function makeSummaryMessage(content) {
|
|
162
205
|
return { role: 'user', content };
|
|
163
206
|
}
|
|
164
207
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
208
|
+
// A compact summary message is a synthetic role:'user' message carrying the
|
|
209
|
+
// SUMMARY_PREFIX anchor. It is NOT a real user turn: it must be excluded from
|
|
210
|
+
// real user-turn boundary calculations and treated as merge input, otherwise
|
|
211
|
+
// an old summary can sit in the preserved tail as a live user message,
|
|
212
|
+
// duplicate, or fail to merge across repeated compaction.
|
|
213
|
+
function isSummaryMessage(m) {
|
|
214
|
+
return m?.role === 'user'
|
|
215
|
+
&& typeof m.content === 'string'
|
|
216
|
+
&& m.content.startsWith(SUMMARY_PREFIX);
|
|
217
|
+
}
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
219
|
+
function isProtectedContextUserMessage(m) {
|
|
220
|
+
if (m?.role !== 'user' || typeof m.content !== 'string') return false;
|
|
221
|
+
return m.content.trimStart().startsWith('<system-reminder>');
|
|
222
|
+
}
|
|
177
223
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
224
|
+
function isProtectedContextAckMessage(m) {
|
|
225
|
+
return m?.role === 'assistant'
|
|
226
|
+
&& typeof m.content === 'string'
|
|
227
|
+
&& m.content.trim() === '.'
|
|
228
|
+
&& !Array.isArray(m.toolCalls);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function splitProtectedContext(messages) {
|
|
232
|
+
const protectedPrefix = [];
|
|
233
|
+
const conversation = [];
|
|
234
|
+
let prefixMode = true;
|
|
235
|
+
let previousWasProtectedContext = false;
|
|
236
|
+
for (const m of messages || []) {
|
|
237
|
+
if (m?.role === 'system') {
|
|
238
|
+
protectedPrefix.push(m);
|
|
239
|
+
previousWasProtectedContext = false;
|
|
240
|
+
continue;
|
|
190
241
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
242
|
+
if (prefixMode && isProtectedContextUserMessage(m)) {
|
|
243
|
+
protectedPrefix.push(m);
|
|
244
|
+
previousWasProtectedContext = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (prefixMode && previousWasProtectedContext && isProtectedContextAckMessage(m)) {
|
|
248
|
+
protectedPrefix.push(m);
|
|
249
|
+
previousWasProtectedContext = false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
prefixMode = false;
|
|
253
|
+
previousWasProtectedContext = false;
|
|
254
|
+
conversation.push(m);
|
|
196
255
|
}
|
|
197
|
-
return
|
|
256
|
+
return { protectedPrefix, conversation };
|
|
198
257
|
}
|
|
199
258
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
259
|
+
// Redaction-ONLY recursive walk for tool-call argument VALUES kept verbatim
|
|
260
|
+
// through compaction. Unlike normalizeToolArgValue (which is for prompt-side
|
|
261
|
+
// summaries and truncates/summarizes/key-sorts), this preserves shape exactly:
|
|
262
|
+
// - sensitive KEY -> '[redacted]'
|
|
263
|
+
// - string value -> redactRawSecretString(value) (no truncation/middle-cut)
|
|
264
|
+
// - array -> same length, items walked in order (no slicing/caps)
|
|
265
|
+
// - object -> same keys in INSERTION order (no sorting/depth caps)
|
|
266
|
+
// - other primitive (number/boolean/bigint/null) -> returned unchanged
|
|
267
|
+
// Returns { value, changed } so callers can preserve byte-exact input when the
|
|
268
|
+
// walk altered nothing. Only sensitive-key values and embedded raw secret pairs
|
|
269
|
+
// inside strings are altered; everything else is structure identical.
|
|
270
|
+
function redactToolArgValueOnly(value, key = '') {
|
|
271
|
+
if (SENSITIVE_TOOL_ARG_KEY_RE.test(String(key || ''))) {
|
|
272
|
+
return { value: '[redacted]', changed: value !== '[redacted]' };
|
|
273
|
+
}
|
|
274
|
+
if (value == null) return { value, changed: false };
|
|
275
|
+
if (typeof value === 'string') {
|
|
276
|
+
const redacted = redactRawSecretString(value);
|
|
277
|
+
return { value: redacted, changed: redacted !== value };
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
let changed = false;
|
|
281
|
+
const out = value.map((item) => {
|
|
282
|
+
const r = redactToolArgValueOnly(item, '');
|
|
283
|
+
if (r.changed) changed = true;
|
|
284
|
+
return r.value;
|
|
285
|
+
});
|
|
286
|
+
return { value: changed ? out : value, changed };
|
|
287
|
+
}
|
|
288
|
+
if (typeof value === 'object') {
|
|
289
|
+
let changed = false;
|
|
290
|
+
const out = {};
|
|
291
|
+
for (const k of Object.keys(value)) {
|
|
292
|
+
const r = redactToolArgValueOnly(value[k], k);
|
|
293
|
+
if (r.changed) changed = true;
|
|
294
|
+
out[k] = r.value;
|
|
295
|
+
}
|
|
296
|
+
return { value: changed ? out : value, changed };
|
|
297
|
+
}
|
|
298
|
+
return { value, changed: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Redact sensitive values inside a single tool-call arguments payload while
|
|
302
|
+
// preserving its original shape (string stays a string, object stays an
|
|
303
|
+
// object) so the provider-valid tool_call structure is not broken. Used to
|
|
304
|
+
// scrub messages that survive compaction VERBATIM (preserved tail / mandatory
|
|
305
|
+
// context), where prompt-side redaction does not apply. Redaction-only — no
|
|
306
|
+
// truncation, summarization, key sorting, or depth/array caps.
|
|
307
|
+
function redactToolCallArgumentsValue(rawArgs) {
|
|
308
|
+
if (rawArgs == null) return rawArgs;
|
|
309
|
+
if (typeof rawArgs === 'string') {
|
|
310
|
+
const trimmed = rawArgs.trim();
|
|
311
|
+
if (/^[\[{]/.test(trimmed)) {
|
|
312
|
+
try {
|
|
313
|
+
// Parse, redaction-only walk; only reserialize when the walk
|
|
314
|
+
// actually changed a sensitive value. When nothing changed,
|
|
315
|
+
// return the ORIGINAL string byte-exact (no JSON re-formatting).
|
|
316
|
+
const { value, changed } = redactToolArgValueOnly(JSON.parse(trimmed));
|
|
317
|
+
return changed ? JSON.stringify(value) : rawArgs;
|
|
318
|
+
} catch { /* fall through to raw redaction */ }
|
|
319
|
+
}
|
|
320
|
+
return redactRawSecretString(rawArgs);
|
|
321
|
+
}
|
|
322
|
+
if (typeof rawArgs === 'object') {
|
|
323
|
+
try {
|
|
324
|
+
const { value, changed } = redactToolArgValueOnly(rawArgs);
|
|
325
|
+
return changed ? value : rawArgs;
|
|
326
|
+
} catch { return rawArgs; }
|
|
327
|
+
}
|
|
328
|
+
return rawArgs;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Return a copy of a message with sensitive tool-call argument values redacted,
|
|
332
|
+
// keeping role / content / toolCallId / tool names+ids and non-sensitive args
|
|
333
|
+
// intact. Non-tool-bearing messages are returned unchanged (same reference).
|
|
334
|
+
function redactMessageToolCallSecrets(m) {
|
|
335
|
+
if (!m || typeof m !== 'object' || !Array.isArray(m.toolCalls) || m.toolCalls.length === 0) {
|
|
336
|
+
return m;
|
|
203
337
|
}
|
|
204
|
-
|
|
338
|
+
let changed = false;
|
|
339
|
+
const toolCalls = m.toolCalls.map((tc) => {
|
|
340
|
+
if (!tc || typeof tc !== 'object') return tc;
|
|
341
|
+
const out = { ...tc };
|
|
342
|
+
if ('arguments' in tc && tc.arguments != null) {
|
|
343
|
+
const redacted = redactToolCallArgumentsValue(tc.arguments);
|
|
344
|
+
if (redacted !== tc.arguments) { out.arguments = redacted; changed = true; }
|
|
345
|
+
}
|
|
346
|
+
if (tc.function && typeof tc.function === 'object' && tc.function.arguments != null) {
|
|
347
|
+
const redacted = redactToolCallArgumentsValue(tc.function.arguments);
|
|
348
|
+
if (redacted !== tc.function.arguments) {
|
|
349
|
+
out.function = { ...tc.function, arguments: redacted };
|
|
350
|
+
changed = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
});
|
|
355
|
+
if (!changed) return m;
|
|
356
|
+
return { ...m, toolCalls };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Scrub an array of messages that are kept verbatim through compaction so a
|
|
360
|
+
// recent assistant tool call carrying a secret (e.g. `authorization: Bearer
|
|
361
|
+
// ...`) cannot survive into the returned compacted transcript. Only assistant
|
|
362
|
+
// toolCalls argument VALUES are touched; structure/order/pairing is preserved.
|
|
363
|
+
export function redactToolCallSecretsInMessages(messages) {
|
|
364
|
+
if (!Array.isArray(messages)) return messages;
|
|
365
|
+
return messages.map((m) => redactMessageToolCallSecrets(m));
|
|
205
366
|
}
|
|
206
367
|
|
|
207
368
|
export function normalizeCompactionBufferRatio(value, fallback = DEFAULT_COMPACTION_BUFFER_RATIO) {
|
|
@@ -222,7 +383,7 @@ export function compactionBufferTokensForBoundary(boundaryTokens, opts = {}) {
|
|
|
222
383
|
}
|
|
223
384
|
|
|
224
385
|
function effectiveBudget(budgetTokens, opts) {
|
|
225
|
-
if (!(budgetTokens > 0)) throw new Error('
|
|
386
|
+
if (!(budgetTokens > 0)) throw new Error('compact: budgetTokens must be > 0');
|
|
226
387
|
const reserve = Number(opts?.reserveTokens) || 0;
|
|
227
388
|
if (reserve <= 0) return budgetTokens;
|
|
228
389
|
const effectiveReserve = Math.min(reserve, Math.floor(budgetTokens * 0.5));
|
|
@@ -239,6 +400,99 @@ const COMPACTION_INPUT_MAX_CHARS = 2_000;
|
|
|
239
400
|
const COMPACTION_PROMPT_HEADROOM = 0.85;
|
|
240
401
|
const PRESERVED_FACTS_MAX_CHARS = 600;
|
|
241
402
|
|
|
403
|
+
export const COMPACT_TYPE_SEMANTIC = 'semantic';
|
|
404
|
+
export const COMPACT_TYPE_RECALL_FASTTRACK = 'recall-fasttrack';
|
|
405
|
+
export const DEFAULT_COMPACT_TYPE = COMPACT_TYPE_SEMANTIC;
|
|
406
|
+
export const COMPACT_TYPES = Object.freeze([
|
|
407
|
+
COMPACT_TYPE_SEMANTIC,
|
|
408
|
+
COMPACT_TYPE_RECALL_FASTTRACK,
|
|
409
|
+
]);
|
|
410
|
+
|
|
411
|
+
export function normalizeCompactType(value, fallback = DEFAULT_COMPACT_TYPE) {
|
|
412
|
+
const raw = String(value ?? '').trim().toLowerCase().replace(/_/g, '-');
|
|
413
|
+
if (!raw) return fallback;
|
|
414
|
+
if (raw === '1' || raw === 'type1' || raw === 'type-1' || raw === 'bench1' || raw === 'bench-1' || raw === 'semantic' || raw === 'summary') {
|
|
415
|
+
return COMPACT_TYPE_SEMANTIC;
|
|
416
|
+
}
|
|
417
|
+
// Recall fast-track aliases. `replace(/_/g,'-')` above already folds
|
|
418
|
+
// snake_case (fast_track -> fast-track), but list both dash/no-dash forms
|
|
419
|
+
// explicitly so callers passing either spelling resolve deterministically.
|
|
420
|
+
if (raw === '2' || raw === 'type2' || raw === 'type-2' || raw === 'recall' || raw === 'recall-fast' || raw === 'recall-fasttrack' || raw === 'recall-fast-track' || raw === 'fasttrack' || raw === 'fast-track') {
|
|
421
|
+
return COMPACT_TYPE_RECALL_FASTTRACK;
|
|
422
|
+
}
|
|
423
|
+
// Unknown / unrecognized value: fall back to the caller-provided default
|
|
424
|
+
// (semantic by default). Callers that need to detect an unknown value
|
|
425
|
+
// should compare the input against COMPACT_TYPES before normalizing.
|
|
426
|
+
return fallback;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function compactTypeIsSemantic(value) {
|
|
430
|
+
return normalizeCompactType(value) === COMPACT_TYPE_SEMANTIC;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function compactTypeIsRecallFastTrack(value) {
|
|
434
|
+
return normalizeCompactType(value) === COMPACT_TYPE_RECALL_FASTTRACK;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Count raw (unchunked) pending rows still present in a dump_session_roots
|
|
438
|
+
// payload. recall-fasttrack must keep cycle1-chunking until this reaches 0 so
|
|
439
|
+
// the injected root is the chunked summary, not the raw transcript tail.
|
|
440
|
+
export function countRawPendingRows(dumpText) {
|
|
441
|
+
const text = String(dumpText || '');
|
|
442
|
+
const matches = text.match(/(?:^|\n)# raw_pending\s+\d+\s+id=/gi);
|
|
443
|
+
return matches ? matches.length : 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Drain a single session's cycle1 in fixed window×concurrency units, looping
|
|
447
|
+
// until no raw rows remain (or a pass stops making progress / the deadline
|
|
448
|
+
// elapses). This replaces the previous single-pass cycle1 so large sessions
|
|
449
|
+
// get fully chunked before their root is injected into the compacted context.
|
|
450
|
+
export async function drainSessionCycle1(runTool, { sessionId, cycleArgs = {}, dumpArgs, maxPasses = 0, deadlineMs = 0 } = {}) {
|
|
451
|
+
if (typeof runTool !== 'function') throw new Error('drainSessionCycle1: runTool is required');
|
|
452
|
+
if (!sessionId) throw new Error('drainSessionCycle1: sessionId is required');
|
|
453
|
+
if (!dumpArgs) throw new Error('drainSessionCycle1: dumpArgs is required');
|
|
454
|
+
const startedAt = Date.now();
|
|
455
|
+
const hardPasses = Math.max(1, Number(maxPasses) || 50);
|
|
456
|
+
const lines = [];
|
|
457
|
+
let recallText = await runTool('memory', dumpArgs);
|
|
458
|
+
let rawRemaining = countRawPendingRows(recallText);
|
|
459
|
+
let pass = 0;
|
|
460
|
+
while (rawRemaining > 0 && pass < hardPasses) {
|
|
461
|
+
if (deadlineMs > 0 && (Date.now() - startedAt) >= deadlineMs) break;
|
|
462
|
+
pass += 1;
|
|
463
|
+
const passDeadline = deadlineMs > 0
|
|
464
|
+
? Math.max(1, deadlineMs - (Date.now() - startedAt))
|
|
465
|
+
: 0;
|
|
466
|
+
let passText = '';
|
|
467
|
+
try {
|
|
468
|
+
passText = await runTool('memory', {
|
|
469
|
+
action: 'cycle1',
|
|
470
|
+
sessionId,
|
|
471
|
+
...cycleArgs,
|
|
472
|
+
...(passDeadline > 0 ? { _callerDeadlineMs: passDeadline } : {}),
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
lines.push(`cycle1 pass=${pass} error=${err?.message || err}`);
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
if (passText) lines.push(`cycle1 pass=${pass}: ${String(passText).trim()}`);
|
|
479
|
+
recallText = await runTool('memory', dumpArgs);
|
|
480
|
+
const nextRaw = countRawPendingRows(recallText);
|
|
481
|
+
// No forward progress (raw not shrinking) — stop instead of spinning.
|
|
482
|
+
if (nextRaw >= rawRemaining) {
|
|
483
|
+
rawRemaining = nextRaw;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
rawRemaining = nextRaw;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
recallText,
|
|
490
|
+
cycle1Text: lines.join('\n'),
|
|
491
|
+
passes: pass,
|
|
492
|
+
rawRemaining,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
242
496
|
function preservedFactHints() {
|
|
243
497
|
return String(process.env.MIXDOG_COMPACT_FACT_HINTS || '')
|
|
244
498
|
.split(/[\n,;]+/u)
|
|
@@ -449,43 +703,113 @@ function userIndexes(messages) {
|
|
|
449
703
|
return out;
|
|
450
704
|
}
|
|
451
705
|
|
|
452
|
-
function
|
|
706
|
+
function indexLiveTurns(live) {
|
|
707
|
+
const turns = splitTailIntoTurns(live);
|
|
708
|
+
const indexed = [];
|
|
709
|
+
let scan = 0;
|
|
710
|
+
for (const messages of turns) {
|
|
711
|
+
while (scan < live.length && live[scan] !== messages[0]) scan += 1;
|
|
712
|
+
const start = scan;
|
|
713
|
+
const end = start + messages.length;
|
|
714
|
+
indexed.push({ start, end, messages });
|
|
715
|
+
scan = end;
|
|
716
|
+
}
|
|
717
|
+
return indexed;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function splitTurnStartIndexForBudget(turn, budget) {
|
|
721
|
+
const { start, end, messages } = turn;
|
|
722
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
723
|
+
const suffixStart = start + i;
|
|
724
|
+
const suffix = messages.slice(i);
|
|
725
|
+
if (suffix.length > 0 && estimateMessagesTokens(suffix) <= budget) {
|
|
726
|
+
return suffixStart;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return end;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function splitLiveCompactionContext(messages) {
|
|
453
733
|
const sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
734
|
+
const { protectedPrefix, conversation: nonSystem } = splitProtectedContext(sanitized);
|
|
735
|
+
let previousSummary = null;
|
|
736
|
+
for (let i = nonSystem.length - 1; i >= 0; i -= 1) {
|
|
737
|
+
if (isSummaryMessage(nonSystem[i])) {
|
|
738
|
+
previousSummary = nonSystem[i].content;
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const live = nonSystem.filter((m) => !isSummaryMessage(m));
|
|
743
|
+
return { system: protectedPrefix, live, previousSummary, sanitized };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function selectCompactionWindow(messages, budget, opts = {}) {
|
|
747
|
+
const { system, live, previousSummary } = splitLiveCompactionContext(messages);
|
|
748
|
+
if (!userIndexes(live).length) throw new Error('semanticCompactMessages: no user turn to preserve');
|
|
458
749
|
|
|
459
750
|
const tailTurns = Math.max(1, Number(opts.tailTurns) || DEFAULT_TAIL_TURNS);
|
|
460
751
|
const recentBudget = preserveRecentBudget(budget, opts);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
752
|
+
const indexedTurns = indexLiveTurns(live);
|
|
753
|
+
if (indexedTurns.length === 0) throw new Error('semanticCompactMessages: no user turn to preserve');
|
|
754
|
+
|
|
755
|
+
let tailStartIdx = live.length;
|
|
756
|
+
let keptTurns = 0;
|
|
757
|
+
|
|
758
|
+
for (let t = indexedTurns.length - 1; t >= 0; t -= 1) {
|
|
759
|
+
if (keptTurns >= tailTurns) break;
|
|
760
|
+
const turn = indexedTurns[t];
|
|
761
|
+
const tailFromTurn = live.slice(turn.start);
|
|
762
|
+
if (keptTurns === 0) {
|
|
763
|
+
if (estimateMessagesTokens(tailFromTurn) <= recentBudget) {
|
|
764
|
+
tailStartIdx = turn.start;
|
|
765
|
+
keptTurns += 1;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const splitIdx = splitTurnStartIndexForBudget(turn, recentBudget);
|
|
769
|
+
if (splitIdx < turn.end) {
|
|
770
|
+
tailStartIdx = splitIdx;
|
|
771
|
+
keptTurns += 1;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
// Newest turn has no fitting suffix: keep entire live transcript in head for summarization.
|
|
775
|
+
tailStartIdx = live.length;
|
|
776
|
+
keptTurns = 0;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const candidateStart = turn.start;
|
|
780
|
+
const candidateTail = live.slice(candidateStart);
|
|
781
|
+
if (estimateMessagesTokens(candidateTail) <= recentBudget) {
|
|
782
|
+
tailStartIdx = candidateStart;
|
|
783
|
+
keptTurns += 1;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
break;
|
|
468
787
|
}
|
|
469
788
|
|
|
470
|
-
const head =
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
789
|
+
const head = live.slice(0, tailStartIdx);
|
|
790
|
+
let tail = live.slice(tailStartIdx);
|
|
791
|
+
tail = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(tail)));
|
|
792
|
+
|
|
793
|
+
if (!head.length && !tail.length) {
|
|
794
|
+
throw new Error('semanticCompactMessages: no user turn to preserve');
|
|
795
|
+
}
|
|
796
|
+
if (tail.length && !tail.some((m) => m?.role === 'user')) {
|
|
797
|
+
const lastUserIdx = live.findLastIndex
|
|
798
|
+
? live.findLastIndex((m) => m?.role === 'user')
|
|
799
|
+
: (() => { for (let i = live.length - 1; i >= 0; i -= 1) { if (live[i]?.role === 'user') return i; } return -1; })();
|
|
800
|
+
if (lastUserIdx < 0 || lastUserIdx >= tailStartIdx) {
|
|
801
|
+
throw new Error('semanticCompactMessages: no user turn to preserve');
|
|
480
802
|
}
|
|
481
803
|
}
|
|
482
|
-
|
|
804
|
+
|
|
805
|
+
const preservedFacts = extractPreservedFacts(head);
|
|
806
|
+
const originalHead = head;
|
|
483
807
|
return {
|
|
484
808
|
system,
|
|
485
|
-
head
|
|
809
|
+
head,
|
|
486
810
|
tail,
|
|
487
811
|
previousSummary,
|
|
488
|
-
originalHead
|
|
812
|
+
originalHead,
|
|
489
813
|
preservedFacts,
|
|
490
814
|
};
|
|
491
815
|
}
|
|
@@ -523,29 +847,114 @@ function buildCompactionPrompt({ head, previousSummary, preservedFacts }, perMes
|
|
|
523
847
|
return lines.join('\n');
|
|
524
848
|
}
|
|
525
849
|
|
|
850
|
+
function estimateCompactionPromptTokens(input, perMessageChars) {
|
|
851
|
+
const prompt = buildCompactionPrompt(input, perMessageChars);
|
|
852
|
+
return estimateMessagesTokens([
|
|
853
|
+
{ role: 'system', content: COMPACTION_SYSTEM_PROMPT },
|
|
854
|
+
{ role: 'user', content: prompt },
|
|
855
|
+
]);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function previousSummaryBodyForCompactionPrompt(previousSummary) {
|
|
859
|
+
const text = String(previousSummary || '').trim();
|
|
860
|
+
if (!text) return '';
|
|
861
|
+
return stripNestedSummaryHeaderLines(text);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function priorSummaryNeedsNormalization(text) {
|
|
865
|
+
const body = String(text || '').trim();
|
|
866
|
+
if (!body) return false;
|
|
867
|
+
if (!/^##\s+/m.test(body)) return true;
|
|
868
|
+
if (!summaryIsSchemaValid(body)) return true;
|
|
869
|
+
return summaryHasUnrecognizedHeadings(body);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function normalizePriorSummaryForCompactionPrompt(fullBody) {
|
|
873
|
+
const text = String(fullBody || '').trim();
|
|
874
|
+
if (!text) return '';
|
|
875
|
+
if (!priorSummaryNeedsNormalization(text)) return text;
|
|
876
|
+
return repairSemanticSummary(text, { head: [], tail: [] });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Shrink or drop a prior anchored summary so the compaction provider prompt fits
|
|
880
|
+
// the call budget. Unstructured/legacy priors are repaired first; section
|
|
881
|
+
// anchors are preserved via truncateSummaryBySections;
|
|
882
|
+
// the last resort is omitting <previous-summary> entirely.
|
|
883
|
+
function fitPreviousSummaryForCompactionPrompt(input, perMessageChars, targetTokens) {
|
|
884
|
+
if (!input?.previousSummary) return input;
|
|
885
|
+
const fullBody = normalizePriorSummaryForCompactionPrompt(
|
|
886
|
+
previousSummaryBodyForCompactionPrompt(input.previousSummary),
|
|
887
|
+
);
|
|
888
|
+
const withSummary = (summaryText) => {
|
|
889
|
+
const trimmed = String(summaryText || '').trim();
|
|
890
|
+
if (!trimmed) return { ...input, previousSummary: null };
|
|
891
|
+
return { ...input, previousSummary: trimmed };
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
if (estimateCompactionPromptTokens(withSummary(fullBody), perMessageChars) <= targetTokens) {
|
|
895
|
+
return withSummary(fullBody);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (fullBody) {
|
|
899
|
+
let lo = 0;
|
|
900
|
+
let hi = fullBody.length;
|
|
901
|
+
let bestChars = -1;
|
|
902
|
+
while (lo <= hi) {
|
|
903
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
904
|
+
const truncated = truncateSummaryBySections(fullBody, mid);
|
|
905
|
+
const candidate = withSummary(truncated);
|
|
906
|
+
if (estimateCompactionPromptTokens(candidate, perMessageChars) <= targetTokens) {
|
|
907
|
+
bestChars = mid;
|
|
908
|
+
lo = mid + 1;
|
|
909
|
+
} else {
|
|
910
|
+
hi = mid - 1;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (bestChars >= 0) {
|
|
914
|
+
return withSummary(truncateSummaryBySections(fullBody, bestChars));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const minimalPrior = minimalSchemaSummary();
|
|
919
|
+
if (estimateCompactionPromptTokens(withSummary(minimalPrior), perMessageChars) <= targetTokens) {
|
|
920
|
+
return withSummary(minimalPrior);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const withoutPrior = withSummary(null);
|
|
924
|
+
if (estimateCompactionPromptTokens(withoutPrior, perMessageChars) <= targetTokens) {
|
|
925
|
+
return withoutPrior;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
|
|
526
931
|
function fitCompactionPrompt(input, targetTokens) {
|
|
527
932
|
const tryFit = (withFacts) => {
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
933
|
+
const baseInp = withFacts ? input : { ...input, preservedFacts: null };
|
|
934
|
+
|
|
935
|
+
const fitAt = (perMessageChars) => {
|
|
936
|
+
let inp = baseInp;
|
|
937
|
+
if (estimateCompactionPromptTokens(inp, perMessageChars) > targetTokens) {
|
|
938
|
+
const fitted = fitPreviousSummaryForCompactionPrompt(inp, perMessageChars, targetTokens);
|
|
939
|
+
if (!fitted) return null;
|
|
940
|
+
inp = fitted;
|
|
941
|
+
if (estimateCompactionPromptTokens(inp, perMessageChars) > targetTokens) return null;
|
|
942
|
+
}
|
|
943
|
+
return buildCompactionPrompt(inp, perMessageChars);
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const minimalPrompt = fitAt(0);
|
|
947
|
+
if (!minimalPrompt) return null;
|
|
535
948
|
|
|
536
949
|
let maxText = 0;
|
|
537
|
-
for (const m of
|
|
950
|
+
for (const m of baseInp.head) maxText = Math.max(maxText, extractText(m).length);
|
|
538
951
|
let lo = 0;
|
|
539
952
|
let hi = Math.min(COMPACTION_INPUT_MAX_CHARS, Math.max(maxText, 0));
|
|
540
|
-
let best =
|
|
953
|
+
let best = minimalPrompt;
|
|
541
954
|
while (lo <= hi) {
|
|
542
955
|
const mid = Math.floor((lo + hi) / 2);
|
|
543
|
-
const candidate =
|
|
544
|
-
|
|
545
|
-
{ role: 'system', content: COMPACTION_SYSTEM_PROMPT },
|
|
546
|
-
{ role: 'user', content: candidate },
|
|
547
|
-
];
|
|
548
|
-
if (estimateMessagesTokens(candidateMessages) <= targetTokens) {
|
|
956
|
+
const candidate = fitAt(mid);
|
|
957
|
+
if (candidate) {
|
|
549
958
|
best = candidate;
|
|
550
959
|
lo = mid + 1;
|
|
551
960
|
} else {
|
|
@@ -582,8 +991,258 @@ function extractResponseText(response) {
|
|
|
582
991
|
return '';
|
|
583
992
|
}
|
|
584
993
|
|
|
994
|
+
// Canonical section anchors the semantic summary template (SUMMARY_TEMPLATE)
|
|
995
|
+
// must contain. Used for lightweight schema validation of provider output.
|
|
996
|
+
const REQUIRED_SUMMARY_SECTIONS = Object.freeze([
|
|
997
|
+
'## Goal',
|
|
998
|
+
'## Constraints',
|
|
999
|
+
'## Progress',
|
|
1000
|
+
'## Key Decisions',
|
|
1001
|
+
'## Next Steps',
|
|
1002
|
+
'## Critical Context',
|
|
1003
|
+
'## Relevant Files',
|
|
1004
|
+
]);
|
|
1005
|
+
|
|
1006
|
+
// Collect actual top-level (`## `, not `### `) heading lines from a summary.
|
|
1007
|
+
// Validation is heading-anchor based (not substring includes) so prose or code
|
|
1008
|
+
// that merely mentions "## Relevant Files" inside a bullet body cannot satisfy
|
|
1009
|
+
// a section anchor.
|
|
1010
|
+
function summaryHeadingLines(summary) {
|
|
1011
|
+
const out = [];
|
|
1012
|
+
for (const rawLine of String(summary || '').split('\n')) {
|
|
1013
|
+
const line = rawLine.trim();
|
|
1014
|
+
if (/^##\s+\S/.test(line) && !/^###\s+/.test(line)) out.push(line);
|
|
1015
|
+
}
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// An anchor matches a heading when the heading title equals the anchor title or
|
|
1020
|
+
// extends it at a word/punctuation boundary. This lets `## Constraints &
|
|
1021
|
+
// Preferences` satisfy the `## Constraints` anchor while NOT letting an
|
|
1022
|
+
// unrelated `## Goalkeeper` heading satisfy `## Goal`. Requires a real `## `
|
|
1023
|
+
// heading line (not a substring buried in prose).
|
|
1024
|
+
function headingMatchesAnchor(heading, anchor) {
|
|
1025
|
+
const anchorTitle = anchor.replace(/^##\s+/, '').trim().toLowerCase();
|
|
1026
|
+
const headingTitle = heading.replace(/^##\s+/, '').trim().toLowerCase();
|
|
1027
|
+
if (headingTitle === anchorTitle) return true;
|
|
1028
|
+
if (!headingTitle.startsWith(anchorTitle)) return false;
|
|
1029
|
+
// Next char after the anchor title must be a boundary (space or &/punct),
|
|
1030
|
+
// not a continuation letter/digit.
|
|
1031
|
+
const nextChar = headingTitle.charAt(anchorTitle.length);
|
|
1032
|
+
return /[\s&:(-]/.test(nextChar);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function summarySchemaScore(summary) {
|
|
1036
|
+
const headings = summaryHeadingLines(summary);
|
|
1037
|
+
let hits = 0;
|
|
1038
|
+
for (const anchor of REQUIRED_SUMMARY_SECTIONS) {
|
|
1039
|
+
if (headings.some((h) => headingMatchesAnchor(h, anchor))) hits += 1;
|
|
1040
|
+
}
|
|
1041
|
+
return hits;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// A summary is schema-valid only when EVERY required section anchor is present
|
|
1045
|
+
// as a real heading. A partial summary (e.g. missing Critical Context /
|
|
1046
|
+
// Relevant Files) must be repaired rather than injected unchanged.
|
|
1047
|
+
function summaryIsSchemaValid(summary) {
|
|
1048
|
+
if (summarySchemaScore(summary) !== REQUIRED_SUMMARY_SECTIONS.length) return false;
|
|
1049
|
+
return !summaryHasUnrecognizedHeadings(summary);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function deriveRelevantFilesBullets(head) {
|
|
1053
|
+
const seen = new Set();
|
|
1054
|
+
const out = [];
|
|
1055
|
+
const fileRe = /(?:[A-Za-z]:[\\/]|\.{1,2}[\\/]|[\w$.-]+[\\/])?[\w$.-]+\.(?:mjs|cjs|js|jsx|ts|tsx|json|md|rs|go|py|java|kt|cs|cpp|c|h|hpp|css|html|yml|yaml|toml|lock|sh|ps1)\b/gi;
|
|
1056
|
+
for (const m of Array.isArray(head) ? head : []) {
|
|
1057
|
+
const text = extractText(m);
|
|
1058
|
+
if (!text) continue;
|
|
1059
|
+
let match;
|
|
1060
|
+
while ((match = fileRe.exec(text)) && out.length < 8) {
|
|
1061
|
+
const file = match[0];
|
|
1062
|
+
const key = file.toLowerCase();
|
|
1063
|
+
if (seen.has(key)) continue;
|
|
1064
|
+
seen.add(key);
|
|
1065
|
+
out.push(`- ${file}`);
|
|
1066
|
+
}
|
|
1067
|
+
if (out.length >= 8) break;
|
|
1068
|
+
}
|
|
1069
|
+
return out;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function deriveCurrentRequest(messages) {
|
|
1073
|
+
for (let i = (Array.isArray(messages) ? messages.length : 0) - 1; i >= 0; i -= 1) {
|
|
1074
|
+
const m = messages[i];
|
|
1075
|
+
if (m?.role === 'user' && !isProtectedContextUserMessage(m)) {
|
|
1076
|
+
const text = truncateMiddle(extractText(m).trim(), 400);
|
|
1077
|
+
if (text) return text;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return '';
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Canonical ordered section headings the structured summary scaffold emits.
|
|
1084
|
+
// Each `## ` heading maps to one REQUIRED_SUMMARY_SECTIONS anchor; Progress
|
|
1085
|
+
// additionally carries its three `### ` sub-headings.
|
|
1086
|
+
const SUMMARY_SECTION_LAYOUT = Object.freeze([
|
|
1087
|
+
{ heading: '## Goal', anchor: '## Goal' },
|
|
1088
|
+
{ heading: '## Constraints & Preferences', anchor: '## Constraints' },
|
|
1089
|
+
{ heading: '## Progress', anchor: '## Progress', sub: ['### Done', '### In Progress', '### Blocked'] },
|
|
1090
|
+
{ heading: '## Key Decisions', anchor: '## Key Decisions' },
|
|
1091
|
+
{ heading: '## Next Steps', anchor: '## Next Steps' },
|
|
1092
|
+
{ heading: '## Critical Context', anchor: '## Critical Context' },
|
|
1093
|
+
{ heading: '## Relevant Files', anchor: '## Relevant Files' },
|
|
1094
|
+
]);
|
|
1095
|
+
|
|
1096
|
+
// Split a markdown summary into a map of top-level `## ` heading -> body lines.
|
|
1097
|
+
function parseSummarySections(text) {
|
|
1098
|
+
const map = new Map();
|
|
1099
|
+
let current = null;
|
|
1100
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
1101
|
+
const trimmed = rawLine.trim();
|
|
1102
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
1103
|
+
if (/^##\s+/.test(trimmed) && !/^###\s+/.test(trimmed)) {
|
|
1104
|
+
current = trimmed;
|
|
1105
|
+
if (!map.has(current)) map.set(current, []);
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (current) map.get(current).push(line);
|
|
1109
|
+
}
|
|
1110
|
+
return map;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function summarySectionIsRecognized(heading) {
|
|
1114
|
+
for (const section of SUMMARY_SECTION_LAYOUT) {
|
|
1115
|
+
if (headingMatchesAnchor(heading, section.anchor)) return true;
|
|
1116
|
+
}
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function summaryHasUnrecognizedHeadings(summary) {
|
|
1121
|
+
for (const heading of parseSummarySections(summary).keys()) {
|
|
1122
|
+
if (!summarySectionIsRecognized(heading)) return true;
|
|
1123
|
+
}
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function summaryLinesToBullets(text) {
|
|
1128
|
+
return String(text || '')
|
|
1129
|
+
.split('\n')
|
|
1130
|
+
.map((l) => l.trim())
|
|
1131
|
+
.filter(Boolean)
|
|
1132
|
+
.map((l) => (l.startsWith('-') ? l : `- ${l}`));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function unrecognizedSummarySectionText(present) {
|
|
1136
|
+
const chunks = [];
|
|
1137
|
+
for (const [heading, body] of present) {
|
|
1138
|
+
if (summarySectionIsRecognized(heading)) continue;
|
|
1139
|
+
const lines = [heading];
|
|
1140
|
+
for (const line of body || []) {
|
|
1141
|
+
const trimmed = String(line).trim();
|
|
1142
|
+
if (trimmed) lines.push(line);
|
|
1143
|
+
}
|
|
1144
|
+
chunks.push(lines.join('\n'));
|
|
1145
|
+
}
|
|
1146
|
+
return chunks.join('\n\n').trim();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Deterministic schema repair for a non-empty but malformed/partial semantic
|
|
1150
|
+
// summary. Preserve every section the provider DID supply (matched by anchor),
|
|
1151
|
+
// and scaffold the missing required sections so downstream consumers always
|
|
1152
|
+
// receive the full structured anchored shape. Content that lives outside any
|
|
1153
|
+
// recognized section is routed into Critical Context so nothing is dropped.
|
|
1154
|
+
// Lightly backfill Goal / Relevant Files from the transcript when empty.
|
|
1155
|
+
function repairSemanticSummary(summary, { head = [], tail = [] } = {}) {
|
|
1156
|
+
const raw = String(summary || '').trim();
|
|
1157
|
+
const present = parseSummarySections(raw);
|
|
1158
|
+
// Capture any leading content before the first recognized `## ` heading so
|
|
1159
|
+
// an entirely unstructured blob is preserved rather than silently dropped.
|
|
1160
|
+
let preamble = '';
|
|
1161
|
+
if (raw) {
|
|
1162
|
+
const firstHeading = raw.search(/(^|\n)\s*##\s+/);
|
|
1163
|
+
preamble = firstHeading === -1 ? raw : raw.slice(0, firstHeading);
|
|
1164
|
+
preamble = preamble.trim();
|
|
1165
|
+
}
|
|
1166
|
+
const orphanText = unrecognizedSummarySectionText(present);
|
|
1167
|
+
const extraContextParts = [];
|
|
1168
|
+
if (preamble) extraContextParts.push(preamble);
|
|
1169
|
+
if (orphanText) extraContextParts.push(orphanText);
|
|
1170
|
+
const extraContext = extraContextParts.join('\n\n').trim();
|
|
1171
|
+
const bulletize = (lines) => {
|
|
1172
|
+
const cleaned = (Array.isArray(lines) ? lines : [])
|
|
1173
|
+
.map((l) => String(l).trim())
|
|
1174
|
+
.filter(Boolean);
|
|
1175
|
+
return cleaned.length ? cleaned : null;
|
|
1176
|
+
};
|
|
1177
|
+
const findPresent = (anchor) => {
|
|
1178
|
+
for (const [heading, body] of present) {
|
|
1179
|
+
if (headingMatchesAnchor(heading, anchor)) return body;
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
};
|
|
1183
|
+
const goal = deriveCurrentRequest(tail) || deriveCurrentRequest(head);
|
|
1184
|
+
const files = deriveRelevantFilesBullets(head);
|
|
1185
|
+
const out = [];
|
|
1186
|
+
for (const section of SUMMARY_SECTION_LAYOUT) {
|
|
1187
|
+
if (out.length) out.push('');
|
|
1188
|
+
out.push(section.heading);
|
|
1189
|
+
const body = bulletize(findPresent(section.anchor));
|
|
1190
|
+
if (section.sub) {
|
|
1191
|
+
// Progress: keep provider sub-bodies when present, else scaffold.
|
|
1192
|
+
if (body) {
|
|
1193
|
+
out.push(...body);
|
|
1194
|
+
} else {
|
|
1195
|
+
for (const sub of section.sub) {
|
|
1196
|
+
out.push(sub, '- (none)');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (section.anchor === '## Critical Context') {
|
|
1202
|
+
const ccLines = [];
|
|
1203
|
+
if (body) ccLines.push(...body);
|
|
1204
|
+
for (const line of summaryLinesToBullets(extraContext)) {
|
|
1205
|
+
if (!ccLines.some((existing) => existing.trim() === line.trim())) ccLines.push(line);
|
|
1206
|
+
}
|
|
1207
|
+
if (ccLines.some((line) => line.trim() !== '- (none)')) {
|
|
1208
|
+
const withoutPlaceholder = ccLines.filter((line) => line.trim() !== '- (none)');
|
|
1209
|
+
out.push(...(withoutPlaceholder.length ? withoutPlaceholder : ccLines));
|
|
1210
|
+
} else {
|
|
1211
|
+
out.push(...(ccLines.length ? ccLines : ['- (none)']));
|
|
1212
|
+
}
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (body) {
|
|
1216
|
+
out.push(...body);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (section.anchor === '## Goal') {
|
|
1220
|
+
out.push(goal ? `- ${goal}` : '- (none)');
|
|
1221
|
+
} else if (section.anchor === '## Relevant Files') {
|
|
1222
|
+
out.push(...(files.length ? files : ['- (none)']));
|
|
1223
|
+
} else {
|
|
1224
|
+
out.push('- (none)');
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return out.join('\n');
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Validate the provider summary against the required template sections; when it
|
|
1231
|
+
// is missing ANY required section anchor (fully or partially malformed) repair
|
|
1232
|
+
// it deterministically so a non-empty-but-broken response is never injected as
|
|
1233
|
+
// the sole summary. Returns { summary, repaired }.
|
|
1234
|
+
function enforceSemanticSummarySchema(summary, ctx = {}) {
|
|
1235
|
+
const text = String(summary || '').trim();
|
|
1236
|
+
if (!text) return { summary: text, repaired: false };
|
|
1237
|
+
if (summaryIsSchemaValid(text)) {
|
|
1238
|
+
return { summary: text, repaired: false };
|
|
1239
|
+
}
|
|
1240
|
+
return { summary: repairSemanticSummary(text, ctx), repaired: true };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
585
1243
|
function makeSemanticSummaryMessage(oldHistory, summary, semanticMeta = {}, preservedFacts = '') {
|
|
586
1244
|
const header = compactHeader(oldHistory);
|
|
1245
|
+
header.push(`compact_type=${COMPACT_TYPE_SEMANTIC}`);
|
|
587
1246
|
header.push(`semantic=true provider=${semanticMeta.provider || 'unknown'} model=${semanticMeta.model || 'unknown'}`);
|
|
588
1247
|
const facts = String(preservedFacts || '').trim();
|
|
589
1248
|
const body = String(summary || '').trim();
|
|
@@ -593,19 +1252,103 @@ function makeSemanticSummaryMessage(oldHistory, summary, semanticMeta = {}, pres
|
|
|
593
1252
|
return makeSummaryMessage(parts.join('\n\n'));
|
|
594
1253
|
}
|
|
595
1254
|
|
|
1255
|
+
export function buildRecallFastTrackQuery(messages, opts = {}) {
|
|
1256
|
+
const maxChars = Math.max(200, Number(opts.maxChars) || 2_000);
|
|
1257
|
+
const hints = String(opts.hints || 'current task decisions constraints file paths changed files verification failures next steps').trim();
|
|
1258
|
+
let latestUser = '';
|
|
1259
|
+
const recent = [];
|
|
1260
|
+
const input = Array.isArray(messages) ? messages : [];
|
|
1261
|
+
for (let i = input.length - 1; i >= 0; i -= 1) {
|
|
1262
|
+
const m = input[i];
|
|
1263
|
+
const text = extractText(m).trim();
|
|
1264
|
+
if (!text) continue;
|
|
1265
|
+
if (recent.length < 6) recent.unshift(text);
|
|
1266
|
+
if (!latestUser && m?.role === 'user' && !isProtectedContextUserMessage(m)) {
|
|
1267
|
+
latestUser = text;
|
|
1268
|
+
}
|
|
1269
|
+
if (latestUser && recent.length >= 6) break;
|
|
1270
|
+
}
|
|
1271
|
+
const parts = [latestUser, hints, recent.join('\n')]
|
|
1272
|
+
.map((s) => String(s || '').trim())
|
|
1273
|
+
.filter(Boolean);
|
|
1274
|
+
return truncateMiddle([...new Set(parts)].join('\n'), maxChars);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// A headings-only structured summary: every required `## ` (and Progress `### `)
|
|
1278
|
+
// anchor present with `- (none)` bodies. This is the minimal schema-valid shape
|
|
1279
|
+
// the fitter can fall back to when token pressure cannot hold real section
|
|
1280
|
+
// bodies — it still passes summaryIsSchemaValid so the injected message is
|
|
1281
|
+
// never partial.
|
|
1282
|
+
function minimalSchemaSummary() {
|
|
1283
|
+
const out = [];
|
|
1284
|
+
for (const section of SUMMARY_SECTION_LAYOUT) {
|
|
1285
|
+
if (out.length) out.push('');
|
|
1286
|
+
out.push(section.heading);
|
|
1287
|
+
if (section.sub) {
|
|
1288
|
+
for (const sub of section.sub) out.push(sub, '- (none)');
|
|
1289
|
+
} else {
|
|
1290
|
+
out.push('- (none)');
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return out.join('\n');
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Section-aware truncation: keep EVERY `## ` heading and Progress `### `
|
|
1297
|
+
// sub-heading intact, trimming only section bodies to `perSectionChars`. Unlike
|
|
1298
|
+
// a raw text.slice(0, n) this never drops a trailing required section, so the
|
|
1299
|
+
// result stays schema-valid (all anchors present) at any budget.
|
|
1300
|
+
function truncateSummaryBySections(summary, perSectionChars) {
|
|
1301
|
+
const sections = parseSummarySections(summary);
|
|
1302
|
+
const out = [];
|
|
1303
|
+
for (const section of SUMMARY_SECTION_LAYOUT) {
|
|
1304
|
+
if (out.length) out.push('');
|
|
1305
|
+
out.push(section.heading);
|
|
1306
|
+
let body = null;
|
|
1307
|
+
for (const [heading, lines] of sections) {
|
|
1308
|
+
if (headingMatchesAnchor(heading, section.anchor)) { body = lines; break; }
|
|
1309
|
+
}
|
|
1310
|
+
const bodyText = (Array.isArray(body) ? body : [])
|
|
1311
|
+
.map((l) => String(l).trim())
|
|
1312
|
+
.filter(Boolean)
|
|
1313
|
+
.join('\n');
|
|
1314
|
+
if (!bodyText) {
|
|
1315
|
+
if (section.sub) for (const sub of section.sub) out.push(sub, '- (none)');
|
|
1316
|
+
else out.push('- (none)');
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
const trimmed = perSectionChars > 0 ? truncateMiddle(bodyText, perSectionChars) : '';
|
|
1320
|
+
if (trimmed) out.push(trimmed);
|
|
1321
|
+
else if (section.sub) for (const sub of section.sub) out.push(sub, '- (none)');
|
|
1322
|
+
else out.push('- (none)');
|
|
1323
|
+
}
|
|
1324
|
+
return out.join('\n');
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Fit the structured semantic summary into the remaining token budget WITHOUT
|
|
1328
|
+
// dropping any required section. The incoming `summary` is already schema-valid
|
|
1329
|
+
// (enforceSemanticSummarySchema ran upstream); here we shrink section bodies via
|
|
1330
|
+
// section-aware truncation, fall back to a headings-only schema-valid summary,
|
|
1331
|
+
// and finally revalidate so the injected SUMMARY_PREFIX message always carries
|
|
1332
|
+
// every required anchor. Returns null only when even the minimal schema-valid
|
|
1333
|
+
// summary cannot fit (caller throws).
|
|
596
1334
|
function fitSemanticSummaryMessage(oldHistory, summary, remainingTokens, semanticMeta, preservedFacts = '') {
|
|
597
1335
|
const tryFit = (factsText) => {
|
|
598
|
-
const minimal = makeSemanticSummaryMessage(oldHistory, '', semanticMeta, factsText);
|
|
599
|
-
if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
|
|
600
1336
|
const text = String(summary || '').trim();
|
|
1337
|
+
// Minimal schema-valid body (headings + "(none)"). If even this does
|
|
1338
|
+
// not fit, this facts variant cannot produce a valid message.
|
|
1339
|
+
const minimalBody = text ? minimalSchemaSummary() : '';
|
|
1340
|
+
const minimal = makeSemanticSummaryMessage(oldHistory, minimalBody, semanticMeta, factsText);
|
|
1341
|
+
if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
|
|
601
1342
|
if (!text) return minimal;
|
|
1343
|
+
// Binary search the per-section body budget; keep all anchors intact.
|
|
602
1344
|
let lo = 0;
|
|
603
1345
|
let hi = text.length;
|
|
604
1346
|
let best = minimal;
|
|
605
1347
|
while (lo <= hi) {
|
|
606
1348
|
const mid = Math.floor((lo + hi) / 2);
|
|
607
|
-
const
|
|
608
|
-
|
|
1349
|
+
const body = truncateSummaryBySections(text, mid);
|
|
1350
|
+
const candidate = makeSemanticSummaryMessage(oldHistory, body, semanticMeta, factsText);
|
|
1351
|
+
if (estimateMessagesTokens([candidate]) <= remainingTokens && summaryIsSchemaValid(body)) {
|
|
609
1352
|
best = candidate;
|
|
610
1353
|
lo = mid + 1;
|
|
611
1354
|
} else {
|
|
@@ -614,11 +1357,90 @@ function fitSemanticSummaryMessage(oldHistory, summary, remainingTokens, semanti
|
|
|
614
1357
|
}
|
|
615
1358
|
return best;
|
|
616
1359
|
};
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1360
|
+
let result = null;
|
|
1361
|
+
if (preservedFacts) result = tryFit(preservedFacts);
|
|
1362
|
+
if (!result) result = tryFit('');
|
|
1363
|
+
return result;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Recall fast-track (compact type 2) does no LLM summarization — it just emits
|
|
1367
|
+
// the chunked history in order. Keep the message clean: the mandatory anchor
|
|
1368
|
+
// header (so selectCompactionWindow / clear-preserve / TUI can recognize the
|
|
1369
|
+
// compact message) plus the chunk text itself. No "Preserved Facts" extraction,
|
|
1370
|
+
// no "Recall Fast-Track Context" heading, no "(no recall hits)" filler.
|
|
1371
|
+
function makeRecallFastTrackSummaryMessage(oldHistory, recallText, recallMeta = {}) {
|
|
1372
|
+
return makeRecallFastTrackSummaryMessageParts(oldHistory, recallText, '', recallMeta);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const RECALL_TAIL_TRUNCATION_MARKER = '[... truncated during recall tail preservation ...]';
|
|
1376
|
+
const RECALL_TAIL_SHORT_TRUNCATION_MARKER = '[truncated]';
|
|
1377
|
+
|
|
1378
|
+
const PRIOR_COMPACTED_CONTEXT_OPEN = '<prior-compacted-context>';
|
|
1379
|
+
const PRIOR_COMPACTED_CONTEXT_CLOSE = '</prior-compacted-context>';
|
|
1380
|
+
|
|
1381
|
+
function formatPriorCompactedContextBlock(priorText) {
|
|
1382
|
+
const prior = String(priorText || '').trim();
|
|
1383
|
+
if (!prior) return '';
|
|
1384
|
+
return `${PRIOR_COMPACTED_CONTEXT_OPEN}\n${prior}\n${PRIOR_COMPACTED_CONTEXT_CLOSE}`;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function makeRecallFastTrackSummaryMessageParts(oldHistory, recallPart, priorPart, recallMeta = {}) {
|
|
1388
|
+
const header = compactHeader(oldHistory);
|
|
1389
|
+
header.push(`compact_type=${COMPACT_TYPE_RECALL_FASTTRACK} source=recall-fasttrack query_sha=${recallMeta.querySha || 'none'}`);
|
|
1390
|
+
const parts = [header.join('\n')];
|
|
1391
|
+
const priorBlock = formatPriorCompactedContextBlock(priorPart);
|
|
1392
|
+
if (priorBlock) parts.push(priorBlock);
|
|
1393
|
+
const recall = String(recallPart || '').trim();
|
|
1394
|
+
if (recall) parts.push(recall);
|
|
1395
|
+
return makeSummaryMessage(parts.join('\n\n'));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function fitRecallFastTrackSummaryMessage(oldHistory, recallText, remainingTokens, recallMeta = {}, priorPart = '') {
|
|
1399
|
+
const recall = String(recallText || '').trim();
|
|
1400
|
+
const prior = String(priorPart || '').trim();
|
|
1401
|
+
|
|
1402
|
+
let fittedPrior = prior;
|
|
1403
|
+
if (prior) {
|
|
1404
|
+
let lo = 0;
|
|
1405
|
+
let hi = prior.length;
|
|
1406
|
+
let bestPriorLen = 0;
|
|
1407
|
+
while (lo <= hi) {
|
|
1408
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
1409
|
+
const candidate = makeRecallFastTrackSummaryMessageParts(oldHistory, '', prior.slice(0, mid), recallMeta);
|
|
1410
|
+
if (estimateMessagesTokens([candidate]) <= remainingTokens) {
|
|
1411
|
+
bestPriorLen = mid;
|
|
1412
|
+
lo = mid + 1;
|
|
1413
|
+
} else {
|
|
1414
|
+
hi = mid - 1;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
fittedPrior = prior.slice(0, bestPriorLen);
|
|
1418
|
+
if (!fittedPrior && prior) {
|
|
1419
|
+
const markerOnly = makeRecallFastTrackSummaryMessageParts(oldHistory, '', RECALL_TAIL_TRUNCATION_MARKER, recallMeta);
|
|
1420
|
+
if (estimateMessagesTokens([markerOnly]) <= remainingTokens) {
|
|
1421
|
+
fittedPrior = RECALL_TAIL_TRUNCATION_MARKER;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
620
1424
|
}
|
|
621
|
-
|
|
1425
|
+
|
|
1426
|
+
const minimal = makeRecallFastTrackSummaryMessageParts(oldHistory, '', fittedPrior, recallMeta);
|
|
1427
|
+
if (estimateMessagesTokens([minimal]) > remainingTokens) return null;
|
|
1428
|
+
if (!recall) return minimal;
|
|
1429
|
+
|
|
1430
|
+
let lo = 0;
|
|
1431
|
+
let hi = recall.length;
|
|
1432
|
+
let best = minimal;
|
|
1433
|
+
while (lo <= hi) {
|
|
1434
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
1435
|
+
const candidate = makeRecallFastTrackSummaryMessageParts(oldHistory, recall.slice(0, mid), fittedPrior, recallMeta);
|
|
1436
|
+
if (estimateMessagesTokens([candidate]) <= remainingTokens) {
|
|
1437
|
+
best = candidate;
|
|
1438
|
+
lo = mid + 1;
|
|
1439
|
+
} else {
|
|
1440
|
+
hi = mid - 1;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return best;
|
|
622
1444
|
}
|
|
623
1445
|
|
|
624
1446
|
function combinedSignal(parent, timeoutMs) {
|
|
@@ -633,11 +1455,21 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
633
1455
|
if (!provider || typeof provider.send !== 'function') {
|
|
634
1456
|
throw new Error('semanticCompactMessages: provider.send is required');
|
|
635
1457
|
}
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
if
|
|
639
|
-
|
|
1458
|
+
let budget = effectiveBudget(budgetTokens, opts);
|
|
1459
|
+
const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
|
|
1460
|
+
// No-op fast path: if the original sanitized transcript already fits and we
|
|
1461
|
+
// are not forced, return it UNCHANGED (no preserved-tail redaction applied)
|
|
1462
|
+
// to keep prior no-compaction semantics.
|
|
1463
|
+
if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
|
|
1464
|
+
return { messages: baseSanitized, usage: null, semantic: false };
|
|
640
1465
|
}
|
|
1466
|
+
// Compaction will proceed: redact sensitive tool-call argument VALUES before
|
|
1467
|
+
// window selection so the preserved tail/system that survive verbatim are
|
|
1468
|
+
// measured AND emitted in their redacted form. Head prompt normalizers
|
|
1469
|
+
// (toolCallSummary/normalizeToolArgValue) still apply on top for the
|
|
1470
|
+
// summarized head. Redaction is shape-preserving, so tool-pair structure
|
|
1471
|
+
// stays provider-valid.
|
|
1472
|
+
const sanitized = redactToolCallSecretsInMessages(baseSanitized);
|
|
641
1473
|
|
|
642
1474
|
const selected = selectCompactionWindow(sanitized, budget, opts);
|
|
643
1475
|
if (selected.head.length === 0 && !selected.previousSummary) {
|
|
@@ -646,8 +1478,14 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
646
1478
|
|
|
647
1479
|
const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...selected.system, ...selected.tail])));
|
|
648
1480
|
const mandatoryCost = estimateMessagesTokens(mandatory);
|
|
649
|
-
|
|
650
|
-
|
|
1481
|
+
// The preserved tail is kept verbatim and the head is replaced by a much
|
|
1482
|
+
// smaller summary, so the compacted result is always smaller than the
|
|
1483
|
+
// input regardless of how the configured target budget compares to the
|
|
1484
|
+
// mandatory cost. When the budget cannot even hold what we must keep, raise
|
|
1485
|
+
// it to fit (mandatory + summary room) rather than refusing — a refusal
|
|
1486
|
+
// here was the source of auto-clear / overflow compact failures.
|
|
1487
|
+
if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
|
|
1488
|
+
budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
|
|
651
1489
|
}
|
|
652
1490
|
|
|
653
1491
|
const callBudget = Math.max(1, Math.floor((opts.compactionInputBudgetTokens || budget) * COMPACTION_PROMPT_HEADROOM));
|
|
@@ -666,9 +1504,14 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
666
1504
|
maxOutputTokens: opts.maxOutputTokens || SUMMARY_OUTPUT_TOKENS,
|
|
667
1505
|
providerState: undefined,
|
|
668
1506
|
onToolCall: undefined,
|
|
1507
|
+
onToolResult: undefined,
|
|
1508
|
+
onTextDelta: undefined,
|
|
1509
|
+
onReasoningDelta: undefined,
|
|
1510
|
+
onUsageDelta: undefined,
|
|
669
1511
|
onStreamDelta: undefined,
|
|
670
1512
|
onStageChange: undefined,
|
|
671
|
-
|
|
1513
|
+
drainSteering: undefined,
|
|
1514
|
+
onSteerMessage: undefined,
|
|
672
1515
|
signal: combinedSignal(opts.signal || opts.sendOpts?.signal || null, opts.timeoutMs || 30_000),
|
|
673
1516
|
};
|
|
674
1517
|
if (opts.sessionId) sendOpts.sessionId = `${opts.sessionId}:compact`;
|
|
@@ -683,8 +1526,13 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
683
1526
|
{ role: 'system', content: COMPACTION_SYSTEM_PROMPT },
|
|
684
1527
|
{ role: 'user', content: prompt },
|
|
685
1528
|
], compactModel, undefined, sendOpts);
|
|
686
|
-
const
|
|
687
|
-
if (!
|
|
1529
|
+
const rawSummary = extractResponseText(response);
|
|
1530
|
+
if (!rawSummary) throw new Error('semanticCompactMessages: compaction agent returned empty summary');
|
|
1531
|
+
// Lightweight schema enforcement: a non-empty but malformed provider
|
|
1532
|
+
// response (missing the required template sections) is deterministically
|
|
1533
|
+
// repaired into the structured anchored shape rather than injected blindly.
|
|
1534
|
+
const enforced = enforceSemanticSummarySchema(rawSummary, { head: selected.head, tail: selected.tail });
|
|
1535
|
+
const summary = enforced.summary;
|
|
688
1536
|
|
|
689
1537
|
const oldHistory = selected.originalHead;
|
|
690
1538
|
const semanticMeta = {
|
|
@@ -696,6 +1544,9 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
696
1544
|
throw new Error(`semanticCompactMessages: summary cannot fit remaining budget=${budget - mandatoryCost}`);
|
|
697
1545
|
}
|
|
698
1546
|
|
|
1547
|
+
// selected.system / selected.tail already carry redacted tool-call args
|
|
1548
|
+
// (sanitized was redacted before window selection), so the preserved tail
|
|
1549
|
+
// is both measured and emitted in redacted form.
|
|
699
1550
|
let result = sanitizeToolPairs([...selected.system, summaryMessage, ...selected.tail]);
|
|
700
1551
|
result = reconcileDedupStubs(dedupToolResultBodies(result));
|
|
701
1552
|
const finalTokens = estimateMessagesTokens(result);
|
|
@@ -707,176 +1558,231 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
|
|
|
707
1558
|
usage: response?.usage || null,
|
|
708
1559
|
providerState: response?.providerState,
|
|
709
1560
|
semantic: true,
|
|
1561
|
+
compactType: COMPACT_TYPE_SEMANTIC,
|
|
710
1562
|
summary,
|
|
1563
|
+
summaryRepaired: enforced.repaired === true,
|
|
711
1564
|
};
|
|
712
1565
|
}
|
|
713
1566
|
|
|
714
|
-
export function
|
|
715
|
-
|
|
716
|
-
const sanitized = sanitizeToolPairs(messages);
|
|
717
|
-
if (estimateMessagesTokens(sanitized) <= budget && opts.force !== true) {
|
|
718
|
-
return reconcileDedupStubs(sanitized);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const system = sanitized.filter(m => m?.role === 'system');
|
|
722
|
-
const nonSystem = sanitized.filter(m => m?.role !== 'system');
|
|
723
|
-
const turnStart = currentTurnStart(nonSystem);
|
|
724
|
-
if (turnStart <= 0) {
|
|
725
|
-
throw new Error(`compactMessages: no compactable prior history before current turn (budget=${budget})`);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const oldHistory = nonSystem.slice(0, turnStart);
|
|
729
|
-
const currentTurn = nonSystem.slice(turnStart);
|
|
730
|
-
let mandatory = sanitizeToolPairs([...system, ...currentTurn]);
|
|
731
|
-
mandatory = reconcileDedupStubs(dedupToolResultBodies(mandatory));
|
|
732
|
-
const mandatoryCost = estimateMessagesTokens(mandatory);
|
|
733
|
-
if (mandatoryCost > budget) {
|
|
734
|
-
throw new Error(`compactMessages: mandatory system+current turn exceeds budget=${budget} (base=${mandatoryCost})`);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const summary = fitSummaryMessage(oldHistory, budget - mandatoryCost);
|
|
738
|
-
if (!summary) {
|
|
739
|
-
throw new Error(`compactMessages: compact summary cannot fit remaining budget=${budget - mandatoryCost}`);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
let result = sanitizeToolPairs([...system, summary, ...currentTurn]);
|
|
743
|
-
result = reconcileDedupStubs(dedupToolResultBodies(result));
|
|
744
|
-
const finalTokens = estimateMessagesTokens(result);
|
|
745
|
-
if (finalTokens > budget) {
|
|
746
|
-
throw new Error(`compactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
|
|
747
|
-
}
|
|
748
|
-
return result;
|
|
1567
|
+
export function recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
|
|
1568
|
+
return _recallFastTrackCompactMessages(messages, budgetTokens, opts);
|
|
749
1569
|
}
|
|
750
1570
|
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
1571
|
+
// Recall fast-track (type 2) tail policy: preserve the most recent turns of the
|
|
1572
|
+
// live conversation VERBATIM and STRUCTURED, keeping role semantics for
|
|
1573
|
+
// user / assistant / tool / system / developer instead of collapsing the tail
|
|
1574
|
+
// to user-only. The chunk summary anchors older history; the preserved tail
|
|
1575
|
+
// keeps recent assistant reasoning, tool_calls, and tool_results so fresh
|
|
1576
|
+
// state is not silently dropped.
|
|
1577
|
+
//
|
|
1578
|
+
// Turns are anchored on user-role boundaries: each turn = a user message plus
|
|
1579
|
+
// the assistant/tool/system/developer messages that follow it (a leading run of
|
|
1580
|
+
// non-user messages before the first user boundary is treated as its own
|
|
1581
|
+
// partial turn so nothing is lost). We keep the newest RECALL_TAIL_USER_MAX
|
|
1582
|
+
// turns; if the kept set exceeds RECALL_TAIL_TOKEN_CAP we drop whole oldest
|
|
1583
|
+
// turns first, then middle-truncate the oldest surviving messages' string
|
|
1584
|
+
// content so the set fits while leaving the newest message whole.
|
|
1585
|
+
//
|
|
1586
|
+
// Partial tool_call/tool_result pairs that truncation might leave behind are
|
|
1587
|
+
// repaired by sanitizeToolPairs/reconcileDedupStubs in the caller, so pairing
|
|
1588
|
+
// stays valid even after trimming.
|
|
1589
|
+
const RECALL_TAIL_USER_MAX = 2;
|
|
1590
|
+
const RECALL_TAIL_TOKEN_CAP = DEFAULT_COMPACTION_KEEP_TOKENS; // 8k
|
|
1591
|
+
// Rough chars-per-token used only to size a truncation target; the real fit is
|
|
1592
|
+
// re-checked with estimateMessagesTokens below.
|
|
1593
|
+
const RECALL_TAIL_CHARS_PER_TOKEN = 4;
|
|
1594
|
+
|
|
1595
|
+
function splitTailIntoTurns(messages) {
|
|
1596
|
+
const turns = [];
|
|
757
1597
|
let current = null;
|
|
758
1598
|
for (const m of messages) {
|
|
759
|
-
if (m
|
|
760
|
-
|
|
1599
|
+
if (isSummaryMessage(m)) continue;
|
|
1600
|
+
if (m?.role === 'user') {
|
|
1601
|
+
if (current) turns.push(current);
|
|
761
1602
|
current = [m];
|
|
762
1603
|
} else {
|
|
763
1604
|
if (!current) current = [];
|
|
764
1605
|
current.push(m);
|
|
765
1606
|
}
|
|
766
1607
|
}
|
|
767
|
-
if (current)
|
|
768
|
-
return
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Active-turn fallback compaction (bridge/worker only).
|
|
773
|
-
*
|
|
774
|
-
* compactMessages/semanticCompactMessages treat the ENTIRE current turn as
|
|
775
|
-
* mandatory, so a hidden worker with one user turn plus many assistant/tool
|
|
776
|
-
* iterations throws overflow even when older same-turn tool outputs could be
|
|
777
|
-
* safely shrunk. This narrow fallback shrinks the current turn itself while
|
|
778
|
-
* preserving, in priority order:
|
|
779
|
-
* - all system messages,
|
|
780
|
-
* - the original task user message (the turn's first user message),
|
|
781
|
-
* - the latest assistant/tool group(s),
|
|
782
|
-
* - valid tool_use/tool_result pairing (via sanitizeToolPairs).
|
|
783
|
-
*
|
|
784
|
-
* Older prior history (before the current turn) is condensed to a best-effort
|
|
785
|
-
* summary when it still fits the remaining budget, otherwise dropped — it has
|
|
786
|
-
* already been superseded by completed turns. It NEVER silently drops the task
|
|
787
|
-
* user message or the latest group: if system + task user + the fully-pruned
|
|
788
|
-
* latest group still cannot fit, it throws so the caller surfaces overflow.
|
|
789
|
-
*/
|
|
790
|
-
export function compactActiveTurn(messages, budgetTokens, opts = {}) {
|
|
791
|
-
const budget = effectiveBudget(budgetTokens, opts);
|
|
792
|
-
const sanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
|
|
793
|
-
const system = sanitized.filter(m => m?.role === 'system');
|
|
794
|
-
const nonSystem = sanitized.filter(m => m?.role !== 'system');
|
|
795
|
-
const turnStart = currentTurnStart(nonSystem);
|
|
796
|
-
if (turnStart < 0) {
|
|
797
|
-
throw new Error(`compactActiveTurn: no current user turn to preserve (budget=${budget})`);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const oldHistory = nonSystem.slice(0, turnStart);
|
|
801
|
-
const currentTurn = nonSystem.slice(turnStart);
|
|
802
|
-
const userMsg = currentTurn[0];
|
|
803
|
-
const groups = splitTurnGroups(currentTurn.slice(1));
|
|
804
|
-
const minGroups = Math.max(1, Number(opts.minActiveTurnGroups) || 1);
|
|
805
|
-
const maxChars = Math.max(256, Number(opts.maxToolOutputChars) || PRUNE_TOOL_OUTPUT_MAX_CHARS);
|
|
806
|
-
const force = opts.force === true;
|
|
807
|
-
|
|
808
|
-
const pruneToolMsg = (m) => (
|
|
809
|
-
m?.role === 'tool' && typeof m.content === 'string' && m.content.length > maxChars
|
|
810
|
-
? {
|
|
811
|
-
...m,
|
|
812
|
-
content: pruneToolOutputText(m.content, maxChars, m.toolCallId),
|
|
813
|
-
compacted: true,
|
|
814
|
-
compactedKind: 'active_turn_tool_prune',
|
|
815
|
-
}
|
|
816
|
-
: m
|
|
817
|
-
);
|
|
1608
|
+
if (current && current.length) turns.push(current);
|
|
1609
|
+
return turns;
|
|
1610
|
+
}
|
|
818
1611
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1612
|
+
function truncateMessageForRecallTail(text, maxChars) {
|
|
1613
|
+
const marker = RECALL_TAIL_TRUNCATION_MARKER;
|
|
1614
|
+
const value = String(text ?? '').replace(/\r\n/g, '\n');
|
|
1615
|
+
if (value.length <= maxChars) return value;
|
|
1616
|
+
if (maxChars <= 0) return RECALL_TAIL_SHORT_TRUNCATION_MARKER;
|
|
1617
|
+
if (maxChars < marker.length) return RECALL_TAIL_SHORT_TRUNCATION_MARKER;
|
|
1618
|
+
const room = maxChars - marker.length;
|
|
1619
|
+
const head = Math.ceil(room * 0.35);
|
|
1620
|
+
const tailPart = Math.floor(room * 0.65);
|
|
1621
|
+
return `${value.slice(0, head)}${marker}${value.slice(value.length - tailPart)}`;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function fitRecallUserMessageToCap(userMsg, cap, following = []) {
|
|
1625
|
+
const followCost = estimateMessagesTokens(following);
|
|
1626
|
+
const room = Math.max(1, cap - followCost);
|
|
1627
|
+
const base = { ...userMsg };
|
|
1628
|
+
const raw = typeof base.content === 'string' ? base.content : extractText(base);
|
|
1629
|
+
if (estimateMessagesTokens([{ ...base, content: raw }]) <= room) return { ...base, content: raw };
|
|
1630
|
+
|
|
1631
|
+
let lo = 0;
|
|
1632
|
+
let hi = raw.length;
|
|
1633
|
+
let best = truncateMessageForRecallTail(raw, 0);
|
|
1634
|
+
while (lo <= hi) {
|
|
1635
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
1636
|
+
const candidateText = truncateMessageForRecallTail(raw, mid);
|
|
1637
|
+
const candidate = { ...base, content: candidateText };
|
|
1638
|
+
if (estimateMessagesTokens([candidate]) <= room) {
|
|
1639
|
+
best = candidateText;
|
|
1640
|
+
lo = mid + 1;
|
|
1641
|
+
} else {
|
|
1642
|
+
hi = mid - 1;
|
|
827
1643
|
}
|
|
828
|
-
|
|
829
|
-
};
|
|
1644
|
+
}
|
|
1645
|
+
return { ...base, content: best };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function fitSingleRecallTurnToCap(turn, cap) {
|
|
1649
|
+
const userIdx = turn.findIndex((m) => m?.role === 'user');
|
|
1650
|
+
if (userIdx < 0) {
|
|
1651
|
+
return truncateTailToCap(turn, cap);
|
|
1652
|
+
}
|
|
1653
|
+
const userMsg = turn[userIdx];
|
|
1654
|
+
const following = turn.slice(userIdx + 1);
|
|
1655
|
+
const fittedUser = fitRecallUserMessageToCap(userMsg, cap, following);
|
|
1656
|
+
let out = [fittedUser];
|
|
1657
|
+
for (const m of following) {
|
|
1658
|
+
const candidate = [...out, m];
|
|
1659
|
+
if (estimateMessagesTokens(candidate) <= cap) out.push(m);
|
|
1660
|
+
}
|
|
1661
|
+
return reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(out)));
|
|
1662
|
+
}
|
|
830
1663
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1664
|
+
function truncateTailToCap(messages, cap) {
|
|
1665
|
+
const turn = Array.isArray(messages) ? messages : [];
|
|
1666
|
+
if (turn.length === 0) return [];
|
|
1667
|
+
return fitSingleRecallTurnToCap(turn, cap);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function stripNestedSummaryHeaderLines(text) {
|
|
1671
|
+
const lines = String(text ?? '').split('\n');
|
|
1672
|
+
const out = [];
|
|
1673
|
+
for (const line of lines) {
|
|
1674
|
+
if (line.startsWith(SUMMARY_PREFIX)) continue;
|
|
1675
|
+
if (/^messages=\d+\s+sha256=/.test(line.trim())) continue;
|
|
1676
|
+
out.push(line);
|
|
1677
|
+
}
|
|
1678
|
+
return out.join('\n').trim();
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function splitRecallFitInputs(recallText, previousSummary) {
|
|
1682
|
+
return {
|
|
1683
|
+
recall: String(recallText || '').trim(),
|
|
1684
|
+
prior: previousSummary ? stripNestedSummaryHeaderLines(previousSummary) : '',
|
|
840
1685
|
};
|
|
1686
|
+
}
|
|
841
1687
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1688
|
+
function recallTailStartIndex(live, tail) {
|
|
1689
|
+
if (!tail.length) return live.length;
|
|
1690
|
+
const first = tail[0];
|
|
1691
|
+
const idx = live.indexOf(first);
|
|
1692
|
+
if (idx >= 0) return idx;
|
|
1693
|
+
return Math.max(0, live.length - tail.length);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function selectRecallPreservedTail(live, opts = {}) {
|
|
1697
|
+
const msgs = (Array.isArray(live) ? live : []).filter((m) => m && !isSummaryMessage(m));
|
|
1698
|
+
if (msgs.length === 0) return { tail: [], head: [], tailStartIdx: 0 };
|
|
1699
|
+
const maxTurns = Math.max(1, Number(opts.maxUsers) || RECALL_TAIL_USER_MAX);
|
|
1700
|
+
const cap = Math.max(1, Number(opts.tokenCap) || RECALL_TAIL_TOKEN_CAP);
|
|
1701
|
+
const turns = splitTailIntoTurns(msgs);
|
|
1702
|
+
if (turns.length === 0) return { tail: [], head: msgs, tailStartIdx: 0 };
|
|
1703
|
+
|
|
1704
|
+
let kept = turns.slice(-maxTurns);
|
|
1705
|
+
while (kept.length > 1 && estimateMessagesTokens(kept.flat()) > cap) {
|
|
1706
|
+
kept = kept.slice(1);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let tail;
|
|
1710
|
+
if (estimateMessagesTokens(kept.flat()) <= cap) {
|
|
1711
|
+
tail = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(kept.flat())));
|
|
1712
|
+
} else {
|
|
1713
|
+
tail = fitSingleRecallTurnToCap(kept[kept.length - 1], cap);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (!tail.some((m) => m?.role === 'user')) {
|
|
1717
|
+
throw new Error('recallFastTrackCompactMessages: preserved tail missing user anchor');
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const tailStartIdx = recallTailStartIndex(msgs, tail);
|
|
1721
|
+
const head = msgs.slice(0, tailStartIdx);
|
|
1722
|
+
return { tail, head, tailStartIdx };
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Kept name as the type-2 tail anchor; behavior now preserves whole structured
|
|
1726
|
+
// turns (all roles) rather than only role=user messages.
|
|
1727
|
+
function selectRecallTailUserMessages(tail, opts = {}) {
|
|
1728
|
+
const msgs = (Array.isArray(tail) ? tail : []).filter((m) => m && !isSummaryMessage(m));
|
|
1729
|
+
return selectRecallPreservedTail(msgs, opts).tail;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
|
|
1733
|
+
let budget = effectiveBudget(budgetTokens, opts);
|
|
1734
|
+
const baseSanitized = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs(messages)));
|
|
1735
|
+
if (estimateMessagesTokens(baseSanitized) <= budget && opts.force !== true) {
|
|
1736
|
+
return { messages: baseSanitized, recallFastTrack: false };
|
|
1737
|
+
}
|
|
1738
|
+
const sanitized = redactToolCallSecretsInMessages(baseSanitized);
|
|
1739
|
+
|
|
1740
|
+
const { system: safeSystem, live, previousSummary } = splitLiveCompactionContext(sanitized);
|
|
1741
|
+
const recallTailOpts = {
|
|
1742
|
+
maxUsers: opts.recallTailMaxUsers ?? opts.tailTurns ?? RECALL_TAIL_USER_MAX,
|
|
1743
|
+
tokenCap: opts.recallTailTokenCap ?? preserveRecentBudget(budget, opts),
|
|
861
1744
|
};
|
|
1745
|
+
const { tail: recallTail, head: recallHead } = selectRecallPreservedTail(live, recallTailOpts);
|
|
1746
|
+
const recallFit = splitRecallFitInputs(opts.recallText, previousSummary);
|
|
1747
|
+
if (recallHead.length === 0 && !previousSummary
|
|
1748
|
+
&& !(recallFit.recall || recallFit.prior || opts.allowEmptyRecall === true)) {
|
|
1749
|
+
throw new Error('recallFastTrackCompactMessages: no compactable prior history before preserved tail');
|
|
1750
|
+
}
|
|
862
1751
|
|
|
863
|
-
const
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const activeHistory = groups.slice(0, drop).flat();
|
|
868
|
-
const modes = force && drop === 0 && activeHistory.length === 0
|
|
869
|
-
? ['all', 'older']
|
|
870
|
-
: ['older', 'all'];
|
|
871
|
-
for (const mode of modes) {
|
|
872
|
-
const candidate = finalize(buildTurnMsgs(kept, mode), activeHistory);
|
|
873
|
-
if (estimateMessagesTokens(candidate) <= budget) return candidate;
|
|
874
|
-
}
|
|
1752
|
+
const mandatory = reconcileDedupStubs(dedupToolResultBodies(sanitizeToolPairs([...safeSystem, ...recallTail])));
|
|
1753
|
+
const mandatoryCost = estimateMessagesTokens(mandatory);
|
|
1754
|
+
if (mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS > budget) {
|
|
1755
|
+
budget = mandatoryCost + COMPACT_SUMMARY_MIN_ROOM_TOKENS;
|
|
875
1756
|
}
|
|
876
1757
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1758
|
+
if (!recallFit.recall && !recallFit.prior && opts.allowEmptyRecall !== true) {
|
|
1759
|
+
throw new Error('recallFastTrackCompactMessages: recall text is empty');
|
|
1760
|
+
}
|
|
1761
|
+
const oldHistory = recallHead;
|
|
1762
|
+
const recallMeta = {
|
|
1763
|
+
querySha: opts.querySha || null,
|
|
1764
|
+
};
|
|
1765
|
+
const summaryMessage = fitRecallFastTrackSummaryMessage(
|
|
1766
|
+
oldHistory,
|
|
1767
|
+
recallFit.recall,
|
|
1768
|
+
budget - mandatoryCost,
|
|
1769
|
+
recallMeta,
|
|
1770
|
+
recallFit.prior,
|
|
881
1771
|
);
|
|
1772
|
+
if (!summaryMessage) {
|
|
1773
|
+
throw new Error(`recallFastTrackCompactMessages: summary cannot fit remaining budget=${budget - mandatoryCost}`);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
let result = sanitizeToolPairs([...safeSystem, summaryMessage, ...recallTail]);
|
|
1777
|
+
result = reconcileDedupStubs(dedupToolResultBodies(result));
|
|
1778
|
+
const finalTokens = estimateMessagesTokens(result);
|
|
1779
|
+
if (finalTokens > budget) {
|
|
1780
|
+
throw new Error(`recallFastTrackCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
|
|
1781
|
+
}
|
|
1782
|
+
return {
|
|
1783
|
+
messages: result,
|
|
1784
|
+
recallFastTrack: true,
|
|
1785
|
+
compactType: COMPACT_TYPE_RECALL_FASTTRACK,
|
|
1786
|
+
query: opts.query || '',
|
|
1787
|
+
};
|
|
882
1788
|
}
|