From b1ed9217390bc63d777e49f7043b2d86fce128ae Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:06:27 -0400 Subject: [PATCH 1/9] deps: update dependencies --- package.json | 12 ++-- pnpm-lock.yaml | 180 ++++++++++++++++++++++++++----------------------- 2 files changed, 100 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index f13ed70..8b206ff 100644 --- a/package.json +++ b/package.json @@ -49,14 +49,14 @@ "README.md" ], "dependencies": { - "typebox": "^1.3.4" + "typebox": "^1.3.6" }, "devDependencies": { - "@biomejs/biome": "^2.5.2", - "@earendil-works/pi-agent-core": "^0.80.3", - "@earendil-works/pi-coding-agent": "^0.80.3", - "@types/node": "^26.1.0", - "tsx": "^4.23.0", + "@biomejs/biome": "^2.5.6", + "@earendil-works/pi-agent-core": "^0.83.0", + "@earendil-works/pi-coding-agent": "^0.83.0", + "@types/node": "^26.1.1", + "tsx": "^4.23.1", "typescript": "^6.0.3", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aade59f..9286765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,30 +9,30 @@ importers: .: dependencies: typebox: - specifier: ^1.3.4 - version: 1.3.4 + specifier: ^1.3.6 + version: 1.3.9 devDependencies: '@biomejs/biome': - specifier: ^2.5.2 - version: 2.5.2 + specifier: ^2.5.6 + version: 2.5.6 '@earendil-works/pi-agent-core': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.83.0 + version: 0.83.0(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.83.0 + version: 0.83.0(ws@8.21.0)(zod@4.4.3) '@types/node': - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^26.1.1 + version: 26.1.2 tsx: - specifier: ^4.23.0 - version: 4.23.0 + specifier: ^4.23.1 + version: 4.23.1 typescript: specifier: ^6.0.3 version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -146,79 +146,79 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.2': - resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} + '@biomejs/biome@2.5.6': + resolution: {integrity: sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.2': - resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} + '@biomejs/cli-darwin-arm64@2.5.6': + resolution: {integrity: sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.2': - resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} + '@biomejs/cli-darwin-x64@2.5.6': + resolution: {integrity: sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.2': - resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} + '@biomejs/cli-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.2': - resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} + '@biomejs/cli-linux-arm64@2.5.6': + resolution: {integrity: sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.2': - resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} + '@biomejs/cli-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.2': - resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} + '@biomejs/cli-linux-x64@2.5.6': + resolution: {integrity: sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.2': - resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} + '@biomejs/cli-win32-arm64@2.5.6': + resolution: {integrity: sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.2': - resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} + '@biomejs/cli-win32-x64@2.5.6': + resolution: {integrity: sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + '@earendil-works/pi-agent-core@0.83.0': + resolution: {integrity: sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + '@earendil-works/pi-ai@0.83.0': + resolution: {integrity: sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-coding-agent@0.83.0': + resolution: {integrity: sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.83.0': + resolution: {integrity: sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==} engines: {node: '>=22.19.0'} '@emnapi/core@1.11.1': @@ -677,6 +677,9 @@ packages: '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -1114,16 +1117,16 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.0: - resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} engines: {node: '>=18.0.0'} hasBin: true - typebox@1.1.38: - resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typebox@1.3.7: + resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} - typebox@1.3.4: - resolution: {integrity: sha512-j3MCKfJMIHdrr4ZQMzaJnvXrUbMNuncwssmQkny3Cv5vxrDE1o1WhZFEqGSlU884xtYS5sAa3O/XsUPbUbPWKQ==} + typebox@1.3.9: + resolution: {integrity: sha512-erh7xzupOex0jkMRU72du2Bq7w1aL8625CJuFEiE7dM4YoJqaxKZqwZw00rsrohS78uDICG3h7FhQ9iJQRDRqg==} typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -1307,7 +1310,7 @@ snapshots: '@aws-sdk/types': 3.973.15 '@smithy/core': 3.29.1 '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.9.3 '@smithy/types': 4.15.1 tslib: 2.8.1 @@ -1484,46 +1487,47 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.2': + '@biomejs/biome@2.5.6': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.2 - '@biomejs/cli-darwin-x64': 2.5.2 - '@biomejs/cli-linux-arm64': 2.5.2 - '@biomejs/cli-linux-arm64-musl': 2.5.2 - '@biomejs/cli-linux-x64': 2.5.2 - '@biomejs/cli-linux-x64-musl': 2.5.2 - '@biomejs/cli-win32-arm64': 2.5.2 - '@biomejs/cli-win32-x64': 2.5.2 + '@biomejs/cli-darwin-arm64': 2.5.6 + '@biomejs/cli-darwin-x64': 2.5.6 + '@biomejs/cli-linux-arm64': 2.5.6 + '@biomejs/cli-linux-arm64-musl': 2.5.6 + '@biomejs/cli-linux-x64': 2.5.6 + '@biomejs/cli-linux-x64-musl': 2.5.6 + '@biomejs/cli-win32-arm64': 2.5.6 + '@biomejs/cli-win32-x64': 2.5.6 - '@biomejs/cli-darwin-arm64@2.5.2': + '@biomejs/cli-darwin-arm64@2.5.6': optional: true - '@biomejs/cli-darwin-x64@2.5.2': + '@biomejs/cli-darwin-x64@2.5.6': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.2': + '@biomejs/cli-linux-arm64-musl@2.5.6': optional: true - '@biomejs/cli-linux-arm64@2.5.2': + '@biomejs/cli-linux-arm64@2.5.6': optional: true - '@biomejs/cli-linux-x64-musl@2.5.2': + '@biomejs/cli-linux-x64-musl@2.5.6': optional: true - '@biomejs/cli-linux-x64@2.5.2': + '@biomejs/cli-linux-x64@2.5.6': optional: true - '@biomejs/cli-win32-arm64@2.5.2': + '@biomejs/cli-win32-arm64@2.5.6': optional: true - '@biomejs/cli-win32-x64@2.5.2': + '@biomejs/cli-win32-x64@2.5.6': optional: true - '@earendil-works/pi-agent-core@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.83.0(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.83.0(ws@8.21.0)(zod@4.4.3) + diff: 8.0.4 ignore: 7.0.5 - typebox: 1.1.38 + typebox: 1.3.7 yaml: 2.9.0 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -1533,7 +1537,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.83.0(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -1545,7 +1549,7 @@ snapshots: https-proxy-agent: 7.0.6 openai: 6.26.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 - typebox: 1.1.38 + typebox: 1.3.7 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -1554,11 +1558,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.83.0(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 + '@earendil-works/pi-agent-core': 0.83.0(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.83.0(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.83.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -1571,7 +1575,7 @@ snapshots: minimatch: 10.2.5 proper-lockfile: 4.1.2 semver: 7.8.0 - typebox: 1.1.38 + typebox: 1.3.7 undici: 8.5.0 yaml: 2.9.0 optionalDependencies: @@ -1584,7 +1588,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.83.0': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -1911,6 +1915,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + '@types/retry@0.12.0': {} '@vitest/expect@4.1.10': @@ -1922,13 +1930,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -2328,15 +2336,15 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.0: + tsx@4.23.1: dependencies: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - typebox@1.1.38: {} + typebox@1.3.7: {} - typebox@1.3.4: {} + typebox@1.3.9: {} typescript@6.0.3: {} @@ -2344,7 +2352,7 @@ snapshots: undici@8.5.0: {} - vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): + vite@8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -2352,17 +2360,17 @@ snapshots: rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - tsx: 4.23.0 + tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(vite@8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2379,11 +2387,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 26.1.0 + '@types/node': 26.1.2 transitivePeerDependencies: - msw From 2f8a36d36345fbdc6d897d363c19833ccc522c0d Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:10:07 -0400 Subject: [PATCH 2/9] chore: update biome schema to version 2.5.6 --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 2b9db4d..132c4ab 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", From a91061910a0ebfe8523ee5ba9f2ad706cc5ce427 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:10:39 -0400 Subject: [PATCH 3/9] refactor: add package content verification script --- scripts/verify-pack.mjs | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/verify-pack.mjs diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs new file mode 100644 index 0000000..abd62a1 --- /dev/null +++ b/scripts/verify-pack.mjs @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process"; + +const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { + encoding: "utf8", +}); + +if (result.error || result.status !== 0) { + throw new Error( + `npm pack failed (${result.status}): ${result.stderr || result.error?.message}`, + ); +} + +const [report] = JSON.parse(result.stdout); +const files = report.files.map(({ path }) => path); +const required = [ + "src/index.ts", + "dcp.schema.json", + "README.md", + "CHANGELOG.md", + "LICENSE", +]; +const allowed = new Set([...required, "package.json"]); +const unexpected = files.filter( + (file) => + !allowed.has(file) && + !file.startsWith("src/") && + !file.startsWith("docs/assets/"), +); +const missing = required.filter((file) => !files.includes(file)); + +if (missing.length || unexpected.length) { + throw new Error( + [ + missing.length && `Missing: ${missing.join(", ")}`, + unexpected.length && `Unexpected: ${unexpected.join(", ")}`, + ] + .filter(Boolean) + .join("\n"), + ); +} + +console.log(`Package contents verified (${files.length} files)`); From 1d6c972290ccf24c1af0b55c7b332120e0050b46 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:10:55 -0400 Subject: [PATCH 4/9] chore: update pnpm to 11.18.0 and consolidate CI pipeline tasks --- .github/workflows/quality.yml | 12 +++--------- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b9700f3..84bea10 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -17,7 +17,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 11.15.1 + version: 11.18.0 run_install: false - name: Setup Node @@ -29,11 +29,5 @@ jobs: - name: Install run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm lint - - - name: Typecheck - run: pnpm typecheck - - - name: Test - run: pnpm test + - name: Check + run: pnpm check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f5756d..eb51ace 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 11.15.1 + version: 11.18.0 run_install: false - name: Setup Node From 2f3ec3b7671b5f74fb2b8db62668d32d0bccfb17 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:10:58 -0400 Subject: [PATCH 5/9] chore: update check script to include formatting and packaging verification --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8b206ff..9eedd62 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,16 @@ ], "scripts": { "format": "biome format --write .", + "format:check": "biome format .", "lint": "biome lint .", "typecheck": "tsc --noEmit", "test": "vitest run", "benchmark": "tsx scripts/benchmark.ts", "generate:schema": "tsx scripts/generate-schema.ts > dcp.schema.json", - "check": "biome lint . && tsc --noEmit && vitest run && pnpm run generate:schema", + "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test", + "pack:verify": "node scripts/verify-pack.mjs", "pack:dry-run": "pnpm pack --dry-run", - "release:check": "pnpm check && pnpm run pack:dry-run" + "release:check": "pnpm check && pnpm run generate:schema && pnpm run pack:verify && pnpm run pack:dry-run" }, "pi": { "extensions": [ From 3fcf8e1cdd7d5e2279eff2d5c2bcc3f79a2dd2d8 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:11:51 -0400 Subject: [PATCH 6/9] chore: remove obsolete implementation plans and specification documents --- .../audits/2026-07-28-pi-dcp-provenance.md | 27 - ...07-28-pi-dcp-phase-1-pruning-foundation.md | 569 ---------------- ...-28-pi-dcp-phase-2-turn-and-pair-safety.md | 322 --------- ...-pi-dcp-phase-3-compression-correctness.md | 414 ------------ ...-28-pi-dcp-phase-4-native-session-state.md | 201 ------ ...-phase-5-operator-and-release-hardening.md | 494 -------------- ...07-28-pi-dcp-reliability-phased-roadmap.md | 138 ---- .../2026-07-28-pi-dcp-reliability-roadmap.md | 624 ------------------ ...-phase-6-benchmark-and-release-evidence.md | 277 -------- ...6-07-28-pi-dcp-comparative-audit-design.md | 68 -- ...026-07-28-pi-dcp-phased-planning-design.md | 193 ------ 11 files changed, 3327 deletions(-) delete mode 100644 docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-phase-1-pruning-foundation.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-phase-4-native-session-state.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md delete mode 100644 docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md delete mode 100644 docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md delete mode 100644 docs/superpowers/specs/2026-07-28-pi-dcp-comparative-audit-design.md delete mode 100644 docs/superpowers/specs/2026-07-28-pi-dcp-phased-planning-design.md diff --git a/docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md b/docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md deleted file mode 100644 index 4721f2c..0000000 --- a/docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md +++ /dev/null @@ -1,27 +0,0 @@ -# Pi DCP Provenance Audit - -Date: 2026-07-28 - -| Repository | Commit | Version | License | Use in this project | -| --- | --- | --- | --- | --- | -| `pi-vault/pi-dcp` | `e68c236` (`v0.4.1` baseline `bfeeff0`) | 0.4.1 | MIT | Current implementation | -| `earendil-works/pi` checkout | `8eef62ed` | coding-agent 0.82.0; installed API 0.80.3 | MIT | Pi lifecycle and extension API reference | -| `opencode-dynamic-context-pruning` | `85b6f5c` | 3.1.14 | AGPL-3.0-or-later | Behavioral comparison only | -| `Davidcreador/pi-dcp` | `7ae24be9` | 0.2.0 | AGPL-3.0-or-later | Behavioral comparison only | -| `complexthings/pi-dynamic-context-pruning` | `75e04cb` | 1.0.7 | No license file or package declaration | Behavioral comparison only | - -## Rules - -- Do not copy source from the AGPL or unlicensed repositories into the MIT package. -- Record behavior and public interfaces in original words. -- Treat Pi core as the authority for extension lifecycle, session, message, and tool APIs. -- Verify `appendEntry`, `getBranch`, `getSessionId`, `session_tree`, `sendMessage`, `ctx.cwd`, and project trust against the installed Pi 0.80.3 types before later phases use them. - -## Verification Baseline - -- Tests: 368 passing. -- Typecheck: passing. -- Lint: exits successfully with 88 warnings and 1 info. -- Package dry-run: passing. -- Local runtime: Node 23.11.0, below the package requirement of Node >=24.15.0; Node 24 CI is the merge gate. -- Comparison suites: not executed because their local runtime dependencies were absent. diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-1-pruning-foundation.md b/docs/superpowers/plans/2026-07-28-pi-dcp-phase-1-pruning-foundation.md deleted file mode 100644 index a0e25f5..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-1-pruning-foundation.md +++ /dev/null @@ -1,569 +0,0 @@ -# Pi DCP Phase 1 Pruning Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Preserve failed-tool diagnostics while purging stale failed inputs, make lookup and shell output pruning effective by default, and record a trustworthy provenance and verification baseline. - -**Architecture:** Retain the existing `syncToolCache → runStrategies → applyPruning` pipeline, shared `state.prune.tools` map, sidecar persistence, and configuration shape. Successful calls continue to use output replacement; failed calls use a separate assistant-argument transformation selected by cached error status. Error savings are estimated from the arguments actually removed. - -**Tech Stack:** TypeScript ESM, Vitest, pnpm, TypeBox-generated schema, existing character-based token estimator, and Pi 0.80.3-compatible message types. - ---- - -## Source and Boundaries - -- Source roadmap: Tasks 1 and 4 in [2026-07-28-pi-dcp-reliability-roadmap.md](2026-07-28-pi-dcp-reliability-roadmap.md). -- Entry commit: `e68c236`; package baseline `v0.4.1` at `bfeeff0`. -- This plan supersedes the prior Phase 1 plan; leave the broader roadmap byte-for-byte unchanged. -- This phase does not add top-level turn protection, change compression blocks, replace persistence, add project config, add manual compression, or bump/tag/publish a package release. - -## Stable Outcome - -After this phase: - -- A stale eligible failed call becomes `{ __purged: "input removed due to failed tool call" }` in the assistant tool-call arguments. -- The matching failed `toolResult` object and content remain unchanged. -- Successful pruned results retain the existing output marker. -- `read`, `grep`, `find`, `ls`, and `bash` calls may participate in deduplication, stale-error purging, and sweep. -- `compress`, `write`, `edit`, and `subagent` remain protected by default; configured protected patterns are additive. -- Reported error-pruning savings count only removed arguments. -- Provenance, schema wording, README behavior, and an Unreleased changelog entry describe the result without copying external source. - -## Internal Interfaces - -- Replace the internal `pruneToolErrors()` export with `pruneFailedInputs()` in `src/messages/prune.ts`. -- Export `PURGED_ERROR_INPUT` and `estimatePurgedInputSavings(parameters)` from `src/strategies/purge-errors.ts` for the pruning pass and strategy accounting. -- Keep `BASE_PROTECTED_TOOLS` exported from `src/config.ts`, with the exact value `['compress', 'write', 'edit', 'subagent']`. -- Do not add configuration fields, serialized state fields, runtime dependencies, or package entry points. - -### Task 1: Record provenance and verification baselines - -**Files:** - -- Create: `docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md` - -- [x] **Step 1: Write the provenance record** - - Record this table and the rules below in the new audit: - - ```markdown - # Pi DCP Provenance Audit - - Date: 2026-07-28 - - | Repository | Commit | Version | License | Use in this project | - | ------------------------------------------ | --------------------------------------- | ----------------------------------------- | -------------------------------------- | ---------------------------------------- | - | `pi-vault/pi-dcp` | `e68c236` (`v0.4.1` baseline `bfeeff0`) | 0.4.1 | MIT | Current implementation | - | `earendil-works/pi` checkout | `8eef62ed` | coding-agent 0.82.0; installed API 0.80.3 | MIT | Pi lifecycle and extension API reference | - | `opencode-dynamic-context-pruning` | `85b6f5c` | 3.1.14 | AGPL-3.0-or-later | Behavioral comparison only | - | `Davidcreador/pi-dcp` | `7ae24be9` | 0.2.0 | AGPL-3.0-or-later | Behavioral comparison only | - | `complexthings/pi-dynamic-context-pruning` | `75e04cb` | 1.0.7 | No license file or package declaration | Behavioral comparison only | - - ## Rules - - - Do not copy source from the AGPL or unlicensed repositories into the MIT package. - - Record behavior and public interfaces in original words. - - Treat Pi core as the authority for extension lifecycle, session, message, and tool APIs. - - Verify `appendEntry`, `getBranch`, `getSessionId`, `session_tree`, `sendMessage`, `ctx.cwd`, and project trust against the installed Pi 0.80.3 types before later phases use them. - - ## Verification Baseline - - - Tests: 368 passing. - - Typecheck: passing. - - Lint: exits successfully with 88 warnings and 1 info. - - Package dry-run: passing. - - Local runtime: Node 23.11.0, below the package requirement of Node >=24.15.0; Node 24 CI is the merge gate. - - Comparison suites: not executed because their local runtime dependencies were absent. - ``` - -- [x] **Step 2: Verify the source facts** - - Run: - - ```bash - sed -n '1,35p' LICENSE - sed -n '1,70p' package.json - git log -1 --oneline - node --version - ``` - - Expected: MIT license, package version 0.4.1, current commit `e68c236`, and the local runtime recorded above. - -- [x] **Step 3: Capture the baseline checks** - - Run: - - ```bash - pnpm test - pnpm typecheck - pnpm lint - pnpm pack --dry-run - git diff --check - ``` - - Expected: 368 tests pass, typecheck passes, lint exits with the recorded diagnostics, package dry-run succeeds, and no whitespace errors are reported. Update only observed baseline values if they differ. - -- [x] **Step 4: Commit the provenance record** - - ```bash - git add docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md - git commit -m "docs: record pi-dcp provenance baseline" - ``` - -### Task 2: Preserve failed results and purge failed inputs - -**Files:** - -- Modify: `src/messages/prune.ts`, `src/strategies/purge-errors.ts`, `src/strategies/runner.ts` -- Modify wording: `src/index.ts`, `src/commands/context.ts` -- Test: `tests/prune.test.ts`, `tests/pipeline.test.ts`, `tests/strategy-runner.test.ts` - -- [x] **Step 1: Replace obsolete unit assertions with a failing argument-purge test** - - Remove the `pruneToolErrors` tests and add a test that seeds an error entry and asserts the assistant argument marker while retaining the same result object; object identity also guarantees unchanged content: - - ```ts - import type { AgentMessage } from "@earendil-works/pi-agent-core"; - import { applyPruning } from "../src/messages/prune.ts"; - import { createSessionState } from "../src/state/state.ts"; - import { makeToolResultMessage } from "./helpers.ts"; - - function makeAssistantWithToolCall( - id: string, - name: string, - arguments_: Record, - ): AgentMessage { - return { - role: "assistant", - content: [{ type: "toolCall", id, name, arguments: arguments_ }], - stopReason: "toolUse", - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - totalTokens: 0, - }, - timestamp: 1, - } as unknown as AgentMessage; - } - - it("purges failed arguments while preserving the diagnostic result", () => { - const state = createSessionState(); - state.prune.tools.set("failed-1", 40); - state.toolParameters.set("failed-1", { - tool: "custom_tool", - parameters: { command: "very long invalid command" }, - status: "error", - error: "command not found", - turn: 0, - tokenCount: 40, - assistantIndex: 0, - resultIndex: 1, - }); - const errorResult = makeToolResultMessage( - "failed-1", - "custom_tool", - "command not found", - true, - ); - const messages = [ - makeAssistantWithToolCall("failed-1", "custom_tool", { - command: "very long invalid command", - }), - errorResult, - ]; - - const result = applyPruning(state, messages); - const toolCall = ( - result[0] as Extract - ).content.find((part) => part.type === "toolCall"); - - expect(toolCall?.arguments).toEqual({ - __purged: "input removed due to failed tool call", - }); - expect(result[1]).toBe(errorResult); - }); - ``` - -- [x] **Step 2: Run the unit regression and confirm the old behavior fails** - - ```bash - pnpm vitest run tests/prune.test.ts -t "purges failed arguments" - ``` - - Expected: FAIL because the current implementation changes failed result content and does not rewrite assistant arguments. - -- [x] **Step 3: Add the argument-savings helper** - - In `src/strategies/purge-errors.ts`, add: - - ```ts - import { countTokens } from "../utils/tokens.ts"; - - export const PURGED_ERROR_INPUT = "input removed due to failed tool call"; - - export function estimatePurgedInputSavings(parameters: unknown): number { - const original = JSON.stringify(parameters); - const replacement = JSON.stringify({ __purged: PURGED_ERROR_INPUT }); - if (original === undefined || replacement === undefined) return 0; - return Math.max(0, countTokens(original) - countTokens(replacement)); - } - ``` - -- [x] **Step 4: Implement failed-input pruning and apply it after output pruning** - - In `src/messages/prune.ts`, import `PURGED_ERROR_INPUT`, remove `PRUNED_ERROR_INPUT_TEXT` and `pruneToolErrors()`, and add: - - ```ts - export function pruneFailedInputs( - state: SessionState, - messages: AgentMessage[], - ): AgentMessage[] { - if (state.prune.tools.size === 0) return messages; - - const failedIds = new Set( - [...state.prune.tools.keys()].filter( - (id) => state.toolParameters.get(id)?.status === "error", - ), - ); - if (failedIds.size === 0) return messages; - - return messages.map((message) => { - if (message.role !== "assistant" || !Array.isArray(message.content)) { - return message; - } - - let changed = false; - const content = message.content.map((part) => { - if (part.type !== "toolCall" || !failedIds.has(part.id)) return part; - changed = true; - return { ...part, arguments: { __purged: PURGED_ERROR_INPUT } }; - }); - - return changed ? { ...message, content } : message; - }); - } - ``` - - Make `applyPruning()` call `pruneToolOutputs()` followed by `pruneFailedInputs()`. - -- [x] **Step 5: Make stale-error accounting match the transformation** - - In `src/strategies/runner.ts`, use `estimatePurgedInputSavings(entry.parameters)` whenever a marked entry has error status, including deduplication; keep successful deduplication and sweep result-token accounting unchanged. - - Update the combined strategy fixture and assertion as follows: - - ```ts - const failedParameters = { command: "x".repeat(400) }; - // Use failedParameters in the seeded error entry. - expect(result.tokensSaved).toBe( - 100 + estimatePurgedInputSavings(failedParameters), - ); - ``` - -- [x] **Step 6: Add a real pipeline regression** - - In `tests/pipeline.test.ts`, add a fixture containing one user message, one assistant `custom_tool` call with `{ command: "x".repeat(400) }`, and one failed `toolResult`. Run the same messages twice: - - ```ts - state.currentTurn = 0; - runPipeline(state, config, messages, undefined); - - state.currentTurn = 4; - const result = runPipeline(state, config, messages, undefined); - const assistant = result.messages.find( - (message): message is Extract => - message.role === "assistant", - ); - const toolCall = assistant?.content.find((part) => part.type === "toolCall"); - const errorResult = result.messages.find( - (message): message is Extract => - message.role === "toolResult", - ); - - expect(toolCall?.arguments).toEqual({ - __purged: "input removed due to failed tool call", - }); - expect(errorResult?.content).toEqual([ - { type: "text", text: "command not found" }, - ]); - expect(result.strategyResult.tokensSaved).toBeGreaterThan(0); - ``` - -- [x] **Step 7: Correct internal labels and run focused tests** - - Change internal log/context labels that say “pruned tool outputs” to “pruned tool calls” or “pruned items.” Run: - - ```bash - pnpm vitest run tests/prune.test.ts tests/pipeline.test.ts tests/purge-errors.test.ts tests/strategy-runner.test.ts - pnpm typecheck - ``` - - Expected: all focused tests and typecheck pass. - -- [x] **Step 8: Commit the semantic and accounting fix** - - ```bash - git add src/messages/prune.ts src/strategies/purge-errors.ts src/strategies/runner.ts src/index.ts src/commands/context.ts tests/prune.test.ts tests/pipeline.test.ts tests/strategy-runner.test.ts - git commit -m "fix: preserve failed tool diagnostics" - ``` - -### Task 3: Narrow the built-in protected-tool policy - -**Files:** - -- Modify: `src/config.ts` -- Test: `tests/config.test.ts`, `tests/strategy-runner.test.ts`, `tests/commands-sweep.test.ts` - -- [x] **Step 1: Add failing policy tests** - - Add these tests to `tests/strategy-runner.test.ts`: - - ```ts - import { BASE_PROTECTED_TOOLS } from "../src/config.ts"; - - it("protects mutation and orchestration tools by default", () => { - expect(BASE_PROTECTED_TOOLS).toEqual([ - "compress", - "write", - "edit", - "subagent", - ]); - }); - - it.each([ - ["read", { path: "same" }], - ["grep", { pattern: "same" }], - ["find", { pattern: "same" }], - ["ls", { path: "same" }], - ["bash", { command: "same" }], - ] as const)( - "allows repeated %s output to deduplicate", - (tool, parameters) => { - const state = createSessionState(); - state.currentTurn = 10; - seedToolCache(state, [ - { - id: `${tool}-1`, - tool, - parameters, - status: "completed", - turn: 1, - tokenCount: 20, - }, - { - id: `${tool}-2`, - tool, - parameters, - status: "completed", - turn: 2, - tokenCount: 20, - }, - ]); - - runStrategies(state, makeDefaultConfig()); - - expect(state.prune.tools.has(`${tool}-1`)).toBe(true); - expect(state.prune.tools.has(`${tool}-2`)).toBe(false); - }, - ); - - it("keeps write output protected", () => { - const state = createSessionState(); - state.currentTurn = 10; - seedToolCache(state, [ - { - id: "write-1", - tool: "write", - parameters: { path: "same" }, - status: "completed", - turn: 1, - tokenCount: 20, - }, - { - id: "write-2", - tool: "write", - parameters: { path: "same" }, - status: "completed", - turn: 2, - tokenCount: 20, - }, - ]); - - runStrategies(state, makeDefaultConfig()); - - expect(state.prune.tools.size).toBe(0); - }); - ``` - -- [x] **Step 2: Remove the existing redundant protected-tool test** - - Delete the old `skips protected tools (BASE_PROTECTED_TOOLS)` test; the exact-list assertion and `keeps write output protected` regression cover the same contract without duplicate setup. - -- [x] **Step 3: Replace the default list** - - In `src/config.ts`, set: - - ```ts - export const BASE_PROTECTED_TOOLS = ["compress", "write", "edit", "subagent"]; - ``` - - Preserve the existing additive merges for user-configured protected tools. - -- [x] **Step 4: Cover sweep behavior and run the strategy suite** - - Add this sweep regression to `tests/commands-sweep.test.ts`: - - ```ts - it("sweeps lookup and shell results but protects mutations", () => { - const state = createSessionState(); - const config = makeDefaultConfig(); - state.toolParameters.set("bash-1", { - tool: "bash", - parameters: { command: "same" }, - status: "completed", - error: undefined, - turn: 1, - tokenCount: 20, - assistantIndex: undefined, - resultIndex: undefined, - }); - state.toolParameters.set("write-1", { - tool: "write", - parameters: { path: "same" }, - status: "completed", - error: undefined, - turn: 1, - tokenCount: 20, - assistantIndex: undefined, - resultIndex: undefined, - }); - - sweepCommand(state, config); - - expect(state.prune.tools.has("bash-1")).toBe(true); - expect(state.prune.tools.has("write-1")).toBe(false); - }); - ``` - - Run: - - ```bash - pnpm vitest run tests/config.test.ts tests/strategy-runner.test.ts tests/commands-sweep.test.ts - pnpm typecheck - ``` - - Expected: all policy tests and typecheck pass. - -- [x] **Step 5: Commit the policy change** - - ```bash - git add src/config.ts tests/config.test.ts tests/strategy-runner.test.ts tests/commands-sweep.test.ts - git commit -m "fix: enable lookup output pruning" - ``` - -### Task 4: Correct public documentation and schema wording - -**Files:** - -- Modify: `README.md`, `CHANGELOG.md`, `src/config-schema.ts` -- Regenerate: `dcp.schema.json` - -- [x] **Step 1: Update README behavior and configuration wording** - - Replace the overview and workflow wording with: - - ```markdown - - **Prunes automatically** — deduplicates repeated tool outputs and purges stale failed tool inputs while preserving diagnostics. - - **Default:** install it and let DCP prune duplicates and stale failed inputs automatically. - ``` - - Replace the `purgeErrors` configuration descriptions with: - - ```markdown - - `purgeErrors.enabled` — enable or disable stale failed-input purging. - - `purgeErrors.turns` — age threshold for failed tool-input purging. - - `purgeErrors.protectedTools` — tool names excluded from failed-input purging. - ``` - - Add this paragraph below the strategy configuration bullets: - - ```markdown - DCP preserves failed tool diagnostics and purges only the historical arguments of eligible stale failures. Repeated `read`, `grep`, `find`, `ls`, and `bash` calls may be deduplicated or swept. `compress`, `write`, `edit`, and `subagent` remain protected by default; configured protected-tool patterns are additive. - ``` - -- [x] **Step 2: Update schema descriptions and regenerate the shipped schema** - - Change the purge-errors descriptions in `src/config-schema.ts` to refer to failed tool inputs, then run: - - ```bash - pnpm run generate:schema - ``` - - Confirm the generated `dcp.schema.json` contains the same wording and a second generation produces no diff. - -- [x] **Step 3: Add merge-ready changelog notes** - - Add this section at the top of `CHANGELOG.md`, without changing the package version: - - ```markdown - ## [Unreleased] - - ### Fixed - - - Preserve failed tool diagnostics while removing stale failed inputs. - - Allow lookup and shell outputs to participate in pruning. - - Keep compression, file mutations, and sub-agent results protected by default. - - Record source provenance and the verification baseline. - ``` - -- [x] **Step 4: Run final verification and commit documentation** - - ```bash - pnpm vitest run tests/prune.test.ts tests/pipeline.test.ts tests/strategy-runner.test.ts tests/config.test.ts - pnpm test - pnpm typecheck - pnpm lint - pnpm pack --dry-run - pnpm run generate:schema - git diff --check - git diff --exit-code e68c236 -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md - ``` - - Expected: all tests pass, typecheck succeeds, lint adds no diagnostics beyond 88 warnings and 1 info, package dry-run succeeds, schema generation is stable, whitespace checks pass, and the source roadmap is unchanged. Node 24 CI must also pass before merge. - - ```bash - git add README.md CHANGELOG.md src/config-schema.ts dcp.schema.json - git commit -m "docs: describe pruning foundation behavior" - ``` - -## Acceptance Criteria - -- Failed tool-call arguments are replaced by `{ __purged: "input removed due to failed tool call" }` only for marked error calls. -- Failed `toolResult` objects and content remain unchanged. -- Successful pruned results retain the existing output marker. -- Error savings reflect removed arguments, not preserved result content. -- Repeated lookup and shell tools can deduplicate and sweep. -- `compress`, `write`, `edit`, and `subagent` are protected by default. -- Configured protected tools remain additive. -- Provenance, README, schema, and changelog describe the new semantics. -- Focused and full verification pass without relying on Phase 2–5 code. -- The phase is merge-ready but not versioned, tagged, or published. - -## Handoff to Phase 2 - -Phase 2 may rely on: - -- `pruneFailedInputs()` as the only failed-call transformation. -- `pruneToolOutputs()` as the successful-output transformation. -- `BASE_PROTECTED_TOOLS` containing only `compress`, `write`, `edit`, and `subagent`. -- Argument-based stale-error token accounting. -- The provenance, test, typecheck, lint, and schema baselines recorded by this phase. - -## Release Record - -- Status: complete (merge-ready) -- Release commit or tag: not applicable; Phase 1 ends merge-ready -- Verification date: 2026-07-28 diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md b/docs/superpowers/plans/2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md deleted file mode 100644 index fb60d3a..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md +++ /dev/null @@ -1,322 +0,0 @@ -# Pi DCP Phase 2 Turn and Pair Safety Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Protect the newest raw user turns consistently and preserve Pi-compatible assistant tool-call/result structure during every DCP transformation. - -**Architecture:** Derive tool age from raw user-message order during every cache synchronization. Use one protected message boundary for pruning, sweep, and both compression modes. Normal compression selections expand to complete tool pairs; the existing one-way orphan-result cleanup remains only as a safety net for stale or corrupt state. - -**Tech Stack:** TypeScript ESM, TypeBox, Vitest, pnpm, generated JSON Schema, and Pi agent message types. - ---- - -## Entry Conditions and Scope - -- Phase 1 is complete at the current merge commit; its handoff guarantees failed-input pruning and the protected default tool set. -- Preserve the source roadmap unchanged: `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md`. -- Do not change compression ownership, nesting/accounting, lifecycle snapshots, trusted project configuration, or manual compression command design; those belong to Phases 3–5. -- `turnProtection: 0` preserves current behavior. Positive values are a hard boundary for deduplication, stale-error pruning, sweep, range compression, and message compression. -- The repository baseline is 377 passing tests, successful typechecking, lint success with 88 warnings and 1 info, a successful package dry-run, and a stable generated schema. - -## Public Interfaces - -- Add `DcpConfig.turnProtection: number`, restricted to non-negative integers, default `0`. -- Replace `ToolParameterEntry.turn` with `userTurn`. -- Replace `SessionState.currentTurn` with runtime-only `currentUserTurn`. -- Add `getProtectedTurnStart(messages, turns): number | undefined`. -- Keep `strategies.deduplication.turnProtection` as a legacy dedup-only setting; deduplication uses the larger of the two values. - -### Task 1: Add the top-level configuration contract - -**Files:** - -- Modify: `src/config-schema.ts`, `src/config.ts`, `dcp.schema.json` -- Test: `tests/config.test.ts`, `tests/helpers.ts` - -- [x] **Step 1: Add failing configuration tests** - - Add tests for the default, a positive value, and invalid negative input: - - ```ts - it("defaults top-level turn protection to zero", () => { - expect( - loadConfig(path.join(tempDir, "missing.json")).config.turnProtection, - ).toBe(0); - }); - - it("accepts a non-negative top-level turn protection", () => { - const file = path.join(tempDir, "dcp.json"); - fs.writeFileSync(file, JSON.stringify({ turnProtection: 2 })); - expect(loadConfig(file).config.turnProtection).toBe(2); - }); - - it("resets a negative top-level turn protection", () => { - const file = path.join(tempDir, "dcp.json"); - fs.writeFileSync(file, JSON.stringify({ turnProtection: -1 })); - const result = loadConfig(file); - expect(result.config.turnProtection).toBe(0); - expect( - result.warnings.some((warning) => warning.includes("turnProtection")), - ).toBe(true); - }); - ``` - -- [x] **Step 2: Confirm the tests fail** - - Run `pnpm vitest run tests/config.test.ts -t "top-level turn protection"`. - - Expected: failure because `DcpConfig` has no top-level property. - -- [x] **Step 3: Add the schema property once** - - Add this property to `DcpConfigSchema`: - - ```ts - turnProtection: Type.Integer({ - default: 0, - minimum: 0, - description: "Protect the newest N user turns from all DCP transformations", - }), - ``` - - Let `Value.Create` populate `DEFAULT_CONFIG`; do not add a second parser or compatibility alias. Add `turnProtection: 0` to hand-built `makeDefaultConfig()` fixtures. - -- [x] **Step 4: Regenerate and verify the schema** - - Run `pnpm run generate:schema` and `pnpm vitest run tests/config.test.ts`. - - Expected: configuration tests pass and `dcp.schema.json` contains the default and minimum. - -- [x] **Step 5: Commit the configuration contract** - - ```bash - git add src/config-schema.ts src/config.ts dcp.schema.json tests/config.test.ts tests/helpers.ts - git commit -m "feat: add global user-turn protection" - ``` - -### Task 2: Derive tool age from raw user turns - -**Files:** - -- Modify: `src/state/types.ts`, `src/state/state.ts`, `src/state/tool-cache.ts`, `src/state/persistence.ts` -- Modify: `src/index.ts`, `src/commands/context.ts` -- Test: `tests/tool-cache.test.ts`, `tests/state.test.ts`, `tests/persistence.test.ts`, `tests/pipeline.test.ts`, `tests/integration.test.ts`, `tests/commands-context.test.ts` -- Update fixtures: `tests/helpers.ts`, `tests/prune.test.ts`, `tests/purge-errors.test.ts`, `tests/compress-search.test.ts`, `tests/commands-sweep.test.ts` - -- [x] **Step 1: Add failing ordinal and rebuild tests** - - Use raw messages containing two tool calls in one user turn and a third call after the next user message: - - ```ts - syncToolCache(state, messages); - expect(state.toolParameters.get("call-1")?.userTurn).toBe(1); - expect(state.toolParameters.get("call-2")?.userTurn).toBe(1); - expect(state.toolParameters.get("call-3")?.userTurn).toBe(2); - expect(state.currentUserTurn).toBe(2); - ``` - - Also call `syncToolCache()` twice, first with a pending call and then with its result, and assert that status, token count, and indices refresh rather than preserving stale metadata. - -- [x] **Step 2: Replace iteration metadata** - - Change the age fields: - - ```ts - // ToolParameterEntry - userTurn: number; - - // SessionState - currentUserTurn: number; - ``` - - Initialize and reset `currentUserTurn` to `0`. In `syncToolCache()`, clear `state.toolParameters`, count raw `user` messages in order, assign the current ordinal to each assistant `toolCall`, and set `state.currentUserTurn` to the final ordinal. Keep the existing result token/error/index collection. - -- [x] **Step 3: Remove the old lifecycle and sidecar counter** - - Delete the `turn_end` listener that increments `currentTurn`. Remove `currentTurn` from sidecar serialization, restoration, and logging. Legacy sidecars may still restore stats, message IDs, nudges, and compaction time, but their discarded counter must not affect protection. - -- [x] **Step 4: Update observability and fixtures** - - Change `/dcp:context` to emit `Current user turn: ${state.currentUserTurn}`. Migrate every fixture field from `turn` to `userTurn` and every manual state assignment from `currentTurn` to `currentUserTurn`. Keep test histories raw-user-message driven where pipeline behavior is being tested. - -- [x] **Step 5: Run state-focused verification** - - Run: - - ```bash - pnpm vitest run tests/tool-cache.test.ts tests/state.test.ts tests/persistence.test.ts tests/pipeline.test.ts tests/integration.test.ts tests/commands-context.test.ts - ``` - - Expected: deterministic rebuilds, refreshed result metadata, no persisted counter, and the renamed context output. - -- [x] **Step 6: Commit user-turn metadata** - - ```bash - git add src/state src/index.ts src/commands/context.ts tests - git commit -m "fix: derive tool age from user turns" - ``` - -### Task 3: Apply the hard boundary to pruning and sweep - -**Files:** - -- Modify: `src/strategies/runner.ts`, `src/strategies/purge-errors.ts` -- Test: `tests/strategy-runner.test.ts`, `tests/turn-protection.test.ts`, `tests/purge-errors.test.ts`, `tests/commands-sweep.test.ts` - -- [x] **Step 1: Rewrite the existing protection tests around user ordinals** - - Replace the current agent-iteration fixtures with raw-user-turn equivalents. Cover: - - global `0` preserves existing dedup behavior; - - dedup uses `Math.max(global, legacy)`; - - stale-error pruning still requires its configured threshold; - - global protection prevents stale-error pruning inside the window; - - sweep skips recent completed results; - - fewer historical user turns than configured protects every existing user turn. - -- [x] **Step 2: Use user-turn age in strategies** - - Deduplication must use: - - ```ts - const protectedTurns = Math.max( - config.turnProtection, - config.strategies.deduplication.turnProtection, - ); - const age = state.currentUserTurn - entry.userTurn; - if (age < protectedTurns) continue; - ``` - - Stale errors must use `state.currentUserTurn` and `entry.userTurn`, with the effective threshold equal to the larger of `purgeErrors.turns` and `config.turnProtection`. Sweep must skip entries whose age is less than `config.turnProtection`. - -- [x] **Step 3: Run the pruning suite** - - Run `pnpm vitest run tests/strategy-runner.test.ts tests/turn-protection.test.ts tests/purge-errors.test.ts tests/commands-sweep.test.ts`. - - Expected: all strategy paths honor their documented effective value. - -- [x] **Step 4: Commit unified pruning protection** - - ```bash - git add src/strategies/runner.ts src/strategies/purge-errors.ts tests/strategy-runner.test.ts tests/turn-protection.test.ts tests/purge-errors.test.ts tests/commands-sweep.test.ts - git commit -m "fix: protect recent user turns during pruning" - ``` - -### Task 4: Define protected boundaries and preserve Pi tool pairs - -**Files:** - -- Modify: `src/compress/search.ts`, `src/compress/handler.ts`, `src/messages/prune.ts` -- Test: `tests/compress-search.test.ts`, `tests/compress-range.test.ts`, `tests/compress-message.test.ts`, `tests/prune.test.ts`, `tests/turn-protection.test.ts` - -- [x] **Step 1: Add boundary tests** - - Cover zero turns, empty history, one protected turn, fewer turns than configured, and a range that begins before but ends inside the protected window: - - ```ts - expect(getProtectedTurnStart(messages, 0)).toBeUndefined(); - expect(getProtectedTurnStart(messages, 1)).toBe(lastUserIndex); - expect(getProtectedTurnStart(messages, 99)).toBe(firstUserIndex); - ``` - -- [x] **Step 2: Add the shared helper** - - ```ts - export function getProtectedTurnStart( - messages: AgentMessage[], - turns: number, - ): number | undefined { - if (turns <= 0) return undefined; - const userIndices = messages.flatMap((message, index) => - message.role === "user" ? [index] : [], - ); - return userIndices[Math.max(0, userIndices.length - turns)]; - } - ``` - -- [x] **Step 3: Enforce the boundary before compression mutation** - - In `handleCompress()`, compute `protectedStart` once from raw `messages`, normalize all entries first, and reject if any resolved selection has `endIndex >= protectedStart`. Route message-mode targets through `resolveSelection(messages, index, index, state)` so they expand to complete tool groups. Reject protected or overlapping selections before allocating a run or block ID. - -- [x] **Step 4: Preserve Pi-compatible fallback behavior** - - Keep `removeOrphanedToolResults()` as a one-way cleanup after DCP filtering. Do not remove assistant `toolCall` parts when their result is absent; Pi’s provider transform inserts an error result. Add tests proving an orphan result is removed and an unmatched assistant call survives. - -- [x] **Step 5: Run compression and pair tests** - - Run: - - ```bash - pnpm vitest run tests/compress-search.test.ts tests/compress-range.test.ts tests/compress-message.test.ts tests/prune.test.ts tests/turn-protection.test.ts - ``` - - Expected: old selections succeed, protected overlap fails atomically, both compression modes expand pairs, and fallback normalization remains one-way. - -- [x] **Step 6: Commit pair-safe compression** - - ```bash - git add src/compress/search.ts src/compress/handler.ts src/messages/prune.ts tests/compress-search.test.ts tests/compress-range.test.ts tests/compress-message.test.ts tests/prune.test.ts tests/turn-protection.test.ts - git commit -m "fix: enforce compression turn and pair safety" - ``` - -### Task 5: Document and release Phase 2 - -**Files:** - -- Modify: `README.md`, `CHANGELOG.md`, `dcp.schema.json` -- Modify: `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md` -- Test: one raw-message integration fixture in `tests/integration.test.ts` - -- [x] **Step 1: Document the released semantics** - - Describe top-level `turnProtection`, raw user-turn counting, the legacy deduplication maximum, fewer-turn behavior, all-path enforcement, pair-complete compression, and Pi’s unmatched-call normalization. - -- [x] **Step 2: Run the independent integration check** - - Configure `turnProtection: 1` in a fixture containing an older turn and a newest user/tool turn. Assert that older eligible content transforms while the newest user turn and its tool-call/result group remain intact. - -- [x] **Step 3: Run final verification** - - ```bash - pnpm run generate:schema - git diff --exit-code -- dcp.schema.json - pnpm test - pnpm typecheck - pnpm lint - pnpm pack --dry-run - git diff --check - git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md - ``` - - Expected: 377 or more passing tests, no type errors, no lint diagnostics above the baseline, stable schema, successful package dry-run, and unchanged source roadmap. - -- [x] **Step 4: Record completion** - - Mark Phase 2 `complete` in the phased roadmap, record the implementation commit and verification date in this plan, then commit: - - ```bash - git add README.md CHANGELOG.md dcp.schema.json docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md docs/superpowers/plans/2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md tests/integration.test.ts - git commit -m "docs: release turn and pair safety" - ``` - -## Acceptance Criteria - -- Top-level protection defaults to `0` and preserves existing behavior. -- User-turn ordinals rebuild deterministically from raw messages and are not persisted. -- Dedup, stale-error pruning, sweep, and both compression modes enforce the hard boundary. -- Protected compression batches fail atomically. -- Overlapping expanded compression selections fail atomically. -- Normal DCP compression does not split assistant/tool-result pairs. -- DCP removes orphan results it creates but leaves unmatched assistant calls for Pi normalization. -- README, schema, changelog, and phase index describe the released behavior. -- Full and focused verification pass without Phase 3–5 code. - -## Handoff to Phase 3 - -Phase 3 may rely on `ToolParameterEntry.userTurn`, runtime `SessionState.currentUserTurn`, `DcpConfig.turnProtection`, `getProtectedTurnStart()`, and pair-complete selection with atomic protected-range rejection. Phase 3 must not change the meaning of user-turn protection. - -## Release Record - -- Status: complete -- Release commit or tag: implementation range `605db9a^..798d70d` (the final release-record commit does not self-reference) -- Verification date: 2026-07-28 -- Verification: schema regenerated; 399 tests, typecheck, lint (84 warnings and 1 info), package, and diff checks passed under Node 23.11.0 (the package requires Node 24.15.0 or newer). diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md b/docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md deleted file mode 100644 index 8b7ebc2..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md +++ /dev/null @@ -1,414 +0,0 @@ -# Pi DCP Phase 3 Compression Correctness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make in-session compression ownership, batch validation, nesting, visible-token accounting, and timing deterministic and correct. - -**Architecture:** Prepare every compression entry completely before mutating state, then commit the prepared batch once. Selection closes over Pi tool-call/result pairs and active compression blocks to a fixed point; one derived-state rebuild maintains visibility and relationships. Blocks own the real compression `toolCallId` and content-derived message keys, while numeric indices remain runtime caches for the current process. - -**Tech Stack:** TypeScript ESM, Vitest, pnpm, Pi `AgentMessage` types, TypeBox tool registration, and the existing token estimator. - ---- - -## Source, Prerequisite, and Boundaries - -- Source requirement: Task 3 in [2026-07-28-pi-dcp-reliability-roadmap.md](2026-07-28-pi-dcp-reliability-roadmap.md). -- Prerequisite: [Phase 2](2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md) is released. The current branch passes 399 tests, typecheck, lint, package dry-run, and diff checks; local Node 23.11.0 reports the existing Node >=24.15.0 engine warning. -- Stable block keys are the exact `getMessageKey()` values (`user::` or `toolResult:`), not visible refs such as `m0001`. Phase 4 will serialize these keys and rehydrate their numeric indices. -- This phase guarantees only the running process. Resume, fork, tree, compaction restoration, lifetime scanning, project config, manual compression, and benchmarks remain Phase 4/5 work. -- The source reliability roadmap remains byte-for-byte unchanged. - -## File Responsibilities - -- `src/state/types.ts`, `src/state/state.ts`: block and timing contracts plus initialization/reset behavior. -- `src/compress/search.ts`, `src/compress/handler.ts`: boundary resolution, fixed-point selection, batch preparation, and result aggregation. -- `src/compress/state.ts`, `src/messages/sync.ts`, `src/messages/prune.ts`: commit, derived-state rebuild, owner-aware synchronization, and visible filtering. -- `src/commands/decompress.ts`, `src/commands/recompress.ts`, `src/index.ts`, `src/pipeline.ts`: command mutations, real tool-call ownership, and timing event flow. -- Existing compression, sync, command, notification, integration, and shared fixture tests are updated in place; no new dependency or second block type is introduced. - -## Public Contract - -```ts -export function handleCompress( - state: SessionState, - config: DcpConfig, - messages: AgentMessage[], - compressToolCallId: string, - args: CompressArgs, -): CompressResult; - -export interface SelectionResult { - startIndex: number; - endIndex: number; - messageIndices: number[]; - toolIds: string[]; - consumedBlockIds: number[]; -} - -export interface CompressionTimingState { - startTimes: Map; -} -``` - -`CompressionBlock` removes `compressMessageIndex` and the unused `includedBlockIds`, and adds `compressToolCallId`, `startKey`, `endKey`, and `anchorKey`. It retains numeric indices, direct/effective memberships, `consumedBlockIds`, `parentBlockIds`, `deactivatedByUser`, and `deactivatedByBlockId`. - -### Task 1: Replace positional ownership and timing contracts - -**Files:** - -- Modify: `src/state/types.ts`, `src/state/state.ts`, `src/compress/state.ts` -- Modify: `src/compress/handler.ts`, `src/index.ts`, `src/pipeline.ts`, `src/messages/sync.ts` -- Test: `tests/helpers.ts`, `tests/compress-range.test.ts`, `tests/compress-state.test.ts`, `tests/sync.test.ts`, `tests/compression-timing.test.ts` - -- [ ] **Step 1: Add failing ownership and timing assertions.** - - Extend the shared block fixture so a valid block contains the new fields and no `compressMessageIndex` or `includedBlockIds`. Add assertions such as: - - ```ts - expect(block.compressToolCallId).toBe("compress-call-1"); - expect(block.startKey).toBe("user:1000:0"); - expect(block.endKey).toBe("assistant:1001:0"); - expect(block.anchorKey).toBe("user:1000:0"); - ``` - - Add a type-level fixture that calls `handleCompress(state, config, messages, "compress-call-1", args)` and add a timing fixture with `callIdToBlockId` and `pendingDurations` removed. - -- [ ] **Step 2: Run the contract tests and confirm the expected failure.** - - Run: - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-state.test.ts tests/sync.test.ts tests/compression-timing.test.ts - pnpm typecheck - ``` - - Expected: failures identify the old handler signature, positional ownership fields, and obsolete timing maps. - -- [ ] **Step 3: Change the state and handler contracts.** - - Replace the block/timing declarations with the approved fields. Change both registered tool callbacks in `src/index.ts` to pass their `_toolCallId` into `handleCompress`. Change `applyCompressionState` to accept prepared block data rather than `compressMessageIndex`. - - Keep `startTimes` only. Remove `applyPendingCompressionDurations` and its pipeline call; timing is applied directly from the end event in Task 4. - -- [ ] **Step 4: Make synchronization consume real messages.** - - Change the signature to: - - ```ts - export function syncCompressionBlocks( - state: SessionState, - messages: AgentMessage[], - ): void; - ``` - - Build the owner set from assistant `toolCall` IDs in `messages`. Blocks whose `compressToolCallId` is absent become ineligible and inactive. Blocks with a present owner and no user deactivation become eligible for derived-state rebuilding. Update the pipeline so `syncToolCache(state, messages)` runs before `syncCompressionBlocks(state, messages)`. - -- [ ] **Step 5: Run the updated contract tests.** - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-state.test.ts tests/sync.test.ts tests/compression-timing.test.ts - pnpm typecheck - ``` - - Expected: all updated fixtures compile; ownership is the real call ID; synchronization no longer depends on an array position; timing state contains only start timestamps. - -- [ ] **Step 6: Commit the contract change.** - - ```bash - git add src/state/types.ts src/state/state.ts src/compress/state.ts src/compress/handler.ts src/messages/sync.ts src/pipeline.ts src/index.ts tests/helpers.ts tests/compress-range.test.ts tests/compress-state.test.ts tests/sync.test.ts tests/compression-timing.test.ts - git commit -m "refactor: give compression blocks real ownership" - ``` - -### Task 2: Prepare complete selections without mutation - -**Files:** - -- Modify: `src/compress/search.ts`, `src/compress/handler.ts` -- Test: `tests/compress-range.test.ts`, `tests/compress-search.test.ts` - -- [ ] **Step 1: Add failing fixed-point and atomicity tests.** - - Add tests for: - - ```ts - const before = snapshotCompressionState(state); - expect(() => - handleCompress(state, config, messages, "compress-call-1", { - mode: "range", - topic: "batch", - content: [ - { startId: "m0001", endId: "m0002", summary: "valid" }, - { startId: "m9999", endId: "m0002", summary: "invalid" }, - ], - }), - ).toThrow(/not available/i); - expect(snapshotCompressionState(state)).toEqual(before); - ``` - - Add a multi-tool assistant case where the range starts on result B; the resolved selection must include the assistant, result A, and result B. Add a batch whose ranges overlap only after an active block expands one of them. - -- [ ] **Step 2: Run the new tests and confirm the old behavior fails.** - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-search.test.ts -t "atomic|fixed|multi-tool" - ``` - - Expected: the old resolver omits active-block membership and the old handler mutates or allocates before all preparation has completed. - -- [ ] **Step 3: Extend `resolveSelection` to a fixed point.** - - Preserve the Phase 2 tool-pair expansion, then repeat these operations until `startIndex` and `endIndex` stop changing: - 1. For every assistant message in the interval, include every matching `toolResult` and every result belonging to that assistant’s tool calls. - 2. For every active block whose `effectiveMessageIndices` intersects the interval, include its complete effective range and record its ID in `consumedBlockIds`. - 3. Re-run pair closure after block expansion. - - Return the sorted message indices, tool IDs, and consumed IDs. Reject reversed/out-of-range boundaries before any state mutation. - -- [ ] **Step 4: Add a prepare phase to `handleCompress`.** - - Resolve every entry into a local prepared record before allocating IDs or writing state: - - ```ts - interface PreparedCompression { - startIndex: number; - endIndex: number; - anchorIndex: number; - startKey: string; - endKey: string; - anchorKey: string; - summary: string; - summaryTokens: number; - compressedTokens: number; - directMessageIndices: number[]; - directToolIds: string[]; - effectiveMessageIndices: number[]; - effectiveToolIds: string[]; - consumedBlockIds: number[]; - } - ``` - - Derive keys through `state.messageIds.byIndex` → `state.messageIds.byRef`; fail before commit if a resolved index has no raw key. Apply turn protection and final interval overlap checks to the prepared array. Use local candidate IDs (`nextBlockId + offset`) while wrapping summaries, then advance counters only during commit. - -- [ ] **Step 5: Run range and search tests.** - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-search.test.ts - ``` - - Expected: invalid, protected, and expanded-overlap batches leave a byte-equivalent compression snapshot; valid disjoint batches prepare all entries before creating any block. - -- [ ] **Step 6: Commit atomic selection preparation.** - - ```bash - git add src/compress/search.ts src/compress/handler.ts tests/compress-range.test.ts tests/compress-search.test.ts - git commit -m "fix: prepare compression batches atomically" - ``` - -### Task 3: Commit nested blocks and rebuild derived visibility - -**Files:** - -- Modify: `src/compress/state.ts`, `src/compress/handler.ts`, `src/messages/sync.ts` -- Modify: `src/commands/decompress.ts`, `src/commands/recompress.ts` -- Test: `tests/compress-cycle.test.ts`, `tests/commands-decompress.test.ts`, `tests/sync.test.ts` - -- [ ] **Step 1: Add failing nested-cycle tests.** - - Create child, parent, and grandparent blocks through `handleCompress`, then assert: - - ```ts - expect(parent.consumedBlockIds).toEqual([child.blockId]); - expect(child.parentBlockIds).toContain(parent.blockId); - expect(grandparent.consumedBlockIds).toContain(parent.blockId); - ``` - - Deactivate the parent and assert eligible children become active. Keep a user-deactivated child inactive. Add a case with another active parent consuming the same child and assert the child remains inactive until all active parents are gone. - -- [ ] **Step 2: Run nested tests and confirm incomplete relationships.** - - ```bash - pnpm vitest run tests/compress-cycle.test.ts tests/commands-decompress.test.ts tests/sync.test.ts - ``` - - Expected: the current implementation leaves consumed blocks empty, overwrites anchor mappings, and never rebuilds child visibility. - -- [ ] **Step 3: Implement one derived-state rebuild function.** - - Add this internal contract in `src/compress/state.ts`: - - ```ts - export function rebuildCompressionState( - state: SessionState, - eligibleBlockIds: ReadonlySet, - ): void; - ``` - - The function must: - - process blocks in creation order; - - activate eligible, non-user-deactivated blocks unless an eligible ancestor consumes them; - - set `deactivatedByBlockId` for blocks hidden by an active parent and clear it for visible blocks; - - rebuild `activeBlockIds` and `activeByAnchorIndex` from active blocks; - - rebuild every `byMessageIndex.blockIds` and `activeBlockIds` entry from block effective memberships while preserving token counts. - - Add a helper that preserves candidates hidden by a parent (`active` or `deactivatedByBlockId !== undefined`) for command mutations. `syncCompressionBlocks` supplies the authoritative eligible set from current compression tool-call IDs; the commit path adds all newly created IDs before rebuilding. - -- [ ] **Step 4: Commit relationships in one pass.** - - During batch commit, store each prepared block, set each consumed block inactive, append the new block ID to its `parentBlockIds`, and call `rebuildCompressionState` once after all blocks exist. Do not update active sets or per-message membership incrementally in the handler. - -- [ ] **Step 5: Make command mutations rebuild state.** - - `decompressCommand` sets `deactivatedByUser = true`, clears `active`, and calls the rebuild helper. `recompressCommand` requires a user-deactivated block, clears the flag, sets it as an eligible active candidate, and calls the same helper. Command handlers retain their existing result strings and validation. - -- [ ] **Step 6: Run nested and pipeline tests.** - - ```bash - pnpm vitest run tests/compress-cycle.test.ts tests/commands-decompress.test.ts tests/sync.test.ts tests/pipeline.test.ts - ``` - - Expected: child/parent/grandparent cycles preserve relationships, active summaries are mutually consistent, and deactivation restores only eligible visible content. - -- [ ] **Step 7: Commit nested-state behavior.** - - ```bash - git add src/compress/state.ts src/compress/handler.ts src/messages/sync.ts src/commands/decompress.ts src/commands/recompress.ts tests/compress-cycle.test.ts tests/commands-decompress.test.ts tests/sync.test.ts tests/pipeline.test.ts - git commit -m "fix: preserve nested compression visibility" - ``` - -### Task 4: Count visible tokens and apply batch timing - -**Files:** - -- Modify: `src/compress/handler.ts`, `src/compress/state.ts`, `src/index.ts` -- Test: `tests/compress-range.test.ts`, `tests/compress-cycle.test.ts`, `tests/compression-timing.test.ts`, `tests/compress-notification.test.ts`, `tests/integration.test.ts` - -- [ ] **Step 1: Add failing accounting tests.** - - Assert: - - ```ts - expect(rawBlock.compressedTokens).toBeGreaterThan(0); - expect(parent.compressedTokens).toBe(child.summaryTokens + directRawTokens); - expect(result.compressedTokens).toBe( - blockOne.compressedTokens + blockTwo.compressedTokens, - ); - expect(result.compressedTokens - result.summaryTokens).toBe(expectedSavings); - ``` - - Include a nested case proving hidden child raw messages are not counted again. - -- [ ] **Step 2: Add failing all-block timing coverage.** - - Create a two-range batch with call ID `compress-call-1`, set a start timestamp, simulate a successful `tool_execution_end`, and assert: - - ```ts - expect(blockOne.durationMs).toBe(1500); - expect(blockTwo.durationMs).toBe(1500); - expect(state.compressionTiming.startTimes.has("compress-call-1")).toBe(false); - ``` - -- [ ] **Step 3: Implement visible-token preparation.** - - For each prepared entry, build the union of consumed blocks’ effective raw indices. Calculate: - - ```ts - compressedTokens = - sum(consumedBlock.summaryTokens) + - sum(countMessageTokens(message) for each direct selected index); - ``` - - Count each consumed summary once, count only direct raw messages thereafter, store the result on the block, and aggregate it into `CompressResult`. Use the existing wrapped-summary estimator for `summaryTokens`. - -- [ ] **Step 4: Apply duration directly from Pi events.** - - In `tool_execution_end`, after computing `durationMs` and confirming `!event.isError`, assign the duration to every block whose `compressToolCallId` equals `event.toolCallId`: - - ```ts - for (const block of state.prune.messages.blocksById.values()) { - if (block.compressToolCallId === event.toolCallId) { - block.durationMs = durationMs; - } - } - ``` - - Remove the newest-block scan, call-ID mapping, pending-duration map, and pipeline application step. - -- [ ] **Step 5: Run accounting, timing, notification, and integration tests.** - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-cycle.test.ts tests/compression-timing.test.ts tests/compress-notification.test.ts tests/integration.test.ts - ``` - - Expected: raw and nested blocks report nonzero visible input, batch results aggregate correctly, every block receives one duration, and notifications retain aggregate token/message fields. - -- [ ] **Step 6: Commit accounting and timing.** - - ```bash - git add src/compress/handler.ts src/compress/state.ts src/index.ts tests/compress-range.test.ts tests/compress-cycle.test.ts tests/compression-timing.test.ts tests/compress-notification.test.ts tests/integration.test.ts - git commit -m "fix: account and time compression batches correctly" - ``` - -### Task 5: Document and release the phase - -**Files:** - -- Modify: `README.md`, `CHANGELOG.md` -- Modify: `docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md` -- Modify: `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md` - -- [ ] **Step 1: Document the released behavior.** - - Add entries under the existing Unreleased sections describing atomic batches, fixed-point pair/block expansion, nested restoration, visible-token accounting, and duration coverage for every block. State explicitly that resume/fork/tree/compaction restoration begins in Phase 4. - -- [ ] **Step 2: Run focused phase verification.** - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-cycle.test.ts tests/compress-state.test.ts tests/sync.test.ts tests/commands-decompress.test.ts tests/compression-timing.test.ts tests/compress-notification.test.ts tests/integration.test.ts - ``` - - Expected: all Phase 3 ownership, atomicity, nesting, accounting, command-cycle, sync, and timing tests pass. - -- [ ] **Step 3: Run full verification.** - - ```bash - pnpm test - pnpm typecheck - pnpm lint - pnpm pack --dry-run - git diff --check - git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md - ``` - - Expected: all tests and type checks pass, lint does not exceed the current baseline of 84 warnings and 1 info, packaging succeeds, and the source roadmap has no diff. - -- [ ] **Step 4: Record the release.** - - After verification, change only Phase 3 from `not started` to `complete` in the phased roadmap and record the release commit/tag and verification date in this plan. Do not alter later phase statuses or the source roadmap. - -- [ ] **Step 5: Commit documentation and release metadata.** - - ```bash - git add README.md CHANGELOG.md docs/superpowers/plans/2026-07-28-pi-dcp-phase-3-compression-correctness.md docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md - git commit -m "docs: release compression correctness" - ``` - -## Acceptance Criteria - -- Every block records the real compression tool-call ID and raw boundary keys. -- Invalid or overlapping batches cannot partially mutate state. -- Pair and active-block expansion reaches a complete fixed point. -- Nested consume/deactivate/recompress cycles preserve coherent visibility and relationships. -- `compressedTokens` measures visible context replaced without nested double-counting. -- Timing is applied to every block created by a successful batch. -- Focused and full verification pass without Phase 4 or Phase 5 behavior. -- Documentation limits Phase 3 guarantees to the running process. - -## Phase 4 Handoff - -Phase 4 may serialize `compressToolCallId`, `startKey`, `endKey`, `anchorKey`, direct/effective memberships, nested relationships, token counts, and durations. It must rebuild numeric indices, active maps, and per-message derived state from current messages instead of trusting stale runtime caches. - -## Release Record - -- Status: complete -- Release commit or tag: b2936da -- Verification date: 2026-07-28 diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-4-native-session-state.md b/docs/superpowers/plans/2026-07-28-pi-dcp-phase-4-native-session-state.md deleted file mode 100644 index 0cf7b5b..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-4-native-session-state.md +++ /dev/null @@ -1,201 +0,0 @@ -# Pi DCP Phase 4 Native Session State Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Preserve released DCP behavior across Pi resume, fork, tree navigation, and compaction without restoring ambiguous shared sidecar state. - -**Architecture:** Store versioned `pi-dcp-state` custom entries on the active Pi branch. Persist only stable durable facts; rebuild message indices, compression memberships, ownership-derived visibility, tool caches, and timing state from current messages. Forks inherit behavior but receive a new owner snapshot with zeroed statistics. - -**Tech Stack:** TypeScript ESM, Pi ExtensionAPI 0.80.3-compatible session APIs, Vitest, pnpm, and Node standard-library JSONL/filesystem APIs. - ---- - -## Readiness Findings - -The previous plan was not implementation-ready because it serialized runtime indices through `CompressionBlock[]`, missed compression mutations that occur before the next `context` pass, had an inconsistent command persistence callback, and referenced nonexistent `tests/messages-inject.test.ts`. The normal test suite also races because `tests/index.test.ts` and `tests/integration.test.ts` share `/tmp/test-pi-agent`; `pnpm vitest run --no-file-parallelism` passes all 408 tests. Final release verification must run under Node `>=24.15.0`; the current audit shell used Node 23.11.0. - -Legacy `{sessionDir}/dcp/state.json` files remain untouched and are never read for restoration. Phase 4 excludes trusted project configuration, `/dcp:compress`, and benchmarks. - -## Durable Contract - -Add these types to `src/state/types.ts`: - -```ts -export interface DcpSnapshotBlockV1 { - blockId: number; - runId: number; - deactivatedByUser: boolean; - compressedTokens: number; - summaryTokens: number; - durationMs: number; - mode: "range" | "message"; - topic: string; - batchTopic?: string; - compressToolCallId: string; - startKey: string; - endKey: string; - anchorKey: string; - consumedBlockIds: number[]; - createdAt: number; - summary: string; -} - -export interface DcpSnapshotV1 { - version: 1; - ownerSessionId: string; - manualMode: false | "active"; - compressPermission: "allow" | "deny"; - stats: SessionStats; - lastCompaction: number; - pruneTools: Array<[string, number]>; - blocks: DcpSnapshotBlockV1[]; - nextBlockId: number; - nextRunId: number; - messageIds: { - byRawId: Array<[string, string]>; - nextRefIndex: number; - }; - nudges: { - contextLimitAnchors: string[]; - turnAnchors: string[]; - iterationAnchors: string[]; - }; -} -``` - -`src/state/persistence.ts` exports `serializeDcpSnapshot`, `restoreDcpSnapshot`, `durableStateFingerprint`, and asynchronous `loadAllSessionStats`. The serializer sorts all collections before emitting them. It excludes numeric indices, active/parent maps, message memberships, inverse ID maps, tool/model caches, compression timing, pending manual triggers, and subagent results. - -### Task 1: Repair the verification baseline - -**Files:** `tests/index.test.ts`, `tests/integration.test.ts` - -- [ ] **Step 1: Give each integration test file an isolated agent directory.** Replace the shared `/tmp/test-pi-agent` mock with a file-local temporary directory and remove it in that file’s cleanup hook. -- [ ] **Step 2: Run the normal suite.** Run `pnpm test`; expected result is 408 existing tests passing in parallel with no cross-file filesystem race. -- [ ] **Step 3: Commit the baseline repair.** - -```bash -git add tests/index.test.ts tests/integration.test.ts -git commit -m "test: isolate extension agent directories" -``` - -### Task 2: Define and validate native snapshots - -**Files:** `src/state/types.ts`, `src/state/persistence.ts`, `tests/persistence.test.ts` - -- [ ] **Step 1: Add failing contract tests.** Cover exact snapshot fields, deterministic sorting, runtime-field exclusion, same-owner round trips, parent-owned fork restoration, malformed roots, malformed map entries, malformed blocks, invalid counters, duplicate IDs, and counter repair. -- [ ] **Step 2: Confirm the new API is absent.** Run `pnpm vitest run tests/persistence.test.ts -t snapshot`; expected failure is missing snapshot types/functions. -- [ ] **Step 3: Implement validation without a dependency.** Add a narrow runtime type guard. Reject invalid root/version/owner/counter data; retain valid blocks and map entries; warn once per discarded block through the existing logger callback. Normalize duplicate/sorted collections and discard invalid consumed-block references. -- [ ] **Step 4: Implement serialization and in-place restore.** `restoreDcpSnapshot(snapshot, state, currentSessionId)` resets and fills the existing `SessionState`; it resets only `stats` when `snapshot.ownerSessionId !== currentSessionId`. Rebuild `byRef` from `byRawId` and leave all runtime caches empty. -- [ ] **Step 5: Run focused tests and commit.** - -```bash -pnpm vitest run tests/persistence.test.ts -git add src/state/types.ts src/state/persistence.ts tests/persistence.test.ts -git commit -m "feat: define native dcp session snapshots" -``` - -### Task 3: Restore active branches and rehydrate current messages - -**Files:** `src/index.ts`, `src/pipeline.ts`, `src/messages/sync.ts`, `src/messages/inject.ts`, `src/compress/state.ts`; tests: `tests/index.test.ts`, `tests/integration.test.ts`, `tests/inject.test.ts`, `tests/sync.test.ts`, `tests/pipeline.test.ts` - -- [ ] **Step 1: Add failing lifecycle tests.** Mock `getSessionId()` and `getBranch()` with unrelated entries, older valid snapshots, malformed newest snapshots, parent-owned snapshots, and no snapshot. Assert resume, reload, fork, and tree restore the expected branch state. -- [ ] **Step 2: Select the newest valid entry.** Scan `getBranch()` in reverse, accept only `custom` entries with `customType === "pi-dcp-state"`, continue past malformed candidates, and record whether filtering/fallback requires a repair append. -- [ ] **Step 3: Restore in place on `session_start` and `session_tree`.** Reset state, apply current config defaults and subagent runtime flags, restore the selected snapshot, set the real Pi session ID, and append a forced child-owned snapshot when ownership differs. Do not read sidecars. -- [ ] **Step 4: Reorder the pipeline.** Run these steps before pruning or injection: - -```text -assign message refs -sync tool cache and current user-turn ordinal -build current tool-call list -remove stale pruned IDs, message mappings, and nudge anchors -resolve stable block keys and drop blocks with missing boundaries/owners -derive parent links, direct/effective memberships, and active maps -run strategies, inject IDs/nudges, and apply pruning -``` - -- [ ] **Step 5: Rebuild compression state from stable facts.** Derive current numeric indices from keys, derive direct/effective memberships from each range and `consumedBlockIds`, and derive parent links/active visibility from retained blocks. Never trust serialized runtime collections. -- [ ] **Step 6: Run lifecycle tests and commit.** - -```bash -pnpm vitest run tests/index.test.ts tests/integration.test.ts tests/inject.test.ts tests/sync.test.ts tests/pipeline.test.ts -git add src/index.ts src/pipeline.ts src/messages/sync.ts src/messages/inject.ts src/compress/state.ts tests/index.test.ts tests/integration.test.ts tests/inject.test.ts tests/sync.test.ts tests/pipeline.test.ts -git commit -m "fix: restore dcp state from pi branches" -``` - -### Task 4: Persist every durable mutation exactly once - -**Files:** `src/index.ts`, `src/commands/register.ts`, `src/state/persistence.ts`; tests: `tests/index.test.ts`, `tests/integration.test.ts`, `tests/commands-register.test.ts`, `tests/compression-timing.test.ts` - -- [ ] **Step 1: Add failing append tests.** Assert one append after successful compression including final duration, strategy/nudge changes, sweep, manual/permission changes, decompress/recompress, compaction reset, and tree cleanup. Assert no append for failed or unchanged commands and repeated context passes. -- [ ] **Step 2: Add the centralized persistence closure.** Keep `lastPersistedFingerprint` in `src/index.ts`; compute fingerprints from the sorted durable serializer with a constant owner value. `persistIfChanged(force = false)` uses `state.sessionId`, catches/logs append failures, and updates the cache only after `pi.appendEntry()` succeeds. -- [ ] **Step 3: Wire mutation sources.** Pass `onStateChange: () => void` to `registerDcpCommands`; command handlers call it after their state mutation and before notification. Call persistence after `runPipeline`, after `session_compact`, after compression `tool_execution_end` timing, and after lifecycle restoration/repair. The timing hook is the compression persistence point so snapshots contain final duration values. -- [ ] **Step 4: Remove runtime sidecar calls.** Delete `saveSessionState`/`loadSessionState` runtime usage while leaving legacy files on disk. -- [ ] **Step 5: Run focused tests and commit.** - -```bash -pnpm vitest run tests/index.test.ts tests/integration.test.ts tests/commands-register.test.ts tests/compression-timing.test.ts -git add src/index.ts src/commands/register.ts src/state/persistence.ts tests/index.test.ts tests/integration.test.ts tests/commands-register.test.ts tests/compression-timing.test.ts -git commit -m "fix: persist durable dcp mutations" -``` - -### Task 5: Aggregate lifetime statistics from Pi sessions - -**Files:** `src/state/persistence.ts`, `src/commands/lifetime.ts`, `src/commands/register.ts`; tests: `tests/commands-lifetime.test.ts`, `tests/persistence.test.ts` - -- [ ] **Step 1: Add JSONL fixtures.** Create temporary nested project-session directories containing valid headers, repeated snapshots for one owner, copied parent snapshots, child-owned snapshots, malformed lines, unrelated entries, unreadable files, and legacy sidecars. -- [ ] **Step 2: Implement the asynchronous scanner.** Stream `.jsonl` files recursively under the supplied sessions root, require a valid Pi header, parse each line independently, validate `pi-dcp-state`, and retain the newest valid entry per owner by custom-entry timestamp. -- [ ] **Step 3: Update the command boundary.** Make `lifetimeCommand()` await `loadAllSessionStats()` and keep the existing output format. The command scans all project session directories under the current sessions parent. -- [ ] **Step 4: Run tests and commit.** - -```bash -pnpm vitest run tests/commands-lifetime.test.ts tests/persistence.test.ts -git add src/state/persistence.ts src/commands/lifetime.ts src/commands/register.ts tests/commands-lifetime.test.ts tests/persistence.test.ts -git commit -m "fix: aggregate lifetime stats from pi sessions" -``` - -### Task 6: Document and release Phase 4 - -**Files:** `README.md`, `CHANGELOG.md`, `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md` - -- [ ] **Step 1: Document lifecycle behavior.** Explain version 1 snapshots, active-branch selection, fork ownership/stat resets, compaction reset, malformed-entry recovery, rebuilt runtime caches, global lifetime aggregation, and ignored legacy sidecars. -- [ ] **Step 2: Run supported-runtime release verification.** Use Node `>=24.15.0` and run: - -```bash -pnpm test -pnpm typecheck -pnpm lint -pnpm pack --dry-run -git diff --check -git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md -``` - -Expected: all tests pass in parallel, typecheck succeeds, lint has no diagnostics above the recorded baseline, package dry-run succeeds, and the source roadmap is unchanged. - -- [ ] **Step 3: Prove the lifecycle acceptance path.** Exercise pruning and nested compression, resume, tree navigation away/back, fork, compaction, malformed snapshot fallback, and `dcp:lifetime`; verify owning-branch behavior, zeroed fork stats, and one lifetime total per owner. -- [ ] **Step 4: Record the release and commit documentation.** Change only Phase 4’s roadmap status after all acceptance criteria pass, then commit: - -```bash -git add README.md CHANGELOG.md docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md -git commit -m "docs: release native session state" -``` - -## Acceptance Criteria - -- Version 1 snapshots contain only durable stable facts and exclude runtime caches/indices. -- Restore selects the newest valid active-branch entry and tolerates malformed entries. -- Forks inherit behavior, reset statistics, and append a new owner snapshot immediately. -- Current message keys rebuild all compression memberships and active visibility. -- Every successful durable mutation persists once; failed/no-op paths do not. -- Runtime never reads or writes shared DCP sidecars. -- Lifetime scans real Pi JSONL sessions and counts each owner’s latest statistics once. -- Normal parallel and supported-runtime release verification pass without Phase 5 code. - -## Handoff to Phase 5 - -Phase 5 may use `DcpSnapshotV1`, active-branch restore, `durableStateFingerprint`, the command `onStateChange()` callback, and owner-correct lifetime aggregation. It must use this persistence path for new commands and must not add another state store. - -## Release Record - -- Status: complete -- Release commit or tag: not recorded -- Verification date: not recorded diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md b/docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md deleted file mode 100644 index 7754550..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md +++ /dev/null @@ -1,494 +0,0 @@ -# Pi DCP Phase 5 Trusted Operator Controls Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add trusted project configuration and a safe manual compression command while preserving Phase 4 native state behavior. - -**Architecture:** Load defaults, global JSON, and an optional trusted project JSON at `session_start`. Keep one mutable effective-config object so existing handlers observe reloads, register the mode-specific tool after the effective config is known, and gate every command/tool path on current enablement and permission. Project prompt overrides use the same Pi cwd/trust boundary. - -**Tech Stack:** TypeScript ESM, Pi ExtensionAPI 0.80.3-compatible APIs, TypeBox, Vitest, pnpm, and Node standard-library filesystem/path APIs. - ---- - -## Source, Prerequisite, and Boundaries - -- Source requirements: Task 5 of [2026-07-28-pi-dcp-reliability-roadmap.md](2026-07-28-pi-dcp-reliability-roadmap.md). -- Phase 4 prerequisite: [2026-07-28-pi-dcp-phase-4-native-session-state.md](2026-07-28-pi-dcp-phase-4-native-session-state.md) is released and its full checks pass. -- Pi 0.80.3 is authoritative for `ctx.cwd`, `ctx.isProjectTrusted()`, `registerTool()`, and `sendMessage()`. -- Phase 5 does not add a state store, benchmark harness, lifetime scanner, package version bump, tag, or publish action. Benchmarks and final release evidence belong to Phase 6. -- The original reliability roadmap remains byte-for-byte unchanged. - -## File Map - -- `src/config.ts`: parse global/project layers, merge plain objects, replace arrays, clean unknown keys, validate, and return warnings. -- `src/prompts/store.ts`: accept an absent project override directory so untrusted project prompt files cannot be read. -- `src/index.ts`: load trusted project config at session start, retain the stable config object, register/gate the compression tool, and pass the current config to commands. -- `src/commands/register.ts`, `src/commands/compress.ts`: register live-config commands and send the hidden manual-compression follow-up. -- `tests/config.test.ts`, `tests/prompt-store.test.ts`, `tests/commands-register.test.ts`, `tests/commands-compress.test.ts`, `tests/integration.test.ts`: focused config, trust, lifecycle, tool, and command regressions. -- `README.md`, `CHANGELOG.md`: document the Phase 5 operator contract after code verification. - -### Task 1: Load trusted project configuration and prompt overrides - -**Files:** - -- Modify: `src/config.ts`, `src/prompts/store.ts` -- Test: `tests/config.test.ts`, `tests/prompt-store.test.ts` - -- [x] **Step 1: Add failing layered-config tests** - - Add tests that create temporary files and call the loader with explicit absolute paths: - - ```ts - it("merges defaults, global, and project layers", () => { - writeJson(globalPath, { - enabled: false, - compress: { mode: "message", protectedTools: ["read"] }, - protectedFilePatterns: ["**/*.secret"], - }); - writeJson(projectPath, { - enabled: true, - compress: { showCompression: true, protectedTools: ["write"] }, - protectedFilePatterns: ["**/*.key"], - }); - - const result = loadConfig(globalPath, projectPath); - - expect(result.config.enabled).toBe(true); - expect(result.config.compress.mode).toBe("message"); - expect(result.config.compress.showCompression).toBe(true); - expect(result.config.compress.protectedTools).toEqual(["write"]); - expect(result.config.protectedFilePatterns).toEqual(["**/*.key"]); - }); - - it("skips a missing layer and warns for malformed JSON", () => { - fs.writeFileSync(globalPath, "{"); - const result = loadConfig(globalPath, path.join(tempDir, "missing.json")); - expect(result.config).toEqual(DEFAULT_CONFIG); - expect(result.warnings).toContain( - `Unable to parse config file: ${globalPath}`, - ); - }); - - it("cleans unknown keys and warns for invalid values", () => { - writeJson(globalPath, { unknown: true, compress: { mode: "invalid" } }); - const result = loadConfig(globalPath); - expect("unknown" in (result.config as Record)).toBe(false); - expect(result.config.compress.mode).toBe(DEFAULT_CONFIG.compress.mode); - expect( - result.warnings.some((warning) => warning.includes("/compress/mode")), - ).toBe(true); - }); - ``` - - Also assert arrays replace rather than concatenate and that the returned config is a fresh clone on every call. - -- [x] **Step 2: Run the focused tests and confirm the current API fails** - - Run: - - ```bash - pnpm vitest run tests/config.test.ts -t "merges defaults|malformed JSON|unknown keys" - ``` - - Expected: FAIL because `loadConfig()` currently accepts one path, does not parse a second layer, and does not report malformed JSON. - -- [x] **Step 3: Implement the two-layer loader** - - Change the exported signature to: - - ```ts - export function loadConfig( - configFilePath: string, - projectConfigPath?: string, - ): { config: DcpConfig; warnings: string[] }; - ``` - - Make `parseConfigFile()` return `{ value?: Record; warning?: string }`. Treat `ENOENT` as an absent file with no warning; report malformed JSON, non-object JSON, and read failures as `Unable to parse config file: `. Start from `structuredClone(DEFAULT_CONFIG)`, merge the parsed global value, then the parsed project value. Reuse the existing `deepMerge`, `Value.Clean`, validation, semantic range checks, and default replacement logic. Do not add a merge dependency. - -- [x] **Step 4: Add trust-safe prompt-store tests** - - Extend `PromptStore` tests with: - - ```ts - it("uses global overrides when the project directory is absent", () => { - fs.mkdirSync(globalDir, { recursive: true }); - fs.writeFileSync(path.join(globalDir, "system.md"), "Global prompt"); - const store = new PromptStore({ globalOverrideDir: globalDir }); - store.reload(); - expect(store.getRuntimePrompts().system).toBe("Global prompt"); - }); - ``` - - Keep the existing project-over-global test for trusted projects. - -- [x] **Step 5: Make project prompt overrides optional** - - Change the options interface and loader path: - - ```ts - interface PromptStoreOptions { - projectOverrideDir?: string; - globalOverrideDir: string; - } - - private loadOverride(filename: string): string | undefined { - if (this.projectDir) { - const projectContent = this.readAndNormalize(path.join(this.projectDir, filename)); - if (projectContent !== undefined) return projectContent; - } - return this.readAndNormalize(path.join(this.globalDir, filename)); - } - ``` - - Store `projectDir` as `options.projectOverrideDir ?? ""` so existing filesystem error handling remains unchanged. - -- [x] **Step 6: Run config and prompt tests** - - ```bash - pnpm vitest run tests/config.test.ts tests/prompt-store.test.ts - ``` - - Expected: all precedence, warning, merge, and trust-boundary tests pass. - -- [ ] **Step 7: Commit the loader and trust boundary** - - ```bash - git add src/config.ts src/prompts/store.ts tests/config.test.ts tests/prompt-store.test.ts - git commit -m "feat: load trusted project configuration" - ``` - -### Task 2: Load effective config at session start and gate runtime behavior - -**Files:** - -- Modify: `src/index.ts` -- Test: `tests/integration.test.ts`, `tests/commands-register.test.ts` - -- [x] **Step 1: Add failing lifecycle tests** - - Extend the mock context with `cwd` and `isProjectTrusted()` and add these cases: - - ```ts - it("lets trusted project config enable a globally disabled extension", async () => { - writeJson(globalConfigPath, { enabled: false }); - writeJson(path.join(projectCwd, ".pi", "dcp.json"), { - enabled: true, - compress: { mode: "message" }, - }); - const { api, handlers, tools, commands } = createMockApi(); - createExtension(api); - - expect(commands.has("dcp:help")).toBe(true); - expect(tools.has("compress")).toBe(false); - await runHandlers(handlers.get("session_start"), { - cwd: projectCwd, - isProjectTrusted: () => true, - }); - - expect(tools.has("compress")).toBe(true); - expect(getRegisteredCompressParameters(tools)).toMatchObject({ - type: "object", - }); - }); - - it("ignores the project file when the project is untrusted", async () => { - writeJson(globalConfigPath, { enabled: false }); - writeJson(path.join(projectCwd, ".pi", "dcp.json"), { enabled: true }); - const { api, handlers, tools } = createMockApi(); - createExtension(api); - await runHandlers(handlers.get("session_start"), { - cwd: projectCwd, - isProjectTrusted: () => false, - }); - expect(tools.has("compress")).toBe(false); - }); - ``` - - Add a regression that changes the global file between two `session_start` events and confirms existing command handlers observe the updated stable config object. - -- [x] **Step 2: Run the lifecycle tests and confirm the current startup order fails** - - ```bash - pnpm vitest run tests/integration.test.ts tests/commands-register.test.ts -t "trusted|untrusted|stable config" - ``` - - Expected: FAIL because the factory returns before registering handlers when global config is disabled and `session_start` loads only the global path. - -- [x] **Step 3: Register handlers before configuration is known** - - In `createExtension`, initialize the stable object from the global file only, remove the factory-level `if (!config.enabled) return`, and register commands/lifecycle handlers unconditionally. Keep existing Phase 4 snapshot and mutation persistence closures unchanged. - -- [x] **Step 4: Resolve trusted project config from the Pi context** - - Change `reloadConfig` to accept the session context: - - ```ts - function reloadConfig(ctx: ExtensionContext, logDir?: string): void { - const projectConfigPath = ctx.isProjectTrusted() - ? path.join(ctx.cwd, ".pi", "dcp.json") - : undefined; - const result = loadConfig(configFilePath, projectConfigPath); - Object.assign(config, result.config); - logger = new Logger(config.debug, logDir); - for (const warning of result.warnings) logger.info("config", warning); - } - ``` - - Call it before `if (!config.enabled)` inside `session_start`. Use `ctx.cwd` and trust for `PromptStore`; never use `process.cwd()` for project-local prompt overrides. - -- [x] **Step 5: Register the mode-specific tool after effective config load** - - Move the existing two `pi.registerTool()` definitions into a local `registerCompressTool()` function. Call it from `session_start` after `reloadConfig(ctx, logDir)` only when `config.enabled` is true. Re-registering the same `name: "compress"` replaces the extension map entry and Pi refreshes the active registry. - - Every tool execute closure must begin with: - - ```ts - if (!config.enabled) { - return { - content: [ - { type: "text", text: "Compression is disabled by configuration." }, - ], - details: {}, - isError: true, - }; - } - ``` - - Keep the `tool_call` handler’s disabled guard and permission block so a stale registration cannot mutate state after a later session disables DCP. Do not call `setActiveTools()`; it would modify the user’s tool selection. - -- [x] **Step 6: Run lifecycle and full integration tests** - - ```bash - pnpm vitest run tests/integration.test.ts tests/commands-register.test.ts tests/index.test.ts - ``` - - Expected: trusted enablement, untrusted exclusion, mode-specific registration, stable-config reload, disabled pipeline behavior, and Phase 4 persistence regressions pass. - -- [ ] **Step 7: Commit live configuration behavior** - - ```bash - git add src/index.ts tests/integration.test.ts tests/commands-register.test.ts - git commit -m "fix: bind dcp runtime to trusted session config" - ``` - -### Task 3: Add `/dcp:compress [focus]` - -**Files:** - -- Create: `src/commands/compress.ts` -- Modify: `src/commands/register.ts` -- Test: `tests/commands-compress.test.ts`, `tests/commands-register.test.ts`, `tests/integration.test.ts` - -- [x] **Step 1: Add failing command tests** - - Use a fake `ExtensionAPI` that records `sendMessage` calls and assert the exact contract: - - ```ts - it("sends a hidden generic follow-up", () => { - const sendMessage = vi.fn(); - const message = compressCommand( - { sendMessage } as unknown as ExtensionAPI, - createSessionState(), - makeDefaultConfig(), - "", - ); - expect(message).toBe("Compression triggered."); - expect(sendMessage).toHaveBeenCalledWith( - { - customType: "dcp-compress-trigger", - content: - "Run the compress tool now on stale, completed context. Preserve details needed for active work.", - display: false, - }, - { triggerTurn: true, deliverAs: "followUp" }, - ); - }); - - it("trims and includes focus", () => { - const sendMessage = vi.fn(); - compressCommand( - { sendMessage } as unknown as ExtensionAPI, - createSessionState(), - makeDefaultConfig(), - " database migrations ", - ); - expect(sendMessage.mock.calls[0][0].content).toContain( - "Focus especially on: database migrations", - ); - }); - - it("does not send when disabled or denied", () => { - const sendMessage = vi.fn(); - const state = createSessionState(); - state.compressPermission = "deny"; - expect( - compressCommand( - { sendMessage } as unknown as ExtensionAPI, - state, - makeDefaultConfig(), - "focus", - ), - ).toBe("Compression is denied by configuration."); - expect(sendMessage).not.toHaveBeenCalled(); - expect( - compressCommand( - { sendMessage } as unknown as ExtensionAPI, - createSessionState(), - { ...makeDefaultConfig(), enabled: false }, - "focus", - ), - ).toBe("DCP is disabled by configuration."); - expect(sendMessage).not.toHaveBeenCalled(); - }); - ``` - - Add integration assertions for command delivery while idle and while streaming; both must use `{ triggerTurn: true, deliverAs: "followUp" }`. Assert the command does not call `appendEntry`. - -- [x] **Step 2: Run the command tests and confirm the module is absent** - - ```bash - pnpm vitest run tests/commands-compress.test.ts tests/commands-register.test.ts -t "compress" - ``` - - Expected: FAIL because `src/commands/compress.ts` and the `dcp:compress` registration do not exist. - -- [x] **Step 3: Implement the command with an explicit current-config gate** - - Create: - - ```ts - import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - import type { DcpConfig } from "../config.ts"; - import type { SessionState } from "../state/types.ts"; - - const TRIGGER = - "Run the compress tool now on stale, completed context. Preserve details needed for active work."; - - export function compressCommand( - pi: ExtensionAPI, - state: SessionState, - config: DcpConfig, - args: string, - ): string { - if (!config.enabled) return "DCP is disabled by configuration."; - if ((state.compressPermission ?? config.compress.permission) === "deny") { - return "Compression is denied by configuration."; - } - const focus = args.trim(); - pi.sendMessage( - { - customType: "dcp-compress-trigger", - content: focus ? `${TRIGGER} Focus especially on: ${focus}` : TRIGGER, - display: false, - }, - { triggerTurn: true, deliverAs: "followUp" }, - ); - return "Compression triggered."; - } - ``` - -- [x] **Step 4: Register the command against the stable config object** - - Add to `registerDcpCommands`: - - ```ts - pi.registerCommand("dcp:compress", { - description: "Trigger manual compression, optionally focused on a topic", - handler: async (args, ctx) => { - ctx.ui.notify(compressCommand(pi, state, config, args), "info"); - }, - }); - ``` - - Keep the existing four-argument `registerDcpCommands(pi, state, config, onStateChange)` signature so every handler shares the Phase 4 stable config object. Do not call `onStateChange()` for this command. - -- [x] **Step 5: Run command and integration tests** - - ```bash - pnpm vitest run tests/commands-compress.test.ts tests/commands-register.test.ts tests/integration.test.ts - ``` - - Expected: generic/focused triggers, disabled/denied no-send behavior, idle/streaming follow-up delivery, and command registration pass. - -- [ ] **Step 6: Commit manual compression** - - ```bash - git add src/commands/compress.ts src/commands/register.ts tests/commands-compress.test.ts tests/commands-register.test.ts tests/integration.test.ts - git commit -m "feat: add manual compression trigger" - ``` - -### Task 4: Document and release Phase 5 - -**Files:** - -- Modify: `README.md`, `CHANGELOG.md` -- Test/verification: all Phase 5 tests and release checks - -- [x] **Step 1: Document the operator contract** - - Add README sections covering: - - global path `/extensions/dcp.json`; - - trusted project path `/.pi/dcp.json`; - - defaults → global → project precedence, recursive object merge, and array replacement; - - untrusted project exclusion; - - trusted project prompt overrides and global fallback; - - `/dcp:compress [focus]`, hidden follow-up delivery, and denial behavior; - - configuration reload at session start and disabled stale-tool blocking. - - Add matching Unreleased changelog entries without changing the package version. - -- [x] **Step 2: Run focused Phase 5 verification** - - ```bash - pnpm vitest run tests/config.test.ts tests/prompt-store.test.ts tests/commands-register.test.ts tests/commands-compress.test.ts tests/integration.test.ts tests/index.test.ts - pnpm run generate:schema - git diff --exit-code -- dcp.schema.json - ``` - - Expected: all focused tests pass and schema regeneration produces no diff because Phase 5 adds no configuration fields. - -- [x] **Step 3: Run the Phase 5 release checks** - - ```bash - pnpm test - pnpm typecheck - pnpm lint - pnpm pack --dry-run - git diff --check - git diff --exit-code 0dc5ec7 -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md - ``` - - Expected: 442 tests pass, typecheck succeeds, lint does not exceed the Phase 5 entry baseline of 58 warnings and 1 info, packaging succeeds, and the source roadmap is unchanged. Run the release gate on Node 24.15+; Node 24.15.0 is the recorded verification environment. - -- [x] **Step 4: Record Phase 5 completion** - - Update only the Phase 5 status and this plan’s release record after all acceptance criteria pass. Do not mark Phase 6 complete. - - ```bash - git add README.md CHANGELOG.md docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md - git commit -m "docs: complete phase 5 operator controls" - ``` - -## Acceptance Criteria - -- Trusted project configuration uses `ctx.cwd`, is excluded when `ctx.isProjectTrusted()` is false, and follows documented precedence. -- Project prompt overrides use the same trust boundary. -- A trusted project can enable DCP when global config disables it. -- Commands and compression execution observe the current effective config object. -- A disabled or denied `/dcp:compress` sends no follow-up. -- An already registered tool cannot mutate state after DCP becomes disabled. -- Manual compression uses Pi’s hidden follow-up API and does not append durable state. -- Focused tests, full checks, schema regeneration, package dry-run, and source-roadmap immutability pass. - -## Phase 5 Handoff to Phase 6 - -- Effective config loading and stable-object mutation are fixed. -- `compressCommand(pi, state, config, args)` and its exact follow-up contract are fixed. -- Phase 4 snapshot persistence remains the only durable state mechanism. -- Phase 6 owns benchmark fixtures, benchmark output, final release documentation, and roadmap completion. - -## Release Record - -- Status: complete -- Release commit or tag: `0dc5ec7` -- Verification date: 2026-07-29 (Node 24.15.0) diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md b/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md deleted file mode 100644 index 235d47f..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md +++ /dev/null @@ -1,138 +0,0 @@ -# Pi DCP Reliability Phased Roadmap Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Deliver the Pi DCP reliability roadmap as six ordered, independently releasable phases. - -**Architecture:** Preserve the original reliability roadmap as the requirement source and use this document only as the phase index. Each linked phase plan owns one coherent behavior slice, its documentation, acceptance criteria, verification, and handoff interfaces. - -**Tech Stack:** TypeScript ESM, Pi ExtensionAPI 0.80.3-compatible APIs, TypeBox, Vitest, pnpm, and Node standard-library APIs. - ---- - -## Source Documents - -- Requirement source: [2026-07-28-pi-dcp-reliability-roadmap.md](2026-07-28-pi-dcp-reliability-roadmap.md) -- Approved phase design: [2026-07-28-pi-dcp-phased-planning-design.md](../specs/2026-07-28-pi-dcp-phased-planning-design.md) -- Comparative audit: [2026-07-28-pi-dcp-comparative-audit-design.md](../specs/2026-07-28-pi-dcp-comparative-audit-design.md) - -The requirement source must remain unchanged: - -```bash -git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md -``` - -Expected: exit code 0 and no output. - -## Phase Index - -| Phase | Status | Prerequisite | Independently usable result | Detailed plan | -| --------------------------------- | ----------- | ---------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | -| 1. Pruning Foundation | complete | v0.4.1 baseline | Failed inputs are purged without losing diagnostics; lookup outputs can deduplicate | [Phase 1](2026-07-28-pi-dcp-phase-1-pruning-foundation.md) | -| 2. Turn and Pair Safety | complete | Phase 1 complete | Fresh user turns are protected consistently and DCP preserves Pi tool-pair invariants | [Phase 2](2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md) | -| 3. Compression Correctness | complete | Phase 2 complete | In-session compression has correct ownership, accounting, batching, nesting, and timing | [Phase 3](2026-07-28-pi-dcp-phase-3-compression-correctness.md) | -| 4. Native Session State | complete | Phase 3 complete | Reliability state survives resume, fork, tree navigation, and compaction without cross-session leakage | [Phase 4](2026-07-28-pi-dcp-phase-4-native-session-state.md) | -| 5. Trusted Operator Controls | complete | Phase 4 complete | Trusted project config, trust-safe prompts, live commands, and manual compression are available | [Phase 5](2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md) | -| 6. Benchmark and Release Evidence | complete | Phase 5 complete | Deterministic benchmark evidence and final release verification are available | [Phase 6](2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md) | - -Allowed status values are `not started`, `in progress`, `blocked`, and `complete`. - -## Execution Order - -```text -Phase 1: pruning semantics - ↓ -Phase 2: user-turn and pair safety - ↓ -Phase 3: compression correctness - ↓ -Phase 4: native lifecycle persistence - ↓ -Phase 5: trusted operator controls - ↓ -Phase 6: benchmark and release evidence -``` - -Later phases may depend only on stable interfaces explicitly handed off by completed earlier phases. Earlier phases must not contain shims, branches, or dormant code for unfinished later phases. - -## Atomic Phase Contract - -A phase may be marked `complete` only when: - -- Its prerequisite release and entry checks passed before work began. -- Every task and checkbox in its detailed plan is complete. -- Its focused tests pass. -- `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm pack --dry-run`, and `git diff --check` pass. -- Schema generation is checked when the phase changes configuration. -- Existing lint diagnostics do not increase above the recorded baseline. -- User-facing behavior and release notes are documented. -- Its acceptance criteria are demonstrated without code from a later phase. -- Its final commit leaves no temporary compatibility path intended for a later phase. - -If implementation reveals a requirement that crosses phase boundaries, stop and revise this index and the affected detailed plans before continuing. - -## Requirement Ownership - -| Original roadmap requirement | Primary phase | -| ------------------------------------------------- | ------------- | -| Provenance record and verification baseline | Phase 1 | -| Preserve errors while purging failed inputs | Phase 1 | -| Mutation/orchestration-only protected defaults | Phase 1 | -| Top-level user-turn protection | Phase 2 | -| Replace agent-iteration age with user-turn age | Phase 2 | -| Pi-compatible tool-call/result safety | Phase 2 | -| Real compression tool-call ownership | Phase 3 | -| Incremental visible-token accounting | Phase 3 | -| Atomic multi-range batches and nested blocks | Phase 3 | -| Timing for every block in a batch | Phase 3 | -| Versioned Pi custom-entry snapshots | Phase 4 | -| Resume, fork, tree, and compaction restoration | Phase 4 | -| Ignore unsafe shared sidecars for restoration | Phase 4 | -| Accurate per-session lifetime totals | Phase 4 | -| Trusted project configuration | Phase 5 | -| Live command configuration | Phase 5 | -| `/dcp:compress [focus]` | Phase 5 | -| Deterministic informational benchmarks | Phase 6 | -| Final README, schema, package, and release checks | Phase 6 | - -Every original requirement has one primary owner. A later phase may integrate an earlier interface but must not redefine its semantics. - -## Phase Handoffs - -- **Phase 1 → Phase 2:** failed-call arguments and protected-tool policy are stable; successful and failed pruning passes are separate. -- **Phase 2 → Phase 3:** `ToolParameterEntry.userTurn`, `DcpConfig.turnProtection`, and the protected-window helper are stable. -- **Phase 3 → Phase 4:** `CompressionBlock.compressToolCallId`, nested relationships, stable boundaries, token accounting, and batch timing are stable. -- **Phase 4 → Phase 5:** `DcpSnapshotV1`, native snapshot append/restore, mutation persistence callbacks, and session-stat aggregation are stable. -- **Phase 5 → Phase 6:** trusted effective configuration, trust-safe project prompts, live command behavior, disabled-state gating, and the manual compression follow-up contract are stable. -- **Phase 6:** produces deterministic benchmark evidence and the final operator-facing release verification; no later reliability phase is assumed. - -## Global Verification - -Run at the end of every phase: - -```bash -pnpm test -pnpm typecheck -pnpm lint -pnpm pack --dry-run -git diff --check -git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md -``` - -Expected: - -- All tests pass. -- TypeScript reports no errors. -- Lint adds no diagnostics beyond the Phase 1 recorded baseline. -- Package dry-run succeeds. -- No whitespace errors are reported. -- The original roadmap remains unchanged. - -## Completion - -When a phase is released: - -1. Change only that phase’s status in the table. -2. Record the release commit or tag in the phase plan’s completion section. -3. Do not pre-mark later phases as in progress. -4. Begin the next phase only after its entry criteria pass against the released branch. diff --git a/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md b/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md deleted file mode 100644 index 7a242c3..0000000 --- a/docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md +++ /dev/null @@ -1,624 +0,0 @@ -# Pi DCP Reliability Roadmap Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make Pi DCP session state, compression, pruning, configuration, and operator controls reliable across resume, fork, tree navigation, and compaction while preserving existing v0.4 commands and JSON configuration. - -**Architecture:** Use Pi’s native append-only custom session entries instead of a second sidecar persistence system. Store versioned DCP snapshots on the active branch, restore them from `sessionManager.getBranch()`, and rebuild derived runtime indices from current messages. Keep compression and pruning as pure pipeline transformations with explicit tool-call ownership and user-turn age. - -**Tech Stack:** TypeScript ESM, Pi ExtensionAPI 0.80.3-compatible APIs, TypeBox, Vitest, pnpm, Node standard-library filesystem and performance APIs. No new runtime dependencies. - ---- - -## File Map - -- `src/state/types.ts`, `src/state/state.ts`: durable/runtime state separation, snapshot types, user-turn metadata. -- `src/state/persistence.ts`: versioned snapshot serialization/validation and lifetime-session scanning; no session-state file writes. -- `src/index.ts`: Pi lifecycle integration, snapshot restore/append, project config loading, compression-tool registration. -- `src/messages/sync.ts`, `src/messages/inject.ts`: stable reference assignment, snapshot rehydration, stale-anchor cleanup. -- `src/compress/handler.ts`, `src/compress/search.ts`, `src/compress/state.ts`: tool-call ownership, token accounting, nested block relationships, batch timing. -- `src/messages/prune.ts`, `src/strategies/runner.ts`, `src/state/tool-cache.ts`: failed-input semantics, protected tools, user-turn protection, pair safety. -- `src/config.ts`, `src/config-schema.ts`, `src/commands/register.ts`, `src/commands/compress.ts`: project precedence, live config access, manual compression. -- `tests/`: focused regression tests for each task plus Pi lifecycle mocks. -- `scripts/benchmark.ts`, `package.json`, `README.md`, `dcp.schema.json`: deterministic benchmark, documentation, and generated schema. - -## Compatibility Decisions - -- Existing shared `{sessionDir}/dcp/state.json` files are left untouched and are never used to restore runtime state. They contain a random DCP ID rather than Pi’s session ID and cannot be safely attributed. -- New snapshots use `customType: "pi-dcp-state"` and `version: 1`. -- `turnProtection` is a new top-level number defaulting to `0`; `0` preserves current behavior. -- Existing `strategies.deduplication.turnProtection` remains valid and applies only to deduplication. Deduplication uses the larger of the legacy and top-level values. -- Default protected tools are `compress`, `write`, `edit`, and `subagent`. -- Project config is JSON, merged as defaults → global config → trusted `/.pi/dcp.json`; nested objects merge and arrays replace. -- Benchmarks produce informational before/after evidence. No 10%/15% release gate is added without measured variance data. - -### Task 1: Record provenance and establish verification baselines - -**Files:** - -- Create: `docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md` -- Inspect: the four reference repositories listed in the comparative audit - -- [ ] **Step 1: Write the provenance record** - - Record each repository’s commit, package version, declared license, corresponding modules, and the rule that external source is behavioral reference only. Record Pi’s `appendEntry`, `getBranch`, `getSessionId`, `session_tree`, `sendMessage`, and project-trust APIs as verified in the installed 0.80.3 dependency and reference 0.82.0 checkout. - -- [ ] **Step 2: Capture the current baseline** - - Run: - - ```bash - pnpm test - pnpm typecheck - pnpm lint - git diff --check - ``` - - Record the passing test count, typecheck result, and existing lint-warning count. The audit baseline is 368 passing tests, successful typechecking, and 88 existing lint warnings; later verification must not add warnings. - -- [ ] **Step 3: Commit the provenance record** - - ```bash - git add docs/superpowers/audits/2026-07-28-pi-dcp-provenance.md - git commit -m "docs: record pi-dcp provenance baseline" - ``` - -### Task 2: Replace shared sidecar state with native Pi snapshots - -**Files:** - -- Modify: `src/state/types.ts`, `src/state/state.ts`, `src/state/persistence.ts` -- Modify: `src/index.ts`, `src/messages/sync.ts`, `src/messages/inject.ts` -- Test: `tests/persistence.test.ts`, `tests/integration.test.ts`, `tests/messages-inject.test.ts` - -- [ ] **Step 1: Add failing snapshot contract tests** - - Add tests for the exact serialized shape and branch behavior: - - ```ts - import type { CompressionBlock, DcpSnapshotV1 } from "../src/state/types.ts"; - import { createSessionState } from "../src/state/state.ts"; - import { - restoreDcpSnapshot, - serializeDcpSnapshot, - } from "../src/state/persistence.ts"; - - const makeActiveBlock = (blockId: number): CompressionBlock => ({ - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 20, - summaryTokens: 5, - durationMs: 1, - mode: "range", - topic: "fixture", - batchTopic: "fixture", - startIndex: 0, - endIndex: 1, - anchorIndex: 0, - compressToolCallId: "compress-call-1", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIndices: [0, 1], - directToolIds: [], - effectiveMessageIndices: [0, 1], - effectiveToolIds: [], - createdAt: 1, - deactivatedAt: undefined, - deactivatedByBlockId: undefined, - summary: "[Compressed Block b1] fixture [End Block b1]", - }); - - const makeSnapshot = (ownerSessionId: string): DcpSnapshotV1 => { - const fixture = createSessionState(); - fixture.prune.messages.blocksById.set(1, makeActiveBlock(1)); - return serializeDcpSnapshot(fixture, ownerSessionId); - }; - - const makeSnapshotWithMalformedBlock = (): DcpSnapshotV1 => { - const snapshot = makeSnapshot("pi-session-1"); - snapshot.blocks.push({ blockId: "invalid" } as unknown as CompressionBlock); - return snapshot; - }; - - it("round-trips durable state without runtime caches", () => { - const state = createSessionState(); - state.stats.messagesCompressed = 2; - state.prune.tools.set("call-1", 42); - state.prune.messages.blocksById.set(1, makeActiveBlock(1)); - - const snapshot = serializeDcpSnapshot(state, "pi-session-1"); - const restored = restoreDcpSnapshot( - snapshot, - createSessionState(), - "pi-session-1", - ); - - expect(restored.stats.messagesCompressed).toBe(2); - expect(restored.prune.tools.get("call-1")).toBe(42); - expect(restored.prune.messages.blocksById.has(1)).toBe(true); - expect(restored.toolParameters.size).toBe(0); - }); - - it("resets only statistics when restoring a fork-owned snapshot", () => { - const restored = restoreDcpSnapshot( - makeSnapshot("parent"), - createSessionState(), - "child", - ); - expect(restored.prune.messages.blocksById.size).toBe(1); - expect(restored.stats.toolsPruned).toBe(0); - }); - - it("ignores malformed blocks while retaining valid blocks", () => { - const restored = restoreDcpSnapshot( - makeSnapshotWithMalformedBlock(), - createSessionState(), - "pi-session-1", - ); - expect(restored.prune.messages.blocksById.has(1)).toBe(true); - expect(restored.prune.messages.blocksById.has(2)).toBe(false); - }); - ``` - -- [ ] **Step 2: Define the durable snapshot type** - - Add this shape to `src/state/types.ts`: - - ```ts - export interface DcpSnapshotV1 { - version: 1; - ownerSessionId: string; - manualMode: false | "active"; - compressPermission: "allow" | "deny"; - stats: SessionStats; - lastCompaction: number; - pruneTools: Array<[string, number]>; - blocks: CompressionBlock[]; - nextBlockId: number; - nextRunId: number; - messageIds: { - byRawId: Record; - byRef: Record; - nextRefIndex: number; - }; - nudges: { - contextLimitAnchors: string[]; - turnAnchors: string[]; - iterationAnchors: string[]; - }; - } - ``` - - Do not serialize `toolParameters`, `toolIdList`, `byIndex`, `activeBlockIds`, `activeByAnchorIndex`, model metadata, timing maps, or sub-agent result caches; these are rebuilt or are ephemeral. - -- [ ] **Step 3: Implement serializer and validator** - - Implement `serializeDcpSnapshot(state, ownerSessionId)` and `restoreDcpSnapshot(snapshot, state, currentSessionId)`. Serialize `state.compressPermission ?? "allow"`. Validate the root object, version, owner ID, finite numbers, arrays, map-entry shapes, and every compression block. Discard malformed blocks and send one warning per discarded block to the existing logger. Rebuild active block IDs, anchor mappings, and per-message block entries from valid blocks instead of trusting duplicated serialized sets. - -- [ ] **Step 4: Restore from the active Pi branch** - - In `session_start` and `session_tree`, scan `ctx.sessionManager.getBranch()` from newest to oldest for `type === "custom" && customType === "pi-dcp-state"`. Restore the newest valid snapshot, then set `state.sessionId = ctx.sessionManager.getSessionId()`. - -- [ ] **Step 5: Append snapshots after durable mutations** - - Add a closure in `src/index.ts`: - - ```ts - const persistSnapshot = (ctx: ExtensionContext): void => { - pi.appendEntry( - "pi-dcp-state", - serializeDcpSnapshot(state, ctx.sessionManager.getSessionId()), - ); - }; - ``` - - Add `durableStateFingerprint(state): string` as `JSON.stringify(serializeDcpSnapshot(state, "fingerprint"))`. Capture the fingerprint before each pipeline pass or command, and call `persistSnapshot(ctx)` only when the fingerprint changes after successful compression, strategy/nudge changes, sweep, manual/permission changes, decompress/recompress, compaction reset, or tree navigation. - -- [ ] **Step 6: Rehydrate message indices in the pipeline** - - Reorder `runPipeline` so `assignMessageRefs(state, messages)` runs before compression-block restoration. Map each block’s stable boundary keys to current indices, deactivate blocks whose boundaries no longer exist, and remove nudge anchors whose keys are absent. Keep existing message-reference formats for compatibility. - -- [ ] **Step 7: Run focused persistence tests** - - ```bash - pnpm vitest run tests/persistence.test.ts tests/integration.test.ts tests/messages-inject.test.ts - ``` - - Expected: snapshot round-trip, fork ownership, malformed-block handling, stale-anchor cleanup, and session-entry restoration pass. - -- [ ] **Step 8: Commit native persistence** - - ```bash - git add src/state src/index.ts src/messages/sync.ts src/messages/inject.ts tests/persistence.test.ts tests/integration.test.ts tests/messages-inject.test.ts - git commit -m "fix: persist dcp state in pi session entries" - ``` - -### Task 3: Make compression ownership, nesting, and token accounting correct - -**Files:** - -- Modify: `src/state/types.ts`, `src/compress/handler.ts`, `src/compress/search.ts`, `src/compress/state.ts` -- Modify: `src/index.ts`, `src/messages/sync.ts` -- Test: `tests/compress-range.test.ts`, `tests/compress-cycle.test.ts`, `tests/compress-notification.test.ts` - -- [ ] **Step 1: Add failing compression regressions** - - Cover these exact behaviors: - - ```ts - import type { AgentMessage } from "@earendil-works/pi-agent-core"; - import { - makeAssistantMessage, - makeDefaultConfig, - makeUserMessage, - } from "./helpers.ts"; - import { createSessionState } from "../src/state/state.ts"; - - const state = createSessionState(); - const config = makeDefaultConfig(); - const messages: AgentMessage[] = [ - makeUserMessage("one", 1000), - makeAssistantMessage("two", 1001), - makeUserMessage("three", 1002), - makeAssistantMessage("four", 1003), - ]; - state.messageIds.byIndex.set(0, "m0001"); - state.messageIds.byIndex.set(1, "m0002"); - state.messageIds.byIndex.set(2, "m0003"); - state.messageIds.byIndex.set(3, "m0004"); - - it("reports nonzero incremental tokens", () => { - const rangeArgs: CompressArgs = { - mode: "range", - topic: "fixture", - content: [ - { startId: "m0001", endId: "m0002", summary: "fixture summary" }, - ], - }; - const result = handleCompress( - state, - config, - messages, - "compress-call-1", - rangeArgs, - ); - expect(result.compressedTokens).toBeGreaterThan(0); - }); - - it("records every block created by a batch under one tool call", () => { - const batchArgs: CompressArgs = { - mode: "range", - topic: "fixture", - content: [ - { startId: "m0001", endId: "m0001", summary: "first" }, - { startId: "m0003", endId: "m0003", summary: "second" }, - ], - }; - const result = handleCompress( - state, - config, - messages, - "compress-call-1", - batchArgs, - ); - expect(result.blockIds).toHaveLength(2); - for (const id of result.blockIds) { - expect(state.prune.messages.blocksById.get(id)?.compressToolCallId).toBe( - "compress-call-1", - ); - } - }); - - it("consumes a contained active block and restores original content after parent deactivation", () => { - const childArgs: CompressArgs = { - mode: "range", - topic: "child", - content: [{ startId: "m0002", endId: "m0002", summary: "child" }], - }; - const parentArgs: CompressArgs = { - mode: "range", - topic: "parent", - content: [{ startId: "m0001", endId: "m0003", summary: "parent" }], - }; - const child = handleCompress( - state, - config, - messages, - "compress-child", - childArgs, - ).blockIds[0]; - const parent = handleCompress( - state, - config, - messages, - "compress-parent", - parentArgs, - ).blockIds[0]; - expect( - state.prune.messages.blocksById.get(parent).consumedBlockIds, - ).toContain(child); - expect(decompressCommand(state, String(parent))).toContain("restored"); - }); - ``` - -- [ ] **Step 2: Change block ownership and handler signatures** - - Extend `CompressionBlock` with `compressToolCallId: string` and remove `compressMessageIndex`. Change the handler signature to: - - ```ts - export function handleCompress( - state: SessionState, - config: DcpConfig, - messages: AgentMessage[], - compressToolCallId: string, - args: CompressArgs, - ): CompressResult; - ``` - -- [ ] **Step 3: Resolve complete, non-overlapping selections** - - Make `resolveSelection()` return `{ startIndex, endIndex, messageIndices, consumedBlockIds }`. Expand ranges until all referenced tool calls/results and all touched active blocks are complete. Reject two input entries that overlap after expansion before allocating any block ID or mutating state. - -- [ ] **Step 4: Calculate incremental visible tokens** - - In `applyCompressionState`, count each uncompressed selected message with `countMessageTokens()`. For an active block consumed by the new block, count its current summary tokens once instead of counting its original messages again. Set `compressedTokens` to the visible tokens removed by the new block and `summaryTokens` to the replacement summary count. - -- [ ] **Step 5: Attach timing to all blocks** - - Replace `callIdToBlockId: Map` with `callIdToBlockIds: Map`. In `tool_execution_end`, use the `CompressResult.blockIds` recorded by the tool execution and apply one duration to every block in the call. Delete the newest-block timestamp scan. - -- [ ] **Step 6: Rehydrate and test compression cycles** - - Restore block indices after message refs are assigned, then run: - - ```bash - pnpm vitest run tests/compress-range.test.ts tests/compress-cycle.test.ts tests/compress-notification.test.ts - ``` - - Expected: nonzero savings, atomic batches, nested consume/deactivate behavior, and all-block timing pass. - -- [ ] **Step 7: Commit compression correctness** - - ```bash - git add src/state/types.ts src/compress src/index.ts src/messages/sync.ts tests/compress-range.test.ts tests/compress-cycle.test.ts tests/compress-notification.test.ts - git commit -m "fix: make compression state accurate and resumable" - ``` - -### Task 4: Correct pruning semantics and user-turn protection - -**Files:** - -- Modify: `src/config.ts`, `src/config-schema.ts`, `src/state/types.ts`, `src/state/tool-cache.ts` -- Modify: `src/messages/prune.ts`, `src/strategies/runner.ts`, `src/compress/search.ts`, `src/compress/handler.ts` -- Test: `tests/config.test.ts`, `tests/tool-cache.test.ts`, `tests/prune.test.ts`, `tests/strategy-runner.test.ts`, `tests/turn-protection.test.ts` - -- [ ] **Step 1: Add failing error-input tests** - - Assert that a stale failed call becomes: - - ```ts - expect(toolCall.arguments).toEqual({ - __purged: "input removed due to failed tool call", - }); - expect(errorResult.content).toEqual(originalErrorContent); - ``` - - Assert that successful pruned results still use the existing output marker. - -- [ ] **Step 2: Implement failed-input pruning** - - Replace `pruneToolErrors()` with an assistant-content pass keyed by `state.toolParameters` entries whose status is `error` and whose IDs are in `state.prune.tools`. Leave all `toolResult` content unchanged. - -- [ ] **Step 3: Add the top-level protection setting** - - Add this schema property: - - ```ts - turnProtection: Type.Number({ - default: 0, - minimum: 0, - description: "Protect the newest N user turns from all automatic DCP transformations", - }), - ``` - - Add it to `DEFAULT_CONFIG` and preserve existing nested deduplication configuration. - -- [ ] **Step 4: Derive user-turn ordinals** - - Add `userTurn: number` to `ToolParameterEntry`. In `syncToolCache`, count raw `user` messages in order and assign each tool call the ordinal of the most recent user message. Remove `SessionState.currentTurn`, its `turn_end` increment, and its persistence. - -- [ ] **Step 5: Apply one protection rule everywhere** - - Use `max(config.turnProtection, config.strategies.deduplication.turnProtection)` for deduplication and `config.turnProtection` for stale errors, sweep, and compression. Add this helper: - - ```ts - export function getProtectedTurnStart( - messages: AgentMessage[], - turns: number, - ): number | undefined { - if (turns <= 0) return undefined; - const userIndices = messages.flatMap((message, index) => - message.role === "user" ? [index] : [], - ); - return userIndices[Math.max(0, userIndices.length - turns)]; - } - ``` - - With fewer than `turns` user messages, this returns the first user message index so every existing turn is protected. - -- [ ] **Step 6: Keep Pi-compatible pair safety** - - Keep removal of orphan `toolResult` messages created by DCP. Do not remove unmatched assistant tool-call blocks; Pi’s provider transform inserts an error result for them. Ensure range expansion prevents DCP from creating either orphan direction. - -- [ ] **Step 7: Change the default protected set** - - Set: - - ```ts - export const BASE_PROTECTED_TOOLS = ["compress", "write", "edit", "subagent"]; - ``` - -- [ ] **Step 8: Run focused pruning tests and commit** - - ```bash - pnpm vitest run tests/config.test.ts tests/tool-cache.test.ts tests/prune.test.ts tests/strategy-runner.test.ts tests/turn-protection.test.ts - git add src/config.ts src/config-schema.ts src/state src/messages/prune.ts src/strategies/runner.ts src/compress/search.ts src/compress/handler.ts tests/config.test.ts tests/tool-cache.test.ts tests/prune.test.ts tests/strategy-runner.test.ts tests/turn-protection.test.ts - git commit -m "fix: preserve errors and protect recent user turns" - ``` - -### Task 5: Load trusted project configuration and add manual compression - -**Files:** - -- Modify: `src/config.ts`, `src/index.ts`, `src/commands/register.ts` -- Create: `src/commands/compress.ts` -- Test: `tests/config.test.ts`, `tests/commands-compress.test.ts`, `tests/commands-register.test.ts`, `tests/integration.test.ts` - -- [ ] **Step 1: Add config precedence tests** - - Create temporary global and project JSON files and assert defaults → global → project precedence, recursive object merging, array replacement, invalid-value warnings, and project-file exclusion when `ctx.isProjectTrusted()` is false. - -- [ ] **Step 2: Extend config loading** - - Change the signature to: - - ```ts - export function loadConfig( - configFilePath: string, - projectConfigPath?: string, - ): { config: DcpConfig; warnings: string[] }; - ``` - - Parse both files with the existing JSON parser, deep-merge, clean unknown properties, and validate once. - -- [ ] **Step 3: Load effective config during session start** - - Resolve the project path from `ctx.cwd` and only merge it when `ctx.isProjectTrusted()` is true. Remove the factory-time `if (!config.enabled) return`; command handlers and lifecycle listeners must remain available so project config can enable DCP. - -- [ ] **Step 4: Register the compression tool after effective config is known** - - Register the mode-specific `compress` tool from the `session_start` handler after config load. Pi’s installed ExtensionAPI supports registering tools after the extension factory and refreshes the active tool registry. Keep the execute closure reading the current `config` variable. - -- [ ] **Step 5: Make commands read live config and persist mutations** - - Change registration to: - - ```ts - registerDcpCommands( - pi: ExtensionAPI, - state: SessionState, - getConfig: () => DcpConfig, - onStateChange: () => void, - ): void; - ``` - - Use `getConfig()` inside sweep, manual, permission, and compression handlers. Call `onStateChange()` after every successful durable command mutation. - -- [ ] **Step 6: Add the manual command** - - Implement `compressCommand(pi, state, args)` with this behavior: - - ```ts - if ((state.compressPermission ?? "allow") === "deny") { - return "Compression is denied by configuration."; - } - pi.sendMessage( - { - customType: "dcp-compress-trigger", - content: args.trim() - ? `Compress stale context now, focusing on: ${args.trim()}` - : "Compress stale context now using the compress tool.", - display: false, - }, - { triggerTurn: true, deliverAs: "followUp" }, - ); - return "Compression triggered."; - ``` - - Register it as `dcp:compress`; deny without sending a message. - -- [ ] **Step 7: Test lifecycle and command behavior** - - ```bash - pnpm vitest run tests/config.test.ts tests/commands-compress.test.ts tests/commands-register.test.ts tests/integration.test.ts - ``` - - Expected: trusted project precedence, live config reads, disabled/enabled startup, idle trigger, streaming follow-up, and denied permission pass. - -- [ ] **Step 8: Commit lifecycle and controls** - - ```bash - git add src/config.ts src/index.ts src/commands src/config-schema.ts tests/config.test.ts tests/commands-compress.test.ts tests/commands-register.test.ts tests/integration.test.ts dcp.schema.json - git commit -m "feat: add trusted project config and manual compression" - ``` - -### Task 6: Make lifetime reporting, benchmarks, and release documentation accurate - -**Files:** - -- Modify: `src/state/persistence.ts`, `src/commands/lifetime.ts`, `README.md`, `dcp.schema.json`, `package.json` -- Create: `scripts/benchmark.ts`, `tests/benchmark.test.ts`, `tests/commands-lifetime.test.ts` - -- [ ] **Step 1: Add session-file lifetime fixtures** - - Create JSONL fixtures containing Pi session headers and `pi-dcp-state` custom entries. Assert that `loadAllSessionStats()` scans actual session files, selects the latest snapshot per owner session, resets fork-inherited statistics, and ignores legacy `dcp/state.json` files. - -- [ ] **Step 2: Implement true session lifetime scanning** - - Scan session JSONL files under the supplied sessions parent directory with Node’s standard-library filesystem APIs. Parse only custom entries with `customType === "pi-dcp-state"`, select the newest entry per `ownerSessionId`, and sum its session statistics. Skip malformed lines and files without valid snapshots. - -- [ ] **Step 3: Add deterministic benchmark workloads** - - Create `scripts/benchmark.ts` with three fixed fixtures: 2,000 clean messages, 2,000 repeated tool pairs with stale errors, and a restored state containing 100 active/nested blocks. Run 30 iterations with `performance.now()`, report median/p95, input/output tokens, and token reduction as JSON. Do not assert wall-clock timing in Vitest. - -- [ ] **Step 4: Add benchmark correctness tests** - - Assert that the pruning fixture reduces estimated tokens, retains protected tools, preserves error text, and produces no orphaned results. Assert that the restored nested fixture rehydrates the same active blocks and relationships. - -- [ ] **Step 5: Add the benchmark command and documentation** - - Add: - - ```json - "benchmark": "tsx scripts/benchmark.ts" - ``` - - Document that benchmark output is informational and should be captured before and after reliability changes on the same machine and Node version. - -- [ ] **Step 6: Regenerate schema and run release checks** - - ```bash - pnpm test - pnpm typecheck - pnpm lint - pnpm run generate:schema - pnpm run benchmark - pnpm pack --dry-run - git diff --check - ``` - - Expected: all tests pass, typecheck succeeds, lint reports no new diagnostics beyond the recorded baseline, generated schema is unchanged except for intentional fields, benchmark JSON contains all workloads, and package dry-run succeeds. - -- [ ] **Step 7: Commit final reporting changes** - - ```bash - git add src/state/persistence.ts src/commands/lifetime.ts scripts/benchmark.ts tests/benchmark.test.ts tests/commands-lifetime.test.ts README.md package.json dcp.schema.json - git commit -m "test: add session lifetime and dcp benchmarks" - ``` - -## Self-Review Checklist - -- Pi-native persistence replaces the shared sidecar and covers resume, fork, tree navigation, and compaction. -- Snapshot ownership prevents fork statistics from being counted twice. -- Compression state includes real tool-call ownership, nonzero token accounting, nested relationships, and batch timing. -- Failed-input pruning preserves diagnostics. -- User-turn protection is opt-in and applies consistently across strategies and compression. -- Project configuration uses Pi’s cwd and trust state, and commands read live configuration. -- Manual compression uses the verified `sendMessage` API. -- Lifetime statistics scan Pi sessions rather than project sidecars. -- Benchmarks are deterministic and informational rather than an unsupported hard gate. -- No task copies source from AGPL repositories or adds a runtime dependency. -- All code-changing tasks include focused tests, commands, expected outcomes, and a commit boundary. diff --git a/docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md b/docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md deleted file mode 100644 index 8efc4ae..0000000 --- a/docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md +++ /dev/null @@ -1,277 +0,0 @@ -# Pi DCP Phase 6 Benchmark and Release Evidence Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Produce deterministic benchmark evidence and complete final release verification for the Pi DCP reliability roadmap. - -**Architecture:** Add one maintainer-only benchmark harness that exercises the existing production pipeline, token estimator, snapshot parser, and state rehydration code. Fixtures are deterministic; each timed workload includes cloning, fresh-state setup, restoration where applicable, and pipeline execution. The CLI emits one JSON report, and the Node 24.15.0 result is retained at `benchmarks/result.json`. - -**Tech Stack:** TypeScript ESM, Node `performance.now()`, Pi DCP state/pipeline helpers, Vitest, `tsx`, pnpm, Biome, and Node standard-library APIs. - ---- - -## Readiness Audit and Boundaries - -- Phase 6 started from clean commit `0dc5ec7`; its final Node 24.15.0 gate passes 446 tests, typecheck, package dry-run, and the established lint baseline of 58 warnings plus 1 info. -- The previous version of this plan was not implementation-ready: it referenced test-only `makeDefaultConfig()`, seeded state before `restoreDcpSnapshot()` cleared it, repeated Phase 5/roadmap work already completed, used a stale test count, and did not define an evidence artifact path. -- Use production `DEFAULT_CONFIG`; let real `compress` call/result messages rebuild `toolParameters` through `runPipeline()` after restoration. Do not manually seed runtime caches. -- Phase 6 changes no runtime behavior, schema fields, lifetime aggregation, package version, dependency, tag, or publish workflow. Benchmark output is informational; no timing or token-reduction release threshold is added. -- The original reliability roadmap at `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md` must remain byte-for-byte unchanged. The phased index is already six phases and Phase 5 is already complete; do not redo those edits. -- The OpenCode DCP, Davidcreador, and complexthings repositories are behavioral/provenance references only. Pi core supplies native session/message contracts. Copy no source from those repositories. - -## File Map - -- Create `scripts/benchmark.ts`: deterministic fixtures, workload runner, statistics, report types, and import-safe CLI. -- Create `tests/benchmark.test.ts`: fixture correctness and report-shape tests without elapsed-time thresholds. -- Create `benchmarks/result.json`: committed Node 24.15.0 informational report; it remains outside the package through the existing `files` allowlist. -- Modify `package.json`, `README.md`, and `CHANGELOG.md` for the maintainer command and documentation. -- Modify the Phase 5 plan only to record its already-merged release commit; modify the Phase 6 plan and phased index only when Phase 6 verification is complete. - -### Task 1: Add failing benchmark correctness tests - -**Files:** - -- Create: `tests/benchmark.test.ts` -- Test imports: `scripts/benchmark.ts` - -- [x] **Step 1: Define the report and workload imports in the test** - - Import `buildCleanWorkload`, `buildRepeatedToolWorkload`, `buildRestoredNestedWorkload`, `runBenchmarkSuite`, and `BenchmarkReport` from `../scripts/benchmark.ts`. - -- [x] **Step 2: Add clean and repeated-tool correctness tests** - - Assert that the clean workload returns exactly 2,000 messages and every output role is `user` or `assistant`. - - Assert that the repeated workload: - - has fewer output than input estimated tokens; - - retains at least one `write` tool result and its original content; - - retains an error result whose text contains `stale error`; - - contains no `toolResult` without a matching assistant owner. - - The projection must describe the transformed production messages; it must not implement a second pruning algorithm. - -- [x] **Step 3: Add the restored-nesting correctness test** - - Assert that the restored workload has 100 blocks, 10 active blocks, active outer IDs `10, 20, 30, 40, 50, 60, 70, 80, 90, 100`, and complete bidirectional relationships: - - ```ts - for (const block of result.state.prune.messages.blocksById.values()) { - for (const childId of block.consumedBlockIds) { - expect( - result.state.prune.messages.blocksById.get(childId)?.parentBlockIds, - ).toContain(block.blockId); - } - } - ``` - -- [x] **Step 4: Add the report-shape test** - - Call `runBenchmarkSuite(1)` and assert the exact workload names: - - ```ts - [ - "clean-2000-messages", - "repeated-tool-pairs-2000", - "restored-nested-blocks-100", - ]; - ``` - - Assert non-negative timing/token fields, `p95Ms >= medianMs`, and: - - ```ts - reductionEstimatedTokens === inputEstimatedTokens - outputEstimatedTokens; - ``` - -- [x] **Step 5: Confirm the red state** - - Run `pnpm vitest run tests/benchmark.test.ts`. - - Expected: failure because `scripts/benchmark.ts` does not yet exist. - -### Task 2: Implement the benchmark harness - -**Files:** - -- Create: `scripts/benchmark.ts` -- Modify: `tests/benchmark.test.ts` only if the finalized projection type requires narrower assertions. - -- [x] **Step 1: Add the report and internal run contracts** - - Export: - - ```ts - export interface BenchmarkWorkloadReport { - name: string; - medianMs: number; - p95Ms: number; - inputEstimatedTokens: number; - outputEstimatedTokens: number; - reductionEstimatedTokens: number; - } - - export interface BenchmarkReport { - nodeVersion: string; - iterations: number; - workloads: BenchmarkWorkloadReport[]; - } - ``` - - Keep projection, run-result, and workload interfaces internal except for the named builders and `runBenchmarkSuite` used by Vitest. - -- [x] **Step 2: Implement the clean workload** - - Build exactly 2,000 alternating user/assistant messages with timestamps `1_000 + index` and text `clean message ${index}`. Each `run()` must clone the fixture, create a fresh `createSessionState()`, use a cloned `DEFAULT_CONFIG`, call `runPipeline(state, config, messages, undefined)`, and count input/output with `countMessageTokens()`. - -- [x] **Step 3: Implement the repeated-tool workload** - - Build exactly 2,000 assistant/tool-result pairs. Insert one user message before every 20-pair group. For pair index `i`: - - `i % 25 === 0`: use protected `write` with unique arguments and output; - - otherwise: use repeated `read` arguments across five stable paths and a large repeated result; - - for non-write `i % 10 === 0`: mark the result as an error containing `stale error` and use a large repeated argument payload. - - Configure enabled deduplication and purge-errors with top-level `turnProtection: 0`; use the production `runPipeline`. Return transformed messages, state, token counts, and a projection containing tool name, error flag, result text, and owner presence. - -- [x] **Step 4: Implement the restored nested workload** - - Build ten chains of ten blocks. For each chain, create ten user messages followed by real completed `compress` assistant/tool-result pairs. For block IDs `chain * 10 + level + 1`: - - use stable message keys for the chain’s first user, the current user, and the owner call; - - use deterministic summary text and non-negative token counters; - - set `consumedBlockIds` to only the immediately previous block ID for levels after the first; - - make the outer block the newest block in each chain. - - Create a valid `DcpSnapshotV1` with `nextBlockId` and `nextRunId` set to 101, restore it into a fresh state with `restoreDcpSnapshot()`, then call `runPipeline()` so production cache and relationship rebuilding are exercised. - -- [x] **Step 5: Add standard-library statistics and suite execution** - - Implement median and nearest-rank p95 using sorted copies of duration arrays. For each workload, run one untimed warm-up, then exactly `iterations` timed calls with `performance.now()` immediately before and after `workload.run()`. Use the first timed result for token counts; do not average token counts. - -- [x] **Step 6: Add the import-safe CLI** - - Use `pathToFileURL(process.argv[1])` to detect the entry point. When invoked directly, write exactly one serialized `BenchmarkReport` plus a newline to stdout. Imports from Vitest must not execute any benchmark. - -- [x] **Step 7: Run focused verification** - - Run `mise exec node@24.15.0 -- pnpm vitest run tests/benchmark.test.ts` and `mise exec node@24.15.0 -- pnpm typecheck`. - - Expected: all benchmark tests pass and typecheck reports no errors. - -- [x] **Step 8: Commit the harness** - - ```bash - git add scripts/benchmark.ts tests/benchmark.test.ts - git commit -m "test: add deterministic dcp benchmarks" - ``` - -### Task 3: Add command, artifact, and documentation - -**Files:** - -- Modify: `package.json`, `README.md`, `CHANGELOG.md` -- Create: `benchmarks/result.json` - -- [x] **Step 1: Add the maintainer command** - - Add exactly: - - ```json - "benchmark": "tsx scripts/benchmark.ts" - ``` - - Do not add a runtime dependency or change the existing package `files` list. - -- [x] **Step 2: Document interpretation and artifact retention** - - In README development documentation, state that fixtures are deterministic but whole-workload timings vary by machine and Node version; comparisons must use the same machine and Node version. Document all three workload names, all report fields, and `benchmarks/result.json`. - - Add only a benchmark evidence entry to the Unreleased changelog. Do not duplicate the existing Phase 4 native-state or Phase 5 operator-control entries. - -- [x] **Step 3: Generate the retained report** - - Run: - - ```bash - mise exec node@24.15.0 -- pnpm benchmark > benchmarks/result.json - ``` - - Parse the file as one JSON object and confirm it has Node `v24.15.0`, 30 iterations, and the three required workload names. - -- [x] **Step 4: Verify package exclusion** - - Run `mise exec node@24.15.0 -- pnpm pack --dry-run`. - - Expected: runtime sources and user-facing docs are listed; `scripts/benchmark.ts`, `tests/`, and `benchmarks/result.json` are absent. - -- [x] **Step 5: Commit command and docs** - - ```bash - git add package.json README.md CHANGELOG.md benchmarks/result.json - git commit -m "docs: record dcp benchmark evidence" - ``` - -### Task 4: Record release evidence and complete the roadmap - -**Files:** - -- Modify: `docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md` -- Modify: `docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md` -- Modify: `docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md` only after all Phase 6 checks pass. - -- [x] **Step 1: Record the already-merged Phase 5 commit** - - The Phase 5 release record now contains commit `0dc5ec7`, status `complete`, and its existing verification date. No Phase 6 work is marked complete. - -- [x] **Step 2: Run the full Node 24.15.0 release gate** - - ```bash - mise exec node@24.15.0 -- pnpm test - mise exec node@24.15.0 -- pnpm typecheck - mise exec node@24.15.0 -- pnpm lint - mise exec node@24.15.0 -- pnpm run generate:schema - git diff --exit-code -- dcp.schema.json - mise exec node@24.15.0 -- pnpm benchmark > /tmp/pi-dcp-phase-6.json - mise exec node@24.15.0 -- pnpm pack --dry-run - git diff --check - git diff --exit-code 0dc5ec7 -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md - ``` - - Observed: 446 tests pass; typecheck succeeds; lint remains at 58 warnings and 1 info; schema is unchanged; temporary benchmark JSON parses; package contents exclude maintainer artifacts; and the original roadmap matches the base commit. - -- [x] **Step 3: Run the existing automated operator substitute** - - Run the Phase 5 configuration, command, integration, lifecycle, and persistence tests. Do not require a live TUI or paid model call because Phase 6 adds no runtime behavior. - -- [x] **Step 4: Record Phase 6 completion** - - After every acceptance criterion passes, set Phase 6 to `complete`, record the verification date, Node version, release commit, and `benchmarks/result.json` path in this plan, then update the phased index’s completion section. Do not modify the original reliability roadmap. - - ```bash - git add docs/superpowers/plans/2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md \ - docs/superpowers/plans/2026-07-29-pi-dcp-phase-6-benchmark-and-release-evidence.md \ - docs/superpowers/plans/2026-07-28-pi-dcp-reliability-phased-roadmap.md - git commit -m "docs: complete dcp reliability roadmap" - ``` - -## Acceptance Criteria - -- Three deterministic workloads run with fresh state and no benchmark side effects during test imports. -- Timed whole-workload results include median/p95, input/output token estimates, and exact token delta. -- Tests verify pruning, protected writes, preserved errors, no orphan results, nested block relationships, and report shape without timing thresholds. -- `benchmarks/result.json` is a valid Node 24.15.0 report and is excluded from the package tarball. -- README and CHANGELOG describe the benchmark without duplicating existing release notes. -- Schema generation is stable and no new lint diagnostics exceed the established baseline. -- Existing Phase 5 automated operator coverage passes. -- The original reliability roadmap remains unchanged. -- Phase 5 and Phase 6 release records and the six-phase index are accurate. - -## Final Handoff - -- Phase 5 supplies trusted effective configuration and manual-compression contracts. -- Phase 6 supplies the retained benchmark JSON and final verification record. -- Version bump, tag, publish, and any live Pi/model smoke test remain outside this plan. - -## Release Record - -- Status: complete -- Release commit or tag: `9b29f8b` -- Verification date: 2026-07-29 (Node 24.15.0) -- Benchmark artifact: `benchmarks/result.json` diff --git a/docs/superpowers/specs/2026-07-28-pi-dcp-comparative-audit-design.md b/docs/superpowers/specs/2026-07-28-pi-dcp-comparative-audit-design.md deleted file mode 100644 index 9efd970..0000000 --- a/docs/superpowers/specs/2026-07-28-pi-dcp-comparative-audit-design.md +++ /dev/null @@ -1,68 +0,0 @@ -# Pi DCP Comparative Audit Summary - -Date: 2026-07-28 - -Compared repositories: - -- Current: `/Users/lanh/Developer/pi-vault/pi-dcp` -- Pi core reference: `/Users/lanh/Developer/pi-packages/pi` (`8eef62ed`, `@earendil-works/pi-coding-agent` 0.82.0) -- Upstream reference: `/Users/lanh/Developer/pi-packages/opencode-dynamic-context-pruning` (`85b6f5c`, 3.1.14) -- Pi port: `/Users/lanh/Developer/pi-packages/Davidcreador-pi-dcp` (`7ae24be9`) -- Pi port: `/Users/lanh/Developer/pi-packages/complexthings-pi-dcp` (`75e04cb`) - -## Executive Summary - -The current implementation has the broadest Pi-specific feature surface and the strongest locally verified test coverage. It also has correctness gaps that matter more than feature breadth: session state is not isolated per Pi session, compression state is not durable, compression token totals are currently zero, and nested compression is only represented in types rather than fully applied. - -The Pi reference changes the recommended design. Pi 0.80.3, the version installed by this repository, already provides `pi.appendEntry()`, `sessionManager.getBranch()`, `sessionManager.getSessionId()`, and `session_tree`. These APIs are intended for branch-aware extension state and remove the need for a second session-persistence system. - -The current checkout passed 368 tests and TypeScript typechecking at audit time. Lint exits successfully but reports 88 existing warnings. The comparison checkouts were not runnable in this environment because upstream lacked its local `tsx`/TypeScript tooling, Davidcreador lacked installed peer/runtime packages, and complexthings requires Bun. - -## Comparison - -| Area | Current implementation | Pi core constraint | Upstream OpenCode DCP | Davidcreador Pi DCP | complexthings Pi DCP | -| ------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------- | --------------------------------------- | -| State persistence | Shared `{sessionDir}/dcp/state.json`; compression blocks are omitted | Native append-only custom entries, branch IDs, and `session_tree` | Versioned compression/pruning sidecar | Atomic compression sidecar | Custom session entries | -| Failed-call pruning | Replaces error output and leaves large failed inputs | Tool calls/results have stable IDs; provider transform preserves pair validity | Purges failed inputs, preserves errors | Same behavior | Replaces error output | -| Dedup defaults | Protects read/search/shell tools | Core tools are read, bash, edit, write, grep, find, ls | Protects mutation/orchestration tools | Protects mutation/orchestration tools | Protects mutation/orchestration tools | -| Compression | Range/message modes, but token totals are zero and consumed blocks are never recorded | `toolCallId` is available at execution; session entries are stable | Full nested block lifecycle | Tool-output compression | Session-entry compression | -| Tool-pair repair | Removes orphan results only | Pi synthesizes missing results before provider requests | Grouped message model | Tool-output-only model | Bidirectional repair | -| Configuration | TypeBox schema and generated JSON schema | Project cwd/trust are available in extension context | Layered JSONC | Layered JSON | Layered JSONC without schema validation | -| Operator controls | Separate `dcp:*` commands, decompression, recompression, lifetime stats | `sendMessage` supports hidden custom follow-ups | Unified surface, manual trigger, TUI | Unified surface, manual trigger | Unified surface, manual trigger | -| Packaging | MIT, CI, schema, current Pi dependencies | Installed Pi API is 0.80.3 | AGPL-3.0-or-later | AGPL-3.0-or-later | No license file or CI workflow found | - -## What We Did Well - -- The pipeline has clear stages for state synchronization, strategy execution, ID injection, compression filtering, and nudge injection. -- TypeBox is the source of truth for defaults, validation, and the shipped schema. -- The implementation includes range and message compression, protected-content preservation, summary buffering, sub-agent enrichment, runtime permission control, and notifications. -- The character-based token estimator avoids repeatedly invoking a provider tokenizer. -- The repository has current Pi dependencies, CI, release checks, and materially more automated coverage than the smaller Pi ports. - -These strengths should be preserved, but claims of “stable message references” and “nested range compression” must be qualified until the persistence and relationship fixes below are implemented. - -## Priority Findings - -1. **Cross-session state collision.** `ctx.sessionManager.getSessionDir()` is a project session directory, not a unique session directory. Every session currently reads and writes the same `dcp/state.json`; the random DCP `sessionId` is not checked on load. -2. **Persistence is incomplete.** Compression blocks, active mappings, pruned tool IDs, block counters, and compression relationships are not restored. -3. **Compression accounting is wrong.** `applyCompressionState()` creates per-message entries with `tokenCount: 0`, so `compressedTokens` and the user-facing savings display remain zero. -4. **Nested compression is incomplete.** `handleCompress()` always passes `consumedBlockIds: []`; active blocks can be overwritten in `activeByAnchorIndex` without their relationships being recorded. -5. **Compression ownership is guessed.** `compressMessageIndex = messages.length - 1` is captured from the pre-request context and does not identify the current `compress` tool call. Batch timing then scans for the newest block and attaches duration to one block. -6. **Failed-error pruning is reversed.** The current pass replaces useful error diagnostics while leaving the failed assistant arguments intact. -7. **Deduplication defaults protect the wrong tools.** Protecting read/search/shell output disables the main repeated-lookup savings case. -8. **Turn semantics are inconsistent.** `turn_end` counts agent iterations, while the requested safety window is expressed in user turns. The current counter is persisted and then assigned to all restored tool calls. -9. **Orphan repair should respect Pi.** DCP should preserve complete tool ranges and remove orphan results it created; Pi’s provider transform already synthesizes missing results for unmatched assistant calls. -10. **Configuration reload is stale.** Commands capture the original config object, and project config is loaded after the compression tool schema has already been selected. -11. **Project config uses the wrong cwd source.** `process.cwd()` is not the authoritative Pi session cwd and bypasses project-trust checks. -12. **Lifetime totals are misnamed.** The current command scans sidecars by project directory, not Pi session files, and would double-count inherited fork state if native snapshots are introduced without an owner session ID. -13. **Provenance must remain explicit.** The upstream and Davidcreador repositories are AGPL-3.0-or-later. They are behavioral references only; no source copying is authorized by this audit. - -## Recommended Direction - -- Persist DCP state as versioned `pi-dcp-state` custom entries on the active Pi branch. Restore from `getBranch()` on `session_start` and `session_tree`; append after durable mutations. -- Ignore existing shared sidecars for restoration and leave them untouched. They cannot be safely associated with a Pi session. -- Use the real compression `toolCallId`, incremental visible-token accounting, complete nested-block relationships, and user-turn ordinals derived from message history. -- Keep existing commands and JSON configuration. Add a numeric opt-in `turnProtection: 0`, trusted project overrides, and `/dcp:compress [focus]`. -- Keep deterministic benchmarks informational until measured variance supports a release threshold. - -The implementation sequence is defined in [2026-07-28-pi-dcp-reliability-roadmap.md](../plans/2026-07-28-pi-dcp-reliability-roadmap.md). diff --git a/docs/superpowers/specs/2026-07-28-pi-dcp-phased-planning-design.md b/docs/superpowers/specs/2026-07-28-pi-dcp-phased-planning-design.md deleted file mode 100644 index fca2934..0000000 --- a/docs/superpowers/specs/2026-07-28-pi-dcp-phased-planning-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Pi DCP Phased Reliability Planning Design - -Date: 2026-07-28 - -## Purpose - -Split the existing Pi DCP reliability roadmap into a new phased parent/index plan and five independently releasable phase plans. Preserve the existing roadmap unchanged as the source of detailed requirements. - -Every phase must end with coherent behavior, documentation, full verification, and no dependency on unfinished later phases. - -## Source Preservation - -The source roadmap remains: - -`docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md` - -It must remain byte-for-byte unchanged while the phased plans are created. The documentation workflow verifies this with: - -```bash -git diff --exit-code HEAD -- docs/superpowers/plans/2026-07-28-pi-dcp-reliability-roadmap.md -``` - -The new documents extract and reorganize its content; they do not replace it. - -## Document Structure - -Create these files under `docs/superpowers/plans/`: - -- `2026-07-28-pi-dcp-reliability-phased-roadmap.md` -- `2026-07-28-pi-dcp-phase-1-pruning-foundation.md` -- `2026-07-28-pi-dcp-phase-2-turn-and-pair-safety.md` -- `2026-07-28-pi-dcp-phase-3-compression-correctness.md` -- `2026-07-28-pi-dcp-phase-4-native-session-state.md` -- `2026-07-28-pi-dcp-phase-5-operator-and-release-hardening.md` - -The parent/index contains phase order, dependencies, user-visible outcomes, entry and exit criteria, status, and links. It does not duplicate detailed implementation steps. - -Each phase plan is self-contained and uses the standard writing-plans header. It includes: - -1. Source-roadmap mapping. -2. Prerequisites limited to completed earlier phases. -3. Exact file map. -4. Bite-sized TDD tasks with code, commands, expected failures, expected passes, and commit boundaries. -5. User-facing documentation changes. -6. Focused verification followed by full release checks. -7. Acceptance criteria proving the phase is usable. -8. Explicit exclusions delegated to named later phases. -9. Stable interfaces available to the next phase. - -## Phase Sequence - -### Phase 1: Pruning Foundation - -Source: parent Tasks 1 and 4. - -Deliver: - -- Provenance and verification baselines. -- Failed assistant arguments are purged while error diagnostics remain intact. -- Default protected tools become `compress`, `write`, `edit`, and `subagent`. -- Pruning behavior and release notes are documented. - -Usable result: safer and more effective pruning without changing persistence, compression structure, or configuration shape. - -Exclude: - -- User-turn protection. -- Compression block changes. -- Native session snapshots. -- Project configuration and manual compression. - -### Phase 2: Turn and Pair Safety - -Source: remaining safety work from parent Task 4. - -Prerequisite: Phase 1 released. - -Deliver: - -- Opt-in top-level `turnProtection: 0`. -- Tool-call age derived from user turns rather than agent iterations. -- Protection applied to deduplication, stale errors, sweep, and compression. -- Tool-pair handling aligned with Pi’s native missing-result normalization. -- Generated schema and configuration documentation updated. - -Usable result: fresh work is protected consistently during normal context rebuilding. - -Exclude: - -- Compression ownership, nesting, and accounting. -- Native session persistence. -- Project configuration and manual compression. - -### Phase 3: Compression Correctness - -Source: parent Task 3. - -Prerequisite: Phase 2 released. - -Deliver: - -- Blocks owned by the real compression `toolCallId`. -- All batch selections validated before state mutation. -- Nonzero incremental visible-token accounting. -- Complete nested-block consumption and relationships. -- Duration applied to every block produced by a batch. -- Corrected compression and notification behavior documented. - -Usable result: deterministic compression within a running session, including batches and nesting. - -Exclude: - -- Resume, fork, and tree restoration. -- Lifetime-session aggregation. -- Project configuration and manual compression. - -### Phase 4: Native Session State - -Source: parent Tasks 2 and the lifetime portion of Task 6. - -Prerequisite: Phase 3 released. - -Deliver: - -- Versioned `pi-dcp-state` snapshots stored as Pi custom entries. -- Correct restoration across resume, fork, tree navigation, and compaction. -- Rebuilding Phase 2 user-turn metadata and rehydrating the final Phase 3 compression shape. -- Shared legacy sidecars ignored for restoration and left untouched. -- Lifetime totals derived from real Pi session snapshots without fork double-counting. -- Recovery, migration, snapshot, and lifetime semantics documented. - -Usable result: all reliability behavior from Phases 1–3 survives Pi lifecycle operations safely. - -Exclude: - -- Trusted project configuration. -- Manual compression trigger. -- Deterministic benchmarks. - -### Phase 5: Operator and Release Hardening - -Source: parent Tasks 5 and the benchmark/release portion of Task 6. - -Prerequisite: Phase 4 released. - -Deliver: - -- Trusted project configuration resolved from `ctx.cwd`. -- Commands consume current configuration rather than a captured stale object. -- `/dcp:compress [focus]` uses Pi’s custom follow-up message API. -- Command mutations persist through Phase 4 snapshots. -- Deterministic informational benchmarks. -- Final README, schema, package, and release verification. - -Usable result: complete operator-facing behavior with trusted configuration, manual control, benchmark evidence, and release-ready documentation. - -## Atomicity and Execution Rules - -- Execute phases strictly in order: 1 → 2 → 3 → 4 → 5. -- A phase begins only when its prerequisite release and entry checks pass. -- A phase is incomplete if it contains deferred fixes, failing checks, undocumented behavior, or temporary compatibility code that depends on a later phase. -- Later phases may depend on stable interfaces from completed earlier phases. Earlier phases must never depend on unfinished later behavior. -- If implementation reveals a requirement crossing a boundary, revise the affected phase plans before continuing. -- A blocked phase does not invalidate already released phases. -- Each phase updates its own user-facing documentation and release notes. - -## Verification Contract - -Every phase ends with: - -```bash -pnpm test -pnpm typecheck -pnpm lint -pnpm pack --dry-run -git diff --check -``` - -Run schema generation and consistency checks in phases that change configuration or the shipped schema. - -Focused tests must pass before the full suite. Lint may retain the recorded baseline warnings but must not introduce new diagnostics. - -The parent/index records each phase’s status as `not started`, `in progress`, `blocked`, or `complete`. A phase changes to `complete` only after its acceptance criteria, documentation, and full verification pass. - -## Success Criteria - -- The source roadmap is unchanged. -- The new parent/index links to all five phase plans and contains no duplicated implementation checklist. -- Every requirement in the source roadmap maps to exactly one primary phase. -- Every phase has a usable, independently releasable outcome. -- Every phase names its prerequisites, exclusions, tests, documentation, acceptance criteria, and handoff interfaces. -- No phase assumes code or behavior from a later phase. -- The five phase plans collectively retain the full approved scope of the source roadmap. From b334c8558b6cdcb20c3fb1f7ba9ea28800aa9d20 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:14:35 -0400 Subject: [PATCH 7/9] release: bump version to v0.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9eedd62..ce35940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pi-vault/pi-dcp", - "version": "0.4.1", + "version": "0.5.0", "type": "module", "description": "Pi extension for dynamic context pruning — incremental tool output pruning and conversation compression", "author": "Lanh Hoang ", From dec50d10a9409a12bc72ae6fa0573f48dabdca4a Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:14:45 -0400 Subject: [PATCH 8/9] docs: update CHANGELOG and README --- CHANGELOG.md | 2 +- README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48758c..dc6c8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [0.5.0] - 2026-08-01 ### Added diff --git a/README.md b/README.md index 83f0b33..0e3e9be 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,17 @@ Use `dcp:context` to see token usage and active DCP state, `dcp:help` to list co - **Shows operational feedback** — pruning and compression can surface in toast or status notifications. - **Lets you tune behavior** — config, manual mode, runtime permission control, and schema-backed validation are all built in. -## Unreleased +## What's new in 0.5.0 -- Compression batches validate completely before state changes, then commit atomically. Selection expands tool-call/result pairs and active blocks to a fixed point. +- Trusted project configuration loads from `/.pi/dcp.json` and layers over global configuration at session start; untrusted projects fall back to the global config only. +- `dcp:compress [focus]` sends Pi a hidden manual-compression follow-up so you can trigger a compression pass on demand. +- Compression batches validate completely before state changes, then commit atomically; selection expands tool-call/result pairs and active blocks to a fixed point. - Nested compression blocks restore coherent visibility when decompressed or recompressed; savings count only visible context, without nested double-counting. -- DCP state now lives in versioned `pi-dcp-state` entries on the active Pi session branch. Resume and tree navigation recover the newest valid entry; forks inherit settings but start with fresh statistics. -- Compression memberships, message indices, tool caches, and nudge positions are rebuilt from current messages. Corrupt entries are skipped, compaction persists its reset, and legacy `dcp/state.json` files are ignored. +- DCP state lives in versioned `pi-dcp-state` entries on the active Pi session branch. Resume, fork, and tree navigation recover the newest valid entry; forks inherit settings but start with fresh statistics. Legacy `dcp/state.json` sidecars are ignored. +- Compression memberships, message indices, tool caches, and nudge positions rebuild from current messages; corrupt entries are skipped and compaction persists its reset. - `dcp:lifetime` scans Pi session JSONL files and totals the newest snapshot for each owning session. +- Top-level user-turn protection preserves recent raw user turns and complete tool pairs across pruning and compression; failed tool diagnostics survive while stale failed inputs are purged. +- Deterministic benchmark evidence (`pnpm benchmark`) covers clean messages, repeated tool pairs, and restored nested compression blocks. ## Commands From 88cec81b69b2f12d62a1f991c502452659d2bfd9 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 1 Aug 2026 14:14:49 -0400 Subject: [PATCH 9/9] style: reformat codebase with consistent indentation and improved readability --- src/commands/context.ts | 4 +- src/commands/recompress.ts | 3 +- src/compress/protected-content.ts | 26 +--- src/compress/search.ts | 6 +- src/compress/state.ts | 10 +- src/config-schema.ts | 21 ++-- src/messages/inject.ts | 25 +--- src/messages/priority.ts | 5 +- src/messages/prune.ts | 19 +-- src/messages/sync.ts | 5 +- src/pipeline.ts | 10 +- src/state/persistence.ts | 37 +++--- src/state/tool-cache.ts | 16 ++- src/strategies/deduplication.ts | 5 +- src/strategies/protected-patterns.ts | 10 +- src/strategies/runner.ts | 39 ++---- src/ui/notification.ts | 12 +- src/utils/context-limits.ts | 4 +- src/utils/message-content.ts | 11 +- src/utils/message-ids.ts | 5 +- src/utils/tokens.ts | 10 +- tests/anchored-nudges.test.ts | 26 ++-- tests/benchmark.test.ts | 12 +- tests/commands-lifetime.test.ts | 48 +++++++- tests/compress-cycle.test.ts | 19 ++- tests/compress-message.test.ts | 28 +++-- tests/context-limits.test.ts | 16 +-- tests/deduplication.test.ts | 5 +- tests/helpers.ts | 8 +- tests/index.test.ts | 80 +++++++------ tests/inject.test.ts | 37 +++--- tests/integration.test.ts | 130 ++++++++++---------- tests/message-content.test.ts | 6 +- tests/message-end.test.ts | 17 +-- tests/message-ids.test.ts | 4 +- tests/notification.test.ts | 16 +-- tests/persistence.test.ts | 172 +++++++++++++++++---------- tests/pipeline.test.ts | 66 +++++----- tests/priority.test.ts | 6 +- tests/protected-content.test.ts | 24 +--- tests/prune.test.ts | 105 ++++++++++------ tests/stable-ids.test.ts | 24 +++- tests/strategy-runner.test.ts | 15 +-- tests/strip.test.ts | 26 ++-- tests/subagent-enrichment.test.ts | 5 +- tests/summary-buffer.test.ts | 20 +--- tests/sync.test.ts | 27 ++++- tests/tool-cache.test.ts | 17 +-- tests/turn-protection.test.ts | 2 +- 49 files changed, 637 insertions(+), 607 deletions(-) diff --git a/src/commands/context.ts b/src/commands/context.ts index 5cb9118..6323052 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -7,7 +7,9 @@ export function contextCommand( const lines: string[] = ["DCP Context Usage:"]; if (contextUsage && contextUsage.tokens != null && contextUsage.percent != null) { - lines.push(` Tokens: ${contextUsage.tokens} / ${contextUsage.contextWindow} (${contextUsage.percent.toFixed(1)}%)`); + lines.push( + ` Tokens: ${contextUsage.tokens} / ${contextUsage.contextWindow} (${contextUsage.percent.toFixed(1)}%)`, + ); } else if (contextUsage) { lines.push(` Tokens: unavailable (context window: ${contextUsage.contextWindow})`); } else { diff --git a/src/commands/recompress.ts b/src/commands/recompress.ts index 24c40a0..c0bb7bd 100644 --- a/src/commands/recompress.ts +++ b/src/commands/recompress.ts @@ -11,7 +11,8 @@ export function recompressCommand(state: SessionState, args: string): string { const block = state.prune.messages.blocksById.get(blockId); if (!block) return `Block ${blockId} not found.`; if (block.active) return `Block ${blockId} is already active.`; - if (!block.deactivatedByUser) return `Block ${blockId} was not deactivated by user. Cannot reactivate.`; + if (!block.deactivatedByUser) + return `Block ${blockId} was not deactivated by user. Cannot reactivate.`; const eligibleBlockIds = getEligibleCompressionBlockIds(state); eligibleBlockIds.add(blockId); diff --git a/src/compress/protected-content.ts b/src/compress/protected-content.ts index a5fd13b..b5fddca 100644 --- a/src/compress/protected-content.ts +++ b/src/compress/protected-content.ts @@ -40,9 +40,7 @@ export function appendProtectedUserMessages( if (userTexts.length === 0) return summary; - const section = userTexts - .map((t) => `[Protected User Message]\n${t}`) - .join("\n\n"); + const section = userTexts.map((t) => `[Protected User Message]\n${t}`).join("\n\n"); return `${summary}\n\n---\n${section}`; } @@ -69,9 +67,7 @@ export function appendProtectedPromptInfo( if (extracted.length === 0) return summary; - const section = extracted - .map((t) => `[Protected Content]\n${t}`) - .join("\n\n"); + const section = extracted.map((t) => `[Protected Content]\n${t}`).join("\n\n"); return `${summary}\n\n---\n${section}`; } @@ -113,21 +109,9 @@ export function enrichSummaryWithProtectedContent( subAgentResultCache?: Map, ): string { let enriched = summary; - enriched = appendProtectedUserMessages( - enriched, - messages, - config.compress.protectUserMessages, - ); - enriched = appendProtectedPromptInfo( - enriched, - messages, - config.compress.protectTags, - ); - enriched = appendProtectedToolOutputs( - enriched, - messages, - config.compress.protectedTools, - ); + enriched = appendProtectedUserMessages(enriched, messages, config.compress.protectUserMessages); + enriched = appendProtectedPromptInfo(enriched, messages, config.compress.protectTags); + enriched = appendProtectedToolOutputs(enriched, messages, config.compress.protectedTools); if (subAgentResultCache) { enriched = appendSubAgentResults(enriched, messages, subAgentResultCache); } diff --git a/src/compress/search.ts b/src/compress/search.ts index 97f8b91..42b4beb 100644 --- a/src/compress/search.ts +++ b/src/compress/search.ts @@ -109,11 +109,7 @@ function expandWithCachedIndices( return { startIndex: start, endIndex: end }; } -function expandByScan( - messages: AgentMessage[], - startIndex: number, - endIndex: number, -) { +function expandByScan(messages: AgentMessage[], startIndex: number, endIndex: number) { let start = startIndex; let end = endIndex; let changed = true; diff --git a/src/compress/state.ts b/src/compress/state.ts index b3ed5e3..0437cfb 100644 --- a/src/compress/state.ts +++ b/src/compress/state.ts @@ -50,10 +50,7 @@ export interface ApplyCompressionParams { consumedBlockIds: number[]; } -export function applyCompressionState( - state: SessionState, - params: ApplyCompressionParams, -): void { +export function applyCompressionState(state: SessionState, params: ApplyCompressionParams): void { storeCompressionState(state, params); rebuildCompressionState(state, getEligibleCompressionBlockIds(state)); } @@ -141,7 +138,10 @@ export function rebuildCompressionState( const byId = messagesState.blocksById; const now = Date.now(); - const findActiveAncestor = (block: CompressionBlock, seen = new Set()): CompressionBlock | undefined => { + const findActiveAncestor = ( + block: CompressionBlock, + seen = new Set(), + ): CompressionBlock | undefined => { for (const parentId of block.parentBlockIds) { if (seen.has(parentId)) continue; const parent = byId.get(parentId); diff --git a/src/config-schema.ts b/src/config-schema.ts index 0588c31..446c098 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -86,8 +86,7 @@ export const CompressConfigSchema = Type.Object({ iterationNudgeThreshold: Type.Number({ default: 15, minimum: 1, - description: - "Number of assistant iterations without user input before nudging", + description: "Number of assistant iterations without user input before nudging", }), nudgeForce: Type.Union([Type.Literal("strong"), Type.Literal("soft")], { default: "soft", @@ -103,13 +102,11 @@ export const CompressConfigSchema = Type.Object({ }), protectTags: Type.Boolean({ default: false, - description: - "Preserve ... tag content in summaries", + description: "Preserve ... tag content in summaries", }), summaryBuffer: Type.Boolean({ default: true, - description: - "Exclude active summary tokens from threshold comparison to prevent cascading", + description: "Exclude active summary tokens from threshold comparison to prevent cascading", }), }); @@ -156,14 +153,10 @@ export const DcpConfigSchema = Type.Object({ description: "Notification verbosity for pruning events", }, ), - nudgeNotificationType: Type.Union( - [Type.Literal("toast"), Type.Literal("status")], - { - default: "status", - description: - "Notification delivery: toast (ephemeral) or status (persistent)", - }, - ), + nudgeNotificationType: Type.Union([Type.Literal("toast"), Type.Literal("status")], { + default: "status", + description: "Notification delivery: toast (ephemeral) or status (persistent)", + }), protectedFilePatterns: Type.Array(Type.String(), { default: [], description: "Glob patterns for file paths to protect from pruning", diff --git a/src/messages/inject.ts b/src/messages/inject.ts index 5f845a9..f2cba51 100644 --- a/src/messages/inject.ts +++ b/src/messages/inject.ts @@ -7,11 +7,7 @@ import { formatMessageRef, formatMessageIdTag, getMessageKey } from "../utils/me import type { PriorityMap } from "./priority.ts"; import { appendText, mapText } from "../utils/message-content.ts"; import { stripHallucinationsFromString } from "./strip.ts"; -import { - CONTEXT_LIMIT_NUDGE, - TURN_NUDGE, - ITERATION_NUDGE, -} from "../prompts/nudges.ts"; +import { CONTEXT_LIMIT_NUDGE, TURN_NUDGE, ITERATION_NUDGE } from "../prompts/nudges.ts"; import type { RuntimePrompts } from "../prompts/store.ts"; /** @@ -23,10 +19,7 @@ import type { RuntimePrompts } from "../prompts/store.ts"; * disambiguates them based on their order in the array. ToolResult messages use * toolCallId (unique) and bypass the counter. */ -export function assignMessageRefs( - state: SessionState, - messages: AgentMessage[], -): void { +export function assignMessageRefs(state: SessionState, messages: AgentMessage[]): void { // Clear runtime index cache — rebuilt each pass state.messageIds.byIndex.clear(); @@ -122,11 +115,7 @@ export function injectCompressNudges( // Summary buffer adjustment (from Phase 3) let effectiveOverMax = overMax; - if ( - effectiveOverMax && - config.compress.summaryBuffer && - contextUsage.tokens != null - ) { + if (effectiveOverMax && config.compress.summaryBuffer && contextUsage.tokens != null) { const summaryTokens = getActiveSummaryTokenUsage(state); if (summaryTokens > 0) { const effectiveTokens = contextUsage.tokens - summaryTokens; @@ -252,10 +241,7 @@ function addAnchorIfAllowed( for (const existingKey of anchorSet) { const existingIndex = keyToIndex.get(existingKey); if (existingIndex !== undefined) { - closestDistance = Math.min( - closestDistance, - Math.abs(targetIndex - existingIndex), - ); + closestDistance = Math.min(closestDistance, Math.abs(targetIndex - existingIndex)); } // Anchors not in current messages (stale) are ignored for distance calculation } @@ -311,8 +297,7 @@ function applyAnchoredNudges( function hasExistingNudge(msg: AgentMessage): boolean { if (!("content" in msg)) return false; - if (typeof msg.content === "string") - return msg.content.includes(""); + if (typeof msg.content === "string") return msg.content.includes(""); if (!Array.isArray(msg.content)) return false; return msg.content.some((p) => { if (typeof p !== "object" || p === null) return false; diff --git a/src/messages/priority.ts b/src/messages/priority.ts index e1fa376..15aeb00 100644 --- a/src/messages/priority.ts +++ b/src/messages/priority.ts @@ -20,10 +20,7 @@ export type PriorityMap = Map; * - Token count: larger messages get higher priority (compress first) * - Role: tool results are resolved content, slightly prioritized for compression */ -export function buildPriorityMap( - state: SessionState, - messages: AgentMessage[], -): PriorityMap { +export function buildPriorityMap(state: SessionState, messages: AgentMessage[]): PriorityMap { if (messages.length === 0) return new Map(); const entries: Array<{ index: number; score: number; tokens: number }> = []; diff --git a/src/messages/prune.ts b/src/messages/prune.ts index 39b3018..34965f2 100644 --- a/src/messages/prune.ts +++ b/src/messages/prune.ts @@ -77,10 +77,7 @@ const PRUNED_OUTPUT_TEXT = * Replace outputs of pruned tool results with placeholder text. * Returns a new array (does not mutate input). */ -export function pruneToolOutputs( - state: SessionState, - messages: AgentMessage[], -): AgentMessage[] { +export function pruneToolOutputs(state: SessionState, messages: AgentMessage[]): AgentMessage[] { if (state.prune.tools.size === 0) return messages; return messages.map((msg) => { @@ -96,16 +93,11 @@ export function pruneToolOutputs( } /** Replace arguments of pruned failed tool calls while preserving diagnostics. */ -export function pruneFailedInputs( - state: SessionState, - messages: AgentMessage[], -): AgentMessage[] { +export function pruneFailedInputs(state: SessionState, messages: AgentMessage[]): AgentMessage[] { if (state.prune.tools.size === 0) return messages; const failedIds = new Set( - [...state.prune.tools.keys()].filter( - (id) => state.toolParameters.get(id)?.status === "error", - ), + [...state.prune.tools.keys()].filter((id) => state.toolParameters.get(id)?.status === "error"), ); if (failedIds.size === 0) return messages; @@ -129,10 +121,7 @@ export function pruneFailedInputs( * Apply all pruning passes to a message array. * Returns a new array. */ -export function applyPruning( - state: SessionState, - messages: AgentMessage[], -): AgentMessage[] { +export function applyPruning(state: SessionState, messages: AgentMessage[]): AgentMessage[] { let result = filterCompressedRanges(state, messages); result = pruneToolOutputs(state, result); result = pruneFailedInputs(state, result); diff --git a/src/messages/sync.ts b/src/messages/sync.ts index ff32a99..34db60d 100644 --- a/src/messages/sync.ts +++ b/src/messages/sync.ts @@ -5,10 +5,7 @@ import { rebuildCompressionState } from "../compress/state.ts"; /** * Reconcile compression blocks with their owning assistant tool calls. */ -export function syncCompressionBlocks( - state: SessionState, - messages: AgentMessage[], -): void { +export function syncCompressionBlocks(state: SessionState, messages: AgentMessage[]): void { const messagesState = state.prune.messages; if (messagesState.blocksById.size === 0) return; diff --git a/src/pipeline.ts b/src/pipeline.ts index 9ffca98..10f85c4 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -6,11 +6,7 @@ import { syncCompressionBlocks } from "./messages/sync.ts"; import { stripHallucinations } from "./messages/strip.ts"; import { syncToolCache, buildToolIdList } from "./state/tool-cache.ts"; import { runStrategies, type StrategyResult } from "./strategies/runner.ts"; -import { - assignMessageRefs, - injectCompressNudges, - injectMessageIds, -} from "./messages/inject.ts"; +import { assignMessageRefs, injectCompressNudges, injectMessageIds } from "./messages/inject.ts"; import { buildPriorityMap, type PriorityMap } from "./messages/priority.ts"; import { applyPruning } from "./messages/prune.ts"; @@ -49,9 +45,7 @@ export function runPipeline( state.messageIds.byRawId = new Map( [...state.messageIds.byRawId].filter(([key]) => rawKeys.has(key)), ); - state.messageIds.byRef = new Map( - [...state.messageIds.byRawId].map(([key, ref]) => [ref, key]), - ); + state.messageIds.byRef = new Map([...state.messageIds.byRawId].map(([key, ref]) => [ref, key])); for (const anchors of Object.values(state.nudges)) { for (const key of anchors) if (!rawKeys.has(key)) anchors.delete(key); } diff --git a/src/state/persistence.ts b/src/state/persistence.ts index becf339..ab21d45 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -1,12 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; -import type { - CompressionBlock, - DcpSnapshotBlockV1, - DcpSnapshotV1, - SessionState, -} from "./types.ts"; +import type { CompressionBlock, DcpSnapshotBlockV1, DcpSnapshotV1, SessionState } from "./types.ts"; import { resetSessionState } from "./state.ts"; import { parseMessageRef } from "../utils/message-ids.ts"; @@ -189,7 +184,12 @@ export function parseDcpSnapshot( value: unknown, warn: SnapshotWarning = () => {}, ): DcpSnapshotV1 | undefined { - if (!isRecord(value) || value.version !== 1 || !isString(value.ownerSessionId) || !value.ownerSessionId) { + if ( + !isRecord(value) || + value.version !== 1 || + !isString(value.ownerSessionId) || + !value.ownerSessionId + ) { return undefined; } if (value.manualMode !== false && value.manualMode !== "active") return undefined; @@ -200,7 +200,8 @@ export function parseDcpSnapshot( !isNonNegativeInteger(value.stats.totalPruneTokens) || !isNonNegativeInteger(value.stats.toolsPruned) || !isNonNegativeInteger(value.stats.messagesCompressed) - ) return undefined; + ) + return undefined; if ( !isNonNegativeInteger(value.lastCompaction) || !isPositiveInteger(value.nextBlockId) || @@ -208,7 +209,11 @@ export function parseDcpSnapshot( ) { return undefined; } - if (!isRecord(value.messageIds) || !isPositiveInteger(value.messageIds.nextRefIndex) || !isRecord(value.nudges)) { + if ( + !isRecord(value.messageIds) || + !isPositiveInteger(value.messageIds.nextRefIndex) || + !isRecord(value.nudges) + ) { return undefined; } if (!Array.isArray(value.blocks)) return undefined; @@ -355,14 +360,16 @@ export async function loadAllSessionStats(parentDir: string): Promise<{ if (!hasHeader || entry.type !== "custom" || entry.customType !== "pi-dcp-state") continue; const snapshot = parseDcpSnapshot(entry.data); if (!snapshot) continue; - const timestamp = typeof entry.timestamp === "number" - ? entry.timestamp - : typeof entry.timestamp === "string" - ? Date.parse(entry.timestamp) - : Number.NaN; + const timestamp = + typeof entry.timestamp === "number" + ? entry.timestamp + : typeof entry.timestamp === "string" + ? Date.parse(entry.timestamp) + : Number.NaN; if (!Number.isFinite(timestamp)) continue; const existing = snapshots.get(snapshot.ownerSessionId); - if (!existing || timestamp >= existing.timestamp) snapshots.set(snapshot.ownerSessionId, { snapshot, timestamp }); + if (!existing || timestamp >= existing.timestamp) + snapshots.set(snapshot.ownerSessionId, { snapshot, timestamp }); } } catch { // Skip inaccessible or malformed streams. diff --git a/src/state/tool-cache.ts b/src/state/tool-cache.ts index 57338c1..cafe92c 100644 --- a/src/state/tool-cache.ts +++ b/src/state/tool-cache.ts @@ -10,10 +10,7 @@ import { countMessageTokens } from "../utils/tokens.ts"; * - Tool calls are in `assistant` messages: content[].type === "toolCall" * - Tool results are separate `toolResult` messages with toolCallId, isError */ -export function syncToolCache( - state: SessionState, - messages: AgentMessage[], -): void { +export function syncToolCache(state: SessionState, messages: AgentMessage[]): void { state.toolParameters.clear(); state.currentUserTurn = 0; @@ -69,10 +66,7 @@ export function syncToolCache( /** * Build ordered list of tool call IDs from messages. */ -export function buildToolIdList( - state: SessionState, - messages: AgentMessage[], -): void { +export function buildToolIdList(state: SessionState, messages: AgentMessage[]): void { const ids: string[] = []; for (const msg of messages) { if (msg.role !== "assistant") continue; @@ -94,7 +88,11 @@ function extractToolResultText(msg: AgentMessage): string | undefined { if (!Array.isArray(msg.content)) return undefined; const texts: string[] = []; for (const part of msg.content) { - if (typeof part === "object" && part !== null && (part as unknown as Record).type === "text") { + if ( + typeof part === "object" && + part !== null && + (part as unknown as Record).type === "text" + ) { texts.push((part as unknown as Record).text as string); } } diff --git a/src/strategies/deduplication.ts b/src/strategies/deduplication.ts index 58a5586..5be4035 100644 --- a/src/strategies/deduplication.ts +++ b/src/strategies/deduplication.ts @@ -5,10 +5,7 @@ * used by the strategy runner to group duplicate calls. */ -export function createToolSignature( - toolName: string, - parameters: unknown, -): string { +export function createToolSignature(toolName: string, parameters: unknown): string { const normalized = normalizeParams(parameters); return `${toolName}::${JSON.stringify(normalized)}`; } diff --git a/src/strategies/protected-patterns.ts b/src/strategies/protected-patterns.ts index 6b6a445..6b60bf0 100644 --- a/src/strategies/protected-patterns.ts +++ b/src/strategies/protected-patterns.ts @@ -44,10 +44,7 @@ function globToRegex(pattern: string): RegExp { return new RegExp(result); } -export function isToolNameProtected( - toolName: string, - protectedPatterns: string[], -): boolean { +export function isToolNameProtected(toolName: string, protectedPatterns: string[]): boolean { for (const pattern of protectedPatterns) { if (pattern === toolName) return true; if (pattern.includes("*") || pattern.includes("?")) { @@ -68,10 +65,7 @@ export function getFilePathsFromParameters( return paths; } -export function isFilePathProtected( - filePaths: string[], - patterns: string[], -): boolean { +export function isFilePathProtected(filePaths: string[], patterns: string[]): boolean { if (filePaths.length === 0 || patterns.length === 0) return false; return filePaths.some((fp) => patterns.some((p) => matchesGlob(fp, p))); } diff --git a/src/strategies/runner.ts b/src/strategies/runner.ts index 65aaf83..4f27944 100644 --- a/src/strategies/runner.ts +++ b/src/strategies/runner.ts @@ -18,10 +18,7 @@ export interface StrategyResult { * Run all enabled pruning strategies against the current tool cache. * Owns: guard checks, protected-tools resolution, eligibility filtering, stat bookkeeping. */ -export function runStrategies( - state: SessionState, - config: DcpConfig, -): StrategyResult { +export function runStrategies(state: SessionState, config: DcpConfig): StrategyResult { if (state.toolIdList.length === 0) { return { pruned: 0, tokensSaved: 0, prunedToolNames: [] }; } @@ -44,9 +41,7 @@ export function runStrategies( config.strategies.deduplication.turnProtection, ); - const unpruned = state.toolIdList.filter( - (id) => !state.prune.tools.has(id), - ); + const unpruned = state.toolIdList.filter((id) => !state.prune.tools.has(id)); // Group by signature const groups = new Map(); @@ -59,8 +54,7 @@ export function runStrategies( entry.tool, entry.parameters as Record, ); - if (isFilePathProtected(filePaths, config.protectedFilePatterns)) - continue; + if (isFilePathProtected(filePaths, config.protectedFilePatterns)) continue; const sig = createToolSignature(entry.tool, entry.parameters); const group = groups.get(sig) ?? []; @@ -77,10 +71,7 @@ export function runStrategies( if (!entry) continue; // Turn protection: skip if this entry is too recent - if ( - turnProtection > 0 && - state.currentUserTurn - entry.userTurn < turnProtection - ) { + if (turnProtection > 0 && state.currentUserTurn - entry.userTurn < turnProtection) { continue; } @@ -102,13 +93,8 @@ export function runStrategies( ...BASE_PROTECTED_TOOLS, ...config.strategies.purgeErrors.protectedTools, ]; - const turnThreshold = Math.max( - config.turnProtection, - config.strategies.purgeErrors.turns, - ); - const unpruned = state.toolIdList.filter( - (id) => !state.prune.tools.has(id), - ); + const turnThreshold = Math.max(config.turnProtection, config.strategies.purgeErrors.turns); + const unpruned = state.toolIdList.filter((id) => !state.prune.tools.has(id)); for (const callId of unpruned) { const entry = state.toolParameters.get(callId); @@ -120,8 +106,7 @@ export function runStrategies( entry.tool, entry.parameters as Record, ); - if (isFilePathProtected(filePaths, config.protectedFilePatterns)) - continue; + if (isFilePathProtected(filePaths, config.protectedFilePatterns)) continue; const tokens = estimatePurgedInputSavings(entry.parameters); state.prune.tools.set(callId, tokens); @@ -142,14 +127,8 @@ export function runStrategies( * Sweep variant: prune all non-protected completed tool outputs. * Used by the dcp:sweep command. */ -export function sweepAll( - state: SessionState, - config: DcpConfig, -): StrategyResult { - const protectedTools = new Set([ - ...BASE_PROTECTED_TOOLS, - ...config.compress.protectedTools, - ]); +export function sweepAll(state: SessionState, config: DcpConfig): StrategyResult { + const protectedTools = new Set([...BASE_PROTECTED_TOOLS, ...config.compress.protectedTools]); let pruned = 0; let tokensSaved = 0; diff --git a/src/ui/notification.ts b/src/ui/notification.ts index b8793c8..a343fca 100644 --- a/src/ui/notification.ts +++ b/src/ui/notification.ts @@ -26,9 +26,7 @@ function formatTokens(tokens: number): string { * Build minimal notification message. * Returns undefined if nothing to report. */ -export function buildMinimalMessage( - stats: NotificationStats, -): string | undefined { +export function buildMinimalMessage(stats: NotificationStats): string | undefined { if (stats.tokensSaved === 0 && stats.pruned === 0) return undefined; return `DCP: ${formatTokens(stats.tokensSaved)} tokens saved (${stats.pruned} items pruned)`; } @@ -52,9 +50,7 @@ export function buildDetailedMessage( * Build minimal compression notification. * Format: "DCP: ~12.4K tokens compressed (~2.1K summary, 5 messages)" */ -export function buildCompressNotificationMinimal( - params: CompressNotificationParams, -): string { +export function buildCompressNotificationMinimal(params: CompressNotificationParams): string { const plural = params.messagesCompressed === 1 ? "message" : "messages"; return `DCP: ${formatTokens(params.compressedTokens)} tokens compressed (${formatTokens(params.summaryTokens)} summary, ${params.messagesCompressed} ${plural})`; } @@ -63,9 +59,7 @@ export function buildCompressNotificationMinimal( * Build detailed compression notification with topic and optional summary. * Summary text is only included when showCompression is true. */ -export function buildCompressNotificationDetailed( - params: CompressNotificationParams, -): string { +export function buildCompressNotificationDetailed(params: CompressNotificationParams): string { let msg = buildCompressNotificationMinimal(params); msg += `\nTopic: ${params.topic}`; if (params.showCompression && params.summary) { diff --git a/src/utils/context-limits.ts b/src/utils/context-limits.ts index c0dba40..f7023f1 100644 --- a/src/utils/context-limits.ts +++ b/src/utils/context-limits.ts @@ -48,9 +48,7 @@ export function isContextOverLimits( const tokens = contextUsage.tokens; const modelKey = - state.modelProvider && state.modelId - ? `${state.modelProvider}/${state.modelId}` - : undefined; + state.modelProvider && state.modelId ? `${state.modelProvider}/${state.modelId}` : undefined; // Effective window: prefer state (persisted), fall back to contextUsage (current) const effectiveWindow = diff --git a/src/utils/message-content.ts b/src/utils/message-content.ts index 0eeeb06..2b86982 100644 --- a/src/utils/message-content.ts +++ b/src/utils/message-content.ts @@ -19,11 +19,7 @@ function findTextPartIndex(content: unknown[]): number { * Handles E9 string content, array content, and missing text parts. * Returns the original message by reference if no change was made. */ -export function appendText( - msg: AgentMessage, - text: string, - marker?: string, -): AgentMessage { +export function appendText(msg: AgentMessage, text: string, marker?: string): AgentMessage { if (!("content" in msg)) return msg; // E9: UserMessage.content can be a plain string @@ -59,10 +55,7 @@ export function appendText( * Transform all text parts in a message via a mapping function. * Returns the original message by reference if fn returns identical strings. */ -export function mapText( - msg: AgentMessage, - fn: (text: string) => string, -): AgentMessage { +export function mapText(msg: AgentMessage, fn: (text: string) => string): AgentMessage { if (!("content" in msg)) return msg; if (!Array.isArray(msg.content)) return msg; diff --git a/src/utils/message-ids.ts b/src/utils/message-ids.ts index 94846fb..09d9ae7 100644 --- a/src/utils/message-ids.ts +++ b/src/utils/message-ids.ts @@ -52,10 +52,7 @@ export function parseBoundaryId(id: string): ParsedBoundaryId | undefined { return undefined; } -export function formatMessageIdTag( - ref: string, - attrs?: { priority?: number }, -): string { +export function formatMessageIdTag(ref: string, attrs?: { priority?: number }): string { if (attrs?.priority !== undefined) { return `${ref}`; } diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 3a9a900..ff8a731 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -19,10 +19,7 @@ export function countTokensBatch(texts: string[]): number { * Handles UserMessage (string | TextContent[]), AssistantMessage (TextContent + * ToolCallContent), and ToolResultMessage. */ -export function extractMessageText(message: { - role: string; - content?: unknown; -}): string { +export function extractMessageText(message: { role: string; content?: unknown }): string { const content = message.content; if (!content) return ""; if (typeof content === "string") return content; @@ -44,9 +41,6 @@ export function extractMessageText(message: { return parts.join(" "); } -export function countMessageTokens(message: { - role: string; - content?: unknown; -}): number { +export function countMessageTokens(message: { role: string; content?: unknown }): number { return countTokens(extractMessageText(message)); } diff --git a/tests/anchored-nudges.test.ts b/tests/anchored-nudges.test.ts index 92c2966..218b3ff 100644 --- a/tests/anchored-nudges.test.ts +++ b/tests/anchored-nudges.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createSessionState } from "../src/state/state.ts"; -import { - assignMessageRefs, - injectCompressNudges, -} from "../src/messages/inject.ts"; +import { assignMessageRefs, injectCompressNudges } from "../src/messages/inject.ts"; import { makeDefaultConfig, resetTestTimestamp } from "./helpers.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; @@ -133,10 +130,8 @@ describe("anchored nudge system", () => { }); // Both anchored messages should have nudge text - const text0 = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; - const text2 = (result[2] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text0 = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; + const text2 = (result[2] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text0).toContain("dcp-system-reminder"); expect(text2).toContain("dcp-system-reminder"); }); @@ -177,8 +172,7 @@ describe("anchored nudge system", () => { }); // Should not double-inject - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; const matches = text.match(//g); expect(matches).toHaveLength(1); }); @@ -202,8 +196,7 @@ describe("anchored nudge system", () => { // Stale anchor should not crash anything; new anchor should be added expect(state.nudges.turnAnchors.has("user:1000:0")).toBe(true); // The text should have nudge on message at index 0 - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("dcp-system-reminder"); }); @@ -232,8 +225,7 @@ describe("anchored nudge system", () => { // Should anchor at the last user/assistant message (index 1, the assistant message) expect(state.nudges.contextLimitAnchors.has("assistant:2000:0")).toBe(true); // And inject nudge text there - const text = (result[1] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[1] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("dcp-system-reminder"); // toolResult at index 2 should be unchanged expect(result[2]).toBe(messages[2]); @@ -260,8 +252,7 @@ describe("anchored nudge system", () => { }); // Pre-existing anchor should still be applied even though no new nudge fires - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("dcp-system-reminder"); }); @@ -293,8 +284,7 @@ describe("anchored nudge system", () => { }); // msgB (index 1) should still have nudge text from the persisted anchor - const textB = (result[1] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const textB = (result[1] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(textB).toContain("dcp-system-reminder"); }); }); diff --git a/tests/benchmark.test.ts b/tests/benchmark.test.ts index c761d70..ed5d708 100644 --- a/tests/benchmark.test.ts +++ b/tests/benchmark.test.ts @@ -12,7 +12,9 @@ describe("benchmark workloads", () => { const result = buildCleanWorkload().run(); expect(result.messages).toHaveLength(2_000); - expect(result.messages.every((message) => message.role === "user" || message.role === "assistant")).toBe(true); + expect( + result.messages.every((message) => message.role === "user" || message.role === "assistant"), + ).toBe(true); }); it("prunes repeated tools without orphaning results or changing protected writes and errors", () => { @@ -24,7 +26,9 @@ describe("benchmark workloads", () => { (tool) => tool.name === "write" && tool.text.includes("protected write 0"), ), ).toBe(true); - expect(result.toolResults.some((tool) => tool.isError && tool.text.includes("stale error"))).toBe(true); + expect( + result.toolResults.some((tool) => tool.isError && tool.text.includes("stale error")), + ).toBe(true); expect(result.toolResults.every((tool) => tool.ownerPresent)).toBe(true); }); @@ -37,7 +41,9 @@ describe("benchmark workloads", () => { ]); for (const block of result.state.prune.messages.blocksById.values()) { for (const childId of block.consumedBlockIds) { - expect(result.state.prune.messages.blocksById.get(childId)?.parentBlockIds).toContain(block.blockId); + expect(result.state.prune.messages.blocksById.get(childId)?.parentBlockIds).toContain( + block.blockId, + ); } } }); diff --git a/tests/commands-lifetime.test.ts b/tests/commands-lifetime.test.ts index 55be3c2..33780eb 100644 --- a/tests/commands-lifetime.test.ts +++ b/tests/commands-lifetime.test.ts @@ -34,15 +34,55 @@ describe("lifetime command", () => { fs.writeFileSync( path.join(dir1, "session.jsonl"), `${JSON.stringify(sessionHeader("session-1"))}\n${JSON.stringify({ - type: "custom", customType: "pi-dcp-state", timestamp: "2026-07-29T00:00:01.000Z", - data: { version: 1, ownerSessionId: "one", manualMode: false, compressPermission: "allow", stats: { totalPruneTokens: 500, toolsPruned: 3, messagesCompressed: 1, pruneTokenCounter: 0 }, lastCompaction: 0, pruneTools: [], blocks: [], nextBlockId: 1, nextRunId: 1, messageIds: { byRawId: [], nextRefIndex: 1 }, nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] } }, + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: { + version: 1, + ownerSessionId: "one", + manualMode: false, + compressPermission: "allow", + stats: { + totalPruneTokens: 500, + toolsPruned: 3, + messagesCompressed: 1, + pruneTokenCounter: 0, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, + messageIds: { byRawId: [], nextRefIndex: 1 }, + nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, + }, })}`, ); fs.writeFileSync( path.join(dir2, "session.jsonl"), `${JSON.stringify(sessionHeader("session-2"))}\n${JSON.stringify({ - type: "custom", customType: "pi-dcp-state", timestamp: "2026-07-29T00:00:01.000Z", - data: { version: 1, ownerSessionId: "two", manualMode: false, compressPermission: "allow", stats: { totalPruneTokens: 1500, toolsPruned: 7, messagesCompressed: 4, pruneTokenCounter: 0 }, lastCompaction: 0, pruneTools: [], blocks: [], nextBlockId: 1, nextRunId: 1, messageIds: { byRawId: [], nextRefIndex: 1 }, nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] } }, + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: { + version: 1, + ownerSessionId: "two", + manualMode: false, + compressPermission: "allow", + stats: { + totalPruneTokens: 1500, + toolsPruned: 7, + messagesCompressed: 4, + pruneTokenCounter: 0, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, + messageIds: { byRawId: [], nextRefIndex: 1 }, + nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, + }, })}`, ); diff --git a/tests/compress-cycle.test.ts b/tests/compress-cycle.test.ts index 36f2888..5a08246 100644 --- a/tests/compress-cycle.test.ts +++ b/tests/compress-cycle.test.ts @@ -7,7 +7,11 @@ import { syncCompressionBlocks } from "../src/messages/sync.ts"; import { syncToolCache } from "../src/state/tool-cache.ts"; import { handleCompress } from "../src/compress/handler.ts"; import { resolveBoundaryIndex } from "../src/compress/search.ts"; -import { makeUserMessage as makeUser, makeAssistantMessage as makeAssistant, makeDefaultConfig } from "./helpers.ts"; +import { + makeUserMessage as makeUser, + makeAssistantMessage as makeAssistant, + makeDefaultConfig, +} from "./helpers.ts"; /** Extract first text content from a message (skips TS union narrowing). */ function textOf(msg: AgentMessage): string { @@ -24,7 +28,10 @@ function textOf(msg: AgentMessage): string { * * Returns the filtered messages (as the model would see them). */ -function runContextPipeline(state: ReturnType, rawMessages: AgentMessage[]): AgentMessage[] { +function runContextPipeline( + state: ReturnType, + rawMessages: AgentMessage[], +): AgentMessage[] { assignMessageRefs(state, rawMessages); syncToolCache(state, rawMessages); syncCompressionBlocks(state, rawMessages); @@ -99,7 +106,13 @@ describe("full compression cycle", () => { handleCompress(state, config, rawMessages2, "compress-call-2", { topic: "Task A", - content: [{ startId: "m0003", endId: "m0004", summary: "User asked for task A, assistant completed it" }], + content: [ + { + startId: "m0003", + endId: "m0004", + summary: "User asked for task A, assistant completed it", + }, + ], mode: "range", }); diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index 8604f1c..c41dd84 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -3,11 +3,7 @@ import { handleCompress } from "../src/compress/handler.ts"; import { createSessionState } from "../src/state/state.ts"; import { assignMessageRefs } from "../src/messages/inject.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { - makeUserMessage, - makeAssistantMessage, - makeDefaultConfig, -} from "./helpers.ts"; +import { makeUserMessage, makeAssistantMessage, makeDefaultConfig } from "./helpers.ts"; describe("handleCompress (message mode)", () => { it("compresses targeted messages", () => { @@ -91,7 +87,11 @@ describe("handleCompress (message mode)", () => { assignMessageRefs(state, messages); // Pre-populate token count for the target message - state.prune.messages.byMessageIndex.set(0, { tokenCount: 120, blockIds: [], activeBlockIds: [] }); + state.prune.messages.byMessageIndex.set(0, { + tokenCount: 120, + blockIds: [], + activeBlockIds: [], + }); const result = handleCompress(state, config, messages, "compress-call-1", { topic: "test", @@ -112,7 +112,13 @@ describe("handleCompress (message mode)", () => { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: 0, } as unknown as AgentMessage, { @@ -147,7 +153,13 @@ describe("handleCompress (message mode)", () => { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: 0, } as unknown as AgentMessage, { diff --git a/tests/context-limits.test.ts b/tests/context-limits.test.ts index 8df5f1f..0d29382 100644 --- a/tests/context-limits.test.ts +++ b/tests/context-limits.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect } from "vitest"; -import { - resolveContextTokenLimit, - isContextOverLimits, -} from "../src/utils/context-limits.ts"; -import { - injectCompressNudges, - assignMessageRefs, -} from "../src/messages/inject.ts"; +import { resolveContextTokenLimit, isContextOverLimits } from "../src/utils/context-limits.ts"; +import { injectCompressNudges, assignMessageRefs } from "../src/messages/inject.ts"; import { createSessionState } from "../src/state/state.ts"; import { makeDefaultConfig, makeUserMessage, makeAssistantMessage } from "./helpers.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; @@ -201,8 +195,7 @@ describe("injectCompressNudges with absolute limits", () => { percent: 25, }); - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("CRITICAL WARNING"); }); @@ -224,8 +217,7 @@ describe("injectCompressNudges with absolute limits", () => { percent: 45, }); - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).not.toContain("dcp-system-reminder"); }); }); diff --git a/tests/deduplication.test.ts b/tests/deduplication.test.ts index d4ddc5c..4f0a8ac 100644 --- a/tests/deduplication.test.ts +++ b/tests/deduplication.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - createToolSignature, - normalizeParams, -} from "../src/strategies/deduplication.ts"; +import { createToolSignature, normalizeParams } from "../src/strategies/deduplication.ts"; describe("deduplication utilities", () => { describe("createToolSignature", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 33558d1..dc0cdd8 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -33,7 +33,13 @@ export function makeAssistantMessage(text: string, timestamp?: number): AgentMes role: "assistant", content: [{ type: "text", text }], stopReason: "stop", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: ts, } as unknown as AgentMessage; } diff --git a/tests/index.test.ts b/tests/index.test.ts index 7ebeb68..a68f65e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -7,9 +7,7 @@ import * as subagentResults from "../src/subagents/subagent-results.ts"; import { createSessionState } from "../src/state/state.ts"; import { serializeDcpSnapshot } from "../src/state/persistence.ts"; -const agentDir = vi.hoisted( - () => `/tmp/dcp-index-test-${Date.now()}-${Math.random()}`, -); +const agentDir = vi.hoisted(() => `/tmp/dcp-index-test-${Date.now()}-${Math.random()}`); vi.mock("@earendil-works/pi-coding-agent", () => ({ getAgentDir: () => agentDir, @@ -115,9 +113,7 @@ describe("dcp extension", () => { sessionManager: { getSessionDir: () => "/tmp/test-session-dir", getSessionId: () => "session", - getBranch: () => [ - { type: "custom", customType: "pi-dcp-state", data: snapshot }, - ], + getBranch: () => [{ type: "custom", customType: "pi-dcp-state", data: snapshot }], }, getContextUsage: () => undefined, }, @@ -148,7 +144,13 @@ describe("dcp extension", () => { provider: "test", model: "test-model", stopReason: "stop", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), }, ]; @@ -220,9 +222,17 @@ describe("dcp extension", () => { { role: "user", content: [{ type: "text", text: "read the file" }], timestamp: 1001 }, { role: "assistant", - content: [{ type: "toolCall", id: "call-1", name: "search_files", arguments: { query: "foo" } }], + content: [ + { type: "toolCall", id: "call-1", name: "search_files", arguments: { query: "foo" } }, + ], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: 1002, }, { @@ -236,9 +246,17 @@ describe("dcp extension", () => { { role: "user", content: [{ type: "text", text: "read it again" }], timestamp: 1004 }, { role: "assistant", - content: [{ type: "toolCall", id: "call-2", name: "search_files", arguments: { query: "foo" } }], + content: [ + { type: "toolCall", id: "call-2", name: "search_files", arguments: { query: "foo" } }, + ], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: 1005, }, { @@ -251,10 +269,7 @@ describe("dcp extension", () => { }, ]; - await (contextHandlers[0] as (...args: unknown[]) => Promise)( - { messages }, - mockCtx, - ); + await (contextHandlers[0] as (...args: unknown[]) => Promise)({ messages }, mockCtx); // setStatus should have been called with the new formatted message expect(setStatus).toHaveBeenCalled(); @@ -281,10 +296,7 @@ describe("dcp extension", () => { const messages = [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }, ]; - await (contextHandlers[0] as (...args: unknown[]) => Promise)( - { messages }, - mockCtx, - ); + await (contextHandlers[0] as (...args: unknown[]) => Promise)({ messages }, mockCtx); expect(setStatus).not.toHaveBeenCalled(); expect(notify).not.toHaveBeenCalled(); }); @@ -305,10 +317,7 @@ describe("dcp extension", () => { const messages = [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }, ]; - await (contextHandlers[0] as (...args: unknown[]) => Promise)( - { messages }, - mockCtx, - ); + await (contextHandlers[0] as (...args: unknown[]) => Promise)({ messages }, mockCtx); expect(setStatus).not.toHaveBeenCalled(); }); @@ -385,9 +394,7 @@ describe("dcp extension", () => { sessionManager: { getSessionDir: () => "/tmp/test-session-dir", getSessionId: () => "session", - getBranch: () => [ - { type: "custom", customType: "pi-dcp-state", data: snapshot }, - ], + getBranch: () => [{ type: "custom", customType: "pi-dcp-state", data: snapshot }], }, getContextUsage: () => undefined, }, @@ -694,9 +701,7 @@ describe("sub-agent support", () => { sessionManager: { getSessionDir: () => "/tmp/test-session", getSessionId: () => "child", - getBranch: () => [ - { type: "custom", customType: "pi-dcp-state", data: snapshot }, - ], + getBranch: () => [{ type: "custom", customType: "pi-dcp-state", data: snapshot }], }, getContextUsage: () => undefined, }, @@ -735,7 +740,11 @@ describe("sub-agent support", () => { // Fire context — should return early (undefined), not { messages: [...] } const contextHandler = handlers.get("context")?.[0]; const result = await (contextHandler as (...args: unknown[]) => Promise)( - { messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }] }, + { + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }, + ], + }, { getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0.05 }) }, ); @@ -773,7 +782,11 @@ describe("sub-agent support", () => { // Fire context — should NOT return early because allowSubAgents overrides the skip const contextHandler = handlers.get("context")?.[0]; const result = await (contextHandler as (...args: unknown[]) => Promise)( - { messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }] }, + { + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }, + ], + }, { getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0.05 }) }, ); @@ -838,10 +851,7 @@ describe("sub-agent support", () => { const sessionCompactHandler = handlers.get("session_compact")?.[0]; await expect( - (sessionCompactHandler as (...args: unknown[]) => Promise)( - {}, - {}, - ), + (sessionCompactHandler as (...args: unknown[]) => Promise)({}, {}), ).resolves.not.toThrow(); }); diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 29338a8..46fba3f 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -8,7 +8,13 @@ import type { ContextUsage } from "../src/state/types.ts"; import { createSessionState } from "../src/state/state.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { CONTEXT_LIMIT_NUDGE, TURN_NUDGE, ITERATION_NUDGE } from "../src/prompts/nudges.ts"; -import { makeUserMessage, makeUserMessageString, makeAssistantMessage, makeDefaultConfig, resetTestTimestamp } from "./helpers.ts"; +import { + makeUserMessage, + makeUserMessageString, + makeAssistantMessage, + makeDefaultConfig, + resetTestTimestamp, +} from "./helpers.ts"; import { buildPriorityMap } from "../src/messages/priority.ts"; // --------------------------------------------------------------------------- @@ -151,9 +157,7 @@ describe("injectMessageIds", () => { it("strips truncated DCP tags before injecting", () => { const state = createSessionState(); - const messages: AgentMessage[] = [ - makeAssistantMessage("Response m0050m0050 { describe("injectMessageIds with priorityMap", () => { it("injects priority attribute when priorityMap is provided", () => { const state = createSessionState(); - const messages = [ - makeUserMessage("a".repeat(400)), - makeAssistantMessage("b".repeat(100)), - ]; + const messages = [makeUserMessage("a".repeat(400)), makeAssistantMessage("b".repeat(100))]; assignMessageRefs(state, messages); const priorityMap = buildPriorityMap(state, messages); const result = injectMessageIds(state, messages, priorityMap); const userText = (result[0] as any).content[0].text as string; - expect(userText).toMatch( - /m0001<\/dcp-message-id>/, - ); + expect(userText).toMatch(/m0001<\/dcp-message-id>/); const assistantText = (result[1] as any).content[0].text as string; - expect(assistantText).toMatch( - /m0002<\/dcp-message-id>/, - ); + expect(assistantText).toMatch(/m0002<\/dcp-message-id>/); }); it("omits priority attribute when priorityMap is undefined", () => { @@ -352,8 +349,16 @@ describe("injectCompressNudges", () => { const messages = [ makeUserMessage("go"), makeAssistantMessage("step 1"), - { role: "toolResult", content: [{ type: "text", text: "result" }], toolCallId: "t1" } as unknown as AgentMessage, - { role: "toolResult", content: [{ type: "text", text: "result" }], toolCallId: "t2" } as unknown as AgentMessage, + { + role: "toolResult", + content: [{ type: "text", text: "result" }], + toolCallId: "t1", + } as unknown as AgentMessage, + { + role: "toolResult", + content: [{ type: "text", text: "result" }], + toolCallId: "t2", + } as unknown as AgentMessage, makeAssistantMessage("step 2"), ]; assignMessageRefs(state, messages); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a1710ae..097ef7a 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -432,71 +432,71 @@ describe("integration", () => { expect(tools.has("compress")).toBe(false); }); - it.each([ - "range", - "message", - ] as const)("blocks an already registered %s compression tool after DCP is disabled", async (mode) => { - const globalConfigPath = path.join(agentDir, "extensions", "dcp.json"); - fs.mkdirSync(path.dirname(globalConfigPath), { recursive: true }); - fs.writeFileSync(globalConfigPath, JSON.stringify({ enabled: true, compress: { mode } })); - const { api, handlers, tools } = createMockApi(); - createExtension(api); - const ctx = { - cwd: agentDir, - isProjectTrusted: () => false, - sessionManager: { getSessionDir: () => "/tmp/test-integration-session" }, - getContextUsage: () => undefined, - hasUI: false, - ui: { setStatus: () => {}, notify: () => {} }, - }; - - for (const handler of handlers.get("session_start") ?? []) { - await handler({ reason: "new" }, ctx); - } - const tool = tools.get("compress") as { - execute: ( - ...args: unknown[] - ) => Promise<{ isError?: boolean; content: Array<{ text: string }> }>; - }; - fs.writeFileSync(globalConfigPath, JSON.stringify({ enabled: false })); - for (const handler of handlers.get("session_start") ?? []) { - await handler({ reason: "resume" }, ctx); - } - - await expect(tool.execute("call", {}, undefined, () => {}, ctx)).resolves.toMatchObject({ - content: [{ text: "Compression is disabled by configuration." }], - isError: true, - }); - }); - - it.each([ - false, - true, - ])("delivers manual compression as a follow-up without persisting while streaming=%s", async (isStreaming) => { - const { api, commands, sentMessages, entries } = createMockApi(); - createExtension(api); - const command = commands.get("dcp:compress") as - | { handler: (args: string, ctx: unknown) => Promise } - | undefined; - if (!command) throw new Error("dcp:compress command not registered"); - const entryCount = entries.length; - - await command.handler("database migrations", { - isStreaming, - ui: { notify: () => {} }, - }); - - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0]).toMatchObject({ - message: { - customType: "dcp-compress-trigger", - content: expect.stringContaining("Focus especially on: database migrations"), - display: false, - }, - options: { triggerTurn: true, deliverAs: "followUp" }, - }); - expect(entries).toHaveLength(entryCount); - }); + it.each(["range", "message"] as const)( + "blocks an already registered %s compression tool after DCP is disabled", + async (mode) => { + const globalConfigPath = path.join(agentDir, "extensions", "dcp.json"); + fs.mkdirSync(path.dirname(globalConfigPath), { recursive: true }); + fs.writeFileSync(globalConfigPath, JSON.stringify({ enabled: true, compress: { mode } })); + const { api, handlers, tools } = createMockApi(); + createExtension(api); + const ctx = { + cwd: agentDir, + isProjectTrusted: () => false, + sessionManager: { getSessionDir: () => "/tmp/test-integration-session" }, + getContextUsage: () => undefined, + hasUI: false, + ui: { setStatus: () => {}, notify: () => {} }, + }; + + for (const handler of handlers.get("session_start") ?? []) { + await handler({ reason: "new" }, ctx); + } + const tool = tools.get("compress") as { + execute: ( + ...args: unknown[] + ) => Promise<{ isError?: boolean; content: Array<{ text: string }> }>; + }; + fs.writeFileSync(globalConfigPath, JSON.stringify({ enabled: false })); + for (const handler of handlers.get("session_start") ?? []) { + await handler({ reason: "resume" }, ctx); + } + + await expect(tool.execute("call", {}, undefined, () => {}, ctx)).resolves.toMatchObject({ + content: [{ text: "Compression is disabled by configuration." }], + isError: true, + }); + }, + ); + + it.each([false, true])( + "delivers manual compression as a follow-up without persisting while streaming=%s", + async (isStreaming) => { + const { api, commands, sentMessages, entries } = createMockApi(); + createExtension(api); + const command = commands.get("dcp:compress") as + | { handler: (args: string, ctx: unknown) => Promise } + | undefined; + if (!command) throw new Error("dcp:compress command not registered"); + const entryCount = entries.length; + + await command.handler("database migrations", { + isStreaming, + ui: { notify: () => {} }, + }); + + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]).toMatchObject({ + message: { + customType: "dcp-compress-trigger", + content: expect.stringContaining("Focus especially on: database migrations"), + display: false, + }, + options: { triggerTurn: true, deliverAs: "followUp" }, + }); + expect(entries).toHaveLength(entryCount); + }, + ); it.each([ ["dcp:sweep", ""], diff --git a/tests/message-content.test.ts b/tests/message-content.test.ts index a61370a..808b35c 100644 --- a/tests/message-content.test.ts +++ b/tests/message-content.test.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from "vitest"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { appendText, mapText } from "../src/utils/message-content.ts"; -import { - makeUserMessage, - makeUserMessageString, - makeAssistantMessage, -} from "./helpers.ts"; +import { makeUserMessage, makeUserMessageString, makeAssistantMessage } from "./helpers.ts"; type WithContent = { content: unknown }; diff --git a/tests/message-end.test.ts b/tests/message-end.test.ts index d589c39..556a48b 100644 --- a/tests/message-end.test.ts +++ b/tests/message-end.test.ts @@ -11,14 +11,10 @@ import { makeAssistantMessage } from "./helpers.ts"; */ describe("message_end strip logic", () => { it("strips complete DCP tags from assistant message content", () => { - const msg = makeAssistantMessage( - "Here is the answer m0012", - ); + const msg = makeAssistantMessage("Here is the answer m0012"); const stripped = mapText(msg, stripHallucinationsFromString); - const textPart = ( - stripped as unknown as { content: Array<{ text: string }> } - ).content[0]; + const textPart = (stripped as unknown as { content: Array<{ text: string }> }).content[0]; expect(textPart.text).toBe("Here is the answer "); }); @@ -26,9 +22,7 @@ describe("message_end strip logic", () => { const msg = makeAssistantMessage("Result m0093 } - ).content[0]; + const textPart = (stripped as unknown as { content: Array<{ text: string }> }).content[0]; expect(textPart.text).toBe("Result "); }); @@ -58,9 +52,8 @@ describe("message_end strip logic", () => { } as unknown as AgentMessage; const stripped = mapText(msg, stripHallucinationsFromString); - const parts = ( - stripped as unknown as { content: Array<{ type: string; text?: string }> } - ).content; + const parts = (stripped as unknown as { content: Array<{ type: string; text?: string }> }) + .content; expect(parts[0].text).toBe("Before "); expect(parts[1].type).toBe("toolCall"); expect(parts[2].text).toBe("After "); diff --git a/tests/message-ids.test.ts b/tests/message-ids.test.ts index 8c32675..c064f2b 100644 --- a/tests/message-ids.test.ts +++ b/tests/message-ids.test.ts @@ -68,9 +68,7 @@ describe("message-ids", () => { describe("formatMessageIdTag", () => { it("formats basic tag", () => { - expect(formatMessageIdTag("m0001")).toBe( - "m0001", - ); + expect(formatMessageIdTag("m0001")).toBe("m0001"); }); it("formats tag with priority", () => { diff --git a/tests/notification.test.ts b/tests/notification.test.ts index 8565858..f3b5e7c 100644 --- a/tests/notification.test.ts +++ b/tests/notification.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - buildMinimalMessage, - buildDetailedMessage, -} from "../src/ui/notification.ts"; +import { buildMinimalMessage, buildDetailedMessage } from "../src/ui/notification.ts"; describe("buildMinimalMessage", () => { it("formats token count and prune count", () => { @@ -38,10 +35,7 @@ describe("buildMinimalMessage", () => { describe("buildDetailedMessage", () => { it("includes pruned tool list", () => { - const result = buildDetailedMessage({ tokensSaved: 5000, pruned: 2 }, [ - "grep", - "ls", - ]); + const result = buildDetailedMessage({ tokensSaved: 5000, pruned: 2 }, ["grep", "ls"]); expect(result).toContain("~5.0K tokens saved"); expect(result).toContain("grep"); expect(result).toContain("ls"); @@ -54,11 +48,7 @@ describe("buildDetailedMessage", () => { }); it("deduplicates tool names", () => { - const result = buildDetailedMessage({ tokensSaved: 3000, pruned: 3 }, [ - "grep", - "grep", - "ls", - ]); + const result = buildDetailedMessage({ tokensSaved: 3000, pruned: 3 }, ["grep", "grep", "ls"]); expect(result).toContain("Pruned: grep, ls"); }); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 40cdcb0..05d48f9 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -34,22 +34,55 @@ describe("persistence", () => { fs.mkdirSync(dir1, { recursive: true }); fs.mkdirSync(dir2, { recursive: true }); const snapshot = (owner: string, total: number, tools: number, messages: number) => ({ - version: 1, ownerSessionId: owner, manualMode: false, compressPermission: "allow", - stats: { pruneTokenCounter: 0, totalPruneTokens: total, toolsPruned: tools, messagesCompressed: messages }, - lastCompaction: 0, pruneTools: [], blocks: [], nextBlockId: 1, nextRunId: 1, + version: 1, + ownerSessionId: owner, + manualMode: false, + compressPermission: "allow", + stats: { + pruneTokenCounter: 0, + totalPruneTokens: total, + toolsPruned: tools, + messagesCompressed: messages, + }, + lastCompaction: 0, + pruneTools: [], + blocks: [], + nextBlockId: 1, + nextRunId: 1, messageIds: { byRawId: [], nextRefIndex: 1 }, nudges: { contextLimitAnchors: [], turnAnchors: [], iterationAnchors: [] }, }); - fs.writeFileSync(path.join(dir1, "session.jsonl"), [ - JSON.stringify(sessionHeader("session-1")), - JSON.stringify({ type: "custom", customType: "pi-dcp-state", timestamp: "2026-07-29T00:00:01.000Z", data: snapshot("one", 300, 2, 1) }), - JSON.stringify({ type: "custom", customType: "pi-dcp-state", timestamp: "2026-07-29T00:00:02.000Z", data: snapshot("one", 400, 3, 2) }), - ].join("\n")); - fs.writeFileSync(path.join(dir2, "session.jsonl"), [ - JSON.stringify(sessionHeader("session-2")), - "not json", - JSON.stringify({ type: "custom", customType: "pi-dcp-state", timestamp: "2026-07-29T00:00:01.000Z", data: snapshot("two", 700, 5, 3) }), - ].join("\n")); + fs.writeFileSync( + path.join(dir1, "session.jsonl"), + [ + JSON.stringify(sessionHeader("session-1")), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: snapshot("one", 300, 2, 1), + }), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:02.000Z", + data: snapshot("one", 400, 3, 2), + }), + ].join("\n"), + ); + fs.writeFileSync( + path.join(dir2, "session.jsonl"), + [ + JSON.stringify(sessionHeader("session-2")), + "not json", + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:01.000Z", + data: snapshot("two", 700, 5, 3), + }), + ].join("\n"), + ); const result = await loadAllSessionStats(tempDir); expect(result.totalTokensSaved).toBe(1100); @@ -87,15 +120,18 @@ describe("persistence", () => { state.stats.totalPruneTokens = 100; const snapshot = persistence.serializeDcpSnapshot(state); if (!snapshot) throw new Error("expected snapshot"); - fs.writeFileSync(path.join(tempDir, "fake.jsonl"), [ - JSON.stringify({ type: "session" }), - JSON.stringify({ - type: "custom", - customType: "pi-dcp-state", - timestamp: "2026-07-29T00:00:00.000Z", - data: snapshot, - }), - ].join("\n")); + fs.writeFileSync( + path.join(tempDir, "fake.jsonl"), + [ + JSON.stringify({ type: "session" }), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "2026-07-29T00:00:00.000Z", + data: snapshot, + }), + ].join("\n"), + ); const result = await loadAllSessionStats(tempDir); @@ -109,15 +145,18 @@ describe("persistence", () => { state.stats.totalPruneTokens = 100; const snapshot = persistence.serializeDcpSnapshot(state); if (!snapshot) throw new Error("expected snapshot"); - fs.writeFileSync(path.join(tempDir, "invalid-timestamp.jsonl"), [ - JSON.stringify(sessionHeader("session")), - JSON.stringify({ - type: "custom", - customType: "pi-dcp-state", - timestamp: "not-a-date", - data: snapshot, - }), - ].join("\n")); + fs.writeFileSync( + path.join(tempDir, "invalid-timestamp.jsonl"), + [ + JSON.stringify(sessionHeader("session")), + JSON.stringify({ + type: "custom", + customType: "pi-dcp-state", + timestamp: "not-a-date", + data: snapshot, + }), + ].join("\n"), + ); const result = await loadAllSessionStats(tempDir); @@ -179,8 +218,16 @@ describe("persistence", () => { manualMode: "active", compressPermission: "allow", stats: { totalPruneTokens: 42 }, - pruneTools: [["a-tool", 3], ["z-tool", 5]], - messageIds: { byRawId: [["a-key", "m0001"], ["z-key", "m0002"]] }, + pruneTools: [ + ["a-tool", 3], + ["z-tool", 5], + ], + messageIds: { + byRawId: [ + ["a-key", "m0001"], + ["z-key", "m0002"], + ], + }, nudges: { contextLimitAnchors: ["a-key", "z-key"] }, }); expect(snapshot?.blocks[0]).toEqual({ @@ -223,9 +270,7 @@ describe("persistence", () => { restored.messageIds.byIndex.set(3, "stale"); expect(persistence).toHaveProperty("restoreDcpSnapshot"); - expect( - persistence.restoreDcpSnapshot(snapshot, restored, "child"), - ).toBe(true); + expect(persistence.restoreDcpSnapshot(snapshot, restored, "child")).toBe(true); expect(restored.sessionId).toBe("child"); expect(restored.manualMode).toBe("active"); expect(restored.compressPermission).toBe("deny"); @@ -246,9 +291,7 @@ describe("persistence", () => { const state = createSessionState(); state.sessionId = "owner"; const snapshot = persistence.serializeDcpSnapshot(state)!; - expect( - persistence.parseDcpSnapshot({ ...snapshot, nextBlockId: 0 }), - ).toBeUndefined(); + expect(persistence.parseDcpSnapshot({ ...snapshot, nextBlockId: 0 })).toBeUndefined(); expect( persistence.parseDcpSnapshot({ ...snapshot, stats: { totalPruneTokens: 1 } }), ).toBeUndefined(); @@ -314,7 +357,10 @@ describe("persistence", () => { state.sessionId = "owner"; const snapshot = persistence.serializeDcpSnapshot(state); if (!snapshot) throw new Error("expected snapshot"); - snapshot.pruneTools = [["call", 1], ["call", 2]]; + snapshot.pruneTools = [ + ["call", 1], + ["call", 2], + ]; const parsed = persistence.parseDcpSnapshot(snapshot); @@ -333,28 +379,32 @@ describe("persistence", () => { stats: { ...snapshot.stats, totalPruneTokens: -1 }, }), ).toBeUndefined(); - expect( - persistence.parseDcpSnapshot({ ...snapshot, nextRunId: 1.5 }), - ).toBeUndefined(); + expect(persistence.parseDcpSnapshot({ ...snapshot, nextRunId: 1.5 })).toBeUndefined(); - snapshot.pruneTools = [["negative", -1], ["fractional", 1.5], ["valid", 0]]; - snapshot.blocks = [{ - blockId: 1, - runId: 1, - deactivatedByUser: false, - compressedTokens: -1, - summaryTokens: 1, - durationMs: 1, - mode: "range", - topic: "topic", - compressToolCallId: "owner", - startKey: "start", - endKey: "end", - anchorKey: "anchor", - consumedBlockIds: [], - createdAt: 1, - summary: "summary", - }]; + snapshot.pruneTools = [ + ["negative", -1], + ["fractional", 1.5], + ["valid", 0], + ]; + snapshot.blocks = [ + { + blockId: 1, + runId: 1, + deactivatedByUser: false, + compressedTokens: -1, + summaryTokens: 1, + durationMs: 1, + mode: "range", + topic: "topic", + compressToolCallId: "owner", + startKey: "start", + endKey: "end", + anchorKey: "anchor", + consumedBlockIds: [], + createdAt: 1, + summary: "summary", + }, + ]; const parsed = persistence.parseDcpSnapshot(snapshot); expect(parsed?.pruneTools).toEqual([["valid", 0]]); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index d23c48d..e364b43 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from "vitest"; import { runPipeline } from "../src/pipeline.ts"; import { createSessionState } from "../src/state/state.ts"; -import { - makeDefaultConfig, - makeUserMessage, - makeAssistantMessage, -} from "./helpers.ts"; +import { makeDefaultConfig, makeUserMessage, makeAssistantMessage } from "./helpers.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ContextUsage } from "../src/state/types.ts"; import { applyCompressionState, allocateBlockId, allocateRunId } from "../src/compress/state.ts"; @@ -15,10 +11,7 @@ describe("runPipeline", () => { it("returns messages unchanged when no pruning applies", () => { const state = createSessionState(); const config = makeDefaultConfig(); - const messages: AgentMessage[] = [ - makeUserMessage("Hello"), - makeAssistantMessage("Hi there"), - ]; + const messages: AgentMessage[] = [makeUserMessage("Hello"), makeAssistantMessage("Hi there")]; const result = runPipeline(state, config, messages, undefined); @@ -31,9 +24,7 @@ describe("runPipeline", () => { const config = makeDefaultConfig(); const messages: AgentMessage[] = [ makeUserMessage("Hello"), - makeAssistantMessage( - 'Response with hallucination', - ), + makeAssistantMessage('Response with hallucination'), ]; const result = runPipeline(state, config, messages, undefined); @@ -77,9 +68,17 @@ describe("runPipeline", () => { makeUserMessage("Read the file"), { role: "assistant", - content: [{ type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "/a.ts" } }], + content: [ + { type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "/a.ts" } }, + ], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, { @@ -94,9 +93,17 @@ describe("runPipeline", () => { makeUserMessage("Read it again"), { role: "assistant", - content: [{ type: "toolCall", id: "call-2", name: "read_file", arguments: { path: "/a.ts" } }], + content: [ + { type: "toolCall", id: "call-2", name: "read_file", arguments: { path: "/a.ts" } }, + ], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, { @@ -162,9 +169,7 @@ describe("runPipeline", () => { (message): message is Extract => message.role === "assistant", ); - const toolCall = assistant?.content.find( - (part) => part.type === "toolCall", - ); + const toolCall = assistant?.content.find((part) => part.type === "toolCall"); const errorResult = result.messages.find( (message): message is Extract => message.role === "toolResult", @@ -173,9 +178,7 @@ describe("runPipeline", () => { expect(toolCall?.arguments).toEqual({ __purged: "input removed due to failed tool call", }); - expect(errorResult?.content).toEqual([ - { type: "text", text: "command not found" }, - ]); + expect(errorResult?.content).toEqual([{ type: "text", text: "command not found" }]); expect(result.strategyResult.tokensSaved).toBe( countTokens(JSON.stringify(parameters)) - countTokens( @@ -206,8 +209,10 @@ describe("runPipeline", () => { const result = runPipeline(state, config, messages, usage); // Should have injected a nudge into the last user message - const lastUser = (result.messages[result.messages.length - 1] as any) - .content as Array<{ type: string; text: string }>; + const lastUser = (result.messages[result.messages.length - 1] as any).content as Array<{ + type: string; + text: string; + }>; expect(lastUser[0].text).toContain(""); }); @@ -312,8 +317,7 @@ describe("runPipeline", () => { }); const target = result.messages.find( (message) => - message.role === "user" && - extractMessageText(message).includes("visible target"), + message.role === "user" && extractMessageText(message).includes("visible target"), ); expect(state.nudges.turnAnchors.has("user:4:0")).toBe(true); @@ -357,10 +361,7 @@ describe("runPipeline", () => { summary: "[Compressed Block b1]\ntest\n[End Block b1]", }); - const messages: AgentMessage[] = [ - makeUserMessage("Hello"), - makeAssistantMessage("Hi"), - ]; + const messages: AgentMessage[] = [makeUserMessage("Hello"), makeAssistantMessage("Hi")]; // Should not throw — sync handles stale blocks gracefully const result = runPipeline(state, config, messages, undefined); @@ -371,10 +372,7 @@ describe("runPipeline", () => { const state1 = createSessionState(); const state2 = createSessionState(); const config = makeDefaultConfig(); - const messages: AgentMessage[] = [ - makeUserMessage("Hello"), - makeAssistantMessage("Hi"), - ]; + const messages: AgentMessage[] = [makeUserMessage("Hello"), makeAssistantMessage("Hi")]; const result1 = runPipeline(state1, config, messages, undefined); const result2 = runPipeline(state2, config, messages, undefined); diff --git a/tests/priority.test.ts b/tests/priority.test.ts index 35b5bde..47e354a 100644 --- a/tests/priority.test.ts +++ b/tests/priority.test.ts @@ -71,7 +71,11 @@ describe("buildPriorityMap", () => { // Same position and similar token count — role weight should be the tiebreaker const messages: AgentMessage[] = [ makeUserMessage("x".repeat(200)), - { role: "toolResult", content: [{ type: "text", text: "y".repeat(200) }], toolCallId: "t1" } as unknown as AgentMessage, + { + role: "toolResult", + content: [{ type: "text", text: "y".repeat(200) }], + toolCallId: "t1", + } as unknown as AgentMessage, ]; assignMessageRefs(state, messages); diff --git a/tests/protected-content.test.ts b/tests/protected-content.test.ts index c13bda7..68fd531 100644 --- a/tests/protected-content.test.ts +++ b/tests/protected-content.test.ts @@ -42,9 +42,7 @@ describe("appendProtectedUserMessages", () => { }); it("strips tags from user message text", () => { - const messages: AgentMessage[] = [ - makeUserMessage("Do important thing"), - ]; + const messages: AgentMessage[] = [makeUserMessage("Do important thing")]; const result = appendProtectedUserMessages("Summary", messages, true); expect(result).toContain("Do important thing"); @@ -56,9 +54,7 @@ describe("appendProtectedUserMessages", () => { describe("appendProtectedPromptInfo", () => { it("extracts content within tags and appends", () => { const messages: AgentMessage[] = [ - makeUserMessage( - "Normal text Critical data: API_KEY=abc more text", - ), + makeUserMessage("Normal text Critical data: API_KEY=abc more text"), ]; const result = appendProtectedPromptInfo("Base summary", messages, true); @@ -69,9 +65,7 @@ describe("appendProtectedPromptInfo", () => { it("handles multiple protect tags in one message", () => { const messages: AgentMessage[] = [ - makeUserMessage( - "Item A gap Item B", - ), + makeUserMessage("Item A gap Item B"), ]; const result = appendProtectedPromptInfo("Summary", messages, true); @@ -80,9 +74,7 @@ describe("appendProtectedPromptInfo", () => { }); it("returns unchanged when protectTags is false", () => { - const messages: AgentMessage[] = [ - makeUserMessage("secret"), - ]; + const messages: AgentMessage[] = [makeUserMessage("secret")]; const result = appendProtectedPromptInfo("Summary", messages, false); expect(result).toBe("Summary"); @@ -98,9 +90,7 @@ describe("appendProtectedPromptInfo", () => { describe("appendProtectedToolOutputs", () => { it("appends tool output when tool name matches protectedTools", () => { - const messages: AgentMessage[] = [ - makeToolResultMessage("call1", "read", "file content here"), - ]; + const messages: AgentMessage[] = [makeToolResultMessage("call1", "read", "file content here")]; const result = appendProtectedToolOutputs("Summary", messages, ["read"]); expect(result).toContain("[Protected Tool Output: read]"); @@ -108,9 +98,7 @@ describe("appendProtectedToolOutputs", () => { }); it("does not append when tool name not in protectedTools", () => { - const messages: AgentMessage[] = [ - makeToolResultMessage("call1", "grep", "grep output"), - ]; + const messages: AgentMessage[] = [makeToolResultMessage("call1", "grep", "grep output")]; const result = appendProtectedToolOutputs("Summary", messages, ["read"]); expect(result).toBe("Summary"); diff --git a/tests/prune.test.ts b/tests/prune.test.ts index 1f90f47..10b263a 100644 --- a/tests/prune.test.ts +++ b/tests/prune.test.ts @@ -46,9 +46,7 @@ describe("prune", () => { const state = createSessionState(); state.prune.tools.set("call1", 100); - const messages: AgentMessage[] = [ - makeToolResult("call1", "glob", "lots of output here"), - ]; + const messages: AgentMessage[] = [makeToolResult("call1", "glob", "lots of output here")]; const result = pruneToolOutputs(state, messages); expect(result).toHaveLength(1); @@ -59,9 +57,7 @@ describe("prune", () => { it("does not modify unpruned tool results", () => { const state = createSessionState(); - const messages: AgentMessage[] = [ - makeToolResult("call1", "glob", "output"), - ]; + const messages: AgentMessage[] = [makeToolResult("call1", "glob", "output")]; const result = pruneToolOutputs(state, messages); expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toBe("output"); @@ -71,12 +67,12 @@ describe("prune", () => { const state = createSessionState(); state.prune.tools.set("call1", 100); - const messages: AgentMessage[] = [ - makeToolResult("call1", "bash", "Error: not found", true), - ]; + const messages: AgentMessage[] = [makeToolResult("call1", "bash", "Error: not found", true)]; const result = pruneToolOutputs(state, messages); - expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toBe("Error: not found"); + expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toBe( + "Error: not found", + ); }); }); @@ -91,7 +87,9 @@ describe("prune", () => { ]; const result = applyPruning(state, messages); - expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toContain("[Output removed"); + expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toContain( + "[Output removed", + ); expect((result[1] as { content: Array<{ text: string }> }).content[0].text).toBe("untouched"); }); @@ -108,12 +106,7 @@ describe("prune", () => { assistantIndex: 0, resultIndex: 1, }); - const errorResult = makeToolResult( - "failed-1", - "custom_tool", - "command not found", - true, - ); + const errorResult = makeToolResult("failed-1", "custom_tool", "command not found", true); const messages = [ makeAssistantWithToolCall("failed-1", "custom_tool", { command: "very long invalid command", @@ -122,13 +115,8 @@ describe("prune", () => { ]; const result = applyPruning(state, messages); - const assistant = result[0] as Extract< - AgentMessage, - { role: "assistant" } - >; - const toolCall = assistant.content.find( - (part) => part.type === "toolCall", - ); + const assistant = result[0] as Extract; + const toolCall = assistant.content.find((part) => part.type === "toolCall"); expect(toolCall?.arguments).toEqual({ __purged: "input removed due to failed tool call", @@ -146,12 +134,22 @@ describe("prune", () => { // Simulate a scenario where compression covers only the assistant (index 1) // but leaves its toolResult (index 2) as an orphan const messages: AgentMessage[] = [ - { role: "user", content: [{ type: "text", text: "read it" }], timestamp: Date.now() } as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "read it" }], + timestamp: Date.now(), + } as AgentMessage, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, { @@ -162,7 +160,11 @@ describe("prune", () => { isError: false, timestamp: Date.now(), } as AgentMessage, - { role: "user", content: [{ type: "text", text: "thanks" }], timestamp: Date.now() } as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "thanks" }], + timestamp: Date.now(), + } as AgentMessage, ]; // Manually create a block that covers only index 1 (the assistant with toolCall) @@ -204,7 +206,13 @@ describe("prune", () => { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], stopReason: "toolUse", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, { @@ -242,11 +250,31 @@ describe("prune", () => { const runId = allocateRunId(state); const messages: AgentMessage[] = [ - { role: "user", content: [{ type: "text", text: "start" }], timestamp: Date.now() } as AgentMessage, - { role: "assistant", content: [{ type: "text", text: "response 1" }], timestamp: Date.now() } as unknown as AgentMessage, - { role: "user", content: [{ type: "text", text: "middle" }], timestamp: Date.now() } as AgentMessage, - { role: "assistant", content: [{ type: "text", text: "response 2" }], timestamp: Date.now() } as unknown as AgentMessage, - { role: "user", content: [{ type: "text", text: "end" }], timestamp: Date.now() } as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "start" }], + timestamp: Date.now(), + } as AgentMessage, + { + role: "assistant", + content: [{ type: "text", text: "response 1" }], + timestamp: Date.now(), + } as unknown as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "middle" }], + timestamp: Date.now(), + } as AgentMessage, + { + role: "assistant", + content: [{ type: "text", text: "response 2" }], + timestamp: Date.now(), + } as unknown as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "end" }], + timestamp: Date.now(), + } as AgentMessage, ]; applyCompressionState(state, { @@ -274,7 +302,10 @@ describe("prune", () => { // biome-ignore lint/suspicious/noExplicitAny: test helper const content = (m as any).content; // biome-ignore lint/suspicious/noExplicitAny: test helper - return Array.isArray(content) && content.some((c: any) => c.type === "text" && c.text.includes("Summary of messages 1-3")); + return ( + Array.isArray(content) && + content.some((c: any) => c.type === "text" && c.text.includes("Summary of messages 1-3")) + ); }); expect(summaryMsg).toBeDefined(); }); @@ -282,7 +313,11 @@ describe("prune", () => { it("passes messages unchanged when no active blocks", () => { const state = createSessionState(); const messages: AgentMessage[] = [ - { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() } as AgentMessage, + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + } as AgentMessage, ]; const result = applyPruning(state, messages); expect(result).toHaveLength(1); diff --git a/tests/stable-ids.test.ts b/tests/stable-ids.test.ts index 7191383..e67826b 100644 --- a/tests/stable-ids.test.ts +++ b/tests/stable-ids.test.ts @@ -71,7 +71,11 @@ describe("assignMessageRefs (stable)", () => { it("preserves refs when messages reorder", () => { const state = createSessionState(); - const msg1 = { role: "user", content: [{ type: "text", text: "first" }], timestamp: 1000 } as AgentMessage; + const msg1 = { + role: "user", + content: [{ type: "text", text: "first" }], + timestamp: 1000, + } as AgentMessage; const msg2 = { role: "assistant", content: [{ type: "text", text: "second" }], @@ -93,8 +97,16 @@ describe("assignMessageRefs (stable)", () => { it("handles new messages added between existing ones", () => { const state = createSessionState(); - const msg1 = { role: "user", content: [{ type: "text", text: "A" }], timestamp: 1000 } as AgentMessage; - const msg2 = { role: "user", content: [{ type: "text", text: "B" }], timestamp: 3000 } as AgentMessage; + const msg1 = { + role: "user", + content: [{ type: "text", text: "A" }], + timestamp: 1000, + } as AgentMessage; + const msg2 = { + role: "user", + content: [{ type: "text", text: "B" }], + timestamp: 3000, + } as AgentMessage; assignMessageRefs(state, [msg1, msg2]); expect(state.messageIds.nextRefIndex).toBe(3); @@ -153,7 +165,11 @@ describe("assignMessageRefs (stable)", () => { it("rebuilds byIndex on every call (runtime cache)", () => { const state = createSessionState(); - const msg = { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1000 } as AgentMessage; + const msg = { + role: "user", + content: [{ type: "text", text: "hi" }], + timestamp: 1000, + } as AgentMessage; assignMessageRefs(state, [msg]); expect(state.messageIds.byIndex.size).toBe(1); diff --git a/tests/strategy-runner.test.ts b/tests/strategy-runner.test.ts index 04d89be..41855b9 100644 --- a/tests/strategy-runner.test.ts +++ b/tests/strategy-runner.test.ts @@ -47,9 +47,7 @@ describe("runStrategies", () => { // b1 is an old error (turn 1, current turn 10, threshold 4) expect(state.prune.tools.has("b1")).toBe(true); expect(result.pruned).toBe(2); - expect(result.tokensSaved).toBe( - 100 + estimatePurgedInputSavings(failedParameters), - ); + expect(result.tokensSaved).toBe(100 + estimatePurgedInputSavings(failedParameters)); }); it("counts removed inputs when deduplicating failed calls", () => { @@ -173,12 +171,7 @@ describe("runStrategies", () => { }); it("protects mutation and orchestration tools by default", () => { - expect(BASE_PROTECTED_TOOLS).toEqual([ - "compress", - "write", - "edit", - "subagent", - ]); + expect(BASE_PROTECTED_TOOLS).toEqual(["compress", "write", "edit", "subagent"]); }); it.each([ @@ -409,9 +402,7 @@ describe("runStrategies", () => { // Purge SHOULD prune the error (custom_tool not protected for purge) expect(state.prune.tools.has("err1")).toBe(true); expect(result.pruned).toBe(1); - expect(result.tokensSaved).toBe( - estimatePurgedInputSavings({ path: "/c.ts" }), - ); + expect(result.tokensSaved).toBe(estimatePurgedInputSavings({ path: "/c.ts" })); }); it("does not re-prune already-pruned tools", () => { diff --git a/tests/strip.test.ts b/tests/strip.test.ts index 048afa1..a5790e9 100644 --- a/tests/strip.test.ts +++ b/tests/strip.test.ts @@ -6,7 +6,7 @@ describe("strip", () => { describe("stripHallucinationsFromString", () => { it("removes paired dcp tags", () => { const result = stripHallucinationsFromString( - "hello m0001 world" + "hello m0001 world", ); expect(result).toBe("hello world"); }); @@ -67,7 +67,13 @@ describe("strip", () => { provider: "test", model: "test-model", stopReason: "stop", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, ]; @@ -82,15 +88,15 @@ describe("strip", () => { const messages: AgentMessage[] = [ { role: "user", - content: [ - { type: "text", text: "m0001" }, - ], + content: [{ type: "text", text: "m0001" }], timestamp: Date.now(), } as AgentMessage, ]; const result = stripHallucinations(messages); - expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toContain("dcp-message-id"); + expect((result[0] as { content: Array<{ text: string }> }).content[0].text).toContain( + "dcp-message-id", + ); }); it("returns same reference when no changes needed", () => { @@ -102,7 +108,13 @@ describe("strip", () => { provider: "test", model: "test-model", stopReason: "stop", - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, totalTokens: 0 }, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, timestamp: Date.now(), } as unknown as AgentMessage, ]; diff --git a/tests/subagent-enrichment.test.ts b/tests/subagent-enrichment.test.ts index eed2591..aad95bd 100644 --- a/tests/subagent-enrichment.test.ts +++ b/tests/subagent-enrichment.test.ts @@ -15,7 +15,10 @@ describe("appendSubAgentResults", () => { } as AgentMessage, ]; const cache = new Map([ - ["call-sub-1", "Child assistant: I refactored the module.\n\nChild assistant: All tests pass."], + [ + "call-sub-1", + "Child assistant: I refactored the module.\n\nChild assistant: All tests pass.", + ], ]); const result = appendSubAgentResults("Original summary", messages, cache); diff --git a/tests/summary-buffer.test.ts b/tests/summary-buffer.test.ts index c4b1796..ebca92a 100644 --- a/tests/summary-buffer.test.ts +++ b/tests/summary-buffer.test.ts @@ -2,15 +2,8 @@ import { describe, it, expect } from "vitest"; import { createSessionState } from "../src/state/state.ts"; import { getActiveSummaryTokenUsage } from "../src/compress/state.ts"; import type { CompressionBlock } from "../src/state/types.ts"; -import { - injectCompressNudges, - assignMessageRefs, -} from "../src/messages/inject.ts"; -import { - makeUserMessage, - makeAssistantMessage, - makeDefaultConfig, -} from "./helpers.ts"; +import { injectCompressNudges, assignMessageRefs } from "../src/messages/inject.ts"; +import { makeUserMessage, makeAssistantMessage, makeDefaultConfig } from "./helpers.ts"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; function makeBlock(overrides: Partial): CompressionBlock { @@ -119,8 +112,7 @@ describe("injectCompressNudges with summaryBuffer", () => { // Should get TURN_NUDGE (not CONTEXT_LIMIT_NUDGE) const lastMsg = result[result.length - 1]; - const text = (lastMsg as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (lastMsg as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("Evaluate the conversation"); expect(text).not.toContain("CRITICAL WARNING"); }); @@ -150,8 +142,7 @@ describe("injectCompressNudges with summaryBuffer", () => { percent: 85, }); - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("CRITICAL WARNING"); }); @@ -179,8 +170,7 @@ describe("injectCompressNudges with summaryBuffer", () => { percent: 82, }); - const text = (result[0] as unknown as { content: Array<{ text: string }> }) - .content[0].text; + const text = (result[0] as unknown as { content: Array<{ text: string }> }).content[0].text; expect(text).toContain("CRITICAL WARNING"); }); }); diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 121d83e..6c77352 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -23,16 +23,31 @@ describe("syncCompressionBlocks", () => { const blockId = allocateBlockId(state); const runId = allocateRunId(state); applyCompressionState(state, { - blockId, runId, topic: "test", mode: "range", startIndex: 0, endIndex: 1, anchorIndex: 1, - compressToolCallId: "compress-call-1", startKey: "user:1:0", endKey: "assistant:2:0", anchorKey: "assistant:2:0", - summary: "summary", summaryTokens: 1, consumedBlockIds: [], + blockId, + runId, + topic: "test", + mode: "range", + startIndex: 0, + endIndex: 1, + anchorIndex: 1, + compressToolCallId: "compress-call-1", + startKey: "user:1:0", + endKey: "assistant:2:0", + anchorKey: "assistant:2:0", + summary: "summary", + summaryTokens: 1, + consumedBlockIds: [], }); const block = state.prune.messages.blocksById.get(blockId); if (!block) throw new Error("expected block"); block.startIndex = -1; const staleId = allocateBlockId(state); state.prune.messages.blocksById.set(staleId, { - ...block, blockId: staleId, startKey: "missing", endKey: "missing", anchorKey: "missing", + ...block, + blockId: staleId, + startKey: "missing", + endKey: "missing", + anchorKey: "missing", }); syncCompressionBlocks(state, messages); @@ -96,7 +111,9 @@ describe("syncCompressionBlocks", () => { syncCompressionBlocks(state, messages); - expect(state.prune.messages.blocksById.get(parentId)?.effectiveMessageIndices).toEqual([0, 1, 2]); + expect(state.prune.messages.blocksById.get(parentId)?.effectiveMessageIndices).toEqual([ + 0, 1, 2, + ]); expect(state.prune.messages.blocksById.get(parentId)?.directMessageIndices).toEqual([2]); expect(state.prune.messages.blocksById.get(childId)?.parentBlockIds).toEqual([parentId]); }); diff --git a/tests/tool-cache.test.ts b/tests/tool-cache.test.ts index 918a25f..20cb07f 100644 --- a/tests/tool-cache.test.ts +++ b/tests/tool-cache.test.ts @@ -10,9 +10,7 @@ function makeAssistantWithToolCall( ): AgentMessage { return { role: "assistant", - content: [ - { type: "toolCall", id: toolCallId, name: toolName, arguments: args }, - ], + content: [{ type: "toolCall", id: toolCallId, name: toolName, arguments: args }], api: "messages", provider: "test", model: "test-model", @@ -28,11 +26,7 @@ function makeAssistantWithToolCall( } as unknown as AgentMessage; } -function makeToolResult( - toolCallId: string, - toolName: string, - isError = false, -): AgentMessage { +function makeToolResult(toolCallId: string, toolName: string, isError = false): AgentMessage { return { role: "toolResult", toolCallId, @@ -170,12 +164,7 @@ describe("tool-cache", () => { syncToolCache(state, messages); // Current raw messages replace stale cache entries. expect( - ( - state.toolParameters.get("call1")!.parameters as Record< - string, - unknown - > - ).filePath, + (state.toolParameters.get("call1")!.parameters as Record).filePath, ).toBe("/new"); }); diff --git a/tests/turn-protection.test.ts b/tests/turn-protection.test.ts index 0118d4c..41903a0 100644 --- a/tests/turn-protection.test.ts +++ b/tests/turn-protection.test.ts @@ -215,7 +215,7 @@ describe("turn protection for deduplication", () => { ]); const result = runStrategies(state, config); - expect(state.prune.tools.has("a1")).toBe(true); // old, pruned + expect(state.prune.tools.has("a1")).toBe(true); // old, pruned expect(state.prune.tools.has("a2")).toBe(false); // recent, protected expect(state.prune.tools.has("a3")).toBe(false); // last in group expect(result.pruned).toBe(1);