mixdog 1.1.2 → 1.2.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 +1 -0
- package/package.json +2 -1
- package/scripts/lib/run-node-tests.mjs +9 -0
- package/scripts/prune-desktop-runtime.mjs +3 -0
- package/scripts/test-direct.mjs +4 -2
- package/src/defaults/shipped-history.json +6 -0
- package/src/defaults/skills/setup/references/actions.md +5 -2
- package/src/defaults/skills/visualize/SKILL.md +157 -0
- package/src/defaults/skills/visualize/references/craft.md +88 -0
- package/src/headless-command.mjs +56 -3
- package/src/headless-exec.mjs +17 -1
- package/src/help.mjs +2 -0
- package/src/rules/shared/70-delivery.md +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
- package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +9 -1
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
- package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
- package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
- package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
- package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
- package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
- package/src/runtime/attachments/store.mjs +38 -0
- package/src/runtime/shared/atomic-file.mjs +8 -2
- package/src/runtime/shared/best-effort-stderr.mjs +8 -0
- package/src/runtime/shared/code-graph-state.mjs +12 -0
- package/src/runtime/shared/defaults-separation.mjs +2 -0
- package/src/runtime/shared/git-runner-worker.mjs +3 -2
- package/src/runtime/shared/git-runner.mjs +4 -3
- package/src/runtime/shared/llm/default-context-window.mjs +6 -0
- package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
- package/src/runtime/shared/llm/model-catalog.mjs +240 -118
- package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
- package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
- package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
- package/src/runtime/shared/model-proxy-mode.mjs +19 -0
- package/src/runtime/shared/provider-accounts.mjs +2 -2
- package/src/runtime/shared/provider-api-key.mjs +21 -1
- package/src/runtime/shared/schedule-model-ref.mjs +11 -2
- package/src/runtime/shared/session-usage-totals.mjs +19 -0
- package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
- package/src/runtime/shared/singleton-owner.mjs +36 -6
- package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
- package/src/runtime/shared/tool-surface.mjs +7 -0
- package/src/runtime/shared/tool-work-units.mjs +14 -0
- package/src/runtime/shared/transcript-metadata.mjs +17 -1
- package/src/runtime/shared/turn-changes.mjs +791 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
- package/src/runtime/shared/turn-snapshot.mjs +690 -81
- package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
- package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
- package/src/runtime/shared/worktrees/contract.mjs +551 -0
- package/src/runtime/shared/worktrees/disk.mjs +57 -0
- package/src/runtime/shared/worktrees/git.mjs +247 -0
- package/src/runtime/shared/worktrees/include.mjs +480 -0
- package/src/runtime/shared/worktrees/lock.mjs +46 -0
- package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
- package/src/runtime/shared/worktrees/manager.mjs +1215 -0
- package/src/runtime/shared/worktrees/merge.mjs +182 -0
- package/src/runtime/shared/worktrees/owner.mjs +46 -0
- package/src/runtime/shared/worktrees/paths.mjs +62 -0
- package/src/runtime/shared/worktrees/registry.mjs +110 -0
- package/src/runtime/shared/worktrees/settings.mjs +84 -0
- package/src/runtime/shared/worktrees/slug.mjs +62 -0
- package/src/runtime/shared/worktrees/test-support.mjs +96 -0
- package/src/runtime/visualize-bridge/client.mjs +181 -0
- package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
- package/src/session-runtime/boot/apis.mjs +35 -0
- package/src/session-runtime/boot/config.mjs +2 -0
- package/src/session-runtime/boot/facade.mjs +20 -0
- package/src/session-runtime/boot/infrastructure.mjs +1 -0
- package/src/session-runtime/boot/session.mjs +1 -0
- package/src/session-runtime/boot/tools.mjs +4 -0
- package/src/session-runtime/config-lifecycle.mjs +10 -1
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
- package/src/session-runtime/global-extensions.mjs +2 -2
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
- package/src/session-runtime/internal-tool-executor.mjs +9 -1
- package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
- package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
- package/src/session-runtime/mcp-glue/connect.mjs +11 -6
- package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
- package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
- package/src/session-runtime/model-route/route-persist.mjs +3 -0
- package/src/session-runtime/model-route/set-route.mjs +15 -2
- package/src/session-runtime/notification-bus.mjs +9 -1
- package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
- package/src/session-runtime/resource-extension-sync.mjs +30 -7
- package/src/session-runtime/resource-mcp-api.mjs +20 -7
- package/src/session-runtime/route-resolution.mjs +10 -1
- package/src/session-runtime/route-state.mjs +16 -2
- package/src/session-runtime/runtime-core.mjs +5 -0
- package/src/session-runtime/runtime-facade.mjs +8 -0
- package/src/session-runtime/runtime-review-api.mjs +9 -0
- package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
- package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
- package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
- package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
- package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
- package/src/session-runtime/services/agent-tool.mjs +1 -0
- package/src/session-runtime/services/daemon-candidate.mjs +3 -2
- package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
- package/src/session-runtime/session-create/session-options.mjs +10 -1
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/settings-api.mjs +4 -2
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
- package/src/session-runtime/settings-worktree-api.mjs +17 -0
- package/src/session-runtime/setup-tool/executor.mjs +11 -4
- package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
- package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
- package/src/session-runtime/turn/agent-ops.mjs +12 -1
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
- package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
- package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
- package/src/session-runtime/turn/turn-run.mjs +6 -7
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
- package/src/session-runtime/workspace/admission-gate.mjs +197 -0
- package/src/session-runtime/workspace/project-branches.mjs +136 -0
- package/src/session-runtime/workspace/transition-steps.mjs +364 -0
- package/src/session-runtime/workspace/transitions.mjs +651 -0
- package/src/session-runtime/workspace/workspace-state.mjs +112 -0
- package/src/standalone/channel-transport/http-routes.mjs +17 -0
- package/src/standalone/channel-transport.mjs +13 -1
- package/src/standalone/daemon-desktop-runtime.mjs +1 -0
- package/src/standalone/daemon-owner.mjs +372 -0
- package/src/standalone/daemon-stored-session-views.mjs +4 -1
- package/src/standalone/daemon.mjs +139 -5
- package/src/standalone/desktop-service-registry.mjs +20 -0
- package/src/standalone/local-session-runtime.mjs +11 -0
- package/src/standalone/session-client/attachment-calls.mjs +3 -1
- package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
- package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
- package/src/standalone/session-client.mjs +104 -13
- package/src/standalone/session-protocol.mjs +16 -0
- package/src/standalone/session-proxy.mjs +3 -0
- package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
- package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
- package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
- package/src/standalone/session-service/projection/revisions.mjs +2 -0
- package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
- package/src/standalone/session-service/projection/workspace.mjs +14 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
- package/src/standalone/session-transport/call-route.mjs +13 -0
- package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
- package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
- package/src/standalone/session-transport/client-registry.mjs +20 -3
- package/src/standalone/session-transport/routes.mjs +79 -2
- package/src/standalone/session-transport/stack.mjs +2 -0
- package/src/standalone/session-transport.mjs +24 -1
- package/src/tui/app/message-selector.mjs +2 -1
- package/src/tui/app/model-options.mjs +35 -5
- package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
- package/src/tui/app/model-picker.mjs +4 -2
- package/src/tui/dist/index.mjs +332 -36
- package/src/tui/index.jsx +10 -2
- package/src/tui/session/compaction-view.mjs +147 -0
- package/src/tui/session/context-state.mjs +6 -0
- package/src/tui/session/initial-state.mjs +3 -0
- package/src/tui/session/quota-resume.mjs +2 -1
- package/src/tui/session/session-api/commands.mjs +10 -2
- package/src/tui/session/session-api/controls.mjs +4 -0
- package/src/tui/session/session-api/intake/rewind.mjs +2 -1
- package/src/tui/session/session-api/integrations.mjs +17 -0
- package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
- package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
- package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
- package/src/tui/session/session-api/routes.mjs +10 -1
- package/src/tui/session/session-api/settings.mjs +2 -0
- package/src/tui/session/session-api/worktree.mjs +60 -0
- package/src/tui/session/session-api.mjs +2 -0
- package/src/tui/session/session-flow/auto-clear.mjs +31 -12
- package/src/tui/session/session-flow/drain.mjs +15 -1
- package/src/tui/session/session-flow/session-reset.mjs +4 -3
- package/src/tui/session/session-flow.mjs +2 -0
- package/src/tui/session/transcript-intake.mjs +14 -5
- package/src/tui/session/turn-stream.mjs +32 -4
- package/src/tui/session/turn-tool-cards.mjs +10 -2
- package/src/tui/session/turn.mjs +39 -10
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture side of the display-only compaction archive (compaction-archive.mjs).
|
|
3
|
+
*
|
|
4
|
+
* A committed compaction hands over the transcript it replaced and the one
|
|
5
|
+
* that replaced it. What left the model context — the messages whose stable
|
|
6
|
+
* id (message-ids.mjs) the result no longer holds, and the lost parts of kept
|
|
7
|
+
* messages (compactionArchiveProjection) — is projected to transcript items
|
|
8
|
+
* exactly like stored history
|
|
9
|
+
* (restoreTranscriptItems over media-sanitized messages, so tool cards and
|
|
10
|
+
* diffs survive), namespaced as `arch<seq>_…` with `archived: true`, and
|
|
11
|
+
* appended as ONE segment to the session's sidecar, followed by the
|
|
12
|
+
* compaction divider.
|
|
13
|
+
*
|
|
14
|
+
* The commit only selects the dropped messages and allocates the segment
|
|
15
|
+
* number (`session.compaction.archive.nextSeq`, saved with the commit); the
|
|
16
|
+
* projection and the write run on a later event-loop turn, serialized per
|
|
17
|
+
* session and fenced against a delete. Nothing here touches
|
|
18
|
+
* `session.messages`.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
22
|
+
import { open, readFile } from 'node:fs/promises';
|
|
23
|
+
import { basename, join } from 'node:path';
|
|
24
|
+
import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
|
|
25
|
+
import { writeStderrBestEffort } from '../../../shared/best-effort-stderr.mjs';
|
|
26
|
+
|
|
27
|
+
import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
|
|
28
|
+
import { getStoreDir } from './store/paths-heartbeat.mjs';
|
|
29
|
+
import { forgetSessionSaveBaseline } from './store/save-worker.mjs';
|
|
30
|
+
import { messageIdOf, stampMessageIds } from './message-ids.mjs';
|
|
31
|
+
import { restoreTranscriptItems } from './transcript-restore/restore.mjs';
|
|
32
|
+
import {
|
|
33
|
+
COMPACTION_ARCHIVE_MAX_BYTES,
|
|
34
|
+
COMPACTION_DIVIDER_LABEL,
|
|
35
|
+
compactionArchiveDeleteEpoch,
|
|
36
|
+
compactionArchivePath,
|
|
37
|
+
compactionSegmentHead,
|
|
38
|
+
compactionSegmentLine,
|
|
39
|
+
} from './compaction-archive.mjs';
|
|
40
|
+
|
|
41
|
+
export { COMPACTION_DIVIDER_LABEL };
|
|
42
|
+
|
|
43
|
+
const pendingWrites = new Map();
|
|
44
|
+
|
|
45
|
+
// ── Dropped messages: by stable id only ──────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function toolCallsOf(message) {
|
|
48
|
+
const calls = Array.isArray(message.toolCalls) ? message.toolCalls : message.tool_calls;
|
|
49
|
+
return Array.isArray(calls) ? calls : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function callIdOf(call) {
|
|
53
|
+
return String(call?.id || '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resultCallIdOf(message) {
|
|
57
|
+
return message.role === 'tool' ? String(message.tool_call_id || message.toolCallId || '') : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasText(content) {
|
|
61
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
62
|
+
return Array.isArray(content) && content.some((part) => typeof part?.text === 'string' && part.text.trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The stable ids `messages` carries (stampMessageIds / carryMessageId). */
|
|
66
|
+
function idsOf(messages) {
|
|
67
|
+
const ids = new Set();
|
|
68
|
+
for (const message of messages) {
|
|
69
|
+
const id = messageIdOf(message);
|
|
70
|
+
if (id) ids.add(id);
|
|
71
|
+
}
|
|
72
|
+
return ids;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Messages of `before` whose stable id the compacted `after` no longer holds,
|
|
77
|
+
* in their original order. Identity is the id alone — never an object
|
|
78
|
+
* reference, a position or content. A message without an id (a string-meta
|
|
79
|
+
* context marker, see message-ids.mjs) has no display identity and is never
|
|
80
|
+
* reported.
|
|
81
|
+
*/
|
|
82
|
+
export function droppedByCompaction(before, after) {
|
|
83
|
+
const kept = idsOf(Array.isArray(after) ? after : []);
|
|
84
|
+
return (Array.isArray(before) ? before : []).filter((message) => {
|
|
85
|
+
const id = messageIdOf(message);
|
|
86
|
+
return id && !kept.has(id);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a compaction took out of the model context, in `before` order, for the
|
|
92
|
+
* display archive: every dropped message, plus — for a kept message whose
|
|
93
|
+
* rewrite lost part of it — the lost part (tool calls no copy still carries,
|
|
94
|
+
* text when no copy has any) as a message of the same id. The tool results of
|
|
95
|
+
* those calls are dropped messages, so their cards restore whole.
|
|
96
|
+
* `textIds` are the ids whose text rows left the context: the dropped
|
|
97
|
+
* messages and the kept ones that lost all their text.
|
|
98
|
+
*/
|
|
99
|
+
export function compactionArchiveProjection(before, after) {
|
|
100
|
+
const copiesById = new Map();
|
|
101
|
+
for (const message of Array.isArray(after) ? after : []) {
|
|
102
|
+
const id = messageIdOf(message);
|
|
103
|
+
if (!id) continue;
|
|
104
|
+
if (!copiesById.has(id)) copiesById.set(id, []);
|
|
105
|
+
copiesById.get(id).push(message);
|
|
106
|
+
}
|
|
107
|
+
const messages = [];
|
|
108
|
+
const textIds = [];
|
|
109
|
+
for (const message of Array.isArray(before) ? before : []) {
|
|
110
|
+
const id = messageIdOf(message);
|
|
111
|
+
if (!id) continue;
|
|
112
|
+
const copies = copiesById.get(id);
|
|
113
|
+
if (!copies) {
|
|
114
|
+
messages.push(message);
|
|
115
|
+
textIds.push(id);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const keptCalls = new Set(copies.flatMap((copy) => toolCallsOf(copy).map(callIdOf)));
|
|
119
|
+
const lostCalls = toolCallsOf(message).filter((call) => callIdOf(call) && !keptCalls.has(callIdOf(call)));
|
|
120
|
+
const lostText = hasText(message.content) && !copies.some((copy) => hasText(copy.content));
|
|
121
|
+
if (!lostCalls.length && !lostText) continue;
|
|
122
|
+
if (lostText) textIds.push(id);
|
|
123
|
+
const { toolCalls: _calls, tool_calls: _wireCalls, ...rest } = message;
|
|
124
|
+
messages.push({
|
|
125
|
+
...rest,
|
|
126
|
+
content: lostText ? message.content : '',
|
|
127
|
+
...(lostCalls.length ? { toolCalls: lostCalls } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { messages, textIds };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a live view needs to find the rows of what left the context: the
|
|
134
|
+
* stable ids whose text rows went (projection `textIds`) and the tool calls
|
|
135
|
+
* that went (dropped results and calls no kept copy carries). */
|
|
136
|
+
export function droppedIdentitySummary(projection) {
|
|
137
|
+
const callIds = new Set();
|
|
138
|
+
for (const message of projection.messages) {
|
|
139
|
+
for (const call of toolCallsOf(message)) if (callIdOf(call)) callIds.add(callIdOf(call));
|
|
140
|
+
const resultId = resultCallIdOf(message);
|
|
141
|
+
if (resultId) callIds.add(resultId);
|
|
142
|
+
}
|
|
143
|
+
return { messageIds: [...new Set(projection.textIds)], callIds: [...callIds] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stamp stable ids on the transcript a compaction is about to run on (or a
|
|
147
|
+
* resumed session restores rows from) and make sure they are persisted: the
|
|
148
|
+
* next save goes out whole instead of as a delta that would leave the
|
|
149
|
+
* stamped, already-saved messages unchanged on disk. Returns the count. */
|
|
150
|
+
export function stampCompactionMessageIds(messages, sessionId) {
|
|
151
|
+
const stamped = stampMessageIds(messages, { sessionId });
|
|
152
|
+
if (stamped && sessionId) forgetSessionSaveBaseline(String(sessionId));
|
|
153
|
+
return stamped;
|
|
154
|
+
}
|
|
155
|
+
// ── Segment projection and publication ───────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function storedHistoryMessage(message) {
|
|
158
|
+
const content = sanitizeContentForStoredHistory(message.content);
|
|
159
|
+
return content === message.content ? message : { ...message, content };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The display items of one segment: what left the context
|
|
163
|
+
* (compactionArchiveProjection messages) as stored history restores it,
|
|
164
|
+
* namespaced and marked archived, then the divider. Empty when nothing
|
|
165
|
+
* visible left. */
|
|
166
|
+
export function compactionSegmentItems(messages, { sessionId, seq, at }) {
|
|
167
|
+
const restored = restoreTranscriptItems(messages.map(storedHistoryMessage), { sessionId });
|
|
168
|
+
if (!restored.length) return [];
|
|
169
|
+
const prefix = `hist_${sessionId}_`;
|
|
170
|
+
const items = restored.map((item) => {
|
|
171
|
+
const id = String(item?.id ?? '');
|
|
172
|
+
return { ...item, id: `arch${seq}_${id.startsWith(prefix) ? id.slice(prefix.length) : id}`, archived: true };
|
|
173
|
+
});
|
|
174
|
+
items.push({
|
|
175
|
+
kind: 'statusdone',
|
|
176
|
+
id: `arch${seq}_divider`,
|
|
177
|
+
status: 'compacted',
|
|
178
|
+
label: COMPACTION_DIVIDER_LABEL,
|
|
179
|
+
at,
|
|
180
|
+
});
|
|
181
|
+
return items;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function itemBytes(item) {
|
|
185
|
+
return Buffer.byteLength(JSON.stringify(item));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A segment line whose bytes plus its trailing newline fit `maxBytes`: a
|
|
189
|
+
* segment that alone exceeds the cap keeps its newest items (and the
|
|
190
|
+
* divider). `bytes` includes the newline. */
|
|
191
|
+
function cappedSegmentLine({ seq, prev, at, items }, maxBytes) {
|
|
192
|
+
let line = compactionSegmentLine({ seq, prev, at, items });
|
|
193
|
+
let bytes = Buffer.byteLength(line) + 1;
|
|
194
|
+
if (bytes <= maxBytes) return { line, bytes };
|
|
195
|
+
const sizes = items.map(itemBytes);
|
|
196
|
+
let start = 0;
|
|
197
|
+
while (bytes > maxBytes && start < items.length - 1) {
|
|
198
|
+
bytes -= sizes[start] + 1;
|
|
199
|
+
start += 1;
|
|
200
|
+
}
|
|
201
|
+
line = compactionSegmentLine({ seq, prev, at, items: items.slice(start) });
|
|
202
|
+
return { line, bytes: Buffer.byteLength(line) + 1 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let publishHook = null;
|
|
206
|
+
/** Test seam: runs after a segment's temp file is written, right before the
|
|
207
|
+
* fenced publication. */
|
|
208
|
+
export function _setCompactionArchivePublishHook(hook) {
|
|
209
|
+
publishHook = typeof hook === 'function' ? hook : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let syncFile = (handle) => handle.sync();
|
|
213
|
+
/** Test seam: replaces the temp file's fsync (null restores it). */
|
|
214
|
+
export function _setCompactionArchiveFsync(sync) {
|
|
215
|
+
syncFile = typeof sync === 'function' ? sync : (handle) => handle.sync();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const recordExists = (dir, sessionId) => existsSync(join(dir, `${sessionId}.json`));
|
|
219
|
+
|
|
220
|
+
/** Write `text` to a private temp file and fsync it — a failed write or
|
|
221
|
+
* fsync fails the publication — then publish it with a synchronous fence
|
|
222
|
+
* check + rename: a delete that bumped the epoch (or removed the record)
|
|
223
|
+
* since `epoch` was captured wins. Any outcome but a rename removes the temp
|
|
224
|
+
* file. */
|
|
225
|
+
async function publishArchive({ dir, path, sessionId, text, epoch }) {
|
|
226
|
+
const tmp = join(dir, `.${basename(path)}.${randomBytes(8).toString('hex')}.tmp`);
|
|
227
|
+
const removeTmp = () => {
|
|
228
|
+
try {
|
|
229
|
+
unlinkSync(tmp);
|
|
230
|
+
} catch {
|
|
231
|
+
/* already gone */
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
const handle = await open(tmp, 'wx', 0o600);
|
|
236
|
+
try {
|
|
237
|
+
await handle.writeFile(text, 'utf8');
|
|
238
|
+
await syncFile(handle);
|
|
239
|
+
} finally {
|
|
240
|
+
await handle.close();
|
|
241
|
+
}
|
|
242
|
+
if (publishHook) await publishHook({ sessionId, path, tmp });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
removeTmp();
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
// No await between the fence and the rename.
|
|
248
|
+
if (compactionArchiveDeleteEpoch(sessionId) !== epoch || !recordExists(dir, sessionId)) {
|
|
249
|
+
removeTmp();
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
renameWithRetrySync(tmp, path);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
removeTmp();
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function appendSegment({ sessionId, seq, at, items, clearedThrough, maxBytes, epoch }) {
|
|
262
|
+
const dir = getStoreDir();
|
|
263
|
+
const path = compactionArchivePath(dir, sessionId);
|
|
264
|
+
// A deleted session must not regrow its archive.
|
|
265
|
+
if (!path || compactionArchiveDeleteEpoch(sessionId) !== epoch || !recordExists(dir, sessionId)) return null;
|
|
266
|
+
let text = '';
|
|
267
|
+
try {
|
|
268
|
+
text = await readFile(path, 'utf8');
|
|
269
|
+
} catch {
|
|
270
|
+
text = '';
|
|
271
|
+
}
|
|
272
|
+
const existing = [];
|
|
273
|
+
for (const line of text.split('\n')) {
|
|
274
|
+
const head = compactionSegmentHead(line);
|
|
275
|
+
// Segments a clear hid are dropped on the next write.
|
|
276
|
+
if (head && head.seq > clearedThrough && head.seq !== seq) existing.push({ line, head });
|
|
277
|
+
}
|
|
278
|
+
const prev = existing.length ? existing.at(-1).head.seq : null;
|
|
279
|
+
const segment = cappedSegmentLine({ seq, prev, at, items }, maxBytes);
|
|
280
|
+
let total = segment.bytes;
|
|
281
|
+
for (const entry of existing) total += Buffer.byteLength(entry.line) + 1;
|
|
282
|
+
while (existing.length && total > maxBytes) {
|
|
283
|
+
total -= Buffer.byteLength(existing.shift().line) + 1;
|
|
284
|
+
}
|
|
285
|
+
const lines = [...existing.map((entry) => entry.line), segment.line];
|
|
286
|
+
if (!(await publishArchive({ dir, path, sessionId, text: `${lines.join('\n')}\n`, epoch }))) return null;
|
|
287
|
+
return lines.map((line) => compactionSegmentHead(line)).filter(Boolean);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Archive what a committed compaction dropped. Synchronous part: select the
|
|
292
|
+
* dropped messages, capture the delete fence and allocate the segment number
|
|
293
|
+
* on `session.compaction.archive`; the rest runs later. Returns
|
|
294
|
+
* `{ dropped, identities, written }` — the dropped message count, their
|
|
295
|
+
* identities for a live view (droppedIdentitySummary) and the pending write
|
|
296
|
+
* (resolves to the retained segment list) — or null when nothing visible was
|
|
297
|
+
* dropped (a rewrite-only compaction).
|
|
298
|
+
*/
|
|
299
|
+
export function scheduleCompactionArchive({
|
|
300
|
+
session,
|
|
301
|
+
sessionId = null,
|
|
302
|
+
before,
|
|
303
|
+
after,
|
|
304
|
+
at = Date.now(),
|
|
305
|
+
maxBytes = COMPACTION_ARCHIVE_MAX_BYTES,
|
|
306
|
+
}) {
|
|
307
|
+
const id = String(sessionId || session?.id || '');
|
|
308
|
+
if (!session || typeof session !== 'object' || !compactionArchivePath('', id)) return null;
|
|
309
|
+
const projection = compactionArchiveProjection(before, after);
|
|
310
|
+
// Only what the transcript shows is archived; a pass that took out nothing
|
|
311
|
+
// visible (a rewrite, control rows, hidden context) leaves the view live.
|
|
312
|
+
// Short-circuits on the first visible message.
|
|
313
|
+
if (!projection.messages.some((message) => restoreTranscriptItems([message], { sessionId: id }).length > 0)) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const dropped = droppedByCompaction(before, after);
|
|
317
|
+
if (!session.compaction || typeof session.compaction !== 'object') session.compaction = {};
|
|
318
|
+
const previous =
|
|
319
|
+
session.compaction.archive && typeof session.compaction.archive === 'object' ? session.compaction.archive : {};
|
|
320
|
+
const seq = Math.max(1, Math.floor(Number(previous.nextSeq) || 1));
|
|
321
|
+
const clearedThrough = Math.max(0, Math.floor(Number(previous.clearedThrough) || 0));
|
|
322
|
+
const epoch = compactionArchiveDeleteEpoch(id);
|
|
323
|
+
session.compaction.archive = { ...previous, nextSeq: seq + 1 };
|
|
324
|
+
const run = async () => {
|
|
325
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
326
|
+
const items = compactionSegmentItems(projection.messages, { sessionId: id, seq, at });
|
|
327
|
+
if (!items.length) return null;
|
|
328
|
+
const segments = await appendSegment({ sessionId: id, seq, at, items, clearedThrough, maxBytes, epoch });
|
|
329
|
+
const archive = session.compaction?.archive;
|
|
330
|
+
// Small metadata only; a later clear may have hidden this segment.
|
|
331
|
+
if (segments && archive && typeof archive === 'object' && !(Number(archive.clearedThrough) >= seq)) {
|
|
332
|
+
archive.segments = segments;
|
|
333
|
+
}
|
|
334
|
+
return segments;
|
|
335
|
+
};
|
|
336
|
+
const task = (pendingWrites.get(id) || Promise.resolve()).then(run).catch((error) => {
|
|
337
|
+
writeStderrBestEffort(`[session] compaction archive write failed (sess=${id}): ${error?.message || error}\n`);
|
|
338
|
+
return null;
|
|
339
|
+
});
|
|
340
|
+
pendingWrites.set(id, task);
|
|
341
|
+
void task.finally(() => {
|
|
342
|
+
if (pendingWrites.get(id) === task) pendingWrites.delete(id);
|
|
343
|
+
});
|
|
344
|
+
return { dropped: dropped.length, identities: droppedIdentitySummary(projection), written: task };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Hide everything archived so far (a clear): later reads and the next write
|
|
348
|
+
* skip those segments. */
|
|
349
|
+
export function hideCompactionArchive(session) {
|
|
350
|
+
const archive = session?.compaction?.archive;
|
|
351
|
+
if (!archive || typeof archive !== 'object') return;
|
|
352
|
+
session.compaction.archive = {
|
|
353
|
+
...archive,
|
|
354
|
+
clearedThrough: Math.max(0, Math.floor(Number(archive.nextSeq) || 1) - 1),
|
|
355
|
+
segments: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Settles once every archive write scheduled so far has finished. */
|
|
360
|
+
export async function flushCompactionArchiveWrites() {
|
|
361
|
+
await Promise.all([...pendingWrites.values()]);
|
|
362
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only archive of the conversation a compaction dropped.
|
|
3
|
+
*
|
|
4
|
+
* One JSONL sidecar per session next to its record
|
|
5
|
+
* (`<id>.compaction-archive.jsonl`), one line per compaction segment, oldest
|
|
6
|
+
* first. A segment holds the transcript ITEMS (the pane's display shape, never
|
|
7
|
+
* model messages) its compaction removed from the model context, followed by
|
|
8
|
+
* the "Conversation compacted" divider. Each line opens with a fixed header
|
|
9
|
+
* (`{"v":1,"seq":N,"id":"segN","prev":…,"at":…,"count":…,"bytes":…,"items":[…]}`)
|
|
10
|
+
* so a reader can pick segments by number without parsing their items.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here ever reaches `session.messages` or a provider payload: the
|
|
13
|
+
* archive is read only by the stored-transcript projection and the paged
|
|
14
|
+
* history loader. Leaf module (no store.mjs, no config) so the cold catalog
|
|
15
|
+
* path can read it; the writer lives in compaction-archive-capture.mjs.
|
|
16
|
+
*/
|
|
17
|
+
import { unlinkSync } from 'node:fs';
|
|
18
|
+
import { open } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const COMPACTION_ARCHIVE_SUFFIX = '.compaction-archive.jsonl';
|
|
22
|
+
/** The one label every compaction boundary row carries (persisted and live),
|
|
23
|
+
* translated by the desktop as the app's compaction wording. */
|
|
24
|
+
export const COMPACTION_DIVIDER_LABEL = 'Conversation compacted';
|
|
25
|
+
/** Per-session cap; the oldest segments are dropped past it. */
|
|
26
|
+
export const COMPACTION_ARCHIVE_MAX_BYTES = 32 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
const SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
29
|
+
const SEGMENT_HEAD = /^\{"v":1,"seq":(\d+),"id":"[^"]*","prev":(null|"[^"]*"),"at":(\d+),"count":(\d+),"bytes":(\d+),/;
|
|
30
|
+
|
|
31
|
+
/** The archive sidecar of `sessionId` inside a sessions directory, or null
|
|
32
|
+
* for an id that is not a stored session id. */
|
|
33
|
+
export function compactionArchivePath(storeDir, sessionId) {
|
|
34
|
+
const id = String(sessionId || '');
|
|
35
|
+
if (!SESSION_ID.test(id)) return null;
|
|
36
|
+
return join(storeDir, `${id}${COMPACTION_ARCHIVE_SUFFIX}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The visible archive a session record names: segments numbered in
|
|
40
|
+
* (after, before). A clear hides everything archived before it. Null when
|
|
41
|
+
* nothing is visible. */
|
|
42
|
+
export function compactionArchiveRange(session) {
|
|
43
|
+
const archive = session?.compaction?.archive;
|
|
44
|
+
if (!archive || typeof archive !== 'object') return null;
|
|
45
|
+
const before = Number(archive.nextSeq);
|
|
46
|
+
const after = Math.max(0, Math.floor(Number(archive.clearedThrough) || 0));
|
|
47
|
+
if (!Number.isInteger(before) || before - 1 <= after) return null;
|
|
48
|
+
return { after, before };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A segment line's header fields, or null for a line that is not one. */
|
|
52
|
+
export function compactionSegmentHead(line) {
|
|
53
|
+
const match = SEGMENT_HEAD.exec(String(line || ''));
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
return {
|
|
56
|
+
seq: Number(match[1]),
|
|
57
|
+
id: `seg${match[1]}`,
|
|
58
|
+
prev: match[2] === 'null' ? null : match[2].slice(1, -1),
|
|
59
|
+
at: Number(match[3]),
|
|
60
|
+
count: Number(match[4]),
|
|
61
|
+
bytes: Number(match[5]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One segment as its JSONL line (no trailing newline). */
|
|
66
|
+
export function compactionSegmentLine({ seq, prev, at, items }) {
|
|
67
|
+
const body = JSON.stringify(items);
|
|
68
|
+
return `{"v":1,"seq":${seq},"id":"seg${seq}","prev":${prev == null ? 'null' : JSON.stringify(`seg${prev}`)},"at":${Math.max(0, Math.floor(Number(at) || 0))},"count":${items.length},"bytes":${Buffer.byteLength(body)},"items":${body}}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function segmentItems(text) {
|
|
72
|
+
try {
|
|
73
|
+
const items = JSON.parse(text)?.items;
|
|
74
|
+
return Array.isArray(items) ? items : [];
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One page from a segment list (oldest → newest heads; `load(entry)` yields a
|
|
82
|
+
* segment's items): segments numbered in (after, before), newest first,
|
|
83
|
+
* skipping the `skip` newest items a reader already holds and returning at
|
|
84
|
+
* most `limit` items above them, in display order. Item counts come from
|
|
85
|
+
* the segment headers: a segment wholly inside the skipped rows or past the
|
|
86
|
+
* page is never read or parsed. `hasOlder` says whether older items remain.
|
|
87
|
+
*/
|
|
88
|
+
async function pageFromSegments(
|
|
89
|
+
entries,
|
|
90
|
+
load,
|
|
91
|
+
{ after = 0, before = Number.POSITIVE_INFINITY, skip = 0, limit = 0 } = {}
|
|
92
|
+
) {
|
|
93
|
+
const held = Math.max(0, Math.floor(Number(skip) || 0));
|
|
94
|
+
const wanted = Math.max(0, Math.floor(Number(limit) || 0));
|
|
95
|
+
const parts = [];
|
|
96
|
+
// Items newer than this entry, counted from the newest.
|
|
97
|
+
let newer = 0;
|
|
98
|
+
let more = false;
|
|
99
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
100
|
+
const { head } = entries[index];
|
|
101
|
+
if (head.seq <= after || head.seq >= before || head.count < 1) continue;
|
|
102
|
+
if (newer >= held + wanted) {
|
|
103
|
+
more = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
const count = head.count;
|
|
107
|
+
if (newer + count > held) {
|
|
108
|
+
// This segment holds rows of the page: newest-first offsets
|
|
109
|
+
// [max(held, newer), min(held + wanted, newer + count)) of it.
|
|
110
|
+
const items = await load(entries[index]);
|
|
111
|
+
const fromEnd = Math.max(0, held - newer);
|
|
112
|
+
const take = Math.min(held + wanted, newer + count) - Math.max(held, newer);
|
|
113
|
+
const end = items.length - fromEnd;
|
|
114
|
+
parts.push(items.slice(Math.max(0, end - take), Math.max(0, end)));
|
|
115
|
+
if (newer + count > held + wanted) more = true;
|
|
116
|
+
}
|
|
117
|
+
newer += count;
|
|
118
|
+
}
|
|
119
|
+
return { items: parts.reverse().flat(), hasOlder: more };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** pageFromSegments over archive text (tests and in-memory callers). */
|
|
123
|
+
export function compactionArchivePageFromText(text, range = {}) {
|
|
124
|
+
const entries = [];
|
|
125
|
+
for (const line of String(text || '').split('\n')) {
|
|
126
|
+
const head = compactionSegmentHead(line);
|
|
127
|
+
if (head) entries.push({ head, line });
|
|
128
|
+
}
|
|
129
|
+
return pageFromSegments(entries, (entry) => segmentItems(entry.line), range);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Per-sidecar segment index (byte offsets + headers), keyed by the file
|
|
133
|
+
// identity it was built from: a page reads only the segments it reaches, and
|
|
134
|
+
// the headers are indexed once per change rather than once per page.
|
|
135
|
+
const INDEX_CACHE_LIMIT = 16;
|
|
136
|
+
const archiveIndexes = new Map();
|
|
137
|
+
|
|
138
|
+
const HEAD_PROBE_BYTES = 512;
|
|
139
|
+
const ITEMS_KEY = '"items":';
|
|
140
|
+
const SCAN_CHUNK_BYTES = 64 * 1024;
|
|
141
|
+
|
|
142
|
+
/** Offset of the first newline at or after `from` (or `size`), scanning. */
|
|
143
|
+
async function nextNewline(handle, from, size) {
|
|
144
|
+
const chunk = Buffer.alloc(SCAN_CHUNK_BYTES);
|
|
145
|
+
for (let at = from; at < size; at += SCAN_CHUNK_BYTES) {
|
|
146
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(SCAN_CHUNK_BYTES, size - at), at);
|
|
147
|
+
const hit = chunk.subarray(0, bytesRead).indexOf(0x0a);
|
|
148
|
+
if (hit >= 0) return at + hit;
|
|
149
|
+
}
|
|
150
|
+
return size;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Segment offsets of an archive file. Each header names its items' byte
|
|
154
|
+
* length, so the index reads only the headers and jumps over the bodies; a
|
|
155
|
+
* line whose header does not account for its length is scanned past. */
|
|
156
|
+
async function indexArchive(handle, size) {
|
|
157
|
+
const entries = [];
|
|
158
|
+
const probe = Buffer.alloc(HEAD_PROBE_BYTES);
|
|
159
|
+
let start = 0;
|
|
160
|
+
while (start < size) {
|
|
161
|
+
const { bytesRead } = await handle.read(probe, 0, Math.min(HEAD_PROBE_BYTES, size - start), start);
|
|
162
|
+
const text = probe.toString('latin1', 0, bytesRead);
|
|
163
|
+
const match = SEGMENT_HEAD.exec(text);
|
|
164
|
+
let end = -1;
|
|
165
|
+
if (match && text.startsWith(ITEMS_KEY, match[0].length)) {
|
|
166
|
+
// `…"items":<body>}` — the header is ASCII, so chars are bytes.
|
|
167
|
+
const candidate = start + match[0].length + ITEMS_KEY.length + Number(match[5]) + 1;
|
|
168
|
+
if (candidate === size) end = candidate;
|
|
169
|
+
else if (candidate < size) {
|
|
170
|
+
const tail = Buffer.alloc(1);
|
|
171
|
+
await handle.read(tail, 0, 1, candidate);
|
|
172
|
+
if (tail[0] === 0x0a) end = candidate;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (end >= 0) entries.push({ head: compactionSegmentHead(text), start, length: end - start });
|
|
176
|
+
else end = await nextNewline(handle, start, size);
|
|
177
|
+
start = end + 1;
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function archiveIndexFor(path, handle) {
|
|
183
|
+
const info = await handle.stat();
|
|
184
|
+
const stamp = `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
|
|
185
|
+
const cached = archiveIndexes.get(path);
|
|
186
|
+
if (cached?.stamp === stamp) {
|
|
187
|
+
archiveIndexes.delete(path);
|
|
188
|
+
archiveIndexes.set(path, cached);
|
|
189
|
+
return cached.entries;
|
|
190
|
+
}
|
|
191
|
+
const entries = await indexArchive(handle, info.size);
|
|
192
|
+
archiveIndexes.set(path, { stamp, entries });
|
|
193
|
+
while (archiveIndexes.size > INDEX_CACHE_LIMIT) archiveIndexes.delete(archiveIndexes.keys().next().value);
|
|
194
|
+
return entries;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One page of a session's archive sidecar (see pageFromSegments). The
|
|
198
|
+
* open handle pins the file a concurrent atomic replace may swap out, so
|
|
199
|
+
* the index and the segment reads describe the same bytes. */
|
|
200
|
+
export async function readCompactionArchivePage(storeDir, sessionId, range = {}) {
|
|
201
|
+
const path = compactionArchivePath(storeDir, sessionId);
|
|
202
|
+
if (!path) return { items: [], hasOlder: false };
|
|
203
|
+
let handle;
|
|
204
|
+
try {
|
|
205
|
+
handle = await open(path, 'r');
|
|
206
|
+
} catch {
|
|
207
|
+
// Display-only history: an absent or unreadable archive shows nothing.
|
|
208
|
+
archiveIndexes.delete(path);
|
|
209
|
+
return { items: [], hasOlder: false };
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const entries = await archiveIndexFor(path, handle);
|
|
213
|
+
return await pageFromSegments(
|
|
214
|
+
entries,
|
|
215
|
+
async (entry) => {
|
|
216
|
+
const buffer = Buffer.alloc(entry.length);
|
|
217
|
+
await handle.read(buffer, 0, entry.length, entry.start);
|
|
218
|
+
return segmentItems(buffer.toString('utf8'));
|
|
219
|
+
},
|
|
220
|
+
range
|
|
221
|
+
);
|
|
222
|
+
} catch {
|
|
223
|
+
return { items: [], hasOlder: false };
|
|
224
|
+
} finally {
|
|
225
|
+
await handle.close().catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Delete fence: every deletion bumps the session's epoch. A writer captures
|
|
230
|
+
// the epoch before it starts and publishes only if it is unchanged at the
|
|
231
|
+
// (synchronous) rename, so a delete that lands while a segment is being
|
|
232
|
+
// written is never undone by that write.
|
|
233
|
+
const deleteEpochs = new Map();
|
|
234
|
+
|
|
235
|
+
export function compactionArchiveDeleteEpoch(sessionId) {
|
|
236
|
+
return deleteEpochs.get(String(sessionId || '')) || 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Remove a session's archive (session delete) and fence every write that
|
|
240
|
+
* started before it. Best-effort: true when the sidecar is gone afterwards. */
|
|
241
|
+
export function deleteCompactionArchive(storeDir, sessionId) {
|
|
242
|
+
const path = compactionArchivePath(storeDir, sessionId);
|
|
243
|
+
if (!path) return false;
|
|
244
|
+
const id = String(sessionId);
|
|
245
|
+
deleteEpochs.set(id, compactionArchiveDeleteEpoch(id) + 1);
|
|
246
|
+
archiveIndexes.delete(path);
|
|
247
|
+
try {
|
|
248
|
+
unlinkSync(path);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return error?.code === 'ENOENT';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -34,7 +34,7 @@ export function resumeContinuation(
|
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
if (!segments.commitIntermediate(response)) return false;
|
|
37
|
-
if (hasContent) segments.record(response.content);
|
|
37
|
+
if (hasContent) segments.record(response.content, { committed: true });
|
|
38
38
|
state.providerContinuationCount += 1;
|
|
39
39
|
state.continuationsSinceToolBatch += 1;
|
|
40
40
|
state.emptyNudgeStreak = 0;
|
|
@@ -1,8 +1,2 @@
|
|
|
1
1
|
// stderr diagnostics for the no-tool recovery ladders; never throws.
|
|
2
|
-
export
|
|
3
|
-
try {
|
|
4
|
-
process.stderr.write(line);
|
|
5
|
-
} catch {
|
|
6
|
-
/* diagnostics only */
|
|
7
|
-
}
|
|
8
|
-
}
|
|
2
|
+
export { writeStderrBestEffort as writeLoopDiagnostic } from '../../../../../shared/best-effort-stderr.mjs';
|
|
@@ -15,7 +15,7 @@ export function resolveRefusal(response, hasContent, { state, segments, messages
|
|
|
15
15
|
// A provider may emit harmless narration before its safety classifier
|
|
16
16
|
// terminates the turn. Preserve that partial turn and its stop reason,
|
|
17
17
|
// but never mistake the non-empty text for a successful completion.
|
|
18
|
-
if (hasContent && segments.commitIntermediate(response)) segments.record(response.content);
|
|
18
|
+
if (hasContent && segments.commitIntermediate(response)) segments.record(response.content, { committed: true });
|
|
19
19
|
messages.push({
|
|
20
20
|
role: 'user',
|
|
21
21
|
content: REFUSAL_RECOVERY_PROMPT,
|
|
@@ -21,14 +21,15 @@ export function createTurnSegments({ messages, opts, suppressMidTurnText }) {
|
|
|
21
21
|
// live. A suppressed sub-agent session neither surfaces nor accumulates
|
|
22
22
|
// text — except on the max-output ladder, whose parts must still rebuild
|
|
23
23
|
// the complete answer the caller receives (keepWhenSuppressed).
|
|
24
|
-
|
|
24
|
+
// `committed: true` marks text that is the just-committed message's own.
|
|
25
|
+
record(text, { keepWhenSuppressed = false, committed = false } = {}) {
|
|
25
26
|
if (suppressMidTurnText) {
|
|
26
27
|
if (keepWhenSuppressed) parts.push(text);
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
parts.push(text);
|
|
30
31
|
try {
|
|
31
|
-
opts.onAssistantText?.(text);
|
|
32
|
+
opts.onAssistantText?.(text, { committed });
|
|
32
33
|
} catch {
|
|
33
34
|
/* best-effort */
|
|
34
35
|
}
|
|
@@ -108,7 +108,8 @@ export function createNoToolTurnResolver({
|
|
|
108
108
|
drainSteering('terminal', {
|
|
109
109
|
maxPriority: 'next',
|
|
110
110
|
beforeAppend: () => {
|
|
111
|
-
if (segments.commitIntermediate(response) && hasContent)
|
|
111
|
+
if (segments.commitIntermediate(response) && hasContent)
|
|
112
|
+
segments.record(response.content, { committed: true });
|
|
112
113
|
},
|
|
113
114
|
})
|
|
114
115
|
) {
|