Merge remote-tracking branch 'origin/main' into fix/issue-6647-retry-summary-requests-2
This commit is contained in:
@@ -13,13 +13,13 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.repository.default_branch }}
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
- name: Update contributor approval
|
- name: Update contributor approval
|
||||||
id: update
|
id: update
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Comment on issue
|
- name: Comment on issue
|
||||||
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated' || steps.update.outputs.status == 'already'
|
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated' || steps.update.outputs.status == 'already'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const issueAuthor = context.payload.issue.user.login;
|
const issueAuthor = context.payload.issue.user.login;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.SOURCE_REF }}
|
ref: ${{ env.SOURCE_REF }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
bun-version: 1.3.14
|
bun-version: 1.3.14
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@@ -201,13 +201,13 @@ jobs:
|
|||||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.SOURCE_REF }}
|
ref: ${{ env.SOURCE_REF }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Verify sender permission
|
- name: Verify sender permission
|
||||||
id: verify
|
id: verify
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -254,19 +254,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create high-entropy working directory name
|
- name: Create high-entropy working directory name
|
||||||
id: workdir
|
id: workdir
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
|
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.workdir.outputs.name }}
|
path: ${{ steps.workdir.outputs.name }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -319,7 +319,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Write auth.json
|
- name: Write auth.json
|
||||||
id: write_auth
|
id: write_auth
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
||||||
with:
|
with:
|
||||||
@@ -341,7 +341,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
- name: Run pi /is
|
- name: Run pi /is
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -404,7 +404,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Persist refreshed auth.json
|
- name: Persist refreshed auth.json
|
||||||
if: always() && steps.write_auth.outcome == 'success'
|
if: always() && steps.write_auth.outcome == 'success'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }}
|
GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }}
|
||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
@@ -457,7 +457,7 @@ jobs:
|
|||||||
- name: Export session files
|
- name: Export session files
|
||||||
id: export_session_files
|
id: export_session_files
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
WORKDIR: ${{ steps.workdir.outputs.name }}
|
WORKDIR: ${{ steps.workdir.outputs.name }}
|
||||||
@@ -509,7 +509,7 @@ jobs:
|
|||||||
- name: Upload session gist
|
- name: Upload session gist
|
||||||
id: gist
|
id: gist
|
||||||
if: always() && steps.export_session_files.outcome == 'success'
|
if: always() && steps.export_session_files.outcome == 'success'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -540,7 +540,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Comment with session import instructions
|
- name: Comment with session import instructions
|
||||||
if: always() && steps.gist.outcome == 'success'
|
if: always() && steps.gist.outcome == 'success'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
GIST_URL: ${{ steps.gist.outputs.url }}
|
GIST_URL: ${{ steps.gist.outputs.url }}
|
||||||
GIST_ID: ${{ steps.gist.outputs.id }}
|
GIST_ID: ${{ steps.gist.outputs.id }}
|
||||||
@@ -619,7 +619,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Remove trigger label
|
- name: Remove trigger label
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check issue author
|
- name: Check issue author
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Update triage labels
|
- name: Update triage labels
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const UNTRIAGED_LABEL = 'untriaged';
|
const UNTRIAGED_LABEL = 'untriaged';
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check if contributor is approved
|
- name: Check if contributor is approved
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ jobs:
|
|||||||
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.SOURCE_REF }}
|
ref: ${{ env.SOURCE_REF }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -87,13 +87,13 @@ jobs:
|
|||||||
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
|
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.SOURCE_REF }}
|
ref: ${{ env.SOURCE_REF }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Remove inprogress label
|
- name: Remove inprogress label
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const labelName = 'inprogress';
|
const labelName = 'inprogress';
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"includes": [
|
"includes": [
|
||||||
"packages/*/src/**/*.ts",
|
"packages/*/src/**/*.ts",
|
||||||
"packages/*/test/**/*.ts",
|
"packages/*/test/**/*.ts",
|
||||||
|
"packages/storage/*/src/**/*.ts",
|
||||||
|
"packages/storage/*/test/**/*.ts",
|
||||||
"packages/coding-agent/examples/**/*.ts",
|
"packages/coding-agent/examples/**/*.ts",
|
||||||
"!**/node_modules/**/*",
|
"!**/node_modules/**/*",
|
||||||
"!**/test-sessions.ts",
|
"!**/test-sessions.ts",
|
||||||
|
|||||||
Generated
+39
-98
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
"packages/storage/*",
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
"packages/coding-agent/examples/extensions/with-deps",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||||
@@ -793,48 +794,18 @@
|
|||||||
"resolved": "packages/coding-agent",
|
"resolved": "packages/coding-agent",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-orchestrator": {
|
"node_modules/@earendil-works/pi-server": {
|
||||||
"resolved": "packages/orchestrator",
|
"resolved": "packages/server",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@earendil-works/pi-storage-sqlite-node": {
|
||||||
|
"resolved": "packages/storage/sqlite-node",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-tui": {
|
||||||
"resolved": "packages/tui",
|
"resolved": "packages/tui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
@@ -1403,9 +1374,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1422,9 +1390,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1441,9 +1406,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1460,9 +1422,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1479,9 +1438,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1796,9 +1752,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1816,9 +1769,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1836,9 +1786,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1856,9 +1803,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1876,9 +1820,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1896,9 +1837,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3607,9 +3545,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3631,9 +3566,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3655,9 +3587,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3679,9 +3608,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5115,10 +5041,10 @@
|
|||||||
},
|
},
|
||||||
"packages/agent": {
|
"packages/agent": {
|
||||||
"name": "@earendil-works/pi-agent-core",
|
"name": "@earendil-works/pi-agent-core",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
@@ -5467,7 +5393,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ai": {
|
"packages/ai": {
|
||||||
"name": "@earendil-works/pi-ai",
|
"name": "@earendil-works/pi-ai",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -5773,12 +5699,12 @@
|
|||||||
},
|
},
|
||||||
"packages/coding-agent": {
|
"packages/coding-agent": {
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.80.10",
|
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"@earendil-works/pi-tui": "^0.80.10",
|
"@earendil-works/pi-tui": "^0.81.0",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -5819,32 +5745,32 @@
|
|||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.52.0"
|
"@anthropic-ai/sdk": "0.52.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||||
"version": "0.80.10"
|
"version": "0.81.0"
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/gondolin": {
|
"packages/coding-agent/examples/extensions/gondolin": {
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/gondolin": "0.12.0"
|
"@earendil-works/gondolin": "0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/sandbox": {
|
"packages/coding-agent/examples/extensions/sandbox": {
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.10.10",
|
"version": "1.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/with-deps": {
|
"packages/coding-agent/examples/extensions/with-deps": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.3"
|
"ms": "2.1.3"
|
||||||
},
|
},
|
||||||
@@ -6138,12 +6064,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/orchestrator": {
|
"packages/server": {
|
||||||
"name": "@earendil-works/pi-orchestrator",
|
"name": "@earendil-works/pi-server",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "^0.80.10"
|
"@earendil-works/pi-coding-agent": "^0.81.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"server": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"shx": "0.4.0"
|
"shx": "0.4.0"
|
||||||
@@ -6152,9 +6081,21 @@
|
|||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/storage/sqlite-node": {
|
||||||
|
"name": "@earendil-works/pi-storage-sqlite-node",
|
||||||
|
"version": "0.81.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||||
|
"@earendil-works/pi-ai": "^0.81.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@earendil-works/pi-tui",
|
"name": "@earendil-works/pi-tui",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
|
|||||||
+3
-2
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
"packages/storage/*",
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
"packages/coding-agent/examples/extensions/with-deps",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||||
@@ -12,8 +13,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "npm run clean --workspaces",
|
"clean": "npm run clean --workspaces",
|
||||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../orchestrator && npm run build",
|
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build",
|
||||||
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../orchestrator && npm run build",
|
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build",
|
||||||
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
|
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
|
||||||
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
|
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
|
||||||
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
|
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- Moved the `uuidv7` export to `@earendil-works/pi-ai`.
|
- Changed `SessionStorage` to use `getPathToRootOrCompaction()`, require session name and statistics methods, support cursor-based entry reads, and store retained compaction tails as self-contained checkpoints ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Moved the `uuidv7` export to `@earendil-works/pi-ai` ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||||
- Replaced the optional `Agent` `streamFn` fallback with a required `streamFunction` and made low-level loop stream functions required, preventing `@earendil-works/pi-ai/compat` and all built-in providers from entering selective-provider bundles ([#6851](https://github.com/earendil-works/pi/issues/6851)).
|
- Replaced the optional `Agent` `streamFn` fallback with a required `streamFunction` and made low-level loop stream functions required, preventing `@earendil-works/pi-ai/compat` and all built-in providers from entering selective-provider bundles ([#6851](https://github.com/earendil-works/pi/issues/6851)).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Stateful agent with tool execution and event streaming. Built on `@earendil-work
|
|||||||
npm install @earendil-works/pi-agent-core
|
npm install @earendil-works/pi-agent-core
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SQLite session backends
|
||||||
|
|
||||||
|
The SQLite session backend and the `node:sqlite` adapter live in a separate package, `@earendil-works/pi-storage-sqlite-node`, so the core package does not pull in runtime builtins or native SQLite dependencies by default. The backend accepts a runtime-specific SQLite factory, allowing other storage backends to ship as their own packages in the future.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-agent-core",
|
"name": "@earendil-works/pi-agent-core",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:harness": "vitest --run --config vitest.harness.config.ts",
|
"test:harness": "vitest --run --config vitest.harness.config.ts",
|
||||||
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
|
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
|
|||||||
@@ -752,6 +752,7 @@ export class AgentHarness<
|
|||||||
result.details,
|
result.details,
|
||||||
provided !== undefined,
|
provided !== undefined,
|
||||||
result.usage,
|
result.usage,
|
||||||
|
result.retainedTail,
|
||||||
);
|
);
|
||||||
const entry = await this.session.getEntry(entryId);
|
const entry = await this.session.getEntry(entryId);
|
||||||
if (entry?.type === "compaction") {
|
if (entry?.type === "compaction") {
|
||||||
|
|||||||
@@ -102,12 +102,14 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage
|
|||||||
export interface CompactionResult<T = unknown> {
|
export interface CompactionResult<T = unknown> {
|
||||||
/** Summary text that replaces compacted history in future context. */
|
/** Summary text that replaces compacted history in future context. */
|
||||||
summary: string;
|
summary: string;
|
||||||
/** Entry id where retained history starts. */
|
/** Entry id where retained history starts. Optional during Pi 2.0 transition. */
|
||||||
firstKeptEntryId: string;
|
firstKeptEntryId?: string;
|
||||||
/** Estimated context tokens before compaction. */
|
/** Estimated context tokens before compaction. */
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
/** Usage from the LLM call(s) that generated this summary, if available. */
|
/** Usage from the LLM call(s) that generated this summary, if available. */
|
||||||
usage?: Usage;
|
usage?: Usage;
|
||||||
|
/** Retained recent messages stored directly on the compaction entry. Optional during Pi 2.0 transition. */
|
||||||
|
retainedTail?: AgentMessage[];
|
||||||
/** Optional implementation-specific details stored with the compaction entry. */
|
/** Optional implementation-specific details stored with the compaction entry. */
|
||||||
details?: T;
|
details?: T;
|
||||||
}
|
}
|
||||||
@@ -608,6 +610,8 @@ export interface CompactionPreparation {
|
|||||||
messagesToSummarize: AgentMessage[];
|
messagesToSummarize: AgentMessage[];
|
||||||
/** Prefix messages summarized separately when compaction splits a turn. */
|
/** Prefix messages summarized separately when compaction splits a turn. */
|
||||||
turnPrefixMessages: AgentMessage[];
|
turnPrefixMessages: AgentMessage[];
|
||||||
|
/** Recent messages retained after compaction and stored on the compaction entry. */
|
||||||
|
retainedTail: AgentMessage[];
|
||||||
/** Whether compaction splits a turn. */
|
/** Whether compaction splits a turn. */
|
||||||
isSplitTurn: boolean;
|
isSplitTurn: boolean;
|
||||||
/** Estimated context tokens before compaction. */
|
/** Estimated context tokens before compaction. */
|
||||||
@@ -642,7 +646,9 @@ export function prepareCompaction(
|
|||||||
if (prevCompactionIndex >= 0) {
|
if (prevCompactionIndex >= 0) {
|
||||||
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
||||||
previousSummary = prevCompaction.summary;
|
previousSummary = prevCompaction.summary;
|
||||||
const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);
|
const firstKeptEntryIndex = prevCompaction.firstKeptEntryId
|
||||||
|
? pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId)
|
||||||
|
: -1;
|
||||||
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
|
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
|
||||||
}
|
}
|
||||||
const boundaryEnd = pathEntries.length;
|
const boundaryEnd = pathEntries.length;
|
||||||
@@ -669,6 +675,11 @@ export function prepareCompaction(
|
|||||||
if (msg) turnPrefixMessages.push(msg);
|
if (msg) turnPrefixMessages.push(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const retainedTail: AgentMessage[] = [];
|
||||||
|
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
|
||||||
|
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
|
||||||
|
if (msg) retainedTail.push(msg);
|
||||||
|
}
|
||||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||||
if (cutPoint.isSplitTurn) {
|
if (cutPoint.isSplitTurn) {
|
||||||
for (const msg of turnPrefixMessages) {
|
for (const msg of turnPrefixMessages) {
|
||||||
@@ -680,6 +691,7 @@ export function prepareCompaction(
|
|||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
messagesToSummarize,
|
messagesToSummarize,
|
||||||
turnPrefixMessages,
|
turnPrefixMessages,
|
||||||
|
retainedTail,
|
||||||
isSplitTurn: cutPoint.isSplitTurn,
|
isSplitTurn: cutPoint.isSplitTurn,
|
||||||
tokensBefore,
|
tokensBefore,
|
||||||
previousSummary,
|
previousSummary,
|
||||||
@@ -720,6 +732,7 @@ export async function compact(
|
|||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
messagesToSummarize,
|
messagesToSummarize,
|
||||||
turnPrefixMessages,
|
turnPrefixMessages,
|
||||||
|
retainedTail,
|
||||||
isSplitTurn,
|
isSplitTurn,
|
||||||
tokensBefore,
|
tokensBefore,
|
||||||
previousSummary,
|
previousSummary,
|
||||||
@@ -795,6 +808,7 @@ export async function compact(
|
|||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
tokensBefore,
|
tokensBefore,
|
||||||
usage: summaryUsage,
|
usage: summaryUsage,
|
||||||
|
retainedTail,
|
||||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||||
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts";
|
import type {
|
||||||
|
FileSystem,
|
||||||
|
JsonlSessionMetadata,
|
||||||
|
LeafEntry,
|
||||||
|
SessionEntryCursorOptions,
|
||||||
|
SessionStorage,
|
||||||
|
SessionTreeEntry,
|
||||||
|
} from "../types.ts";
|
||||||
import { SessionError, toError } from "../types.ts";
|
import { SessionError, toError } from "../types.ts";
|
||||||
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
|
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
|
||||||
|
|
||||||
@@ -293,13 +300,66 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
|||||||
return this.labelsById.get(id);
|
return this.labelsById.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
async getSessionName(): Promise<string | undefined> {
|
||||||
|
const entries = await this.findEntries("session_info");
|
||||||
|
return entries[entries.length - 1]?.name?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionStats() {
|
||||||
|
let messageCount = 0;
|
||||||
|
let cachedTokens = 0;
|
||||||
|
let uncachedTokens = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
let costTotal = 0;
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
if (entry.type === "message") {
|
||||||
|
messageCount += 1;
|
||||||
|
}
|
||||||
|
const usage =
|
||||||
|
entry.type === "message"
|
||||||
|
? entry.message.role === "assistant"
|
||||||
|
? entry.message.usage
|
||||||
|
: undefined
|
||||||
|
: entry.type === "compaction" || entry.type === "branch_summary"
|
||||||
|
? entry.usage
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
!usage ||
|
||||||
|
typeof usage.input !== "number" ||
|
||||||
|
typeof usage.output !== "number" ||
|
||||||
|
typeof usage.cacheRead !== "number" ||
|
||||||
|
typeof usage.cacheWrite !== "number" ||
|
||||||
|
typeof usage.cost?.total !== "number"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cachedTokens += usage.cacheRead;
|
||||||
|
uncachedTokens += usage.input + usage.cacheWrite;
|
||||||
|
totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||||
|
costTotal += usage.cost.total;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageCount,
|
||||||
|
cachedTokens,
|
||||||
|
uncachedTokens,
|
||||||
|
totalTokens,
|
||||||
|
costTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||||
if (leafId === null) return [];
|
if (leafId === null) return [];
|
||||||
const path: SessionTreeEntry[] = [];
|
const path: SessionTreeEntry[] = [];
|
||||||
|
let stopAtEntryId: string | null = null;
|
||||||
let current = this.byId.get(leafId);
|
let current = this.byId.get(leafId);
|
||||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current);
|
path.unshift(current);
|
||||||
|
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
|
||||||
|
if (current.type === "compaction") {
|
||||||
|
if (current.retainedTail) break;
|
||||||
|
stopAtEntryId = current.firstKeptEntryId ?? null;
|
||||||
|
}
|
||||||
if (!current.parentId) break;
|
if (!current.parentId) break;
|
||||||
const parent = this.byId.get(current.parentId);
|
const parent = this.byId.get(current.parentId);
|
||||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||||
@@ -308,7 +368,9 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
||||||
return [...this.entries];
|
const start = options?.afterEntrySeq ?? 0;
|
||||||
|
const end = options?.limit === undefined ? undefined : start + options.limit;
|
||||||
|
return this.entries.slice(start, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
type LeafEntry,
|
type LeafEntry,
|
||||||
|
type SessionEntryCursorOptions,
|
||||||
SessionError,
|
SessionError,
|
||||||
type SessionMetadata,
|
type SessionMetadata,
|
||||||
type SessionStorage,
|
type SessionStorage,
|
||||||
@@ -112,13 +113,66 @@ export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionM
|
|||||||
return this.labelsById.get(id);
|
return this.labelsById.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
async getSessionName(): Promise<string | undefined> {
|
||||||
|
const entries = await this.findEntries("session_info");
|
||||||
|
return entries[entries.length - 1]?.name?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionStats() {
|
||||||
|
let messageCount = 0;
|
||||||
|
let cachedTokens = 0;
|
||||||
|
let uncachedTokens = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
let costTotal = 0;
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
if (entry.type === "message") {
|
||||||
|
messageCount += 1;
|
||||||
|
}
|
||||||
|
const usage =
|
||||||
|
entry.type === "message"
|
||||||
|
? entry.message.role === "assistant"
|
||||||
|
? entry.message.usage
|
||||||
|
: undefined
|
||||||
|
: entry.type === "compaction" || entry.type === "branch_summary"
|
||||||
|
? entry.usage
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
!usage ||
|
||||||
|
typeof usage.input !== "number" ||
|
||||||
|
typeof usage.output !== "number" ||
|
||||||
|
typeof usage.cacheRead !== "number" ||
|
||||||
|
typeof usage.cacheWrite !== "number" ||
|
||||||
|
typeof usage.cost?.total !== "number"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cachedTokens += usage.cacheRead;
|
||||||
|
uncachedTokens += usage.input + usage.cacheWrite;
|
||||||
|
totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||||
|
costTotal += usage.cost.total;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageCount,
|
||||||
|
cachedTokens,
|
||||||
|
uncachedTokens,
|
||||||
|
totalTokens,
|
||||||
|
costTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||||
if (leafId === null) return [];
|
if (leafId === null) return [];
|
||||||
const path: SessionTreeEntry[] = [];
|
const path: SessionTreeEntry[] = [];
|
||||||
|
let stopAtEntryId: string | null = null;
|
||||||
let current = this.byId.get(leafId);
|
let current = this.byId.get(leafId);
|
||||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current);
|
path.unshift(current);
|
||||||
|
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
|
||||||
|
if (current.type === "compaction") {
|
||||||
|
if (current.retainedTail) break;
|
||||||
|
stopAtEntryId = current.firstKeptEntryId ?? null;
|
||||||
|
}
|
||||||
if (!current.parentId) break;
|
if (!current.parentId) break;
|
||||||
const parent = this.byId.get(current.parentId);
|
const parent = this.byId.get(current.parentId);
|
||||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||||
@@ -127,7 +181,9 @@ export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionM
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
||||||
return [...this.entries];
|
const start = options?.afterEntrySeq ?? 0;
|
||||||
|
const end = options?.limit === undefined ? undefined : start + options.limit;
|
||||||
|
return this.entries.slice(start, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,5 @@ export async function getEntriesToFork(
|
|||||||
}
|
}
|
||||||
effectiveLeafId = target.parentId;
|
effectiveLeafId = target.parentId;
|
||||||
}
|
}
|
||||||
return storage.getPathToRoot(effectiveLeafId);
|
return storage.getPathToRootOrCompaction(effectiveLeafId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import type {
|
|||||||
MessageEntry,
|
MessageEntry,
|
||||||
ModelChangeEntry,
|
ModelChangeEntry,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
|
SessionEntryCursorOptions,
|
||||||
SessionInfoEntry,
|
SessionInfoEntry,
|
||||||
SessionMetadata,
|
SessionMetadata,
|
||||||
|
SessionStats,
|
||||||
SessionStorage,
|
SessionStorage,
|
||||||
SessionTreeEntry,
|
SessionTreeEntry,
|
||||||
ThinkingLevelChangeEntry,
|
ThinkingLevelChangeEntry,
|
||||||
@@ -67,12 +69,20 @@ export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEn
|
|||||||
|
|
||||||
const entries: SessionTreeEntry[] = [compaction];
|
const entries: SessionTreeEntry[] = [compaction];
|
||||||
const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id);
|
const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id);
|
||||||
|
if (compaction.retainedTail) {
|
||||||
|
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
||||||
|
entries.push(pathEntries[i]!);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
if (compaction.firstKeptEntryId) {
|
||||||
let foundFirstKept = false;
|
let foundFirstKept = false;
|
||||||
for (let i = 0; i < compactionIdx; i++) {
|
for (let i = 0; i < compactionIdx; i++) {
|
||||||
const entry = pathEntries[i]!;
|
const entry = pathEntries[i]!;
|
||||||
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
||||||
if (foundFirstKept) entries.push(entry);
|
if (foundFirstKept) entries.push(entry);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
||||||
entries.push(pathEntries[i]!);
|
entries.push(pathEntries[i]!);
|
||||||
}
|
}
|
||||||
@@ -111,7 +121,10 @@ export function sessionEntryToContextMessages(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (entry.type === "compaction") {
|
if (entry.type === "compaction") {
|
||||||
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
|
return [
|
||||||
|
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
||||||
|
...(entry.retainedTail ?? []),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
if (entry.type === "branch_summary" && entry.summary) {
|
if (entry.type === "branch_summary" && entry.summary) {
|
||||||
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
||||||
@@ -159,13 +172,13 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||||||
return this.storage.getEntry(id);
|
return this.storage.getEntry(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEntries(): Promise<SessionTreeEntry[]> {
|
getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
||||||
return this.storage.getEntries();
|
return this.storage.getEntries(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
|
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
|
||||||
const leafId = fromId ?? (await this.storage.getLeafId());
|
const leafId = fromId ?? (await this.storage.getLeafId());
|
||||||
return this.storage.getPathToRoot(leafId);
|
return this.storage.getPathToRootOrCompaction(leafId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildContextEntries(options: SessionContextBuildOptions = {}): Promise<SessionTreeEntry[]> {
|
async buildContextEntries(options: SessionContextBuildOptions = {}): Promise<SessionTreeEntry[]> {
|
||||||
@@ -190,9 +203,12 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||||||
return this.storage.getLabel(id);
|
return this.storage.getLabel(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSessionStats(): Promise<SessionStats> {
|
||||||
|
return this.storage.getSessionStats();
|
||||||
|
}
|
||||||
|
|
||||||
async getSessionName(): Promise<string | undefined> {
|
async getSessionName(): Promise<string | undefined> {
|
||||||
const entries = await this.storage.findEntries("session_info");
|
return this.storage.getSessionName();
|
||||||
return entries[entries.length - 1]?.name?.trim() || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async appendTypedEntry<TEntry extends SessionTreeEntry>(entry: TEntry): Promise<string> {
|
private async appendTypedEntry<TEntry extends SessionTreeEntry>(entry: TEntry): Promise<string> {
|
||||||
@@ -243,11 +259,12 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||||||
|
|
||||||
async appendCompaction<T = unknown>(
|
async appendCompaction<T = unknown>(
|
||||||
summary: string,
|
summary: string,
|
||||||
firstKeptEntryId: string,
|
firstKeptEntryId: string | undefined,
|
||||||
tokensBefore: number,
|
tokensBefore: number,
|
||||||
details?: T,
|
details?: T,
|
||||||
fromHook?: boolean,
|
fromHook?: boolean,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
|
retainedTail?: AgentMessage[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.appendTypedEntry({
|
return this.appendTypedEntry({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
@@ -257,6 +274,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||||||
summary,
|
summary,
|
||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
tokensBefore,
|
tokensBefore,
|
||||||
|
retainedTail,
|
||||||
details,
|
details,
|
||||||
usage,
|
usage,
|
||||||
fromHook,
|
fromHook,
|
||||||
|
|||||||
@@ -371,8 +371,9 @@ export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
|
|||||||
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
|
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
|
||||||
type: "compaction";
|
type: "compaction";
|
||||||
summary: string;
|
summary: string;
|
||||||
firstKeptEntryId: string;
|
firstKeptEntryId?: string;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
|
retainedTail?: AgentMessage[];
|
||||||
details?: T;
|
details?: T;
|
||||||
usage?: Usage;
|
usage?: Usage;
|
||||||
fromHook?: boolean;
|
fromHook?: boolean;
|
||||||
@@ -437,6 +438,14 @@ export interface SessionContext {
|
|||||||
activeToolNames: string[] | null;
|
activeToolNames: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionStats {
|
||||||
|
messageCount: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
uncachedTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionMetadata {
|
export interface SessionMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -449,6 +458,11 @@ export interface JsonlSessionMetadata extends SessionMetadata {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionEntryCursorOptions {
|
||||||
|
afterEntrySeq?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||||
getMetadata(): Promise<TMetadata>;
|
getMetadata(): Promise<TMetadata>;
|
||||||
getLeafId(): Promise<string | null>;
|
getLeafId(): Promise<string | null>;
|
||||||
@@ -461,8 +475,10 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
|
|||||||
type: TType,
|
type: TType,
|
||||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>;
|
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>;
|
||||||
getLabel(id: string): Promise<string | undefined>;
|
getLabel(id: string): Promise<string | undefined>;
|
||||||
getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]>;
|
getSessionName(): Promise<string | undefined>;
|
||||||
getEntries(): Promise<SessionTreeEntry[]>;
|
getSessionStats(): Promise<SessionStats>;
|
||||||
|
getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]>;
|
||||||
|
getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Session } from "./session/session.ts";
|
export type { Session } from "./session/session.ts";
|
||||||
@@ -779,10 +795,11 @@ export interface AbortResult {
|
|||||||
|
|
||||||
export interface CompactResult {
|
export interface CompactResult {
|
||||||
summary: string;
|
summary: string;
|
||||||
firstKeptEntryId: string;
|
firstKeptEntryId?: string;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
/** Usage from the LLM call(s) that generated this summary, if available. */
|
/** Usage from the LLM call(s) that generated this summary, if available. */
|
||||||
usage?: Usage;
|
usage?: Usage;
|
||||||
|
retainedTail?: AgentMessage[];
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +819,7 @@ export interface CompactionPreparation {
|
|||||||
firstKeptEntryId: string;
|
firstKeptEntryId: string;
|
||||||
messagesToSummarize: AgentMessage[];
|
messagesToSummarize: AgentMessage[];
|
||||||
turnPrefixMessages: AgentMessage[];
|
turnPrefixMessages: AgentMessage[];
|
||||||
|
retainedTail: AgentMessage[];
|
||||||
isSplitTurn: boolean;
|
isSplitTurn: boolean;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
previousSummary?: string;
|
previousSummary?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Core Agent
|
// Core Agent
|
||||||
|
export { uuidv7 } from "@earendil-works/pi-ai";
|
||||||
export * from "./agent.ts";
|
export * from "./agent.ts";
|
||||||
// Loop functions
|
// Loop functions
|
||||||
export * from "./agent-loop.ts";
|
export * from "./agent-loop.ts";
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ function createCompactionEntry(
|
|||||||
summary: string,
|
summary: string,
|
||||||
firstKeptEntryId: string,
|
firstKeptEntryId: string,
|
||||||
parentId: string | null = null,
|
parentId: string | null = null,
|
||||||
|
retainedTail?: AgentMessage[],
|
||||||
): CompactionEntry {
|
): CompactionEntry {
|
||||||
return {
|
return {
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
@@ -100,6 +101,7 @@ function createCompactionEntry(
|
|||||||
summary,
|
summary,
|
||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
tokensBefore: 1234,
|
tokensBefore: 1234,
|
||||||
|
retainedTail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +345,38 @@ describe("harness compaction", () => {
|
|||||||
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
|
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
|
||||||
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
|
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
|
||||||
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
|
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
|
||||||
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id);
|
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [
|
||||||
|
createUserMessage("2"),
|
||||||
|
createAssistantMessage("b"),
|
||||||
|
]);
|
||||||
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
|
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
|
||||||
const a3 = createMessageEntry(createAssistantMessage("c"), u3.id);
|
const a3 = createMessageEntry(createAssistantMessage("c"), u3.id);
|
||||||
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3, a3]);
|
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3, a3]);
|
||||||
expect(loaded.messages).toHaveLength(5);
|
expect(loaded.messages).toHaveLength(5);
|
||||||
expect(loaded.messages[0]?.role).toBe("compactionSummary");
|
expect(loaded.messages[0]?.role).toBe("compactionSummary");
|
||||||
|
expect(loaded.messages.map((message) => message.role)).toEqual([
|
||||||
|
"compactionSummary",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to firstKeptEntryId when a compaction has no retained tail", () => {
|
||||||
|
const u1 = createMessageEntry(createUserMessage("1"));
|
||||||
|
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
|
||||||
|
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
|
||||||
|
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
|
||||||
|
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id);
|
||||||
|
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
|
||||||
|
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]);
|
||||||
|
expect(loaded.messages.map((message) => message.role)).toEqual([
|
||||||
|
"compactionSummary",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"user",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks model and thinking level changes in built context", () => {
|
it("tracks model and thinking level changes in built context", () => {
|
||||||
@@ -374,6 +402,7 @@ describe("harness compaction", () => {
|
|||||||
expect(preparation).toBeDefined();
|
expect(preparation).toBeDefined();
|
||||||
expect(preparation?.previousSummary).toBe("First summary");
|
expect(preparation?.previousSummary).toBe("First summary");
|
||||||
expect(preparation?.firstKeptEntryId).toBeTruthy();
|
expect(preparation?.firstKeptEntryId).toBeTruthy();
|
||||||
|
expect(preparation?.retainedTail.length).toBeGreaterThan(0);
|
||||||
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
|
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -566,6 +595,7 @@ describe("harness compaction", () => {
|
|||||||
firstKeptEntryId: "entry-keep",
|
firstKeptEntryId: "entry-keep",
|
||||||
messagesToSummarize: messages,
|
messagesToSummarize: messages,
|
||||||
turnPrefixMessages: messages,
|
turnPrefixMessages: messages,
|
||||||
|
retainedTail: messages,
|
||||||
isSplitTurn: true,
|
isSplitTurn: true,
|
||||||
tokensBefore: 600000,
|
tokensBefore: 600000,
|
||||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||||
@@ -583,6 +613,7 @@ describe("harness compaction", () => {
|
|||||||
firstKeptEntryId: "entry-keep",
|
firstKeptEntryId: "entry-keep",
|
||||||
messagesToSummarize: messages,
|
messagesToSummarize: messages,
|
||||||
turnPrefixMessages: [],
|
turnPrefixMessages: [],
|
||||||
|
retainedTail: messages,
|
||||||
isSplitTurn: false,
|
isSplitTurn: false,
|
||||||
tokensBefore: 100,
|
tokensBefore: 100,
|
||||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||||
@@ -619,6 +650,7 @@ describe("harness compaction", () => {
|
|||||||
turnPrefixMessages: messages,
|
turnPrefixMessages: messages,
|
||||||
isSplitTurn: true,
|
isSplitTurn: true,
|
||||||
tokensBefore: 100,
|
tokensBefore: 100,
|
||||||
|
retainedTail: messages,
|
||||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||||
};
|
};
|
||||||
@@ -642,6 +674,7 @@ describe("harness compaction", () => {
|
|||||||
firstKeptEntryId: "entry-keep",
|
firstKeptEntryId: "entry-keep",
|
||||||
messagesToSummarize: [],
|
messagesToSummarize: [],
|
||||||
turnPrefixMessages: messages,
|
turnPrefixMessages: messages,
|
||||||
|
retainedTail: messages,
|
||||||
isSplitTurn: true,
|
isSplitTurn: true,
|
||||||
tokensBefore: 100,
|
tokensBefore: 100,
|
||||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||||
@@ -659,6 +692,7 @@ describe("harness compaction", () => {
|
|||||||
firstKeptEntryId: "entry-keep",
|
firstKeptEntryId: "entry-keep",
|
||||||
messagesToSummarize: [],
|
messagesToSummarize: [],
|
||||||
turnPrefixMessages: messages,
|
turnPrefixMessages: messages,
|
||||||
|
retainedTail: messages,
|
||||||
isSplitTurn: true,
|
isSplitTurn: true,
|
||||||
tokensBefore: 100,
|
tokensBefore: 100,
|
||||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||||
@@ -697,6 +731,7 @@ describe("harness compaction", () => {
|
|||||||
expect(result.summary.length).toBeGreaterThan(0);
|
expect(result.summary.length).toBeGreaterThan(0);
|
||||||
expect(result.firstKeptEntryId).toBeTruthy();
|
expect(result.firstKeptEntryId).toBeTruthy();
|
||||||
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
||||||
|
expect(result.retainedTail?.length).toBeGreaterThan(0);
|
||||||
expect(result.details).toBeDefined();
|
expect(result.details).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ async function runSessionSuite(
|
|||||||
await session.appendMessage(createAssistantMessage("two"));
|
await session.appendMessage(createAssistantMessage("two"));
|
||||||
const user2 = await session.appendMessage(createUserMessage("three"));
|
const user2 = await session.appendMessage(createUserMessage("three"));
|
||||||
await session.appendMessage(createAssistantMessage("four"));
|
await session.appendMessage(createAssistantMessage("four"));
|
||||||
await session.appendCompaction("summary", user2, 1234);
|
await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [
|
||||||
|
createUserMessage("three"),
|
||||||
|
createAssistantMessage("four"),
|
||||||
|
]);
|
||||||
await session.appendMessage(createUserMessage("five"));
|
await session.appendMessage(createUserMessage("five"));
|
||||||
const context = await session.buildContext();
|
const context = await session.buildContext();
|
||||||
expect(context.messages[0]?.role).toBe("compactionSummary");
|
expect(context.messages[0]?.role).toBe("compactionSummary");
|
||||||
expect(context.messages).toHaveLength(4);
|
expect(context.messages).toHaveLength(4);
|
||||||
|
expect(context.messages.map((message) => message.role)).toEqual([
|
||||||
|
"compactionSummary",
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"user",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports moving with branch summary entries in context", async () => {
|
it("supports moving with branch summary entries in context", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyMigrations,
|
||||||
|
createNodeSqliteFactory,
|
||||||
|
type SqliteDatabase,
|
||||||
|
type SqliteDatabaseFactory,
|
||||||
|
type SqliteRunResult,
|
||||||
|
type SqliteSessionMetadata,
|
||||||
|
SqliteSessionRepo,
|
||||||
|
SqliteSessionStorage,
|
||||||
|
type SqliteStatement,
|
||||||
|
} from "../../../storage/sqlite-node/src/index.ts";
|
||||||
|
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||||
|
import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts";
|
||||||
|
|
||||||
|
function createTempDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThrowingStatement implements SqliteStatement {
|
||||||
|
private readonly onRun: () => Promise<SqliteRunResult>;
|
||||||
|
|
||||||
|
constructor(onRun: () => Promise<SqliteRunResult>) {
|
||||||
|
this.onRun = onRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(..._params: unknown[]): Promise<SqliteRunResult> {
|
||||||
|
return this.onRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<TRow extends object>(..._params: unknown[]): Promise<TRow | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async all<TRow extends object>(..._params: unknown[]): Promise<TRow[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CountingDatabase implements SqliteDatabase {
|
||||||
|
closeCount = 0;
|
||||||
|
private readonly statementFactory: (sql: string) => SqliteStatement;
|
||||||
|
|
||||||
|
constructor(statementFactory: (sql: string) => SqliteStatement) {
|
||||||
|
this.statementFactory = statementFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(_sql: string): Promise<void> {}
|
||||||
|
|
||||||
|
prepare(sql: string): SqliteStatement {
|
||||||
|
return this.statementFactory(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closeCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SQLite migrations", () => {
|
||||||
|
it("applies file-based migrations and records them", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||||
|
await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
try {
|
||||||
|
const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>();
|
||||||
|
expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]);
|
||||||
|
const tables = await db
|
||||||
|
.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||||
|
.all<{ name: string; sql: string | null }>();
|
||||||
|
expect(tables.map((row) => row.name)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"migrations",
|
||||||
|
"sessions",
|
||||||
|
"session_entries",
|
||||||
|
"session_sequences",
|
||||||
|
"branch_entries",
|
||||||
|
"session_materialized",
|
||||||
|
"entry_materialized",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>();
|
||||||
|
expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id");
|
||||||
|
for (const tableName of [
|
||||||
|
"sessions",
|
||||||
|
"session_sequences",
|
||||||
|
"branch_entries",
|
||||||
|
"session_materialized",
|
||||||
|
"entry_materialized",
|
||||||
|
]) {
|
||||||
|
const table = tables.find((row) => row.name === tableName);
|
||||||
|
expect(table?.sql).toContain("WITHOUT ROWID");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists session metadata through create, list, open, and fork", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||||
|
const source = await repo.create({
|
||||||
|
cwd: root,
|
||||||
|
id: "session-1",
|
||||||
|
metadata: { profile: "reviewer" },
|
||||||
|
});
|
||||||
|
const sourceMetadata = await source.getMetadata();
|
||||||
|
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
|
||||||
|
expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]);
|
||||||
|
expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||||
|
const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" });
|
||||||
|
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||||
|
const overridden = await repo.fork(sourceMetadata, {
|
||||||
|
cwd: root,
|
||||||
|
id: "session-3",
|
||||||
|
metadata: { profile: "writer" },
|
||||||
|
});
|
||||||
|
expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes active leaf id in sessions transactionally", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||||
|
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||||
|
const childId = await session.appendMessage(createAssistantMessage("child"));
|
||||||
|
await session.getStorage().setLeafId(rootId);
|
||||||
|
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
try {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT active_leaf_id FROM sessions WHERE id = ?")
|
||||||
|
.get<{ active_leaf_id: string | null }>("session-1");
|
||||||
|
expect(row?.active_leaf_id).toBe(rootId);
|
||||||
|
const latestBranchRow = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
|
||||||
|
const latestSessionEntry = await db
|
||||||
|
.prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
|
||||||
|
.get<{ id: string; type: string }>("session-1");
|
||||||
|
expect(latestSessionEntry?.type).toBe("leaf");
|
||||||
|
expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reopened = await repo.open(await session.getMetadata());
|
||||||
|
expect(await reopened.getLeafId()).toBe(rootId);
|
||||||
|
expect(childId).not.toBe(rootId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes a new branch when appending from a parent with an existing child", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||||
|
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||||
|
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
|
||||||
|
await session.getStorage().setLeafId(rootId);
|
||||||
|
const secondChildId = await session.appendMessage(createAssistantMessage("second child"));
|
||||||
|
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
try {
|
||||||
|
const branchRows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq",
|
||||||
|
)
|
||||||
|
.all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
|
||||||
|
const branchIds = [...new Set(branchRows.map((row) => row.branch_id))];
|
||||||
|
expect(branchIds).toHaveLength(3);
|
||||||
|
expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(3);
|
||||||
|
expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1);
|
||||||
|
expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens using branch materialization and session summary state", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||||
|
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||||
|
await session.appendMessage(createAssistantMessage("first child"));
|
||||||
|
await session.appendSessionName(" Reopened Session ");
|
||||||
|
await session.getStorage().setLeafId(rootId);
|
||||||
|
await session.appendMessage(createAssistantMessage("branched child"));
|
||||||
|
|
||||||
|
const reopened = await repo.open(await session.getMetadata());
|
||||||
|
expect(await reopened.getSessionName()).toBe("Reopened Session");
|
||||||
|
expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||||
|
expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({
|
||||||
|
content: [{ type: "text", text: "branched child" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pages entries by entry_seq cursor", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||||
|
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
await session.appendMessage(createUserMessage("one"));
|
||||||
|
await session.appendMessage(createAssistantMessage("two"));
|
||||||
|
await session.appendMessage(createUserMessage("three"));
|
||||||
|
|
||||||
|
expect((await session.getEntries({ limit: 2 })).map((entry) => entry.type)).toEqual(["message", "message"]);
|
||||||
|
expect((await session.getEntries({ afterEntrySeq: 2, limit: 2 })).map((entry) => entry.type)).toEqual([
|
||||||
|
"message",
|
||||||
|
"message",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the database when create fails after openDatabase succeeds", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const db = new CountingDatabase((sql) => {
|
||||||
|
if (sql.startsWith("INSERT INTO sessions")) {
|
||||||
|
return new ThrowingStatement(async () => {
|
||||||
|
throw new Error("insert failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new ThrowingStatement(async () => ({ changes: 1 }));
|
||||||
|
});
|
||||||
|
const sqlite: SqliteDatabaseFactory = {
|
||||||
|
open: async () => db,
|
||||||
|
};
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||||
|
|
||||||
|
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
|
||||||
|
expect(db.closeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the database when open fails after openDatabase succeeds", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const db = new CountingDatabase((sql) => {
|
||||||
|
if (sql.includes("FROM sessions WHERE id = ?")) {
|
||||||
|
return new ThrowingStatement(async () => ({ changes: 0 }));
|
||||||
|
}
|
||||||
|
return new ThrowingStatement(async () => ({ changes: 1 }));
|
||||||
|
});
|
||||||
|
const sqlite: SqliteDatabaseFactory = {
|
||||||
|
open: async () => db,
|
||||||
|
};
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||||
|
const metadata: SqliteSessionMetadata = {
|
||||||
|
id: "missing",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cwd: root,
|
||||||
|
path: join(root, "sessions.sqlite"),
|
||||||
|
};
|
||||||
|
writeFileSync(metadata.path, "");
|
||||||
|
|
||||||
|
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
|
||||||
|
expect(db.closeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the source storage after fork reads its entries", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||||
|
let cleanupCount = 0;
|
||||||
|
const sourceStorage = {
|
||||||
|
async getEntries() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async getPathToRootOrCompaction() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
|
cleanupCount += 1;
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const originalOpen = repo.open.bind(repo);
|
||||||
|
repo.open = async () =>
|
||||||
|
({
|
||||||
|
getStorage() {
|
||||||
|
return sourceStorage;
|
||||||
|
},
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await repo.fork(
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
cwd: root,
|
||||||
|
path: databasePath,
|
||||||
|
},
|
||||||
|
{ cwd: root, id: "session-2" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
repo.open = originalOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cleanupCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores in-memory state when appendEntry fails after mutating caches", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
await applyMigrations(db);
|
||||||
|
const storage = await SqliteSessionStorage.create(db, databasePath, {
|
||||||
|
cwd: root,
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
const originalPrepare = db.prepare.bind(db);
|
||||||
|
db.prepare = (sql: string) => {
|
||||||
|
if (sql.startsWith("UPDATE sessions SET active_leaf_id = ?")) {
|
||||||
|
return new ThrowingStatement(async () => {
|
||||||
|
throw new Error("active leaf update failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return originalPrepare(sql);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
storage.appendEntry({
|
||||||
|
type: "message",
|
||||||
|
id: "root",
|
||||||
|
parentId: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: createUserMessage("root"),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "storage" });
|
||||||
|
expect(await storage.getLeafId()).toBeNull();
|
||||||
|
expect(await storage.getEntry("root")).toBeUndefined();
|
||||||
|
expect(await storage.getEntries()).toEqual([]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes session summary fields transactionally", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "sessions.sqlite");
|
||||||
|
const env = new NodeExecutionEnv({ cwd: root });
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||||
|
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||||
|
const userId = await session.appendMessage(createUserMessage("one"));
|
||||||
|
await session.appendThinkingLevelChange("high");
|
||||||
|
await session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||||
|
const assistant = {
|
||||||
|
...createAssistantMessage("two"),
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
usage: {
|
||||||
|
input: 100,
|
||||||
|
output: 25,
|
||||||
|
cacheRead: 40,
|
||||||
|
cacheWrite: 10,
|
||||||
|
totalTokens: 175,
|
||||||
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await session.appendMessage(assistant);
|
||||||
|
await session.appendCompaction("summary", userId, 200, undefined, false, {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 3,
|
||||||
|
cacheWrite: 4,
|
||||||
|
totalTokens: 10,
|
||||||
|
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
|
||||||
|
});
|
||||||
|
await session.moveTo(userId, {
|
||||||
|
summary: "branch summary",
|
||||||
|
usage: {
|
||||||
|
input: 5,
|
||||||
|
output: 6,
|
||||||
|
cacheRead: 7,
|
||||||
|
cacheWrite: 8,
|
||||||
|
totalTokens: 26,
|
||||||
|
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await session.appendSessionName(" My Session ");
|
||||||
|
await session.appendLabel(userId, "checkpoint");
|
||||||
|
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
try {
|
||||||
|
const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{
|
||||||
|
session_id: string;
|
||||||
|
payload: string;
|
||||||
|
}>("session-1");
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row?.session_id).toBe("session-1");
|
||||||
|
expect(JSON.parse(row?.payload ?? "null")).toMatchObject({
|
||||||
|
name: "My Session",
|
||||||
|
messageCount: 2,
|
||||||
|
cachedTokens: 50,
|
||||||
|
uncachedTokens: 128,
|
||||||
|
totalTokens: 211,
|
||||||
|
costTotal: 0.73,
|
||||||
|
currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
|
||||||
|
currentThinkingLevel: "high",
|
||||||
|
});
|
||||||
|
const entryRows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
|
||||||
|
)
|
||||||
|
.all<{
|
||||||
|
session_id: string;
|
||||||
|
entry_seq: number;
|
||||||
|
type: string;
|
||||||
|
payload: string;
|
||||||
|
}>("session-1");
|
||||||
|
expect(
|
||||||
|
entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId),
|
||||||
|
).toBe(true);
|
||||||
|
expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false);
|
||||||
|
expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts";
|
||||||
|
import { createTempDir } from "./session-test-utils.ts";
|
||||||
|
|
||||||
|
describe("sqlite-node adapter", () => {
|
||||||
|
it("supports node:sqlite-style named parameters", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const databasePath = join(root, "adapter.sqlite");
|
||||||
|
const sqlite = createNodeSqliteFactory();
|
||||||
|
const db = await sqlite.open(databasePath);
|
||||||
|
try {
|
||||||
|
await db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
|
||||||
|
await db.prepare("INSERT INTO items (id, text) VALUES ($id, $text)").run({ $id: 1, $text: "hello" });
|
||||||
|
const row = await db.prepare("SELECT text FROM items WHERE id = $id").get<{ text: string }>({ $id: 1 });
|
||||||
|
expect(row).toEqual({ text: "hello" });
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,13 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
|
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
|
||||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||||
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
|
import {
|
||||||
|
type BranchSummaryEntry,
|
||||||
|
type CompactionEntry,
|
||||||
|
type MessageEntry,
|
||||||
|
ok,
|
||||||
|
type SessionMetadata,
|
||||||
|
} from "../../src/harness/types.ts";
|
||||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
|
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||||
|
|
||||||
describe("InMemorySessionStorage", () => {
|
describe("InMemorySessionStorage", () => {
|
||||||
@@ -80,7 +86,74 @@ describe("InMemorySessionStorage", () => {
|
|||||||
expect(await storage.getLabel("entry-1")).toBeUndefined();
|
expect(await storage.getLabel("entry-1")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("walks paths to root", async () => {
|
it("includes summary-entry usage in session stats", async () => {
|
||||||
|
const assistant: MessageEntry = {
|
||||||
|
type: "message",
|
||||||
|
id: "assistant",
|
||||||
|
parentId: null,
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "reply" }],
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
usage: {
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
cacheRead: 30,
|
||||||
|
cacheWrite: 40,
|
||||||
|
totalTokens: 100,
|
||||||
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const compaction: CompactionEntry = {
|
||||||
|
type: "compaction",
|
||||||
|
id: "compaction",
|
||||||
|
parentId: "assistant",
|
||||||
|
timestamp: "2026-01-01T00:00:01.000Z",
|
||||||
|
summary: "summary",
|
||||||
|
firstKeptEntryId: "assistant",
|
||||||
|
tokensBefore: 1234,
|
||||||
|
usage: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 3,
|
||||||
|
cacheWrite: 4,
|
||||||
|
totalTokens: 10,
|
||||||
|
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const branchSummary: BranchSummaryEntry = {
|
||||||
|
type: "branch_summary",
|
||||||
|
id: "branch-summary",
|
||||||
|
parentId: "compaction",
|
||||||
|
timestamp: "2026-01-01T00:00:02.000Z",
|
||||||
|
fromId: "assistant",
|
||||||
|
summary: "branch",
|
||||||
|
usage: {
|
||||||
|
input: 5,
|
||||||
|
output: 6,
|
||||||
|
cacheRead: 7,
|
||||||
|
cacheWrite: 8,
|
||||||
|
totalTokens: 26,
|
||||||
|
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const storage = new InMemorySessionStorage({ entries: [assistant, compaction, branchSummary] });
|
||||||
|
expect(await storage.getSessionStats()).toEqual({
|
||||||
|
messageCount: 1,
|
||||||
|
cachedTokens: 40,
|
||||||
|
uncachedTokens: 68,
|
||||||
|
totalTokens: 136,
|
||||||
|
costTotal: 1.36,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks paths to root or retained-tail compaction", async () => {
|
||||||
const root: MessageEntry = {
|
const root: MessageEntry = {
|
||||||
type: "message",
|
type: "message",
|
||||||
id: "root",
|
id: "root",
|
||||||
@@ -94,9 +167,29 @@ describe("InMemorySessionStorage", () => {
|
|||||||
parentId: "root",
|
parentId: "root",
|
||||||
message: createAssistantMessage("child"),
|
message: createAssistantMessage("child"),
|
||||||
};
|
};
|
||||||
const storage = new InMemorySessionStorage({ entries: [root, child] });
|
const compaction: CompactionEntry = {
|
||||||
expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
type: "compaction",
|
||||||
expect(await storage.getPathToRoot(null)).toEqual([]);
|
id: "compaction",
|
||||||
|
parentId: "child",
|
||||||
|
timestamp: "2026-01-01T00:00:01.000Z",
|
||||||
|
summary: "summary",
|
||||||
|
firstKeptEntryId: "child",
|
||||||
|
tokensBefore: 1234,
|
||||||
|
retainedTail: [createAssistantMessage("child")],
|
||||||
|
};
|
||||||
|
const afterCompaction: MessageEntry = {
|
||||||
|
...root,
|
||||||
|
id: "after-compaction",
|
||||||
|
parentId: "compaction",
|
||||||
|
message: createUserMessage("after"),
|
||||||
|
};
|
||||||
|
const storage = new InMemorySessionStorage({ entries: [root, child, compaction, afterCompaction] });
|
||||||
|
expect((await storage.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||||
|
expect((await storage.getPathToRootOrCompaction("after-compaction")).map((entry) => entry.id)).toEqual([
|
||||||
|
"compaction",
|
||||||
|
"after-compaction",
|
||||||
|
]);
|
||||||
|
expect(await storage.getPathToRootOrCompaction(null)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,7 +348,7 @@ describe("JsonlSessionStorage", () => {
|
|||||||
const reloaded = await JsonlSessionStorage.open(env, filePath);
|
const reloaded = await JsonlSessionStorage.open(env, filePath);
|
||||||
expect(await reloaded.getLeafId()).toBe("root");
|
expect(await reloaded.getLeafId()).toBe("root");
|
||||||
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
|
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
|
||||||
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
expect((await loaded.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds entries by type", async () => {
|
it("finds entries by type", async () => {
|
||||||
@@ -309,6 +402,76 @@ describe("JsonlSessionStorage", () => {
|
|||||||
expect(await loaded.getLabel("entry-1")).toBeUndefined();
|
expect(await loaded.getLabel("entry-1")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes summary-entry usage in session stats", async () => {
|
||||||
|
const dir = createTempDir();
|
||||||
|
const env = new NodeExecutionEnv({ cwd: dir });
|
||||||
|
const filePath = join(dir, "session.jsonl");
|
||||||
|
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||||
|
await storage.appendEntry({
|
||||||
|
type: "message",
|
||||||
|
id: "assistant",
|
||||||
|
parentId: null,
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "reply" }],
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
usage: {
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
cacheRead: 30,
|
||||||
|
cacheWrite: 40,
|
||||||
|
totalTokens: 100,
|
||||||
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await storage.appendEntry({
|
||||||
|
type: "compaction",
|
||||||
|
id: "compaction",
|
||||||
|
parentId: "assistant",
|
||||||
|
timestamp: "2026-01-01T00:00:01.000Z",
|
||||||
|
summary: "summary",
|
||||||
|
firstKeptEntryId: "assistant",
|
||||||
|
tokensBefore: 1234,
|
||||||
|
usage: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 3,
|
||||||
|
cacheWrite: 4,
|
||||||
|
totalTokens: 10,
|
||||||
|
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await storage.appendEntry({
|
||||||
|
type: "branch_summary",
|
||||||
|
id: "branch-summary",
|
||||||
|
parentId: "compaction",
|
||||||
|
timestamp: "2026-01-01T00:00:02.000Z",
|
||||||
|
fromId: "assistant",
|
||||||
|
summary: "branch",
|
||||||
|
usage: {
|
||||||
|
input: 5,
|
||||||
|
output: 6,
|
||||||
|
cacheRead: 7,
|
||||||
|
cacheWrite: 8,
|
||||||
|
totalTokens: 26,
|
||||||
|
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await storage.getSessionStats()).toEqual({
|
||||||
|
messageCount: 1,
|
||||||
|
cachedTokens: 40,
|
||||||
|
uncachedTokens: 68,
|
||||||
|
totalTokens: 136,
|
||||||
|
costTotal: 1.36,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("reads session metadata through the line-reading filesystem operation", async () => {
|
it("reads session metadata through the line-reading filesystem operation", async () => {
|
||||||
const dir = createTempDir();
|
const dir = createTempDir();
|
||||||
const filePath = join(dir, "session.jsonl");
|
const filePath = join(dir, "session.jsonl");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
|
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
|
||||||
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
|
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
|
||||||
|
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -21,6 +22,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
|
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
|
||||||
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
|
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
|
||||||
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
|
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,23 +2,32 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added `contentText` for extracting joined text from message content.
|
- Added Qwen Token Plan and Qwen Token Plan China as built-in providers with regional endpoints, API-key authentication, and generated model catalogs ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)).
|
||||||
- Added a shared `uuidv7` utility for time-ordered identifiers.
|
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
|
||||||
|
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||||
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Changed generated model catalogs to keep TypeScript model shapes separate from ignored JSON model values, reducing generated source churn ([#6765](https://github.com/earendil-works/pi/pull/6765) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||||
- Changed model generation to validate ignored provider data before compilation; `npm run build` refreshes model data as before, while `npm run build:offline` reuses existing data without network access.
|
- Changed model generation to validate ignored provider data before compilation; `npm run build` refreshes model data as before, while `npm run build:offline` reuses existing data without network access.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs, enabling models that reject UUIDv4 IDs.
|
- Fixed stored API-key credentials to apply their provider-scoped `env` values during auth resolution, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Fixed OpenAI-compatible cross-provider replay to preserve unique tool call IDs when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Fixed Kimi K3 to expose its supported low, high, and max thinking levels, and normalized the `k2p7` alias to the canonical `kimi-for-coding` model.
|
||||||
|
- Fixed the OpenCode Go provider to support models routed through the OpenAI Responses API.
|
||||||
|
- Fixed the `pi-ai` executable path to match npm registry metadata, avoiding repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)).
|
||||||
|
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs, enabling models that reject UUIDv4 IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||||
- Fixed GitHub Copilot long-context pricing tiers in generated model metadata ([#6668](https://github.com/earendil-works/pi/issues/6668)).
|
- Fixed GitHub Copilot long-context pricing tiers in generated model metadata ([#6668](https://github.com/earendil-works/pi/issues/6668)).
|
||||||
- Fixed Kimi Coding subscription models to report API-equivalent implied costs when models.dev reports zero pricing.
|
- Fixed Kimi Coding subscription models to report API-equivalent implied costs when models.dev reports zero pricing.
|
||||||
- Fixed OpenAI Responses early stream endings to be classified as retryable provider errors ([#6727](https://github.com/earendil-works/pi/issues/6727)).
|
- Fixed OpenAI Responses early stream endings to be classified as retryable provider errors ([#6727](https://github.com/earendil-works/pi/issues/6727)).
|
||||||
- Fixed GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6838](https://github.com/earendil-works/pi/issues/6838)).
|
- Fixed GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)).
|
||||||
|
|
||||||
## [0.80.10] - 2026-07-16
|
## [0.80.10] - 2026-07-16
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-ai",
|
"name": "@earendil-works/pi-ai",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1761,6 +1761,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
if (isKimiK3) {
|
if (isKimiK3) {
|
||||||
compat.requiresReasoningContentOnAssistantMessages = true;
|
compat.requiresReasoningContentOnAssistantMessages = true;
|
||||||
compat.deferredToolsMode = "kimi";
|
compat.deferredToolsMode = "kimi";
|
||||||
|
compat.thinkingFormat = "openai";
|
||||||
|
compat.supportsReasoningEffort = true;
|
||||||
}
|
}
|
||||||
models.push({
|
models.push({
|
||||||
id: modelId,
|
id: modelId,
|
||||||
|
|||||||
@@ -170,6 +170,51 @@ export const IMAGE_MODELS = {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
} satisfies ImagesModel<"openrouter-images">,
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
|
"krea/krea-2-large": {
|
||||||
|
id: "krea/krea-2-large",
|
||||||
|
name: "Krea: Krea 2 Large",
|
||||||
|
api: "openrouter-images",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
input: ["text", "image"],
|
||||||
|
output: ["image"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
|
"krea/krea-2-medium": {
|
||||||
|
id: "krea/krea-2-medium",
|
||||||
|
name: "Krea: Krea 2 Medium",
|
||||||
|
api: "openrouter-images",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
input: ["text", "image"],
|
||||||
|
output: ["image"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
|
"krea/krea-2-medium-turbo": {
|
||||||
|
id: "krea/krea-2-medium-turbo",
|
||||||
|
name: "Krea: Krea 2 Medium Turbo",
|
||||||
|
api: "openrouter-images",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
input: ["text", "image"],
|
||||||
|
output: ["image"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
"microsoft/mai-image-2.5": {
|
"microsoft/mai-image-2.5": {
|
||||||
id: "microsoft/mai-image-2.5",
|
id: "microsoft/mai-image-2.5",
|
||||||
name: "Microsoft: MAI-Image-2.5",
|
name: "Microsoft: MAI-Image-2.5",
|
||||||
@@ -290,6 +335,21 @@ export const IMAGE_MODELS = {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
} satisfies ImagesModel<"openrouter-images">,
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
|
"openrouter/auto-beta": {
|
||||||
|
id: "openrouter/auto-beta",
|
||||||
|
name: "Auto Router (Beta)",
|
||||||
|
api: "openrouter-images",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
input: ["text", "image"],
|
||||||
|
output: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: -1000000,
|
||||||
|
output: -1000000,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
} satisfies ImagesModel<"openrouter-images">,
|
||||||
"recraft/recraft-v3": {
|
"recraft/recraft-v3": {
|
||||||
id: "recraft/recraft-v3",
|
id: "recraft/recraft-v3",
|
||||||
name: "Recraft: Recraft V3",
|
name: "Recraft: Recraft V3",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Api, Model } from "./types.ts";
|
|||||||
|
|
||||||
export interface ModelsStoreEntry {
|
export interface ModelsStoreEntry {
|
||||||
models: readonly Model<Api>[];
|
models: readonly Model<Api>[];
|
||||||
|
/** Unix timestamp from the remote catalog's Last-Modified header. */
|
||||||
|
lastModified?: number;
|
||||||
/** Unix timestamp of the last completed remote check. */
|
/** Unix timestamp of the last completed remote check. */
|
||||||
checkedAt?: number;
|
checkedAt?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export function getBuiltinProviders(): BuiltinProvider[] {
|
|||||||
return Object.keys(MODELS) as BuiltinProvider[];
|
return Object.keys(MODELS) as BuiltinProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL of a generated provider catalog, used to compare its mtime with remote catalogs during development. */
|
||||||
|
export function getBuiltinModelDataUrl(provider: BuiltinProvider): URL {
|
||||||
|
return new URL(`./data/${provider}.json`, import.meta.url);
|
||||||
|
}
|
||||||
|
|
||||||
export function getBuiltinModels<TProvider extends BuiltinProvider>(
|
export function getBuiltinModels<TProvider extends BuiltinProvider>(
|
||||||
provider: TProvider,
|
provider: TProvider,
|
||||||
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
|
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
|
||||||
|
|||||||
@@ -2,14 +2,41 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Local llama.cpp model management** — Connect to a llama.cpp router, search and download Hugging Face models, and explicitly load or unload models with live progress. See [llama.cpp](docs/llama-cpp.md).
|
||||||
|
- **Full provider extensions** — Extensions can register complete pi-ai providers with authentication, model refresh, filtering, and custom streaming. See [Register New Provider](docs/custom-provider.md#register-new-provider).
|
||||||
|
- **Qwen Token Plan providers** — Use the built-in international and China subscription providers with regional endpoints and API-key authentication. See [API Keys](docs/providers.md#api-keys).
|
||||||
|
- **Expanded usage accounting** — Tool, compaction, and branch-summary usage is persisted and included in session totals. See [Compaction & Branch Summarization](docs/compaction.md).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added Qwen Token Plan and Qwen Token Plan China to built-in provider setup, default model resolution, and provider documentation ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)).
|
||||||
|
- Added the `get_available_thinking_levels` RPC command and `RpcClient.getAvailableThinkingLevels()` method ([#6865](https://github.com/earendil-works/pi/pull/6865) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Exported message and tool execution lifecycle event types from the package root ([#6772](https://github.com/earendil-works/pi/pull/6772) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
|
- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md).
|
||||||
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
|
- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior.
|
||||||
- Added usage accounting for tools, compaction, and branch summaries in persisted sessions, footer totals, and session statistics ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
- Added usage accounting for tools, compaction, and branch summaries in persisted sessions, footer totals, and session statistics ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Updated the packaged `brace-expansion` dependency to 5.0.7 ([#6896](https://github.com/earendil-works/pi/pull/6896) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
- Fixed persisted remote model catalogs from overriding newer bundled catalogs after an upgrade.
|
||||||
|
- Fixed inherited stored API-key credentials to apply their provider-scoped `env` values, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Fixed inherited OpenAI-compatible cross-provider replay to keep tool call IDs unique when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
- Fixed inherited Kimi K3 thinking levels to expose low, high, and max, and normalized the `k2p7` alias to `kimi-for-coding`.
|
||||||
|
- Fixed inherited OpenCode Go models routed through the OpenAI Responses API.
|
||||||
|
- Fixed inherited `pi-ai` package metadata to avoid repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)).
|
||||||
|
- Fixed inherited terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)).
|
||||||
|
- Fixed inherited ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)).
|
||||||
|
- Fixed inherited editor paste registry corruption after deleting and undoing paste markers, preventing literal or mismatched paste markers in submitted prompts ([#6844](https://github.com/earendil-works/pi/issues/6844)).
|
||||||
|
- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||||
|
- Fixed inherited GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)).
|
||||||
|
- Fixed messages queued during compaction to preserve steering and follow-up delivery behavior ([#6730](https://github.com/earendil-works/pi/pull/6730) by [@dannote](https://github.com/dannote)).
|
||||||
|
- Fixed read tool errors being syntax-highlighted as if they were file contents ([#6731](https://github.com/earendil-works/pi/pull/6731) by [@dannote](https://github.com/dannote)).
|
||||||
|
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
|
||||||
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
|
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
|
||||||
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
|
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
|
||||||
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
|
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
|
||||||
|
|||||||
@@ -232,10 +232,18 @@ Created when context is compacted. Stores a summary of earlier messages.
|
|||||||
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
|
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Newer harness-generated compactions embed the retained post-compaction context directly on the entry, instead of `firstKeptEntryId`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","tokensBefore":50000,"retainedTail":[{"role":"user","content":"latest request"},{"role":"assistant","content":[{"type":"text","text":"latest reply"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}]}
|
||||||
|
```
|
||||||
|
|
||||||
Optional fields:
|
Optional fields:
|
||||||
- `usage`: LLM usage from generating the summary; included in session token and cost totals
|
- `usage`: LLM usage from generating the summary; included in session token and cost totals
|
||||||
|
- `retainedTail`: Materialized `AgentMessage[]` kept after compaction. This is optional only for backward compatibility with older sessions. Newer harness-generated compactions include it so we can rebuild context from this checkpoint without walking older entries before the compaction entry.
|
||||||
- `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions)
|
- `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions)
|
||||||
- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)
|
- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)
|
||||||
|
- `firstKeptEntryId`: for compatibility with old entry format.
|
||||||
|
|
||||||
### BranchSummaryEntry
|
### BranchSummaryEntry
|
||||||
|
|
||||||
@@ -314,8 +322,9 @@ Entries form a tree:
|
|||||||
1. Collects all entries on the path
|
1. Collects all entries on the path
|
||||||
2. If a `CompactionEntry` is on the path:
|
2. If a `CompactionEntry` is on the path:
|
||||||
- Includes the compaction entry first
|
- Includes the compaction entry first
|
||||||
- Then entries from `firstKeptEntryId` to compaction
|
- If `retainedTail` is present, it acts as a self-contained checkpoint and entries after the compaction are included
|
||||||
- Then entries after compaction
|
- Otherwise entries from `firstKeptEntryId` to the compaction are included
|
||||||
|
- Then entries after compaction are included
|
||||||
3. Preserves non-message entries in the selected range so interactive mode can render them
|
3. Preserves non-message entries in the selected range so interactive mode can render them
|
||||||
|
|
||||||
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
|
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
|
||||||
@@ -323,11 +332,13 @@ Entries form a tree:
|
|||||||
1. Extracts current model and thinking level settings from the full path
|
1. Extracts current model and thinking level settings from the full path
|
||||||
2. Converts selected entries to messages:
|
2. Converts selected entries to messages:
|
||||||
- `message` -> stored `AgentMessage`
|
- `message` -> stored `AgentMessage`
|
||||||
- `compaction` -> `compactionSummary`
|
- `compaction` -> `compactionSummary` plus `retainedTail` when present
|
||||||
- `branch_summary` -> `branchSummary`
|
- `branch_summary` -> `branchSummary`
|
||||||
- `custom_message` -> `CustomMessage`
|
- `custom_message` -> `CustomMessage`
|
||||||
- `custom` -> no context message
|
- `custom` -> no context message
|
||||||
|
|
||||||
|
This makes newer compactions act like self-contained checkpoints. `retainedTail` is optional only so older sessions that only store `firstKeptEntryId` continue to load correctly.
|
||||||
|
|
||||||
## Parsing Example
|
## Parsing Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider",
|
"name": "pi-extension-custom-provider",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-custom-provider",
|
"name": "pi-extension-custom-provider",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.52.0"
|
"@anthropic-ai/sdk": "^0.52.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/gondolin": "0.12.0"
|
"@earendil-works/gondolin": "0.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.10.10",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.10.10",
|
"version": "1.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.10",
|
"version": "1.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
+15
-30
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.80.10"
|
"@earendil-works/pi-coding-agent": "0.81.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
@@ -450,11 +450,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
@@ -464,8 +464,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -488,13 +488,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent": {
|
"node_modules/@earendil-works/pi-coding-agent": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.80.10",
|
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"@earendil-works/pi-tui": "^0.80.10",
|
"@earendil-works/pi-tui": "^0.81.0",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -522,8 +522,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-tui": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
@@ -638,9 +638,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
||||||
@@ -657,9 +654,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
||||||
@@ -676,9 +670,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
||||||
@@ -695,9 +686,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
||||||
@@ -714,9 +702,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lockfile root used by the Pi installer and updater.",
|
"description": "Lockfile root used by the Pi installer and updater.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.80.10"
|
"@earendil-works/pi-coding-agent": "0.81.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rimraf": "6.1.2",
|
"rimraf": "6.1.2",
|
||||||
|
|||||||
+12
-27
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.80.10",
|
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"@earendil-works/pi-tui": "^0.80.10",
|
"@earendil-works/pi-tui": "^0.81.0",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -474,11 +474,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.0"
|
||||||
@@ -488,8 +488,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -512,8 +512,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-tui": {
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
@@ -628,9 +628,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
||||||
@@ -647,9 +644,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
||||||
@@ -666,9 +660,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
||||||
@@ -685,9 +676,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
||||||
@@ -704,9 +692,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"piConfig": {
|
"piConfig": {
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.80.10",
|
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||||
"@earendil-works/pi-ai": "^0.80.10",
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
"@earendil-works/pi-tui": "^0.80.10",
|
"@earendil-works/pi-tui": "^0.81.0",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
|
|||||||
@@ -143,7 +143,15 @@ export class ModelRuntime implements Models {
|
|||||||
const providers = builtinProviderCatalog
|
const providers = builtinProviderCatalog
|
||||||
.builtinProviders()
|
.builtinProviders()
|
||||||
.map((provider) =>
|
.map((provider) =>
|
||||||
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
|
provider.id === "radius"
|
||||||
|
? provider
|
||||||
|
: withRemoteCatalog(
|
||||||
|
provider,
|
||||||
|
options.catalogBaseUrl,
|
||||||
|
builtinProviderCatalog.getBuiltinModelDataUrl(
|
||||||
|
provider.id as builtinProviderCatalog.BuiltinProvider,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const runtime = new ModelRuntime(
|
const runtime = new ModelRuntime(
|
||||||
credentials,
|
credentials,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
|
import { stat } from "node:fs/promises";
|
||||||
|
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
|
||||||
import { VERSION } from "../config.ts";
|
import { VERSION } from "../config.ts";
|
||||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||||
|
|
||||||
@@ -29,8 +30,26 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
|
|||||||
.map((model) => ({ ...model, provider: providerId }));
|
.map((model) => ({ ...model, provider: providerId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remoteModels(
|
||||||
|
entry: ModelsStoreEntry | undefined,
|
||||||
|
localLastModified: number | undefined,
|
||||||
|
): readonly Model<Api>[] {
|
||||||
|
if (!entry) return [];
|
||||||
|
if (
|
||||||
|
localLastModified !== undefined &&
|
||||||
|
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entry.models;
|
||||||
|
}
|
||||||
|
|
||||||
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
|
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
|
||||||
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
|
export function withRemoteCatalog(
|
||||||
|
provider: Provider,
|
||||||
|
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
|
||||||
|
localCatalogUrl?: URL,
|
||||||
|
): Provider {
|
||||||
let dynamicModels: readonly Model<Api>[] = [];
|
let dynamicModels: readonly Model<Api>[] = [];
|
||||||
let inflightRefresh: Promise<void> | undefined;
|
let inflightRefresh: Promise<void> | undefined;
|
||||||
|
|
||||||
@@ -40,12 +59,21 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
|
|||||||
refreshModels: (context) => {
|
refreshModels: (context) => {
|
||||||
inflightRefresh ??= (async () => {
|
inflightRefresh ??= (async () => {
|
||||||
try {
|
try {
|
||||||
|
const localLastModified = localCatalogUrl
|
||||||
|
? await stat(localCatalogUrl).then(
|
||||||
|
(value) => value.mtimeMs,
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const stored = await context.store.read();
|
const stored = await context.store.read();
|
||||||
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
|
dynamicModels = remoteModels(stored, localLastModified).filter(
|
||||||
|
(model) => model.provider === provider.id,
|
||||||
|
);
|
||||||
if (!context.allowNetwork || context.signal?.aborted) return;
|
if (!context.allowNetwork || context.signal?.aborted) return;
|
||||||
if (
|
if (
|
||||||
!context.force &&
|
!context.force &&
|
||||||
stored?.checkedAt !== undefined &&
|
stored?.checkedAt !== undefined &&
|
||||||
|
stored.lastModified !== undefined &&
|
||||||
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
|
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -62,17 +90,23 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
|
|||||||
if (context.signal?.aborted) return;
|
if (context.signal?.aborted) return;
|
||||||
const checkedAt = Date.now();
|
const checkedAt = Date.now();
|
||||||
if (response.status === 404 || response.status === 501) {
|
if (response.status === 404 || response.status === 501) {
|
||||||
await context.store.write({ models: dynamicModels, checkedAt });
|
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await context.store.write({ models: dynamicModels, checkedAt });
|
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
|
||||||
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
|
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
|
||||||
}
|
}
|
||||||
const refreshed = parseCatalog(provider.id, await response.json());
|
const refreshed = parseCatalog(provider.id, await response.json());
|
||||||
|
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
|
||||||
if (context.signal?.aborted) return;
|
if (context.signal?.aborted) return;
|
||||||
dynamicModels = refreshed;
|
const entry = {
|
||||||
await context.store.write({ models: refreshed, checkedAt });
|
models: refreshed,
|
||||||
|
checkedAt,
|
||||||
|
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
|
||||||
|
};
|
||||||
|
dynamicModels = remoteModels(entry, localLastModified);
|
||||||
|
await context.store.write(entry);
|
||||||
} finally {
|
} finally {
|
||||||
inflightRefresh = undefined;
|
inflightRefresh = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,11 @@ function parseLoadProgress(data: unknown): LlamaProgress | undefined {
|
|||||||
|
|
||||||
function parseDownloadProgress(data: unknown): LlamaProgress | undefined {
|
function parseDownloadProgress(data: unknown): LlamaProgress | undefined {
|
||||||
if (typeof data !== "object" || data === null) return undefined;
|
if (typeof data !== "object" || data === null) return undefined;
|
||||||
|
const nested = (data as { progress?: unknown }).progress;
|
||||||
|
const files = typeof nested === "object" && nested !== null ? nested : data;
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const value of Object.values(data as Record<string, unknown>)) {
|
for (const value of Object.values(files as Record<string, unknown>)) {
|
||||||
if (typeof value !== "object" || value === null) continue;
|
if (typeof value !== "object" || value === null) continue;
|
||||||
const entry = value as { done?: unknown; total?: unknown };
|
const entry = value as { done?: unknown; total?: unknown };
|
||||||
if (typeof entry.done !== "number" || typeof entry.total !== "number") continue;
|
if (typeof entry.done !== "number" || typeof entry.total !== "number") continue;
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
|
|||||||
model: target.id,
|
model: target.id,
|
||||||
initialMessage: "Starting…",
|
initialMessage: "Starting…",
|
||||||
cancelTitle: "Stop loading?",
|
cancelTitle: "Stop loading?",
|
||||||
cancelMessage: `Stop loading ${target.id}?`,
|
cancelMessage: target.id,
|
||||||
run: (signal, update) => client.loadAndWait(target.id, update, signal),
|
run: (signal, update) => client.loadAndWait(target.id, update, signal),
|
||||||
cancel: () => client.unload(target.id),
|
cancel: () => client.unload(target.id),
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
|
|||||||
client: LlamaClient,
|
client: LlamaClient,
|
||||||
model: LlamaModelInfo,
|
model: LlamaModelInfo,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`))) return;
|
if (!(await ui.confirm("Unload model?", model.id))) return;
|
||||||
await client.unloadAndWait(model.id);
|
await client.unloadAndWait(model.id);
|
||||||
await syncCatalog(ctx, client);
|
await syncCatalog(ctx, client);
|
||||||
ctx.ui.notify(`Unloaded ${model.id}`);
|
ctx.ui.notify(`Unloaded ${model.id}`);
|
||||||
@@ -162,7 +162,7 @@ export default function llamaExtension(pi: ExtensionAPI): void {
|
|||||||
model,
|
model,
|
||||||
initialMessage: "Starting…",
|
initialMessage: "Starting…",
|
||||||
cancelTitle: "Stop download?",
|
cancelTitle: "Stop download?",
|
||||||
cancelMessage: `Stop downloading ${model}?`,
|
cancelMessage: model,
|
||||||
run: (signal, update) => client.downloadAndWait(model, update, signal),
|
run: (signal, update) => client.downloadAndWait(model, update, signal),
|
||||||
cancel: () => client.unload(model),
|
cancel: () => client.unload(model),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ describe("llama.cpp extension", () => {
|
|||||||
send({
|
send({
|
||||||
model: "owner/repo:Q4_K_M",
|
model: "owner/repo:Q4_K_M",
|
||||||
event: "download_progress",
|
event: "download_progress",
|
||||||
data: { "https://example/model.gguf": { done: 512, total: 1024 } },
|
data: { progress: { "https://example/model.gguf": { done: 512, total: 1024 } } },
|
||||||
});
|
});
|
||||||
status = "unloaded";
|
status = "unloaded";
|
||||||
send({ model: "owner/repo:Q4_K_M", event: "download_finished", data: {} });
|
send({ model: "owner/repo:Q4_K_M", event: "download_finished", data: {} });
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ describe("ModelRegistry", () => {
|
|||||||
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
||||||
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
|
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
|
||||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||||
});
|
}, 60_000);
|
||||||
|
|
||||||
test("removing custom models from models.json keeps built-in provider models", async () => {
|
test("removing custom models from models.json keeps built-in provider models", async () => {
|
||||||
writeModelsJson({
|
writeModelsJson({
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { createProvider, InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
|
import { statSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
createProvider,
|
||||||
|
InMemoryModelsStore,
|
||||||
|
type Model,
|
||||||
|
type ModelsStoreEntry,
|
||||||
|
type ProviderModelsStore,
|
||||||
|
} from "@earendil-works/pi-ai";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { VERSION } from "../src/config.ts";
|
import { VERSION } from "../src/config.ts";
|
||||||
import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts";
|
import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts";
|
||||||
@@ -18,6 +25,34 @@ function model(id: string): Model<"openai-completions"> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testProvider(localCatalogUrl?: URL) {
|
||||||
|
return withRemoteCatalog(
|
||||||
|
createProvider({
|
||||||
|
id: "test-provider",
|
||||||
|
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
|
||||||
|
models: [model("static")],
|
||||||
|
api: {
|
||||||
|
stream: () => {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
streamSimple: () => {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"https://pi.dev",
|
||||||
|
localCatalogUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedStore(store: InMemoryModelsStore): ProviderModelsStore {
|
||||||
|
return {
|
||||||
|
read: () => store.read("test-provider"),
|
||||||
|
write: (entry: ModelsStoreEntry) => store.write("test-provider", entry),
|
||||||
|
delete: () => store.delete("test-provider"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
describe("remote catalog provider", () => {
|
describe("remote catalog provider", () => {
|
||||||
@@ -29,50 +64,12 @@ describe("remote catalog provider", () => {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const provider = withRemoteCatalog(
|
const provider = testProvider();
|
||||||
createProvider({
|
|
||||||
id: "test-provider",
|
|
||||||
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
|
|
||||||
models: [model("static")],
|
|
||||||
api: {
|
|
||||||
stream: () => {
|
|
||||||
throw new Error("not used");
|
|
||||||
},
|
|
||||||
streamSimple: () => {
|
|
||||||
throw new Error("not used");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const store = new InMemoryModelsStore();
|
const store = new InMemoryModelsStore();
|
||||||
await provider.refreshModels?.({
|
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||||
credential: { type: "api_key" },
|
await provider.refreshModels?.(refresh);
|
||||||
store: {
|
await provider.refreshModels?.(refresh);
|
||||||
read: () => store.read(provider.id),
|
await provider.refreshModels?.({ ...refresh, force: true });
|
||||||
write: (entry) => store.write(provider.id, entry),
|
|
||||||
delete: () => store.delete(provider.id),
|
|
||||||
},
|
|
||||||
allowNetwork: true,
|
|
||||||
});
|
|
||||||
await provider.refreshModels?.({
|
|
||||||
credential: { type: "api_key" },
|
|
||||||
store: {
|
|
||||||
read: () => store.read(provider.id),
|
|
||||||
write: (entry) => store.write(provider.id, entry),
|
|
||||||
delete: () => store.delete(provider.id),
|
|
||||||
},
|
|
||||||
allowNetwork: true,
|
|
||||||
});
|
|
||||||
await provider.refreshModels?.({
|
|
||||||
credential: { type: "api_key" },
|
|
||||||
store: {
|
|
||||||
read: () => store.read(provider.id),
|
|
||||||
write: (entry) => store.write(provider.id, entry),
|
|
||||||
delete: () => store.delete(provider.id),
|
|
||||||
},
|
|
||||||
allowNetwork: true,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
|
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
|
||||||
expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
|
expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
|
||||||
@@ -82,33 +79,40 @@ describe("remote catalog provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the newer of the generated and remote catalogs", async () => {
|
||||||
|
const localCatalogUrl = new URL(import.meta.url);
|
||||||
|
const localMtime = statSync(localCatalogUrl).mtimeMs;
|
||||||
|
const newerHeader = new Date(localMtime + 60_000).toUTCString();
|
||||||
|
const responses = [
|
||||||
|
new Response(JSON.stringify({ old: model("old") }), {
|
||||||
|
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
|
||||||
|
}),
|
||||||
|
new Response(JSON.stringify({ newer: model("newer") }), {
|
||||||
|
headers: { "last-modified": newerHeader },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
|
||||||
|
const provider = testProvider(localCatalogUrl);
|
||||||
|
const store = new InMemoryModelsStore();
|
||||||
|
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||||
|
|
||||||
|
await provider.refreshModels?.(refresh);
|
||||||
|
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]);
|
||||||
|
|
||||||
|
await provider.refreshModels?.({ ...refresh, force: true });
|
||||||
|
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "newer"]);
|
||||||
|
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
|
||||||
|
});
|
||||||
|
|
||||||
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
|
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
|
||||||
const provider = withRemoteCatalog(
|
const provider = testProvider();
|
||||||
createProvider({
|
|
||||||
id: "test-provider",
|
|
||||||
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
|
|
||||||
models: [model("static")],
|
|
||||||
api: {
|
|
||||||
stream: () => {
|
|
||||||
throw new Error("not used");
|
|
||||||
},
|
|
||||||
streamSimple: () => {
|
|
||||||
throw new Error("not used");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const store = new InMemoryModelsStore();
|
const store = new InMemoryModelsStore();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
provider.refreshModels?.({
|
provider.refreshModels?.({
|
||||||
credential: { type: "api_key" },
|
credential: { type: "api_key" },
|
||||||
store: {
|
store: scopedStore(store),
|
||||||
read: () => store.read(provider.id),
|
|
||||||
write: (entry) => store.write(provider.id, entry),
|
|
||||||
delete: () => store.delete(provider.id),
|
|
||||||
},
|
|
||||||
allowNetwork: true,
|
allowNetwork: true,
|
||||||
}),
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [0.80.10] - 2026-07-16
|
|
||||||
|
|
||||||
## [0.80.9] - 2026-07-16
|
|
||||||
|
|
||||||
## [0.80.8] - 2026-07-16
|
|
||||||
|
|
||||||
## [0.80.7] - 2026-07-14
|
|
||||||
|
|
||||||
## [0.80.6] - 2026-07-09
|
|
||||||
|
|
||||||
## [0.80.5] - 2026-07-09
|
|
||||||
|
|
||||||
## [0.80.4] - 2026-07-09
|
|
||||||
|
|
||||||
## [0.80.3] - 2026-06-30
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
|
|
||||||
|
## [0.80.10] - 2026-07-16
|
||||||
|
|
||||||
|
## [0.80.9] - 2026-07-16
|
||||||
|
|
||||||
|
## [0.80.8] - 2026-07-16
|
||||||
|
|
||||||
|
## [0.80.7] - 2026-07-14
|
||||||
|
|
||||||
|
## [0.80.6] - 2026-07-09
|
||||||
|
|
||||||
|
## [0.80.5] - 2026-07-09
|
||||||
|
|
||||||
|
## [0.80.4] - 2026-07-09
|
||||||
|
|
||||||
|
## [0.80.3] - 2026-06-30
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# @earendil-works/pi-orchestrator
|
# @earendil-works/pi-server
|
||||||
|
|
||||||
Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable.
|
Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable.
|
||||||
|
|
||||||
Orchestrator package for pi.
|
Server package for pi.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
orchestrator --help
|
server --help
|
||||||
```
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-orchestrator",
|
"name": "@earendil-works/pi-server",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"description": "experimental orchestrator package for pi",
|
"description": "experimental server package for pi",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"server": "./dist/cli.js"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md",
|
"README.md",
|
||||||
@@ -24,20 +27,20 @@
|
|||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi",
|
"pi",
|
||||||
"orchestrator"
|
"server"
|
||||||
],
|
],
|
||||||
"author": "Earendil Works",
|
"author": "Earendil Works",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/earendil-works/pi.git",
|
"url": "git+https://github.com/earendil-works/pi.git",
|
||||||
"directory": "packages/orchestrator"
|
"directory": "packages/server"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "^0.80.10"
|
"@earendil-works/pi-coding-agent": "^0.81.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"shx": "0.4.0"
|
"shx": "0.4.0"
|
||||||
@@ -18,7 +18,7 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"),
|
|||||||
|
|
||||||
function printHelp(): void {
|
function printHelp(): void {
|
||||||
console.log(
|
console.log(
|
||||||
`orchestrator v${packageJson.version}\n\nUsage:\n orchestrator serve\n orchestrator list\n orchestrator spawn [--cwd <path>] [--label <label>]\n orchestrator status <instance-id>\n orchestrator stop <instance-id>\n orchestrator rpc <instance-id> <json-command>\n orchestrator rpc-stream <instance-id>\n orchestrator --help\n orchestrator --version\n\nRPC stream stdin expects JSONL RpcCommand or extension_ui_response messages.`,
|
`server v${packageJson.version}\n\nUsage:\n server serve\n server list\n server spawn [--cwd <path>] [--label <label>]\n server status <instance-id>\n server stop <instance-id>\n server rpc <instance-id> <json-command>\n server rpc-stream <instance-id>\n server --help\n server --version\n\nRPC stream stdin expects JSONL RpcCommand or extension_ui_response messages.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ async function main(): Promise<void> {
|
|||||||
if (args[0] === "status") {
|
if (args[0] === "status") {
|
||||||
const instanceId = args[1];
|
const instanceId = args[1];
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
console.error("Usage: orchestrator status <instance-id>");
|
console.error("Usage: server status <instance-id>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
printResponse(await sendIpcRequest({ type: "status", instanceId }));
|
printResponse(await sendIpcRequest({ type: "status", instanceId }));
|
||||||
@@ -119,7 +119,7 @@ async function main(): Promise<void> {
|
|||||||
if (args[0] === "stop") {
|
if (args[0] === "stop") {
|
||||||
const instanceId = args[1];
|
const instanceId = args[1];
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
console.error("Usage: orchestrator stop <instance-id>");
|
console.error("Usage: server stop <instance-id>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
printResponse(await sendIpcRequest({ type: "stop", instanceId }));
|
printResponse(await sendIpcRequest({ type: "stop", instanceId }));
|
||||||
@@ -130,7 +130,7 @@ async function main(): Promise<void> {
|
|||||||
const instanceId = args[1];
|
const instanceId = args[1];
|
||||||
const commandJson = args[2];
|
const commandJson = args[2];
|
||||||
if (!instanceId || !commandJson) {
|
if (!instanceId || !commandJson) {
|
||||||
console.error("Usage: orchestrator rpc <instance-id> <json-command>");
|
console.error("Usage: server rpc <instance-id> <json-command>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
printResponse(
|
printResponse(
|
||||||
@@ -146,7 +146,7 @@ async function main(): Promise<void> {
|
|||||||
if (args[0] === "rpc-stream") {
|
if (args[0] === "rpc-stream") {
|
||||||
const instanceId = args[1];
|
const instanceId = args[1];
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
console.error("Usage: orchestrator rpc-stream <instance-id>");
|
console.error("Usage: server rpc-stream <instance-id>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
await rpcStream(instanceId);
|
await rpcStream(instanceId);
|
||||||
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const CONFIG_DIR_NAME = ".pi";
|
const CONFIG_DIR_NAME = ".pi";
|
||||||
const ENV_ORCHESTRATOR_DIR = "PI_ORCHESTRATOR_DIR";
|
const ENV_SERVER_DIR = "PI_SERVER_DIR";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -42,28 +42,28 @@ try {
|
|||||||
|
|
||||||
export const VERSION: string = pkg.version || "0.0.0";
|
export const VERSION: string = pkg.version || "0.0.0";
|
||||||
|
|
||||||
export function getOrchestratorDir(): string {
|
export function getServerDir(): string {
|
||||||
const envDir = process.env[ENV_ORCHESTRATOR_DIR];
|
const envDir = process.env[ENV_SERVER_DIR];
|
||||||
if (envDir) {
|
if (envDir) {
|
||||||
return envDir;
|
return envDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
const piDir = process.env.PI_CONFIG_DIR || join(homedir(), CONFIG_DIR_NAME);
|
const piDir = process.env.PI_CONFIG_DIR || join(homedir(), CONFIG_DIR_NAME);
|
||||||
return join(piDir, "orchestrator");
|
return join(piDir, "server");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthPath(): string {
|
export function getAuthPath(): string {
|
||||||
return join(getOrchestratorDir(), "auth.json");
|
return join(getServerDir(), "auth.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMachinePath(): string {
|
export function getMachinePath(): string {
|
||||||
return join(getOrchestratorDir(), "machine.json");
|
return join(getServerDir(), "machine.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInstancesPath(): string {
|
export function getInstancesPath(): string {
|
||||||
return join(getOrchestratorDir(), "instances.json");
|
return join(getServerDir(), "instances.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocketPath(): string {
|
export function getSocketPath(): string {
|
||||||
return join(getOrchestratorDir(), "orchestrator.sock");
|
return join(getServerDir(), "server.sock");
|
||||||
}
|
}
|
||||||
@@ -10,12 +10,12 @@ import type {
|
|||||||
InstanceSummary,
|
InstanceSummary,
|
||||||
ListRequest,
|
ListRequest,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
OrchestratorRequest,
|
|
||||||
OrchestratorResponse,
|
|
||||||
RpcBridgeResponse,
|
RpcBridgeResponse,
|
||||||
RpcReadyResponse,
|
RpcReadyResponse,
|
||||||
RpcRequest,
|
RpcRequest,
|
||||||
RpcStreamRequest,
|
RpcStreamRequest,
|
||||||
|
ServerRequest,
|
||||||
|
ServerResponse,
|
||||||
SpawnRequest,
|
SpawnRequest,
|
||||||
SpawnResponse,
|
SpawnResponse,
|
||||||
StatusRequest,
|
StatusRequest,
|
||||||
@@ -53,8 +53,8 @@ export async function handleIpcRequest(request: StopRequest): Promise<StopRespon
|
|||||||
export async function handleIpcRequest(request: StatusRequest): Promise<StatusResponse | ErrorResponse>;
|
export async function handleIpcRequest(request: StatusRequest): Promise<StatusResponse | ErrorResponse>;
|
||||||
export async function handleIpcRequest(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse>;
|
export async function handleIpcRequest(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse>;
|
||||||
export async function handleIpcRequest(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse>;
|
export async function handleIpcRequest(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse>;
|
||||||
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse>;
|
export async function handleIpcRequest(request: ServerRequest): Promise<ServerResponse>;
|
||||||
export async function handleIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse> {
|
export async function handleIpcRequest(request: ServerRequest): Promise<ServerResponse> {
|
||||||
switch (request.type) {
|
switch (request.type) {
|
||||||
case "spawn": {
|
case "spawn": {
|
||||||
const instance = await supervisor.spawnInstance({
|
const instance = await supervisor.spawnInstance({
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
import { getSocketPath } from "../config.ts";
|
import { getSocketPath } from "../config.ts";
|
||||||
import { encodeMessage, type OrchestratorRequest, type OrchestratorResponse, parseResponseLine } from "./protocol.ts";
|
import { encodeMessage, parseResponseLine, type ServerRequest, type ServerResponse } from "./protocol.ts";
|
||||||
|
|
||||||
export async function sendIpcRequest(request: OrchestratorRequest): Promise<OrchestratorResponse> {
|
export async function sendIpcRequest(request: ServerRequest): Promise<ServerResponse> {
|
||||||
const socketPath = getSocketPath();
|
const socketPath = getSocketPath();
|
||||||
|
|
||||||
return new Promise<OrchestratorResponse>((resolve, reject) => {
|
return new Promise<ServerResponse>((resolve, reject) => {
|
||||||
const socket = createConnection(socketPath);
|
const socket = createConnection(socketPath);
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -56,7 +56,7 @@ export async function sendIpcRequest(request: OrchestratorRequest): Promise<Orch
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
reject(new Error(`Orchestrator socket closed before a response was received: ${socketPath}`));
|
reject(new Error(`Server socket closed before a response was received: ${socketPath}`));
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +49,7 @@ export interface RequestMap {
|
|||||||
rpc_stream: RpcStreamRequest;
|
rpc_stream: RpcStreamRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrchestratorRequest = RequestMap[keyof RequestMap];
|
export type ServerRequest = RequestMap[keyof RequestMap];
|
||||||
|
|
||||||
export interface InstanceSummary {
|
export interface InstanceSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -111,7 +111,7 @@ export interface ResponseMap {
|
|||||||
rpc_stream: RpcReadyResponse;
|
rpc_stream: RpcReadyResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrchestratorResponse = ResponseMap[keyof ResponseMap] | ErrorResponse;
|
export type ServerResponse = ResponseMap[keyof ResponseMap] | ErrorResponse;
|
||||||
export type RpcClientMessage = RpcCommand | RpcExtensionUIResponse;
|
export type RpcClientMessage = RpcCommand | RpcExtensionUIResponse;
|
||||||
export type RpcServerMessage =
|
export type RpcServerMessage =
|
||||||
| RpcReadyResponse
|
| RpcReadyResponse
|
||||||
@@ -119,9 +119,9 @@ export type RpcServerMessage =
|
|||||||
| AgentSessionEvent
|
| AgentSessionEvent
|
||||||
| RpcExtensionUIRequest
|
| RpcExtensionUIRequest
|
||||||
| ErrorResponse;
|
| ErrorResponse;
|
||||||
export type ProtocolMessage = OrchestratorRequest | OrchestratorResponse | RpcClientMessage | RpcServerMessage;
|
export type ProtocolMessage = ServerRequest | ServerResponse | RpcClientMessage | RpcServerMessage;
|
||||||
|
|
||||||
export type ResponseFor<T extends OrchestratorRequest> = T extends { type: infer K }
|
export type ResponseFor<T extends ServerRequest> = T extends { type: infer K }
|
||||||
? K extends keyof ResponseMap
|
? K extends keyof ResponseMap
|
||||||
? ResponseMap[K] | ErrorResponse
|
? ResponseMap[K] | ErrorResponse
|
||||||
: ErrorResponse
|
: ErrorResponse
|
||||||
@@ -131,12 +131,12 @@ export function encodeMessage(message: ProtocolMessage): string {
|
|||||||
return `${JSON.stringify(message)}\n`;
|
return `${JSON.stringify(message)}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRequestLine(line: string): OrchestratorRequest {
|
export function parseRequestLine(line: string): ServerRequest {
|
||||||
const value = JSON.parse(line) as OrchestratorRequest;
|
const value = JSON.parse(line) as ServerRequest;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseResponseLine(line: string): OrchestratorResponse {
|
export function parseResponseLine(line: string): ServerResponse {
|
||||||
const value = JSON.parse(line) as OrchestratorResponse;
|
const value = JSON.parse(line) as ServerResponse;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
encodeMessage,
|
encodeMessage,
|
||||||
type ListRequest,
|
type ListRequest,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
type OrchestratorRequest,
|
|
||||||
type OrchestratorResponse,
|
|
||||||
parseRequestLine,
|
parseRequestLine,
|
||||||
type RpcBridgeResponse,
|
type RpcBridgeResponse,
|
||||||
type RpcReadyResponse,
|
type RpcReadyResponse,
|
||||||
type RpcRequest,
|
type RpcRequest,
|
||||||
type RpcStreamRequest,
|
type RpcStreamRequest,
|
||||||
|
type ServerRequest,
|
||||||
|
type ServerResponse,
|
||||||
type SpawnRequest,
|
type SpawnRequest,
|
||||||
type SpawnResponse,
|
type SpawnResponse,
|
||||||
type StatusRequest,
|
type StatusRequest,
|
||||||
@@ -29,7 +29,7 @@ export interface IpcRequestHandler {
|
|||||||
(request: StatusRequest): Promise<StatusResponse | ErrorResponse> | StatusResponse | ErrorResponse;
|
(request: StatusRequest): Promise<StatusResponse | ErrorResponse> | StatusResponse | ErrorResponse;
|
||||||
(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse> | RpcBridgeResponse | ErrorResponse;
|
(request: RpcRequest): Promise<RpcBridgeResponse | ErrorResponse> | RpcBridgeResponse | ErrorResponse;
|
||||||
(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse> | RpcReadyResponse | ErrorResponse;
|
(request: RpcStreamRequest): Promise<RpcReadyResponse | ErrorResponse> | RpcReadyResponse | ErrorResponse;
|
||||||
(request: OrchestratorRequest): Promise<OrchestratorResponse> | OrchestratorResponse;
|
(request: ServerRequest): Promise<ServerResponse> | ServerResponse;
|
||||||
openRpcStream(
|
openRpcStream(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
onResponse: (response: RpcResponse) => void,
|
onResponse: (response: RpcResponse) => void,
|
||||||
@@ -166,7 +166,7 @@ async function removeStaleSocketIfNeeded(socketPath: string): Promise<void> {
|
|||||||
|
|
||||||
const isLive = await isSocketLive(socketPath);
|
const isLive = await isSocketLive(socketPath);
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
throw new Error(`orchestrator is already running: ${socketPath}`);
|
throw new Error(`server is already running: ${socketPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
unlinkSync(socketPath);
|
unlinkSync(socketPath);
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { hostname, platform } from "node:os";
|
import { hostname, platform } from "node:os";
|
||||||
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
||||||
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
||||||
import { getOrchestratorDir, getSocketPath, VERSION } from "./config.ts";
|
import { getServerDir, getSocketPath, VERSION } from "./config.ts";
|
||||||
import { loadMachine, saveMachine } from "./storage.ts";
|
import { loadMachine, saveMachine } from "./storage.ts";
|
||||||
import type { InstanceRecord, MachineRecord, RadiusRegistration } from "./types.ts";
|
import type { InstanceRecord, MachineRecord, RadiusRegistration } from "./types.ts";
|
||||||
|
|
||||||
const DEFAULT_RADIUS_URL = "https://radius.pi.dev/";
|
const DEFAULT_RADIUS_URL = "https://radius.pi.dev/";
|
||||||
const DEFAULT_ORCHESTRATOR_BASE_PATH = "/v1/";
|
const DEFAULT_SERVER_BASE_PATH = "/v1/";
|
||||||
const NOT_FOUND_RETRY_THRESHOLD = 3;
|
const NOT_FOUND_RETRY_THRESHOLD = 3;
|
||||||
const HEARTBEAT_BACKOFF_BASE_MS = 1_000;
|
const HEARTBEAT_BACKOFF_BASE_MS = 1_000;
|
||||||
const HEARTBEAT_BACKOFF_MAX_MS = 30_000;
|
const HEARTBEAT_BACKOFF_MAX_MS = 30_000;
|
||||||
@@ -45,7 +45,7 @@ class RadiusHttpError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
const response = await fetch(new URL(path, getRadiusOrchestratorBaseUrl()), {
|
const response = await fetch(new URL(path, getRadiusServerBaseUrl()), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${getRadiusAccessToken()}`,
|
Authorization: `Bearer ${getRadiusAccessToken()}`,
|
||||||
@@ -62,7 +62,7 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybePost(path: string, body: unknown): Promise<void> {
|
async function maybePost(path: string, body: unknown): Promise<void> {
|
||||||
const response = await fetch(new URL(path, getRadiusOrchestratorBaseUrl()), {
|
const response = await fetch(new URL(path, getRadiusServerBaseUrl()), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${getRadiusAccessToken()}`,
|
Authorization: `Bearer ${getRadiusAccessToken()}`,
|
||||||
@@ -108,13 +108,13 @@ export function getRadiusUrl(): string {
|
|||||||
return process.env.PI_RADIUS_URL || DEFAULT_RADIUS_URL;
|
return process.env.PI_RADIUS_URL || DEFAULT_RADIUS_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRadiusOrchestratorBaseUrl(): string {
|
export function getRadiusServerBaseUrl(): string {
|
||||||
const explicitUrl = process.env.PI_RADIUS_ORCHESTRATOR_URL;
|
const explicitUrl = process.env.PI_RADIUS_SERVER_URL;
|
||||||
if (explicitUrl) {
|
if (explicitUrl) {
|
||||||
return explicitUrl;
|
return explicitUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new URL(DEFAULT_ORCHESTRATOR_BASE_PATH, getRadiusUrl()).toString();
|
return new URL(DEFAULT_SERVER_BASE_PATH, getRadiusUrl()).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredRadiusCredential(): OAuthCredential | undefined {
|
function getStoredRadiusCredential(): OAuthCredential | undefined {
|
||||||
@@ -307,7 +307,7 @@ export class RadiusPresence {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await maybePost(`machines/${this.machine.id}/heartbeat`, {
|
await maybePost(`machines/${this.machine.id}/heartbeat`, {
|
||||||
cwd: getOrchestratorDir(),
|
cwd: getServerDir(),
|
||||||
socketPath: getSocketPath(),
|
socketPath: getSocketPath(),
|
||||||
});
|
});
|
||||||
this.machineConsecutiveNotFoundCount = 0;
|
this.machineConsecutiveNotFoundCount = 0;
|
||||||
@@ -144,7 +144,7 @@ export class RpcProcessInstance {
|
|||||||
if (this.exited) {
|
if (this.exited) {
|
||||||
throw new Error(`RPC process is not running. Stderr: ${this.stderrBuffer}`);
|
throw new Error(`RPC process is not running. Stderr: ${this.stderrBuffer}`);
|
||||||
}
|
}
|
||||||
const id = command.id ?? `orchestrator_${++this.nextRequestId}_${randomUUID()}`;
|
const id = command.id ?? `server_${++this.nextRequestId}_${randomUUID()}`;
|
||||||
const fullCommand = { ...command, id };
|
const fullCommand = { ...command, id };
|
||||||
return new Promise<RpcResponse>((resolve, reject) => {
|
return new Promise<RpcResponse>((resolve, reject) => {
|
||||||
this.pendingRequests.set(id, { resolve, reject });
|
this.pendingRequests.set(id, { resolve, reject });
|
||||||
@@ -3,7 +3,7 @@ import { dirname } from "node:path";
|
|||||||
import { getSocketPath } from "./config.ts";
|
import { getSocketPath } from "./config.ts";
|
||||||
import { handleIpcRequest, openRpcStream } from "./handler.ts";
|
import { handleIpcRequest, openRpcStream } from "./handler.ts";
|
||||||
import { startIpcServer } from "./ipc/server.ts";
|
import { startIpcServer } from "./ipc/server.ts";
|
||||||
import { getRadiusOrchestratorBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts";
|
import { getRadiusServerBaseUrl, isRadiusEnabled, radiusPresence } from "./radius.ts";
|
||||||
import { supervisor } from "./supervisor.ts";
|
import { supervisor } from "./supervisor.ts";
|
||||||
|
|
||||||
export async function serve(): Promise<void> {
|
export async function serve(): Promise<void> {
|
||||||
@@ -19,7 +19,7 @@ export async function serve(): Promise<void> {
|
|||||||
await supervisor.recoverAfterRestart();
|
await supervisor.recoverAfterRestart();
|
||||||
if (isRadiusEnabled()) {
|
if (isRadiusEnabled()) {
|
||||||
const machine = await radiusPresence.start();
|
const machine = await radiusPresence.start();
|
||||||
console.log(`radius integration enabled: ${socketPath} -> ${getRadiusOrchestratorBaseUrl()}`);
|
console.log(`radius integration enabled: ${socketPath} -> ${getRadiusServerBaseUrl()}`);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
console.log(`radius machine id: ${machine.id}`);
|
console.log(`radius machine id: ${machine.id}`);
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ export async function serve(): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`orchestrator listening on ${socketPath}`);
|
console.log(`server listening on ${socketPath}`);
|
||||||
|
|
||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = async (exitCode: number) => {
|
const shutdown = async (exitCode: number) => {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { getInstancesPath, getMachinePath, getOrchestratorDir } from "./config.ts";
|
import { getInstancesPath, getMachinePath, getServerDir } from "./config.ts";
|
||||||
import type { InstanceRecord, MachineRecord } from "./types.ts";
|
import type { InstanceRecord, MachineRecord } from "./types.ts";
|
||||||
|
|
||||||
function ensureOrchestratorDir(): void {
|
function ensureServerDir(): void {
|
||||||
const orchestratorDir = getOrchestratorDir();
|
const serverDir = getServerDir();
|
||||||
if (!existsSync(orchestratorDir)) {
|
if (!existsSync(serverDir)) {
|
||||||
mkdirSync(orchestratorDir, { recursive: true });
|
mkdirSync(serverDir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export function loadMachine(): MachineRecord | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveMachine(machine: MachineRecord): void {
|
export function saveMachine(machine: MachineRecord): void {
|
||||||
ensureOrchestratorDir();
|
ensureServerDir();
|
||||||
writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2));
|
writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export function loadInstances(): InstanceRecord[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveInstances(instances: InstanceRecord[]): void {
|
export function saveInstances(instances: InstanceRecord[]): void {
|
||||||
ensureOrchestratorDir();
|
ensureServerDir();
|
||||||
writeFileSync(getInstancesPath(), JSON.stringify(instances, null, 2));
|
writeFileSync(getInstancesPath(), JSON.stringify(instances, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ function isGetStateSuccess(
|
|||||||
return response.success === true && response.command === "get_state" && "data" in response;
|
return response.success === true && response.command === "get_state" && "data" in response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OrchestratorSupervisor {
|
export class ServerSupervisor {
|
||||||
private readonly liveInstances = new Map<string, LiveInstance>();
|
private readonly liveInstances = new Map<string, LiveInstance>();
|
||||||
|
|
||||||
private setStatus(live: LiveInstance, status: InstanceStatus): void {
|
private setStatus(live: LiveInstance, status: InstanceStatus): void {
|
||||||
@@ -339,7 +339,7 @@ export class OrchestratorSupervisor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const supervisor = new OrchestratorSupervisor();
|
export const supervisor = new ServerSupervisor();
|
||||||
|
|
||||||
radiusPresence.setCoordinator({
|
radiusPresence.setCoordinator({
|
||||||
getLiveInstance(instanceId) {
|
getLiveInstance(instanceId) {
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a Node.js SQLite storage backend for agent harness sessions, including migrations and materialized session views ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @earendil-works/pi-storage-sqlite-node
|
||||||
|
|
||||||
|
Node sqlite storage backend for `@earendil-works/pi-agent-core` sessions. Provides the
|
||||||
|
`node:sqlite` adapter (`SqliteDatabase` implementation) and the SQLite session
|
||||||
|
repo/storage implementation (`SqliteSessionRepo`, migrations, materialized views).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@earendil-works/pi-storage-sqlite-node",
|
||||||
|
"version": "0.81.0",
|
||||||
|
"description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"",
|
||||||
|
"build": "tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations",
|
||||||
|
"prepublishOnly": "npm run clean && npm run build"
|
||||||
|
},
|
||||||
|
"author": "Earendil Works",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/earendil-works/pi.git",
|
||||||
|
"directory": "packages/storage/sqlite-node"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@earendil-works/pi-ai": "^0.81.0",
|
||||||
|
"@earendil-works/pi-agent-core": "^0.81.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { cp, mkdir, rm } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageDir = resolve(scriptDir, "..");
|
||||||
|
const distDir = resolve(packageDir, "dist");
|
||||||
|
const migrationSourceDir = resolve(packageDir, "src/sqlite/migrations");
|
||||||
|
const migrationDestDir = resolve(distDir, "sqlite/migrations");
|
||||||
|
|
||||||
|
async function clean() {
|
||||||
|
await rm(distDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySqliteMigrations() {
|
||||||
|
await mkdir(migrationDestDir, { recursive: true });
|
||||||
|
await cp(migrationSourceDir, migrationDestDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = process.argv[2];
|
||||||
|
|
||||||
|
if (command === "clean") {
|
||||||
|
await clean();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "copy-sqlite-migrations") {
|
||||||
|
await copySqliteMigrations();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Usage: node scripts/prepare-dist.mjs <clean|copy-sqlite-migrations>");
|
||||||
|
process.exit(1);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { SQLInputValue } from "node:sqlite";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts";
|
||||||
|
|
||||||
|
function isNamedParameters(value: unknown): value is Record<string, SQLInputValue> {
|
||||||
|
if (value === null || typeof value !== "object") return false;
|
||||||
|
if (Array.isArray(value) || ArrayBuffer.isView(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodeSqliteStatement implements SqliteStatement {
|
||||||
|
private readonly statement: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
|
||||||
|
constructor(statement: ReturnType<DatabaseSync["prepare"]>) {
|
||||||
|
this.statement = statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(...params: unknown[]): Promise<SqliteRunResult> {
|
||||||
|
const [first, ...rest] = params;
|
||||||
|
const result = isNamedParameters(first)
|
||||||
|
? this.statement.run(first, ...(rest as SQLInputValue[]))
|
||||||
|
: this.statement.run(...(params as SQLInputValue[]));
|
||||||
|
return {
|
||||||
|
changes: Number(result.changes),
|
||||||
|
lastInsertRowid: result.lastInsertRowid === undefined ? undefined : Number(result.lastInsertRowid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<TRow extends object>(...params: unknown[]): Promise<TRow | undefined> {
|
||||||
|
const [first, ...rest] = params;
|
||||||
|
return (
|
||||||
|
isNamedParameters(first)
|
||||||
|
? this.statement.get(first, ...(rest as SQLInputValue[]))
|
||||||
|
: this.statement.get(...(params as SQLInputValue[]))
|
||||||
|
) as TRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async all<TRow extends object>(...params: unknown[]): Promise<TRow[]> {
|
||||||
|
const [first, ...rest] = params;
|
||||||
|
return (
|
||||||
|
isNamedParameters(first)
|
||||||
|
? this.statement.all(first, ...(rest as SQLInputValue[]))
|
||||||
|
: this.statement.all(...(params as SQLInputValue[]))
|
||||||
|
) as TRow[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodeSqliteDatabase implements SqliteDatabase {
|
||||||
|
private readonly db: DatabaseSync;
|
||||||
|
|
||||||
|
constructor(db: DatabaseSync) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(sql: string): Promise<void> {
|
||||||
|
this.db.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(sql: string): SqliteStatement {
|
||||||
|
return new NodeSqliteStatement(this.db.prepare(sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
this.db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
this.db.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
this.db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// Ignore rollback errors to rethrow original error.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapNodeSqliteDatabase(db: DatabaseSync): SqliteDatabase {
|
||||||
|
return new NodeSqliteDatabase(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeSqliteFactory(): SqliteDatabaseFactory {
|
||||||
|
return {
|
||||||
|
async open(path: string): Promise<SqliteDatabase> {
|
||||||
|
return new NodeSqliteDatabase(new DatabaseSync(path));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the SQLite session storage backend and types so this package is a complete node-sqlite backend.
|
||||||
|
export * from "./sqlite/index.ts";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./migrations.ts";
|
||||||
|
export * from "./repo.ts";
|
||||||
|
export * from "./storage/index.ts";
|
||||||
|
export * from "./types.ts";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { SqliteDatabase } from "./types.ts";
|
||||||
|
|
||||||
|
export interface SqliteMigration {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMigrationSql(relativePath: string): Promise<string> {
|
||||||
|
return readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMigrations(): Promise<SqliteMigration[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "001_initial.sql",
|
||||||
|
order: 1,
|
||||||
|
sql: await loadMigrationSql("./migrations/001_initial.sql"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMigrationsTable(db: SqliteDatabase): Promise<void> {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyMigrations(db: SqliteDatabase): Promise<void> {
|
||||||
|
await ensureMigrationsTable(db);
|
||||||
|
const migrations = await loadMigrations();
|
||||||
|
const appliedRows = await db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>();
|
||||||
|
const applied = new Set(appliedRows.map((row) => row.id));
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (applied.has(migration.id)) continue;
|
||||||
|
await db.transaction(async () => {
|
||||||
|
await db.exec(migration.sql);
|
||||||
|
await db
|
||||||
|
.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)")
|
||||||
|
.run(migration.id, new Date().toISOString());
|
||||||
|
});
|
||||||
|
applied.add(migration.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
cwd TEXT NOT NULL,
|
||||||
|
parent_session_id TEXT NULL,
|
||||||
|
metadata TEXT NULL,
|
||||||
|
active_leaf_id TEXT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_entries (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
entry_seq INTEGER NOT NULL,
|
||||||
|
parent_id TEXT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_session_entries_session_seq ON session_entries(session_id, entry_seq);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_entries_session_parent ON session_entries(session_id, parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_entries_session_type ON session_entries(session_id, type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_sequences (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
next_seq INTEGER NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS branch_entries (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
branch_id TEXT NOT NULL,
|
||||||
|
entry_id TEXT NOT NULL,
|
||||||
|
entry_seq INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, branch_id, entry_id)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch ON branch_entries(session_id, branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_materialized (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_materialized (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
entry_seq INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, entry_seq, type)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_materialized_session_type_seq ON entry_materialized(session_id, type, entry_seq);
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Session, SessionStorage, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||||
|
import {
|
||||||
|
createSessionId,
|
||||||
|
getEntriesToFork,
|
||||||
|
getFileSystemResultOrThrow,
|
||||||
|
SessionError,
|
||||||
|
toSession,
|
||||||
|
} from "@earendil-works/pi-agent-core";
|
||||||
|
import { applyMigrations } from "./migrations.ts";
|
||||||
|
import { SqliteSessionStorage } from "./storage/index.ts";
|
||||||
|
import { rowToMetadata, type SessionRow } from "./storage/sessions.ts";
|
||||||
|
import type {
|
||||||
|
SqliteDatabase,
|
||||||
|
SqliteDatabaseFactory,
|
||||||
|
SqliteSessionCreateOptions,
|
||||||
|
SqliteSessionListOptions,
|
||||||
|
SqliteSessionMetadata,
|
||||||
|
SqliteSessionRepoApi,
|
||||||
|
SqliteSessionRepoEnv,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
function getParentPath(path: string): string {
|
||||||
|
const normalized = path.replace(/[\\/]+$/, "");
|
||||||
|
const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||||
|
if (lastSlash < 0) return ".";
|
||||||
|
if (lastSlash === 0) return normalized.slice(0, 1);
|
||||||
|
return normalized.slice(0, lastSlash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureSqliteDatabase(db: SqliteDatabase): Promise<void> {
|
||||||
|
await db.exec("PRAGMA journal_mode=WAL");
|
||||||
|
await db.exec("PRAGMA synchronous=FULL");
|
||||||
|
await db.exec("PRAGMA busy_timeout=5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupSessionStorage(storage: SessionStorage): Promise<void> {
|
||||||
|
const maybeClosable = storage as SessionStorage & { cleanup?: () => Promise<void> };
|
||||||
|
if (typeof maybeClosable.cleanup === "function") {
|
||||||
|
await maybeClosable.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
||||||
|
private readonly env: SqliteSessionRepoEnv;
|
||||||
|
private readonly sqlite: SqliteDatabaseFactory;
|
||||||
|
private readonly databasePathInput: string;
|
||||||
|
private databasePath: string | undefined;
|
||||||
|
|
||||||
|
constructor(options: { env: SqliteSessionRepoEnv; sqlite: SqliteDatabaseFactory; databasePath: string }) {
|
||||||
|
this.env = options.env;
|
||||||
|
this.sqlite = options.sqlite;
|
||||||
|
this.databasePathInput = options.databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDatabasePath(): Promise<string> {
|
||||||
|
if (!this.databasePath) {
|
||||||
|
this.databasePath = getFileSystemResultOrThrow(
|
||||||
|
await this.env.absolutePath(this.databasePathInput),
|
||||||
|
`Failed to resolve SQLite sessions database ${this.databasePathInput}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDatabaseDir(): Promise<void> {
|
||||||
|
const path = await this.getDatabasePath();
|
||||||
|
const directory = getParentPath(path);
|
||||||
|
getFileSystemResultOrThrow(
|
||||||
|
await this.env.createDir(directory, { recursive: true }),
|
||||||
|
`Failed to create SQLite sessions directory ${directory}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openDatabase(): Promise<SqliteDatabase> {
|
||||||
|
await this.ensureDatabaseDir();
|
||||||
|
const db = await this.sqlite.open(await this.getDatabasePath());
|
||||||
|
try {
|
||||||
|
await configureSqliteDatabase(db);
|
||||||
|
await applyMigrations(db);
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
await db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(options: SqliteSessionCreateOptions): Promise<Session<SqliteSessionMetadata>> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
try {
|
||||||
|
const id = options.id ?? createSessionId();
|
||||||
|
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: id,
|
||||||
|
parentSessionId: options.parentSessionId,
|
||||||
|
metadata: options.metadata,
|
||||||
|
});
|
||||||
|
return toSession(storage);
|
||||||
|
} catch (error) {
|
||||||
|
await db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(metadata: SqliteSessionMetadata): Promise<Session<SqliteSessionMetadata>> {
|
||||||
|
if (
|
||||||
|
!getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`)
|
||||||
|
) {
|
||||||
|
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||||
|
}
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
try {
|
||||||
|
const storage = await SqliteSessionStorage.open(db, metadata);
|
||||||
|
return toSession(storage);
|
||||||
|
} catch (error) {
|
||||||
|
await db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(options: SqliteSessionListOptions = {}): Promise<SqliteSessionMetadata[]> {
|
||||||
|
const path = await this.getDatabasePath();
|
||||||
|
if (!getFileSystemResultOrThrow(await this.env.exists(path), `Failed to check database ${path}`)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
try {
|
||||||
|
const rows = options.cwd
|
||||||
|
? await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE cwd = ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.all<SessionRow>(options.cwd)
|
||||||
|
: await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.all<SessionRow>();
|
||||||
|
return rows.map((row) => rowToMetadata(row, path));
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(metadata: SqliteSessionMetadata): Promise<void> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
try {
|
||||||
|
await db.transaction(async () => {
|
||||||
|
await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(metadata.id);
|
||||||
|
await db.prepare("DELETE FROM session_entries WHERE session_id = ?").run(metadata.id);
|
||||||
|
await db.prepare("DELETE FROM entry_materialized WHERE session_id = ?").run(metadata.id);
|
||||||
|
await db.prepare("DELETE FROM session_materialized WHERE session_id = ?").run(metadata.id);
|
||||||
|
await db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(metadata.id);
|
||||||
|
const result = await db.prepare("DELETE FROM sessions WHERE id = ?").run(metadata.id);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fork(
|
||||||
|
sourceMetadata: SqliteSessionMetadata,
|
||||||
|
options: SqliteSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||||
|
): Promise<Session<SqliteSessionMetadata>> {
|
||||||
|
const source = await this.open(sourceMetadata);
|
||||||
|
let forkedEntries: SessionTreeEntry[];
|
||||||
|
try {
|
||||||
|
forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||||
|
} finally {
|
||||||
|
await cleanupSessionStorage(source.getStorage());
|
||||||
|
}
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
try {
|
||||||
|
const id = options.id ?? createSessionId();
|
||||||
|
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||||
|
cwd: options.cwd,
|
||||||
|
sessionId: id,
|
||||||
|
parentSessionId: options.parentSessionId ?? sourceMetadata.id,
|
||||||
|
metadata: options.metadata ?? sourceMetadata.metadata,
|
||||||
|
});
|
||||||
|
for (const entry of forkedEntries) {
|
||||||
|
await storage.appendEntry(entry);
|
||||||
|
}
|
||||||
|
return toSession(storage);
|
||||||
|
} catch (error) {
|
||||||
|
await db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { SqliteDatabase } from "../types.ts";
|
||||||
|
import { decodeEntry, type SessionEntryRow } from "./session-entries.ts";
|
||||||
|
import { invalidSession } from "./shared.ts";
|
||||||
|
|
||||||
|
export interface BranchEntryRow {
|
||||||
|
entry_id: string;
|
||||||
|
entry_seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaterializedBranchPathOrCompaction(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
sessionId: string,
|
||||||
|
branchId: string,
|
||||||
|
byId: Map<string, SessionTreeEntry>,
|
||||||
|
): Promise<SessionTreeEntry[]> {
|
||||||
|
const branchRows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT entry_id, entry_seq FROM branch_entries WHERE session_id = ? AND branch_id = ? ORDER BY entry_seq",
|
||||||
|
)
|
||||||
|
.all<BranchEntryRow>(sessionId, branchId);
|
||||||
|
if (branchRows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entryIds = branchRows.map((row) => row.entry_id);
|
||||||
|
const placeholders = entryIds.map(() => "?").join(", ");
|
||||||
|
const entryRows = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
|
||||||
|
)
|
||||||
|
.all<SessionEntryRow>(sessionId, ...entryIds);
|
||||||
|
const entryRowsById = new Map(entryRows.map((row) => [row.id, row]));
|
||||||
|
const entries: SessionTreeEntry[] = [];
|
||||||
|
for (const branchRow of branchRows) {
|
||||||
|
// leaf entries are navigation markers used to mark which branch became active;
|
||||||
|
// they are not part of the model/context path reconstructed from branch_entries.
|
||||||
|
const cached = byId.get(branchRow.entry_id);
|
||||||
|
if (cached) {
|
||||||
|
if (cached.type !== "leaf") {
|
||||||
|
entries.push(cached);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entryRow = entryRowsById.get(branchRow.entry_id);
|
||||||
|
if (!entryRow) throw invalidSession(`missing entry row for branch entry ${branchRow.entry_id}`);
|
||||||
|
try {
|
||||||
|
const entry = decodeEntry(entryRow);
|
||||||
|
byId.set(entry.id, entry);
|
||||||
|
if (entry.type !== "leaf") {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw invalidSession(`invalid entry row for branch entry ${branchRow.entry_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import type {
|
||||||
|
LeafEntry,
|
||||||
|
SessionEntryCursorOptions,
|
||||||
|
SessionStorage,
|
||||||
|
SessionTreeEntry,
|
||||||
|
} from "@earendil-works/pi-agent-core";
|
||||||
|
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||||
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||||
|
import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts";
|
||||||
|
import { getMaterializedBranchPathOrCompaction } from "./branch-entries.ts";
|
||||||
|
import { decodeEntry, encodeEntry, type SessionEntryRow } from "./session-entries.ts";
|
||||||
|
import {
|
||||||
|
applyEntryToMaterializedState,
|
||||||
|
createEmptyMaterializedState,
|
||||||
|
type EntryMaterializedRow,
|
||||||
|
entryMaterializedValues,
|
||||||
|
materializedStateFromRows,
|
||||||
|
materializedStateValues,
|
||||||
|
type SessionMaterializedRow,
|
||||||
|
type SessionMaterializedState,
|
||||||
|
serializeSummary,
|
||||||
|
sessionStatsFromMaterializedState,
|
||||||
|
} from "./session-materialized.ts";
|
||||||
|
import { advanceSequence, getNextSequence } from "./session-sequences.ts";
|
||||||
|
import { rowToMetadata, type SessionRow } from "./sessions.ts";
|
||||||
|
import { generateEntryId, invalidSession, leafIdAfterEntry } from "./shared.ts";
|
||||||
|
|
||||||
|
async function decodeEntryRows(entryRows: SessionEntryRow[]): Promise<{
|
||||||
|
entries: SessionTreeEntry[];
|
||||||
|
leafId: string | null;
|
||||||
|
}> {
|
||||||
|
const entries: SessionTreeEntry[] = [];
|
||||||
|
let leafId: string | null = null;
|
||||||
|
for (const entryRow of entryRows) {
|
||||||
|
try {
|
||||||
|
const entry = decodeEntry(entryRow);
|
||||||
|
entries.push(entry);
|
||||||
|
leafId = leafIdAfterEntry(entry);
|
||||||
|
} catch {
|
||||||
|
// Keep JSONL-like permissive resume behavior: skip malformed entries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { entries, leafId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntryRowsByIds(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
sessionId: string,
|
||||||
|
entryIds: string[],
|
||||||
|
): Promise<Map<string, SessionEntryRow>> {
|
||||||
|
if (entryIds.length === 0) return new Map<string, SessionEntryRow>();
|
||||||
|
const placeholders = entryIds.map(() => "?").join(", ");
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id IN (${placeholders})`,
|
||||||
|
)
|
||||||
|
.all<SessionEntryRow>(sessionId, ...entryIds);
|
||||||
|
return new Map(rows.map((row) => [row.id, row]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveBranchId(db: SqliteDatabase, sessionId: string): Promise<string | null> {
|
||||||
|
// branch_entries includes leaf navigation entries for the active branch, so the
|
||||||
|
// newest branch_entries row identifies the branch that was most recently made active.
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT branch_id FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC, branch_id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.get<{ branch_id: string }>(sessionId);
|
||||||
|
return row?.branch_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasExistingChild(db: SqliteDatabase, sessionId: string, parentId: string | null): Promise<boolean> {
|
||||||
|
const row =
|
||||||
|
parentId === null
|
||||||
|
? await db
|
||||||
|
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id IS NULL LIMIT 1")
|
||||||
|
.get<{ found: number }>(sessionId)
|
||||||
|
: await db
|
||||||
|
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND parent_id = ? LIMIT 1")
|
||||||
|
.get<{ found: number }>(sessionId, parentId);
|
||||||
|
return row !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSqliteStorage(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{
|
||||||
|
row: SessionRow;
|
||||||
|
leafId: string | null;
|
||||||
|
activeBranchId: string | null;
|
||||||
|
materializedState: SessionMaterializedState;
|
||||||
|
}> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT id, created_at, metadata, cwd, parent_session_id, active_leaf_id FROM sessions WHERE id = ?")
|
||||||
|
.get<SessionRow>(sessionId);
|
||||||
|
if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`);
|
||||||
|
|
||||||
|
const leafId = row.active_leaf_id;
|
||||||
|
const materializedRow = await db
|
||||||
|
.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?")
|
||||||
|
.get<SessionMaterializedRow>(sessionId);
|
||||||
|
if (!materializedRow) throw invalidSession(`missing materialized row for session ${sessionId}`);
|
||||||
|
const entryMaterializedRows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
|
||||||
|
)
|
||||||
|
.all<EntryMaterializedRow>(sessionId);
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
leafId,
|
||||||
|
activeBranchId: await loadActiveBranchId(db, sessionId),
|
||||||
|
materializedState: materializedStateFromRows(materializedRow, entryMaterializedRows),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SqliteSessionStorage implements SessionStorage<SqliteSessionMetadata> {
|
||||||
|
private readonly db: SqliteDatabase;
|
||||||
|
private readonly metadata: SqliteSessionMetadata;
|
||||||
|
private byId: Map<string, SessionTreeEntry>;
|
||||||
|
private labelsById: Map<string, string>;
|
||||||
|
private currentLeafId: string | null;
|
||||||
|
private activeBranchId: string | null;
|
||||||
|
private materializedState: SessionMaterializedState;
|
||||||
|
|
||||||
|
private async getPathToRootOrCompactionEntries(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||||
|
if (leafId === null) return [];
|
||||||
|
const path: SessionTreeEntry[] = [];
|
||||||
|
let stopAtEntryId: string | null = null;
|
||||||
|
let current = await this.getEntry(leafId);
|
||||||
|
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current);
|
||||||
|
if (stopAtEntryId !== null && current.id === stopAtEntryId) break;
|
||||||
|
if (current.type === "compaction") {
|
||||||
|
if (current.retainedTail) break;
|
||||||
|
stopAtEntryId = current.firstKeptEntryId ?? null;
|
||||||
|
}
|
||||||
|
if (!current.parentId) break;
|
||||||
|
const parent = await this.getEntry(current.parentId);
|
||||||
|
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async materializeBranch(leafId: string | null): Promise<void> {
|
||||||
|
const branchId = uuidv7();
|
||||||
|
// Rebuild the branch path only when branch membership changes: branch switch
|
||||||
|
// (leaf navigation) or a new fork from a parent that already has a child.
|
||||||
|
// Linear appends stay cheap and extend the active branch incrementally.
|
||||||
|
const path = await this.getPathToRootOrCompactionEntries(leafId);
|
||||||
|
const entryRowsById = await loadEntryRowsByIds(
|
||||||
|
this.db,
|
||||||
|
this.metadata.id,
|
||||||
|
path.map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
for (const entry of path) {
|
||||||
|
const entryRow = entryRowsById.get(entry.id);
|
||||||
|
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entry.id}`);
|
||||||
|
await this.db
|
||||||
|
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(this.metadata.id, branchId, entry.id, entryRow.entry_seq);
|
||||||
|
}
|
||||||
|
this.activeBranchId = branchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendToActiveBranch(entryId: string, parentId: string | null): Promise<void> {
|
||||||
|
if (!this.activeBranchId) {
|
||||||
|
await this.materializeBranch(parentId);
|
||||||
|
}
|
||||||
|
// After a branch is materialized/resynced, subsequent linear appends only add the
|
||||||
|
// new tip entry. We do not rebuild the full branch on every append.
|
||||||
|
if (!this.activeBranchId) {
|
||||||
|
throw invalidSession(`active branch missing for session ${this.metadata.id}`);
|
||||||
|
}
|
||||||
|
const entryRow = await this.db
|
||||||
|
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? AND id = ?")
|
||||||
|
.get<{ entry_seq: number }>(this.metadata.id, entryId);
|
||||||
|
if (!entryRow) throw invalidSession(`missing entry row for session ${this.metadata.id} entry ${entryId}`);
|
||||||
|
await this.db
|
||||||
|
.prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(this.metadata.id, this.activeBranchId, entryId, entryRow.entry_seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
metadata: SqliteSessionMetadata,
|
||||||
|
entries: SessionTreeEntry[] | null,
|
||||||
|
leafId: string | null,
|
||||||
|
activeBranchId: string | null,
|
||||||
|
materializedState: SessionMaterializedState,
|
||||||
|
) {
|
||||||
|
this.db = db;
|
||||||
|
this.metadata = metadata;
|
||||||
|
this.byId = new Map((entries ?? []).map((entry) => [entry.id, entry]));
|
||||||
|
this.materializedState = materializedState;
|
||||||
|
this.labelsById = materializedState.labelsById;
|
||||||
|
this.currentLeafId = leafId;
|
||||||
|
this.activeBranchId = activeBranchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async open(db: SqliteDatabase, metadata: SqliteSessionMetadata): Promise<SqliteSessionStorage> {
|
||||||
|
const loaded = await loadSqliteStorage(db, metadata.id);
|
||||||
|
return new SqliteSessionStorage(
|
||||||
|
db,
|
||||||
|
rowToMetadata(loaded.row, metadata.path),
|
||||||
|
null,
|
||||||
|
loaded.leafId,
|
||||||
|
loaded.activeBranchId,
|
||||||
|
loaded.materializedState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
cwd: string;
|
||||||
|
sessionId: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<SqliteSessionStorage> {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id, active_leaf_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
options.sessionId,
|
||||||
|
createdAt,
|
||||||
|
options.metadata === undefined ? null : JSON.stringify(options.metadata),
|
||||||
|
options.cwd,
|
||||||
|
options.parentSessionId ?? null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(options.sessionId, 1);
|
||||||
|
await db
|
||||||
|
.prepare("INSERT INTO session_materialized (session_id, payload) VALUES (?, ?)")
|
||||||
|
.run(...materializedStateValues(options.sessionId, createEmptyMaterializedState()));
|
||||||
|
return new SqliteSessionStorage(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
id: options.sessionId,
|
||||||
|
createdAt,
|
||||||
|
cwd: options.cwd,
|
||||||
|
path,
|
||||||
|
parentSessionId: options.parentSessionId,
|
||||||
|
metadata: options.metadata,
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
createEmptyMaterializedState(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetadata(): Promise<SqliteSessionMetadata> {
|
||||||
|
return this.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeafId(): Promise<string | null> {
|
||||||
|
return this.currentLeafId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLeafId(leafId: string | null): Promise<void> {
|
||||||
|
if (leafId !== null && !(await this.getEntry(leafId))) {
|
||||||
|
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||||
|
}
|
||||||
|
const entry: LeafEntry = {
|
||||||
|
type: "leaf",
|
||||||
|
id: await this.createEntryId(),
|
||||||
|
parentId: this.currentLeafId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
targetId: leafId,
|
||||||
|
};
|
||||||
|
await this.appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEntryId(): Promise<string> {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const id = generateEntryId(this.byId);
|
||||||
|
const existing = await this.db
|
||||||
|
.prepare("SELECT 1 AS found FROM session_entries WHERE session_id = ? AND id = ? LIMIT 1")
|
||||||
|
.get<{ found: number }>(this.metadata.id, id);
|
||||||
|
if (!existing) return id;
|
||||||
|
}
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||||
|
const encoded = encodeEntry(entry);
|
||||||
|
const previousMaterializedState: SessionMaterializedState = {
|
||||||
|
...this.materializedState,
|
||||||
|
labelsById: new Map(this.materializedState.labelsById),
|
||||||
|
modelThinkingConfigs: [...this.materializedState.modelThinkingConfigs],
|
||||||
|
currentModel: this.materializedState.currentModel ? { ...this.materializedState.currentModel } : null,
|
||||||
|
};
|
||||||
|
const previousById = new Map(this.byId);
|
||||||
|
const previousLeafId = this.currentLeafId;
|
||||||
|
const previousActiveBranchId = this.activeBranchId;
|
||||||
|
try {
|
||||||
|
applyEntryToMaterializedState(this.materializedState, entry);
|
||||||
|
await this.db.transaction(async () => {
|
||||||
|
const parentHadExistingChild = await hasExistingChild(this.db, this.metadata.id, entry.parentId);
|
||||||
|
const nextSeq = await getNextSequence(this.db, this.metadata.id);
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO session_entries (session_id, id, entry_seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(this.metadata.id, entry.id, nextSeq, entry.parentId, entry.type, entry.timestamp, encoded.payload);
|
||||||
|
await advanceSequence(this.db, this.metadata.id, nextSeq);
|
||||||
|
await this.db
|
||||||
|
.prepare("UPDATE session_materialized SET payload = ? WHERE session_id = ?")
|
||||||
|
.run(serializeSummary(this.materializedState), this.metadata.id);
|
||||||
|
for (const materializedEntry of entryMaterializedValues(entry)) {
|
||||||
|
await this.db
|
||||||
|
.prepare("INSERT INTO entry_materialized (session_id, entry_seq, type, payload) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(this.metadata.id, nextSeq, materializedEntry.type, materializedEntry.payload);
|
||||||
|
}
|
||||||
|
this.byId.set(entry.id, entry);
|
||||||
|
this.currentLeafId = leafIdAfterEntry(entry);
|
||||||
|
await this.db
|
||||||
|
.prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?")
|
||||||
|
.run(this.currentLeafId, this.metadata.id);
|
||||||
|
if (entry.type === "leaf") {
|
||||||
|
this.activeBranchId = null;
|
||||||
|
await this.materializeBranch(entry.targetId);
|
||||||
|
await this.appendToActiveBranch(entry.id, entry.parentId);
|
||||||
|
} else {
|
||||||
|
if (parentHadExistingChild) {
|
||||||
|
await this.materializeBranch(entry.parentId);
|
||||||
|
}
|
||||||
|
await this.appendToActiveBranch(entry.id, entry.parentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.materializedState = previousMaterializedState;
|
||||||
|
this.labelsById = previousMaterializedState.labelsById;
|
||||||
|
this.byId = previousById;
|
||||||
|
this.currentLeafId = previousLeafId;
|
||||||
|
this.activeBranchId = previousActiveBranchId;
|
||||||
|
if (error instanceof SessionError) throw error;
|
||||||
|
throw new SessionError("storage", `Failed to append SQLite session entry ${entry.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||||
|
const cached = this.byId.get(id);
|
||||||
|
if (cached) return cached;
|
||||||
|
const row = await this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND id = ?",
|
||||||
|
)
|
||||||
|
.get<SessionEntryRow>(this.metadata.id, id);
|
||||||
|
if (!row) return undefined;
|
||||||
|
try {
|
||||||
|
const entry = decodeEntry(row);
|
||||||
|
this.byId.set(entry.id, entry);
|
||||||
|
return entry;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntries<TType extends SessionTreeEntry["type"]>(
|
||||||
|
type: TType,
|
||||||
|
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
||||||
|
const rows = await this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND type = ? ORDER BY entry_seq",
|
||||||
|
)
|
||||||
|
.all<SessionEntryRow>(this.metadata.id, type);
|
||||||
|
const entries: Array<Extract<SessionTreeEntry, { type: TType }>> = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const entry = decodeEntry(row) as Extract<SessionTreeEntry, { type: TType }>;
|
||||||
|
this.byId.set(entry.id, entry);
|
||||||
|
entries.push(entry);
|
||||||
|
} catch {
|
||||||
|
// Keep JSONL-like permissive resume behavior: skip malformed entries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLabel(id: string): Promise<string | undefined> {
|
||||||
|
return this.labelsById.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionName(): Promise<string | undefined> {
|
||||||
|
return this.materializedState.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionStats() {
|
||||||
|
return sessionStatsFromMaterializedState(this.materializedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPathToRootOrCompaction(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||||
|
if (leafId === null) return [];
|
||||||
|
if (leafId === this.currentLeafId) {
|
||||||
|
if (!this.activeBranchId) {
|
||||||
|
throw invalidSession(`missing active branch for session ${this.metadata.id} leaf ${leafId}`);
|
||||||
|
}
|
||||||
|
return getMaterializedBranchPathOrCompaction(this.db, this.metadata.id, this.activeBranchId, this.byId);
|
||||||
|
}
|
||||||
|
return this.getPathToRootOrCompactionEntries(leafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]> {
|
||||||
|
const limit = options?.limit;
|
||||||
|
if (limit !== undefined) {
|
||||||
|
const beforeOrAtEntrySeq =
|
||||||
|
options?.afterEntrySeq ??
|
||||||
|
(
|
||||||
|
await this.db
|
||||||
|
.prepare("SELECT entry_seq FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
|
||||||
|
.get<{ entry_seq: number }>(this.metadata.id)
|
||||||
|
)?.entry_seq;
|
||||||
|
if (beforeOrAtEntrySeq === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = await this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? AND entry_seq <= ? ORDER BY entry_seq DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.all<SessionEntryRow>(this.metadata.id, beforeOrAtEntrySeq, limit);
|
||||||
|
const entries = (await decodeEntryRows(rows)).entries;
|
||||||
|
for (const entry of entries) {
|
||||||
|
this.byId.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
return entries.reverse();
|
||||||
|
}
|
||||||
|
const rows = await this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT session_id, id, entry_seq, parent_id, type, timestamp, payload FROM session_entries WHERE session_id = ? ORDER BY entry_seq",
|
||||||
|
)
|
||||||
|
.all<SessionEntryRow>(this.metadata.id);
|
||||||
|
const entries = (await decodeEntryRows(rows)).entries;
|
||||||
|
for (const entry of entries) {
|
||||||
|
this.byId.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
await this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { SessionTreeEntry, SessionTreeEntryBase } from "@earendil-works/pi-agent-core";
|
||||||
|
import { invalidEntry, isRecord } from "./shared.ts";
|
||||||
|
|
||||||
|
export interface SessionEntryRow {
|
||||||
|
session_id: string;
|
||||||
|
id: string;
|
||||||
|
entry_seq: number;
|
||||||
|
parent_id: string | null;
|
||||||
|
type: SessionTreeEntry["type"];
|
||||||
|
timestamp: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncodedEntry = {
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryPayload<TEntry extends SessionTreeEntry> = Omit<TEntry, keyof SessionTreeEntryBase | "type">;
|
||||||
|
|
||||||
|
type MessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "message" }>>;
|
||||||
|
type ThinkingLevelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "thinking_level_change" }>>;
|
||||||
|
type ModelChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "model_change" }>>;
|
||||||
|
type ActiveToolsChangePayload = EntryPayload<Extract<SessionTreeEntry, { type: "active_tools_change" }>>;
|
||||||
|
type CompactionPayload = EntryPayload<Extract<SessionTreeEntry, { type: "compaction" }>>;
|
||||||
|
type BranchSummaryPayload = EntryPayload<Extract<SessionTreeEntry, { type: "branch_summary" }>>;
|
||||||
|
type CustomPayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom" }>>;
|
||||||
|
type CustomMessagePayload = EntryPayload<Extract<SessionTreeEntry, { type: "custom_message" }>>;
|
||||||
|
type LabelPayload = EntryPayload<Extract<SessionTreeEntry, { type: "label" }>>;
|
||||||
|
type SessionInfoPayload = EntryPayload<Extract<SessionTreeEntry, { type: "session_info" }>>;
|
||||||
|
type LeafPayload = EntryPayload<Extract<SessionTreeEntry, { type: "leaf" }>>;
|
||||||
|
|
||||||
|
function parsePayload(row: SessionEntryRow): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw invalidEntry(`entry ${row.id} payload is not valid JSON`, error instanceof Error ? error : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextImageContentArray(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(item) =>
|
||||||
|
isRecord(item) && typeof item.type === "string" && (item.type !== "text" || typeof item.text === "string"),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSessionTreeEntry(entry: SessionTreeEntry): void {
|
||||||
|
if (typeof entry.id !== "string" || !entry.id) throw invalidEntry("entry is missing id");
|
||||||
|
if (entry.parentId !== null && typeof entry.parentId !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid parentId`);
|
||||||
|
}
|
||||||
|
if (typeof entry.timestamp !== "string" || !entry.timestamp) {
|
||||||
|
throw invalidEntry(`entry ${entry.id} is missing timestamp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (entry.type) {
|
||||||
|
case "message":
|
||||||
|
if (!isRecord(entry.message) || typeof entry.message.role !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} is missing message payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "thinking_level_change":
|
||||||
|
if (typeof entry.thinkingLevel !== "string") throw invalidEntry(`entry ${entry.id} is missing thinkingLevel`);
|
||||||
|
break;
|
||||||
|
case "model_change":
|
||||||
|
if (typeof entry.provider !== "string" || typeof entry.modelId !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid model_change payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "active_tools_change":
|
||||||
|
if (
|
||||||
|
!Array.isArray(entry.activeToolNames) ||
|
||||||
|
entry.activeToolNames.some((value) => typeof value !== "string")
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid active_tools_change payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "compaction":
|
||||||
|
if (
|
||||||
|
typeof entry.summary !== "string" ||
|
||||||
|
typeof entry.firstKeptEntryId !== "string" ||
|
||||||
|
typeof entry.tokensBefore !== "number" ||
|
||||||
|
(entry.retainedTail !== undefined && !Array.isArray(entry.retainedTail))
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid compaction payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "branch_summary":
|
||||||
|
if (typeof entry.fromId !== "string" || typeof entry.summary !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid branch_summary payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "custom":
|
||||||
|
if (typeof entry.customType !== "string") throw invalidEntry(`entry ${entry.id} has invalid custom payload`);
|
||||||
|
break;
|
||||||
|
case "custom_message":
|
||||||
|
if (
|
||||||
|
typeof entry.customType !== "string" ||
|
||||||
|
typeof entry.display !== "boolean" ||
|
||||||
|
!(typeof entry.content === "string" || isTextImageContentArray(entry.content))
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid custom_message payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "label":
|
||||||
|
if (typeof entry.targetId !== "string" || (entry.label !== undefined && typeof entry.label !== "string")) {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid label payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "session_info":
|
||||||
|
if (entry.name !== undefined && typeof entry.name !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid session_info payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "leaf":
|
||||||
|
if (entry.targetId !== null && typeof entry.targetId !== "string") {
|
||||||
|
throw invalidEntry(`entry ${entry.id} has invalid leaf payload`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = entry;
|
||||||
|
throw invalidEntry(`unknown entry type ${(exhaustive as { type?: string }).type ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToPayload<TEntry extends SessionTreeEntry>(entry: TEntry): EntryPayload<TEntry> {
|
||||||
|
const { type: _type, id: _id, parentId: _parentId, timestamp: _timestamp, ...payload } = entry;
|
||||||
|
return payload as EntryPayload<TEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeEntry(entry: SessionTreeEntry): EncodedEntry {
|
||||||
|
validateSessionTreeEntry(entry);
|
||||||
|
return { payload: JSON.stringify(entryToPayload(entry)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeEntry(row: SessionEntryRow): SessionTreeEntry {
|
||||||
|
const payload = parsePayload(row);
|
||||||
|
if (!isRecord(payload)) throw invalidEntry(`entry ${row.id} payload is not an object`);
|
||||||
|
const base = {
|
||||||
|
id: row.id,
|
||||||
|
parentId: row.parent_id,
|
||||||
|
timestamp: row.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (row.type) {
|
||||||
|
case "message": {
|
||||||
|
if (!("message" in payload)) throw invalidEntry(`entry ${row.id} is missing message payload`);
|
||||||
|
const messagePayload = payload as MessagePayload;
|
||||||
|
return { ...base, type: "message", ...messagePayload };
|
||||||
|
}
|
||||||
|
case "thinking_level_change":
|
||||||
|
if (typeof payload.thinkingLevel !== "string") throw invalidEntry(`entry ${row.id} is missing thinkingLevel`);
|
||||||
|
return { ...base, type: "thinking_level_change", ...(payload as ThinkingLevelChangePayload) };
|
||||||
|
case "model_change":
|
||||||
|
if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid model_change payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "model_change", ...(payload as ModelChangePayload) };
|
||||||
|
case "active_tools_change":
|
||||||
|
if (
|
||||||
|
!Array.isArray(payload.activeToolNames) ||
|
||||||
|
payload.activeToolNames.some((value) => typeof value !== "string")
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid active_tools_change payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "active_tools_change", ...(payload as ActiveToolsChangePayload) };
|
||||||
|
case "compaction":
|
||||||
|
if (
|
||||||
|
typeof payload.summary !== "string" ||
|
||||||
|
typeof payload.firstKeptEntryId !== "string" ||
|
||||||
|
typeof payload.tokensBefore !== "number" ||
|
||||||
|
(payload.retainedTail !== undefined && !Array.isArray(payload.retainedTail))
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid compaction payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "compaction", ...(payload as CompactionPayload) };
|
||||||
|
case "branch_summary":
|
||||||
|
if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid branch_summary payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "branch_summary", ...(payload as BranchSummaryPayload) };
|
||||||
|
case "custom":
|
||||||
|
if (typeof payload.customType !== "string") throw invalidEntry(`entry ${row.id} has invalid custom payload`);
|
||||||
|
return { ...base, type: "custom", ...(payload as CustomPayload) };
|
||||||
|
case "custom_message":
|
||||||
|
if (
|
||||||
|
typeof payload.customType !== "string" ||
|
||||||
|
typeof payload.display !== "boolean" ||
|
||||||
|
!("content" in payload)
|
||||||
|
) {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid custom_message payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "custom_message", ...(payload as CustomMessagePayload) };
|
||||||
|
case "label":
|
||||||
|
if (typeof payload.targetId !== "string") throw invalidEntry(`entry ${row.id} has invalid label payload`);
|
||||||
|
if (payload.label !== undefined && typeof payload.label !== "string") {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid label payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "label", ...(payload as LabelPayload) };
|
||||||
|
case "session_info":
|
||||||
|
if (payload.name !== undefined && typeof payload.name !== "string") {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid session_info payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "session_info", ...(payload as SessionInfoPayload) };
|
||||||
|
case "leaf":
|
||||||
|
if (payload.targetId !== null && typeof payload.targetId !== "string") {
|
||||||
|
throw invalidEntry(`entry ${row.id} has invalid leaf payload`);
|
||||||
|
}
|
||||||
|
return { ...base, type: "leaf", ...(payload as LeafPayload) };
|
||||||
|
default:
|
||||||
|
throw invalidEntry(`unknown entry type ${row.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import type { SessionStats, SessionTreeEntry, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
|
import { invalidSession, isRecord } from "./shared.ts";
|
||||||
|
|
||||||
|
export interface SessionMaterializedRow {
|
||||||
|
session_id: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryMaterializedRow {
|
||||||
|
session_id: string;
|
||||||
|
entry_seq: number;
|
||||||
|
type: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelThinkingConfig {
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMaterializedState {
|
||||||
|
name: string | undefined;
|
||||||
|
messageCount: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
uncachedTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costTotal: number;
|
||||||
|
labelsById: Map<string, string>;
|
||||||
|
modelThinkingConfigs: ModelThinkingConfig[];
|
||||||
|
currentModel: { provider: string; modelId: string } | null;
|
||||||
|
currentThinkingLevel: ThinkingLevel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionMaterializedSummary {
|
||||||
|
name?: string;
|
||||||
|
messageCount: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
uncachedTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costTotal: number;
|
||||||
|
currentModel?: { provider: string; modelId: string } | null;
|
||||||
|
currentThinkingLevel?: ThinkingLevel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareModelThinkingConfig(left: ModelThinkingConfig, right: ModelThinkingConfig): number {
|
||||||
|
return (
|
||||||
|
left.provider.localeCompare(right.provider) ||
|
||||||
|
left.modelId.localeCompare(right.modelId) ||
|
||||||
|
left.thinkingLevel.localeCompare(right.thinkingLevel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelThinkingConfigs(configs: readonly ModelThinkingConfig[]): ModelThinkingConfig[] {
|
||||||
|
const unique = new Map<string, ModelThinkingConfig>();
|
||||||
|
for (const config of configs) {
|
||||||
|
unique.set(`${config.provider}\u0000${config.modelId}\u0000${config.thinkingLevel}`, config);
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort(compareModelThinkingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModelThinkingConfig(
|
||||||
|
state: SessionMaterializedState,
|
||||||
|
provider: string,
|
||||||
|
modelId: string,
|
||||||
|
thinkingLevel: ThinkingLevel,
|
||||||
|
): void {
|
||||||
|
state.modelThinkingConfigs = normalizeModelThinkingConfigs([
|
||||||
|
...state.modelThinkingConfigs,
|
||||||
|
{ provider, modelId, thinkingLevel },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
||||||
|
return (
|
||||||
|
value === "off" ||
|
||||||
|
value === "minimal" ||
|
||||||
|
value === "low" ||
|
||||||
|
value === "medium" ||
|
||||||
|
value === "high" ||
|
||||||
|
value === "xhigh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantUsage(message: unknown):
|
||||||
|
| {
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
costTotal: number;
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
if (!isRecord(message) || message.role !== "assistant") return undefined;
|
||||||
|
if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined;
|
||||||
|
if (!isRecord(message.usage) || !isRecord(message.usage.cost)) return undefined;
|
||||||
|
const { input, output, cacheRead, cacheWrite } = message.usage;
|
||||||
|
const costTotal = message.usage.cost.total;
|
||||||
|
if (
|
||||||
|
typeof input !== "number" ||
|
||||||
|
typeof output !== "number" ||
|
||||||
|
typeof cacheRead !== "number" ||
|
||||||
|
typeof cacheWrite !== "number" ||
|
||||||
|
typeof costTotal !== "number"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: message.provider,
|
||||||
|
modelId: message.model,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
costTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyMaterializedState(): SessionMaterializedState {
|
||||||
|
return {
|
||||||
|
name: undefined,
|
||||||
|
messageCount: 0,
|
||||||
|
cachedTokens: 0,
|
||||||
|
uncachedTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
costTotal: 0,
|
||||||
|
labelsById: new Map<string, string>(),
|
||||||
|
modelThinkingConfigs: [],
|
||||||
|
currentModel: null,
|
||||||
|
currentThinkingLevel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyEntryToMaterializedState(state: SessionMaterializedState, entry: SessionTreeEntry): void {
|
||||||
|
switch (entry.type) {
|
||||||
|
case "session_info":
|
||||||
|
state.name = entry.name?.trim() || undefined;
|
||||||
|
break;
|
||||||
|
case "label": {
|
||||||
|
const label = entry.label?.trim();
|
||||||
|
if (label) {
|
||||||
|
state.labelsById.set(entry.targetId, label);
|
||||||
|
} else {
|
||||||
|
state.labelsById.delete(entry.targetId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "model_change":
|
||||||
|
state.currentModel = { provider: entry.provider, modelId: entry.modelId };
|
||||||
|
if (state.currentThinkingLevel) {
|
||||||
|
addModelThinkingConfig(state, entry.provider, entry.modelId, state.currentThinkingLevel);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "thinking_level_change":
|
||||||
|
if (!isThinkingLevel(entry.thinkingLevel)) break;
|
||||||
|
state.currentThinkingLevel = entry.thinkingLevel;
|
||||||
|
if (state.currentModel) {
|
||||||
|
addModelThinkingConfig(state, state.currentModel.provider, state.currentModel.modelId, entry.thinkingLevel);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "message": {
|
||||||
|
state.messageCount += 1;
|
||||||
|
const usage = getAssistantUsage(entry.message);
|
||||||
|
if (!usage) break;
|
||||||
|
state.cachedTokens += usage.cacheRead;
|
||||||
|
state.uncachedTokens += usage.input + usage.cacheWrite;
|
||||||
|
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||||
|
state.costTotal += usage.costTotal;
|
||||||
|
state.currentModel = { provider: usage.provider, modelId: usage.modelId };
|
||||||
|
if (state.currentThinkingLevel) {
|
||||||
|
addModelThinkingConfig(state, usage.provider, usage.modelId, state.currentThinkingLevel);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "compaction":
|
||||||
|
case "branch_summary": {
|
||||||
|
const usage = entry.usage;
|
||||||
|
if (
|
||||||
|
!isRecord(usage) ||
|
||||||
|
!isRecord(usage.cost) ||
|
||||||
|
typeof usage.input !== "number" ||
|
||||||
|
typeof usage.output !== "number" ||
|
||||||
|
typeof usage.cacheRead !== "number" ||
|
||||||
|
typeof usage.cacheWrite !== "number" ||
|
||||||
|
typeof usage.cost.total !== "number"
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
state.cachedTokens += usage.cacheRead;
|
||||||
|
state.uncachedTokens += usage.input + usage.cacheWrite;
|
||||||
|
state.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||||
|
state.costTotal += usage.cost.total;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "active_tools_change":
|
||||||
|
case "custom":
|
||||||
|
case "custom_message":
|
||||||
|
case "leaf":
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = entry;
|
||||||
|
void exhaustive;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSummary(state: SessionMaterializedState): string {
|
||||||
|
const summary: SessionMaterializedSummary = {
|
||||||
|
name: state.name,
|
||||||
|
messageCount: state.messageCount,
|
||||||
|
cachedTokens: state.cachedTokens,
|
||||||
|
uncachedTokens: state.uncachedTokens,
|
||||||
|
totalTokens: state.totalTokens,
|
||||||
|
costTotal: state.costTotal,
|
||||||
|
currentModel: state.currentModel,
|
||||||
|
currentThinkingLevel: state.currentThinkingLevel,
|
||||||
|
};
|
||||||
|
return JSON.stringify(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummary(json: string): SessionMaterializedSummary {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch (error) {
|
||||||
|
throw invalidSession(
|
||||||
|
`materialized session summary is not valid JSON`,
|
||||||
|
error instanceof Error ? error : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
||||||
|
throw invalidSession("materialized session summary is not an object");
|
||||||
|
}
|
||||||
|
const currentModel = parsed.currentModel;
|
||||||
|
const currentThinkingLevel = parsed.currentThinkingLevel;
|
||||||
|
if (
|
||||||
|
(parsed.name !== undefined && typeof parsed.name !== "string") ||
|
||||||
|
typeof parsed.messageCount !== "number" ||
|
||||||
|
typeof parsed.cachedTokens !== "number" ||
|
||||||
|
typeof parsed.uncachedTokens !== "number" ||
|
||||||
|
typeof parsed.totalTokens !== "number" ||
|
||||||
|
typeof parsed.costTotal !== "number" ||
|
||||||
|
(currentModel !== undefined &&
|
||||||
|
currentModel !== null &&
|
||||||
|
(!isRecord(currentModel) ||
|
||||||
|
typeof currentModel.provider !== "string" ||
|
||||||
|
typeof currentModel.modelId !== "string")) ||
|
||||||
|
(currentThinkingLevel !== undefined && currentThinkingLevel !== null && !isThinkingLevel(currentThinkingLevel))
|
||||||
|
) {
|
||||||
|
throw invalidSession("materialized session summary has invalid fields");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: parsed.name?.trim() || undefined,
|
||||||
|
messageCount: parsed.messageCount,
|
||||||
|
cachedTokens: parsed.cachedTokens,
|
||||||
|
uncachedTokens: parsed.uncachedTokens,
|
||||||
|
totalTokens: parsed.totalTokens,
|
||||||
|
costTotal: parsed.costTotal,
|
||||||
|
currentModel:
|
||||||
|
currentModel && isRecord(currentModel)
|
||||||
|
? { provider: currentModel.provider as string, modelId: currentModel.modelId as string }
|
||||||
|
: (currentModel ?? undefined),
|
||||||
|
currentThinkingLevel: (currentThinkingLevel as ThinkingLevel | null | undefined) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntryMaterializedPayload(row: EntryMaterializedRow): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw invalidSession(
|
||||||
|
`materialized entry row ${row.entry_seq} is not valid JSON`,
|
||||||
|
error instanceof Error ? error : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializedStateFromRows(
|
||||||
|
summaryRow: SessionMaterializedRow,
|
||||||
|
entryRows: EntryMaterializedRow[],
|
||||||
|
): SessionMaterializedState {
|
||||||
|
const summary = parseSummary(summaryRow.payload);
|
||||||
|
const state: SessionMaterializedState = {
|
||||||
|
name: summary.name,
|
||||||
|
messageCount: summary.messageCount,
|
||||||
|
cachedTokens: summary.cachedTokens,
|
||||||
|
uncachedTokens: summary.uncachedTokens,
|
||||||
|
totalTokens: summary.totalTokens,
|
||||||
|
costTotal: summary.costTotal,
|
||||||
|
labelsById: new Map<string, string>(),
|
||||||
|
modelThinkingConfigs: [],
|
||||||
|
currentModel: summary.currentModel ?? null,
|
||||||
|
currentThinkingLevel: summary.currentThinkingLevel ?? null,
|
||||||
|
};
|
||||||
|
for (const row of entryRows) {
|
||||||
|
const payload = parseEntryMaterializedPayload(row);
|
||||||
|
if (!isRecord(payload)) throw invalidSession(`materialized entry row ${row.entry_seq} is not an object`);
|
||||||
|
if (row.type === "label") {
|
||||||
|
if (typeof payload.targetId !== "string") {
|
||||||
|
throw invalidSession(`materialized label row ${row.entry_seq} is missing targetId`);
|
||||||
|
}
|
||||||
|
if (payload.label !== null && payload.label !== undefined && typeof payload.label !== "string") {
|
||||||
|
throw invalidSession(`materialized label row ${row.entry_seq} has invalid label`);
|
||||||
|
}
|
||||||
|
const label = typeof payload.label === "string" ? payload.label.trim() : "";
|
||||||
|
if (label) {
|
||||||
|
state.labelsById.set(payload.targetId, label);
|
||||||
|
} else {
|
||||||
|
state.labelsById.delete(payload.targetId);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.type !== "label") {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionStatsFromMaterializedState(state: SessionMaterializedState): SessionStats {
|
||||||
|
return {
|
||||||
|
messageCount: state.messageCount,
|
||||||
|
cachedTokens: state.cachedTokens,
|
||||||
|
uncachedTokens: state.uncachedTokens,
|
||||||
|
totalTokens: state.totalTokens,
|
||||||
|
costTotal: state.costTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializedStateValues(
|
||||||
|
sessionId: string,
|
||||||
|
state: SessionMaterializedState,
|
||||||
|
): [sessionId: string, payload: string] {
|
||||||
|
return [sessionId, serializeSummary(state)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryMaterializedValues(
|
||||||
|
entry: SessionTreeEntry,
|
||||||
|
): Array<{ type: EntryMaterializedRow["type"]; payload: string }> {
|
||||||
|
switch (entry.type) {
|
||||||
|
case "label":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "label",
|
||||||
|
payload: JSON.stringify({ targetId: entry.targetId, label: entry.label ?? null }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "model_change":
|
||||||
|
case "thinking_level_change":
|
||||||
|
case "message":
|
||||||
|
return [];
|
||||||
|
case "active_tools_change":
|
||||||
|
case "branch_summary":
|
||||||
|
case "compaction":
|
||||||
|
case "custom":
|
||||||
|
case "custom_message":
|
||||||
|
case "leaf":
|
||||||
|
case "session_info":
|
||||||
|
return [];
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = entry;
|
||||||
|
void exhaustive;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { SqliteDatabase } from "../types.ts";
|
||||||
|
import { invalidSession } from "./shared.ts";
|
||||||
|
|
||||||
|
export async function getNextSequence(db: SqliteDatabase, sessionId: string): Promise<number> {
|
||||||
|
const sequenceRow = await db
|
||||||
|
.prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?")
|
||||||
|
.get<{ next_seq: number }>(sessionId);
|
||||||
|
if (!sequenceRow) {
|
||||||
|
throw invalidSession(`missing sequence row for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
return sequenceRow.next_seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function advanceSequence(db: SqliteDatabase, sessionId: string, nextSeq: number): Promise<void> {
|
||||||
|
await db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq + 1, sessionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { SqliteSessionMetadata } from "../types.ts";
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
metadata: string | null;
|
||||||
|
cwd: string;
|
||||||
|
parent_session_id: string | null;
|
||||||
|
active_leaf_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(metadata: string | null, sessionId: string): Record<string, unknown> | undefined {
|
||||||
|
if (metadata === null) return undefined;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
throw new SessionError(
|
||||||
|
"invalid_session",
|
||||||
|
`Invalid SQLite session ${sessionId}: metadata is not valid JSON`,
|
||||||
|
error instanceof Error ? error : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new SessionError("invalid_session", `Invalid SQLite session ${sessionId}: metadata must be an object`);
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToMetadata(row: SessionRow, path: string): SqliteSessionMetadata {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
cwd: row.cwd,
|
||||||
|
path,
|
||||||
|
parentSessionId: row.parent_session_id ?? undefined,
|
||||||
|
metadata: parseMetadata(row.metadata, row.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||||
|
import { SessionError } from "@earendil-works/pi-agent-core";
|
||||||
|
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
|
export function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
|
||||||
|
// so short ids must come from the random tail.
|
||||||
|
const id = uuidv7().slice(0, 8);
|
||||||
|
if (!byId.has(id)) return id;
|
||||||
|
}
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidSession(message: string, cause?: Error): SessionError {
|
||||||
|
return new SessionError("invalid_session", `Invalid SQLite session: ${message}`, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidEntry(message: string, cause?: Error): SessionError {
|
||||||
|
return new SessionError("invalid_entry", `Invalid SQLite session entry: ${message}`, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||||
|
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
|
||||||
|
|
||||||
|
/** Result of a prepared SQLite statement execution. */
|
||||||
|
export interface SqliteRunResult {
|
||||||
|
/** Number of rows changed by the statement. */
|
||||||
|
changes: number;
|
||||||
|
/** Inserted row id when the backend exposes one. */
|
||||||
|
lastInsertRowid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepared SQLite statement capability used by the SQLite session backend. */
|
||||||
|
export interface SqliteStatement {
|
||||||
|
run(...params: unknown[]): Promise<SqliteRunResult>;
|
||||||
|
get<TRow extends object>(...params: unknown[]): Promise<TRow | undefined>;
|
||||||
|
all<TRow extends object>(...params: unknown[]): Promise<TRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQLite database capability used by the SQLite session backend. */
|
||||||
|
export interface SqliteDatabase {
|
||||||
|
exec(sql: string): Promise<void>;
|
||||||
|
prepare(sql: string): SqliteStatement;
|
||||||
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteDatabaseFactory {
|
||||||
|
open(path: string): Promise<SqliteDatabase>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteSessionMetadata extends SessionMetadata {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteSessionCreateOptions extends SessionCreateOptions {
|
||||||
|
cwd: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteSessionListOptions {
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteSessionBackendOptions {
|
||||||
|
kind: "sqlite";
|
||||||
|
databasePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteSessionRepoApi
|
||||||
|
extends SessionRepo<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
|
||||||
|
|
||||||
|
export type SqliteSessionRepoEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": false,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor, avoiding a duplicate cursor artifact ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)).
|
||||||
|
- Fixed ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles across lines ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)).
|
||||||
- Fixed editor paste registry corruption when deleting paste markers: undo now restores the paste registry together with the text, and marker renumbering shifts registry entries in ascending id order, so submitted prompts no longer contain literal `[paste #N ...]` markers or the wrong paste's content ([#6844](https://github.com/earendil-works/pi/issues/6844)).
|
- Fixed editor paste registry corruption when deleting paste markers: undo now restores the paste registry together with the text, and marker renumbering shifts registry entries in ascending id order, so submitted prompts no longer contain literal `[paste #N ...]` markers or the wrong paste's content ([#6844](https://github.com/earendil-works/pi/issues/6844)).
|
||||||
|
|
||||||
## [0.80.10] - 2026-07-16
|
## [0.80.10] - 2026-07-16
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-tui",
|
"name": "@earendil-works/pi-tui",
|
||||||
"version": "0.80.10",
|
"version": "0.81.0",
|
||||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const packages = [
|
|||||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||||
|
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -217,15 +218,15 @@ if (!options.skipCheck) {
|
|||||||
run("npm", ["run", "check"], { cwd: repoRoot });
|
run("npm", ["run", "check"], { cwd: repoRoot });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.skipTest) {
|
|
||||||
run("./test.sh", [], { cwd: repoRoot });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
run("npm", ["run", "clean"], { cwd: pkg.directory });
|
run("npm", ["run", "clean"], { cwd: pkg.directory });
|
||||||
run("npm", ["run", pkg.directory === "packages/ai" ? "build:offline" : "build"], { cwd: pkg.directory });
|
run("npm", ["run", pkg.directory === "packages/ai" ? "build:offline" : "build"], { cwd: pkg.directory });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options.skipTest) {
|
||||||
|
run("./test.sh", [], { cwd: repoRoot });
|
||||||
|
}
|
||||||
|
|
||||||
const tarballs = new Map();
|
const tarballs = new Map();
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
const tarball = packPackage(pkg, tarballDirectory);
|
const tarball = packPackage(pkg, tarballDirectory);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const SKIPPED_DIRECTORIES = new Set(["dist", "node_modules"]);
|
||||||
|
|
||||||
|
export function findPackageDirectories(root = "packages") {
|
||||||
|
const packageDirectories = [];
|
||||||
|
|
||||||
|
function visit(directory) {
|
||||||
|
if (existsSync(join(directory, "package.json"))) {
|
||||||
|
packageDirectories.push(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visit(join(directory, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(root);
|
||||||
|
return packageDirectories.sort();
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|||||||
const packages = [
|
const packages = [
|
||||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||||
|
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||||
];
|
];
|
||||||
|
|||||||
+49
-10
@@ -18,9 +18,10 @@
|
|||||||
* 9. Push main and the tag to trigger CI publishing
|
* 9. Push main and the tag to trigger CI publishing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs";
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "path";
|
import { join } from "node:path";
|
||||||
|
import { findPackageDirectories } from "./package-workspaces.mjs";
|
||||||
|
|
||||||
const RELEASE_TARGET = process.argv[2];
|
const RELEASE_TARGET = process.argv[2];
|
||||||
const BUMP_TYPES = new Set(["major", "minor", "patch"]);
|
const BUMP_TYPES = new Set(["major", "minor", "patch"]);
|
||||||
@@ -67,6 +68,36 @@ function shellQuote(value) {
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeStaleWorkspaceLockEntries() {
|
||||||
|
const workspaceVersions = new Map(
|
||||||
|
findPackageDirectories()
|
||||||
|
.map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8")))
|
||||||
|
.filter((pkg) => pkg.private !== true)
|
||||||
|
.map((pkg) => [pkg.name, pkg.version]),
|
||||||
|
);
|
||||||
|
const lockPath = "package-lock.json";
|
||||||
|
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const [path, pkg] of Object.entries(lock.packages)) {
|
||||||
|
if (!path.startsWith("packages/") || pkg.link === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [name, version] of workspaceVersions) {
|
||||||
|
if (path.endsWith(`/node_modules/${name}`) && pkg.version !== version) {
|
||||||
|
delete lock.packages[path];
|
||||||
|
removed++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
writeFileSync(lockPath, `${JSON.stringify(lock, null, "\t")}\n`);
|
||||||
|
console.log(`Removed ${removed} stale workspace package lock ${removed === 1 ? "entry" : "entries"}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stageChangedFiles() {
|
function stageChangedFiles() {
|
||||||
const output = run("git ls-files -m -o -d --exclude-standard", { silent: true });
|
const output = run("git ls-files -m -o -d --exclude-standard", { silent: true });
|
||||||
const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))];
|
const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))];
|
||||||
@@ -83,9 +114,7 @@ function bumpOrSetVersion(target) {
|
|||||||
if (BUMP_TYPES.has(target)) {
|
if (BUMP_TYPES.has(target)) {
|
||||||
console.log(`Bumping version (${target})...`);
|
console.log(`Bumping version (${target})...`);
|
||||||
run(`npm run version:${target}`);
|
run(`npm run version:${target}`);
|
||||||
return getVersion();
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
if (compareVersions(target, currentVersion) <= 0) {
|
if (compareVersions(target, currentVersion) <= 0) {
|
||||||
console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`);
|
console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -93,14 +122,20 @@ function bumpOrSetVersion(target) {
|
|||||||
|
|
||||||
console.log(`Setting explicit version (${target})...`);
|
console.log(`Setting explicit version (${target})...`);
|
||||||
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`);
|
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// npm version can temporarily install the previous workspace versions before
|
||||||
|
// sync-versions updates inter-package ranges. Remove those stale lock entries,
|
||||||
|
// refresh the lockfile, then hydrate from the final dependency graph.
|
||||||
|
removeStaleWorkspaceLockEntries();
|
||||||
|
run("npm install --package-lock-only --ignore-scripts");
|
||||||
|
run("npm ci --ignore-scripts");
|
||||||
return getVersion();
|
return getVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChangelogs() {
|
function getChangelogs() {
|
||||||
const packagesDir = "packages";
|
return findPackageDirectories()
|
||||||
const packages = readdirSync(packagesDir);
|
.map((directory) => join(directory, "CHANGELOG.md"))
|
||||||
return packages
|
|
||||||
.map((pkg) => join(packagesDir, pkg, "CHANGELOG.md"))
|
|
||||||
.filter((path) => existsSync(path));
|
.filter((path) => existsSync(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +212,10 @@ console.log("Running checks...");
|
|||||||
run("npm run check");
|
run("npm run check");
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
|
console.log("Building packages for tests...");
|
||||||
|
run("npm run build:offline");
|
||||||
|
console.log();
|
||||||
|
|
||||||
console.log("Running tests...");
|
console.log("Running tests...");
|
||||||
run("./test.sh");
|
run("./test.sh");
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user