From c722104d6f2f2339cddfcead3c4fcd2f84c2302e Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Sat, 21 Mar 2026 00:03:49 +0100 Subject: [PATCH] add compaction (#183) --- README.md | 4 +- bun.lock | 145 ++++++++++ package.json | 5 +- src/agent/agent.ts | 377 ++++++++++++++++---------- src/agent/compaction.test.ts | 134 ++++++++++ src/agent/compaction.ts | 476 +++++++++++++++++++++++++++++++++ src/storage/index.ts | 13 +- src/storage/migrations.ts | 33 ++- src/storage/transcript-view.ts | 45 ++++ src/storage/transcript.ts | 126 +++++++-- src/ui/app.tsx | 6 + 11 files changed, 1197 insertions(+), 167 deletions(-) create mode 100644 src/agent/compaction.test.ts create mode 100644 src/agent/compaction.ts create mode 100644 src/storage/transcript-view.ts diff --git a/README.md b/README.md index ada3d63..7ea7420 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ Treat the bot token like a password. --- -## MCP & project settings +## Project settings -Project file: **`.grok/settings.json`** — e.g. current model, **`mcpServers`** for stdio/http/sse MCP servers. Manage interactively with **`/mcps`** in the TUI. +Project file: **`.grok/settings.json`** — e.g. the current model for this project. --- diff --git a/bun.lock b/bun.lock index 1930686..96303e1 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "husky": "^9.1.7", "lint-staged": "^16.4.0", "typescript": "^5.9.3", + "vitest": "^4.1.0", }, }, }, @@ -62,6 +63,12 @@ "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], @@ -122,8 +129,12 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], @@ -142,18 +153,74 @@ "@opentui/react": ["@opentui/react@0.1.88", "", { "dependencies": { "@opentui/core": "0.1.88", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-EfEUBlGgCx9LqpbB852L+m7F19NcR8yeUww+C8NytIQeVKJbFIhSLu3r3wjBEGNoYiRk1DtAwWBpG5YaVvkXXw=="], + "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], + + "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], + + "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], + + "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -174,6 +241,8 @@ "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -202,6 +271,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], @@ -214,6 +285,8 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -228,6 +301,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -246,10 +321,14 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -264,6 +343,8 @@ "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], @@ -272,6 +353,8 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -280,6 +363,8 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], @@ -334,12 +419,38 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -358,6 +469,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -366,6 +479,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -388,8 +503,12 @@ "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], @@ -400,6 +519,8 @@ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -426,6 +547,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -456,16 +579,24 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="], "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], @@ -478,16 +609,24 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -500,6 +639,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="], + + "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -508,6 +651,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/package.json b/package.json index ec0cf9d..755cbcd 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "build": "tsc", "start": "bun run dist/index.js", "typecheck": "tsc --noEmit", + "test": "bunx vitest run", + "test:watch": "bunx vitest", "lint": "biome check src/", "format": "biome format src/", "format:fix": "biome format --write src/", @@ -60,7 +62,8 @@ "@types/react": "^19.2.14", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.0" }, "engines": { "node": ">=18.0.0" diff --git a/src/agent/agent.ts b/src/agent/agent.ts index c998597..956ba9b 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -1,13 +1,17 @@ import { type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai"; import { createProvider, generateTitle as genTitle, type XaiProvider } from "../grok/client"; +import { getModelInfo } from "../grok/models"; import { createTools } from "../grok/tools"; import { buildMcpToolSet } from "../mcp/runtime"; import { + appendCompaction, appendMessages, appendSystemMessage, buildChatEntries, + getNextMessageSequence, getSessionTotalTokens, loadTranscript, + loadTranscriptState, recordUsageEvent, SessionStore, } from "../storage/index"; @@ -29,6 +33,17 @@ import type { import { loadCustomInstructions } from "../utils/instructions"; import { loadMcpServers } from "../utils/settings"; import { discoverSkills, formatSkillsForPrompt } from "../utils/skills"; +import { + type CompactionSettings, + createCompactionSummaryMessage, + DEFAULT_KEEP_RECENT_TOKENS, + DEFAULT_RESERVE_TOKENS, + estimateConversationTokens, + generateCompactionSummary, + prepareCompaction, + relaxCompactionSettings, + shouldCompactContext, +} from "./compaction"; import { DelegationManager } from "./delegations"; const MAX_TOOL_ROUNDS = 400; @@ -198,7 +213,7 @@ export class Agent { private workspace: WorkspaceInfo | null = null; private session: SessionInfo | null = null; private messages: ModelMessage[] = []; - private recordedTokens = 0; + private messageSeqs: Array = []; private abortController: AbortController | null = null; private maxToolRounds: number; private mode: AgentMode = "agent"; @@ -230,8 +245,9 @@ export class Agent { this.workspace = this.sessionStore.getWorkspace(); this.session = this.sessionStore.openSession(options.session, this.modelId, this.mode, this.bash.getCwd()); this.mode = this.session.mode; - this.messages = loadTranscript(this.session.id); - this.recordedTokens = getSessionTotalTokens(this.session.id); + const transcript = loadTranscriptState(this.session.id); + this.messages = transcript.messages; + this.messageSeqs = transcript.seqs; this.sessionStore.setModel(this.session.id, this.modelId); } } @@ -291,8 +307,7 @@ export class Agent { ratioRemaining: number; } { const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); - const estimatedTokens = estimateTokens(`${system}\n${JSON.stringify(this.messages)}\n${inFlightText}`); - const usedTokens = Math.min(contextWindow, Math.max(estimatedTokens, this.recordedTokens)); + const usedTokens = Math.min(contextWindow, estimateConversationTokens(system, this.messages, inFlightText)); const remainingTokens = Math.max(0, contextWindow - usedTokens); return { @@ -331,7 +346,7 @@ export class Agent { startNewSession(): SessionSnapshot | null { if (!this.sessionStore) { this.messages = []; - this.recordedTokens = 0; + this.messageSeqs = []; return null; } @@ -339,7 +354,7 @@ export class Agent { this.workspace = this.sessionStore.getWorkspace(); this.session = this.sessionStore.createSession(this.modelId, this.mode, this.bash.getCwd()); this.messages = []; - this.recordedTokens = 0; + this.messageSeqs = []; return this.getSessionSnapshot(); } @@ -388,6 +403,7 @@ export class Agent { const idx = this.messages.lastIndexOf(userMessage); if (idx >= 0) { this.messages.splice(idx, 1); + this.messageSeqs.splice(idx, 1); } } @@ -397,10 +413,6 @@ export class Agent { model = this.modelId, ): void { if (!usage) return; - const total = usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); - if (Number.isFinite(total) && total > 0) { - this.recordedTokens += total; - } if (this.session) { recordUsageEvent(this.session.id, source, model, usage); } @@ -411,9 +423,11 @@ export class Agent { const notifications = await this.delegations.consumeNotifications(); for (const notification of notifications) { this.messages.push({ role: "system", content: notification.message }); + let seq: number | null = null; if (this.session) { - appendSystemMessage(this.session.id, notification.message); + seq = appendSystemMessage(this.session.id, notification.message); } + this.messageSeqs.push(seq); } return notifications.map((notification) => notification.message); } catch { @@ -600,6 +614,62 @@ export class Agent { } } + private getCompactionSettings(): CompactionSettings { + return { + reserveTokens: Math.max(this.maxTokens, DEFAULT_RESERVE_TOKENS), + keepRecentTokens: DEFAULT_KEEP_RECENT_TOKENS, + }; + } + + private async compactForContext( + provider: XaiProvider, + system: string, + contextWindow: number, + signal: AbortSignal, + settings = this.getCompactionSettings(), + force = false, + ): Promise { + if (!this.session) return false; + + const preparation = prepareCompaction(this.messages, system, settings); + if (!preparation) return false; + if (!force && !shouldCompactContext(preparation.tokensBefore, contextWindow, settings)) { + return false; + } + + const keptSeqs = this.messageSeqs.slice(preparation.firstKeptIndex); + const firstKeptSeq = keptSeqs.find((seq): seq is number => seq !== null) ?? getNextMessageSequence(this.session.id); + const summary = await generateCompactionSummary(provider, this.modelId, preparation, undefined, signal); + + appendCompaction(this.session.id, firstKeptSeq, summary, preparation.tokensBefore); + this.messages = [createCompactionSummaryMessage(summary), ...preparation.keptMessages]; + this.messageSeqs = [null, ...keptSeqs]; + return true; + } + + private appendCompletedTurn(userMessage: ModelMessage, newMessages: ModelMessage[]): void { + if (newMessages.length === 0) return; + + const userIndex = this.messages.lastIndexOf(userMessage); + if (!this.sessionStore || !this.session) { + if (userIndex >= 0 && this.messageSeqs[userIndex] == null) { + this.messageSeqs[userIndex] = null; + } + this.messages.push(...newMessages); + this.messageSeqs.push(...newMessages.map(() => null)); + return; + } + + const insertedSeqs = appendMessages(this.session.id, [userMessage, ...newMessages]); + if (userIndex >= 0) { + this.messageSeqs[userIndex] = insertedSeqs[0] ?? this.messageSeqs[userIndex]; + } + this.messages.push(...newMessages); + this.messageSeqs.push(...insertedSeqs.slice(1)); + this.sessionStore.touchSession(this.session.id, this.bash.getCwd()); + this.session = this.sessionStore.getRequiredSession(this.session.id); + } + async *processMessage(userMessage: string): AsyncGenerator { this.abortController = new AbortController(); const signal = this.abortController.signal; @@ -608,147 +678,171 @@ export class Agent { await this.consumeBackgroundNotifications(); const userModelMessage: ModelMessage = { role: "user", content: userMessage }; this.messages.push(userModelMessage); + this.messageSeqs.push(null); - let assistantText = ""; - let streamOk = false; - let closeMcp: (() => Promise) | undefined; + const provider = this.requireProvider(); + const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); + const modelInfo = getModelInfo(this.modelId); + this.planContext = null; + let attemptedOverflowRecovery = false; try { - const provider = this.requireProvider(); - const baseTools = createTools(this.bash, provider, this.mode, { - runTask: (request, abortSignal) => this.runTask(request, combineAbortSignals(signal, abortSignal)), - runDelegation: (request, abortSignal) => this.runDelegation(request, combineAbortSignals(signal, abortSignal)), - readDelegation: (id) => this.readDelegation(id), - listDelegations: () => this.listDelegations(), - }); - let tools: ToolSet = baseTools; - if (this.mode === "agent") { - const mcpBundle = await buildMcpToolSet(loadMcpServers()); - closeMcp = mcpBundle.close; - tools = { ...baseTools, ...mcpBundle.tools }; - if (mcpBundle.errors.length > 0) { - yield { type: "content", content: `MCP unavailable: ${mcpBundle.errors.join(" | ")}\n\n` }; - } - } - const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); - this.planContext = null; + while (true) { + let assistantText = ""; + let streamOk = false; + let closeMcp: (() => Promise) | undefined; - const result = streamText({ - model: provider(this.modelId), - system, - messages: this.messages, - tools, - stopWhen: stepCountIs(this.maxToolRounds), - maxRetries: 0, - abortSignal: signal, - temperature: 0.7, - maxOutputTokens: this.maxTokens, - onFinish: ({ totalUsage }) => { - this.recordUsage(totalUsage, "message"); - }, - }); - - for await (const part of result.fullStream) { - if (signal.aborted) { - yield { type: "content", content: "\n\n[Cancelled]" }; - break; - } - - switch (part.type) { - case "text-delta": - assistantText += part.text; - yield { type: "content", content: part.text }; - break; - - case "reasoning-delta": - yield { type: "reasoning", content: part.text }; - break; - - case "tool-call": { - const tc = toToolCall(part); - yield { type: "tool_calls", toolCalls: [tc] }; - break; + try { + const settings = attemptedOverflowRecovery + ? relaxCompactionSettings(this.getCompactionSettings()) + : this.getCompactionSettings(); + if (modelInfo) { + await this.compactForContext( + provider, + system, + modelInfo.contextWindow, + signal, + settings, + attemptedOverflowRecovery, + ); } - case "tool-result": { - const tc: ToolCall = { - id: part.toolCallId, - type: "function", - function: { name: part.toolName, arguments: JSON.stringify(part.input ?? {}) }, - }; - const tr = toToolResult(part.output); - yield { type: "tool_result", toolCall: tc, toolResult: tr }; - break; + const baseTools = createTools(this.bash, provider, this.mode, { + runTask: (request, abortSignal) => this.runTask(request, combineAbortSignals(signal, abortSignal)), + runDelegation: (request, abortSignal) => + this.runDelegation(request, combineAbortSignals(signal, abortSignal)), + readDelegation: (id) => this.readDelegation(id), + listDelegations: () => this.listDelegations(), + }); + let tools: ToolSet = baseTools; + if (this.mode === "agent") { + const mcpBundle = await buildMcpToolSet(loadMcpServers()); + closeMcp = mcpBundle.close; + tools = { ...baseTools, ...mcpBundle.tools }; + if (mcpBundle.errors.length > 0) { + yield { type: "content", content: `MCP unavailable: ${mcpBundle.errors.join(" | ")}\n\n` }; + } } - case "error": - yield { type: "error", content: String(part.error) }; - break; + const result = streamText({ + model: provider(this.modelId), + system, + messages: this.messages, + tools, + stopWhen: stepCountIs(this.maxToolRounds), + maxRetries: 0, + abortSignal: signal, + temperature: 0.7, + maxOutputTokens: this.maxTokens, + onFinish: ({ totalUsage }) => { + this.recordUsage(totalUsage, "message"); + }, + }); - case "abort": + for await (const part of result.fullStream) { + if (signal.aborted) { + yield { type: "content", content: "\n\n[Cancelled]" }; + break; + } + + switch (part.type) { + case "text-delta": + assistantText += part.text; + yield { type: "content", content: part.text }; + break; + + case "reasoning-delta": + yield { type: "reasoning", content: part.text }; + break; + + case "tool-call": { + const tc = toToolCall(part); + yield { type: "tool_calls", toolCalls: [tc] }; + break; + } + + case "tool-result": { + const tc: ToolCall = { + id: part.toolCallId, + type: "function", + function: { name: part.toolName, arguments: JSON.stringify(part.input ?? {}) }, + }; + const tr = toToolResult(part.output); + yield { type: "tool_result", toolCall: tc, toolResult: tr }; + break; + } + + case "error": + yield { type: "error", content: String(part.error) }; + break; + + case "abort": + yield { type: "content", content: "\n\n[Cancelled]" }; + break; + } + } + + if (signal.aborted) { + this.discardAbortedTurn(userModelMessage); + yield { type: "done" }; + return; + } + + try { + const response = await result.response; + if (!signal.aborted) { + this.appendCompletedTurn(userModelMessage, response.messages); + streamOk = true; + } + } catch (responseError: unknown) { + if ( + !attemptedOverflowRecovery && + !assistantText.trim() && + modelInfo && + isContextLimitError(responseError) + ) { + attemptedOverflowRecovery = true; + continue; + } + } + + if (signal.aborted) { + this.discardAbortedTurn(userModelMessage); + yield { type: "done" }; + return; + } + + if (!streamOk && assistantText.trim()) { + this.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: assistantText }]); + } + + yield { type: "done" }; + return; + } catch (err: unknown) { + if (signal.aborted) { + this.discardAbortedTurn(userModelMessage); yield { type: "content", content: "\n\n[Cancelled]" }; - break; - } - } - - if (signal.aborted) { - this.discardAbortedTurn(userModelMessage); - yield { type: "done" }; - return; - } - - try { - const response = await result.response; - if (!signal.aborted) { - this.messages.push(...response.messages); - if (this.sessionStore && this.session) { - appendMessages(this.session.id, [userModelMessage, ...response.messages]); - this.sessionStore.touchSession(this.session.id, this.bash.getCwd()); - this.session = this.sessionStore.getRequiredSession(this.session.id); + yield { type: "done" }; + return; } - streamOk = true; - } - } catch { - // response promise can fail after stream errors — fall back to manual message - } - if (signal.aborted) { - this.discardAbortedTurn(userModelMessage); - yield { type: "done" }; - return; - } + if (!attemptedOverflowRecovery && !assistantText.trim() && modelInfo && isContextLimitError(err)) { + attemptedOverflowRecovery = true; + continue; + } - if (!streamOk && assistantText.trim()) { - const fallbackMessage: ModelMessage = { role: "assistant", content: assistantText }; - this.messages.push(fallbackMessage); - if (this.sessionStore && this.session) { - appendMessages(this.session.id, [userModelMessage, fallbackMessage]); - this.sessionStore.touchSession(this.session.id, this.bash.getCwd()); - this.session = this.sessionStore.getRequiredSession(this.session.id); + const msg = err instanceof Error ? err.message : String(err); + yield { type: "error", content: `Error: ${msg}` }; + if (assistantText.trim()) { + this.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: assistantText }]); + } + yield { type: "done" }; + return; + } finally { + await closeMcp?.().catch(() => {}); } } - - yield { type: "done" }; - } catch (err: unknown) { - if (signal.aborted) { - this.discardAbortedTurn(userModelMessage); - yield { type: "content", content: "\n\n[Cancelled]" }; - } else { - const msg = err instanceof Error ? err.message : String(err); - yield { type: "error", content: `Error: ${msg}` }; - } - if (!signal.aborted && assistantText.trim()) { - const fallbackMessage: ModelMessage = { role: "assistant", content: assistantText }; - this.messages.push(fallbackMessage); - if (this.sessionStore && this.session) { - appendMessages(this.session.id, [userModelMessage, fallbackMessage]); - this.sessionStore.touchSession(this.session.id, this.bash.getCwd()); - this.session = this.sessionStore.getRequiredSession(this.session.id); - } - } - yield { type: "done" }; } finally { - await closeMcp?.().catch(() => {}); if (this.abortController?.signal === signal) { this.abortController = null; } @@ -828,10 +922,6 @@ function truncate(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max - 1)}…`; } -function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - function combineAbortSignals(...signals: Array): AbortSignal | undefined { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); if (activeSignals.length === 0) return undefined; @@ -853,3 +943,8 @@ function combineAbortSignals(...signals: Array): AbortS return controller.signal; } + +function isContextLimitError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /(context|token|prompt).*(limit|length|large|window|overflow)|too many tokens|maximum context/i.test(message); +} diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts new file mode 100644 index 0000000..7278665 --- /dev/null +++ b/src/agent/compaction.test.ts @@ -0,0 +1,134 @@ +import type { ModelMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { buildEffectiveTranscript, type PersistedCompaction } from "../storage/transcript-view"; +import { + COMPACTION_SUMMARY_HEADER, + createCompactionSummaryMessage, + findCutPoint, + prepareCompaction, + serializeConversation, + shouldCompactContext, +} from "./compaction"; + +function user(text: string): ModelMessage { + return { role: "user", content: text } as ModelMessage; +} + +function assistantText(text: string): ModelMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + } as ModelMessage; +} + +function assistantToolCall(toolCallId: string, toolName: string, input: Record = {}): ModelMessage { + return { + role: "assistant", + content: [{ type: "tool-call", toolCallId, toolName, input }], + } as ModelMessage; +} + +function toolResult(toolCallId: string, toolName: string, output: unknown): ModelMessage { + return { + role: "tool", + content: [{ type: "tool-result", toolCallId, toolName, output }], + } as ModelMessage; +} + +describe("compaction helpers", () => { + it("triggers when context exceeds reserved headroom", () => { + const settings = { reserveTokens: 100, keepRecentTokens: 40 }; + + expect(shouldCompactContext(901, 1000, settings)).toBe(true); + expect(shouldCompactContext(900, 1000, settings)).toBe(false); + }); + + it("never selects a tool-result message as the cut point", () => { + const messages = [ + user("inspect this file"), + assistantToolCall("call-1", "read_file", { path: "src/index.ts" }), + toolResult("call-1", "read_file", "x".repeat(400)), + assistantText("I found the relevant section."), + user("continue"), + ]; + + const cutPoint = findCutPoint(messages, 0, 130); + + expect(cutPoint.firstKeptIndex).not.toBe(2); + expect(messages[cutPoint.firstKeptIndex]?.role).not.toBe("tool"); + }); + + it("detects split turns and captures the turn prefix for summarization", () => { + const messages = [ + user("Refactor the session loader."), + assistantText("a".repeat(320)), + assistantText("recent status update"), + ]; + + const preparation = prepareCompaction(messages, "system prompt", { + reserveTokens: 100, + keepRecentTokens: 5, + }); + + expect(preparation).not.toBeNull(); + expect(preparation?.isSplitTurn).toBe(true); + expect(preparation?.messagesToSummarize).toHaveLength(0); + expect(preparation?.turnPrefixMessages).toHaveLength(2); + expect(preparation?.keptMessages).toHaveLength(1); + }); + + it("excludes the previous summary message from new compaction input", () => { + const messages = [ + createCompactionSummaryMessage("Earlier work"), + user("Handle migration edge cases"), + assistantText("I added the table and loader changes."), + user("Now wire the retry path"), + ]; + + const preparation = prepareCompaction(messages, "system prompt", { + reserveTokens: 100, + keepRecentTokens: 4, + }); + + expect(preparation).not.toBeNull(); + expect(preparation?.previousSummary).toBe("Earlier work"); + expect(preparation?.messagesToSummarize[0]).toEqual(user("Handle migration edge cases")); + expect(preparation?.messagesToSummarize.some((message) => message.role === "system")).toBe(false); + }); + + it("serializes tool results with truncation markers", () => { + const transcript = serializeConversation([user("Read the output"), toolResult("call-1", "bash", "x".repeat(2105))]); + + expect(transcript).toContain("[Tool result]:"); + expect(transcript).toContain("more characters truncated"); + }); + + it("builds the effective transcript from the latest persisted checkpoint", () => { + const rawMessages = [ + user("old request"), + assistantText("old answer"), + user("new request"), + assistantText("new answer"), + user("latest request"), + ]; + const seqs = [1, 2, 3, 4, 5]; + const timestamps = seqs.map((seq) => new Date(`2026-03-20T00:00:0${seq}.000Z`)); + const checkpoint: PersistedCompaction = { + firstKeptSeq: 4, + summary: "Summarized old work", + tokensBefore: 1234, + createdAt: new Date("2026-03-20T00:00:10.000Z"), + }; + + const transcript = buildEffectiveTranscript(rawMessages, seqs, timestamps, checkpoint); + + expect(transcript.messages).toHaveLength(3); + expect(transcript.seqs).toEqual([null, 4, 5]); + expect(transcript.messages[0]).toEqual(createCompactionSummaryMessage("Summarized old work")); + expect( + transcript.messages[0]?.role === "system" && typeof transcript.messages[0].content === "string" + ? transcript.messages[0].content + : "", + ).toContain(COMPACTION_SUMMARY_HEADER); + }); +}); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts new file mode 100644 index 0000000..4f6f7f1 --- /dev/null +++ b/src/agent/compaction.ts @@ -0,0 +1,476 @@ +import { generateText, type ModelMessage } from "ai"; +import type { XaiProvider } from "../grok/client"; + +export interface CompactionSettings { + reserveTokens: number; + keepRecentTokens: number; +} + +export interface CutPointResult { + firstKeptIndex: number; + turnStartIndex: number; + isSplitTurn: boolean; +} + +export interface PreparedCompaction { + previousSummary?: string; + messagesToSummarize: ModelMessage[]; + turnPrefixMessages: ModelMessage[]; + keptMessages: ModelMessage[]; + firstKeptIndex: number; + isSplitTurn: boolean; + tokensBefore: number; + settings: CompactionSettings; +} + +const TOOL_RESULT_MAX_CHARS = 2000; +const MIN_KEPT_TOKENS_ON_RETRY = 4000; + +export const DEFAULT_RESERVE_TOKENS = 16_384; +export const DEFAULT_KEEP_RECENT_TOKENS = 20_000; +export const COMPACTION_SUMMARY_HEADER = "[Context checkpoint summary]"; + +const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. + +Do not continue the conversation. Do not answer any questions from the conversation. +Only output a structured checkpoint summary that another coding agent can use to continue the work.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this exact format: + +## Goal +[What the user is trying to accomplish] + +## Constraints & Preferences +- [Requirements, preferences, or constraints] +- [(none) if none were mentioned] + +## Progress +### Done +- [x] [Completed work] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Any active blockers] + +## Key Decisions +- **[Decision]**: [Rationale] + +## Next Steps +1. [The next action to take] + +## Critical Context +- [Important details needed to continue] +- [(none) if not applicable] + +Keep it concise, but preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are new conversation messages to incorporate into the existing summary provided below. + +Update the existing structured summary with new information. Rules: +- Preserve still-relevant information from the previous summary +- Add new progress, decisions, and critical context +- Move completed items from "In Progress" to "Done" when appropriate +- Update "Next Steps" based on the current state +- Preserve exact file paths, function names, and error messages + +Use the exact same section structure as the existing summary format.`; + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the early prefix of a single turn that was too large to keep in full. The recent suffix is still available. + +Summarize only what is needed so another coding agent can understand the retained suffix. + +Use this exact format: + +## Original Request +[What the user asked for in this turn] + +## Early Progress +- [Key work done before the kept suffix] + +## Context For Suffix +- [Information needed to understand the kept recent messages]`; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getTextParts(content: unknown): string[] { + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) continue; + if (part.type === "text" && typeof part.text === "string") { + parts.push(part.text); + continue; + } + if (part.type === "reasoning" && typeof part.text === "string") { + parts.push(part.text); + continue; + } + if (part.type === "reasoning" && typeof part.reasoning === "string") { + parts.push(part.reasoning); + } + } + return parts; +} + +function stringifyForSummary(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function truncateForSummary(text: string, maxChars = TOOL_RESULT_MAX_CHARS): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n\n[... ${text.length - maxChars} more characters truncated]`; +} + +function extractUserContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) continue; + if (part.type === "text" && typeof part.text === "string") { + parts.push(part.text); + continue; + } + if (part.type === "image") { + parts.push("[Image]"); + continue; + } + if (part.type === "file") { + const filename = typeof part.filename === "string" ? part.filename : null; + parts.push(filename ? `[File: ${filename}]` : "[File]"); + } + } + return parts.join("\n"); +} + +function extractAssistantText(content: unknown): string { + return getTextParts(content).join("\n"); +} + +function extractToolCallText(content: unknown): string[] { + if (!Array.isArray(content)) return []; + + const toolCalls: string[] = []; + for (const part of content) { + if (!isRecord(part) || part.type !== "tool-call") continue; + const toolName = typeof part.toolName === "string" ? part.toolName : "tool"; + const input = isRecord(part.input) ? part.input : {}; + const args = Object.entries(input) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", "); + toolCalls.push(`${toolName}(${args})`); + } + return toolCalls; +} + +function extractToolResultText(content: unknown): string[] { + if (!Array.isArray(content)) return []; + + const toolResults: string[] = []; + for (const part of content) { + if (!isRecord(part) || part.type !== "tool-result") continue; + toolResults.push(truncateForSummary(stringifyForSummary(part.output))); + } + return toolResults; +} + +export function createCompactionSummaryMessage(summary: string): ModelMessage { + return { + role: "system", + content: `${COMPACTION_SUMMARY_HEADER}\n${summary.trim()}`, + }; +} + +export function isCompactionSummaryMessage(message: ModelMessage | undefined): boolean { + return message?.role === "system" && typeof message.content === "string" + ? message.content.startsWith(COMPACTION_SUMMARY_HEADER) + : false; +} + +export function getCompactionSummaryText(message: ModelMessage | undefined): string | null { + if (!isCompactionSummaryMessage(message) || typeof message?.content !== "string") { + return null; + } + return message.content.slice(COMPACTION_SUMMARY_HEADER.length).trim(); +} + +export function estimateMessageTokens(message: ModelMessage): number { + let chars = 0; + + switch (message.role) { + case "user": + chars += extractUserContent(message.content).length; + break; + case "assistant": + chars += extractAssistantText(message.content).length; + chars += extractToolCallText(message.content).join("; ").length; + break; + case "tool": + chars += extractToolResultText(message.content).join("\n").length; + break; + case "system": + chars += + typeof message.content === "string" ? message.content.length : getTextParts(message.content).join("\n").length; + break; + default: + chars += stringifyForSummary((message as { content?: unknown }).content).length; + break; + } + + return Math.ceil(chars / 4); +} + +export function estimateConversationTokens(systemPrompt: string, messages: ModelMessage[], inFlightText = ""): number { + const systemTokens = Math.ceil((systemPrompt.length + inFlightText.length) / 4); + return systemTokens + messages.reduce((sum, message) => sum + estimateMessageTokens(message), 0); +} + +export function shouldCompactContext( + contextTokens: number, + contextWindow: number, + settings: CompactionSettings, +): boolean { + return contextTokens > contextWindow - settings.reserveTokens; +} + +function isValidCutPoint(message: ModelMessage): boolean { + return message.role !== "tool"; +} + +function findTurnStartIndex(messages: ModelMessage[], entryIndex: number, startIndex: number): number { + for (let i = entryIndex; i >= startIndex; i--) { + if (messages[i]?.role === "user") { + return i; + } + } + return -1; +} + +export function findCutPoint(messages: ModelMessage[], startIndex: number, keepRecentTokens: number): CutPointResult { + const cutPoints: number[] = []; + for (let i = startIndex; i < messages.length; i++) { + if (isValidCutPoint(messages[i])) { + cutPoints.push(i); + } + } + + if (cutPoints.length === 0) { + return { firstKeptIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; + + for (let i = messages.length - 1; i >= startIndex; i--) { + accumulatedTokens += estimateMessageTokens(messages[i]); + if (accumulatedTokens >= keepRecentTokens) { + cutIndex = cutPoints.find((index) => index >= i) ?? cutPoints[cutPoints.length - 1]; + break; + } + } + + const cutMessage = messages[cutIndex]; + const isUserMessage = cutMessage?.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(messages, cutIndex, startIndex); + + return { + firstKeptIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +export function prepareCompaction( + messages: ModelMessage[], + systemPrompt: string, + settings: CompactionSettings, +): PreparedCompaction | null { + const previousSummary = getCompactionSummaryText(messages[0]) ?? undefined; + const boundaryStart = previousSummary ? 1 : 0; + if (boundaryStart >= messages.length) { + return null; + } + + const cutPoint = findCutPoint(messages, boundaryStart, settings.keepRecentTokens); + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptIndex; + const messagesToSummarize = messages.slice(boundaryStart, Math.max(boundaryStart, historyEnd)); + const turnPrefixMessages = cutPoint.isSplitTurn + ? messages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex) + : []; + const keptMessages = messages.slice(cutPoint.firstKeptIndex); + const tokensBefore = estimateConversationTokens(systemPrompt, messages); + + if (keptMessages.length === 0) { + return null; + } + + if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { + return null; + } + + return { + previousSummary, + messagesToSummarize, + turnPrefixMessages, + keptMessages, + firstKeptIndex: cutPoint.firstKeptIndex, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + settings, + }; +} + +export function relaxCompactionSettings(settings: CompactionSettings): CompactionSettings { + return { + ...settings, + keepRecentTokens: Math.max(MIN_KEPT_TOKENS_ON_RETRY, Math.floor(settings.keepRecentTokens / 2)), + }; +} + +export function serializeConversation(messages: ModelMessage[]): string { + const parts: string[] = []; + + for (const message of messages) { + if (isCompactionSummaryMessage(message)) { + const summary = getCompactionSummaryText(message); + if (summary) { + parts.push(`[Previous summary]: ${summary}`); + } + continue; + } + + if (message.role === "user") { + const content = extractUserContent(message.content).trim(); + if (content) parts.push(`[User]: ${content}`); + continue; + } + + if (message.role === "assistant") { + const text = extractAssistantText(message.content).trim(); + const toolCalls = extractToolCallText(message.content); + if (text) parts.push(`[Assistant]: ${text}`); + if (toolCalls.length > 0) parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + continue; + } + + if (message.role === "tool") { + const results = extractToolResultText(message.content); + for (const result of results) { + if (result.trim()) parts.push(`[Tool result]: ${result}`); + } + continue; + } + + if (message.role === "system") { + const content = + typeof message.content === "string" ? message.content.trim() : getTextParts(message.content).join("\n").trim(); + if (content) parts.push(`[System]: ${content}`); + } + } + + return parts.join("\n\n"); +} + +async function summarizeConversation( + provider: XaiProvider, + modelId: string, + messages: ModelMessage[], + reserveTokens: number, + customInstructions?: string, + previousSummary?: string, + promptOverride?: string, + signal?: AbortSignal, +): Promise { + const serialized = serializeConversation(messages); + const promptParts = [serialized]; + + if (previousSummary) { + promptParts.push(`Existing summary:\n${previousSummary}`); + } + + const basePrompt = promptOverride ?? (previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT); + promptParts.push(basePrompt); + + if (customInstructions?.trim()) { + promptParts.push(`Additional focus: ${customInstructions.trim()}`); + } + + const { text } = await generateText({ + model: provider(modelId), + system: SUMMARIZATION_SYSTEM_PROMPT, + prompt: promptParts.filter(Boolean).join("\n\n"), + abortSignal: signal, + maxRetries: 0, + temperature: 0.2, + maxOutputTokens: Math.max(512, Math.floor(reserveTokens * 0.8)), + }); + + return text.trim(); +} + +export async function generateCompactionSummary( + provider: XaiProvider, + modelId: string, + preparation: PreparedCompaction, + customInstructions?: string, + signal?: AbortSignal, +): Promise { + const { messagesToSummarize, turnPrefixMessages, isSplitTurn, previousSummary, settings } = preparation; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const [historySummary, prefixSummary] = await Promise.all([ + messagesToSummarize.length > 0 + ? summarizeConversation( + provider, + modelId, + messagesToSummarize, + settings.reserveTokens, + customInstructions, + previousSummary, + undefined, + signal, + ) + : Promise.resolve(previousSummary?.trim() || ""), + summarizeConversation( + provider, + modelId, + turnPrefixMessages, + settings.reserveTokens, + undefined, + undefined, + TURN_PREFIX_SUMMARIZATION_PROMPT, + signal, + ), + ]); + + if (historySummary && prefixSummary) { + return `${historySummary}\n\n---\n\n${prefixSummary}`; + } + return (historySummary || prefixSummary).trim(); + } + + return summarizeConversation( + provider, + modelId, + messagesToSummarize, + settings.reserveTokens, + customInstructions, + previousSummary, + undefined, + signal, + ); +} diff --git a/src/storage/index.ts b/src/storage/index.ts index 6e8ebb8..a517523 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,4 +1,15 @@ export { getDatabasePath } from "./db"; export { SessionStore } from "./sessions"; -export { appendMessages, appendSystemMessage, buildChatEntries, loadTranscript } from "./transcript"; +export { + appendCompaction, + appendMessages, + appendSystemMessage, + buildChatEntries, + getNextMessageSequence, + loadLatestCompaction, + loadRawTranscript, + loadTranscript, + loadTranscriptState, +} from "./transcript"; +export { buildEffectiveTranscript, type LoadedTranscriptState, type PersistedCompaction } from "./transcript-view"; export { getSessionTotalTokens, listSessionUsage, recordUsageEvent, type TokenUsageLike } from "./usage"; diff --git a/src/storage/migrations.ts b/src/storage/migrations.ts index 020de70..976dbd0 100644 --- a/src/storage/migrations.ts +++ b/src/storage/migrations.ts @@ -1,6 +1,6 @@ import type { SQLiteDatabase } from "./db"; -const LATEST_DB_VERSION = 1; +const LATEST_DB_VERSION = 2; export function applyMigrations(db: SQLiteDatabase): void { const version = Number(db.pragma("user_version", { simple: true })) || 0; @@ -11,6 +11,10 @@ export function applyMigrations(db: SQLiteDatabase): void { createInitialSchema(db); db.pragma("user_version = 1"); } + if (version < 2) { + createCompactionSchema(db); + db.pragma("user_version = 2"); + } }); migrate(); @@ -84,6 +88,15 @@ function createInitialSchema(db: SQLiteDatabase): void { created_at TEXT NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS compactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + first_kept_seq INTEGER NOT NULL, + summary TEXT NOT NULL, + tokens_before INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS idx_sessions_workspace_updated ON sessions(workspace_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_session_seq @@ -92,5 +105,23 @@ function createInitialSchema(db: SQLiteDatabase): void { ON tool_calls(session_id, message_seq); CREATE INDEX IF NOT EXISTS idx_usage_events_session_created ON usage_events(session_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_compactions_session_created + ON compactions(session_id, created_at DESC); + `); +} + +function createCompactionSchema(db: SQLiteDatabase): void { + db.exec(` + CREATE TABLE IF NOT EXISTS compactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + first_kept_seq INTEGER NOT NULL, + summary TEXT NOT NULL, + tokens_before INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) STRICT; + + CREATE INDEX IF NOT EXISTS idx_compactions_session_created + ON compactions(session_id, created_at DESC); `); } diff --git a/src/storage/transcript-view.ts b/src/storage/transcript-view.ts new file mode 100644 index 0000000..7e83410 --- /dev/null +++ b/src/storage/transcript-view.ts @@ -0,0 +1,45 @@ +import type { ModelMessage } from "ai"; +import { createCompactionSummaryMessage } from "../agent/compaction"; + +export interface PersistedCompaction { + firstKeptSeq: number; + summary: string; + tokensBefore: number; + createdAt: Date; +} + +export interface LoadedTranscriptState { + messages: ModelMessage[]; + seqs: Array; + timestamps: Date[]; + compaction: PersistedCompaction | null; +} + +export function buildEffectiveTranscript( + messages: ModelMessage[], + seqs: number[], + timestamps: Date[], + compaction: PersistedCompaction | null, +): LoadedTranscriptState { + if (!compaction) { + return { + messages: [...messages], + seqs: [...seqs], + timestamps: [...timestamps], + compaction: null, + }; + } + + const firstKeptIndex = seqs.findIndex((seq) => seq >= compaction.firstKeptSeq); + const keptIndex = firstKeptIndex >= 0 ? firstKeptIndex : messages.length; + const keptMessages = messages.slice(keptIndex); + const keptSeqs = seqs.slice(keptIndex); + const keptTimestamps = timestamps.slice(keptIndex); + + return { + messages: [createCompactionSummaryMessage(compaction.summary), ...keptMessages], + seqs: [null, ...keptSeqs], + timestamps: [compaction.createdAt, ...keptTimestamps], + compaction, + }; +} diff --git a/src/storage/transcript.ts b/src/storage/transcript.ts index 3d8b35b..9d366f8 100644 --- a/src/storage/transcript.ts +++ b/src/storage/transcript.ts @@ -1,6 +1,8 @@ import type { ModelMessage } from "ai"; +import { getCompactionSummaryText } from "../agent/compaction"; import type { ChatEntry, ToolCall, ToolResult } from "../types/index"; import { getDatabase, withTransaction } from "./db"; +import { buildEffectiveTranscript, type LoadedTranscriptState, type PersistedCompaction } from "./transcript-view"; interface MessageRow { session_id: string; @@ -22,8 +24,22 @@ interface StoredToolResultRow { output_json: string; } -export function loadTranscript(sessionId: string): ModelMessage[] { - const rows = getDatabase() +interface CompactionRow { + session_id: string; + first_kept_seq: number; + summary: string; + tokens_before: number; + created_at: string; +} + +interface EffectiveMessageRecord { + message: ModelMessage; + seq: number | null; + timestamp: Date; +} + +function loadMessageRows(sessionId: string): MessageRow[] { + return getDatabase() .prepare(` SELECT session_id, seq, role, message_json, created_at FROM messages @@ -31,13 +47,72 @@ export function loadTranscript(sessionId: string): ModelMessage[] { ORDER BY seq ASC `) .all(sessionId) as MessageRow[]; - - return rows.map((row) => JSON.parse(row.message_json) as ModelMessage); } -export function appendMessages(sessionId: string, messages: ModelMessage[]): void { - if (messages.length === 0) return; +function toPersistedCompaction(row: CompactionRow | undefined): PersistedCompaction | null { + if (!row) return null; + return { + firstKeptSeq: row.first_kept_seq, + summary: row.summary, + tokensBefore: row.tokens_before, + createdAt: new Date(row.created_at), + }; +} +export function loadLatestCompaction(sessionId: string): PersistedCompaction | null { + const row = getDatabase() + .prepare(` + SELECT session_id, first_kept_seq, summary, tokens_before, created_at + FROM compactions + WHERE session_id = ? + ORDER BY id DESC + LIMIT 1 + `) + .get(sessionId) as CompactionRow | undefined; + + return toPersistedCompaction(row); +} + +function buildEffectiveMessageRecords(sessionId: string): EffectiveMessageRecord[] { + const rows = loadMessageRows(sessionId); + const messages = rows.map((row) => JSON.parse(row.message_json) as ModelMessage); + const seqs = rows.map((row) => row.seq); + const timestamps = rows.map((row) => new Date(row.created_at)); + const transcript = buildEffectiveTranscript(messages, seqs, timestamps, loadLatestCompaction(sessionId)); + + return transcript.messages.map((message, index) => ({ + message, + seq: transcript.seqs[index], + timestamp: transcript.timestamps[index], + })); +} + +export function loadRawTranscript(sessionId: string): ModelMessage[] { + return loadMessageRows(sessionId).map((row) => JSON.parse(row.message_json) as ModelMessage); +} + +export function loadTranscriptState(sessionId: string): LoadedTranscriptState { + const rows = loadMessageRows(sessionId); + return buildEffectiveTranscript( + rows.map((row) => JSON.parse(row.message_json) as ModelMessage), + rows.map((row) => row.seq), + rows.map((row) => new Date(row.created_at)), + loadLatestCompaction(sessionId), + ); +} + +export function loadTranscript(sessionId: string): ModelMessage[] { + return loadTranscriptState(sessionId).messages; +} + +export function getNextMessageSequence(sessionId: string): number { + return getNextSequence(getDatabase(), sessionId); +} + +export function appendMessages(sessionId: string, messages: ModelMessage[]): number[] { + if (messages.length === 0) return []; + + const insertedSeqs: number[] = []; withTransaction((db) => { const nextSeq = getNextSequence(db, sessionId); const insertMessage = db.prepare(` @@ -72,6 +147,7 @@ export function appendMessages(sessionId: string, messages: ModelMessage[]): voi messages.forEach((message, index) => { const seq = nextSeq + index; const createdAt = new Date().toISOString(); + insertedSeqs.push(seq); insertMessage.run(sessionId, seq, message.role, JSON.stringify(message), createdAt); if (message.role === "assistant" && Array.isArray(message.content)) { @@ -122,29 +198,36 @@ export function appendMessages(sessionId: string, messages: ModelMessage[]): voi updateSession.run(new Date().toISOString(), sessionId); }); + + return insertedSeqs; } -export function appendSystemMessage(sessionId: string, content: string): void { - appendMessages(sessionId, [{ role: "system", content }]); +export function appendSystemMessage(sessionId: string, content: string): number | null { + return appendMessages(sessionId, [{ role: "system", content }])[0] ?? null; +} + +export function appendCompaction(sessionId: string, firstKeptSeq: number, summary: string, tokensBefore: number): void { + withTransaction((db) => { + db.prepare(` + INSERT INTO compactions (session_id, first_kept_seq, summary, tokens_before, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(sessionId, firstKeptSeq, summary, tokensBefore, new Date().toISOString()); + + db.prepare(` + UPDATE sessions + SET updated_at = ? + WHERE id = ? + `).run(new Date().toISOString(), sessionId); + }); } export function buildChatEntries(sessionId: string): ChatEntry[] { - const db = getDatabase(); - const messageRows = db - .prepare(` - SELECT session_id, seq, role, message_json, created_at - FROM messages - WHERE session_id = ? - ORDER BY seq ASC - `) - .all(sessionId) as MessageRow[]; const toolResults = loadStoredToolResults(sessionId); const callMap = new Map(); const entries: ChatEntry[] = []; - for (const row of messageRows) { - const message = JSON.parse(row.message_json) as ModelMessage; - const timestamp = new Date(row.created_at); + for (const row of buildEffectiveMessageRecords(sessionId)) { + const { message, timestamp } = row; if (message.role === "user") { const content = renderUserContent(message.content); @@ -155,7 +238,8 @@ export function buildChatEntries(sessionId: string): ChatEntry[] { } if (message.role === "system") { - const content = typeof message.content === "string" ? message.content.trim() : ""; + const content = + getCompactionSummaryText(message) ?? (typeof message.content === "string" ? message.content.trim() : ""); if (content) { entries.push({ type: "assistant", content, timestamp }); } diff --git a/src/ui/app.tsx b/src/ui/app.tsx index b781047..8fb85d2 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -1177,6 +1177,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) ]); } + if (!isStale() && agent.getSessionId()) { + setMessages(agent.getChatEntries()); + setSessionTitle(agent.getSessionTitle()); + setSessionId(agent.getSessionId()); + } + contentAccRef.current = ""; if (!isStale()) { setStreamContent("");