diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml deleted file mode 100644 index a7106667b..000000000 --- a/.github/workflows/beta.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: beta - -on: - workflow_dispatch: - schedule: - - cron: "0 * * * *" - -jobs: - sync: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup Git Committer - id: setup-git-committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Install OpenCode - run: bun i -g opencode-ai - - - name: Sync beta branch - env: - GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - run: bun script/beta.ts diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml deleted file mode 100644 index e0e571b46..000000000 --- a/.github/workflows/close-stale-prs.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: close-stale-prs - -on: - workflow_dispatch: - inputs: - dryRun: - description: "Log actions without closing PRs" - type: boolean - default: false - schedule: - - cron: "0 6 * * *" - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-stale-prs: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Close inactive PRs - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const DAYS_INACTIVE = 60 - const MAX_RETRIES = 3 - - // Adaptive delay: fast for small batches, slower for large to respect - // GitHub's 80 content-generating requests/minute limit - const SMALL_BATCH_THRESHOLD = 10 - const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) - const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit - - const startTime = Date.now() - const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) - const { owner, repo } = context.repo - const dryRun = context.payload.inputs?.dryRun === "true" - - core.info(`Dry run mode: ${dryRun}`) - core.info(`Cutoff date: ${cutoff.toISOString()}`) - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) - } - - async function withRetry(fn, description = 'API call') { - let lastError - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - const result = await fn() - return result - } catch (error) { - lastError = error - const isRateLimited = error.status === 403 && - (error.message?.includes('rate limit') || error.message?.includes('secondary')) - - if (!isRateLimited) { - throw error - } - - // Parse retry-after header, default to 60 seconds - const retryAfter = error.response?.headers?.['retry-after'] - ? parseInt(error.response.headers['retry-after']) - : 60 - - // Exponential backoff: retryAfter * 2^attempt - const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) - - core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) - - await sleep(backoffMs) - } - } - core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) - throw lastError - } - - const query = ` - query($owner: String!, $repo: String!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 100, states: OPEN, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - number - title - author { - login - } - createdAt - commits(last: 1) { - nodes { - commit { - committedDate - } - } - } - comments(last: 1) { - nodes { - createdAt - } - } - reviews(last: 1) { - nodes { - createdAt - } - } - } - } - } - } - ` - - const allPrs = [] - let cursor = null - let hasNextPage = true - let pageCount = 0 - - while (hasNextPage) { - pageCount++ - core.info(`Fetching page ${pageCount} of open PRs...`) - - const result = await withRetry( - () => github.graphql(query, { owner, repo, cursor }), - `GraphQL page ${pageCount}` - ) - - allPrs.push(...result.repository.pullRequests.nodes) - hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage - cursor = result.repository.pullRequests.pageInfo.endCursor - - core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) - - // Delay between pagination requests (use small batch delay for reads) - if (hasNextPage) { - await sleep(SMALL_BATCH_DELAY_MS) - } - } - - core.info(`Found ${allPrs.length} open pull requests`) - - const stalePrs = allPrs.filter((pr) => { - const dates = [ - new Date(pr.createdAt), - pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, - pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, - pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, - ].filter((d) => d !== null) - - const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] - - if (!lastActivity || lastActivity > cutoff) { - core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) - return false - } - - core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) - return true - }) - - if (!stalePrs.length) { - core.info("No stale pull requests found.") - return - } - - core.info(`Found ${stalePrs.length} stale pull requests`) - - // ============================================ - // Close stale PRs - // ============================================ - const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD - ? LARGE_BATCH_DELAY_MS - : SMALL_BATCH_DELAY_MS - - core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) - - let closedCount = 0 - let skippedCount = 0 - - for (const pr of stalePrs) { - const issue_number = pr.number - const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` - - if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - continue - } - - try { - // Add comment - await withRetry( - () => github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }), - `Comment on PR #${issue_number}` - ) - - // Close PR - await withRetry( - () => github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }), - `Close PR #${issue_number}` - ) - - closedCount++ - core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) - - // Delay before processing next PR - await sleep(requestDelayMs) - } catch (error) { - skippedCount++ - core.error(`Failed to close PR #${issue_number}: ${error.message}`) - } - } - - const elapsed = Math.round((Date.now() - startTime) / 1000) - core.info(`\n========== Summary ==========`) - core.info(`Total open PRs found: ${allPrs.length}`) - core.info(`Stale PRs identified: ${stalePrs.length}`) - core.info(`PRs closed: ${closedCount}`) - core.info(`PRs skipped (errors): ${skippedCount}`) - core.info(`Elapsed time: ${elapsed}s`) - core.info(`=============================`) diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml deleted file mode 100644 index c3bcf9f68..000000000 --- a/.github/workflows/compliance-close.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: compliance-close - -on: - schedule: - # Run every 30 minutes to check for expired compliance windows - - cron: "*/30 * * * *" - workflow_dispatch: - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-non-compliant: - runs-on: ubuntu-latest - steps: - - name: Close non-compliant issues and PRs after 2 hours - uses: actions/github-script@v7 - with: - script: | - const { data: items } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'needs:compliance', - state: 'open', - per_page: 100, - }); - - if (items.length === 0) { - core.info('No open issues/PRs with needs:compliance label'); - return; - } - - const now = Date.now(); - const twoHours = 2 * 60 * 60 * 1000; - - for (const item of items) { - const isPR = !!item.pull_request; - const kind = isPR ? 'PR' : 'issue'; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - }); - - const complianceComment = comments.find(c => c.body.includes('')); - if (!complianceComment) continue; - - const commentAge = now - new Date(complianceComment.created_at).getTime(); - if (commentAge < twoHours) { - core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`); - continue; - } - - const closeMessage = isPR - ? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.' - : 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - body: closeMessage, - }); - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - name: 'needs:compliance', - }); - } catch (e) {} - - if (isPR) { - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: item.number, - state: 'closed', - }); - } else { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - state: 'closed', - state_reason: 'not_planned', - }); - } - - core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`); - } diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml deleted file mode 100644 index c7df066d4..000000000 --- a/.github/workflows/containers.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: containers - -on: - push: - branches: - - dev - paths: - - packages/containers/** - - .github/workflows/containers.yml - - package.json - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build: - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - REGISTRY: ghcr.io/${{ github.repository_owner }} - TAG: "24.04" - steps: - - uses: actions/checkout@v4 - - - uses: ./.github/actions/setup-bun - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push containers - run: bun ./packages/containers/script/build.ts --push - env: - REGISTRY: ${{ env.REGISTRY }} - TAG: ${{ env.TAG }} diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml deleted file mode 100644 index 31cf08233..000000000 --- a/.github/workflows/daily-issues-recap.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml deleted file mode 100644 index 2f0f023cf..000000000 --- a/.github/workflows/daily-pr-recap.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index c08d7edf3..000000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: deploy - -on: - push: - branches: - - dev - - production - workflow_dispatch: - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - deploy: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v3 - - - uses: ./.github/actions/setup-bun - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - # Workaround for Pulumi version conflict: - # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag - # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065). - # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict. - # Removing the system language plugin forces SST to use its bundled compatible version. - # TODO: Remove when sst supports Pulumi >3.210.0 - - name: Fix Pulumi version conflict - run: sudo rm -f /usr/local/bin/pulumi-language-nodejs - - - run: bun sst deploy --stage=${{ github.ref_name }} - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} - PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} - STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml deleted file mode 100644 index fff2ec429..000000000 --- a/.github/workflows/docs-locale-sync.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: docs-locale-sync - -on: - push: - branches: - - dev - paths: - - packages/web/src/content/docs/*.mdx - -jobs: - sync-locales: - if: github.actor != 'opencode-agent[bot]' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - ref: ${{ github.ref_name }} - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Compute changed English docs - id: changes - run: | - FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true) - if [ -z "$FILES" ]; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No English docs changed in push range" - exit 0 - fi - echo "has_changes=true" >> "$GITHUB_OUTPUT" - { - echo "files<> "$GITHUB_OUTPUT" - - - name: Install OpenCode - if: steps.changes.outputs.has_changes == 'true' - run: curl -fsSL https://opencode.ai/install | bash - - - name: Sync locale docs with OpenCode - if: steps.changes.outputs.has_changes == 'true' - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - OPENCODE_CONFIG_CONTENT: | - { - "permission": { - "*": "deny", - "read": "allow", - "edit": "allow", - "glob": "allow", - "task": "allow" - } - } - run: | - opencode run --agent docs --model opencode/gpt-5.3-codex <<'EOF' - Update localized docs to match the latest English docs changes. - - Changed English doc files: - - ${{ steps.changes.outputs.files }} - - - Requirements: - 1. Update all relevant locale docs under packages/web/src/content/docs// so they reflect these English page changes. - 2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md). - 3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates. - 4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent. - 5. Use only the minimum tools needed for this task (read/glob, file edits, and translator Task). Do not use shell, web, search, or GitHub tools for translation work. - 6. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update. - 7. Keep locale docs structure aligned with their corresponding English pages. - 8. Do not modify English source docs in packages/web/src/content/docs/*.mdx. - 9. If no locale updates are needed, make no changes. - EOF - - - name: Commit and push locale docs updates - if: steps.changes.outputs.has_changes == 'true' - run: | - if [ -z "$(git status --porcelain)" ]; then - echo "No locale docs changes to commit" - exit 0 - fi - git add -A - git commit -m "docs(i18n): sync locale docs from english changes" - git pull --rebase --autostash origin "$GITHUB_REF_NAME" - git push origin HEAD:"$GITHUB_REF_NAME" diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml deleted file mode 100644 index 900ad2b0c..000000000 --- a/.github/workflows/docs-update.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: docs-update - -on: - schedule: - - cron: "0 */12 * * *" - workflow_dispatch: - -env: - LOOKBACK_HOURS: 4 - -jobs: - update-docs: - if: github.repository == 'sst/opencode' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - id-token: write - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch full history to access commits - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Get recent commits - id: commits - run: | - COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "") - if [ -z "$COMMITS" ]; then - echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours" - echo "has_commits=false" >> $GITHUB_OUTPUT - else - echo "has_commits=true" >> $GITHUB_OUTPUT - { - echo "list<> $GITHUB_OUTPUT - fi - - - name: Run opencode - if: steps.commits.outputs.has_commits == 'true' - uses: sst/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - with: - model: opencode/gpt-5.2 - agent: docs - prompt: | - Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation. - - - ${{ steps.commits.outputs.list }} - - - Steps: - 1. For each commit that looks like a new feature or significant change: - - Read the changed files to understand what was added - - Check if the feature is already documented in packages/web/src/content/docs/* - 2. If you find undocumented features: - - Update the relevant documentation files in packages/web/src/content/docs/* - - Follow the existing documentation style and structure - - Make sure to document the feature clearly with examples where appropriate - 3. If all new features are already documented, report that no updates are needed - 4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too. - - Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior. - Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all. - Try to keep documentation only for large features or changes that already have a good spot to be documented. diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml deleted file mode 100644 index 6c1943fe7..000000000 --- a/.github/workflows/duplicate-issues.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: duplicate-issues - -on: - issues: - types: [opened, edited] - -jobs: - check-duplicates: - if: github.event.action == 'opened' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Check duplicates and compliance - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow" - }, - "webfetch": "deny" - } - run: | - opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: - - Issue number: ${{ github.event.issue.number }} - - Lookup this issue with gh issue view ${{ github.event.issue.number }}. - - You have TWO tasks. Perform both, then post a SINGLE comment (if needed). - - --- - - TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK - - Check whether the issue follows our contributing guidelines and issue templates. - - This project has three issue templates that every issue MUST use one of: - - 1. Bug Report - requires a Description field with real content - 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: - 3. Question - requires the Question field with real content - - Additionally check: - - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) - - The issue has real content, not just template placeholder text left unchanged - - Bug reports should include some context about how to reproduce - - Feature requests should explain the problem or need - - We want to push for having the user provide system description & information - - Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. - - --- - - TASK 2: DUPLICATE CHECK - - Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. - Consider: - 1. Similar titles or descriptions - 2. Same error messages or symptoms - 3. Related functionality or components - 4. Similar feature requests - - Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997. - - --- - - POSTING YOUR COMMENT: - - Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - - If the issue is NOT compliant, start the comment with: - - Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance - - If duplicates were found, include a section about potential duplicates with links. - - If the issue mentions keybinds/keyboard shortcuts, include a note about #4997. - - If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all. - - Use this format for the comment: - - [If not compliant:] - - This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). - - **What needs to be fixed:** - - [specific reasons] - - Please edit this issue to address the above within **2 hours**, or it will be automatically closed. - - [If duplicates found, add:] - --- - This issue might be a duplicate of existing issues. Please check: - - #[issue_number]: [brief description of similarity] - - [If keybind-related, add:] - For keybind-related issues, please also check our pinned keybinds documentation: #4997 - - [End with if not compliant:] - If you believe this was flagged incorrectly, please let a maintainer know. - - Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." - - recheck-compliance: - if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Recheck compliance - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow" - }, - "webfetch": "deny" - } - run: | - opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. - - Lookup this issue with gh issue view ${{ github.event.issue.number }}. - - Re-check whether the issue now follows our contributing guidelines and issue templates. - - This project has three issue templates that every issue MUST use one of: - - 1. Bug Report - requires a Description field with real content - 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: - 3. Question - requires the Question field with real content - - Additionally check: - - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) - - The issue has real content, not just template placeholder text left unchanged - - Bug reports should include some context about how to reproduce - - Feature requests should explain the problem or need - - We want to push for having the user provide system description & information - - Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. - - If the issue is NOW compliant: - 1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance - 2. Find and delete the previous compliance comment (the one containing ) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id} - 3. Post a short comment thanking them for updating the issue. - - If the issue is STILL not compliant: - Post a comment explaining what still needs to be fixed. Keep the needs:compliance label." diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml deleted file mode 100644 index 706ab2989..000000000 --- a/.github/workflows/generate.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: generate - -on: - push: - branches: - - dev - -jobs: - generate: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Generate - run: ./script/generate.ts - - - name: Commit and push - run: | - if [ -z "$(git status --porcelain)" ]; then - echo "No changes to commit" - exit 0 - fi - git add -A - git commit -m "chore: generate" --allow-empty - git push origin HEAD:${{ github.ref_name }} --no-verify - # if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then - # echo "" - # echo "============================================" - # echo "Failed to push generated code." - # echo "Please run locally and push:" - # echo "" - # echo " ./script/generate.ts" - # echo " git add -A && git commit -m \"chore: generate\" && git push" - # echo "" - # echo "============================================" - # exit 1 - # fi diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml deleted file mode 100644 index c76b2c972..000000000 --- a/.github/workflows/nix-eval.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: nix-eval - -on: - push: - branches: [dev] - pull_request: - branches: [dev] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - nix-eval: - runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 15 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - - name: Evaluate flake outputs (all systems) - run: | - set -euo pipefail - nix --version - - echo "=== Flake metadata ===" - nix flake metadata - - echo "" - echo "=== Flake structure ===" - nix flake show --all-systems - - SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" - PACKAGES="opencode" - # TODO: move 'desktop' to PACKAGES when #11755 is fixed - OPTIONAL_PACKAGES="desktop" - - echo "" - echo "=== Evaluating packages for all systems ===" - for system in $SYSTEMS; do - echo "" - echo "--- $system ---" - for pkg in $PACKAGES; do - printf " %s: " "$pkg" - if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then - echo "✓" - else - echo "✗" - echo "::error::Evaluation failed for packages.$system.$pkg" - echo "$output" - exit 1 - fi - done - done - - echo "" - echo "=== Evaluating optional packages ===" - for system in $SYSTEMS; do - echo "" - echo "--- $system ---" - for pkg in $OPTIONAL_PACKAGES; do - printf " %s: " "$pkg" - if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then - echo "✓" - else - echo "✗" - echo "::warning::Evaluation failed for packages.$system.$pkg" - echo "$output" - fi - done - done - - echo "" - echo "=== Evaluating devShells for all systems ===" - for system in $SYSTEMS; do - printf "%s: " "$system" - if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then - echo "✓" - else - echo "✗" - echo "::error::Evaluation failed for devShells.$system.default" - echo "$output" - exit 1 - fi - done - - echo "" - echo "=== All evaluations passed ===" diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml deleted file mode 100644 index 2529c14c2..000000000 --- a/.github/workflows/nix-hashes.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: nix-hashes - -permissions: - contents: write - -on: - workflow_dispatch: - push: - branches: [dev, beta] - paths: - - "bun.lock" - - "package.json" - - "packages/*/package.json" - - "flake.lock" - - "nix/node_modules.nix" - - "nix/scripts/**" - - "patches/**" - - ".github/workflows/nix-hashes.yml" - -jobs: - # Native runners required: bun install cross-compilation flags (--os/--cpu) - # do not produce byte-identical node_modules as native installs. - compute-hash: - strategy: - fail-fast: false - matrix: - include: - - system: x86_64-linux - runner: blacksmith-4vcpu-ubuntu-2404 - - system: aarch64-linux - runner: blacksmith-4vcpu-ubuntu-2404-arm - - system: x86_64-darwin - runner: macos-15-intel - - system: aarch64-darwin - runner: macos-latest - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - - name: Compute node_modules hash - id: hash - env: - SYSTEM: ${{ matrix.system }} - run: | - set -euo pipefail - - BUILD_LOG=$(mktemp) - trap 'rm -f "$BUILD_LOG"' EXIT - - # Build with fakeHash to trigger hash mismatch and reveal correct hash - nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true - - # Extract hash from build log with portability - HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" - - if [ -z "$HASH" ]; then - echo "::error::Failed to compute hash for ${SYSTEM}" - cat "$BUILD_LOG" - exit 1 - fi - - echo "$HASH" > hash.txt - echo "Computed hash for ${SYSTEM}: $HASH" - - - name: Upload hash - uses: actions/upload-artifact@v4 - with: - name: hash-${{ matrix.system }} - path: hash.txt - retention-days: 1 - - update-hashes: - needs: compute-hash - if: github.event_name != 'pull_request' - runs-on: blacksmith-4vcpu-ubuntu-2404 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - ref: ${{ github.ref_name }} - - - name: Setup git committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Pull latest changes - run: | - git pull --rebase --autostash origin "$GITHUB_REF_NAME" - - - name: Download hash artifacts - uses: actions/download-artifact@v4 - with: - path: hashes - pattern: hash-* - - - name: Update hashes.json - run: | - set -euo pipefail - - HASH_FILE="nix/hashes.json" - - [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE" - - for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do - FILE="hashes/hash-${SYSTEM}/hash.txt" - if [ -f "$FILE" ]; then - HASH="$(tr -d '[:space:]' < "$FILE")" - echo "${SYSTEM}: ${HASH}" - jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json - mv tmp.json "$HASH_FILE" - else - echo "::warning::Missing hash for ${SYSTEM}" - fi - done - - cat "$HASH_FILE" - - - name: Commit changes - run: | - set -euo pipefail - - HASH_FILE="nix/hashes.json" - - if [ -z "$(git status --short -- "$HASH_FILE")" ]; then - echo "No changes to commit" - echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" - echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - git add "$HASH_FILE" - git commit -m "chore: update nix node_modules hashes" - - git pull --rebase --autostash origin "$GITHUB_REF_NAME" - git push origin HEAD:"$GITHUB_REF_NAME" - - echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" - echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml deleted file mode 100644 index b1d805360..000000000 --- a/.github/workflows/notify-discord.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: notify-discord - -on: - release: - types: [released] # fires when a draft release is published - -jobs: - notify: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Send nicely-formatted embed to Discord - uses: SethCohen/github-releases-to-discord@v1 - with: - webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml deleted file mode 100644 index 76e75fcae..000000000 --- a/.github/workflows/opencode.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: opencode - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - -jobs: - opencode: - if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - id-token: write - contents: read - pull-requests: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: ./.github/actions/setup-bun - - - name: Run opencode - uses: anomalyco/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - OPENCODE_PERMISSION: '{"bash": "deny"}' - with: - model: opencode/claude-opus-4-5 diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml deleted file mode 100644 index 35bd7ae36..000000000 --- a/.github/workflows/pr-management.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: pr-management - -on: - pull_request_target: - types: [opened] - -jobs: - check-duplicates: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Check team membership - id: team-check - run: | - LOGIN="${{ github.event.pull_request.user.login }}" - if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then - echo "is_team=true" >> "$GITHUB_OUTPUT" - echo "Skipping: $LOGIN is a team member or bot" - else - echo "is_team=false" >> "$GITHUB_OUTPUT" - fi - - - name: Setup Bun - if: steps.team-check.outputs.is_team != 'true' - uses: ./.github/actions/setup-bun - - - name: Install dependencies - if: steps.team-check.outputs.is_team != 'true' - run: bun install - - - name: Install opencode - if: steps.team-check.outputs.is_team != 'true' - run: curl -fsSL https://opencode.ai/install | bash - - - name: Build prompt - if: steps.team-check.outputs.is_team != 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - { - echo "Check for duplicate PRs related to this new PR:" - echo "" - echo "CURRENT_PR_NUMBER: $PR_NUMBER" - echo "" - echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)" - echo "" - echo "Description:" - gh pr view "$PR_NUMBER" --json body --jq .body - } > pr_info.txt - - - name: Check for duplicate PRs - if: steps.team-check.outputs.is_team != 'true' - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates") - - if [ "$COMMENT" != "No duplicate PRs found" ]; then - gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_ - - $COMMENT" - fi - - add-contributor-label: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - steps: - - name: Add Contributor Label - uses: actions/github-script@v8 - with: - script: | - const isPR = !!context.payload.pull_request; - const issueNumber = isPR ? context.payload.pull_request.number : context.payload.issue.number; - const authorAssociation = isPR ? context.payload.pull_request.author_association : context.payload.issue.author_association; - - if (authorAssociation === 'CONTRIBUTOR') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['contributor'] - }); - } diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml deleted file mode 100644 index 1edbd5d06..000000000 --- a/.github/workflows/pr-standards.yml +++ /dev/null @@ -1,351 +0,0 @@ -name: pr-standards - -on: - pull_request_target: - types: [opened, edited, synchronize] - -jobs: - check-standards: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check PR standards - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - const login = pr.user.login; - - // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC) - const cutoff = new Date('2026-02-19T00:00:00Z'); - const prCreated = new Date(pr.created_at); - if (prCreated < cutoff) { - console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`); - return; - } - - // Check if author is a team member or bot - if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'dev' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; - } - - const title = pr.title; - - async function addLabel(label) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [label] - }); - } - - async function removeLabel(label) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name: label - }); - } catch (e) { - // Label wasn't present, ignore - } - } - - async function comment(marker, body) { - const markerText = ``; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - - const existing = comments.find(c => c.body.includes(markerText)); - if (existing) return; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: markerText + '\n' + body - }); - } - - // Step 1: Check title format - // Matches: feat:, feat(scope):, feat (scope):, etc. - const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/; - const hasValidTitle = titlePattern.test(title); - - if (!hasValidTitle) { - await addLabel('needs:title'); - await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format. - - Please update it to start with one of: - - \`feat:\` or \`feat(scope):\` new feature - - \`fix:\` or \`fix(scope):\` bug fix - - \`docs:\` or \`docs(scope):\` documentation changes - - \`chore:\` or \`chore(scope):\` maintenance tasks - - \`refactor:\` or \`refactor(scope):\` code refactoring - - \`test:\` or \`test(scope):\` adding or updating tests - - Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`). - - See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`); - return; - } - - await removeLabel('needs:title'); - - // Step 2: Check for linked issue (skip for docs/refactor/feat PRs) - const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); - if (skipIssueCheck) { - await removeLabel('needs:issue'); - console.log('Skipping issue check for docs/refactor/feat PR'); - return; - } - const query = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - closingIssuesReferences(first: 1) { - totalCount - } - } - } - } - `; - - const result = await github.graphql(query, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number - }); - - const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount; - - if (linkedIssues === 0) { - await addLabel('needs:issue'); - await comment('issue', `Thanks for your contribution! - - This PR doesn't have a linked issue. All PRs must reference an existing issue. - - Please: - 1. Open an issue describing the bug/feature (if one doesn't exist) - 2. Add \`Fixes #\` or \`Closes #\` to this PR description - - See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`); - return; - } - - await removeLabel('needs:issue'); - console.log('PR meets all standards'); - - check-compliance: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check PR template compliance - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - const login = pr.user.login; - - // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC) - const cutoff = new Date('2026-02-19T00:00:00Z'); - const prCreated = new Date(pr.created_at); - if (prCreated < cutoff) { - console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`); - return; - } - - // Check if author is a team member or bot - if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'dev' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; - } - - const body = pr.body || ''; - const title = pr.title; - const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); - - const issues = []; - - // Check: template sections exist - const hasWhatSection = /### What does this PR do\?/.test(body); - const hasTypeSection = /### Type of change/.test(body); - const hasVerifySection = /### How did you verify your code works\?/.test(body); - const hasChecklistSection = /### Checklist/.test(body); - const hasIssueSection = /### Issue for this PR/.test(body); - - if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) { - issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).'); - } - - // Check: "What does this PR do?" has real content (not just placeholder text) - if (hasWhatSection) { - const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/); - const whatContent = whatMatch ? whatMatch[1].trim() : ''; - const placeholder = 'Please provide a description of the issue'; - const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20; - if (!whatContent || onlyPlaceholder) { - issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.'); - } - } - - // Check: at least one "Type of change" checkbox is checked - if (hasTypeSection) { - const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/); - const typeContent = typeMatch ? typeMatch[1] : ''; - const hasCheckedBox = /- \[x\]/i.test(typeContent); - if (!hasCheckedBox) { - issues.push('No "Type of change" checkbox is checked. Please select at least one.'); - } - } - - // Check: issue reference (skip for docs/refactor/feat) - if (!isDocsRefactorOrFeat && hasIssueSection) { - const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/); - const issueContent = issueMatch ? issueMatch[1].trim() : ''; - const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent); - if (!hasIssueRef) { - issues.push('No issue referenced. Please add `Closes #` linking to the relevant issue.'); - } - } - - // Check: "How did you verify" has content - if (hasVerifySection) { - const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/); - const verifyContent = verifyMatch ? verifyMatch[1].trim() : ''; - if (!verifyContent) { - issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.'); - } - } - - // Check: checklist boxes are checked - if (hasChecklistSection) { - const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/); - const checklistContent = checklistMatch ? checklistMatch[1] : ''; - const unchecked = (checklistContent.match(/- \[ \]/g) || []).length; - const checked = (checklistContent.match(/- \[x\]/gi) || []).length; - if (checked < 2) { - issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.'); - } - } - - // Helper functions - async function addLabel(label) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [label] - }); - } - - async function removeLabel(label) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name: label - }); - } catch (e) {} - } - - const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance'); - - if (issues.length > 0) { - // Non-compliant - if (!hasComplianceLabel) { - await addLabel('needs:compliance'); - } - - const marker = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - const existing = comments.find(c => c.body.includes(marker)); - - const body_text = `${marker} - This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md). - - **What needs to be fixed:** - ${issues.map(i => `- ${i}`).join('\n')} - - Please edit this PR description to address the above within **2 hours**, or it will be automatically closed. - - If you believe this was flagged incorrectly, please let a maintainer know.`; - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: body_text - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: body_text - }); - } - - console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`); - } else if (hasComplianceLabel) { - // Was non-compliant, now fixed - await removeLabel('needs:compliance'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - const marker = ''; - const existing = comments.find(c => c.body.includes(marker)); - if (existing) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id - }); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:' - }); - - console.log(`PR #${pr.number} is now compliant, label removed`); - } else { - console.log(`PR #${pr.number} is compliant`); - } diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml deleted file mode 100644 index d2789373a..000000000 --- a/.github/workflows/publish-github-action.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: publish-github-action - -on: - workflow_dispatch: - push: - tags: - - "github-v*.*.*" - - "!github-v1" - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -permissions: - contents: write - -jobs: - publish: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - run: git fetch --force --tags - - - name: Publish - run: | - git config --global user.email "opencode@sst.dev" - git config --global user.name "opencode" - ./script/publish - working-directory: ./github diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml deleted file mode 100644 index f49a10578..000000000 --- a/.github/workflows/publish-vscode.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: publish-vscode - -on: - workflow_dispatch: - push: - tags: - - "vscode-v*.*.*" - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -permissions: - contents: write - -jobs: - publish: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup-bun - - - run: git fetch --force --tags - - run: bun install -g @vscode/vsce - - - name: Install extension dependencies - run: bun install - working-directory: ./sdks/vscode - - - name: Publish - run: | - ./script/publish - working-directory: ./sdks/vscode - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index b425b32a5..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,447 +0,0 @@ -name: publish -run-name: "${{ format('release {0}', inputs.bump) }}" - -on: - push: - branches: - - ci - - dev - - beta - - snapshot-* - workflow_dispatch: - inputs: - bump: - description: "Bump major, minor, or patch" - required: false - type: choice - options: - - major - - minor - - patch - version: - description: "Override version (optional)" - required: false - type: string - -concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} - -permissions: - id-token: write - contents: write - packages: write - -jobs: - version: - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.repository == 'anomalyco/opencode' - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup-bun - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Install OpenCode - if: inputs.bump || inputs.version - run: bun i -g opencode-ai - - - id: version - run: | - ./script/version.ts - env: - GH_TOKEN: ${{ steps.committer.outputs.token }} - OPENCODE_BUMP: ${{ inputs.bump }} - OPENCODE_VERSION: ${{ inputs.version }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }} - outputs: - version: ${{ steps.version.outputs.version }} - release: ${{ steps.version.outputs.release }} - tag: ${{ steps.version.outputs.tag }} - repo: ${{ steps.version.outputs.repo }} - - build-cli: - needs: version - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.repository == 'anomalyco/opencode' - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: ./.github/actions/setup-bun - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build - id: build - run: | - ./packages/opencode/script/build.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - OPENCODE_RELEASE: ${{ needs.version.outputs.release }} - GH_REPO: ${{ needs.version.outputs.repo }} - GH_TOKEN: ${{ steps.committer.outputs.token }} - - - uses: actions/upload-artifact@v4 - with: - name: opencode-cli - path: packages/opencode/dist - outputs: - version: ${{ needs.version.outputs.version }} - - build-tauri: - needs: - - build-cli - - version - continue-on-error: false - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - - host: macos-latest - target: aarch64-apple-darwin - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: windows-2025 - target: aarch64-pc-windows-msvc - - host: blacksmith-4vcpu-windows-2025 - target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 - target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ runner.os == 'macOS' }} - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Verify Certificate - if: ${{ runner.os == 'macOS' }} - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - - name: Setup Apple API Key - if: ${{ runner.os == 'macOS' }} - run: | - echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt- - - - name: install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - sudo chmod -R a+rw ~/apt-cache - - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.settings.target }} - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/desktop/src-tauri - shared-key: ${{ matrix.settings.target }} - - - name: Prepare - run: | - cd packages/desktop - bun ./scripts/prepare.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Resolve tauri portable SHA - if: contains(matrix.settings.host, 'ubuntu') - run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" - - # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - name: Install tauri-cli from portable appimage branch - uses: taiki-e/cache-cargo-install-action@v3 - if: contains(matrix.settings.host, 'ubuntu') - with: - tool: tauri-cli - git: https://github.com/tauri-apps/tauri - # branch: feat/truly-portable-appimage - rev: ${{ env.TAURI_PORTABLE_SHA }} - - - name: Show tauri-cli version - if: contains(matrix.settings.host, 'ubuntu') - run: cargo tauri --version - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build and upload artifacts - uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a - timeout-minutes: 60 - with: - projectPath: packages/desktop - uploadWorkflowArtifacts: true - tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} - args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose - updaterJsonPreferNsis: true - releaseId: ${{ needs.version.outputs.release }} - tagName: ${{ needs.version.outputs.tag }} - releaseDraft: true - releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] - repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }} - releaseCommitish: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - - build-electron: - needs: - - build-cli - - version - continue-on-error: false - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - platform_flag: --mac --x64 - - host: macos-latest - target: aarch64-apple-darwin - platform_flag: --mac --arm64 - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: "windows-2025" - target: aarch64-pc-windows-msvc - platform_flag: --win --arm64 - - host: "blacksmith-4vcpu-windows-2025" - target: x86_64-pc-windows-msvc - platform_flag: --win - - host: "blacksmith-4vcpu-ubuntu-2404" - target: x86_64-unknown-linux-gnu - platform_flag: --linux - - host: "blacksmith-4vcpu-ubuntu-2404" - target: aarch64-unknown-linux-gnu - platform_flag: --linux - runs-on: ${{ matrix.settings.host }} - # if: github.ref_name == 'beta' - steps: - - uses: actions/checkout@v3 - - - uses: apple-actions/import-codesign-certs@v2 - if: runner.os == 'macOS' - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Setup Apple API Key - if: runner.os == 'macOS' - run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron- - - - name: Install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm - sudo chmod -R a+rw ~/apt-cache - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Prepare - run: bun ./scripts/prepare.ts - working-directory: packages/desktop-electron - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Build - run: bun run build - working-directory: packages/desktop-electron - env: - OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - - - name: Package and publish - if: needs.version.outputs.release - run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts - working-directory: packages/desktop-electron - timeout-minutes: 60 - env: - OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - - - name: Package (no publish) - if: ${{ !needs.version.outputs.release }} - run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts - working-directory: packages/desktop-electron - timeout-minutes: 60 - env: - OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - - - uses: actions/upload-artifact@v4 - with: - name: opencode-electron-${{ matrix.settings.target }} - path: packages/desktop-electron/dist/* - - - uses: actions/upload-artifact@v4 - if: needs.version.outputs.release - with: - name: latest-yml-${{ matrix.settings.target }} - path: packages/desktop-electron/dist/latest*.yml - - publish: - needs: - - version - - build-cli - - build-tauri - - build-electron - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v3 - - - uses: ./.github/actions/setup-bun - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - uses: actions/download-artifact@v4 - with: - name: opencode-cli - path: packages/opencode/dist - - - uses: actions/download-artifact@v4 - if: needs.version.outputs.release - with: - pattern: latest-yml-* - path: /tmp/latest-yml - - - name: Cache apt packages (AUR) - uses: actions/cache@v4 - with: - path: /var/cache/apt/archives - key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-apt-aur- - - - name: Setup SSH for AUR - run: | - sudo apt-get update - sudo apt-get install -y pacman-package-manager - mkdir -p ~/.ssh - echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - git config --global user.email "opencode@sst.dev" - git config --global user.name "opencode" - ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true - - - run: ./script/publish.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - OPENCODE_RELEASE: ${{ needs.version.outputs.release }} - AUR_KEY: ${{ secrets.AUR_KEY }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - GH_REPO: ${{ needs.version.outputs.repo }} - NPM_CONFIG_PROVENANCE: false - LATEST_YML_DIR: /tmp/latest-yml diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml deleted file mode 100644 index 3f5caa55c..000000000 --- a/.github/workflows/release-github-action.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: release-github-action - -on: - push: - branches: - - dev - paths: - - "github/**" - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -permissions: - contents: write - -jobs: - release: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - run: git fetch --force --tags - - - name: Release - run: | - git config --global user.email "opencode@sst.dev" - git config --global user.name "opencode" - ./github/script/release diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml deleted file mode 100644 index 58e73fac8..000000000 --- a/.github/workflows/review.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: review - -on: - issue_comment: - types: [created] - -jobs: - check-guidelines: - if: | - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/review') && - contains(fromJson('["OWNER","MEMBER"]'), github.event.comment.author_association) - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: write - steps: - - name: Get PR number - id: pr-number - run: | - if [ "${{ github.event_name }}" = "pull_request_target" ]; then - echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT - else - echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT - fi - - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Get PR details - id: pr-details - run: | - gh api /repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }} > pr_data.json - echo "title=$(jq -r .title pr_data.json)" >> $GITHUB_OUTPUT - echo "sha=$(jq -r .head.sha pr_data.json)" >> $GITHUB_OUTPUT - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check PR guidelines compliance - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }' - PR_TITLE: ${{ steps.pr-details.outputs.title }} - run: | - PR_BODY=$(jq -r .body pr_data.json) - opencode run -m anthropic/claude-opus-4-5 "A new pull request has been created: '${PR_TITLE}' - - - ${{ steps.pr-number.outputs.number }} - - - - $PR_BODY - - - Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do - - When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage. - When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) - - Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. - If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. - Generally, write a comment instead of writing suggested change if you can help it. - - Command MUST be like this. - \`\`\` - gh api \ - --method POST \ - -H \"Accept: application/vnd.github+json\" \ - -H \"X-GitHub-Api-Version: 2022-11-28\" \ - /repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }}/comments \ - -f 'body=[summary of issue]' -f 'commit_id=${{ steps.pr-details.outputs.sha }}' -f 'path=[path-to-file]' -F \"line=[line]\" -f 'side=RIGHT' - \`\`\` - - Only create comments for actual violations. If the code follows all guidelines, comment on the issue using gh cli: 'lgtm' AND NOTHING ELSE!!!!." diff --git a/.github/workflows/sign-cli.yml b/.github/workflows/sign-cli.yml deleted file mode 100644 index d9d61fd80..000000000 --- a/.github/workflows/sign-cli.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: sign-cli - -on: - push: - branches: - - brendan/desktop-signpath - workflow_dispatch: - -permissions: - contents: read - actions: read - -jobs: - sign-cli: - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.repository == 'anomalyco/opencode' - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: ./.github/actions/setup-bun - - - name: Build - run: | - ./packages/opencode/script/build.ts - - - name: Upload unsigned Windows CLI - id: upload_unsigned_windows_cli - uses: actions/upload-artifact@v4 - with: - name: unsigned-opencode-windows-cli - path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe - if-no-files-found: error - - - name: Submit SignPath signing request - id: submit_signpath_signing_request - uses: signpath/github-action-submit-signing-request@v1 - with: - api-token: ${{ secrets.SIGNPATH_API_KEY }} - organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} - project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} - signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} - artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} - github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }} - wait-for-completion: true - output-artifact-directory: signed-opencode-cli - - - name: Upload signed Windows CLI - uses: actions/upload-artifact@v4 - with: - name: signed-opencode-windows-cli - path: signed-opencode-cli/*.exe - if-no-files-found: error diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml deleted file mode 100644 index a4b8583f9..000000000 --- a/.github/workflows/stale-issues.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: stale-issues - -on: - schedule: - - cron: "30 1 * * *" # Daily at 1:30 AM - workflow_dispatch: - -env: - DAYS_BEFORE_STALE: 90 - DAYS_BEFORE_CLOSE: 7 - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/stale@v10 - with: - days-before-stale: ${{ env.DAYS_BEFORE_STALE }} - days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} - stale-issue-label: "stale" - close-issue-message: | - [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity. - - Feel free to reopen if you still need this! - stale-issue-message: | - [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days. - - It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity. - remove-stale-when-updated: true - exempt-issue-labels: "pinned,security,feature-request,on-hold" - start-date: "2025-12-27" diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml deleted file mode 100644 index 824733901..000000000 --- a/.github/workflows/stats.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: stats - -on: - schedule: - - cron: "0 12 * * *" # Run daily at 12:00 UTC - workflow_dispatch: # Allow manual trigger - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - stats: - if: github.repository == 'anomalyco/opencode' - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run stats script - run: bun script/stats.ts - - - name: Commit stats - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add STATS.md - git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)" - git push - env: - POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml deleted file mode 100644 index 6d143a8a2..000000000 --- a/.github/workflows/storybook.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: storybook - -on: - push: - branches: [dev] - paths: - - ".github/workflows/storybook.yml" - - "package.json" - - "bun.lock" - - "packages/storybook/**" - - "packages/ui/**" - pull_request: - branches: [dev] - paths: - - ".github/workflows/storybook.yml" - - "package.json" - - "bun.lock" - - "packages/storybook/**" - - "packages/ui/**" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: storybook build - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Build Storybook - run: bun --cwd packages/storybook build diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml deleted file mode 100644 index f14487cde..000000000 --- a/.github/workflows/sync-zed-extension.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "sync-zed-extension" - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - zed: - name: Release Zed Extension - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup-bun - - - name: Get version tag - id: get_tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${{ github.event.release.tag_name }}" - else - TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1) - fi - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "Using tag: ${TAG}" - - - name: Sync Zed extension - run: | - ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} - env: - ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }} - ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c928e8223..6db005a11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,16 +1,11 @@ name: test on: - push: - branches: - - dev pull_request: workflow_dispatch: concurrency: - # Keep every run on dev so cancelled checks do not pollute the default branch - # commit history. PRs and other branches still share a group and cancel stale runs. - group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: @@ -18,81 +13,19 @@ permissions: jobs: unit: - name: unit (${{ matrix.settings.name }}) - strategy: - fail-fast: false - matrix: - settings: - - name: linux - host: blacksmith-4vcpu-ubuntu-2404 - - name: windows - host: blacksmith-4vcpu-windows-2025 - runs-on: ${{ matrix.settings.host }} - defaults: - run: - shell: bash + name: unit + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Bun uses: ./.github/actions/setup-bun - name: Configure git identity run: | - git config --global user.email "bot@opencode.ai" - git config --global user.name "opencode" + git config --global user.email "ci@frankencode" + git config --global user.name "frankencode-ci" - name: Run unit tests run: bun turbo test - - e2e: - name: e2e (${{ matrix.settings.name }}) - needs: unit - strategy: - fail-fast: false - matrix: - settings: - - name: linux - host: blacksmith-4vcpu-ubuntu-2404 - playwright: bunx playwright install --with-deps - - name: windows - host: blacksmith-4vcpu-windows-2025 - playwright: bunx playwright install - runs-on: ${{ matrix.settings.host }} - env: - PLAYWRIGHT_BROWSERS_PATH: 0 - defaults: - run: - shell: bash - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Install Playwright browsers - working-directory: packages/app - run: ${{ matrix.settings.playwright }} - - - name: Run app e2e tests - run: bun --cwd packages/app test:e2e:local - env: - CI: true - timeout-minutes: 30 - - - name: Upload Playwright artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} - if-no-files-found: ignore - retention-days: 7 - path: | - packages/app/e2e/test-results - packages/app/e2e/playwright-report diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index 99e7b5b34..000000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: triage - -on: - issues: - types: [opened] - -jobs: - triage: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Triage issue - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} - run: | - opencode run --agent triage "The following issue was just opened, triage it: - - Title: $ISSUE_TITLE - - $ISSUE_BODY" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b247d24b4..e9b7b99dc 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,15 +1,12 @@ name: typecheck on: - push: - branches: [dev] pull_request: - branches: [dev] workflow_dispatch: jobs: typecheck: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml deleted file mode 100644 index 4c2aa960b..000000000 --- a/.github/workflows/vouch-check-issue.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: vouch-check-issue - -on: - issues: - types: [opened] - -permissions: - contents: read - issues: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if issue author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.issue.user.login; - const issueNumber = context.payload.issue.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the issue - const body = 'This issue has been automatically closed.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'not_planned', - }); - - core.info(`Closed issue #${issueNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing issue.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to issue #${issueNumber} from ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml deleted file mode 100644 index 51816dfb7..000000000 --- a/.github/workflows/vouch-check-pr.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: vouch-check-pr - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if PR author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.pull_request.user.login; - const prNumber = context.payload.pull_request.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: 'This pull request has been automatically closed.', - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', - }); - - core.info(`Closed PR #${prNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing PR.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to PR #${prNumber} from ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml deleted file mode 100644 index 9604bf87f..000000000 --- a/.github/workflows/vouch-manage-by-issue.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: vouch-manage-by-issue - -on: - issue_comment: - types: [created] - -concurrency: - group: vouch-manage - cancel-in-progress: false - -permissions: - contents: write - issues: write - pull-requests: read - -jobs: - manage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - uses: mitchellh/vouch/action/manage-by-issue@main - with: - issue-id: ${{ github.event.issue.number }} - comment-id: ${{ github.event.comment.id }} - roles: admin,maintain - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..9e38fbf3c --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +msg=$(head -1 "$1") + +# Conventional commit format: type(scope): description +# or: type: description +if ! echo "$msg" | grep -qE '^(feat|fix|docs|chore|refactor|test|perf|ci|build|revert)(\([a-zA-Z0-9_-]+\))?!?: .+'; then + echo "ERROR: Commit message does not follow conventional commit format." + echo "" + echo "Expected: (): " + echo " type: feat|fix|docs|chore|refactor|test|perf|ci|build|revert" + echo " scope: optional, e.g. opencode, cas, plugin" + echo "" + echo "Got: $msg" + exit 1 +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..e2abcc64d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Typecheck +bun turbo typecheck + +# Unit tests +bun turbo test diff --git a/DO_NEXT.md b/DO_NEXT.md new file mode 100644 index 000000000..06704e37d --- /dev/null +++ b/DO_NEXT.md @@ -0,0 +1,34 @@ +# Frankencode — Do Next + +## All 4 Phases Complete + +### Implementation done: +- [x] Phase 1: CAS (SQLite) + Part Editing (EditMeta, filterEdited, context_edit, context_deref) +- [x] Phase 2: Conversation Graph (edit_graph DAG, context_history with log/tree/checkout/fork) +- [x] Phase 3: Focus Agent + Side Threads (side_thread table, thread_park, thread_list, focus agent, objective tracker) +- [x] Phase 4: Integration (system prompt injection, plugin hooks) + +### Remaining (polish & testing): +- [ ] Write unit tests for CAS (store, get, dedup via ON CONFLICT) +- [ ] Write unit tests for filterEdited (hidden parts stripped, superseded parts stripped, empty messages dropped) +- [ ] Write unit tests for EditGraph (commit chain, log walk, checkout restore, fork branch) +- [ ] Write unit tests for SideThread CRUD +- [ ] Write unit tests for ContextEdit validation (ownership, budget, recency) +- [ ] Manual end-to-end test: enable `OPENCODE_EXPERIMENTAL_FOCUS_AGENT=1`, run a session, verify: + - context_edit(hide) removes part from next LLM call + - context_edit(externalize) replaces with summary, context_deref retrieves original + - context_history(log) shows edit chain + - thread_park creates a project-level thread + - Focus agent runs post-turn and parks side threads + - System prompt includes focus status + thread summary + +### Future enhancements (from design docs): +- [ ] Threshold refocus mode (configurable %, proactive context rewrite) +- [ ] Background curator mode (between-turn cleanup) +- [ ] Pin & decay scoring (relevance decay per turn) +- [ ] Handoff artifacts (cross-session persistence) +- [ ] thread_investigate (spawn subagent with pre-loaded CAS context) +- [ ] thread_promote (swap side thread into main objective) +- [ ] TUI rendering (toggle edit indicators, sidebar thread panel) +- [ ] Focus intensity levels (relaxed/moderate/strict) +- [ ] CAS garbage collection (orphan cleanup) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..892cb7e16 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,593 @@ +# Frankencode — Context Editing MVP Implementation Plan + +> **Frankencode** is a fork of [OpenCode](https://github.com/anomalyco/opencode) (`dev` branch) that adds surgical, reversible, agent-driven context editing with content-addressable storage and a conversation history graph. The name reflects its nature: a creation stitched together from the best parts of the original, brought to life with new capabilities. + +## Context + +OpenCode agents accumulate stale tool output, wrong assumptions, and off-topic explorations that degrade performance as sessions grow. The only existing remedy is compaction — a blunt summarization at ~85% context usage that destroys detail. Frankencode adds surgical, reversible, agent-driven context editing with a content-addressable store that preserves all original content (like git preserves history), plus a conversation graph that models the editing history as a DAG with parent relationships. + +--- + +## Approach: 4 Phases + +1. **CAS + Part Editing** — SQLite-backed content-addressable store + edit operations +2. **Conversation Graph** — DAG of edit versions with git-like parent pointers, branching, checkout +3. **Focus Agent + Side Threads** — Automated context curation and off-topic parking +4. **Integration** — System prompt injection, plugin hooks + +--- + +## Phase 1: CAS + Part Editing Foundation + +### 1.1 Content-Addressable Store (SQLite) + +**New file:** `packages/opencode/src/cas/cas.sql.ts` +**New file:** `packages/opencode/src/cas/index.ts` + +The CAS lives in SQLite (same DB as everything else). This gives us: +- Atomic transactions with part updates (CAS write + part edit in one tx) +- Queryable (find all CAS entries for a session, GC orphans) +- No filesystem overhead +- Conversation content is text, well within SQLite's comfort zone + +**Schema:** +```typescript +// cas.sql.ts +export const CASObjectTable = sqliteTable("cas_object", { + hash: text().primaryKey(), // SHA-256 of content + content: text().notNull(), // Original content (JSON-serialized) + content_type: text().notNull(), // "part" | "text" | "tool-output" | "reasoning" + tokens: integer().notNull(), // Token estimate + session_id: text(), // Source session + message_id: text(), // Source message + part_id: text(), // Source part + ...Timestamps, +}, (table) => [ + index("cas_object_session_idx").on(table.session_id), +]) +``` + +**Migration:** `packages/opencode/migration/YYYYMMDDHHMMSS_cas/migration.sql` +```sql +CREATE TABLE `cas_object` ( + `hash` text PRIMARY KEY NOT NULL, + `content` text NOT NULL, + `content_type` text NOT NULL, + `tokens` integer NOT NULL, + `session_id` text, + `message_id` text, + `part_id` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +CREATE INDEX `cas_object_session_idx` ON `cas_object`(`session_id`); +``` + +**Export from schema registry:** Add to `packages/opencode/src/storage/schema.ts`: +```typescript +export { CASObjectTable } from "../cas/cas.sql" +``` + +**Module (~80 lines):** +```typescript +// cas/index.ts +export namespace CAS { + export async function store(content: string, meta: { + contentType: string, sessionID?: string, messageID?: string, partID?: string + }): Promise { + const hash = createHash("sha256").update(content).digest("hex") + Database.use((db) => { + db.insert(CASObjectTable) + .values({ + hash, + content, + content_type: meta.contentType, + tokens: Token.estimate(content), + session_id: meta.sessionID, + message_id: meta.messageID, + part_id: meta.partID, + }) + .onConflictDoNothing() // idempotent — same content = same hash + .run() + }) + return hash + } + + export function get(hash: string): CASObject | null { + return Database.use((db) => + db.select().from(CASObjectTable).where(eq(CASObjectTable.hash, hash)).get() ?? null + ) + } + + export function exists(hash: string): boolean { + return Database.use((db) => + !!db.select({ hash: CASObjectTable.hash }).from(CASObjectTable).where(eq(CASObjectTable.hash, hash)).get() + ) + } +} +``` + +Note: SQLite ops are synchronous (Bun SQLite), matching the pattern in `todo.ts` and `session/index.ts`. No file-based storage — CAS is entirely in SQLite. + +### 1.2 Add `edit` field to PartBase + +**Modify:** `packages/opencode/src/session/message-v2.ts` — line 81 + +```typescript +// NEW: Insert before PartBase (line 81) +export const EditMeta = z.object({ + hidden: z.boolean(), + casHash: z.string().optional(), // hash into CAS for original content + supersededBy: PartID.zod.optional(), // points to replacement part + replacementOf: PartID.zod.optional(), // on replacement: points to original + annotation: z.string().optional(), + editedAt: z.number(), + editedBy: z.string(), // agent name + version: z.string().optional(), // graph node ID +}).optional() + +// MODIFY: PartBase (line 81-85) — add edit field +const PartBase = z.object({ + id: PartID.zod, + sessionID: SessionID.zod, + messageID: MessageID.zod, + edit: EditMeta, // NEW — all 12 part types inherit this +}) +``` + +Safe: `.optional()` means existing parts parse as `edit: undefined`. No SQL migration needed (JSON blob column). + +### 1.3 `filterEdited()` function + +**Modify:** `packages/opencode/src/session/message-v2.ts` — insert after `filterCompacted` (~line 898) + +```typescript +export function filterEdited(messages: WithParts[]): WithParts[] { + return messages + .map(msg => ({ + ...msg, + parts: msg.parts.filter(part => { + if (!part.edit) return true + if (part.edit.hidden) return false + if (part.edit.supersededBy) return false + return true + }) + })) + .filter(msg => msg.parts.length > 0) +} +``` + +### 1.4 Pipeline insertion + +**Modify:** `packages/opencode/src/session/prompt.ts` — line 301 + +```diff + let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) ++ msgs = MessageV2.filterEdited(msgs) +``` + +### 1.5 Core edit logic + +**New file:** `packages/opencode/src/context-edit/index.ts` (~300 lines) + +Operations: `hide`, `unhide`, `replace`, `annotate`, `externalize` + +Each operation: +1. Validates ownership (`msg.role !== "user"`, `msg.agent === caller.agent`) +2. Validates budget (max 10/turn, max 70% hidden) +3. Validates recency (cannot edit last 2 turns) +4. Stores original in CAS within same DB transaction +5. Updates part via `Session.updatePart()` (sets `edit` field) +6. Records a graph node (Phase 2 — no-op stub in Phase 1) +7. Publishes bus event via `Database.effect()` + +Key: `replace` uses `Database.transaction()` for atomicity: +```typescript +Database.transaction(() => { + const hash = CAS.store(JSON.stringify(part), { contentType: "part", sessionID, partID }) + const newPartID = Identifier.ascending("part") + Session.updatePart({...part, edit: {hidden:true, casHash:hash, supersededBy:newPartID, editedAt:Date.now(), editedBy:agent}}) + Session.updatePart({id:newPartID, sessionID, messageID, type:"text", text:replacement, edit:{hidden:false, replacementOf:partID, editedAt:Date.now(), editedBy:agent}}) +}) +``` + +CAS + part update + replacement insert: all atomic. If anything fails, nothing is written. + +### 1.6 Tools + +**New file:** `packages/opencode/src/tool/context-edit.ts` (~80 lines) + +```typescript +export const ContextEditTool = Tool.define("context_edit", async () => ({ + description: `Edit the conversation context. Operations: +- hide(partID, messageID): Remove a part from context (preserved in CAS) +- unhide(partID, messageID): Restore a hidden part +- replace(partID, messageID, replacement): Replace with corrected content (original in CAS) +- externalize(partID, messageID, summary): Move to CAS, leave summary + hash reference +- annotate(partID, messageID, annotation): Add a note +Constraints: own messages only, not last 2 turns, max 10 edits/turn.`, + parameters: z.object({ + operation: z.enum(["hide", "unhide", "replace", "annotate", "externalize"]), + partID: z.string().optional(), + messageID: z.string().optional(), + replacement: z.string().optional(), + annotation: z.string().optional(), + summary: z.string().optional(), + }), + async execute(args, ctx) { /* dispatch to ContextEdit.* */ } +})) +``` + +**New file:** `packages/opencode/src/tool/context-deref.ts` (~40 lines) + +```typescript +export const ContextDerefTool = Tool.define("context_deref", async () => ({ + description: `Retrieve externalized content from the content-addressable store.`, + parameters: z.object({ hash: z.string() }), + async execute(args, ctx) { + const entry = CAS.get(args.hash) + if (!entry) return { title: "Not found", output: `No content for hash ${args.hash}`, metadata: {} } + return { title: "Retrieved", output: entry.content, metadata: { hash: args.hash, tokens: entry.tokens } } + } +})) +``` + +### 1.7 Tool registration + +**Modify:** `packages/opencode/src/tool/registry.ts` — imports + BUILTIN array (~line 119) + +```typescript +import { ContextEditTool } from "./context-edit" +import { ContextDerefTool } from "./context-deref" +// In BUILTIN: +ContextEditTool, +ContextDerefTool, +``` + +--- + +## Phase 2: Conversation Graph + +### 2.1 Graph table (SQLite) + +The conversation graph models edits as a DAG with parent pointers — like git commits. Each edit creates a node. Branches and checkout enable exploring alternative edit histories. + +**New file:** `packages/opencode/src/cas/graph.sql.ts` + +```typescript +export const EditGraphNodeTable = sqliteTable("edit_graph_node", { + id: text().primaryKey(), // Node ID + parent_id: text(), // Parent node (forms DAG) + session_id: text().notNull(), // Session scope + part_id: text().notNull(), // Part that was edited + operation: text().notNull(), // hide | unhide | replace | annotate | externalize + cas_hash: text(), // CAS hash of content BEFORE this edit + agent: text().notNull(), // Who made the edit + ...Timestamps, +}, (table) => [ + index("edit_graph_session_idx").on(table.session_id), + index("edit_graph_parent_idx").on(table.parent_id), +]) + +export const EditGraphHeadTable = sqliteTable("edit_graph_head", { + session_id: text().primaryKey(), // One head per session + node_id: text().notNull(), // Current tip + branches: text({ mode: "json" }).$type>(), // name → node ID +}) +``` + +**Migration:** Same migration directory as CAS (or separate): +```sql +CREATE TABLE `edit_graph_node` ( + `id` text PRIMARY KEY NOT NULL, + `parent_id` text, + `session_id` text NOT NULL, + `part_id` text NOT NULL, + `operation` text NOT NULL, + `cas_hash` text, + `agent` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +CREATE INDEX `edit_graph_session_idx` ON `edit_graph_node`(`session_id`); +CREATE INDEX `edit_graph_parent_idx` ON `edit_graph_node`(`parent_id`); + +CREATE TABLE `edit_graph_head` ( + `session_id` text PRIMARY KEY NOT NULL, + `node_id` text NOT NULL, + `branches` text +); +``` + +**Export from schema registry:** Add to `packages/opencode/src/storage/schema.ts`. + +### 2.2 Graph module + +**New file:** `packages/opencode/src/cas/graph.ts` (~200 lines) + +```typescript +export namespace EditGraph { + export function commit(input: { + sessionID: string, partID: string, operation: string, + casHash?: string, agent: string, parentID?: string + }): string // returns node ID + + export function log(sessionID: string): GraphNode[] + // Walk parent pointers from head to root + + export function tree(sessionID: string): { nodes: GraphNode[], head: string, branches: Record } + // Full DAG for the session + + export function checkout(sessionID: string, nodeID: string): void + // 1. Walk from current head back to common ancestor with target + // 2. For each node being "undone": restore part from CAS + // 3. For each node being "applied": re-apply edit + // 4. Update head to target node + + export function fork(sessionID: string, nodeID: string, branchName: string): void + // Create a named branch pointing at nodeID + // Future edits from this point form a new path in the DAG +} +``` + +Integration: `ContextEdit.*` operations call `EditGraph.commit()` inside the same `Database.transaction()`. The `edit.version` field on the part links to the graph node ID. + +### 2.3 `context_history` tool + +**New file:** `packages/opencode/src/tool/context-history.ts` (~60 lines) + +```typescript +export const ContextHistoryTool = Tool.define("context_history", async () => ({ + description: `Navigate the edit history...`, + parameters: z.object({ + operation: z.enum(["log", "tree", "checkout", "fork"]), + nodeID: z.string().optional(), + branch: z.string().optional(), + }), + async execute(args, ctx) { /* dispatch to EditGraph.* */ } +})) +``` + +Register in `registry.ts`. + +### 2.4 Session-level graph index + +The existing `SessionTable.parent_id` already provides session-level parent pointers (used by the `task` tool for child sessions). The edit graph adds content-level parent pointers within a session. Together they form a two-level graph: + +``` +Session DAG (existing): + ses_001 → ses_002 (fork) + → ses_003 (task subagent) + +Edit Graph (new, per-session): + ses_001: + node_1 → node_2 → node_3 (main branch) + → node_4 (alt branch) +``` + +The session index for the graph is the `edit_graph_head` table — one row per session with the current head and named branches. + +--- + +## Phase 3: Focus Agent + Side Threads + +### 3.1 Side thread table + +**New file:** `packages/opencode/src/session/side-thread.sql.ts` + +```typescript +export const SideThreadTable = sqliteTable("side_thread", { + id: text().primaryKey(), + project_id: text().notNull().references(() => ProjectTable.id, { onDelete: "cascade" }), + title: text().notNull(), + description: text().notNull(), + status: text().notNull().$default(() => "parked"), + priority: text().notNull().$default(() => "medium"), + category: text().notNull().$default(() => "other"), + source_session_id: text(), + source_part_ids: text({ mode: "json" }).$type(), + cas_refs: text({ mode: "json" }).$type(), + related_files: text({ mode: "json" }).$type(), + created_by: text().notNull(), + ...Timestamps, +}, (table) => [ + index("side_thread_project_idx").on(table.project_id, table.status), +]) +``` + +Add to same migration. Export from `storage/schema.ts`. + +### 3.2 Side thread module + +**New file:** `packages/opencode/src/session/side-thread.ts` (~120 lines) + +CRUD following `todo.ts` pattern. + +### 3.3 Focus agent + +**Modify:** `packages/opencode/src/agent/agent.ts` (~line 203) + +```typescript +focus: { + name: "focus", + mode: "primary" as const, + native: true, + hidden: true, + prompt: await readPrompt("focus"), + temperature: 0, + steps: 8, + permission: PermissionNext.merge(defaults, PermissionNext.fromConfig({ + "*": "deny", + context_edit: "allow", + context_deref: "allow", + thread_park: "allow", + thread_list: "allow", + question: "allow", + }), user), + options: {}, +}, +``` + +**New file:** `packages/opencode/src/agent/prompt/focus.txt` (~50 lines) + +### 3.4 Thread tools + +**New file:** `packages/opencode/src/tool/thread-park.ts` (~60 lines) +**New file:** `packages/opencode/src/tool/thread-list.ts` (~40 lines) + +### 3.5 Post-turn focus hook + +**Modify:** `packages/opencode/src/session/prompt.ts` (~line 686) + +```typescript +if (config.experimental?.focus_agent && step >= 2 && result === "continue") { + await runFocusAgent(sessionID, model, abort, msgs) +} +``` + +Follows `SessionCompaction.process()` pattern. + +### 3.6 Objective tracker + +**New file:** `packages/opencode/src/session/objective.ts` (~80 lines) + +--- + +## Phase 4: Integration + +### 4.1 System prompt injection + +**Modify:** `packages/opencode/src/session/prompt.ts` (~line 656) + +### 4.2 Plugin hooks + +**Modify:** `packages/plugin/src/index.ts` (~line 233) — `context.edit.before` / `context.edit.after` + +--- + +## Files Summary + +| Phase | File | Action | ~LOC | +|:-----:|------|--------|:----:| +| 1 | `src/cas/cas.sql.ts` | New | 20 | +| 1 | `src/cas/index.ts` | New | 80 | +| 1 | `src/storage/schema.ts` | Modify | +3 | +| 1 | `migration/.../migration.sql` | New | 30 | +| 1 | `src/session/message-v2.ts` | Modify | +25 | +| 1 | `src/session/prompt.ts` | Modify | +1 | +| 1 | `src/context-edit/index.ts` | New | 300 | +| 1 | `src/tool/context-edit.ts` | New | 80 | +| 1 | `src/tool/context-deref.ts` | New | 40 | +| 1 | `src/tool/registry.ts` | Modify | +5 | +| 2 | `src/cas/graph.sql.ts` | New | 30 | +| 2 | `src/cas/graph.ts` | New | 200 | +| 2 | `src/tool/context-history.ts` | New | 60 | +| 3 | `src/session/side-thread.sql.ts` | New | 25 | +| 3 | `src/session/side-thread.ts` | New | 120 | +| 3 | `src/agent/agent.ts` | Modify | +20 | +| 3 | `src/agent/prompt/focus.txt` | New | 50 | +| 3 | `src/tool/thread-park.ts` | New | 60 | +| 3 | `src/tool/thread-list.ts` | New | 40 | +| 3 | `src/session/prompt.ts` | Modify | +15 | +| 3 | `src/session/objective.ts` | New | 80 | +| 4 | `src/session/prompt.ts` | Modify | +10 | +| 4 | `packages/plugin/src/index.ts` | Modify | +12 | +| | | **Total** | **~1,380** | + +All paths relative to `packages/opencode/`. + +--- + +## Key Design Decisions + +### Why SQLite for CAS (not files) + +| Concern | SQLite | File-based | +|---------|--------|------------| +| Atomicity with part updates | Same transaction | Separate write, can drift | +| Queryable (GC, session lookup) | Yes (SQL) | Must scan filesystem | +| Deduplication | `ON CONFLICT DO NOTHING` | Check before write | +| Performance | Fast for text blobs <1MB | File-per-blob overhead | +| DB size growth | Only concern | Not an issue | + +Mitigation for DB growth: add `VACUUM` to the existing hourly `Snapshot.cleanup()` scheduler. Content is text, compresses well in WAL mode. + +### Why conversation graph in SQLite (not file-based Storage) + +The graph needs: +- Parent pointer traversal (walk DAG) — `WHERE parent_id = ?` is fast with index +- Session-scoped queries — `WHERE session_id = ?` +- Atomic commits (graph node + CAS entry + part update in one tx) + +File-based storage would require loading the entire tree into memory to traverse. SQLite handles this natively. + +### Relationship between session DAG and edit graph + +``` +Session level (existing): Edit level (new, per-session): +┌──────────────────────┐ ┌─────────────────────────────────┐ +│ ses_001 (main) │ │ ses_001 edit graph: │ +│ ├─ ses_002 (fork) │ │ n1 → n2 → n3 (head:main) │ +│ └─ ses_003 (task) │ │ └─ n4 (head:alt) │ +└──────────────────────┘ └─────────────────────────────────┘ + ┌─────────────────────────────────┐ + │ ses_002 edit graph: │ + │ n5 → n6 (head:main) │ + └─────────────────────────────────┘ +``` + +Session.fork() copies messages. Edit graph.fork() creates a branch within the same session's edit history. They're orthogonal. + +--- + +## Key Reuse Points + +| Existing Code | Reuse For | +|--------------|-----------| +| `Database.transaction()` + `Database.use()` | Atomic CAS + part + graph writes | +| `Database.effect()` | Bus events after DB commit | +| `Session.updatePart()` | All part mutations | +| `BusEvent.define()` + `Bus.publish()` | Edit events | +| `SessionCompaction.process()` pattern | Focus agent post-turn invocation | +| `Todo` module pattern | Side thread CRUD | +| `Identifier.ascending("part")` | New part IDs, graph node IDs | +| `Token.estimate()` | Token counting for CAS | +| `Timestamps` from `storage/schema.ts` | `time_created`/`time_updated` on new tables | +| `index()` from drizzle-orm | Table indexes | +| Schema export pattern in `storage/schema.ts` | Register new tables | + +--- + +## Verification + +### Phase 1 +1. Create session, get assistant response with tool calls +2. `context_edit(operation:"hide", partID:"prt_...", messageID:"msg_...")` +3. Verify: hidden part absent from next LLM call; CAS entry exists in `cas_object` table +4. `context_edit(operation:"externalize", ..., summary:"...")` on a tool result +5. Verify: part text replaced with summary + hash; CAS entry has original content +6. `context_deref(hash:"...")` — verify original content returned +7. `context_edit(operation:"replace", ..., replacement:"corrected text")` +8. Verify: original in CAS, new TextPart created, old part has `supersededBy` + +### Phase 2 +9. After edits, `context_history(operation:"log")` — verify chain n1→n2→n3 +10. `context_history(operation:"fork", nodeID:"n2", branch:"alt")` — branch created +11. `context_history(operation:"checkout", nodeID:"n1")` — parts restored from CAS + +### Phase 3 +12. Enable focus agent, multi-turn session with divergence +13. Verify focus agent parks a side thread, hides divergent content +14. `thread_list` — parked thread appears with CAS refs + +### Phase 4 +15. Verify system prompt contains focus status + thread summary +16. Verify plugin `context.edit.before` hook fires + +### Running Tests +```bash +cd packages/opencode +bun test src/cas/ +bun test src/context-edit/ +bun test src/session/side-thread.test.ts +``` diff --git a/WHAT_WE_DID.md b/WHAT_WE_DID.md new file mode 100644 index 000000000..00cb7eb1d --- /dev/null +++ b/WHAT_WE_DID.md @@ -0,0 +1,126 @@ +# Frankencode — What We Did + +## Phase 0: Research & Design (completed) + +- Deep research on OpenCode architecture +- Designed editable context system (6 modes, Merkle CAS, focus agent, side threads) +- Literature review of 40+ papers/tools/frameworks +- Narrowed to MVP plan + +### Documents produced (now in `docs/research/`): +`REPORT.md`, `EDITABLE_CONTEXT.md`, `EDITABLE_CONTEXT_PLUGIN_PLAN.md`, `EDITABLE_CONTEXT_FORK_PLAN.md`, `EDITABLE_CONTEXT_MODES.md`, `EDITABLE_CONTEXT_MERKLE.md`, `EDITABLE_CONTEXT_FOCUS.md`, `EDITABLE_CONTEXT_PRESS_RELEASE.md`, `DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md`, `CONTEXT_EDITING_MVP.md`, `UI_CUSTOMIZATION.md` + +Root-level: `PLAN.md`, `WHAT_WE_DID.md`, `DO_NEXT.md` + +--- + +## Phase 1: CAS + Part Editing Foundation (completed) + +### Files created: +- `packages/opencode/src/cas/cas.sql.ts` — Drizzle table definitions for `cas_object`, `edit_graph_node`, `edit_graph_head` +- `packages/opencode/src/cas/index.ts` — CAS module: `store()`, `get()`, `exists()`, `listBySession()` +- `packages/opencode/src/context-edit/index.ts` — Core edit logic: `hide`, `unhide`, `replace`, `annotate`, `externalize` with ownership/budget/recency validation, CAS integration, bus events +- `packages/opencode/src/tool/context-edit.ts` — `context_edit` tool (5 operations) +- `packages/opencode/src/tool/context-deref.ts` — `context_deref` tool (retrieve CAS content by hash) +- `packages/opencode/migration/20260315120000_context_editing/migration.sql` — SQL migration for all 3 new tables + +### Files modified: +- `packages/opencode/src/session/message-v2.ts` — Added `EditMeta` schema on `PartBase` (all 12 part types inherit `edit` field); added `filterEdited()` function +- `packages/opencode/src/session/prompt.ts` — Inserted `msgs = MessageV2.filterEdited(msgs)` after `filterCompacted` in the main loop (line 302) +- `packages/opencode/src/storage/schema.ts` — Exported new tables from CAS module +- `packages/opencode/src/tool/registry.ts` — Registered `ContextEditTool` and `ContextDerefTool` in BUILTIN array + +### Verification: +- All 1310 existing tests pass (0 failures) +- 1 pre-existing error in retry.test.ts (unrelated `test.concurrent` issue) + +--- + +## Phase 2: Conversation Graph (completed) + +### Files created: +- `packages/opencode/src/cas/graph.ts` — `EditGraph` namespace: `commit`, `getLog`, `tree`, `checkout`, `fork`, `switchBranch`. DAG of edit nodes with parent pointers, per-session head tracking, named branches. +- `packages/opencode/src/tool/context-history.ts` — `context_history` tool (log, tree, checkout, fork operations) + +### Files modified: +- `packages/opencode/src/context-edit/index.ts` — All 4 edit operations (hide, replace, externalize, annotate) now call `EditGraph.commit()` inside the same `Database.transaction()`, setting `edit.version` on parts +- `packages/opencode/src/tool/registry.ts` — Registered `ContextHistoryTool` in BUILTIN array +- `packages/opencode/src/session/message-v2.ts` — Fixed pre-existing `as` casts (line 536, 873) to route through `unknown` for compatibility with new `edit` field on `PartBase` +- `packages/opencode/src/session/prompt.ts` — Fixed pre-existing `as` cast (line 992) same pattern + +### Verification: +- All 1310 existing tests pass (0 failures) + +--- + +## Phase 3: Focus Agent + Side Threads (completed) + +### Files created: +- `packages/opencode/src/session/side-thread.sql.ts` — Drizzle table for `side_thread` (project-level, survives sessions) +- `packages/opencode/src/session/side-thread.ts` — CRUD module: `create`, `get`, `list`, `update` with bus events +- `packages/opencode/src/session/objective.ts` — Objective tracker: `get`, `set`, `extract` (from first user message, cached in Storage) +- `packages/opencode/src/tool/thread-park.ts` — `thread_park` tool +- `packages/opencode/src/tool/thread-list.ts` — `thread_list` tool +- `packages/opencode/src/agent/prompt/focus.txt` — Focus agent system prompt + +### Files modified: +- `packages/opencode/migration/20260315120000_context_editing/migration.sql` — Added `side_thread` table + index +- `packages/opencode/src/storage/schema.ts` — Exported `SideThreadTable` +- `packages/opencode/src/agent/agent.ts` — Added `focus` agent (hidden, temp 0, max 8 steps, restricted to context_edit/thread_park/thread_list/question tools) +- `packages/opencode/src/flag/flag.ts` — Added `OPENCODE_EXPERIMENTAL_FOCUS_AGENT` flag +- `packages/opencode/src/session/prompt.ts` — Added post-turn focus agent hook (after processor.process(), guarded by flag, runs on step >= 2) +- `packages/opencode/src/tool/registry.ts` — Registered `ThreadParkTool` and `ThreadListTool` + +### Verification: +- All 1310 existing tests pass (0 failures) + +--- + +## Phase 4: Integration (completed) + +### Files modified: +- `packages/opencode/src/session/prompt.ts` — Injected focus status block + side thread summary into system prompt (when `OPENCODE_EXPERIMENTAL_FOCUS_AGENT` is set) +- `packages/plugin/src/index.ts` — Added `context.edit.before` and `context.edit.after` hook types to Hooks interface +- `packages/opencode/src/context-edit/index.ts` — Added `Plugin.trigger()` calls: `pluginGuard()` before hide/replace/externalize, `pluginNotify()` after successful operations + +### Verification: +- All 1310 existing tests pass (0 failures) + +--- + +## Summary of All Changes + +### New files (13): +| File | LOC | Purpose | +|------|:---:|---------| +| `src/cas/cas.sql.ts` | 40 | Drizzle tables: cas_object, edit_graph_node, edit_graph_head | +| `src/cas/index.ts` | 85 | CAS module (SQLite): store, get, exists, listBySession | +| `src/cas/graph.ts` | 235 | Edit graph DAG: commit, log, tree, checkout, fork, switchBranch | +| `src/context-edit/index.ts` | 370 | Core edit ops: hide, unhide, replace, annotate, externalize + validation + plugin hooks | +| `src/tool/context-edit.ts` | 90 | context_edit tool | +| `src/tool/context-deref.ts` | 35 | context_deref tool | +| `src/tool/context-history.ts` | 95 | context_history tool | +| `src/tool/thread-park.ts` | 55 | thread_park tool | +| `src/tool/thread-list.ts` | 45 | thread_list tool | +| `src/session/side-thread.sql.ts` | 30 | Drizzle table: side_thread | +| `src/session/side-thread.ts` | 155 | SideThread CRUD module | +| `src/session/objective.ts` | 55 | Objective tracker | +| `src/agent/prompt/focus.txt` | 30 | Focus agent system prompt | +| `migration/.../migration.sql` | 45 | SQL migration for 4 new tables | +| **Total** | **~1,365** | | + +### Modified files (8): +| File | Changes | +|------|---------| +| `src/session/message-v2.ts` | +EditMeta on PartBase, +filterEdited(), fixed as-casts | +| `src/session/prompt.ts` | +filterEdited in pipeline, +focus agent post-turn hook, +focus status in system prompt | +| `src/storage/schema.ts` | +exports for 4 new tables | +| `src/tool/registry.ts` | +6 new tools in BUILTIN array | +| `src/agent/agent.ts` | +focus agent definition (hidden, temp 0, restricted tools) | +| `src/flag/flag.ts` | +OPENCODE_EXPERIMENTAL_FOCUS_AGENT flag | +| `packages/plugin/src/index.ts` | +context.edit.before/after hook types | +| `src/context-edit/index.ts` | +Plugin.trigger() guard/notify calls | + +--- + +*Last updated after: Phase 4 (all phases complete)* diff --git a/bun.lock b/bun.lock index e06beaa4d..9e6d9e955 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "opencode", @@ -1890,7 +1889,7 @@ "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }], "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], @@ -3006,7 +3005,7 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/docs/research/CONTEXT_EDITING_MVP.md b/docs/research/CONTEXT_EDITING_MVP.md new file mode 100644 index 000000000..286d646f3 --- /dev/null +++ b/docs/research/CONTEXT_EDITING_MVP.md @@ -0,0 +1,260 @@ +# Frankencode — Context Editing MVP + +> **Frankencode**: a fork of [OpenCode](https://github.com/anomalyco/opencode) with agent-driven context editing. + +Narrowed-down feature set derived from the full research and design work in `EDITABLE_CONTEXT*.md`. + +--- + +## What We're Building + +A context editing system (codename **Frankencode**) where agents can surgically edit their own conversation context — hiding stale content, replacing errors, externalizing verbose output to a content-addressable store — while preserving all original content in a git-like versioned history. + +Three layers: + +1. **CAS + Part Editing** — Content-addressable storage + edit operations on parts +2. **Version Tree** — Git-like edit history with branches and checkout +3. **Focus Agent + Side Threads** — Automated context curation and off-topic parking + +--- + +## Layer 1: CAS + Part Editing + +### Content-Addressable Store (SQLite) + +Every edit preserves the original content by hashing it and storing it in a SQLite table: + +``` +Original part → SHA-256 hash → INSERT INTO cas_object (hash, content, ...) +Part updated with edit metadata → LLM sees the edited version +Agent can dereference hash to retrieve original at any time +``` + +Entirely in SQLite (same DB as everything else). This gives us: +- **Atomic transactions** — CAS write + part edit + graph node in one `Database.transaction()` +- **Queryable** — find all CAS entries for a session, GC orphans with SQL +- **Deduplicated** — `ON CONFLICT DO NOTHING` (same content = same hash) +- No filesystem overhead, no file-per-blob + +### Edit Operations + +| Operation | What It Does | LLM Sees | Original | +|-----------|-------------|----------|----------| +| `hide` | Remove part from context | Nothing | In CAS | +| `unhide` | Restore a hidden part | The part again | N/A | +| `replace` | Swap content | New text | In CAS | +| `annotate` | Add a note | Part + note | Unchanged | +| `externalize` | Move to CAS, leave summary | Summary + hash ref | In CAS | + +### Schema Change + +`edit` field added to `PartBase` (inherited by all 12 part types): + +```typescript +edit?: { + hidden: boolean + casHash?: string // hash into CAS + supersededBy?: string // ID of replacement part + replacementOf?: string // ID of original part + annotation?: string + editedAt: number + editedBy: string // agent name + version?: string // version tree node +} +``` + +No SQL migration needed — parts are stored as JSON blobs. + +### Safety Constraints + +- Agents can only edit their own assistant messages (not user messages, not other agents') +- Cannot edit the last 2 turns (prevents infinite edit loops) +- Max 10 edits per turn, max 70% of parts hidden +- `skill` tool results are protected (never hidden) + +### Tools + +**`context_edit`** — The editing tool: +``` +context_edit(operation: "hide", partID: "prt_abc", messageID: "msg_xyz") +context_edit(operation: "replace", partID: "prt_abc", messageID: "msg_xyz", replacement: "corrected text") +context_edit(operation: "externalize", partID: "prt_abc", messageID: "msg_xyz", summary: "47 matches in src/auth/") +``` + +**`context_deref`** — Retrieve externalized content: +``` +context_deref(hash: "a1b2c3d4") +→ Returns full original content from CAS +``` + +### Pipeline + +``` +Session.messages() → raw from DB +filterCompacted() → existing: truncate at compaction boundary +filterEdited() → NEW: drop hidden/superseded parts +toModelMessages() → convert to LLM format +LLM.stream() → send to provider +``` + +`filterEdited()` inserted at prompt.ts line 301 (messages are re-read from DB every loop iteration, so edits take effect on the next turn). + +--- + +## Layer 2: Version Tree + +Every edit creates a version node. Nodes form a tree (like git commits): + +``` +v1: hide prt_abc ← initial edit +v2: externalize prt_def ← second edit +v3: replace prt_ghi ← third edit (current head) + └─ v3a: unhide prt_abc ← branch: "what if we kept that part?" +``` + +### Data Structure + +```typescript +VersionNode = { + id: string + parentID?: string // forms the tree + sessionID: string + partID: string + operation: string + casHash?: string // content BEFORE this edit + timestamp: number + agent: string +} + +VersionTree = { + sessionID: string + head: string // current tip + branches: Record // name → node ID + nodes: VersionNode[] +} +``` + +Stored in the file-based `Storage` module at key `["version-tree", sessionID]`. + +### Tool + +**`context_history`** — Navigate the version tree: +``` +context_history(operation: "log") → show linear history +context_history(operation: "tree") → show full tree with branches +context_history(operation: "checkout", versionID: "v2") → restore to v2 +context_history(operation: "fork", versionID: "v2", branch: "alt") → create branch from v2 +``` + +`checkout` restores part state by reading the CAS entry for each version node and reversing edits back to the target version. + +--- + +## Layer 3: Focus Agent + Side Threads + +### Focus Agent + +A hidden agent that runs after each main agent turn to keep the conversation on-topic: + +``` +Main agent finishes turn + → Focus agent reviews: "Is this on-topic?" + → Off-topic findings → parked as side threads + → Stale content → hidden or externalized + → Focus status → injected into main agent's system prompt +``` + +**Agent definition:** +- `name: "focus"`, `hidden: true` +- Tools: `context_edit`, `context_deref`, `thread_park`, `thread_list`, `question` +- Model: `small` (fast, cheap — judgment, not generation) +- Max steps: 8, Temperature: 0 +- Runs after step 2+ (not on first turns) + +**Focus status block** (injected into main agent's system prompt): +``` +## Focus Status +Objective: Add pagination to the API +Parked threads: 3 +Stay focused. If you find unrelated issues, note them in one sentence. +``` + +### Side Threads + +Project-level SQLite table for deferred findings: + +```sql +CREATE TABLE side_thread ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT DEFAULT 'parked', -- parked | active | resolved + priority TEXT DEFAULT 'medium', -- low | medium | high | critical + category TEXT DEFAULT 'other', -- bug | tech-debt | security | performance | test | other + source_session_id TEXT, + source_part_ids TEXT, -- JSON array + cas_refs TEXT, -- JSON array of CAS hashes + related_files TEXT, -- JSON array + created_by TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL +); +``` + +**Why project-level:** Side threads survive across sessions. A finding from Session 1 can be investigated in Session 5. + +### Side Thread Tools + +**`thread_park`** — Park a finding: +``` +thread_park(title: "Race condition in DB pool", description: "...", + sourcePartIDs: ["prt_abc"], priority: "medium", category: "bug") +``` + +**`thread_list`** — List parked threads: +``` +thread_list(status: "parked") +→ thr_abc [parked, medium, bug] "Race condition in DB pool" +→ thr_def [parked, medium, security] "Auth middleware missing rate limiting" +``` + +### Objective Tracker (Basic) + +Extracts the user's objective from the first user message. Stored per-session in the file-based `Storage` module. The focus agent uses it to judge what's on-topic. + +--- + +## Implementation Scope + +| Phase | New Files | Modified Files | ~LOC | Key Dependencies | +|:-----:|:---------:|:--------------:|:----:|-----------------| +| 1 | 4 | 3 | ~505 | Storage, Session.updatePart, Bus | +| 2 | 2 | 1 | ~260 | Phase 1 CAS | +| 3 | 6 | 2 | ~410 | Phase 1, Drizzle migration | +| 4 | 0 | 2 | ~22 | All above | +| **Total** | **12** | **8** | **~1,200** | | + +### Critical Path + +``` +Phase 1: CAS module → EditMeta on PartBase → filterEdited() → pipeline insertion + → ContextEdit operations → context_edit tool → context_deref tool → registry +Phase 2: Version tree → context_history tool → registry +Phase 3: Side thread table + migration → side thread module → focus agent → tools → post-turn hook → objective tracker +Phase 4: System prompt injection → plugin hooks +``` + +Phase 1 is self-contained and delivers the core value. Phase 2 adds history navigation. Phase 3 adds automation. Phase 4 ties everything together. + +--- + +## What This Enables + +**Short sessions (< 20 turns):** Agent can `replace` incorrect statements and `annotate` key findings. Minimal overhead. + +**Medium sessions (20-50 turns):** Agent `externalizes` verbose tool output, `hides` superseded exploration. Focus agent parks side threads. Context stays lean. + +**Long sessions (50+ turns):** Version tree provides full edit history. Externalized content recoverable on demand. Side threads capture deferred work. Handoff artifacts (future) carry structured state to new sessions. + +**Cross-session:** Side threads persist at project level. CAS objects persist on disk. Future: handoff artifacts load into new sessions. diff --git a/docs/research/DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md b/docs/research/DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md new file mode 100644 index 000000000..ba905cc08 --- /dev/null +++ b/docs/research/DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md @@ -0,0 +1,311 @@ +# Deep Research: Post-Factum Context Editing — State of the Art + +A literature review of published papers, tools, frameworks, and industry trends related to live context editing, conversation memory management, and agent self-modification. + +--- + +## 1. Academic Papers + +### 1.1 Virtual Memory & Demand Paging for Context + +**"MemGPT: Towards LLMs as Operating Systems"** +- Packer, Wooders, Lin, Fang, Patil, Gonzalez — October 2023 +- [arxiv.org/abs/2310.08560](https://arxiv.org/abs/2310.08560) +- The foundational paper on virtual context management. Draws from OS virtual memory: main context = RAM, external storage = disk. The LLM itself manages paging between tiers using function calls. Evaluated on document analysis (exceeding context window) and multi-session chat. Evolved into the **Letta** framework. Directly relevant: the agent moves content in and out of its own working context. + +**"The Missing Memory Hierarchy: Demand Paging for LLM Context Windows"** +- Tony Mason — March 2026 +- [arxiv.org/abs/2603.09023](https://arxiv.org/abs/2603.09023) +- Introduces **Pichay**, a transparent proxy that implements demand paging between client and inference API. Evicts stale content, detects "page faults" when the model needs evicted material, and pins working-set pages by fault history. In 681 production turns: **93% context reduction** (5,038KB → 339KB), fault rate only 0.025%. The closest analog to our CAS/Merkle externalization design — content is dynamically evicted/restored based on access patterns. + +### 1.2 Hierarchical Memory Structures + +**"MemTree: From Isolated Conversations to Hierarchical Schemas"** +- Rezazadeh, Li et al. — October 2024, ICLR 2025 +- [arxiv.org/abs/2410.14052](https://arxiv.org/abs/2410.14052) +- Dynamic tree-structured memory where each node encapsulates aggregated text, embeddings, and varying abstraction levels. Unlike flat lookup tables, MemTree hierarchically organizes and dynamically adapts by traversing the tree. Directly relevant to our Merkle tree design: tree structures allow surgical editing at any abstraction level. + +**"Memory OS of AI Agent" (MemoryOS)** +- BAI-LAB — June 2025, EMNLP 2025 Oral +- [arxiv.org/abs/2506.06326](https://arxiv.org/abs/2506.06326) +- Hierarchical storage with four modules (Storage, Updating, Retrieval, Generation). 49% improvement on F1 and 46% on BLEU-1 over baselines on LoCoMo benchmark for personalized dialogue agents. + +**"MemOS: An Operating System for Memory-Augmented Generation"** +- MemTensor — May 2025 +- [arxiv.org/abs/2505.22101](https://arxiv.org/abs/2505.22101) +- Treats memory as a manageable system resource with "MemCubes" encapsulating both content and metadata (provenance, versioning). Provides a unified API to **add, retrieve, edit, and delete** memory. Directly implements editable context at the infrastructure level. + +**"H-MEM: Hierarchical Memory for High-Efficiency"** +- 2025 +- [arxiv.org/abs/2507.22925](https://arxiv.org/abs/2507.22925) +- Uses hierarchical memory and position index to search layer by layer, removing influence of irrelevant memories. + +### 1.3 Agent Self-Reflection & Self-Correction + +**"Reflexion: Language Agents with Verbal Reinforcement Learning"** +- Shinn, Cassano et al. — March 2023, NeurIPS 2023 +- [arxiv.org/abs/2303.11366](https://arxiv.org/abs/2303.11366) +- Seminal paper on agent self-reflection. Agents verbally reflect on task feedback, maintaining reflective text in episodic memory to improve subsequent decisions. The agent effectively **edits its own context** by writing self-corrections that persist. Key precursor to our `thread_edit(replace)` operation. + +**"A-MEM: Agentic Memory for LLM Agents"** +- Xu, Liang et al. — February 2025, NeurIPS 2025 +- [arxiv.org/abs/2502.12110](https://arxiv.org/abs/2502.12110) +- Memory system following the Zettelkasten method. Creates interconnected knowledge networks through dynamic indexing and linking. When new memory is added, the system generates structured notes with descriptions, keywords, and tags, then determines connections. Superior to baselines across six models. + +**"Agentic Context Engineering (ACE): Evolving Contexts for Self-Improving Language Models"** +- Zhang et al. — October 2025 +- [arxiv.org/abs/2510.04618](https://arxiv.org/abs/2510.04618) +- Treats contexts as evolving playbooks via three roles: **Generator** (produces reasoning), **Reflector** (distills insights), **Curator** (integrates into structured context). Addresses "brevity bias" and "context collapse." +10.6% on agent benchmarks. Directly implements self-editing context: the agent evolves its own system prompt and memory over time. Closest academic work to our Curator + Focus Agent design. + +**"In Prospect and Retrospect: Reflective Memory Management for Long-term Personalized Dialogue Agents"** +- ACL 2025 +- [aclanthology.org/2025.acl-long.413](https://aclanthology.org/2025.acl-long.413.pdf) +- Selective retention and updating of memories based on prospective and retrospective evaluation. Relevant to our Pin & Decay scoring. + +### 1.4 Selective Context Compression + +**"LLMLingua: Compressing Prompts for Accelerated Inference"** +- Microsoft Research (Jiang et al.) — EMNLP 2023 +- [arxiv.org/abs/2310.05736](https://arxiv.org/abs/2310.05736) +- Uses a small model to identify and remove unimportant tokens. Up to **20x compression** with minimal loss. LLMLingua-2 (ACL 2024) uses GPT-4 distillation for task-agnostic compression with a BERT-level encoder. Integrated into LangChain and LlamaIndex. + +**"LongLLMLingua: Accelerating LLMs in Long Context Scenarios"** +- Microsoft Research — October 2023 +- [arxiv.org/abs/2310.06839](https://arxiv.org/abs/2310.06839) +- Extends LLMLingua for long contexts. +21.4% RAG performance using 1/4 tokens. Mitigates the "lost in the middle" problem. + +**"Prompt Compression for Large Language Models: A Survey"** +- Li et al. — NAACL 2025 Oral +- [aclanthology.org/2025.naacl-long.368](https://aclanthology.org/2025.naacl-long.368.pdf) +- Comprehensive survey categorizing methods into token pruning, abstractive compression, and extractive compression. + +**"ACON: Agent Context Optimization"** +- OpenReview submission +- [openreview.net/pdf?id=7JbSwX6bNL](https://openreview.net/pdf?id=7JbSwX6bNL) +- Framework for compressing both environment observations and interaction histories. Uses failure-driven, task-aware compression guideline optimization. + +### 1.5 Temporal Knowledge & Context Editing + +**"Zep: A Temporal Knowledge Graph Architecture for Agent Memory"** +- Rasmussen et al. — January 2025 +- [arxiv.org/abs/2501.13956](https://arxiv.org/abs/2501.13956) +- Introduces **Graphiti**, a temporally-aware knowledge graph. Facts have **validity periods** and can be **updated/overwritten** — a form of post-factum context editing. Outperforms MemGPT on Deep Memory Retrieval (94.8% vs 93.4%), 18.5% accuracy improvement on LongMemEval with 90% latency reduction. + +### 1.6 Context Distillation + +**"Learning by Distilling Context"** +- 2022 +- [arxiv.org/abs/2209.15189](https://arxiv.org/abs/2209.15189) +- Internalizes context performance gains into model weights via fine-tuning. Distills instructions, reasoning chains, and examples. + +**"Efficient LLM Context Distillation"** +- September 2024 +- [arxiv.org/abs/2409.01930](https://arxiv.org/abs/2409.01930) +- Reduces cost of internalizing system prompts into model behavior. + +### 1.7 RAG on Conversation History + +**"ReadAgent: A Human-Inspired Reading Agent with Gist Memory"** +- Google DeepMind — 2023 +- [deepmind.google/research/publications/74917](https://deepmind.google/research/publications/74917/) +- Decides what to compress into "gist memories" and what to look up when needed. 3-20x effective context extension while outperforming baselines. Similar to our CAS externalize + summary approach. + +### 1.8 Infinite Context Approaches + +**"Infini-attention: Leave No Context Behind"** +- Google (Munkhdalai et al.) — April 2024 +- [arxiv.org/abs/2404.07143](https://arxiv.org/abs/2404.07143) +- Compressive memory storing summaries of past history in a fixed-size matrix. 114x less memory, lower perplexity. + +**"InfiniteHiP: Context Up to 3 Million Tokens on a Single GPU"** +- February 2025 +- [arxiv.org/abs/2502.08910](https://arxiv.org/abs/2502.08910) +- Hierarchical token pruning for 3M tokens on a single L40s 48GB GPU without permanent loss. + +**"ReAttention: Training-Free Infinite Context with Finite Attention"** +- January 2025 +- [openreview.net/forum?id=KDGP8yAz5b](https://openreview.net/forum?id=KDGP8yAz5b) +- Expands LLaMA3.2-3B-chat by 128x to 4M tokens without training. + +### 1.9 Surveys + +**"Memory in the Age of AI Agents: A Survey"** +- Hu et al. (47 authors) — December 2025 +- [arxiv.org/abs/2512.13564](https://arxiv.org/abs/2512.13564) +- Definitive survey. Proposes "forms-functions-dynamics" taxonomy. Identifies frontiers: memory automation, RL integration, multimodal memory, multi-agent memory, trustworthiness. + +**"A Survey on the Memory Mechanism of LLM-based Agents"** +- April 2024, ACM TOIS +- [arxiv.org/abs/2404.13501](https://arxiv.org/abs/2404.13501) +- Covers short-term, long-term, episodic, and semantic memory for LLM agents. + +--- + +## 2. Engineering Articles & Blog Posts + +### From AI Labs + +**Anthropic: "Effective Context Engineering for AI Agents"** (2025) +- [anthropic.com/engineering/effective-context-engineering-for-ai-agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) +- Four strategies: system prompt optimization, tool design, strategic examples, dynamic retrieval. Defines **compaction** as summarizing conversation history while preserving architectural decisions. + +**Anthropic: "Effective Harnesses for Long-Running Agents"** (November 2025) +- [anthropic.com/engineering/effective-harnesses-for-long-running-agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) +- Multi-context-window workflows: initializer agent + coding agent leaving clear progress artifacts (`claude-progress.txt`) so fresh context windows quickly understand prior state. Relevant to our Handoff mode. + +**OpenAI: "Memory and New Controls for ChatGPT"** (April 2025) +- [openai.com/index/memory-and-new-controls-for-chatgpt](https://openai.com/index/memory-and-new-controls-for-chatgpt/) +- Two-layer approach: saved memories (explicit) and chat history (implicit retrieval). Users can **edit and delete** memories. Consumer-facing editable context. + +**Google: "Context Engineering: Sessions & Memory"** (November 2025) +- Agent intelligence comes not from the model but from context assembly. Session management, memory persistence, context assembly patterns. + +### From Tool Companies + +**Cursor: "Dynamic Context Discovery for Production Coding Agents"** (2025) +- [cursor.com/blog/dynamic-context-discovery](https://cursor.com/blog/dynamic-context-discovery) +- Five techniques: (1) long outputs written to files for selective retrieval, (2) chat history preserved as files during summarization, (3) Agent Skills standard, (4) selective MCP tool loading (**46.9% token reduction**), (5) terminal sessions as files. Philosophy: fewer details upfront, lazy-load. + +**JetBrains Research: "Cutting Through the Noise"** (December 2025, NeurIPS DL4Code) +- [blog.jetbrains.com/research/2025/12/efficient-context-management](https://blog.jetbrains.com/research/2025/12/efficient-context-management/) +- Empirical comparison: simple observation masking matched or exceeded LLM summarization while being **52% cheaper**. Summarization caused agents to run 15% longer. Developed a hybrid approach. + +**LangChain: "Context Engineering for Agents"** (June 2025) +- [rlancemartin.github.io/2025/06/23/context_engineering](https://rlancemartin.github.io/2025/06/23/context_engineering/) +- Practical guide to retrieval, summarization, and memory management patterns. + +**LangChain: "Context Management for Deep Agents"** +- [blog.langchain.com/context-management-for-deepagents](https://blog.langchain.com/context-management-for-deepagents/) +- Compression techniques: summarization, filtering stale information, retention decisions. + +**Morphic LLM: "Context Engineering: Why More Tokens Makes Agents Worse"** +- [morphllm.com/context-engineering](https://www.morphllm.com/context-engineering) +- Overfilling context windows degrades performance. Case for selective, curated context. + +--- + +## 3. Tools & Frameworks + +### Agent Memory Platforms + +| Tool | Approach | Key Feature | Relevance to Editable Context | +|------|----------|-------------|-------------------------------| +| **Letta** (ex-MemGPT) | OS-style virtual memory | Agents self-edit memory blocks via function calls | Direct: agent-initiated context editing | +| **Mem0** | Hybrid vector-graph | Sub-second retrieval, graph memory | Persistent editable memory across sessions | +| **Zep / Graphiti** | Temporal knowledge graph | Facts have validity periods, can be overwritten | Post-factum editing: update/supersede facts | +| **Cognee** | Cognitive science pipelines | Outperforms Mem0 + Graphiti on multi-hop | Knowledge structuring and editing | +| **CrewAI Memory** | LLM-scored importance | 4 memory types, ChromaDB + SQLite | Content scoring → our Pin & Decay | +| **AWS Bedrock AgentCore** | Managed service | Auto-extracts insights across sessions | Cross-session memory management | +| **Microsoft Agent Framework** | Mem0 integration | InMemoryHistoryProvider + persistent DB | Session persistence + editable memory | + +### Conversation Branching + +| Tool | Approach | Relevance | +|------|----------|-----------| +| **Forky** | Git-style DAG for LLM chats | Fork, explore alternatives, semantic merge | +| **GitChat** | Git metaphors for conversation | Branching conversations | +| **LibreChat** | Fork from any message | Creates independent sessions from shared history | +| **OpenCode** | Session fork (`Session.fork()`) | Copies messages up to a point into new session | + +### Context Compression + +| Tool | Approach | Compression | Relevance | +|------|----------|:-----------:|-----------| +| **LLMLingua** (Microsoft) | Small-model token pruning | 20x | Automated context trimming | +| **LLMLingua-2** | BERT encoder via GPT-4 distillation | Task-agnostic | Integrated in LangChain/LlamaIndex | +| **OpenCode DCP** | Dynamic context pruning plugin | Variable | Plugin for OpenCode specifically | +| **Pichay** | Demand paging proxy | 93% | Transparent evict/restore by access pattern | + +--- + +## 4. Industry Trends + +### "Context Engineering" as a Discipline + +Term popularized by Tobi Lutke (Shopify CEO) in June 2025: *"The art of providing all the context for the task to be plausibly solvable by the LLM."* Endorsed by Andrej Karpathy. By 2026, context engineering is a recognized engineering discipline encompassing system prompts, tools, retrieval, compaction, and memory management. + +### Convergence Points + +The field is converging on several patterns directly relevant to editable context: + +**1. Four-Type Memory Taxonomy.** Working (context window), Procedural (how-to), Semantic (facts), Episodic (experiences). Every major framework implements this. Our design maps: Manual editing = working memory, CAS = semantic, Handoff = episodic, Agent prompts = procedural. + +**2. Self-Editing Agents.** Letta agents edit their own memory blocks. ACE agents evolve their system prompts. Reflexion agents write self-corrections. MemOS provides explicit edit/delete APIs. Our `thread_edit` tool fits squarely in this trend. + +**3. Hierarchical/Tree Structures.** MemTree, H-MEM, and Merkle approaches use tree structures for multi-level abstraction. Our Merkle tree design (externalize → summarize → tree navigation) aligns with this direction but applies it to conversation context specifically. + +**4. Temporal Validity.** Zep/Graphiti tracks when facts become stale and allows overwriting. Our Pin & Decay scoring is a simpler version of this — relevance decays over turns, pinned items are immune. + +**5. Demand Paging.** Pichay's demand paging (93% reduction, 0.025% fault rate) validates the core CAS/externalize design. Content is evicted to backing store and paged back in on demand. Our `thread_externalize` + `thread_deref` tools implement the same pattern with agent-level control. + +### The Gap Our Design Fills + +The literature shows no unified framework that combines all of: +1. **Human-initiated edits** to conversation history (hide, replace, annotate) +2. **Agent-initiated self-edits** (retract, correct, summarize) +3. **Automated curation** (background curator, focus agent, decay scoring) +4. **Content-addressable externalization** (CAS/Merkle with on-demand deref) +5. **Cross-session persistence** (handoff artifacts, project-level side threads) +6. **Objective-aware focus management** (focus agent, side thread parking) + +Individual pieces exist across Letta, Pichay, ACE, Zep, and others. The editable context design integrates them into a coherent system for coding agent workflows. + +### What's Ahead + +**Short-term (2026):** Context engineering tooling matures. Expect every major coding agent (Cursor, Windsurf, Claude Code, OpenCode) to ship some form of context pruning/compression beyond basic compaction. Memory frameworks (Mem0, Letta) will integrate more deeply into agent harnesses. + +**Medium-term (2027):** Self-editing agents become standard. The agent-edits-its-own-context pattern (currently in Letta and ACE) will be expected behavior for production agents. Cross-session memory will be table stakes. + +**Long-term:** Context windows become a managed resource like virtual memory — the developer never thinks about size, the system transparently pages content in and out based on access patterns and relevance. The "context engineering" discipline either gets absorbed into standard agent frameworks or becomes a specialized field like database query optimization. + +--- + +## References + +### Papers +1. Packer et al. "MemGPT: Towards LLMs as Operating Systems" (2023) — arxiv:2310.08560 +2. Mason. "The Missing Memory Hierarchy: Demand Paging" (2026) — arxiv:2603.09023 +3. Rezazadeh et al. "MemTree" (2024) — arxiv:2410.14052 +4. BAI-LAB. "Memory OS of AI Agent" (2025) — arxiv:2506.06326 +5. MemTensor. "MemOS" (2025) — arxiv:2505.22101 +6. Shinn et al. "Reflexion" (2023) — arxiv:2303.11366 +7. Xu et al. "A-MEM" (2025) — arxiv:2502.12110 +8. Zhang et al. "ACE: Agentic Context Engineering" (2025) — arxiv:2510.04618 +9. Jiang et al. "LLMLingua" (2023) — arxiv:2310.05736 +10. "LongLLMLingua" (2023) — arxiv:2310.06839 +11. Li et al. "Prompt Compression Survey" (NAACL 2025) — aclanthology.org/2025.naacl-long.368 +12. "ACON: Agent Context Optimization" — openreview.net/pdf?id=7JbSwX6bNL +13. Rasmussen et al. "Zep: Temporal Knowledge Graph" (2025) — arxiv:2501.13956 +14. Hu et al. "Memory in the Age of AI Agents: A Survey" (2025) — arxiv:2512.13564 +15. "A Survey on Memory Mechanism of LLM-based Agents" (2024) — arxiv:2404.13501 +16. Munkhdalai et al. "Infini-attention" (2024) — arxiv:2404.07143 +17. "InfiniteHiP" (2025) — arxiv:2502.08910 +18. "ReAttention" (2025) — openreview.net/forum?id=KDGP8yAz5b +19. "ReadAgent" (2023) — deepmind.google/research/publications/74917 +20. "Learning by Distilling Context" (2022) — arxiv:2209.15189 +21. "H-MEM" (2025) — arxiv:2507.22925 +22. "Reflective Memory Management" (ACL 2025) — aclanthology.org/2025.acl-long.413 +23. "Pretraining Context Compressor" (ACL 2025) — aclanthology.org/2025.acl-long.1394 + +### Blog Posts & Articles +24. Anthropic. "Effective Context Engineering" (2025) +25. Anthropic. "Effective Harnesses for Long-Running Agents" (2025) +26. OpenAI. "Memory and Controls for ChatGPT" (2025) +27. Cursor. "Dynamic Context Discovery" (2025) +28. JetBrains. "Smarter Context Management" (NeurIPS DL4Code 2025) +29. LangChain. "Context Engineering for Agents" (2025) +30. LangChain. "Context Management for Deep Agents" +31. Morphic LLM. "Why More Tokens Makes Agents Worse" +32. Google. "Context Engineering: Sessions & Memory" (2025) + +### Tools & Frameworks +33. Letta (ex-MemGPT) — github.com/letta-ai/letta +34. Mem0 — mem0.ai +35. Zep / Graphiti — getzep.com +36. Cognee — github.com/topoteretes/cognee +37. CrewAI Memory — docs.crewai.com +38. LLMLingua — github.com/microsoft/LLMLingua +39. Forky — github.com/ishandhanani/forky +40. OpenCode DCP — github.com/Opencode-DCP/opencode-dynamic-context-pruning +41. AWS Bedrock AgentCore Memory +42. Microsoft Agent Framework Memory diff --git a/docs/research/EDITABLE_CONTEXT.md b/docs/research/EDITABLE_CONTEXT.md new file mode 100644 index 000000000..b00048668 --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT.md @@ -0,0 +1,600 @@ +# Frankencode: Editable Threads by Agents — Design Document + +## Problem Statement + +Currently, agents in OpenCode can only **append** to conversation threads. They produce tool results and text responses that are added sequentially. The only mechanism that "edits" context is **compaction** — a blunt instrument that summarizes everything before a boundary and hides it. Agents cannot: + +- Retract or correct a previous response +- Update a stale tool result with fresh data +- Remove irrelevant or misleading messages from their own context +- Annotate or amend previous reasoning +- Restructure a conversation to improve coherence for subsequent turns + +This document designs a system where agents can **selectively edit, annotate, hide, replace, and restructure** their own conversation threads. + +--- + +## Current Architecture (What We Have) + +### Storage Primitives Already Support Mutation + +The DB layer already provides full CRUD on messages and parts: + +| Operation | Function | Behavior | +|-----------|----------|----------| +| Upsert message | `Session.updateMessage()` | `INSERT ... ON CONFLICT DO UPDATE` on `MessageTable` | +| Delete message | `Session.removeMessage()` | `DELETE` + CASCADE to parts | +| Upsert part | `Session.updatePart()` | `INSERT ... ON CONFLICT DO UPDATE` on `PartTable` | +| Delete part | `Session.removePart()` | `DELETE` from `PartTable` | + +Bus events already fire for all mutations: `MessageV2.Event.Updated`, `Removed`, `PartUpdated`, `PartRemoved`. + +### Context Assembly Pipeline + +``` +Session.messages() → MessageV2.WithParts[] (raw from DB) + ↓ +MessageV2.filterCompacted() → MessageV2.WithParts[] (truncated at compaction boundary) + ↓ +MessageV2.toModelMessages() → ModelMessage[] (AI SDK format) + ↓ +LLM.stream() prepends system prompt, calls streamText() +``` + +### What Tools Can See + +Tools receive `ctx.messages: MessageV2.WithParts[]` — the full conversation history, **read-only**. They cannot mutate it; they can only return output strings. + +### Compaction as Precedent + +Compaction already "edits" threads by: +1. Setting `time.compacted` on tool part states (clears output, shows `[Old tool result content cleared]`) +2. Inserting a `CompactionPart` marker on a synthetic user message +3. `filterCompacted()` hiding everything before the marker + +This proves the architecture can handle mid-thread mutations. Editable threads generalizes this. + +--- + +## Design + +### Core Concept: Part-Level Edits with Visibility Control + +Rather than allowing agents to arbitrarily rewrite history (which would break audit trails), we introduce **part-level edit operations** with a **visibility layer** that controls what the LLM sees vs. what is stored. + +### 1. New Part Fields + +Extend the base part schema in `message-v2.ts`: + +```typescript +// Added to all Part types +{ + // ... existing fields ... + edit?: { + hidden: boolean // If true, excluded from LLM context (but kept in DB) + supersededBy?: PartID // Points to the replacement part + annotation?: string // Agent-provided note about why this was edited + editedAt: number // Timestamp of the edit + editedBy: string // Agent name that made the edit + } +} +``` + +**Why part-level, not message-level:** Messages are the structural unit (user turn / assistant turn). Parts are the content units (text blocks, tool calls, reasoning). Editing at the part level allows surgical precision — hide one bad tool result without losing the rest of the assistant's response. + +### 2. New Visibility Layer: `filterEdited()` + +Add a new filter step in the context assembly pipeline: + +```typescript +// In message-v2.ts +function filterEdited(messages: WithParts[]): WithParts[] { + return messages + .map(msg => ({ + ...msg, + parts: msg.parts.filter(part => !part.edit?.hidden) + })) + .filter(msg => msg.parts.length > 0) // Drop messages with no visible parts +} +``` + +The pipeline becomes: + +``` +Session.messages() + ↓ +filterCompacted() ← existing: truncate at compaction boundary + ↓ +filterEdited() ← NEW: remove hidden parts + ↓ +toModelMessages() ← existing: convert to LLM format +``` + +This keeps the edit metadata in the DB for audit/undo but removes hidden content from the LLM's view. + +### 3. New Tool: `thread_edit` + +A new built-in tool that exposes edit operations to agents: + +```typescript +// packages/opencode/src/tool/thread-edit.ts + +Tool.define("thread_edit", async () => ({ + description: `Edit the conversation thread. Operations: +- hide: Remove a part from LLM context (still stored for audit) +- unhide: Restore a hidden part +- replace: Hide a part and insert a replacement +- annotate: Add a note to a part without changing it +- summarize_range: Replace a range of messages with a summary +- retract: Hide all parts of an assistant message (self-correction)`, + + parameters: z.object({ + operation: z.enum(["hide", "unhide", "replace", "annotate", "summarize_range", "retract"]), + // For hide/unhide/annotate/retract: + target: z.object({ + messageID: z.string().optional(), + partID: z.string().optional(), + }).optional(), + // For replace: + replacement: z.string().optional(), + // For annotate: + annotation: z.string().optional(), + // For summarize_range: + range: z.object({ + fromMessageID: z.string(), + toMessageID: z.string(), + summary: z.string(), + }).optional(), + }), + + async execute(args, ctx: Tool.Context) { + // ... implementation below + } +})) +``` + +#### Operation Semantics + +**`hide`** — Mark a part as hidden. The LLM will no longer see it. +``` +target: { partID: "prt_abc123" } +→ Sets part.edit.hidden = true +→ Publishes PartUpdated event +``` + +**`unhide`** — Restore a previously hidden part. +``` +target: { partID: "prt_abc123" } +→ Sets part.edit.hidden = false +→ Publishes PartUpdated event +``` + +**`replace`** — Hide a part and insert a new TextPart with the replacement content on the same message. +``` +target: { partID: "prt_abc123" }, replacement: "corrected text" +→ Hides original part (edit.hidden = true, edit.supersededBy = newPartID) +→ Inserts new TextPart with replacement content +→ New part has edit.annotation = "Replaced " +``` + +**`annotate`** — Add metadata to a part without changing visibility. +``` +target: { partID: "prt_abc123" }, annotation: "This finding was later contradicted by..." +→ Sets part.edit.annotation (does NOT affect LLM context directly) +``` + +**`summarize_range`** — Replace a range of messages with a single summary (targeted compaction). +``` +range: { fromMessageID: "msg_aaa", toMessageID: "msg_zzz", summary: "..." } +→ Hides all parts in messages within the range +→ Inserts a new synthetic user message with a TextPart containing the summary +→ Marks it as synthetic: true so the TUI can render it distinctly +``` + +**`retract`** — Self-correction: hide all parts of an assistant message. +``` +target: { messageID: "msg_abc123" } +→ Hides all parts of the specified assistant message +→ Sets edit.annotation = "Retracted by agent" +``` + +### 4. Safety Constraints + +Agents should not be able to destroy important context or manipulate the thread maliciously. + +#### 4.1 Ownership Rules + +```typescript +const EDIT_RULES = { + // Agents can only edit their own assistant messages + canEditMessage(agent: string, message: MessageV2.Info): boolean { + if (message.role === "user") return false // Never edit user messages + return message.agent === agent // Only own messages + }, + + // Exception: summarize_range can span any messages (it hides, doesn't delete) + canSummarize(agent: string): boolean { + return agent === "build" || agent === "compaction" // Only primary agents + }, + + // Parts of user messages: read-only + canEditPart(agent: string, part: MessageV2.Part, message: MessageV2.Info): boolean { + if (message.role === "user") return false + return message.agent === agent + } +} +``` + +**Rationale:** Agents should not edit user messages (that's the user's input). Agents should only edit their own output (their assistant messages). This prevents a subagent from corrupting the primary agent's context. + +#### 4.2 Edit Budget + +To prevent runaway self-editing (an agent in a loop editing and re-editing): + +```typescript +const MAX_EDITS_PER_TURN = 10 // Max edit operations per assistant turn +const MAX_HIDDEN_RATIO = 0.7 // Cannot hide more than 70% of all parts +const PROTECTED_RECENT_MESSAGES = 2 // Cannot edit the 2 most recent turns (prevents loops) +``` + +The `PROTECTED_RECENT_MESSAGES` constraint is critical — without it, an agent could hide its own most recent output and create an infinite edit loop. + +#### 4.3 Permission Integration + +The `thread_edit` tool integrates with `PermissionNext`: + +```jsonc +// In opencode.json +{ + "permission": { + "thread_edit": "ask" // Default: ask user before editing thread + // Can be set to "allow" for autonomous operation + } +} +``` + +For `summarize_range` and `retract`, always require permission (even if `thread_edit` is set to `allow`), since these are high-impact operations. + +### 5. Database Migration + +```sql +-- No schema change needed for the main tables. +-- The `edit` metadata is stored inside the `data` JSON column of the `part` table. +-- However, we need an index for efficient lookups of edited parts: + +CREATE INDEX IF NOT EXISTS idx_part_edited + ON part(session_id) + WHERE json_extract(data, '$.edit.hidden') = true; +``` + +Since parts are stored as JSON blobs in the `data` column, the `edit` field is simply a new optional key in the JSON. No migration is needed for the column structure — only an index for query performance. + +### 6. Changes to `toModelMessages()` + +In `message-v2.ts`, the `toModelMessages()` function needs to respect the `edit` field: + +```typescript +// Inside toModelMessages(), when processing parts: +for (const part of msg.parts) { + // Skip hidden parts + if (part.edit?.hidden) continue + + // For parts with supersededBy, skip (the replacement part will be included) + if (part.edit?.supersededBy) continue + + // ... existing conversion logic ... +} +``` + +Alternatively (and preferably), `filterEdited()` runs **before** `toModelMessages()` so the conversion function doesn't need changes. + +### 7. Changes to the Processor Loop + +In `processor.ts`, the message re-read between loop iterations needs to apply the new filter: + +```typescript +// Before each LLM call in the while(true) loop: +const raw = await Session.messages({ sessionID }) +const afterCompaction = MessageV2.filterCompacted(raw) +const afterEdits = MessageV2.filterEdited(afterCompaction) // NEW +const modelMessages = MessageV2.toModelMessages(afterEdits, model) +``` + +This means edits take effect **immediately on the next turn** — if an agent hides a part in turn N, the LLM won't see it in turn N+1. + +### 8. TUI Rendering + +The TUI needs to show edited content differently: + +#### 8.1 Hidden Parts + +Hidden parts should be **collapsed by default** with an indicator: + +``` +┃ [hidden by build agent: "Retracted — contained incorrect file path"] +┃ ▸ Click to expand original content +``` + +#### 8.2 Replaced Parts + +Show the replacement with a subtle indicator: + +``` +┃ The correct implementation uses a HashMap... +┃ ↻ replaced original (was: "The correct implementation uses a TreeMap...") +``` + +#### 8.3 Annotations + +Show as inline notes: + +``` +┃ The API returns a 200 status code. +┃ 📌 build: "Later confirmed this is actually 201 for POST requests" +``` + +#### 8.4 Summarized Ranges + +Show as a collapsed block: + +``` +┃ ━━━ 12 messages summarized ━━━ +┃ Summary: Explored the authentication module, found that JWT tokens +┃ are validated in middleware.ts. Identified 3 potential issues... +┃ ▸ Expand original messages +``` + +### 9. Plugin Hooks + +New hooks for the plugin system: + +```typescript +interface Hooks { + // ... existing hooks ... + + // Called before a thread edit is applied + "thread.edit.before"?: (input: { + operation: string + target?: { messageID?: string; partID?: string } + agent: string + }) => Promise<{ allow: boolean; reason?: string }> + + // Called after a thread edit is applied + "thread.edit.after"?: (input: { + operation: string + target?: { messageID?: string; partID?: string } + agent: string + result: "success" | "denied" + }) => Promise +} +``` + +This lets plugins enforce custom policies (e.g., "never hide tool results from security-audit tools") or log edit operations. + +### 10. Event Bus Integration + +New events on the existing bus: + +```typescript +namespace ThreadEdit { + export const Event = { + PartHidden: Bus.event("thread.edit.part.hidden"), + PartUnhidden: Bus.event("thread.edit.part.unhidden"), + PartReplaced: Bus.event("thread.edit.part.replaced"), + PartAnnotated: Bus.event("thread.edit.part.annotated"), + RangeSummarized: Bus.event("thread.edit.range.summarized"), + MessageRetracted: Bus.event("thread.edit.message.retracted"), + } +} +``` + +The TUI subscribes to these events via SSE to update the display in real-time. + +--- + +## Implementation Plan + +### Phase 1: Foundation (Part-Level Visibility) + +**Files to modify:** + +| File | Change | +|------|--------| +| `packages/opencode/src/session/message-v2.ts` | Add `edit` field to Part schema; implement `filterEdited()` | +| `packages/opencode/src/session/index.ts` | No changes needed (upsert/remove already exist) | +| `packages/opencode/src/session/processor.ts` | Insert `filterEdited()` into the message pipeline | +| `packages/opencode/src/session/llm.ts` | Ensure `filterEdited()` is applied before `toModelMessages()` | + +**New files:** + +| File | Purpose | +|------|---------| +| `packages/opencode/src/session/thread-edit.ts` | Core edit logic: `hide()`, `unhide()`, `replace()`, `annotate()`, `retract()`, `summarizeRange()` with ownership and budget enforcement | + +**Estimated scope:** ~300 lines of new code, ~50 lines of modifications. + +### Phase 2: Tool Exposure + +**New files:** + +| File | Purpose | +|------|---------| +| `packages/opencode/src/tool/thread-edit.ts` | `thread_edit` tool definition wrapping the core logic | + +**Files to modify:** + +| File | Change | +|------|--------| +| `packages/opencode/src/tool/registry.ts` | Register `ThreadEditTool` in the built-in tools list | +| `packages/opencode/src/permission/` | Add default permission rule for `thread_edit` | + +**Estimated scope:** ~150 lines new, ~20 lines modifications. + +### Phase 3: TUI Integration + +**Files to modify:** + +| File | Change | +|------|--------| +| `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | Render hidden/replaced/annotated/summarized parts with distinct styling | +| `packages/opencode/src/cli/cmd/tui/context/sdk.tsx` | Subscribe to new `thread.edit.*` SSE events | + +**Estimated scope:** ~200 lines modifications. + +### Phase 4: Plugin Hooks & Events + +**Files to modify:** + +| File | Change | +|------|--------| +| `packages/plugin/src/index.ts` | Add `thread.edit.before` / `thread.edit.after` hook types | +| `packages/opencode/src/session/thread-edit.ts` | Call `Plugin.trigger()` before/after edits | + +**Estimated scope:** ~50 lines. + +### Phase 5: Agent Prompting + +**Files to modify:** + +| File | Change | +|------|--------| +| `packages/opencode/src/agent/prompts/` | Add instructions for when/how agents should use `thread_edit` | + +Prompt additions should teach agents: +- When to retract (found an error in their own previous output) +- When to hide (a tool result is no longer relevant and wastes context) +- When to summarize ranges (long exploration can be compressed) +- When NOT to edit (don't hide errors — the user needs to see them) + +--- + +## Interaction with Existing Systems + +### Compaction + +Compaction and editable threads are complementary: +- **Compaction** is automatic, threshold-based, and summarizes everything before a boundary +- **Thread editing** is agent-directed, surgical, and preserves the thread structure + +`filterEdited()` runs **after** `filterCompacted()`. If compaction has already hidden a message, editing it is a no-op. If an agent hides parts and then compaction triggers, the compaction agent sees the edited (filtered) view. + +### Fork + +When forking a session (`Session.fork()`), edit metadata is preserved on the copied parts. The fork contains the same visibility state as the original at the fork point. + +### Session Sharing + +When sharing a session, hidden parts should be **excluded** from the shared data (they were hidden for a reason). The `share-next.ts` module should apply `filterEdited()` before serializing. + +### Undo/Redo (Snapshots) + +The snapshot system (`packages/opencode/src/snapshot/`) tracks file changes, not conversation edits. Thread edits need their own undo mechanism: + +```typescript +// In thread-edit.ts +interface EditRecord { + id: string + sessionID: SessionID + operation: string + target: { messageID?: MessageID; partID?: PartID } + before: Partial // Snapshot of the part before the edit + after: Partial // State after the edit + timestamp: number + agent: string +} +``` + +Store edit records in a new `thread_edit_log` table (or in the file-based storage). This enables: +- `undo_edit(editID)` — Restore the part to its pre-edit state +- `edit_history(sessionID)` — List all edits for audit + +### Stats + +The `stats` CLI command should include edit metrics: +- Total edits per session +- Parts hidden / replaced / retracted +- Context tokens saved by edits + +--- + +## Edge Cases + +### Agent Edits Its Own Current Turn + +If an agent tries to edit a part from its own current (in-progress) turn, this should be rejected. The `PROTECTED_RECENT_MESSAGES = 2` guard handles this, but additionally, any part with `ToolState.status === "running"` should be uneditable. + +### Concurrent Edits (Subagents) + +If a subagent (via `task` tool) is running in a child session, it should not be able to edit the parent session's thread. Edit operations are scoped to `ctx.sessionID`. + +### Compacted Tool Outputs + +If a tool output has already been compacted (`time.compacted` set), hiding it via `thread_edit` is redundant but harmless. The `[Old tool result content cleared]` placeholder is still shown unless the part is hidden. + +### Empty Messages After Edits + +If all parts of a message are hidden, `filterEdited()` drops the message entirely. This could create gaps (user message with no following assistant message). The `toModelMessages()` function already handles missing messages gracefully — it simply skips them. + +### Token Counting + +After edits, the token count stored on the assistant message (`tokens.input`, `tokens.output`) no longer reflects what the LLM actually saw. This is informational only (cost tracking) and doesn't affect behavior. Future calls will re-count tokens based on the filtered message list. + +--- + +## API Surface Summary + +### New Tool + +``` +thread_edit(operation, target?, replacement?, annotation?, range?) +``` + +### New Part Field + +```typescript +part.edit?: { + hidden: boolean + supersededBy?: PartID + annotation?: string + editedAt: number + editedBy: string +} +``` + +### New Filter Function + +```typescript +MessageV2.filterEdited(messages: WithParts[]): WithParts[] +``` + +### New Plugin Hooks + +``` +thread.edit.before → { allow, reason? } +thread.edit.after → void +``` + +### New Bus Events + +``` +thread.edit.part.hidden +thread.edit.part.unhidden +thread.edit.part.replaced +thread.edit.part.annotated +thread.edit.range.summarized +thread.edit.message.retracted +``` + +--- + +## Why This Design + +**Part-level, not message-level:** Surgical precision. An assistant message may have 5 tool calls and 3 text blocks. Hiding one bad tool result shouldn't lose the other 7 parts. + +**Visibility layer, not deletion:** Audit trail preservation. The original content stays in SQLite. Users can expand hidden content in the TUI. Edit history is reviewable. + +**Agent-scoped ownership:** Prevents cross-agent context manipulation. A subagent can't gaslight the primary agent by editing its messages. + +**Budget limits:** Prevents infinite self-editing loops. An agent that keeps hiding and re-generating would hit the edit budget and be forced to move forward. + +**Builds on existing primitives:** No new tables, no schema migration (JSON fields), reuses the existing upsert/event/bus infrastructure. The `filterEdited()` function mirrors the established `filterCompacted()` pattern. diff --git a/docs/research/EDITABLE_CONTEXT_FOCUS.md b/docs/research/EDITABLE_CONTEXT_FOCUS.md new file mode 100644 index 000000000..6df9af869 --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_FOCUS.md @@ -0,0 +1,1031 @@ +# Editable Context — Focus Agent & Side Thread System + +## The Problem + +During a coding session, conversations naturally branch: + +``` +Main objective: "Add pagination to the API" + → Turn 4: Agent discovers auth middleware has no rate limiting + → Turn 8: Agent finds a race condition in the DB connection pool + → Turn 12: Agent notices the test fixtures are outdated + → Turn 15: Agent realizes the ORM version is 2 majors behind +``` + +Today, these side discoveries have three fates: +1. **Agent chases them** — loses focus, burns context, pagination still isn't done at turn 50 +2. **Agent ignores them** — findings are lost, buried in a 200-turn conversation +3. **User manually notes them** — interrupts flow, burden on the user + +The focus system solves this with two components: +- A **focus agent** that ruthlessly keeps the conversation on-topic +- **Side threads** as a first-class data structure — detected, parked, summarized, and actionable + +--- + +## Why a Separate Focus Agent (Not the Curator) + +The curator and focus agent serve different purposes: + +| | Curator | Focus Agent | +|---|---|---| +| **Question** | "Is this content still *fresh*?" | "Is this content *on-topic*?" | +| **Judgment** | Temporal — age, staleness, token size | Directional — relevance to objective | +| **Actions** | Hide, externalize, prune | Park, redirect, block, promote | +| **Needs to understand objective?** | No (just needs recency) | Yes (deeply) | +| **Personality** | Janitor — quietly cleans up | Project manager — actively redirects | +| **When it runs** | Between turns (cleanup) | During/after turns (enforcement) | + +Merging them overloads one prompt with two kinds of reasoning. The curator looks backward ("what's stale?"). The focus agent looks forward ("where should we be going?"). These are different cognitive tasks. + +### Division of Responsibility + +``` +Main agent produces output + ↓ +Focus agent reviews: "Is this on-topic?" + ├── Yes → pass through + ├── No, but valuable → park as side thread, externalize, redirect agent + └── No, and not valuable → flag for curator + ↓ +Curator reviews: "Is anything stale?" + ├── Stale tool output → externalize or hide + ├── Redundant content → hide + └── Below decay threshold → externalize +``` + +The focus agent runs **first** (it may park content that the curator would otherwise just hide — parking is better than hiding because it preserves the finding). The curator runs **second** on whatever remains. + +--- + +## The Focus Agent + +### Agent Definition + +```typescript +{ + name: "focus", + hidden: true, + description: "Keeps the conversation focused on the current objective", + tools: [ + "thread_park", // Park side threads + "thread_edit", // Hide/externalize divergent content + "thread_externalize", // CAS-store divergent content + "thread_list", // Check existing threads (avoid duplicates) + "question", // Ask user about critical findings + ], + maxSteps: 8, // More steps than curator (needs to park + edit + externalize) + model: "small", // Fast model — judgment, not generation + temperature: 0, +} +``` + +### When It Runs + +The focus agent runs at two points: + +**1. Post-turn (after main agent completes a turn):** + +``` +Main agent finishes turn + → Focus agent reviews the turn's output + → Parks side threads, externalizes divergent content + → Leaves markers in the thread + → Curator runs (cleans up stale content) + → User sees clean, focused thread +``` + +**2. Mid-conversation injection (via system prompt):** + +The focus agent's assessment is injected into the main agent's system prompt so the main agent *self-corrects*: + +``` +## Focus Status (updated by focus agent) + +Current objective: "Add pagination to the API" +On-track: YES +Parked side threads: 3 (use /threads to see) +Warning: Last turn showed signs of diverging into auth concerns. +Stay focused on pagination. If you find unrelated issues, note them +briefly and I will park them. +``` + +This is the "ruthless" part — the main agent reads this every turn and is constantly reminded to stay on track. The focus agent updates this status block after each review. + +### Focus Agent Prompt + +``` +You are the focus agent. Your single job: keep the conversation on the current objective. + +## Current Objective +{objective_tracker.current} + +## Related Files +{objective_tracker.relatedFiles} + +## Your Task +Review the latest turn(s) and: + +1. DETECT divergence: Did the agent start investigating something off-topic? + - File reads/edits outside the objective's scope + - Agent says "I also noticed...", "While looking at X, I found Y..." + - Errors from tangential systems + - The agent going down a rabbit hole without user request + +2. PARK valuable divergences as side threads: + - Use thread_park(title, description, sourcePartIDs, priority, category) + - Externalize the supporting content to CAS via thread_externalize + - Hide the divergent inline content via thread_edit(hide) + - Leave a brief marker in context + +3. REDIRECT by updating the focus status block (injected into main agent's system prompt) + +4. ASK the user if a divergence looks critical (security, data loss, crash): + - "I noticed [issue]. Park it, or switch to it now?" + +5. DO NOT park: + - Content the user explicitly asked about + - Content directly required by the current objective + - Content the agent is actively building on for the objective + +## Parked threads (do not duplicate): +{existing_threads_summary} + +Be ruthless. An agent that does 10 things poorly is worse than one that does +1 thing well. If the agent is exploring something the user didn't ask for, +park it immediately. The user can always promote a parked thread later. +``` + +### Focus Intensity Levels + +The user can tune how aggressive the focus agent is: + +```jsonc +{ + "editableContext": { + "focus": { + "intensity": "moderate" + // "relaxed" — only parks obvious divergences, never interrupts + // "moderate" — parks divergences, asks about critical ones + // "strict" — parks aggressively, redirects mid-turn, blocks rabbit holes + } + } +} +``` + +**Relaxed:** Focus agent runs every 3 turns. Only parks things that are clearly off-topic (different directory, different subsystem). Never asks the user. + +**Moderate (default):** Focus agent runs every turn. Parks divergences, asks about critical findings, updates the focus status block. + +**Strict:** Focus agent runs every turn AND injects a hard directive into the main agent's system prompt: + +``` +STRICT FOCUS MODE: Do NOT investigate anything outside of these files: +{objective_tracker.relatedFiles} +If you encounter an issue in other files, state it in one sentence and move on. +The focus agent will park it. +``` + +In strict mode, the focus agent can also intervene *during* a tool-call loop by setting a flag that the processor checks: + +```typescript +// In processor.ts, inside the tool-call loop: +if (focusAgent.shouldInterrupt(currentToolCall)) { + // Inject a synthetic message: "Focus: you're diverging. Return to {objective}." + // The main agent sees this as a system-level redirect +} +``` + +This is the most aggressive mode — useful for long, expensive sessions where staying on track matters. + +--- + +## Side Threads as First-Class Data Structure + +### Schema + +```typescript +interface SideThread { + id: string // Unique ID (prefixed "thr_") + title: string // "Race condition in DB connection pool" + description: string // 2-3 sentence summary of the finding + status: "parked" | "investigating" | "resolved" | "integrated" | "deferred" + + // Source context + sourceSessionID: string // Where it was discovered + sourceMessageID: string // Which turn + sourcePartIDs: string[] // Specific parts that contain the finding + casRefs: string[] // CAS hashes of externalized supporting content + + // Classification + priority: "critical" | "high" | "medium" | "low" + category: "bug" | "tech-debt" | "security" | "performance" | "test" | "other" + relatedFiles: string[] // Files involved + blockedBy?: string[] // IDs of threads this depends on + blocks?: string[] // IDs of threads this blocks + + // Investigation + investigationSessionID?: string // Child session if promoted to investigation + resolution?: { + summary: string // What was found/done + outcome: "fixed" | "wont-fix" | "duplicate" | "not-a-problem" + resolvedAt: number + } + + // Lifecycle + createdAt: number + createdBy: string // "focus" | "build" | "user" + updatedAt: number + projectID: string +} +``` + +### Storage + +Project-level SQLite table (survives across sessions): + +```sql +CREATE TABLE side_thread ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'parked', + priority TEXT NOT NULL DEFAULT 'medium', + category TEXT NOT NULL DEFAULT 'other', + source_session_id TEXT, + source_message_id TEXT, + source_part_ids TEXT, -- JSON array + cas_refs TEXT, -- JSON array + related_files TEXT, -- JSON array + blocked_by TEXT, -- JSON array of thread IDs + blocks TEXT, -- JSON array of thread IDs + investigation_session_id TEXT, + resolution TEXT, -- JSON object + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_side_thread_project ON side_thread(project_id, status); +``` + +**Why project-level:** A side thread discovered in Session 1 might be investigated in Session 5. Project-level means every session sees the full backlog. + +### Thread Lifecycle + +``` + ┌─────────┐ + detect │ │ user: "/park" + ─────────▶│ PARKED │◀────────────── + │ │ + └────┬────┘ + │ + ┌──────────┼──────────┐ + │ investigate │ promote + ▼ ▼ + ┌────────────────┐ ┌──────────────┐ + │ INVESTIGATING │ │ (becomes │ + │ (subagent │ │ main │ + │ session) │ │ objective) │ + └───────┬────────┘ └──────────────┘ + │ + ┌──────┼──────┐ + │ │ + ▼ ▼ +┌──────────┐ ┌──────────┐ +│ RESOLVED │ │ DEFERRED │ +│ (done) │ │ (later) │ +└──────────┘ └──────────┘ + │ + ▼ +┌──────────────┐ +│ INTEGRATED │ +│ (fix merged │ +│ into main) │ +└──────────────┘ +``` + +### Thread Context Extraction + +When the focus agent parks a thread, it doesn't just note the title — it captures the full context chain that led to the discovery: + +```typescript +async function extractThreadContext( + messages: MessageV2.WithParts[], + divergentPartIDs: string[], + sessionID: string, +): Promise { + const casRefs: string[] = [] + + for (const partID of divergentPartIDs) { + // Find the part and its surrounding context + const part = findPart(messages, partID) + if (!part) continue + + // Externalize the part itself + const hash = await CAS.store(partContent(part), { + type: "side_thread_context", + sessionID, + partID, + }) + casRefs.push(hash) + + // Also externalize the triggering tool call if this is a tool result + if (part.type === "tool" && part.state.status === "completed") { + const inputHash = await CAS.store(JSON.stringify(part.state.input), { + type: "side_thread_trigger", + sessionID, + partID, + }) + casRefs.push(inputHash) + } + } + + return casRefs +} +``` + +This means when someone later investigates the thread, they get: +- The exact tool result that revealed the issue +- The tool input that triggered it +- Any surrounding text where the agent discussed the finding + +No need to search through old sessions or guess what the context was. + +### Thread Dependency Tracking + +Side threads can reference each other: + +``` +#thr_abc: "DB pool race condition" + blocks: [#thr_def] + +#thr_def: "Add connection retry logic" + blockedBy: [#thr_abc] +``` + +This is lightweight — just a note, not enforced. The focus agent sets it when it detects relationships: + +``` +Focus agent: "Thread #thr_def (retry logic) depends on understanding #thr_abc +(race condition). Marking dependency." +``` + +When the user investigates `thr_def`, the investigation prompt includes: "Note: this thread depends on #thr_abc (DB pool race condition). You may need to dereference that thread's context too." + +--- + +## Tools + +### `thread_park` + +```typescript +Tool.define("thread_park", async () => ({ + description: `Park a divergent finding as a side thread. Stores the finding with +its context at the project level. Survives across sessions. The finding is +externalized from the current conversation but fully recoverable.`, + + parameters: z.object({ + title: z.string().describe("Short title (under 80 chars)"), + description: z.string().describe("2-3 sentence summary: what was found, why it matters, where it is"), + sourcePartIDs: z.array(z.string()).describe("Part IDs containing the finding"), + priority: z.enum(["critical", "high", "medium", "low"]), + category: z.enum(["bug", "tech-debt", "security", "performance", "test", "other"]), + relatedFiles: z.array(z.string()).optional(), + blockedBy: z.array(z.string()).optional().describe("IDs of threads this depends on"), + blocks: z.array(z.string()).optional().describe("IDs of threads this blocks"), + }), + + async execute(args, ctx) { + // 1. Extract and externalize context to CAS + const casRefs = await extractThreadContext(ctx.messages, args.sourcePartIDs, ctx.sessionID) + + // 2. Create SideThread record + const thread = await SideThread.create({ + ...args, + casRefs, + sourceSessionID: ctx.sessionID, + sourceMessageID: ctx.messageID, + createdBy: ctx.agent, + projectID: Instance.project.id, + }) + + // 3. Hide the divergent inline content + for (const partID of args.sourcePartIDs) { + await ThreadEdit.hide({ + sessionID: ctx.sessionID, + partID, + messageID: /* lookup */, + agent: ctx.agent, + }) + } + + // 4. Insert marker + return { + title: `Parked: ${args.title}`, + metadata: { threadID: thread.id, priority: args.priority, category: args.category }, + output: `[Side thread ${thread.id} parked: "${args.title}" (${args.priority}, ${args.category})] +${args.description} +Related files: ${args.relatedFiles?.join(", ") ?? "none identified"} +Context preserved in ${casRefs.length} CAS objects. Use thread_investigate to explore later.`, + } + } +})) +``` + +### `thread_list` + +```typescript +Tool.define("thread_list", async () => ({ + description: `List side threads for this project.`, + parameters: z.object({ + status: z.enum(["parked", "investigating", "resolved", "deferred", "all"]).optional().default("all"), + }), + async execute(args, ctx) { + const threads = await SideThread.list({ projectID: Instance.project.id, status: args.status }) + if (threads.length === 0) return { output: "No side threads found.", title: "0 threads", metadata: {} } + + const lines = threads.map(t => + `${t.id} [${t.status}, ${t.priority}, ${t.category}] "${t.title}"\n ${t.description}\n Files: ${t.relatedFiles.join(", ") || "—"}` + ) + return { output: lines.join("\n\n"), title: `${threads.length} threads`, metadata: { count: threads.length } } + } +})) +``` + +### `thread_investigate` + +Wraps the existing `task` tool with pre-loaded side thread context: + +```typescript +Tool.define("thread_investigate", async () => ({ + description: `Investigate a parked side thread. Spawns a subagent session +pre-loaded with the thread's full context (dereferenced from CAS).`, + + parameters: z.object({ + threadID: z.string(), + approach: z.string().optional().describe("Specific investigation approach (optional)"), + agent: z.string().optional().default("general").describe("Agent type to use"), + }), + + async execute(args, ctx) { + const thread = await SideThread.get(args.threadID) + if (!thread) return { output: `Thread ${args.threadID} not found`, title: "Error", metadata: {} } + if (thread.status === "investigating") + return { output: `Thread already being investigated in session ${thread.investigationSessionID}`, title: "Already active", metadata: {} } + + // Dereference CAS context + const contextParts: string[] = [] + for (const hash of thread.casRefs) { + const blob = await CAS.get(hash) + contextParts.push(blob.content) + } + + // Build investigation prompt + const prompt = [ + `Investigate this finding:\n`, + `**Title:** ${thread.title}`, + `**Description:** ${thread.description}`, + `**Priority:** ${thread.priority} | **Category:** ${thread.category}`, + `**Related files:** ${thread.relatedFiles.join(", ")}`, + thread.blockedBy?.length ? `**Depends on:** ${thread.blockedBy.join(", ")}` : "", + `\n**Original context:**\n${contextParts.join("\n---\n")}`, + `\n**Approach:** ${args.approach ?? "Determine the scope of the issue, assess severity, and suggest a fix."}`, + ].filter(Boolean).join("\n") + + // Update status + await SideThread.update(args.threadID, { status: "investigating" }) + + // Spawn via existing task infrastructure + // (uses Session.create + SessionPrompt.prompt internally) + const session = await Session.create({ + parentID: ctx.sessionID, + title: `Investigate: ${thread.title}`, + }) + await SideThread.update(args.threadID, { investigationSessionID: session.id }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + messageID: MessageID.ascending(), + model: ctx.model ?? thread.sourceModel, + agent: args.agent, + parts: [{ type: "text", text: prompt }], + }) + + // Extract result and update thread + const resultText = result.parts.findLast(p => p.type === "text")?.text ?? "" + await SideThread.update(args.threadID, { + status: "resolved", + resolution: { summary: resultText, outcome: "fixed", resolvedAt: Date.now() }, + }) + + return { + title: `Investigated: ${thread.title}`, + metadata: { threadID: args.threadID, sessionID: session.id }, + output: `Investigation of ${thread.title}:\n\n${resultText}\n\ntask_id: ${session.id}`, + } + } +})) +``` + +### `thread_promote` + +```typescript +Tool.define("thread_promote", async () => ({ + description: `Promote a side thread to the main objective. The current objective +is saved as a new side thread, and this thread becomes the focus.`, + + parameters: z.object({ threadID: z.string() }), + + async execute(args, ctx) { + const thread = await SideThread.get(args.threadID) + if (!thread) return { output: "Thread not found", title: "Error", metadata: {} } + + // Park current objective as a side thread + const currentObjective = await ObjectiveTracker.get(ctx.sessionID) + if (currentObjective) { + await SideThread.create({ + title: currentObjective.current, + description: `Previous main objective, parked when promoting ${args.threadID}`, + priority: "high", + category: "other", + sourceSessionID: ctx.sessionID, + createdBy: ctx.agent, + projectID: Instance.project.id, + casRefs: [], + sourcePartIDs: [], + relatedFiles: currentObjective.relatedFiles ?? [], + }) + } + + // Promote: update objective tracker + await ObjectiveTracker.set(ctx.sessionID, { + current: thread.title, + approach: thread.description, + relatedFiles: thread.relatedFiles, + }) + + // Dereference CAS context into conversation + for (const hash of thread.casRefs) { + const blob = await CAS.get(hash) + // Inject as synthetic text part so the agent has the context + await Session.updatePart({ + id: PartID.ascending(), + messageID: ctx.messageID, + sessionID: ctx.sessionID, + type: "text", + text: `[Context from side thread ${args.threadID}]\n${blob.content}`, + synthetic: true, + }) + } + + await SideThread.update(args.threadID, { status: "integrated" }) + + return { + title: `Promoted: ${thread.title}`, + metadata: { threadID: args.threadID }, + output: `Objective updated to: "${thread.title}"\nPrevious objective parked as side thread.\n${thread.casRefs.length} context objects loaded.`, + } + } +})) +``` + +### `thread_resolve` + +```typescript +Tool.define("thread_resolve", async () => ({ + description: `Mark a side thread as resolved or deferred.`, + parameters: z.object({ + threadID: z.string(), + outcome: z.enum(["fixed", "wont-fix", "duplicate", "not-a-problem", "deferred"]), + summary: z.string().describe("What was done or decided"), + }), + async execute(args, ctx) { + const status = args.outcome === "deferred" ? "deferred" : "resolved" + await SideThread.update(args.threadID, { + status, + resolution: { summary: args.summary, outcome: args.outcome, resolvedAt: Date.now() }, + }) + return { + title: `${args.outcome}: ${args.threadID}`, + metadata: { threadID: args.threadID, outcome: args.outcome }, + output: `Thread ${args.threadID} marked as ${args.outcome}: ${args.summary}`, + } + } +})) +``` + +--- + +## Focus Agent Execution Model + +### Post-Turn Review + +```typescript +// In the session orchestration layer, after main agent finishes a turn: + +async function postTurnReview(sessionID: SessionID, model: Provider.Model, abort: AbortSignal) { + const config = await Config.get() + const focusConfig = config.editableContext?.focus + if (!focusConfig?.enabled) return + + // Check frequency + const turnCount = await getTurnCount(sessionID) + const frequency = focusConfig.intensity === "relaxed" ? 3 : 1 + if (turnCount % frequency !== 0) return + + // Check minimum turns + if (turnCount < (focusConfig.minTurns ?? 4)) return + + const messages = await Session.messages({ sessionID }) + const objective = await ObjectiveTracker.get(sessionID) + const existingThreads = await SideThread.list({ projectID: Instance.project.id, status: "all" }) + + // Create focus agent session (hidden, not shown to user) + const focusAgent = await Agent.get("focus") + const focusModel = focusAgent.model + ? await Provider.getModel(focusAgent.model.providerID, focusAgent.model.modelID) + : model // fall back to main model's small variant + + const focusMsg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + agent: "focus", + // Hidden: not rendered in TUI message list + summary: false, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: focusModel.id, + providerID: focusModel.providerID, + time: { created: Date.now() }, + }) + + const processor = SessionProcessor.create({ + assistantMessage: focusMsg, + sessionID, + model: focusModel, + abort, + }) + + // The focus agent gets: + // - Full message history (to see what the main agent did) + // - Current objective + // - Existing side threads (to avoid duplicates) + // - Its focus tools + await processor.process({ + user: /* synthetic focus review prompt */, + agent: focusAgent, + tools: { + thread_park: ThreadParkTool, + thread_edit: ThreadEditTool, + thread_externalize: ThreadExternalizeTool, + thread_list: ThreadListTool, + question: QuestionTool, // for asking user about critical findings + }, + messages: MessageV2.toModelMessages( + MessageV2.filterEdited(MessageV2.filterCompacted(messages)), + focusModel + ), + system: [buildFocusPrompt(objective, existingThreads, focusConfig.intensity)], + model: focusModel, + }) + + // After focus agent runs, update the focus status block + // (injected into main agent's system prompt on next turn) + await updateFocusStatus(sessionID, objective, existingThreads) +} +``` + +### System Prompt Injection (Focus Pressure) + +The focus agent's assessment is injected into the main agent's system prompt: + +```typescript +// In session/llm.ts, during system prompt assembly: +async function buildSystemPrompt(sessionID, agent, model) { + const parts = [ + agent.prompt ?? SystemPrompt.provider(model), + // ... existing system prompt parts ... + ] + + // Inject focus status if focus agent is enabled + const focusStatus = await FocusStatus.get(sessionID) + if (focusStatus) { + parts.push(focusStatus.block) + } + + return parts +} +``` + +The focus status block looks like: + +```markdown +## Focus Status + +**Objective:** Add pagination to the API +**Related files:** src/api/users.ts, src/api/pagination.ts, src/db/queries.ts +**On-track:** YES +**Parked threads:** 3 + - thr_abc: DB pool race condition (medium, bug) + - thr_def: Auth rate limiting (medium, security) + - thr_ghi: ORM upgrade (low, tech-debt) + +Stay focused on the objective. If you find unrelated issues, note them +in one sentence. The focus agent will park them with full context. +``` + +In **strict** mode, the block is more aggressive: + +```markdown +## STRICT FOCUS MODE + +**Objective:** Add pagination to the API +**Allowed scope:** src/api/**, src/db/queries.ts, tests/api/** +**Parked threads:** 3 + +DO NOT investigate files outside the allowed scope. +DO NOT chase tangential issues. +If you encounter something outside scope, state it in ONE sentence and return to the objective. +``` + +### Focus Agent's Hidden Messages + +The focus agent's own messages (its analysis, tool calls to `thread_park`, etc.) are marked as hidden agent output — they don't appear in the TUI's main message stream. The user sees only: + +1. **Toast notifications:** "Parked: DB pool race condition (medium)" +2. **Thread markers** in the conversation: `[Parked: thr_abc — DB pool race condition]` +3. **The focus status block** in the system prompt (visible if user inspects it) +4. **Side thread panel** in the TUI sidebar (if enabled) + +If the focus agent asks a question (critical finding), that question *does* appear in the TUI — it's surfaced via the `question` tool, same as any other agent question. + +--- + +## Detection Heuristics + +The focus agent uses LLM judgment (it's an agent, not regex), but these heuristics in its prompt guide it: + +### 1. File Divergence + +``` +If the main agent reads or edits files outside the objective's related files +and directories, that's likely a divergence. Exception: dependency files +(package.json, imports) and config files that are needed for the objective. +``` + +### 2. Language Patterns + +``` +Watch for these phrases from the main agent: +- "I also noticed..." +- "While looking at X, I found Y..." +- "Unrelated, but..." +- "Side note:", "By the way..." +- "There's also a problem with..." +- "I should mention..." +These signal the agent has discovered something off-topic. +``` + +### 3. Error Context + +``` +If a tool error comes from a file/system unrelated to the objective, +that's a side discovery. The agent may be tempted to fix it. +Park it unless the error blocks the main objective. +``` + +### 4. Time-on-Task + +``` +If the agent has spent 3+ consecutive turns on something that isn't +directly advancing the objective (no progress on objective files, +no new test results, no code changes in scope), it may be in a rabbit hole. +``` + +### 5. User Intent + +``` +If the user explicitly asked about something, that is NEVER a side thread, +even if it seems off-topic. The user defines the main thread. +Only park things the agent discovered on its own. +``` + +--- + +## User Interaction + +### Slash Commands + +| Command | Action | +|---------|--------| +| `/threads` | List all side threads | +| `/threads parked` | List only parked | +| `/investigate thr_abc` | Investigate with subagent | +| `/promote thr_abc` | Swap into main objective | +| `/resolve thr_abc fixed "done"` | Mark resolved | +| `/park "title" "description"` | Manually park | +| `/focus strict` | Switch to strict focus mode | +| `/focus relaxed` | Switch to relaxed mode | +| `/focus off` | Disable focus agent | + +### User-Initiated Parking + +``` +User: "park that auth issue and let's keep going with pagination" + +Main agent: [calls thread_park] + Parked #thr_def: "Auth middleware missing rate limiting" + Priority: medium | Category: security + Files: src/auth/middleware.ts + [continues with pagination] +``` + +### Focus Agent Question (Critical Finding) + +``` +Focus Agent: While reviewing the last turn, I found that the agent discovered +an unhandled exception in the payment processing module (src/payments/charge.ts:89) +that could cause double-charges. + + [1] Park as critical (investigate soon) + [2] Park as medium + [3] Switch to it now + [4] Ignore +> _ +``` + +The focus agent asks via the `question` tool. Timeout: 60 seconds, default: option 1 (park as critical). + +### Investigation Flow + +``` +User: "what threads do we have?" + +Agent: [calls thread_list] + thr_abc [parked, med, bug] "Race condition in DB connection pool" + thr_def [parked, med, security] "Auth middleware missing rate limiting" + thr_ghi [parked, low, tech-debt] "ORM version 2 majors behind" + +User: "investigate the race condition" + +Agent: [calls thread_investigate("thr_abc")] + → Spawns @general subagent + → Subagent gets: thread description + dereferenced CAS context + related files + → Subagent reads src/db/pool.ts, analyzes the condition + → Returns findings + +Agent: Thread #thr_abc resolved: + "Race condition in connection pool when concurrent requests exceed + maxConnections (10). Fix: add 5s timeout + exponential retry in + pool.ts:45-60. ~15 lines. Want me to apply the fix?" +``` + +--- + +## Integration With Other Systems + +### Focus + Curator (Ordered Pipeline) + +``` +Main agent turn completes + → Focus agent: parks divergences, updates focus status + → Curator: cleans stale content, externalizes old parts + → Pin & Decay: updates relevance scores +``` + +The focus agent runs first because parking is strictly better than hiding — the curator shouldn't hide a finding that should have been parked. After focus runs, the curator cleans up whatever non-divergent stale content remains. + +### Focus + Objective Tracker + +The objective tracker is the focus agent's reference point. Without an objective, the focus agent can't judge what's on-topic. They're tightly coupled: + +- Objective defines scope → focus agent enforces it +- Focus agent detects drift → updates objective (or asks user) +- Thread promotion → objective tracker update +- New session with handoff → objective loaded → focus agent has immediate context + +### Focus + CAS (Merkle) + +When parking, the focus agent externalizes the divergent content to CAS. This is critical because: +- Hidden content is invisible to the LLM +- CAS content is invisible but **recoverable** +- A future investigation can dereference the exact context + +### Focus + Handoff (Cross-Session) + +The handoff artifact includes all side threads: + +```typescript +interface HandoffArtifact { + // ... existing fields ... + sideThreads: SideThread[] // Full thread objects, not just summaries + focusStatus: FocusStatus // Latest focus assessment +} +``` + +New session loads handoff → focus agent activates with full thread backlog → system prompt includes parked threads → agent knows what was deferred. + +### Focus + Todo System + +The existing `todowrite` is the agent's **within-task** scratchpad: +``` +Todo: [pending] Step 1: Add offset/limit params to /users +Todo: [done] Step 2: Update SQL query +Todo: [pending] Step 3: Add Link headers +``` + +Side threads are the **across-task** backlog: +``` +thr_abc: [parked] DB pool race condition +thr_def: [parked] Auth rate limiting +``` + +They don't overlap. Todos track steps toward the objective. Side threads track deferred work outside the objective. + +--- + +## TUI Integration (Fork Only) + +### Sidebar Panel + +``` +┌─ Side Threads (3) ──────────┐ +│ ● thr_abc [med] DB pool race│ +│ ● thr_def [med] Auth rate │ +│ ○ thr_ghi [low] ORM upgrade │ +│ │ +│ ● parked ◉ investigating │ +│ ✓ resolved ◇ deferred │ +└──────────────────────────────┘ +``` + +Keybindings: +- `f` — toggle side thread panel +- Enter on thread → show details dialog +- `i` on thread → investigate +- `p` on thread → promote to objective + +### Focus Status in Header + +The session header shows a focus indicator: + +``` +[claude-sonnet-4-6] @build | Focus: ON (moderate) | Threads: 3 parked +``` + +--- + +## Configuration + +```jsonc +{ + "editableContext": { + "focus": { + "enabled": true, + "intensity": "moderate", // "relaxed" | "moderate" | "strict" + "model": "small", // Use small/fast model for focus agent + "minTurns": 4, // Don't run focus before 4 turns + "maxParksPerRun": 3, // Focus agent can park max 3 threads per run + "askOnCritical": true, // Ask user about critical findings + "askTimeout": 60, // Seconds before defaulting on question + "showInSystemPrompt": true, // Inject focus status into main agent prompt + "showInSidebar": true, // TUI sidebar panel (fork only) + "maxParkedThreads": 30, // Per project + "autoExternalizeContext": true // CAS-store thread context + } + } +} +``` + +--- + +## Implementation + +| Component | Lines | Phase | Depends On | +|-----------|:-----:|-------|------------| +| `SideThread` data model + SQLite table + migration | ~100 | 1 | — | +| `thread_park` tool | ~80 | 1 | SideThread model | +| `thread_list` tool | ~40 | 1 | SideThread model | +| `thread_resolve` tool | ~30 | 1 | SideThread model | +| Focus agent definition (hidden agent) | ~40 | 2 | — | +| Focus agent prompt + focus status block | ~60 | 2 | Objective Tracker | +| Post-turn review orchestration | ~120 | 2 | Focus agent + processor.ts | +| System prompt injection (focus status) | ~40 | 2 | session/llm.ts | +| `thread_investigate` tool (task wrapper) | ~100 | 2 | SideThread + existing `task` tool | +| `thread_promote` tool | ~70 | 2 | SideThread + Objective Tracker | +| Detection heuristics in focus prompt | ~50 | 2 | — | +| Slash commands (`/threads`, `/focus`, etc.) | ~50 | 2 | — | +| Focus intensity modes (relaxed/moderate/strict) | ~60 | 3 | Phase 2 | +| Strict mode mid-turn intervention | ~80 | 3 | processor.ts | +| TUI sidebar panel | ~80 | 3 | Fork only | +| TUI header focus indicator | ~20 | 3 | Fork only | +| Handoff integration (cross-session threads) | ~50 | 3 | Handoff (Mode 4) | +| CAS context extraction on park | ~60 | 3 | CAS (Merkle) | +| **Total** | **~1,130** | | | + +**Phase 1** (~250 LOC): Data model + manual tools. Users can park, list, and resolve threads immediately. +**Phase 2** (~530 LOC): The focus agent itself — automatic detection, system prompt injection, investigation, promotion. +**Phase 3** (~350 LOC): Intensity modes, TUI integration, cross-system integration. diff --git a/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md b/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md new file mode 100644 index 000000000..a06b88daf --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_FORK_PLAN.md @@ -0,0 +1,687 @@ +# Editable Context — Fork Plan + +Fork of `anomalyco/opencode` (`dev` branch). Changes are surgical and upstream-mergeable. + +--- + +## Architecture + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ Core changes (in the fork) │ +│ │ +│ message-v2.ts │ +│ ├── PartBase.extend: add optional `edit` field to all parts │ +│ ├── filterEdited(): drop hidden/superseded parts │ +│ └── toModelMessages(): respects edit.hidden │ +│ │ +│ session/thread-edit.ts (NEW) │ +│ ├── Core edit operations with ownership/budget enforcement │ +│ ├── Bus events (6 event types) │ +│ └── Plugin.trigger() before/after hooks │ +│ │ +│ processor.ts │ +│ └── Insert filterEdited() in message assembly pipeline │ +│ │ +│ tool/thread-edit.ts (NEW) │ +│ └── thread_edit tool definition (Tool.define wrapper) │ +│ │ +│ tool/registry.ts │ +│ └── Register ThreadEditTool in BUILTIN array │ +│ │ +│ server/routes/session.ts │ +│ └── GET /:sessionID/edits endpoint │ +│ │ +│ TUI: cli/cmd/tui/routes/session/index.tsx │ +│ └── Render hidden/replaced/annotated parts, toggle keybind │ +│ │ +│ Web UI: packages/ui/src/components/message-part.tsx │ +│ └── ToolRegistry.register("thread_edit", ...) for web rendering │ +│ │ +│ Plugin hooks: packages/plugin/src/index.ts │ +│ └── thread.edit.before / thread.edit.after hook types │ +└───────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Change 1: Part Schema Extension + +**File:** `packages/opencode/src/session/message-v2.ts` + +Add `edit` to `PartBase` so every part type inherits it. Since parts are stored as JSON blobs in the `data` column, this requires no SQL migration. + +```typescript +// New schema (add near line 81, after PartBase definition) +const EditMeta = z.object({ + hidden: z.boolean(), + supersededBy: PartID.zod.optional(), // points to replacement part + replacementOf: PartID.zod.optional(), // points to original part + annotation: z.string().optional(), + editedAt: z.number(), + editedBy: z.string(), // agent name +}).optional() + +// Extend PartBase (line 81) +const PartBase = z.object({ + id: PartID.zod, + sessionID: SessionID.zod, + messageID: MessageID.zod, + edit: EditMeta, // NEW — inherits to all 12 part types +}) +``` + +**Lines changed:** ~12 (add `EditMeta` schema + one field on `PartBase`). + +**Why it's safe:** `.optional()` means existing parts without `edit` remain valid. Zod parses them as `edit: undefined`. No data migration. No SQL changes (JSON blob column). + +--- + +## Change 2: `filterEdited()` + +**File:** `packages/opencode/src/session/message-v2.ts` + +```typescript +// Add as a new exported function in the MessageV2 namespace +export function filterEdited(messages: WithParts[]): WithParts[] { + return messages + .map(msg => ({ + ...msg, + parts: msg.parts.filter(part => { + if (!part.edit) return true + if (part.edit.hidden) return false + if (part.edit.supersededBy) return false + return true + }) + })) + .filter(msg => msg.parts.length > 0) +} +``` + +**Lines added:** ~15. + +--- + +## Change 3: Pipeline Integration + +**File:** `packages/opencode/src/session/processor.ts` + +In the processor loop, where messages are assembled before each LLM call. The exact location is where `filterCompacted()` is called and the result is passed to `toModelMessages()`: + +```diff + const raw = await Session.messages({ sessionID }) + const afterCompaction = MessageV2.filterCompacted(raw) ++ const afterEdits = MessageV2.filterEdited(afterCompaction) +- const modelMessages = MessageV2.toModelMessages(afterCompaction, model) ++ const modelMessages = MessageV2.toModelMessages(afterEdits, model) +``` + +**Lines changed:** ~3. + +--- + +## Change 4: Core Edit Logic + +**New file:** `packages/opencode/src/session/thread-edit.ts` (~280 lines) + +```typescript +import { Session } from "./index" +import { MessageV2 } from "./message-v2" +import { BusEvent } from "@/bus/bus-event" +import { Database } from "@/storage/db" +import { Plugin } from "@/plugin" +import { ID } from "@/id" +import z from "zod" + +export namespace ThreadEdit { + // ── Types ────────────────────────────────────────────── + + export interface EditResult { + success: boolean + editID?: string + error?: string + } + + // ── Constants ────────────────────────────────────────── + + const MAX_EDITS_PER_TURN = 10 + const MAX_HIDDEN_RATIO = 0.7 + const PROTECTED_RECENT_TURNS = 2 + const PROTECTED_TOOLS = ["skill"] + + // ── Events (published via Bus) ───────────────────────── + + export const Event = { + PartHidden: BusEvent.define("thread.edit.part.hidden", + z.object({ sessionID: z.string(), partID: z.string(), agent: z.string() })), + PartUnhidden: BusEvent.define("thread.edit.part.unhidden", + z.object({ sessionID: z.string(), partID: z.string(), agent: z.string() })), + PartReplaced: BusEvent.define("thread.edit.part.replaced", + z.object({ sessionID: z.string(), oldPartID: z.string(), newPartID: z.string(), agent: z.string() })), + PartAnnotated: BusEvent.define("thread.edit.part.annotated", + z.object({ sessionID: z.string(), partID: z.string(), annotation: z.string(), agent: z.string() })), + MessageRetracted: BusEvent.define("thread.edit.message.retracted", + z.object({ sessionID: z.string(), messageID: z.string(), agent: z.string() })), + RangeSummarized: BusEvent.define("thread.edit.range.summarized", + z.object({ sessionID: z.string(), fromMessageID: z.string(), toMessageID: z.string(), agent: z.string() })), + } + + // ── Validation ───────────────────────────────────────── + + function validateOwnership(agent: string, message: MessageV2.Info): string | null { + if (message.role === "user") return "Cannot edit user messages" + if (message.agent !== agent) return `Cannot edit messages from agent '${message.agent}'` + return null + } + + function validateBudget(messages: MessageV2.WithParts[], currentEditCount: number): string | null { + if (currentEditCount >= MAX_EDITS_PER_TURN) + return `Edit budget exhausted (max ${MAX_EDITS_PER_TURN} per turn)` + const totalParts = messages.reduce((n, m) => n + m.parts.length, 0) + const hiddenParts = messages.reduce( + (n, m) => n + m.parts.filter(p => p.edit?.hidden).length, 0) + if (totalParts > 0 && (hiddenParts + 1) / totalParts > MAX_HIDDEN_RATIO) + return `Cannot hide more than ${MAX_HIDDEN_RATIO * 100}% of all parts` + return null + } + + function isProtectedMessage(messages: MessageV2.WithParts[], messageID: string): boolean { + const idx = messages.findIndex(m => m.info.id === messageID) + if (idx < 0) return true + return idx >= messages.length - PROTECTED_RECENT_TURNS * 2 + } + + // ── Plugin guard ─────────────────────────────────────── + + async function pluginGuard(op: string, sessionID: string, agent: string, + target?: { messageID?: string; partID?: string }): Promise { + const guard = await Plugin.trigger("thread.edit.before", + { operation: op, target, agent, sessionID }, + { allow: true }) + if (!guard.allow) return { success: false, error: guard.reason ?? "Blocked by plugin" } + return null + } + + async function pluginNotify(op: string, sessionID: string, agent: string, success: boolean, + target?: { messageID?: string; partID?: string }) { + await Plugin.trigger("thread.edit.after", + { operation: op, target, agent, sessionID, success }, {}) + } + + // ── Operations ───────────────────────────────────────── + + export async function hide(input: { + sessionID: string; partID: string; messageID: string; agent: string + }): Promise { + const blocked = await pluginGuard("hide", input.sessionID, input.agent, + { messageID: input.messageID, partID: input.partID }) + if (blocked) return blocked + + const msg = await MessageV2.get(input) + if (!msg) return { success: false, error: "Message not found" } + + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + + const messages = await Session.messages({ sessionID: input.sessionID }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot edit recent messages" } + + const part = msg.parts.find(p => p.id === input.partID) + if (!part) return { success: false, error: "Part not found" } + if (part.type === "tool" && PROTECTED_TOOLS.includes((part as any).tool)) + return { success: false, error: `Cannot hide ${(part as any).tool} tool results` } + + const budgetErr = validateBudget(messages, 0) + if (budgetErr) return { success: false, error: budgetErr } + + Session.updatePart({ ...part, + edit: { hidden: true, editedAt: Date.now(), editedBy: input.agent } + }) + Bus.publish(Event.PartHidden, { sessionID: input.sessionID, partID: input.partID, agent: input.agent }) + await pluginNotify("hide", input.sessionID, input.agent, true, + { messageID: input.messageID, partID: input.partID }) + return { success: true, editID: input.partID } + } + + export async function unhide(input: { + sessionID: string; partID: string; messageID: string; agent: string + }): Promise { + const msg = await MessageV2.get(input) + if (!msg) return { success: false, error: "Message not found" } + const part = msg.parts.find(p => p.id === input.partID) + if (!part?.edit?.hidden) return { success: false, error: "Part is not hidden" } + + Session.updatePart({ ...part, edit: undefined }) + Bus.publish(Event.PartUnhidden, { sessionID: input.sessionID, partID: input.partID, agent: input.agent }) + return { success: true } + } + + export async function replace(input: { + sessionID: string; partID: string; messageID: string; agent: string; replacement: string + }): Promise { + const blocked = await pluginGuard("replace", input.sessionID, input.agent, + { messageID: input.messageID, partID: input.partID }) + if (blocked) return blocked + + const msg = await MessageV2.get(input) + if (!msg) return { success: false, error: "Message not found" } + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + const messages = await Session.messages({ sessionID: input.sessionID }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot edit recent messages" } + const part = msg.parts.find(p => p.id === input.partID) + if (!part) return { success: false, error: "Part not found" } + + const newPartID = ID.ascending("prt") + Database.transaction(() => { + Session.updatePart({ ...part, + edit: { hidden: true, supersededBy: newPartID, editedAt: Date.now(), editedBy: input.agent } + }) + Session.updatePart({ + id: newPartID, sessionID: input.sessionID, messageID: input.messageID, + type: "text", text: input.replacement, + edit: { hidden: false, replacementOf: input.partID, editedAt: Date.now(), editedBy: input.agent } + } as any) + }) + Bus.publish(Event.PartReplaced, { + sessionID: input.sessionID, oldPartID: input.partID, newPartID, agent: input.agent }) + await pluginNotify("replace", input.sessionID, input.agent, true, + { messageID: input.messageID, partID: input.partID }) + return { success: true } + } + + export async function annotate(input: { + sessionID: string; partID: string; messageID: string; agent: string; annotation: string + }): Promise { + const msg = await MessageV2.get(input) + if (!msg) return { success: false, error: "Message not found" } + const part = msg.parts.find(p => p.id === input.partID) + if (!part) return { success: false, error: "Part not found" } + + Session.updatePart({ ...part, + edit: { ...(part.edit ?? { hidden: false, editedAt: 0, editedBy: "" }), + annotation: input.annotation, editedAt: Date.now(), editedBy: input.agent } + }) + Bus.publish(Event.PartAnnotated, { + sessionID: input.sessionID, partID: input.partID, annotation: input.annotation, agent: input.agent }) + return { success: true } + } + + export async function retract(input: { + sessionID: string; messageID: string; agent: string + }): Promise { + const blocked = await pluginGuard("retract", input.sessionID, input.agent, + { messageID: input.messageID }) + if (blocked) return blocked + + const msg = await MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }) + if (!msg) return { success: false, error: "Message not found" } + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + const messages = await Session.messages({ sessionID: input.sessionID }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot retract recent messages" } + + Database.transaction(() => { + for (const part of msg.parts) { + Session.updatePart({ ...part, + edit: { hidden: true, annotation: "Retracted by agent", editedAt: Date.now(), editedBy: input.agent } + }) + } + }) + Bus.publish(Event.MessageRetracted, { sessionID: input.sessionID, messageID: input.messageID, agent: input.agent }) + await pluginNotify("retract", input.sessionID, input.agent, true, { messageID: input.messageID }) + return { success: true } + } + + export async function summarizeRange(input: { + sessionID: string; fromMessageID: string; toMessageID: string; summary: string; agent: string + }): Promise { + if (input.agent !== "build" && input.agent !== "compaction") + return { success: false, error: "Only primary agents can summarize ranges" } + + const blocked = await pluginGuard("summarize_range", input.sessionID, input.agent, + { messageID: input.fromMessageID }) + if (blocked) return blocked + + const messages = await Session.messages({ sessionID: input.sessionID }) + const fromIdx = messages.findIndex(m => m.info.id === input.fromMessageID) + const toIdx = messages.findIndex(m => m.info.id === input.toMessageID) + if (fromIdx < 0 || toIdx < 0 || fromIdx > toIdx) + return { success: false, error: "Invalid message range" } + if (toIdx >= messages.length - PROTECTED_RECENT_TURNS * 2) + return { success: false, error: "Cannot summarize recent messages" } + + const summaryMsgID = ID.descending("msg") + Database.transaction(() => { + for (let i = fromIdx; i <= toIdx; i++) { + for (const part of messages[i].parts) { + Session.updatePart({ ...part, + edit: { hidden: true, + annotation: `Summarized in range ${input.fromMessageID}..${input.toMessageID}`, + editedAt: Date.now(), editedBy: input.agent } + }) + } + } + Session.updateMessage({ + id: summaryMsgID, sessionID: input.sessionID, role: "user", + time: { created: Date.now() }, agent: input.agent, + } as any) + Session.updatePart({ + id: ID.ascending("prt"), sessionID: input.sessionID, messageID: summaryMsgID, + type: "text", text: `[Summary of ${toIdx - fromIdx + 1} messages]\n\n${input.summary}`, + synthetic: true, + edit: { hidden: false, annotation: "Range summary", editedAt: Date.now(), editedBy: input.agent } + } as any) + }) + Bus.publish(Event.RangeSummarized, { + sessionID: input.sessionID, fromMessageID: input.fromMessageID, + toMessageID: input.toMessageID, agent: input.agent }) + await pluginNotify("summarize_range", input.sessionID, input.agent, true, + { messageID: input.fromMessageID }) + return { success: true } + } +} +``` + +--- + +## Change 5: Tool Definition + +**New file:** `packages/opencode/src/tool/thread-edit.ts` (~70 lines) + +```typescript +import z from "zod" +import { Tool } from "./tool" +import { ThreadEdit } from "../session/thread-edit" + +export const ThreadEditTool = Tool.define("thread_edit", async () => ({ + description: `Edit the conversation thread to correct mistakes, remove stale context, or compress explorations. + +Operations: +- hide(partID, messageID): Remove a part from your context +- unhide(partID, messageID): Restore a hidden part +- replace(partID, messageID, replacement): Replace a part with corrected text +- annotate(partID, messageID, annotation): Add a note to a part +- retract(messageID): Hide all parts of a previous assistant message +- summarize_range(fromMessageID, toMessageID, summary): Replace a message range with a summary + +Constraints: only your own assistant messages, not the 2 most recent turns, max 10 edits/turn.`, + + parameters: z.object({ + operation: z.enum(["hide", "unhide", "replace", "annotate", "retract", "summarize_range"]), + partID: z.string().optional(), + messageID: z.string().optional(), + replacement: z.string().optional(), + annotation: z.string().optional(), + fromMessageID: z.string().optional(), + toMessageID: z.string().optional(), + summary: z.string().optional(), + }), + + async execute(args, ctx) { + const base = { sessionID: ctx.sessionID, agent: ctx.agent } + let result: ThreadEdit.EditResult + + switch (args.operation) { + case "hide": + result = await ThreadEdit.hide({ ...base, partID: args.partID!, messageID: args.messageID! }); break + case "unhide": + result = await ThreadEdit.unhide({ ...base, partID: args.partID!, messageID: args.messageID! }); break + case "replace": + result = await ThreadEdit.replace({ ...base, partID: args.partID!, messageID: args.messageID!, replacement: args.replacement! }); break + case "annotate": + result = await ThreadEdit.annotate({ ...base, partID: args.partID!, messageID: args.messageID!, annotation: args.annotation! }); break + case "retract": + result = await ThreadEdit.retract({ ...base, messageID: args.messageID! }); break + case "summarize_range": + result = await ThreadEdit.summarizeRange({ ...base, fromMessageID: args.fromMessageID!, toMessageID: args.toMessageID!, summary: args.summary! }); break + default: + return { title: "Error", metadata: {}, output: `Unknown operation: ${args.operation}` } + } + if (!result.success) + return { title: "Edit failed", metadata: {}, output: `Error: ${result.error}` } + return { title: `Thread edit: ${args.operation}`, metadata: { operation: args.operation, editID: result.editID }, output: `Successfully applied ${args.operation}.` } + } +})) +``` + +--- + +## Change 6: Register the Tool + +**File:** `packages/opencode/src/tool/registry.ts` + +```diff ++ import { ThreadEditTool } from "./thread-edit" + + // In the built-in tools array: + const BUILTIN = [ + // ... existing tools ... ++ ThreadEditTool, + ] +``` + +--- + +## Change 7: Server Endpoint for Edit History + +**File:** `packages/opencode/src/server/routes/session.ts` (+25 lines) + +```typescript +// GET /:sessionID/edits +app.openapi( + createRoute({ + method: "get", + path: "/:sessionID/edits", + operationId: "session.edits", + request: { params: z.object({ sessionID: z.string() }) }, + responses: { 200: { content: { "application/json": { schema: z.array(z.any()) } } } } + }), + async (c) => { + const { sessionID } = c.req.valid("param") + const messages = await Session.messages({ sessionID }) + const edits = messages + .flatMap(m => m.parts) + .filter(p => p.edit) + .map(p => ({ partID: p.id, messageID: p.messageID, ...p.edit })) + return c.json(edits) + } +) +``` + +--- + +## Change 8: TUI Rendering + +**File:** `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` + +Add a KV-backed toggle alongside existing toggles (near line 155): + +```typescript +const [showEdits, setShowEdits] = kv.signal("edit_indicators_visibility", false) +``` + +Register a command in the command palette: + +```typescript +command.register({ + title: "Toggle edit indicators", + value: "edits.toggle", + category: "View", + onSelect: () => setShowEdits(v => !v), +}) +``` + +In the part rendering section, wrap existing part display with edit-awareness. The TUI uses `@opentui/solid` (Solid.js in terminal), so the component style uses Solid.js primitives, not React/Ink: + +```tsx +// Before rendering each part: +{part.edit?.hidden && !showEdits() ? null : ( + + + [hidden{part.edit?.annotation ? `: ${part.edit.annotation}` : ""}] + + +)} + +// For replacement indicators: + + ↻ replaced + + +// For annotations: + + ⌁ {part.edit.editedBy}: "{part.edit.annotation}" + +``` + +**Lines added:** ~60. + +--- + +## Change 9: Web UI Rendering + +**File:** `packages/ui/src/components/message-part.tsx` + +Register a custom tool renderer for `thread_edit` (near line 2200, after existing `ToolRegistry.register()` calls): + +```typescript +ToolRegistry.register({ + name: "thread_edit", + render(props) { + const metadata = () => props.metadata as { operation?: string; editID?: string } | undefined + return ( + +
+ + Thread edit: {metadata()?.operation} + + ({metadata()!.editID}) + +
+
+ ) + } +}) +``` + +Additionally, in the part visibility check function `isPartVisible()` (near line 466): + +```diff + export function isPartVisible(part: PartType, ...) { ++ // Hide edited parts unless showing edits ++ if ((part as any).edit?.hidden) return false + if (part.type === "tool") { ... } +``` + +**Lines added:** ~30. + +--- + +## Change 10: Plugin Hook Types + +**File:** `packages/plugin/src/index.ts` + +Add to the `Hooks` interface (near line 233): + +```typescript +"thread.edit.before"?: ( + input: { operation: string; target?: { messageID?: string; partID?: string }; agent: string; sessionID: string }, + output: { allow: boolean; reason?: string }, +) => Promise + +"thread.edit.after"?: ( + input: { operation: string; target?: { messageID?: string; partID?: string }; agent: string; sessionID: string; success: boolean }, + output: {}, +) => Promise +``` + +--- + +## Change 11: Agent Prompt + +**File:** `packages/opencode/src/agent/prompts/` (build agent prompt file) + +``` +## Thread Editing + +You have a thread_edit tool. Use it when: +- You discover a previous response was factually wrong → retract or replace +- A tool result is stale or irrelevant and consuming context → hide +- A long exploration (5+ messages) can be compressed → summarize_range +- You want to leave a note on a previous finding → annotate + +Do NOT: +- Hide errors or failed tool results (the user needs those) +- Edit the last 2 turns (work forward instead) +- Use more than 10 edits per turn +``` + +--- + +## Complete Change Summary + +| # | File | Type | Lines | Description | +|---|------|------|:-----:|-------------| +| 1 | `session/message-v2.ts` | Modify | +12 | `EditMeta` schema on `PartBase` | +| 2 | `session/message-v2.ts` | Modify | +15 | `filterEdited()` function | +| 3 | `session/processor.ts` | Modify | +3 | Insert `filterEdited()` in pipeline | +| 4 | `session/thread-edit.ts` | **New** | +280 | Core edit operations, validation, events, plugin hooks | +| 5 | `tool/thread-edit.ts` | **New** | +70 | Tool definition wrapping core logic | +| 6 | `tool/registry.ts` | Modify | +3 | Register ThreadEditTool | +| 7 | `server/routes/session.ts` | Modify | +25 | Edit history endpoint | +| 8 | `cli/cmd/tui/routes/session/index.tsx` | Modify | +60 | TUI edit rendering + command palette toggle | +| 9 | `ui/src/components/message-part.tsx` | Modify | +30 | Web UI tool renderer + part visibility | +| 10 | `plugin/src/index.ts` | Modify | +12 | Plugin hook type definitions | +| 11 | `agent/prompts/` | Modify | +15 | Agent instructions | +| | | **Total** | **~525** | 2 new files, 8 modified files | + +--- + +## Plugin vs Fork Comparison (Updated) + +| Capability | Plugin | Fork | +|------------|:------:|:----:| +| Edit metadata in the DB | ✓ via `part.metadata.edit` | ✓ via `part.edit` (top-level, cleaner) | +| Replacement parts persisted | ✓ in hidden part's metadata | ✓ as real parts in DB | +| Filtering before LLM | `experimental.*` hook (may change) | Hardcoded in processor pipeline | +| TUI rendering of edits | ✗ impossible | ✓ hidden/replaced/annotated indicators | +| TUI toggle keybind | ✗ | ✓ command palette "Toggle edit indicators" | +| Web UI rendering | ✗ (unless custom build) | ✓ `ToolRegistry.register("thread_edit")` | +| Web UI part visibility | ✗ | ✓ `isPartVisible()` respects `edit.hidden` | +| Custom bus events | ✗ | ✓ 6 event types via `BusEvent.define()` | +| Plugin hooks for edit interception | ✗ | ✓ `thread.edit.before` / `thread.edit.after` | +| Works in non-interactive/SDK mode | Depends on transform hook firing | ✓ pipeline is universal | +| DB transactions for replace | ✗ (sequential PATCH calls) | ✓ atomic `Database.transaction()` | +| Survives upstream hook API changes | ✗ `experimental.*` may break | ✓ own code | +| No fork maintenance | ✓ | ✗ requires rebase | + +--- + +## Rebase Strategy + +The changes touch 8 existing files with small, isolated diffs: + +1. **`message-v2.ts`** — Additive: new schema field on `PartBase`, new exported function. Low conflict. +2. **`processor.ts`** — Single line insertion at the message assembly point. If upstream refactors, this is the only line to fix. +3. **`registry.ts`** — Single import + array entry. Trivial. +4. **`server/routes/session.ts`** — New endpoint appended at end of file. No conflict with existing routes. +5. **`plugin/src/index.ts`** — Additive hook types at end of `Hooks` interface. Low conflict. +6. **`ui/src/components/message-part.tsx`** — New `ToolRegistry.register()` call at end + small change to `isPartVisible()`. Medium conflict risk if upstream adds new tool renderers at the same location. +7. **TUI `session/index.tsx`** — New KV signal + command registration + part rendering guards. **Highest conflict risk** if upstream redesigns the session view. +8. **Agent prompts** — Appended text. Trivial. + +**Strategy:** Keep the 2 new files self-contained with minimal imports. Do not refactor surrounding code. The `thread-edit.ts` module is the only substantial new code; everything else is a 3-15 line insertion. + +--- + +## When to Choose This Plan + +- You need **production-grade reliability** (atomic transactions, no state drift) +- You want **TUI + Web UI integration** (visual edit indicators, command palette toggle) +- You want edit events to propagate to **all clients** (TUI, web, SDK, plugins) +- You're willing to **maintain a fork** and rebase on upstream releases +- You want to **upstream the feature** as a PR to anomalyco/opencode diff --git a/docs/research/EDITABLE_CONTEXT_MERKLE.md b/docs/research/EDITABLE_CONTEXT_MERKLE.md new file mode 100644 index 000000000..e4622c586 --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_MERKLE.md @@ -0,0 +1,706 @@ +# Editable Context — Merkle Tree / Content-Addressable Context + +## Core Idea + +Instead of **hiding** content (removing it from the LLM's view entirely) or **compacting** it (summarizing it into prose), introduce a third option: **externalize** it. Replace inline content with a compact reference (hash + summary) linked to the full content in a content-addressable object store. The agent can dereference any hash to page the full content back into context on demand. + +This turns the context window into a **working set** backed by a persistent store — like virtual memory for conversations. + +``` +Before: +┌──────────────────────────────────────────┐ +│ Part: grep result (4,200 tokens) │ +│ Found 47 matches in src/auth/... │ +│ src/auth/middleware.ts:23: validate(...) │ +│ src/auth/middleware.ts:45: refresh(...) │ +│ ... 45 more lines ... │ +└──────────────────────────────────────────┘ + +After externalization: +┌──────────────────────────────────────────┐ +│ Part: grep result (externalized) │ +│ ref: cas://sha256:a1b2c3d4 (4,200 tok) │ +│ summary: "47 matches in src/auth/. │ +│ Key: middleware.ts:23 validate(), │ +│ middleware.ts:45 refresh()" │ +│ Use thread_deref to expand. │ +└──────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Object Store │ + │ .opencode/cas/ │ + │ a1b2c3d4.json │ + │ (full content) │ + └─────────────────┘ +``` + +--- + +## Why This Is Different From Hide/Compact + +| Strategy | Content in LLM context | Content recoverable | Agent effort to recover | Token cost | +|----------|:---:|:---:|:---:|:---:| +| **Keep** | Full | N/A | None | High | +| **Hide** | Gone | User toggle only | Can't (unless unhide) | Zero | +| **Compact** | Summary only | No (original destroyed) | Can't | Low | +| **Externalize** | Hash + summary | Yes, on demand | `thread_deref(hash)` | Very low (summary only) | + +The key property: **lossless compression with on-demand expansion.** The agent retains awareness (via the summary) that the information exists and what it contains, and can bring it back into context when needed. This is strictly better than hiding — you get the token savings of hiding with the recoverability of keeping. + +--- + +## Existing Infrastructure + +OpenCode already has content-addressable primitives: + +| Component | Location | How It Works | +|-----------|----------|-------------| +| **Snapshot system** | `packages/opencode/src/snapshot/index.ts` | Uses `git write-tree` → returns SHA hash. `Snapshot.track()` creates a tree hash, `Snapshot.restore(hash)` recovers. | +| **PatchPart** | `message-v2.ts:95-102` | `{ type: "patch", hash: string, files: string[] }` — patches stored by hash | +| **SnapshotPart** | `message-v2.ts:87-93` | `{ type: "snapshot", snapshot: string }` — tree hashes stored as part data | +| **Storage module** | `packages/opencode/src/storage/storage.ts` | File-based JSON with read/write locks at `Global.Path.data/storage/`. Key-path → file mapping. | +| **Tool output truncation** | `tool.ts` → `Truncate.output()` | Already truncates tool output at 50K tokens — but throws away the excess | + +The snapshot system proves git's object store works for content-addressable storage. The storage module provides the file-based KV store. We need to combine them into a **conversation content** object store. + +--- + +## Architecture + +### Object Store + +``` +.opencode/cas/ # Content-Addressable Store +├── objects/ +│ ├── a1/b2c3d4e5f6...json # Individual content blobs +│ ├── f7/89abcdef01...json +│ └── ... +├── trees/ +│ ├── {sessionID}/ +│ │ └── {messageID}.json # Merkle tree per message +│ └── ... +└── index.json # Global index: hash → metadata +``` + +### Content Blob + +```typescript +interface ContentBlob { + hash: string // SHA-256 of content + content: string // The full original content + tokens: number // Token count + created: number // Timestamp + sessionID: string // Source session + messageID: string // Source message + partID: string // Source part + type: string // "tool_output" | "text" | "reasoning" | "file" | "range_summary" +} +``` + +### Merkle Tree Node + +```typescript +interface MerkleNode { + hash: string // Hash of this node's content (or hash of children) + summary: string // Human-readable summary (what the LLM sees) + tokens: number // Original token count + summaryTokens: number // Summary token count + children?: string[] // Child hashes (for tree nodes — message/range summaries) + depth: number // 0 = leaf (single part), 1+ = aggregated +} +``` + +The Merkle structure enables hierarchical summarization: + +``` + ┌─────────────────────┐ + │ Session tree root │ + │ hash: abc123 │ + │ "Auth refactor: ... │ + │ explored, found..." │ + └─────────┬───────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ Range 1-10 │ │ Range 11-20│ │ Range 21-30│ + │ hash: def │ │ hash: ghi │ │ hash: jkl │ + │ "Explored │ │ "Implement │ │ "Testing │ + │ auth..." │ │ JWT..." │ │ and fix..." │ + └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ + │ │ │ + ┌────┼────┐ ┌────┼────┐ ┌────┼────┐ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ + leaf leaf leaf leaf leaf leaf leaf leaf leaf + (parts) (parts) (parts) +``` + +At any level, the agent can dereference a hash to expand one level deeper. Expanding the root shows the 3 range summaries. Expanding a range shows its individual parts. Expanding a part shows the full original content. + +--- + +## New Tools + +### `thread_externalize` + +Replace a part's inline content with a hash + summary. + +```typescript +Tool.define("thread_externalize", async () => ({ + description: `Move a part's content to the object store, replacing it inline with a compact +summary + hash reference. The original content is preserved and can be retrieved +with thread_deref. Use this to free context window space while keeping the +information available. + +Prefer this over hide when the content might be needed again later.`, + + parameters: z.object({ + partID: z.string().describe("Part to externalize"), + messageID: z.string().describe("Parent message"), + summary: z.string().describe("1-3 line summary of the content for the agent to see inline"), + }), + + async execute(args, ctx) { + // 1. Read the part's full content + // 2. Hash the content (SHA-256) + // 3. Write to object store: .opencode/cas/objects/{hash}.json + // 4. Update the part: + // - Set metadata.cas = { hash, summary, tokens, externalized: true } + // - Original content stays in the part (for DB integrity) + // - The transform hook replaces it with the summary in LLM view + // 5. Return the hash for reference + } +})) +``` + +### `thread_deref` + +Expand a hash reference back into full content in the current context. + +```typescript +Tool.define("thread_deref", async () => ({ + description: `Dereference a content hash, returning the full original content. +Use this when you need to re-examine externalized content. +The content is returned as tool output (not re-inlined into the original part).`, + + parameters: z.object({ + hash: z.string().describe("Content hash to dereference (from cas:// reference)"), + range: z.object({ + start: z.number().optional(), + end: z.number().optional(), + }).optional().describe("Optional line range to fetch a subset"), + }), + + async execute(args, ctx) { + // 1. Look up hash in object store + // 2. Read the content blob + // 3. If range specified, extract subset + // 4. Return content as tool output + // The agent now has the full content in its current turn + } +})) +``` + +### `thread_tree` + +Show the Merkle tree structure for the current session — what's externalized, at what depth, with what summaries. + +```typescript +Tool.define("thread_tree", async () => ({ + description: `Show the content tree for this session. Lists all externalized content +with hashes, summaries, token counts, and tree structure. +Use this to understand what content is available for dereferencing.`, + + parameters: z.object({ + depth: z.number().optional().describe("Max depth to show (default: 1)"), + messageID: z.string().optional().describe("Show tree for specific message only"), + }), + + async execute(args, ctx) { + // Return a formatted tree showing: + // - Session root hash + // - Range summaries (depth 1) + // - Part summaries (depth 2) + // - Token counts at each level + // - Compression ratios + } +})) +``` + +--- + +## Transform Hook Integration + +The `experimental.chat.messages.transform` hook (plugin) or `filterEdited()` (fork) reads `metadata.cas` and replaces inline content: + +```typescript +// For each part with metadata.cas: +if (part.metadata?.cas?.externalized) { + // Replace the part's text/output with the compact reference + if (part.type === "text") { + part.text = `[externalized: cas://${part.metadata.cas.hash} (${part.metadata.cas.tokens} tokens)]\n${part.metadata.cas.summary}` + } + if (part.type === "tool" && part.state?.status === "completed") { + part.state.output = `[externalized: cas://${part.metadata.cas.hash} (${part.metadata.cas.tokens} tokens)]\n${part.metadata.cas.summary}` + } +} +``` + +Token savings: a 4,000-token grep result becomes a ~50-token reference + summary. + +--- + +## Merkle Tree Construction + +### Leaf Level (Per-Part) + +When a part is externalized, it becomes a leaf in the Merkle tree: + +```typescript +const leaf: MerkleNode = { + hash: sha256(part.content), + summary: userProvidedSummary, + tokens: Token.estimate(part.content), + summaryTokens: Token.estimate(userProvidedSummary), + depth: 0, +} +``` + +### Range Level (Per-Range) + +When `summarize_range` runs, it creates a range node whose children are the leaf hashes: + +```typescript +const range: MerkleNode = { + hash: sha256(childHashes.join(":")), // hash of children + summary: rangeSummary, + tokens: children.reduce((n, c) => n + c.tokens, 0), + summaryTokens: Token.estimate(rangeSummary), + children: childHashes, + depth: 1, +} +``` + +### Session Level (Root) + +The session tree root's children are range nodes (or leaf nodes for un-ranged parts): + +```typescript +const root: MerkleNode = { + hash: sha256(topLevelHashes.join(":")), + summary: sessionSummary, + tokens: allNodes.reduce((n, c) => n + c.tokens, 0), + summaryTokens: Token.estimate(sessionSummary), + children: topLevelHashes, + depth: 2, +} +``` + +### Integrity Verification + +Because it's a Merkle tree, you can verify that no content has been tampered with: + +```typescript +function verify(node: MerkleNode, store: ObjectStore): boolean { + if (node.depth === 0) { + // Leaf: hash should match content + const blob = store.get(node.hash) + return sha256(blob.content) === node.hash + } + // Tree: hash should match children + const childHashes = node.children!.join(":") + return sha256(childHashes) === node.hash +} +``` + +This is useful for shared sessions — the recipient can verify the externalized content hasn't been modified. + +--- + +## Where Else This Applies + +### 1. Tool Output Truncation (Replace, Don't Discard) + +Currently, `Truncate.output()` cuts tool output at 50K tokens and throws away the rest. With CAS: + +```typescript +// Instead of: +output = output.slice(0, MAX_TOKENS) + "\n[truncated]" + +// Do: +if (Token.estimate(output) > MAX_TOKENS) { + const hash = await CAS.store(output, { type: "tool_output", sessionID, partID }) + output = output.slice(0, SUMMARY_TOKENS) + + `\n[full output externalized: cas://${hash} (${Token.estimate(output)} tokens)]` +} +``` + +The agent can always dereference the hash to see the full output if it needs lines 201-400 of a grep. + +### 2. Compaction (Merkle Compaction) + +Instead of compaction producing a single summary and hiding everything: + +``` +Before compaction: +[turn 1] [turn 2] [turn 3] ... [turn 30] ← all inline, ~80K tokens + +After traditional compaction: +[summary: 2K tokens] [turn 31] [turn 32] ... + +After Merkle compaction: +[session tree: cas://root_hash] ← 200 tokens + ├── [range 1-10: cas://abc] "Explored auth module, found JWT..." ← 100 tokens + ├── [range 11-20: cas://def] "Implemented token refresh..." ← 100 tokens + └── [range 21-30: cas://ghi] "Fixed race condition in..." ← 100 tokens +[turn 31] [turn 32] ... ← inline, recent +``` + +The agent sees a 500-token tree of summaries instead of 80K tokens of raw history. But unlike compaction, it can expand any branch: `thread_deref("abc")` → full turns 1-10. + +### 3. Cross-Session Knowledge Base + +Handoff artifacts (from Mode 4) can reference CAS objects. A new session loads the handoff summary but can dereference into the previous session's actual content: + +``` +Session 2 loads handoff: + "Previous session explored auth middleware. Key finding: + JWT validation skips expiry check on refresh tokens. + Full analysis: cas://sha256:xyz789" + +Agent in Session 2: + > thread_deref("sha256:xyz789") + → Gets the full 3,000-token analysis from Session 1 +``` + +The CAS objects persist at the project level (`.opencode/cas/`), so they survive across sessions. This gives handoff both the compact summary AND the full backing data. + +### 4. File Read Caching + +When the `read` tool reads a file, the content could be CAS-stored: + +```typescript +// In read tool: +const content = await fs.readFile(path) +const hash = sha256(content) +await CAS.store(content, { type: "file_read", path, sessionID }) + +// If the same file is read again (same hash), return: +"File unchanged since last read (cas://${hash}). Use thread_deref to re-read." +``` + +This prevents the common pattern of the agent reading the same file 5 times during a session, each time consuming full tokens. + +### 5. MCP Tool Results + +MCP server responses can be large and unpredictable. CAS provides a safety net: + +```typescript +// In MCP tool execution wrapper: +const result = await mcpClient.callTool(name, args) +if (Token.estimate(result) > threshold) { + const hash = await CAS.store(result, { type: "mcp_result", tool: name }) + return `[MCP result externalized: cas://${hash}]\n${summarize(result)}` +} +``` + +### 6. Reasoning Blocks + +Model reasoning/thinking blocks are often 5K-20K tokens and rarely re-read. Auto-externalize after the turn completes: + +```typescript +// After reasoning-end: +if (reasoningPart.text.length > REASONING_EXTERNALIZE_THRESHOLD) { + await CAS.store(reasoningPart.text, { type: "reasoning", sessionID }) + // Mark for externalization on next curator pass +} +``` + +### 7. Session Sharing (Efficient) + +Currently, sharing serializes the full conversation. With CAS: + +``` +Shared session = tree root hash + object references +Recipient fetches only the objects they expand +``` + +This is like a git clone — you get the tree structure immediately and fetch blobs on demand. + +### 8. Multi-Session Deduplication + +If two sessions read the same file or get the same grep result, CAS stores it once: + +``` +Session A reads src/auth.ts → cas://sha256:abc (stored once) +Session B reads src/auth.ts → cas://sha256:abc (same hash, no duplicate storage) +``` + +--- + +## Object Store Implementation + +### Option A: File-Based (Simple) + +Use the existing `Storage` module: + +```typescript +export namespace CAS { + export async function store(content: string, meta: BlobMeta): Promise { + const hash = createHash("sha256").update(content).digest("hex") + const key = ["cas", hash.slice(0, 2), hash] + try { + await Storage.read(key) // Already exists + } catch { + await Storage.write(key, { hash, content, tokens: Token.estimate(content), ...meta, created: Date.now() }) + } + return hash + } + + export async function get(hash: string): Promise { + return Storage.read(["cas", hash.slice(0, 2), hash]) + } + + export async function has(hash: string): Promise { + try { await get(hash); return true } catch { return false } + } +} +``` + +**Path:** `~/.local/share/opencode/storage/cas/{first2chars}/{hash}.json` + +Pros: Uses existing infra, read/write locks, migration system. +Cons: JSON overhead (base64 for binary), no deduplication across projects. + +### Option B: Git Object Store (Leverage Existing) + +Use the snapshot system's git repo: + +```typescript +export namespace CAS { + export async function store(content: string): Promise { + // git hash-object -w --stdin + const hash = await Process.text( + ["git", "--git-dir", gitdir(), "hash-object", "-w", "--stdin"], + { input: content } + ) + return hash.text.trim() + } + + export async function get(hash: string): Promise { + // git cat-file -p {hash} + const content = await Process.text( + ["git", "--git-dir", gitdir(), "cat-file", "-p", hash] + ) + return content.text + } +} +``` + +Pros: True content-addressable storage, deduplication built-in, garbage collection via `git gc`, already initialized per-project. +Cons: Tied to git, binary overhead for large objects. + +### Option C: SQLite Table (Best for Queries) + +New table in the existing DB: + +```sql +CREATE TABLE cas_object ( + hash TEXT PRIMARY KEY, + content TEXT NOT NULL, + tokens INTEGER NOT NULL, + type TEXT NOT NULL, + session_id TEXT, + message_id TEXT, + part_id TEXT, + time_created INTEGER NOT NULL +); + +CREATE INDEX idx_cas_session ON cas_object(session_id); +CREATE INDEX idx_cas_type ON cas_object(type); +``` + +Pros: Atomic with other DB operations, queryable, no filesystem overhead. +Cons: SQLite blob storage is less efficient than files for large content, DB size grows. + +### Recommendation: Option A (file-based) for content, Option C (SQLite) for index + +Store large blobs in files (via `Storage`), store the metadata index in SQLite. This mirrors how git works (loose objects in files, pack index in a database). + +```typescript +// Store: blob → file, metadata → SQLite +await Storage.write(["cas", hash.slice(0, 2), hash], { content }) +await db.insert(CASIndex).values({ hash, tokens, type, sessionID, messageID, partID, timeCreated: Date.now() }) + +// Retrieve: index → SQLite, content → file +const meta = await db.select().from(CASIndex).where(eq(CASIndex.hash, hash)) +const blob = await Storage.read(["cas", hash.slice(0, 2), hash]) +``` + +--- + +## Automatic Externalization Policies + +The curator and refocus agents can use CAS automatically based on policies: + +```jsonc +{ + "editableContext": { + "cas": { + "enabled": true, + "store": "file", // "file" | "git" | "sqlite" + + // Auto-externalize thresholds (tokens) + "autoExternalize": { + "tool_output": 2000, // Externalize tool results > 2K tokens + "reasoning": 5000, // Externalize reasoning > 5K tokens + "text": 3000, // Externalize text blocks > 3K tokens + "file_read": 1500, // Externalize file reads > 1.5K tokens + "mcp_result": 1000 // Externalize MCP results > 1K tokens + }, + + // Age-based externalization + "agePolicy": { + "turnsBeforeExternalize": 5, // Externalize parts older than 5 turns + "excludePinned": true // Don't externalize pinned parts + }, + + // Garbage collection + "gc": { + "maxAge": "30d", // Delete blobs older than 30 days + "maxSize": "500MB", // Cap total CAS size + "orphanCleanup": true // Delete blobs with no referencing parts + } + } + } +} +``` + +### Auto-Externalize in Curator + +The background curator can externalize instead of hiding: + +``` +Curator prompt addition: + +When you find content that is stale but might be needed later: +- Use thread_externalize instead of thread_edit(hide) +- Write a 1-3 line summary that captures the essential finding +- The agent can dereference the hash later if needed + +Use externalize for: old tool results, verbose outputs, resolved explorations +Use hide for: completely irrelevant content, errors that were already addressed +``` + +### Auto-Externalize in Refocus + +When refocus runs at the 50% threshold, it can externalize aggressively: + +``` +Before refocus: 50% of 200K context = 100K tokens used +After refocus: + - 40K tokens kept inline (recent + relevant) + - 55K tokens externalized (summaries + hashes = ~5K tokens) + - 5K tokens hidden (truly irrelevant) + = 45K tokens in context (22.5% of capacity) + = 55K tokens recoverable on demand +``` + +--- + +## Integration With Other Modes + +### CAS + Handoff + +Handoff artifacts reference CAS objects: + +```typescript +interface HandoffArtifact { + // ... existing fields ... + references: Array<{ + hash: string + summary: string + tokens: number + relevance: "critical" | "useful" | "background" + }> +} +``` + +New sessions load the handoff and can dereference any reference. The CAS objects live at the project level, so cross-session dereferencing works. + +### CAS + Pin & Decay + +Pinned parts are never externalized. Decayed parts are externalized before being hidden: + +``` +Score > 0.5 → inline (keep in context) +Score 0.1-0.5 → externalize (summary + hash) +Score < 0.1 → hide (if unpinned) or externalize (if pinned) +``` + +### CAS + Compaction + +Merkle compaction replaces traditional compaction as a configurable option: + +```jsonc +{ + "compaction": { + "strategy": "merkle" // "traditional" (default) | "merkle" + } +} +``` + +With `"merkle"`, the compaction agent builds a Merkle tree instead of a flat summary, preserving drill-down capability. + +### CAS + Session Sharing + +Shared sessions include only the tree structure and summaries. The CAS objects are uploaded separately and fetched on demand by the viewer: + +``` +Share payload: + { tree: MerkleNode, objects: string[] } // list of hashes + +Viewer: + - Sees summaries immediately + - Clicks "expand" → fetches object from share server + - Progressive loading, not all-or-nothing +``` + +--- + +## Token Economics + +Example: 50-turn coding session, 150K tokens of raw content + +| Strategy | Tokens in context | Recoverable | Cost reduction | +|----------|:-:|:-:|:-:| +| No editing | 150K | N/A | 0% | +| Hide only | 60K | 0 (hidden = lost) | 60% | +| Compact at 85% | 10K summary + recent | 0 (summarized) | 80% but lossy | +| **Externalize** | 30K inline + 5K refs | 115K via deref | 77% and **lossless** | +| **Merkle compact** | 3K tree + 20K recent | 127K via deref | 85% and **lossless** | + +The externalize strategy gives comparable token savings to compaction while keeping everything recoverable. + +--- + +## Implementation Estimate + +| Component | Lines | Phase | +|-----------|:-----:|-------| +| CAS store (file-based + SQLite index) | ~150 | Phase 1 | +| `thread_externalize` tool | ~80 | Phase 1 | +| `thread_deref` tool | ~60 | Phase 1 | +| `thread_tree` tool | ~100 | Phase 1 | +| Transform hook (replace inline with ref) | ~40 | Phase 1 | +| Auto-externalize in curator | ~50 | Phase 2 | +| Merkle tree construction | ~120 | Phase 2 | +| Merkle compaction strategy | ~150 | Phase 3 | +| Cross-session CAS (handoff integration) | ~80 | Phase 3 | +| File read dedup | ~40 | Phase 3 | +| Session sharing with CAS | ~100 | Phase 4 | +| GC and size management | ~60 | Phase 4 | +| **Total** | **~1,030** | | + +Phase 1 alone (~430 LOC) gives you the core: store, externalize, deref, tree. This is self-contained and valuable without any other editable context mode. diff --git a/docs/research/EDITABLE_CONTEXT_MODES.md b/docs/research/EDITABLE_CONTEXT_MODES.md new file mode 100644 index 000000000..110540f6e --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_MODES.md @@ -0,0 +1,702 @@ +# Editable Context — Modes + +Expanding the base `thread_edit` tool into a system of intelligent context management modes. + +--- + +## Mode Overview + +| Mode | Trigger | Who Edits | User Interaction | Persistence | +|------|---------|-----------|-----------------|-------------| +| **Manual** | Agent decides, or user instructs | Active agent | None (tool call in stream) | Session-scoped | +| **Background Curator** | Between every turn | Hidden `curator` agent | Optional: can ask questions | Session-scoped | +| **Threshold Refocus** | At configurable % (default 50%) | Hidden `refocus` agent | Optional: confirms objective | Session-scoped | +| **Handoff** | Session end, manual `/handoff`, or threshold | Hidden `handoff` agent | Optional: reviews artifact | **Cross-session** (project-level) | +| **Pin & Decay** | Continuous (per-turn scoring) | Automatic (no LLM) | User pins via command | Session-scoped | +| **Objective Tracker** | Every N turns or on drift detection | Active agent or curator | Can ask "is this still the goal?" | **Cross-session** | + +--- + +## Mode 1: Manual (Base) + +Already designed in `EDITABLE_CONTEXT.md`. The active agent calls `thread_edit` when it recognizes a need. No automation, no background process. + +--- + +## Mode 2: Background Curator + +### Concept + +A lightweight hidden agent that runs **between turns** (after the main agent finishes, before the user's next input). It reads the current thread, identifies noise, and makes surgical edits. Think of it as a copy editor working in the margins while the author takes a break. + +### When It Runs + +``` +User message → Main agent responds → [Curator runs] → User sees clean thread → Next input +``` + +The curator fires as a **post-step hook** — after `processor.process()` returns `"continue"` or `"stop"`, before control returns to the TUI prompt. It does NOT run during the agent's tool-call loop (that would create interference). + +### What It Does + +The curator receives the full thread and a focused prompt: + +``` +You are a context curator. Your job is to keep the conversation thread clean and focused. + +Review the conversation and apply thread_edit operations for: +1. Tool results that are now stale (file was edited since the read, grep from before refactor) +2. Exploration dead-ends (the agent tried something, it didn't work, moved on) +3. Redundant information (same file read twice, same error shown multiple times) +4. Verbose tool output that can be summarized (a 200-line grep can become "found 12 matches in auth/") + +Do NOT hide: +- The user's messages (you can't anyway — ownership rules) +- The most recent 2 turns +- Errors the user should know about +- The current approach/strategy the agent is following + +Current objective: {objective_tracker.current} +``` + +### Implementation + +```typescript +// In processor.ts, after the main process() returns: +if (config.editableContext?.curator?.enabled) { + const curator = await Agent.get("curator") // new hidden agent + const messages = await Session.messages({ sessionID }) + const filtered = MessageV2.filterCompacted(MessageV2.filterEdited(messages)) + + // Only run if enough content to curate (>= 6 turns) + if (filtered.length >= 6) { + const curatorProcessor = SessionProcessor.create({ + assistantMessage: /* hidden, not shown to user */, + sessionID, model: curator.model, abort + }) + await curatorProcessor.process({ + user: /* synthetic curator prompt */, + agent: curator, + tools: { thread_edit: ThreadEditTool }, // only editing tools + messages: MessageV2.toModelMessages(filtered, model), + ... + }) + } +} +``` + +### Curator Agent Definition + +```typescript +// New hidden agent in the agent system +{ + name: "curator", + hidden: true, // not user-selectable + tools: ["thread_edit"], // only edit tools, no bash/read/write + maxSteps: 5, // hard cap — curator should be fast + model: "small", // use the small/fast model, not the primary + temperature: 0, // deterministic curation +} +``` + +### Configuration + +```jsonc +// opencode.json +{ + "editableContext": { + "curator": { + "enabled": true, + "frequency": "every_turn", // "every_turn" | "every_n_turns" | "on_idle" + "n": 3, // for "every_n_turns" + "minTurns": 6, // don't curate short conversations + "model": "small", // override model + "maxEditsPerRun": 5, // curator budget per invocation + "askUser": false // see "User Interaction" below + } + } +} +``` + +### User Interaction: Curator Questions + +When `askUser: true`, the curator can use a modified `question` tool to ask the user before making significant edits: + +``` +Curator: I'm about to hide 8 tool results from your early file exploration +(turns 3-7) since you've since refactored those files. Keep or hide? +[Keep] [Hide] [Hide and summarize] +``` + +This uses the existing `Question` tool infrastructure (`packages/opencode/src/question/`). The curator's question appears as a lightweight prompt in the TUI, distinct from the main agent's questions (styled with the curator's agent color, prefixed with "Context Curator:"). + +If the user doesn't respond within 30 seconds (configurable), the curator proceeds with default action (hide, since it's non-destructive and reversible). + +### Cost Control + +The curator uses the `small_model` (configured in `opencode.json`). On a cheap model like Haiku, curating a 50-turn conversation costs ~$0.01. The curator's own messages are marked `hidden: true` in the agent system, so they don't appear in the thread or consume context. + +--- + +## Mode 3: Threshold Refocus + +### Concept + +When context usage hits a configurable threshold (default 50%), a `refocus` agent activates and rewrites the thread around the current objective. Unlike compaction (which summarizes everything at ~85%), refocus is **opinionated** — it keeps what matters for the current goal and aggressively compresses everything else. + +### How It Differs From Compaction + +| Aspect | Compaction | Threshold Refocus | +|--------|-----------|-------------------| +| Trigger | ~85% context (panic mode) | 50% context (proactive) | +| Strategy | Summarize everything uniformly | Keep goal-relevant details, compress the rest | +| Granularity | All-or-nothing boundary | Part-by-part, preserving structure | +| Output | Single summary block | Thread with some parts hidden, some summarized, some untouched | +| Audit trail | Original gone from LLM view | All edits reversible | +| Objective awareness | No | Yes — uses the objective tracker | + +### When It Runs + +```typescript +// In processor.ts, at finish-step (where isOverflow is already checked): +case "finish-step": + // ... existing token tracking ... + const usage = computeUsageRatio(tokens, model) + + if (usage >= config.editableContext.refocus.threshold) { + // Refocus, not compaction + return "refocus" + } + if (await SessionCompaction.isOverflow({ tokens, model })) { + needsCompaction = true // existing compaction as fallback + } +``` + +The processor returns a new `"refocus"` result, and the caller runs the refocus agent instead of compaction. + +### Refocus Agent Prompt + +``` +You are a context refocus agent. The conversation is at {usage}% of context capacity. + +Current objective: {objective_tracker.current} + +Your task: make the thread precise and focused on the current objective. + +Strategy: +1. KEEP: Everything directly relevant to the current objective +2. KEEP: Key discoveries, decisions, and the current approach +3. SUMMARIZE: Long explorations that produced a useful conclusion (use summarize_range) +4. HIDE: Dead-end explorations, superseded approaches, stale tool output +5. HIDE: Verbose tool results where only 1-2 lines were actually useful + +After editing, the thread should read like a focused narrative: +- What we're doing (objective) +- What we tried and learned (discoveries) +- Where we are now (current state) +- What to do next (plan) + +Do NOT hide the user's instructions, even if they seem tangential — the user decides relevance. +``` + +### Refocus Output: Structured State + +After refocusing, the agent writes a **state summary** as a synthetic TextPart at the refocus boundary: + +``` +--- Context refocused at 52% usage --- + +**Objective:** Implement editable context for OpenCode agents +**Current approach:** Plugin-based with part.metadata.edit persistence +**Key files:** session/message-v2.ts, tool/thread-edit.ts, plugin/index.ts +**Blocked on:** Need to verify experimental.chat.messages.transform fires in SDK mode +**Next steps:** 1) Write transform hook, 2) Test with non-interactive mode +**Hidden:** 12 parts (3 dead-end explorations, 5 stale tool results, 4 verbose outputs) +``` + +### Configuration + +```jsonc +{ + "editableContext": { + "refocus": { + "enabled": true, + "threshold": 0.5, // 50% of context capacity + "model": "small", // use fast model + "maxEditsPerRun": 20, // more aggressive than curator + "askUser": true, // confirm objective before refocusing + "preserveRecentTurns": 4 // never touch last 4 turns + } + } +} +``` + +### User Interaction: Objective Confirmation + +When `askUser: true` and refocus triggers: + +``` +Context Refocus: Thread is at 52% capacity. I'd like to refocus around your current goal. + +Current objective: "Implement editable context plugin for OpenCode" +Is this still what we're working on? (Press Enter to confirm, or type a new objective) +> _ +``` + +If the user provides a new objective, the refocus agent uses that instead. If they press Enter, it proceeds with the tracked objective. + +--- + +## Mode 4: Handoff (Cross-Session Persistence) + +### Concept + +When a session ends (or at threshold), distill the curated thread into a structured **handoff artifact** that persists at the project level and loads into the next session. This is the bridge between session-scoped editable context and project-level memory. + +### The Handoff Artifact + +Stored at project level (not session level): + +**Path:** `.opencode/handoff/{workspaceID|projectID}.json` and `.opencode/handoff/{id}.md` + +```typescript +interface HandoffArtifact { + id: string + sessionID: string // source session + projectID: string + workspaceID?: string + createdAt: number + objective: string + status: "in_progress" | "completed" | "blocked" | "abandoned" + summary: { + goal: string + approach: string + discoveries: string[] // key findings + accomplished: string[] // completed work + remaining: string[] // work left to do + blockers: string[] // what's stuck and why + files: string[] // relevant files + decisions: Array<{ // important decisions made + decision: string + reason: string + alternatives: string[] + }> + } + context: { + pinnedParts: Array<{ // parts the user/agent pinned + content: string + reason: string + }> + keyExchanges: Array<{ // important user-agent exchanges + user: string + agent: string + }> + } +} +``` + +### When It Runs + +Three triggers: + +1. **Session end:** User closes the TUI, types `/quit`, or session is archived +2. **Manual:** User types `/handoff` or tells the agent "create a handoff" +3. **Threshold:** When refocus triggers, it also updates the handoff artifact + +### Loading Into New Sessions + +When a new session starts, the system checks for handoff artifacts: + +```typescript +// In session creation flow: +const handoffs = await HandoffStore.list({ projectID, workspaceID }) +const recent = handoffs.filter(h => h.status === "in_progress" && isRecent(h)) + +if (recent.length > 0) { + // Inject as a synthetic user message at the start of the session + const handoff = recent[0] + await Session.updatePart({ + type: "text", + synthetic: true, + text: `[Continuing from previous session]\n\n${formatHandoff(handoff)}`, + metadata: { handoff: { id: handoff.id, sessionID: handoff.sessionID } } + }) +} +``` + +The agent sees the handoff as context at the start of its thread. It knows what was done, what's remaining, and what the objective is — without replaying the entire previous session. + +### Handoff Agent + +```typescript +{ + name: "handoff", + hidden: true, + tools: [], // no tools — pure analysis + model: "small", // fast model + prompt: `Analyze the conversation and create a handoff artifact for the next session. + + Focus on: + 1. What is the user trying to accomplish? (objective) + 2. What approach was taken? What worked, what didn't? + 3. What key discoveries were made? + 4. What work is completed? What remains? + 5. What important decisions were made and why? + 6. What specific files/functions are relevant? + 7. Are there any blockers? + + Be precise and specific. Include file paths, function names, error messages. + Do NOT include verbose tool output — summarize to the essential finding.` +} +``` + +### Configuration + +```jsonc +{ + "editableContext": { + "handoff": { + "enabled": true, + "trigger": "session_end", // "session_end" | "manual" | "threshold" | "all" + "autoLoad": true, // load handoffs into new sessions + "maxAge": "7d", // ignore handoffs older than 7 days + "maxHandoffs": 5, // keep last 5 per workspace + "askUser": true // confirm before loading into new session + } + } +} +``` + +### Interaction With Existing AGENTS.md + +Handoff artifacts are **not** the same as `AGENTS.md` / instructions. Instructions are static rules ("always use TypeScript", "run tests before committing"). Handoff artifacts are dynamic state ("we're halfway through implementing the auth refactor, the JWT validation is done but the middleware isn't"). They complement each other: + +- `AGENTS.md` → **how** to work (rules, conventions) +- Handoff → **what** to work on (state, progress, next steps) + +--- + +## Mode 5: Pin & Decay + +### Concept + +Every part has an implicit **relevance score** that decays over turns. Parts the user or agent explicitly **pins** are immune to decay. Low-score parts are auto-hidden by the curator. This creates a natural "memory curve" where recent and important things stay sharp while old noise fades. + +### Scoring + +```typescript +interface RelevanceScore { + base: number // Initial score based on part type + decayRate: number // Score lost per turn + pinned: boolean // Immune to decay + pinnedBy?: string // "user" or agent name + pinnedReason?: string // Why it was pinned + lastReferenced: number // Turn number when last referenced by agent +} + +// Base scores by part type: +const BASE_SCORES: Record = { + "text": 1.0, // Agent's analysis/conclusions + "tool:edit": 0.9, // File edits (high relevance) + "tool:write": 0.9, + "tool:bash": 0.6, // Shell output (often transient) + "tool:read": 0.4, // File reads (stale quickly) + "tool:grep": 0.3, // Search results (most transient) + "tool:glob": 0.2, + "reasoning": 0.5, // Thinking blocks + "compaction": 1.0, // Compaction summaries (always relevant) +} + +// Decay: 0.05 per turn (a grep result at 0.3 hits threshold after ~4 turns) +const DECAY_PER_TURN = 0.05 +const HIDE_THRESHOLD = 0.1 +``` + +### Reference Boosting + +When the agent references a part (quotes it, uses data from it), its score resets: + +```typescript +// In tool.execute — if the agent's output references content from a previous part +// (detected via string matching or explicit reference), boost that part's score +if (referencedPartIDs.length > 0) { + for (const partID of referencedPartIDs) { + relevanceScores[partID].lastReferenced = currentTurn + relevanceScores[partID].base = BASE_SCORES[partType] // reset to initial + } +} +``` + +### Pin Commands + +Users pin via prompt: + +``` +> /pin ← pin the last assistant message (all parts) +> /pin prt_abc ← pin a specific part +> /unpin prt_abc ← remove pin +``` + +Agents pin via the `thread_edit` tool: + +``` +thread_edit(operation: "annotate", partID: "prt_abc", annotation: "PIN: critical finding for auth refactor") +``` + +The curator respects pins: pinned parts are never auto-hidden regardless of score. + +### Implementation + +Score tracking lives in `part.metadata.relevance` (same pattern as `metadata.edit`): + +```typescript +metadata: { + relevance: { + score: 0.7, + pinned: false, + lastReferenced: 12, // turn number + } +} +``` + +The background curator reads scores and auto-hides parts below threshold. No LLM call needed for scoring — it's deterministic math. + +--- + +## Mode 6: Objective Tracker + +### Concept + +A running document at the "top" of context that tracks what the user is trying to accomplish. Updated by the curator or refocus agent. Used by all other modes to judge relevance. Persists across sessions via handoff. + +### Structure + +```typescript +interface ObjectiveState { + current: string // One-sentence current objective + subgoals: Array<{ + goal: string + status: "active" | "done" | "blocked" | "abandoned" + }> + approach: string // Current strategy + constraints: string[] // "must use TypeScript", "no breaking changes" + updatedAt: number + updatedBy: string // "user" | agent name +} +``` + +### How It's Maintained + +1. **Initialized** from the first user message (agent extracts the objective) +2. **Updated** when the user changes direction ("actually, let's focus on X instead") +3. **Updated** by the refocus agent when it detects goal drift +4. **Loaded** from handoff artifact in new sessions +5. **Confirmed** by asking the user when refocus triggers (if `askUser: true`) + +### Where It Lives + +Injected as the **first system prompt segment** (before agent instructions, after provider template): + +```typescript +// In session/llm.ts, system prompt assembly: +const objective = await ObjectiveTracker.get(sessionID) +if (objective) { + system.unshift(`## Current Objective\n${objective.current}\n\nApproach: ${objective.approach}`) +} +``` + +This ensures every agent (primary, subagent, curator, refocus) knows the objective. + +### Drift Detection + +The curator checks for goal drift by comparing the agent's recent actions to the objective: + +``` +Recent actions: reading auth middleware, modifying JWT validation +Tracked objective: "Add pagination to the API endpoints" +→ Drift detected. Ask user: "It looks like we've shifted from pagination to auth work. Should I update the objective?" +``` + +--- + +## Cross-Session Data Flow + +``` +Session 1 Session 2 +┌─────────────────────┐ ┌─────────────────────┐ +│ Turn 1-50 │ │ [Handoff loaded] │ +│ Curator edits │ │ Turn 1: Agent reads │ +│ Refocus at 50% │ │ handoff, continues │ +│ Objective tracked │ │ Curator resumes │ +│ │ │ Objective carried │ +│ Session end: │ │ forward │ +│ Handoff generated ─────────────▶│ │ +│ Pinned parts saved │ │ Pins restored │ +│ Objective persisted │ │ │ +└─────────────────────┘ └─────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────┐ +│ .opencode/handoff/ │ +│ ├── {workspace}.json (latest handoff) │ +│ ├── {id}.md (human-readable) │ +│ └── objective.json (current objective) │ +│ │ +│ Persists: objective, discoveries, decisions, │ +│ remaining work, pinned parts, key exchanges │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Mode Interactions + +### Curator + Refocus + +The curator runs frequently (every turn or every N turns) with a small budget (5 edits). Refocus runs rarely (at threshold) with a large budget (20 edits). They don't conflict because: + +1. Refocus checks `part.edit?.hidden` — if the curator already hid something, refocus skips it +2. Refocus has a higher edit budget and can `summarize_range` (which the curator avoids) +3. After refocus runs, the curator has less work to do (thread is already clean) + +### Curator + Pin & Decay + +Decay scoring is deterministic (no LLM). The curator reads scores and acts: + +```typescript +// In curator prompt: +// Parts with relevance score < 0.15: [list] +// Pinned parts (do not hide): [list] +// Recommendation: hide the low-score parts listed above. +``` + +The curator can override decay (keep a low-score part if it judges it relevant), and can also force-hide a high-score part if it's clearly noise. + +### Refocus + Handoff + +When refocus runs, it also updates the handoff artifact (since it already has the analysis): + +```typescript +// After refocus completes: +if (config.editableContext.handoff.trigger === "threshold" || trigger === "all") { + await HandoffStore.update({ + sessionID, + objective: objectiveTracker.current, + summary: refocusAgent.analysis, + ... + }) +} +``` + +### Objective Tracker + All Modes + +The objective is the shared reference point: + +- **Curator** uses it to judge "is this part still relevant?" +- **Refocus** uses it as the axis for what to keep vs. compress +- **Handoff** uses it as the primary field in the artifact +- **Pin & Decay** uses it for reference boosting (parts related to objective get boosted) + +--- + +## User-Facing Commands + +| Command | Action | +|---------|--------| +| `/curator on` / `/curator off` | Toggle background curator | +| `/refocus` | Manually trigger refocus | +| `/refocus 0.3` | Set refocus threshold to 30% | +| `/handoff` | Create handoff artifact now | +| `/handoff load` | Load a specific handoff into current session | +| `/pin` | Pin last assistant message | +| `/pin prt_abc` | Pin a specific part | +| `/unpin prt_abc` | Unpin | +| `/objective` | Show current tracked objective | +| `/objective set "new goal"` | Manually set objective | +| `/edits` | Show all edits in current session | +| `/edits undo` | Undo last edit | + +--- + +## Configuration (Complete) + +```jsonc +{ + "editableContext": { + // Mode 1: Manual — always available, no config needed + + // Mode 2: Background Curator + "curator": { + "enabled": true, + "frequency": "every_turn", + "n": 3, + "minTurns": 6, + "model": "small", + "maxEditsPerRun": 5, + "askUser": false + }, + + // Mode 3: Threshold Refocus + "refocus": { + "enabled": true, + "threshold": 0.5, + "model": "small", + "maxEditsPerRun": 20, + "askUser": true, + "preserveRecentTurns": 4 + }, + + // Mode 4: Handoff + "handoff": { + "enabled": true, + "trigger": "session_end", + "autoLoad": true, + "maxAge": "7d", + "maxHandoffs": 5, + "askUser": true + }, + + // Mode 5: Pin & Decay + "decay": { + "enabled": true, + "ratePerTurn": 0.05, + "hideThreshold": 0.1, + "autoPin": ["tool:edit", "tool:write"] + }, + + // Mode 6: Objective Tracker + "objective": { + "enabled": true, + "driftDetection": true, + "askOnDrift": true, + "persistAcrossSessions": true + } + } +} +``` + +--- + +## Implementation Priority + +| Phase | Modes | Depends On | Effort | +|-------|-------|-----------|--------| +| **1** | Manual + Objective Tracker | Base `thread_edit` tool | ~600 LOC | +| **2** | Background Curator | Phase 1 + new hidden agent | ~400 LOC | +| **3** | Pin & Decay | Phase 2 (curator reads scores) | ~200 LOC | +| **4** | Threshold Refocus | Phase 1 + processor change | ~350 LOC | +| **5** | Handoff | Phase 4 + new storage | ~500 LOC | + +Total: ~2,050 LOC across all phases. Phase 1 alone gives you the foundation. Phase 2 gives the most user-visible value. Phase 5 is the most ambitious (cross-session state). + +--- + +## What This Replaces vs. Complements + +| Existing Feature | Editable Context Modes | Relationship | +|-----------------|----------------------|-------------| +| Compaction (85% threshold) | Threshold Refocus (50%) | **Complement** — refocus is proactive, compaction is the safety net | +| Session fork | Handoff + new session | **Complement** — fork copies everything, handoff distills | +| `AGENTS.md` instructions | Objective Tracker | **Complement** — instructions are static rules, objective is dynamic state | +| Session summary (git diffs) | Handoff artifact | **Complement** — summary tracks file changes, handoff tracks decisions/progress | +| Title agent | Objective Tracker | **Subsumes** — the objective IS the title, updated continuously | diff --git a/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md b/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md new file mode 100644 index 000000000..3e7729292 --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_PLUGIN_PLAN.md @@ -0,0 +1,495 @@ +# Editable Context — Plugin-Only Plan + +No fork. Everything runs as an external plugin installed via `.opencode/plugins/` or npm. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Plugin: opencode-editable-context │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────────────┐ │ +│ │ thread_edit │────▶│ Persistence: part.metadata.edit │ │ +│ │ tool │ │ (via REST PATCH to server) │ │ +│ └──────┬──────┘ └──────────────┬───────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ experimental.chat.messages.transform │ │ +│ │ (reads part.metadata.edit, filters hidden parts) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ experimental.chat.system.transform │ │ +│ │ (injects agent instructions about the tool) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ tool.execute.after (thread_edit) │ │ +│ │ (fires TuiEvent.ToastShow for edit notifications) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ event listener │ │ +│ │ (cleans up orphaned metadata on session.deleted) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Discoveries + +### 1. `experimental.chat.messages.transform` — Message Filtering + +The plugin system exposes this hook (from `packages/plugin/src/index.ts:200`): + +```typescript +"experimental.chat.messages.transform"?: ( + input: {}, + output: { messages: { info: Message; parts: Part[] }[] }, +) => Promise +``` + +Receives the **full message+parts array** as a mutable object before it reaches the LLM. `Plugin.trigger()` passes the same mutable `output` reference to every hook — hooks mutate in place, return `void`. + +### 2. `part.metadata` — In-DB Persistence Without Schema Changes + +TextPart, ToolPart, and ReasoningPart all carry (from `packages/opencode/src/session/message-v2.ts:115`): + +```typescript +metadata: z.record(z.string(), z.any()).optional() +``` + +Zod default is `.strip()` — a top-level `edit` field would be silently dropped. But `metadata: { edit: { hidden: true } }` **passes validation**. The server's `PATCH /:sessionID/message/:messageID/part/:partID` endpoint validates against this schema and accepts it. + +**This eliminates the sidecar file entirely.** Edit state lives atomically in the part's `metadata` field inside SQLite. No drift, no orphans. + +### 3. `TuiEvent.ToastShow` — TUI Notifications From Plugins + +From `packages/opencode/src/cli/cmd/tui/event.ts:34`: + +```typescript +TuiEvent.ToastShow = BusEvent.define("tui.toast.show", z.object({ + title: z.string().optional(), + message: z.string(), + variant: z.enum(["info", "success", "warning", "error"]), + duration: z.number().default(5000).optional(), +})) +``` + +Plugins can listen to events via the `event` hook and can trigger toasts by publishing to the bus. This gives us **limited but real TUI feedback** — a toast notification every time an edit is applied. + +### 4. Web UI `PART_MAPPING` / `ToolRegistry` — Render Extensions + +The web app (`packages/ui/src/components/message-part.tsx:686-687,1168-1171`) exports: + +```typescript +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} +export const ToolRegistry = { register: registerTool, render: getTool } +``` + +The `ToolRegistry.register()` function allows registering custom renderers for tools by name. The `thread_edit` tool's results will render via the `GenericTool` fallback component, which shows metadata when `generic_tool_output_visibility` is toggled on. A custom web app build could register a `thread_edit` renderer that reads `part.state.metadata` to show edit indicators. + +### 5. SDK Client Gap + +The auto-generated SDK client (`packages/sdk/js/src/gen/sdk.gen.ts`) does **not** expose: +- `PATCH /:sessionID/message/:messageID/part/:partID` (part update) +- `DELETE /:sessionID/message/:messageID` (message delete) + +The server has these routes. The plugin must call them with raw `fetch()` using `serverUrl` from `PluginInput`. + +--- + +## Components + +### 1. Persistence via `part.metadata.edit` + +Edit state stored directly in the part's `metadata` field in SQLite: + +```typescript +// Shape of metadata.edit on a part +interface EditMetadata { + hidden: boolean + supersededBy?: string // partID of replacement + replacementOf?: string // partID this replaces + annotation?: string + editedAt: number + editedBy: string // agent name +} + +// How it's stored: +// part.metadata = { ...existingMetadata, edit: { hidden: true, editedBy: "build", ... } } +``` + +**Write path (via raw fetch):** + +```typescript +async function applyEdit(serverUrl: URL, part: Part, editMeta: EditMetadata) { + const updated = { + ...part, + metadata: { ...(part.metadata ?? {}), edit: editMeta } + } + const res = await fetch( + `${serverUrl}/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`, + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updated) } + ) + if (!res.ok) throw new Error(`Part update failed: ${res.status}`) + return res.json() +} +``` + +**Read path:** Messages fetched via `client.session.messages()` include parts with metadata — `part.metadata?.edit` is accessible. + +**Atomicity:** The PATCH endpoint calls `Session.updatePart()` which is a synchronous SQLite upsert inside `Database.use()`. No sidecar drift possible. + +### 2. `thread_edit` Tool + +Defined via the `tool` property on the Hooks object: + +```typescript +import { tool } from "@opencode-ai/plugin" +import { z } from "zod" + +export default function(input: PluginInput): Hooks { + const serverUrl = input.serverUrl + + return { + tool: { + thread_edit: tool({ + description: `Edit the conversation thread. You can: +- hide: Remove a part from context (you will no longer see it) +- replace: Replace a part with corrected content +- annotate: Add a note to a part +- retract: Hide all parts of one of your previous messages +- summarize_range: Replace a range of messages with a summary + +You can only edit your own assistant messages. You cannot edit user messages. +Use this when you realize a previous response was wrong, when tool output +is stale/irrelevant and wasting context, or when a long exploration can +be compressed into a summary.`, + + args: { + operation: z.enum(["hide", "unhide", "replace", "annotate", "retract", "summarize_range"]), + partID: z.string().optional().describe("Target part ID (for hide/unhide/replace/annotate)"), + messageID: z.string().optional().describe("Target message ID (for retract)"), + replacement: z.string().optional().describe("Replacement text (for replace)"), + annotation: z.string().optional().describe("Annotation text (for annotate)"), + fromMessageID: z.string().optional().describe("Start of range (for summarize_range)"), + toMessageID: z.string().optional().describe("End of range (for summarize_range)"), + summary: z.string().optional().describe("Summary text (for summarize_range)"), + }, + + async execute(args, ctx) { + // 1. Fetch target message via REST to validate ownership + const msgRes = await fetch(`${serverUrl}/session/${ctx.sessionID}/message/${args.messageID}`) + const { info, parts } = await msgRes.json() + + // 2. Ownership check + if (info.role === "user") return "Error: cannot edit user messages" + // ctx from PluginInput doesn't have agent directly, but we can + // read it from the message context or track it via the event hook + + // 3. Budget check (fetch all messages, count existing edits) + const allMsgs = await fetch(`${serverUrl}/session/${ctx.sessionID}/message`) + const messages = await allMsgs.json() + const editCount = messages + .flatMap(m => m.parts) + .filter(p => p.metadata?.edit?.hidden).length + const totalParts = messages.flatMap(m => m.parts).length + if (totalParts > 0 && (editCount + 1) / totalParts > 0.7) + return "Error: cannot hide more than 70% of all parts" + + // 4. Apply edit via PATCH + switch (args.operation) { + case "hide": { + const part = parts.find(p => p.id === args.partID) + if (!part) return "Error: part not found" + await applyEdit(serverUrl, part, { + hidden: true, editedAt: Date.now(), editedBy: info.agent + }) + return `Hidden part ${args.partID}` + } + case "replace": { + const part = parts.find(p => p.id === args.partID) + if (!part) return "Error: part not found" + // Hide original + await applyEdit(serverUrl, part, { + hidden: true, supersededBy: `synth_${Date.now()}`, + editedAt: Date.now(), editedBy: info.agent + }) + // Note: replacement injected in transform hook, not as a real part + // Store replacement text in the hidden part's metadata for the transform to read + return `Replaced part ${args.partID}` + } + case "retract": { + for (const part of parts) { + await applyEdit(serverUrl, part, { + hidden: true, annotation: "Retracted", + editedAt: Date.now(), editedBy: info.agent + }) + } + return `Retracted message ${args.messageID}` + } + // ... annotate, unhide, summarize_range similarly + } + } + }) + }, + // ... other hooks below + } +} +``` + +### 3. Message Transform Hook + +Reads `part.metadata.edit` to filter and inject: + +```typescript +"experimental.chat.messages.transform": async (_input, output) => { + // Build state from persisted metadata + const replacements = new Map() + + for (let i = output.messages.length - 1; i >= 0; i--) { + const msg = output.messages[i] + const visibleParts: Part[] = [] + + for (const part of msg.parts) { + const edit = (part as any).metadata?.edit as EditMetadata | undefined + if (!edit) { + visibleParts.push(part) + continue + } + if (edit.hidden) { + // If this is a replaced part, prepare the replacement injection + if (edit.supersededBy && edit.replacementOf === undefined) { + // The replacement text was stored alongside the hide + // We need a different approach: store replacement text in the annotation + // or create a separate metadata key + } + continue // skip hidden parts + } + visibleParts.push(part) + } + + msg.parts = visibleParts + if (msg.parts.length === 0) { + output.messages.splice(i, 1) + } + } +} +``` + +### 4. Replacement Strategy (Revised) + +Since we can't create real parts from a plugin (the `PATCH` endpoint only updates existing parts), replacements use a two-field approach in metadata: + +```typescript +// On the HIDDEN original part: +metadata: { + edit: { + hidden: true, + replacement: "The corrected text goes here", // stored WITH the hidden part + editedAt: Date.now(), + editedBy: "build" + } +} +``` + +The transform hook reads `edit.replacement` from hidden parts and injects a synthetic text part: + +```typescript +if (edit.hidden && edit.replacement) { + visibleParts.push({ + id: `replaced_${part.id}`, + type: "text", + sessionID: msg.info.sessionID, + messageID: msg.info.id, + text: edit.replacement, + metadata: { edit: { replacementOf: part.id, editedAt: edit.editedAt, editedBy: edit.editedBy } } + } as any) +} +``` + +This keeps the replacement text **persisted in the DB** (in the hidden part's metadata), solving the earlier problem of synthetic-only replacements. + +### 5. System Prompt Injection + +```typescript +"experimental.chat.system.transform": async (_input, output) => { + output.system.push(` +You have access to a thread_edit tool that lets you edit your own previous messages. +Use it when: +- You discover an earlier response was incorrect (retract or replace) +- A tool result is stale and wasting context window (hide) +- A long exploration sequence can be compressed (summarize_range) +Do NOT use it to hide errors — the user needs to see those. +Do NOT edit user messages — you can only edit your own outputs. +`) +} +``` + +### 6. TUI Feedback via Toast + +```typescript +"tool.execute.after": async (input, output) => { + if (input.tool !== "thread_edit") return + // The event hook listens for all bus events — we can publish a toast + // But tool.execute.after doesn't have Bus access directly. + // Instead, we signal via the tool output which the TUI renders. + // The tool output string itself serves as feedback. +} + +// Alternative: use the event hook to watch for part updates with metadata.edit +"event": async ({ event }) => { + if (event.type === "message.part.updated") { + const part = event.properties?.part + if (part?.metadata?.edit) { + // Can't directly publish TuiEvent.ToastShow from plugin event hook, + // but the part update itself triggers a re-render in connected clients + } + } +} +``` + +**Practical TUI feedback:** The `thread_edit` tool's output string (e.g., "Hidden part prt_abc123") appears as a tool result in the message stream, which the TUI renders. This is the primary feedback mechanism. + +### 7. Compaction Awareness + +```typescript +"experimental.session.compacting": async (input, output) => { + // Fetch messages and count edits + const res = await fetch(`${serverUrl}/session/${input.sessionID}/message`) + const messages = await res.json() + const editCount = messages + .flatMap(m => m.parts) + .filter(p => p.metadata?.edit && !p.metadata.edit.hidden === false).length + + if (editCount > 0) { + output.context.push( + `Note: ${editCount} thread edits have been applied in this session. ` + + `Hidden content has already been removed from your context.` + ) + } +} +``` + +--- + +## File Structure + +``` +opencode-editable-context/ +├── package.json +├── src/ +│ ├── index.ts # Plugin entry: exports Plugin function, wires all hooks +│ ├── persistence.ts # applyEdit() — PATCH calls to update part.metadata.edit +│ ├── tool.ts # thread_edit tool definition +│ ├── transform.ts # experimental.chat.messages.transform hook +│ ├── system.ts # experimental.chat.system.transform hook +│ ├── compaction.ts # experimental.session.compacting hook +│ └── validate.ts # Ownership rules, budget enforcement +└── tsconfig.json +``` + +**Install:** Either npm (`"plugins": ["opencode-editable-context"]` in opencode.json) or local (`.opencode/plugins/editable-context.ts`). + +--- + +## TUI & UI Capabilities (Plugin-Only) + +### What IS possible + +| Mechanism | How | Limitation | +|-----------|-----|------------| +| **Toast notifications** | Tool output appears as a tool-result part in the TUI message stream | Shows as tool output, not a native toast | +| **Tool details in TUI** | Toggle `generic_tool_output_visibility` to see thread_edit tool metadata | Requires user to enable toggle | +| **Web UI tool rendering** | `ToolRegistry.register("thread_edit", renderer)` in a custom web app build | Requires building the web app | +| **Web UI part type** | `registerPartComponent("edited", renderer)` for custom part types | Only useful if creating new part types | +| **SSE events** | `message.part.updated` events carry full part with metadata to all clients | Custom client could render edit indicators | +| **System prompt hint** | Agent is told what's hidden via system prompt | Agent-only, not user-visible | + +### What is NOT possible + +| Feature | Why | +|---------|-----| +| **Custom TUI components** | Zero plugin hooks in TUI code. No `Plugin.trigger()` in any TUI file. | +| **Custom keybindings** | Keybinds defined in `tui.json` — no plugin access to `command.register()` | +| **Part rendering override** | TUI part rendering (`Switch` blocks) is hardcoded. `PART_MAPPING`/`ToolRegistry` exist only in the web UI's `@opencode-ai/ui` package. | +| **Sidebar/header/footer injection** | Fixed components, no slots or injection points | +| **Dim/style hidden parts** | Would need TUI code changes | + +### Workaround: Custom Web UI + +The web app (`packages/app`) renders via `PART_MAPPING` and `ToolRegistry`. A separate build of the web app could: + +1. Import `registerPartComponent` and `ToolRegistry.register` from `@opencode-ai/ui` +2. Register a custom renderer for the `thread_edit` tool that reads `part.state.metadata.edit` +3. Add visual indicators (strikethrough, opacity, badges) for edited parts +4. The web app connects to the same opencode server via SSE, so it sees all part updates + +This is a **web app customization**, not a plugin — but it requires no fork of the core `packages/opencode` package. + +--- + +## Limitations + +### Hard Limitations + +| Limitation | Impact | Severity | +|------------|--------|----------| +| **No TUI rendering of edit indicators** | Hidden/replaced parts show normally in TUI. User sees tool output "Hidden part X" but no visual change to the hidden part. | High | +| **`experimental.*` hooks may change** | `experimental.chat.messages.transform` and `experimental.session.compacting` are not guaranteed stable | Medium | +| **Replacement parts are synthetic in LLM context** | Replacement text is persisted (in hidden part's `metadata.edit.replacement`) but injected as synthetic parts in the transform hook. If the hook doesn't run, the LLM sees the hidden version. | Medium | +| **No custom bus events** | Cannot publish `thread.edit.*` events to the bus | Low | +| **ToolContext lacks `agent` field** | Must infer agent from the target message's `agent` field | Low | + +### Improvements Over Previous Version + +| Previous Problem | Now Solved | +|-----------------|------------| +| Sidecar file drift | Eliminated — edit state in `part.metadata.edit` in SQLite | +| No atomic transactions | Solved — single PATCH call per edit | +| Orphaned sidecar files | N/A — metadata lives with the part, deleted when part/session is deleted | +| Replacement text not persisted | Solved — stored in `metadata.edit.replacement` on the hidden part | + +--- + +## Risk Assessment + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| `experimental.chat.messages.transform` removed/changed | Medium | Pin opencode version; propose upstreaming as stable hook | +| Transform hook doesn't fire in all code paths (CLI `run`, SDK) | Medium | Test all modes; fallback: edits only affect TUI sessions | +| `metadata` field silently truncated or stripped | Low | Zod schema accepts `z.record(z.string(), z.any())` — no truncation | +| Agent enters edit loop | Low | Budget enforcement (10 edits/turn, 70% max hidden ratio) | +| Part PATCH endpoint behavior changes | Low | Server-side endpoint is stable (used by existing features) | + +--- + +## Estimated Effort + +| Component | Lines of Code | Complexity | +|-----------|:---:|---| +| Persistence (PATCH wrapper) | ~60 | Low | +| thread_edit tool | ~200 | Medium | +| Message transform hook | ~100 | Medium | +| System prompt hook | ~20 | Trivial | +| Compaction hook | ~30 | Low | +| Validation module | ~80 | Low | +| **Total** | **~490** | | + +--- + +## When to Choose This Plan + +- You want to **prototype and iterate** without maintaining a fork +- You accept that the **TUI will not show edit indicators** (tool output is the feedback) +- You're comfortable depending on `experimental.*` hooks +- You want the feature **portable** across opencode versions without rebasing +- You want to **publish as a community plugin** that anyone can install +- You may later build a **custom web UI** that renders edit indicators via `ToolRegistry` diff --git a/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md b/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md new file mode 100644 index 000000000..b986ac1cd --- /dev/null +++ b/docs/research/EDITABLE_CONTEXT_PRESS_RELEASE.md @@ -0,0 +1,116 @@ +# Frankencode: Editable Context — Amazon-Style PR/FAQ + +> **About this document:** This follows Amazon's "Working Backwards" methodology. The PR/FAQ is written *before* building the feature, as if it has already launched. The press release forces clarity about who benefits and why. The FAQ surfaces assumptions, risks, and design decisions early. Per Amazon's rules: one page for the press release, customer-centric language, no internal jargon. Werner Vogels: *"Start with your customer and work your way backwards until you get to the minimum set of technology requirements."* + +--- + +## Press Release + +### FRANKENCODE INTRODUCES EDITABLE CONTEXT, ENABLING AI AGENTS TO CORRECT THEIR OWN MISTAKES AND MANAGE THEIR MEMORY IN REAL TIME + +*Agents can now retract wrong answers, hide stale tool output, and compress long explorations — without losing the audit trail* + +**March 2026** — Frankencode, a fork of the open-source AI coding agent OpenCode, today announced Editable Context, a new capability that lets agents edit their own conversation threads during a session. With Editable Context, agents can hide irrelevant tool output that wastes their context window, replace incorrect statements with corrections, and summarize long exploration sequences into concise recaps. All edits are non-destructive — original content is preserved for the user to review at any time. + +**The problem.** Today's AI coding agents accumulate errors and noise as conversations grow. An agent that makes a wrong assumption in turn 5 carries that mistake through turns 6 through 50, because its own earlier output is frozen in the conversation. Stale tool results — a `grep` from 20 minutes ago, a file read from before an edit — consume precious context window space and can mislead the agent into outdated conclusions. When the context window fills up, the only remedy is full compaction, which summarizes *everything* indiscriminately and often loses important details. Developers working on long, complex tasks are forced to start new sessions or manually re-state corrections, breaking flow and wasting time. + +**The solution.** Editable Context gives agents a `thread_edit` tool with six operations: *hide* (remove a part from context), *unhide* (restore it), *replace* (swap in a correction), *annotate* (leave a note on a finding), *retract* (withdraw an entire response), and *summarize_range* (compress a sequence of messages). Agents can only edit their own output — never the user's messages, never another agent's work. Edits take effect immediately on the next turn: the LLM sees the cleaned-up thread, not the raw history. Every edit is recorded and reversible. The user can toggle edit indicators on and off to see what was changed and why. + +"Most AI agent failures aren't catastrophic single mistakes — they're the slow accumulation of stale context and uncorrected assumptions that compound over a long session," said the Editable Context team. "We built this because agents should be able to do what any good engineer does: go back, cross something out, and write the right thing." + +**How it works.** During a conversation, the agent recognizes that a previous tool result is outdated or a prior conclusion was wrong. It calls `thread_edit` with the target part ID and the desired operation. For example, `thread_edit(operation: "hide", partID: "prt_abc123", messageID: "msg_xyz789")` removes a stale grep result from context. The hidden content stays in the database but disappears from the agent's working view. If the agent later needs it back, it calls `unhide`. For corrections, `replace` hides the original and inserts new text in its place. For long explorations that produced a simple answer, `summarize_range` compresses 15 messages into a 3-line recap. Safety guardrails prevent abuse: agents cannot edit the last 2 turns (to prevent infinite loops), cannot hide more than 70% of parts (to preserve context integrity), and are limited to 10 edits per turn. + +A developer working on a multi-hour refactoring session said: "I used to restart sessions every 30 minutes because the agent would get confused by its own old output. With Editable Context, the agent cleans up after itself. My last session ran for 3 hours and the agent was still sharp at the end because it had been pruning stale context the whole time." + +**Editable Context is available today in Frankencode.** Install Frankencode (the OpenCode fork with context editing built in), or use the `opencode-editable-context` plugin for the upstream version. Full documentation at the project repository. + +--- + +## Frequently Asked Questions + +### Customer FAQs + +**Q: Do I need to tell the agent to use Editable Context, or does it use it automatically?** + +A: The agent uses it automatically when it recognizes a situation that warrants editing — a wrong assumption, stale tool output, or a long exploration that can be compressed. The tool description in the system prompt teaches the agent when and how to use it. You can also instruct the agent explicitly: "retract your last analysis, it was based on the wrong file." + +**Q: Can the agent delete my messages or change what I said?** + +A: No. Agents can only edit their own assistant messages. User messages are permanently read-only. Additionally, agents cannot edit other agents' messages — a subagent cannot modify the primary agent's output. + +**Q: Will I lose information when the agent hides something?** + +A: No. All edits are non-destructive. Hidden content remains in the database. In the TUI, toggle edit indicators (command palette → "Toggle edit indicators") to see all hidden parts with their annotations explaining why they were hidden. In the web UI, hidden parts appear with a visual indicator. You can also call `thread_edit(operation: "unhide", ...)` to restore any hidden part. + +**Q: How is this different from compaction?** + +A: Compaction is automatic, threshold-based, and all-or-nothing — when the context window hits ~85%, everything before a boundary gets summarized into a structured recap. Editable Context is surgical and agent-directed — the agent hides one specific stale grep result, or replaces one incorrect statement, preserving the rest of the thread. They're complementary: Editable Context reduces the need for compaction by keeping the context clean, and when compaction does trigger, it works on the already-cleaned thread. + +**Q: What happens to edits when I fork a session?** + +A: Edit metadata is preserved. The forked session has the same visibility state as the original at the fork point. Hidden parts remain hidden in the fork. + +**Q: Can I undo an edit the agent made?** + +A: Yes. Every edit records the agent name, timestamp, and reason. You can tell the agent "unhide that part" or "undo the last edit." The edit history is queryable via the `/edits` API endpoint. + +**Q: Does this increase cost?** + +A: Each `thread_edit` call is a tool invocation, consuming a small number of tokens (the tool parameters). However, Editable Context typically *reduces* overall cost by preventing context window bloat — fewer tokens sent per turn means lower cost per turn, and sessions last longer before hitting compaction or requiring a restart. + +**Q: What if the agent edits itself into a corner — hides too much and loses important context?** + +A: Three safety mechanisms prevent this: (1) the agent cannot hide more than 70% of all parts, (2) it cannot edit the 2 most recent turns, and (3) it's limited to 10 edits per turn. If the agent does over-prune, you can instruct it to unhide specific parts, or toggle edit indicators to see everything that was hidden. + +--- + +### Internal / Technical FAQs + +**Q: Why build this as an agent tool rather than an automatic background process?** + +A: Automatic editing (like compaction) is indiscriminate — it can't know which specific tool result is stale or which statement is wrong. Only the agent, in the context of the ongoing task, has the judgment to decide what's noise and what's signal. Making it a tool means the agent explicitly decides, and the decision is visible in the conversation log as a tool call. + +**Q: Why part-level edits rather than message-level?** + +A: An assistant message often contains 5+ parts (text blocks, tool calls, reasoning). Hiding one bad grep result shouldn't lose the other 4 good tool results in the same turn. Part-level granularity is surgical. Message-level would be too coarse. + +**Q: How does persistence work?** + +A: Two approaches, depending on deployment: + +- **Plugin path:** Edit metadata stored in `part.metadata.edit` (the existing `metadata: z.record(z.string(), z.any()).optional()` field on TextPart, ToolPart, ReasoningPart). Written via REST `PATCH` to the server. Atomic — lives in the same SQLite row as the part. + +- **Fork path:** An `edit` field added to `PartBase` in the Zod schema. Inherits to all 12 part types. Same SQLite storage, cleaner type safety. + +**Q: What is the risk that `experimental.chat.messages.transform` gets removed?** + +A: Medium. The hook is prefixed `experimental`, which means the API is not guaranteed stable. However, it's actively used by the codebase and has a clear, useful purpose. Mitigation: pin to a specific opencode version, and propose upstreaming the hook as stable. The fork path eliminates this risk entirely by hardcoding `filterEdited()` in the processor pipeline. + +**Q: Why can't agents edit the last 2 turns?** + +A: To prevent doom loops. Without this guard, an agent could: generate output → decide it's wrong → hide it → regenerate → decide *that's* wrong → hide it → repeat forever. The 2-turn protection forces the agent to move forward and only edit retrospectively. + +**Q: Could a malicious plugin use this to gaslight the agent?** + +A: In the fork path, the `thread.edit.before` plugin hook lets plugins *block* edits but not *initiate* them — only the agent's `thread_edit` tool can create edits. The ownership rule (agents can only edit their own messages) prevents cross-agent manipulation. In the plugin path, the tool itself enforces ownership by checking the message's `agent` field against the calling agent. + +**Q: How does this interact with session sharing?** + +A: When sharing a session, hidden parts should be excluded from the shared data (they were hidden for a reason). The share module applies the edit filter before serialization. If a user wants to share the full unedited history, they can unhide all parts before sharing. + +**Q: What metrics should we track to validate this feature?** + +A: Key metrics: +- **Session length before restart** — should increase (agents stay effective longer) +- **Compaction frequency** — should decrease (edits keep context lean) +- **Total tokens per session** — should decrease (less noise in context) +- **User-initiated "start over" rate** — should decrease +- **Edit operations per session** — usage adoption signal (target: 2-5 edits in sessions >20 turns) + +**Q: What's the migration path from plugin to fork?** + +A: The plugin stores edit metadata in `part.metadata.edit`. The fork stores it in `part.edit`. A migration script reads all parts with `metadata.edit`, copies the data to the top-level `edit` field, and clears `metadata.edit`. This is a one-time, non-destructive operation. Both representations use the same `EditMeta` shape, so the transform logic is identical. + +**Q: Why not just improve compaction instead?** + +A: Compaction solves a different problem (context window overflow). It's a blunt instrument by design — when you're out of space, you summarize everything. Editable Context solves the *quality* problem: the agent knows something in its context is wrong or stale *before* the context window fills up. Better compaction doesn't help when the issue is a wrong answer on turn 5 being treated as ground truth on turn 40. Editable Context lets the agent say "actually, I was wrong about that" and fix it in place. diff --git a/docs/research/REPORT.md b/docs/research/REPORT.md new file mode 100644 index 000000000..26f6de66c --- /dev/null +++ b/docs/research/REPORT.md @@ -0,0 +1,1028 @@ +# Frankencode — Deep Research Report on OpenCode + +> **Frankencode** is a fork of OpenCode with agent-driven context editing. This report documents the base architecture. + +**Upstream repository:** +**Default branch:** `dev` +**License:** MIT +**Language:** TypeScript (54.7%), MDX (41.2%), CSS (3.2%) +**Runtime:** Bun +**Stars:** ~122K + +--- + +## Table of Contents + +1. [Project Overview & Architecture](#1-project-overview--architecture) +2. [Monorepo Structure](#2-monorepo-structure) +3. [Dependencies & Technology Stack](#3-dependencies--technology-stack) +4. [Database & Storage Layer](#4-database--storage-layer) +5. [Session & Thread Data Structures](#5-session--thread-data-structures) +6. [Message System & Data Structures](#6-message-system--data-structures) +7. [Context Management & Compaction](#7-context-management--compaction) +8. [Agent System](#8-agent-system) +9. [Tool System](#9-tool-system) +10. [Plugin System & Extensibility](#10-plugin-system--extensibility) +11. [MCP (Model Context Protocol) Integration](#11-mcp-model-context-protocol-integration) +12. [Provider System](#12-provider-system) +13. [Command System (Slash Commands)](#13-command-system-slash-commands) +14. [Skill System](#14-skill-system) +15. [TUI (Terminal UI)](#15-tui-terminal-ui) +16. [Server & API](#16-server--api) +17. [LSP Integration](#17-lsp-integration) +18. [Tracking, Telemetry & HTTP Headers](#18-tracking-telemetry--http-headers) +19. [Privacy & Data Transmission](#19-privacy--data-transmission) +20. [Configuration System](#20-configuration-system) +21. [Authentication & Credential Management](#21-authentication--credential-management) + +--- + +## 1. Project Overview & Architecture + +OpenCode is an open-source, provider-agnostic AI coding agent built for the terminal. It is a direct competitor/alternative to Claude Code, with key differentiators being full open-source availability, multi-provider support, LSP integration, and a client/server architecture. + +**Core architecture:** Client/server model where the TUI spawns a Worker process for the backend. Communication between client and server happens via RPC with fetch proxy and EventSource (SSE) for real-time updates. + +**Key architectural traits:** +- Monorepo with Bun workspaces + Turborepo +- Effect-TS for structured concurrency and dependency injection throughout the core +- Vercel AI SDK (`ai` package) for LLM provider abstraction +- Drizzle ORM over SQLite for persistence +- Hono for the HTTP server +- Solid.js rendered into the terminal via `@opentui/solid` / `@opentui/core` for the TUI +- Yargs for CLI argument parsing + +--- + +## 2. Monorepo Structure + +| Package | Purpose | +|---------|---------| +| `packages/opencode` | Core CLI and agent engine (main product) | +| `packages/plugin` | Plugin type definitions and SDK | +| `packages/app` | Web app frontend (SolidJS) with e2e tests | +| `packages/desktop` | Tauri-based desktop app | +| `packages/desktop-electron` | Electron-based desktop app | +| `packages/console` | Console/website (opencode.ai) with Drizzle migrations and DB schema | +| `packages/web` | Documentation website (Astro/Starlight, i18n in 17+ languages) | +| `packages/ui` | Shared UI component library (icons, themes, styles) | +| `packages/sdk/js` | JavaScript SDK for programmatic access | +| `packages/docs` | Documentation content (MDX) | +| `packages/enterprise` | Enterprise features | +| `packages/slack` | Slack integration | +| `packages/storybook` | Storybook for UI components | +| `packages/containers` | Docker container definitions | +| `packages/function` | Serverless functions | +| `packages/identity` | Authentication/identity | +| `packages/util` | Shared utilities | +| `sdks/vscode` | VS Code extension | +| `infra` | Infrastructure (SST) | +| `nix` | Nix build configuration | +| `.opencode` | Project's own OpenCode configuration | + +Key subdirectories within `packages/opencode/src`: + +``` +src/ +├── agent/ # Agent system with prompt templates +├── provider/ # LLM provider integrations +├── tool/ # Tool implementations (bash, edit, read, etc.) +├── lsp/ # Language Server Protocol integration +├── mcp/ # Model Context Protocol support +├── session/ # Session management, message processing, LLM streaming +├── server/ # HTTP server with routes (Hono) +├── cli/cmd/tui/ # Terminal UI (Solid.js components, routes, themes) +├── config/ # Configuration system +├── permission/ # Permission system +├── snapshot/ # File snapshot/undo system +├── worktree/ # Git worktree support +├── skill/ # Skill system +├── shell/ # Shell integration +├── pty/ # PTY (pseudo-terminal) management +├── storage/ # SQLite + file-based storage +├── plugin/ # Plugin loading and hook dispatch +├── auth/ # Credential storage +├── account/ # Account management (OAuth) +├── command/ # Slash command registry +├── share/ # Session sharing +├── id/ # ID generation +├── flag/ # Feature flags (env vars) +├── env/ # Per-instance env isolation +└── installation/ # Version, channel, upgrade logic +``` + +--- + +## 3. Dependencies & Technology Stack + +**Runtime & Build:** +- **Bun** `1.3.10` — runtime, package manager, bundler, native SQLite bindings +- **Turborepo** — monorepo task orchestration +- **Husky** — git hooks +- **Vite 7** — frontend build (web/desktop apps) +- **SST 3.18** — infrastructure deployment + +**Core Libraries:** +- **Effect** `4.0.0-beta.31` — structured concurrency, DI, error handling +- **Vercel AI SDK** (`ai` `5.0.124`) — LLM provider abstraction, streaming, tool calling +- **Hono** — HTTP server framework +- **Drizzle ORM** — SQLite schema/queries +- **Zod 4** — schema validation +- **Yargs** — CLI parsing + +**UI:** +- **Solid.js** — reactive UI framework (both TUI and web) +- **`@opentui/solid`** / **`@opentui/core`** — Solid.js terminal rendering +- **Tailwind CSS 4** — styling (web/desktop) +- **Shiki** — syntax highlighting + +**Desktop:** +- **Tauri** — primary desktop wrapper +- **Electron** — alternative desktop wrapper + +**LLM Provider SDKs (20+):** +- `@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, `@ai-sdk/xai`, `@ai-sdk/mistral`, `@ai-sdk/groq`, `@ai-sdk/deepinfra`, `@ai-sdk/cerebras`, `@ai-sdk/cohere`, `@ai-sdk/togetherai`, `@ai-sdk/perplexity`, and more + +--- + +## 4. Database & Storage Layer + +### Two storage systems coexist: + +#### 4.1 SQLite Database (Primary) +**Location:** `packages/opencode/src/storage/db.ts` +**Driver:** Bun's native SQLite bindings via Drizzle ORM +**Configuration:** WAL mode, foreign keys enabled, 64MB cache +**Path:** Channel-aware (separate DBs for latest/beta/other channels) + +Features: +- Context-based transaction management +- Side-effect queue that runs after DB operations complete +- Migrations managed via Drizzle + +#### 4.2 File-based JSON Storage +**Location:** `packages/opencode/src/storage/storage.ts` +**Purpose:** Unstructured data like session diffs +**Mechanism:** Key arrays map to file paths, read/write locks for concurrency + +### SQL Schema + +The initial migration (`20260127222353`) defines these tables: + +| Table | Purpose | Key Columns | +|-------|---------|-------------| +| **`project`** | Git project/repo tracking | `id`, `worktree`, `vcs`, `name`, `sandboxes` | +| **`session`** | Conversation session | `id`, `project_id` (FK), `parent_id`, `slug`, `directory`, `title`, `version`, `share_url`, `summary_*`, `revert`, `permission`, timestamps | +| **`message`** | Messages within session | `id`, `session_id` (FK, CASCADE), `data` (JSON blob), timestamps | +| **`part`** | Sub-components of messages | `id`, `message_id` (FK, CASCADE), `session_id`, `data` (JSON blob), timestamps | +| **`todo`** | Task items per session | composite PK(`session_id`, `position`), `content`, `status`, `priority` | +| **`permission`** | Per-project permission rules | `project_id` (FK, CASCADE), `data` (JSON) | +| **`session_share`** | Sharing metadata | `session_id` (FK, CASCADE), `id`, `secret`, `url` | +| **`workspace`** | Workspace per project | `id`, `project_id` (FK, CASCADE), `type`, `branch`, `name`, `directory`, `extra` (JSON) | +| **`account`** | User accounts | `id`, `email`, `url`, `access_token`, `refresh_token` | +| **`account_state`** | Active account tracking | `active_account_id`, `active_org_id` | + +**Relationships:** `project → session → message → part` (all CASCADE delete) + +#### Subsequent Migrations: +- `20260211` — Added project commands +- `20260225` — Created `workspace` table +- `20260227` — Added `workspace_id` to session + index +- `20260303` — Added workspace fields +- `20260309` — Moved org to state +- `20260312` — Replaced simple indexes with composite indexes on `message(session_id, time_created, id)` and `part(message_id, id)` + +--- + +## 5. Session & Thread Data Structures + +**Location:** `packages/opencode/src/session/index.ts` + +### Session.Info Schema + +```typescript +{ + id: string // 26-char descending ID (prefix "ses") + slug: string // Human-readable identifier + projectID: string // FK to project + workspaceID?: string // FK to workspace + directory: string // Working directory + parentID?: string // Parent session (for forks) + title: string // Auto-generated or user-set + version: number // Schema version + summary: { + additions: number + deletions: number + files: string[] + diffs: string[] + } + share?: { // Sharing metadata + id: string + secret: string + url: string + } + permission: object // Permission state + revert?: object // Revert/undo state + time: { + created: Date + updated: Date + compacting?: Date // When compaction is in progress + archived?: Date // Soft-delete timestamp + } +} +``` + +### Session Operations + +| Operation | Description | +|-----------|-------------| +| `create()` | New session with project/workspace binding | +| `get()` / `list()` / `listGlobal()` | Retrieval (global = cross-project) | +| `remove()` | Soft-delete (archive) | +| `fork()` | Clone session up to a given message | +| `children()` | List sub-sessions | +| `messages()` | Stream all messages | +| `updateMessage()` / `updatePart()` | Upsert (INSERT...ON CONFLICT UPDATE) | +| `setTitle()` / `setArchived()` / `setPermission()` | Metadata updates | +| `setSummary()` / `setRevert()` / `clearRevert()` | Summary/undo state | +| `share()` / `unshare()` | Session sharing via ShareNext | + +### ID Generation (`packages/opencode/src/id/id.ts`) + +- 26-character IDs: 3-letter prefix + hex timestamp/counter + random base62 +- Sessions use `descending()` IDs (reverse chronological sort) +- Messages and parts use `ascending()` IDs + +### Event Bus + +Sessions publish events via `Bus`: +- `session.created` +- `session.updated` +- `session.deleted` +- `session.diff` +- `session.error` + +--- + +## 6. Message System & Data Structures + +**Location:** `packages/opencode/src/session/message-v2.ts` + +### Message Types (discriminated union by `role`) + +**User Message:** +```typescript +{ + role: "user" + format: "text" | "json-schema" + summary?: string + agent?: { name: string, model: string } + model?: { id: string, provider: string } + systemPrompt?: string[] + toolConfig?: object +} +``` + +**Assistant Message:** +```typescript +{ + role: "assistant" + error?: string + tokens: { + input: number + output: number + reasoning: number + cache: { read: number, write: number } + } + cost: number + metadata?: object +} +``` + +### Part Types (discriminated union by `type`) + +Parts are sub-components of messages stored in the `part` table: + +| Part Type | Description | +|-----------|-------------| +| `text` | Plain text content | +| `reasoning` | Model reasoning/thinking blocks | +| `file` | Images/PDFs with source tracking | +| `tool` | Tool invocations (states: pending → running → completed/error) | +| `snapshot` | File state snapshots | +| `patch` | File patches/diffs | +| `agent` | Agent delegation markers | +| `compaction` | Compaction summary markers | +| `subtask` | Subtask delegation markers | +| `retry` | Retry markers | +| `step` | Step markers for multi-step operations | + +### Key Functions + +- `toModelMessages()` — Converts stored messages to provider-compatible format for LLM calls +- `page()` — Cursor-based pagination over messages +- `stream()` — Async generator for all messages in a session +- `filterCompacted()` — Filters messages based on compaction state (hides pre-compaction messages from LLM context) + +--- + +## 7. Context Management & Compaction + +**Location:** `packages/opencode/src/session/compaction.ts`, `processor.ts` + +### Context Window Strategy + +1. **Tool Output Truncation:** 50K token cap per tool execution output; 40K token threshold for pruning old tool outputs during compaction +2. **Preemptive Compaction:** Triggered at ~85% context usage threshold +3. **Compaction Agent:** A dedicated hidden `compaction` agent summarizes conversation history into structured categories + +### Compaction Summary Structure + +When compaction triggers, conversation history is summarized into: +- **Goal** — What the user is trying to accomplish +- **Instructions** — Standing instructions and constraints +- **Discoveries** — Important findings from exploration +- **Accomplished** — Work completed so far +- **Relevant files** — Files touched or referenced + +### Processor Loop (`processor.ts`) + +The processor manages the LLM stream lifecycle: +- Handles text generation, tool calls, reasoning blocks, snapshots +- **Doom-loop detection:** Detects 3 consecutive identical tool calls and breaks the loop +- **Error recovery:** Retry with exponential backoff +- **Compaction trigger:** Monitors token usage and triggers compaction when threshold exceeded +- Publishes events for each stream chunk (text delta, tool start/complete, reasoning) + +### Message Filtering + +`filterCompacted()` hides pre-compaction messages from the LLM context window. The compaction part itself becomes the new "start" of conversation history, preserving context without repeating the full history. + +--- + +## 8. Agent System + +**Location:** `packages/opencode/src/agent/agent.ts` + +### Built-in Agents + +| Agent | Role | Tool Access | Visibility | +|-------|------|-------------|------------| +| **build** | Primary coding agent | Full (all tools) | User-facing | +| **plan** | Analysis/planning | Read-only (no edit tools) | User-facing | +| **general** | Research/parallel tasks | Subset (search, read, web) | Subagent via `@general` | +| **explore** | Fast codebase exploration | Read-only, fast | Subagent | +| **compaction** | Context summarization | None | Hidden/internal | +| **title** | Session title generation | None | Hidden/internal | + +### Agent Configuration + +Agents can be configured via: +- `opencode.json` agent section +- Markdown files in `~/.config/opencode/agents/` +- Interactive setup via `opencode agent create` + +Configuration options: model override, tool enable/disable (with wildcards), permissions (`ask`/`allow`/`deny`), temperature, custom system prompt, max steps. + +### Permission Framework + +Uses `PermissionNext` for fine-grained control over what each agent can do. Permissions are resolved per-tool, per-agent, with project-level overrides. + +--- + +## 9. Tool System + +**Location:** `packages/opencode/src/tool/` + +### Tool Definition API (`tool.ts`) + +```typescript +Tool.Info = { + id: string + init: () => { + description: string + parameters: ZodSchema + execute: (params, context: Tool.Context) => Promise + } +} + +Tool.Context = { + sessionID: string + messageID: string + agent: AgentInfo + abort: AbortSignal + metadata(): object + ask(): Promise // Request user permission +} +``` + +### Tool Registry (`registry.ts`) + +Discovery sources: +1. Built-in tools (hardcoded) +2. Custom tools from `{tool,tools}/*.{js,ts}` directories +3. Plugin-defined tools +4. MCP server tools + +### Core Built-in Tools (17+) + +| Tool | File | Description | +|------|------|-------------| +| `bash` | `bash.ts` | Shell execution; tree-sitter parsing, 2-min timeout, 30KB output limit | +| `read` | `read.ts` | File/directory reading; 2000-line default, 50KB cap, binary detection, image/PDF base64 | +| `write` | `write.ts` | File creation with diff generation, LSP diagnostics | +| `edit` | `edit.ts` | File editing with **9 fallback replacement strategies**: simple, line-trimmed, block-anchor, whitespace-normalized, indentation-flexible, escape-normalized, trimmed-boundary, context-aware, multi-occurrence | +| `multiedit` | — | Multiple edits in one call | +| `apply_patch` | `apply_patch.ts` | Unified diff patch application (add/update/delete/move files) | +| `glob` | `glob.ts` | Ripgrep-based file pattern matching, 100 file limit | +| `grep` | `grep.ts` | Ripgrep content search, 100 match limit, 2000-char line truncation | +| `webfetch` | `webfetch.ts` | URL fetching with HTML→Markdown, 5MB limit | +| `websearch` | `websearch.ts` | Exa MCP API integration, SSE parsing, 25s timeout | +| `codesearch` | `codesearch.ts` | Exa API for code context, 30s timeout | +| `lsp` | `lsp.ts` | 9 LSP operations (definition, references, hover, symbols, etc.) | +| `task` | `task.ts` | Subagent delegation, child session creation | +| `batch` | `batch.ts` | Parallel execution of up to 25 tool calls | +| `skill` | `skill.ts` | Load domain-specific skills into context | +| `question` | — | Ask user for input | +| `plan` | — | Planning/analysis tool | +| `todo` | — | Task management (todowrite/todoread) | + +### Tool Execution Lifecycle + +1. **Discovery:** Registry scans built-in, custom, plugin, and MCP tools +2. **Filtering:** Based on active agent, model capabilities, config flags +3. **Plugin hooks:** `tool.define` hook allows plugins to modify tool definitions +4. **Permission check:** `ask()` for tools requiring user approval +5. **Execution:** With automatic parameter validation and output truncation +6. **Post-execution:** LSP diagnostics for file-modifying tools + +--- + +## 10. Plugin System & Extensibility + +### Plugin SDK (`packages/plugin/src/index.ts`) + +**Plugin signature:** +```typescript +type Plugin = (input: PluginInput) => Hooks + +type PluginInput = { + client: SDKClient + project: ProjectInfo + directory: string + worktree: string + $: BunShell + serverURL: string + sessionID: string + agent: AgentInfo +} +``` + +### Hook Types + +| Hook Category | Hooks | Description | +|---------------|-------|-------------| +| **Events** | `event` | Listen to system-wide events (session, message, file, permission, LSP, command lifecycle) | +| **Config** | `config` | Modify configuration at load time | +| **Tools** | `tool.define`, `tool.execute.before`, `tool.execute.after` | Define tools, intercept execution | +| **Auth** | `auth.provider` | Add OAuth/API key authentication methods | +| **Chat** | `chat.params`, `chat.headers`, `chat.message.before`, `chat.message.after`, `experimental.chat.system.transform` | Modify LLM requests, headers, messages, system prompt | +| **Permission** | `permission.request` | Auto-allow/deny specific permission requests | +| **Command** | `command.execute.before`, `command.execute.after` | Intercept slash commands | +| **Shell** | `shell.env` | Inject environment variables | +| **Session** | `session.compaction` | Customize session compaction | +| **Completion** | `text.completion` | Autocomplete suggestions | +| **Stop** | `stop` | Intercept agent stop attempts | + +### Plugin Loading (`packages/opencode/src/plugin/index.ts`) + +Three sources: +1. **Internal plugins:** Directly imported (Codex `codex.ts`, Copilot `copilot.ts`, GitLab) +2. **Built-in plugins:** Installed from npm (e.g., `opencode-anthropic-auth@0.0.13`), can be disabled via flags +3. **External plugins:** User-configured via npm packages or local file paths, auto-installed via `BunProc.install()` + +Plugin dispatch: `Plugin.trigger("hook.name", context, data)` — used throughout the codebase for LLM streaming, tool resolution, chat params/headers. + +### Internal Plugin Examples + +**Copilot plugin (`copilot.ts`):** +- GitHub Copilot OAuth device flow +- Vision capability detection +- Agent feature detection +- Rate limit handling + +**Codex plugin (`codex.ts`):** +- OpenAI Codex OAuth with PKCE +- Token refresh +- JWT claim extraction +- Model configuration + +--- + +## 11. MCP (Model Context Protocol) Integration + +**Location:** `packages/opencode/src/mcp/` + +### Transport Types +- `StreamableHTTPClientTransport` — HTTP-based +- `SSEClientTransport` — Server-Sent Events +- `StdioClientTransport` — Standard I/O (local processes) + +### Configuration + +```jsonc +{ + "mcp": { + "my-server": { + "type": "local", + "command": ["npx", "-y", "my-mcp-server"], + "env": { "KEY": "value" }, + "timeout": 30000 + }, + "remote-server": { + "type": "remote", + "url": "https://example.com/mcp", + // OAuth handled automatically + } + } +} +``` + +### Tool Integration + +MCP tools are discovered via `client.listTools()` and converted to Vercel AI SDK format via `convertMcpTool()`. Multiple MCP servers can provide tools simultaneously. + +### OAuth for Remote MCP + +- Credential storage in `mcp-auth.json` (file mode `0o600`) +- PKCE flow support +- Token expiration checking +- Dynamic client registration +- CLI auth via `opencode mcp auth ` + +--- + +## 12. Provider System + +**Location:** `packages/opencode/src/provider/` + +### Architecture + +Uses a `BUNDLED_PROVIDERS` map linking npm AI SDK packages to factory functions, plus `CUSTOM_LOADERS` for provider-specific initialization. + +### Supported Providers (20+) + +| Provider | Custom Loader | Special Handling | +|----------|:---:|---| +| Anthropic | ✓ | Beta headers, SDK-managed User-Agent | +| OpenAI | ✓ | Codex integration | +| Google Vertex | ✓ | OAuth token injection via custom fetch | +| Google Vertex (Anthropic) | ✓ | Cross-provider routing | +| Amazon Bedrock | ✓ | AWS credential chain | +| Azure | ✓ | Azure AD auth | +| GitHub Copilot | ✓ | OAuth device flow via plugin | +| GitHub Copilot Enterprise | ✓ | Enterprise auth | +| OpenRouter | ✓ | Referrer headers | +| Vercel | ✓ | Referrer headers | +| GitLab | ✓ | Custom User-Agent | +| Cerebras | ✓ | Integration header | +| SAP AI Core | ✓ | SAP-specific auth | +| Cloudflare Workers AI | ✓ | Workers routing | +| Cloudflare AI Gateway | ✓ | Gateway routing | +| xAI | — | Standard AI SDK | +| Mistral | — | Standard AI SDK | +| Groq | — | Standard AI SDK | +| DeepInfra | — | Standard AI SDK | +| Cohere | — | Standard AI SDK | +| Together | — | Standard AI SDK | +| Perplexity | — | Standard AI SDK | + +### Model Resolution + +Model definitions fetched from `https://models.dev/api.json` with: +- `User-Agent: ${Installation.USER_AGENT}` +- Local cache, hourly refresh + +### Model Priority (at startup) + +1. Command-line flag +2. Config setting +3. Last used model +4. Internal default + +--- + +## 13. Command System (Slash Commands) + +**Location:** `packages/opencode/src/command/index.ts` + +### Sources + +1. **Built-in:** `/init` (create/update AGENTS.md), `/review` (review changes) +2. **User-configured:** From config files (markdown or JSON) +3. **MCP prompts:** Auto-converted from MCP server prompts +4. **Skills:** Auto-registered as commands when no name conflict + +### Command Definition + +```jsonc +{ + "command": { + "review-pr": { + "template": "Review PR #$1 focusing on $2", + "description": "Review a pull request", + "agent": "plan", // Optional: override agent + "model": "claude-4.6", // Optional: override model + "subtask": true // Optional: force subagent + } + } +} +``` + +Template placeholders: `$1`, `$2`, `$3`, `$ARGUMENTS` + +File-based: `.opencode/commands/*.md` (project) or `~/.config/opencode/commands/*.md` (global) + +--- + +## 14. Skill System + +**Location:** `packages/opencode/src/skill/skill.ts` + +### Discovery Paths + +- `.opencode/skill/`, `.opencode/skills/` +- `~/.config/opencode/skills/` +- `.claude/skills/`, `.agents/skills/` (compatibility) +- Configured paths in `opencode.json` +- Remote URLs (with caching) + +### Skill Format + +```markdown +--- +name: my-skill +description: Brief description of what this skill does +license: MIT +compatibility: opencode >= 1.0 +metadata: {} +--- + +Skill content with instructions for the agent... +``` + +### Permission Control + +```jsonc +{ + "skill": { + "permissions": { + "my-skill": "allow", // Immediate access + "untrusted-*": "ask", // User approval required + "blocked-skill": "deny" // Hidden from agents + } + } +} +``` + +Skills are loaded on-demand via the `skill` tool and injected as synthetic messages into the conversation context. + +--- + +## 15. TUI (Terminal UI) + +**Location:** `packages/opencode/src/cli/cmd/tui/` + +### Framework + +**Solid.js** rendered into the terminal via `@opentui/solid` and `@opentui/core`. This is notable — most terminal UIs use Go's Bubble Tea or similar; OpenCode uses a reactive web framework adapted for terminal rendering. + +### Architecture + +``` +app.tsx # Root: providers, routing, theme, keybindings +├── routes/ +│ ├── home.tsx # Landing: logo, prompt, MCP status, tips, version +│ └── session/ +│ ├── index.tsx # Session view: messages, tools, reasoning, files +│ ├── header.tsx # Model/agent display, navigation +│ ├── footer.tsx # Status bar +│ ├── sidebar.tsx # Session list, file tree +│ ├── permission.tsx # Permission request dialogs +│ └── question.tsx # User question dialogs +├── component/ +│ ├── prompt/index.tsx # Interactive textarea: autocomplete, file paste, +│ │ # shell mode (! prefix), / commands, history, stash +│ ├── dialog-command.tsx # Command palette +│ ├── dialog-model.tsx # Model picker +│ ├── dialog-agent.tsx # Agent picker +│ ├── dialog-skill.tsx # Skill browser +│ ├── dialog-mcp.tsx # MCP server status +│ └── dialog-session-list.tsx # Session browser +└── context/ + ├── keybind.tsx # Leader key pattern with 2s timeout + ├── route.tsx # Routing state + ├── sdk.tsx # SDK client context + ├── theme.tsx # Dark/light mode, terminal color detection + ├── prompt.tsx # Prompt state management + ├── sync.tsx # Real-time sync + └── tui-config.tsx # TUI-specific config +``` + +### Key Features + +- Background color detection (dark/light mode) +- Text selection with clipboard support +- Terminal title management +- Windows raw mode handling +- Prompt history and stash +- File/image pasting into prompt +- Shell mode (`!` prefix for direct shell commands) +- Timeline navigation within sessions +- Undo/redo support +- Session forking from any message + +--- + +## 16. Server & API + +**Location:** `packages/opencode/src/server/` + +### Framework + +Hono-based HTTP server with OpenAPI schema generation. + +### Features + +- REST API for all session/message/tool operations +- SSE event streaming with 10-second heartbeat +- Authentication middleware +- CORS support +- Workspace context injection +- Fallback proxy to `app.opencode.ai` + +### Key Endpoints + +Session CRUD, message sending (sync/async), forking, aborting, sharing, reverting, diffs, shell commands, permission handling, todos, status. + +### Client-Server Communication + +The TUI spawns a Worker process and communicates via two transport modes: +1. **Internal:** Direct worker RPC +2. **External:** Network server (for remote/multi-client scenarios) + +--- + +## 17. LSP Integration + +**Location:** `packages/opencode/src/lsp/` + +### Supported Languages (30+) + +Built-in language server support for JavaScript/TypeScript, Python (Pyright), Rust (rust-analyzer), Go (gopls), Java (jdtls), PHP (Intelephense), and many more. + +### LSP Tool Operations (9) + +| Operation | Description | +|-----------|-------------| +| `goToDefinition` | Navigate to symbol definition | +| `findReferences` | Find all references to a symbol | +| `hover` | Type info and documentation | +| `documentSymbol` | List symbols in a file | +| `workspaceSymbol` | Search symbols across workspace | +| `codeAction` | Get available code actions | +| `rename` | Rename symbol across project | +| `callHierarchy` (incoming) | Who calls this function | +| `callHierarchy` (outgoing) | What does this function call | + +### Lifecycle + +- Servers auto-spawn based on file extensions +- Automatic dependency checking +- Diagnostics aggregated and provided to LLM after file edits +- Auto-download can be disabled via `OPENCODE_DISABLE_LSP_DOWNLOAD` env var + +--- + +## 18. Tracking, Telemetry & HTTP Headers + +### HTTP Headers Set Per Provider + +| Provider | Header | Value | +|----------|--------|-------| +| **All non-Anthropic** | `User-Agent` | `opencode/${VERSION}` | +| **Anthropic** | (none custom) | SDK manages its own User-Agent | +| **Anthropic** | `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14` | +| **OpenRouter** | `HTTP-Referer` | `https://opencode.ai/` | +| **OpenRouter** | `X-Title` | `opencode` | +| **Vercel** | `HTTP-Referer` | `https://opencode.ai/` | +| **Vercel** | `X-Title` | `opencode` | +| **Cerebras** | `X-Cerebras-3rd-Party-Integration` | `opencode` | +| **GitLab** | `User-Agent` | `opencode/${VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${platform} ${release}; ${arch})` | +| **OpenCode provider** | `x-opencode-project` | Project ID | +| **OpenCode provider** | `x-opencode-session` | Session ID | +| **OpenCode provider** | `x-opencode-request` | Request ID | +| **OpenCode provider** | `x-opencode-client` | Client identifier | + +### User-Agent String + +Defined in `packages/opencode/src/installation/index.ts`: + +``` +opencode/${CHANNEL}/${VERSION}/${OPENCODE_CLIENT} +``` + +Where: +- `VERSION` = build-time `OPENCODE_VERSION` +- `CHANNEL` = build-time `OPENCODE_CHANNEL` (e.g., `latest`, `beta`) +- `OPENCODE_CLIENT` = from `Flag.OPENCODE_CLIENT` env var + +### OpenTelemetry (Opt-in Only) + +```typescript +// In session/llm.ts — streamText() configuration +experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, // false by default + metadata: { + userId: cfg.username ?? "unknown", + sessionId: input.sessionID + } +} +``` + +- **Disabled by default** — must be explicitly enabled via `experimental.openTelemetry: true` in config +- Uses the Vercel AI SDK's built-in OpenTelemetry integration +- User configures their own OTLP endpoint +- When enabled, sends: `userId`, `sessionId`, plus standard AI SDK telemetry (model, tokens, latency) + +### Custom Fetch Wrapper + +The provider system wraps all LLM API calls with a custom `fetch` that: +1. Adds SSE chunk timeout (default 5 minutes) via AbortController +2. Strips OpenAI `itemId` metadata from request bodies (Codex compatibility) +3. Supports custom provider-level fetch functions (e.g., Google Vertex OAuth token injection) +4. Merges plugin-provided headers via `Plugin.trigger("chat.headers", ...)` + +### Model Data Fetching + +- Fetches model definitions from `https://models.dev/api.json` +- Sends `User-Agent: ${Installation.USER_AGENT}` +- Caches locally, refreshes hourly + +### No Dedicated Analytics/Tracking + +**There is no dedicated analytics, crash reporting, or phone-home telemetry module.** The codebase does not contain: +- No Google Analytics, Mixpanel, Segment, PostHog, or similar +- No crash reporting (Sentry, Bugsnag, etc.) +- No usage statistics sent to anomalyco/opencode servers +- No cookies (it's a CLI/TUI application) + +--- + +## 19. Privacy & Data Transmission + +### External Data Transmission Points + +| What | Where | When | Can Disable? | +|------|-------|------|:---:| +| LLM API calls | Configured provider endpoint | Every message | Use local models | +| Model definitions | `models.dev/api.json` | Hourly refresh | — | +| Session sharing | `opncd.ai` (default) | Manual or auto share | `OPENCODE_DISABLE_SHARE=true` or `share: "disabled"` in config | +| OpenTelemetry | User-configured endpoint | When enabled | `experimental.openTelemetry: false` (default) | +| MCP servers | Configured endpoints | When MCP tools used | Don't configure MCP | +| Plugin npm install | npm registry | First plugin load | Use local plugins | +| Remote skills | Configured URLs | On skill discovery | Use local skills only | +| Remote instructions | Configured URLs | On config load (5s timeout) | Don't configure remote instructions | +| `.well-known/opencode` | Org-configured URL | On config load | — | +| Web search/fetch | Exa API / target URLs | When tools invoked | Disable tools in config | +| Upgrade check | Package registry | On `opencode upgrade` | Don't run upgrade | + +### Session Sharing Details + +- `packages/opencode/src/share/share-next.ts` +- Default endpoint: `https://opncd.ai` +- Enterprise: configurable URL +- Modes: `"manual"` (explicit), `"auto"` (automatic), `"disabled"` +- Env override: `OPENCODE_DISABLE_SHARE=true` +- Data shared: messages, parts, diffs, model info + +### Credential Storage + +- **Auth file:** `~/.local/share/opencode/auth.json` (file mode `0o600`) +- **MCP auth:** `mcp-auth.json` (file mode `0o600`) +- Three auth types: `Api` (plain key), `Oauth` (access/refresh tokens), `WellKnown` (key+token) +- API key resolution: env vars → stored auth → plugin auth → custom loader → config + +### Headers Sent to OpenCode's Own Provider + +When using the `opencode` provider (OpenCode Zen/Go service), these identifying headers are sent: +- `x-opencode-project` — Project identifier +- `x-opencode-session` — Session identifier +- `x-opencode-request` — Per-request identifier +- `x-opencode-client` — Client identifier + +These enable server-side request correlation and are only sent to OpenCode's own LLM proxy service. + +--- + +## 20. Configuration System + +**Location:** `packages/opencode/src/config/config.ts` + +### Precedence Order (lowest → highest) + +1. Remote `.well-known/opencode` (organizational defaults) +2. Global config (`~/.config/opencode/opencode.json`) +3. Custom config (`OPENCODE_CONFIG` env var path) +4. Project config (`opencode.json` in project root) +5. `.opencode` directory configs +6. Inline config (`OPENCODE_CONFIG_CONTENT` env var) +7. Managed config (enterprise — highest priority) + +Configurations **merge together** (not replace). + +### Format + +JSON or JSONC (`opencode.jsonc`), validated against Zod schemas. + +### Variable Substitution + +- `{env:VARIABLE_NAME}` — environment variable injection +- `{file:path}` — file content injection + +### Feature Flags + +40+ flags via `OPENCODE_*` environment variables (`packages/opencode/src/flag/flag.ts`), controlling experimental features, model settings, behavior toggles. + +### Key Config Sections + +- `model` / `small_model` — Default model selection +- `provider` — Provider configuration with `baseURL`, API keys, options +- `agent` — Agent definitions and permissions +- `mcp` — MCP server configuration +- `tools` — Tool enable/disable +- `permission` — Global permission rules +- `command` — Slash command definitions +- `instructions` — Additional instruction files/URLs +- `lsp` — LSP server configuration +- `formatter` — Code formatter settings +- `watcher` — File watcher configuration +- `compaction` — Compaction thresholds +- `experimental` — Experimental features (OpenTelemetry, etc.) +- `disabled_providers` / `enabled_providers` — Provider filtering +- `share` — Sharing configuration + +--- + +## 21. Authentication & Credential Management + +**Location:** `packages/opencode/src/auth/`, `packages/opencode/src/account/` + +### Auth Storage + +File: `~/.local/share/opencode/auth.json` with permissions `0o600`. + +Three auth types: +```typescript +type AuthEntry = + | { type: "Api"; key: string } + | { type: "Oauth"; accessToken: string; refreshToken: string; expiry: Date } + | { type: "WellKnown"; key: string; token: string } +``` + +### API Key Resolution Order + +1. Environment variables (provider-specific, e.g., `ANTHROPIC_API_KEY`) +2. Stored auth from `auth.json` +3. Plugin-provided auth (e.g., GitHub Copilot OAuth) +4. Custom loader logic (e.g., AWS credential chain for Bedrock) +5. Config file options (`provider.*.options.apiKey`) + +### Account System + +- Device code OAuth flow for OpenCode console accounts +- Token refresh support +- Effect HTTP client for account API calls +- Account state tracked in SQLite (`account` and `account_state` tables) + +### Plugin Auth Hooks + +Plugins can provide custom authentication via the `auth.provider` hook, supporting: +- OAuth flows (authorization URL, callback, token exchange) +- API key collection (with interactive prompts) +- Custom credential management + +--- + +## Summary + +OpenCode is a sophisticated, extensible AI coding agent with a clean separation of concerns: + +- **No hidden telemetry** — OpenTelemetry is opt-in, no analytics SDKs, no phone-home +- **Provider-agnostic** — 20+ LLM providers via Vercel AI SDK +- **Rich extensibility** — Plugins (JS/TS hooks), MCP servers, custom tools, slash commands, skills +- **Solid data model** — SQLite with Drizzle ORM, well-structured session→message→part hierarchy +- **Context-aware** — Automatic compaction, token tracking, doom-loop detection +- **Privacy-conscious** — All external data transmission is configurable or disableable; credentials stored with restrictive file permissions; identifying headers only sent to relevant providers + +The primary tracking vectors are the `User-Agent` string (sent to LLM providers and models.dev) and the `x-opencode-*` headers (sent only to OpenCode's own proxy service). Session sharing is the only feature that transmits conversation data externally, and it can be fully disabled. diff --git a/docs/research/UI_CUSTOMIZATION.md b/docs/research/UI_CUSTOMIZATION.md new file mode 100644 index 000000000..9f2d5daac --- /dev/null +++ b/docs/research/UI_CUSTOMIZATION.md @@ -0,0 +1,565 @@ +# OpenCode UI Customization — Complete Reference + +All ways to customize the terminal UI (TUI), web UI, and desktop app in OpenCode. + +--- + +## Table of Contents + +1. [Themes](#1-themes) +2. [Keybindings](#2-keybindings) +3. [TUI Config Options](#3-tui-config-options) +4. [Runtime Display Toggles](#4-runtime-display-toggles) +5. [Command Palette & Slash Commands](#5-command-palette--slash-commands) +6. [Agent Colors](#6-agent-colors) +7. [Environment Flags](#7-environment-flags) +8. [TUI Event Bus (Programmatic Control)](#8-tui-event-bus-programmatic-control) +9. [Web UI Extension Points](#9-web-ui-extension-points) +10. [What Is NOT Customizable](#10-what-is-not-customizable) +11. [Plugin Influence on UI](#11-plugin-influence-on-ui) + +--- + +## 1. Themes + +### Configuration + +Set in `tui.json` (project or global): + +```json +{ "theme": "catppuccin" } +``` + +Config file locations (lowest to highest priority): +1. `~/.config/opencode/tui.json` (global) +2. `OPENCODE_TUI_CONFIG` env var path +3. Project-level `tui.json` / `tui.jsonc` +4. `.opencode/tui.json` (walking up from cwd) +5. Managed config directory (enterprise) + +### 33 Built-in Themes + +`aura`, `ayu`, `carbonfox`, `catppuccin`, `catppuccin-frappe`, `catppuccin-macchiato`, `cobalt2`, `cursor`, `dracula`, `everforest`, `flexoki`, `github`, `gruvbox`, `kanagawa`, `lucent-orng`, `material`, `matrix`, `mercury`, `monokai`, `nightowl`, `nord`, `one-dark`, `opencode`, `orng`, `osaka-jade`, `palenight`, `rosepine`, `solarized`, `synthwave84`, `tokyonight`, `vercel`, `vesper`, `zenburn` + +### Custom Themes + +Drop `*.json` files into: +- `~/.config/opencode/themes/` (global) +- `.opencode/themes/` (project-level, searched walking up from cwd) + +### Theme JSON Schema + +```jsonc +{ + // Reusable color aliases + "defs": { + "bg": "#1a1b26", + "fg": "#c0caf5", + "accent": "#7aa2f7" + }, + + "theme": { + // Core colors + "primary": "{defs.accent}", // Can reference defs + "secondary": "#9ece6a", + "accent": "#bb9af7", + "error": "#f7768e", + "warning": "#e0af68", + "success": "#9ece6a", + "info": "#7dcfff", + + // Text + "text": "{defs.fg}", + "textMuted": "#565f89", + "selectedListItemText": "#1a1b26", // defaults to background + + // Backgrounds + "background": "{defs.bg}", + "backgroundPanel": "#1f2335", + "backgroundElement": "#24283b", + "backgroundMenu": "#24283b", // defaults to backgroundElement + + // Borders + "border": "#3b4261", + "borderActive": "{defs.accent}", + "borderSubtle": "#292e42", + + // Diff rendering (12 keys) + "diffAdded": "#9ece6a", + "diffRemoved": "#f7768e", + "diffContext": "{defs.fg}", + "diffHunkHeader": "#7aa2f7", + "diffHighlightAdded": "#9ece6a", + "diffHighlightRemoved": "#f7768e", + "diffAddedBg": "#1a2b1a", + "diffRemovedBg": "#2b1a1a", + "diffContextBg": "{defs.bg}", + "diffLineNumber": "#565f89", + "diffAddedLineNumberBg": "#1a2b1a", + "diffRemovedLineNumberBg": "#2b1a1a", + + // Markdown rendering (14 keys) + "markdownText": "{defs.fg}", + "markdownHeading": "{defs.accent}", + "markdownLink": "#7dcfff", + "markdownLinkText": "#73daca", + "markdownCode": "#bb9af7", + "markdownBlockQuote": "#565f89", + "markdownEmph": "#e0af68", + "markdownStrong": "#f7768e", + "markdownHorizontalRule": "#3b4261", + "markdownListItem": "#9ece6a", + "markdownListEnumeration": "#e0af68", + "markdownImage": "#7dcfff", + "markdownImageText": "#73daca", + "markdownCodeBlock": "#24283b", + + // Syntax highlighting (9 keys) + "syntaxComment": "#565f89", + "syntaxKeyword": "#bb9af7", + "syntaxFunction": "#7aa2f7", + "syntaxVariable": "#c0caf5", + "syntaxString": "#9ece6a", + "syntaxNumber": "#ff9e64", + "syntaxType": "#2ac3de", + "syntaxOperator": "#89ddff", + "syntaxPunctuation": "#c0caf5" + }, + + // Optional + "thinkingOpacity": 0.6, // Opacity for reasoning/thinking blocks + "selectedListItemText": "#1a1b26" // Text color in selected list items +} +``` + +Each color key supports dark/light variants: +```json +"primary": { "dark": "#7aa2f7", "light": "#2e7de9" } +``` + +### Special "system" Theme + +Auto-generated from the terminal's actual ANSI palette (queried via `renderer.getPalette()`). Force reload with `SIGUSR2` signal. + +### Dark/Light Mode + +Auto-detected from terminal background luminance. Toggleable via: +- Command palette: "Toggle appearance" +- Persisted in KV store as `theme_mode` + +--- + +## 2. Keybindings + +### Configuration + +Set in `tui.json`: + +```json +{ + "keybinds": { + "leader": "ctrl+space", + "session_new": "ctrl+n", + "input_submit": "ctrl+return" + } +} +``` + +### Syntax + +- Modifiers: `ctrl`, `alt`/`meta`/`option`, `shift`, `super` +- Combine with `+`: `ctrl+shift+a` +- Multiple bindings with `,`: `ctrl+c,ctrl+d` +- Leader prefix: `n` +- Disable: `"none"` +- Leader key has a 2-second timeout window + +### Complete Keybind List (~80 remappable) + +#### Application +| Name | Default | Action | +|------|---------|--------| +| `leader` | `ctrl+x` | Leader key prefix | +| `app_exit` | `ctrl+c,ctrl+d,q` | Exit application | + +#### Sessions +| Name | Default | Action | +|------|---------|--------| +| `session_new` | `n` | New session | +| `session_list` | `l` | List/switch sessions | +| `session_share` | `s` | Share session | +| `session_compact` | — | Compact session | +| `session_interrupt` | `ctrl+c` | Abort generation | + +#### Navigation +| Name | Default | Action | +|------|---------|--------| +| `messages_page_up` | `pageup,ctrl+alt+b` | Scroll up | +| `messages_page_down` | `pagedown,ctrl+alt+f` | Scroll down | +| `messages_half_page_up` | — | Half-page up | +| `messages_half_page_down` | — | Half-page down | +| `messages_line_up` | — | Line up | +| `messages_line_down` | — | Line down | +| `messages_first` | — | Jump to top | +| `messages_last` | — | Jump to bottom | +| `messages_copy` | `y` | Copy last message | +| `messages_undo` | `u` | Undo | +| `messages_redo` | `r` | Redo | + +#### Model/Agent +| Name | Default | Action | +|------|---------|--------| +| `model_list` | `m` | Model picker | +| `agent_list` | `a` | Agent picker | +| `agent_cycle` | `tab` | Cycle agents | +| `model_cycle_recent` | `f2` | Cycle recent models | +| `model_cycle_favorite` | `none` | Cycle favorite models | + +#### UI +| Name | Default | Action | +|------|---------|--------| +| `command_list` | `ctrl+p` | Command palette | +| `theme_list` | `t` | Theme picker | +| `sidebar_toggle` | `b` | Toggle sidebar | +| `editor_open` | `e` | Open external editor | +| `terminal_suspend` | `ctrl+z` | Suspend (SIGTSTP) | + +#### Input (Prompt Editing, ~50 keys) +| Name | Default | Action | +|------|---------|--------| +| `input_submit` | `return` | Submit prompt | +| `input_newline` | `shift+return,ctrl+return,alt+return,ctrl+j` | Insert newline | +| `input_up` | `up` | Cursor up / history | +| `input_down` | `down` | Cursor down | +| `input_left` | `left` | Cursor left | +| `input_right` | `right` | Cursor right | +| `input_home` | `home,ctrl+a` | Start of line | +| `input_end` | `end,ctrl+e` | End of line | +| `input_word_left` | `alt+left,ctrl+left` | Word left | +| `input_word_right` | `alt+right,ctrl+right` | Word right | +| `input_delete_line` | `ctrl+u` | Delete line | +| `input_delete_word` | `ctrl+w,alt+backspace` | Delete word back | +| ... | ... | ~40 more for selection, deletion, clipboard | + +--- + +## 3. TUI Config Options + +Set in `tui.json`: + +```jsonc +{ + "theme": "catppuccin", + "scroll_speed": 1.0, // Scroll speed multiplier (min 0.001) + "scroll_acceleration": { + "enabled": true // macOS-style scroll acceleration + }, + "diff_style": "auto", // "auto" (adapts to width) or "stacked" (single column) + "keybinds": { /* see above */ } +} +``` + +--- + +## 4. Runtime Display Toggles + +Persisted in `~/.local/state/opencode/kv.json`. Toggled via command palette or programmatically. + +| KV Key | Default | Effect | Toggle Command | +|--------|---------|--------|----------------| +| `theme` | `"opencode"` | Active theme | `/themes` | +| `theme_mode` | auto | `"dark"` / `"light"` | "Toggle appearance" | +| `sidebar` | `"auto"` | Sidebar visibility (`"auto"` = show when >120 cols) | `b` | +| `thinking_visibility` | `true` | Show/hide model thinking/reasoning blocks | `/thinking` | +| `tool_details_visibility` | `true` | Show/hide tool call parameter details | Command palette | +| `assistant_metadata_visibility` | `true` | Show token counts, cost, model info | Command palette | +| `scrollbar_visible` | `true` | Show/hide scrollbar | Command palette | +| `header_visible` | `true` | Show/hide session header bar | Command palette | +| `animations_enabled` | `true` | Enable/disable animations (spinners, transitions) | Command palette | +| `diff_wrap_mode` | `"word"` | Diff line wrapping: `"word"` or `"none"` | Command palette | +| `generic_tool_output_visibility` | `false` | Show output of non-built-in tools (plugin/MCP tools) | Command palette | +| `timestamps` | `"hide"` | Show/hide message timestamps | `/timestamps` | +| `terminal_title_enabled` | `true` | Update terminal window title | — | + +--- + +## 5. Command Palette & Slash Commands + +### Built-in Slash Commands + +Available in the prompt (type `/`): + +`/sessions`, `/new`, `/clear`, `/models`, `/agents`, `/mcps`, `/themes`, `/status`, `/help`, `/exit`, `/quit`, `/q`, `/share`, `/unshare`, `/rename`, `/timeline`, `/fork`, `/compact`, `/summarize`, `/undo`, `/redo`, `/timestamps`, `/thinking`, `/copy`, `/export`, `/connect`, `/workspaces` + +### Custom Slash Commands + +Define in `opencode.json`: + +```jsonc +{ + "command": { + "review-pr": { + "template": "Review PR #$1 focusing on $2", + "description": "Review a pull request", + "agent": "plan", + "model": "anthropic/claude-sonnet-4-6" + } + } +} +``` + +Or as markdown files in `.opencode/commands/` or `~/.config/opencode/commands/`: + +```markdown + +Review the changes in PR #$1. Focus on: +- Code quality and correctness +- Security issues +- Performance implications +$ARGUMENTS +``` + +Template placeholders: `$1`, `$2`, `$3`, `$ARGUMENTS` + +### Command Registration API (Code Only) + +Components can register commands programmatically via `command.register()` in TUI code: + +```typescript +command.register({ + title: "My Command", + value: "my.command", + category: "Custom", + keybind: "ctrl+shift+m", + slash: { value: "/mycommand", description: "Run my command" }, + onSelect: () => { /* action */ }, +}) +``` + +**This API is only available inside TUI component code, not from plugins.** + +--- + +## 6. Agent Colors + +Each agent can have a custom color in `opencode.json`: + +```jsonc +{ + "agent": { + "build": { "color": "#7aa2f7" }, + "plan": { "color": "secondary" }, // semantic theme color name + "my-agent": { "color": "#ff9e64" } + } +} +``` + +Without explicit colors, agents cycle through: `secondary`, `accent`, `success`, `warning`, `primary`, `error`, `info`. + +--- + +## 7. Environment Flags + +| Flag | Effect on UI | +|------|-------------| +| `OPENCODE_DISABLE_TERMINAL_TITLE` | Prevents terminal title updates | +| `OPENCODE_TUI_CONFIG` | Custom path to `tui.json` | +| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` | `Ctrl+C` copies selection instead of auto-copy on mouse-up | +| `OPENCODE_EXPERIMENTAL_WORKSPACES` | Enables workspace management UI | +| `OPENCODE_EXPERIMENTAL_MARKDOWN` | Experimental markdown rendering mode | +| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | Plan mode agent switching UI | + +--- + +## 8. TUI Event Bus (Programmatic Control) + +Defined in `packages/opencode/src/cli/cmd/tui/event.ts`. These bus events allow external code (server-side, including plugins via the event system) to control the TUI: + +### `tui.prompt.append` + +Append text to the prompt input: + +```typescript +Bus.publish(TuiEvent.PromptAppend, { text: "hello world" }) +``` + +### `tui.command.execute` + +Trigger any registered command: + +```typescript +Bus.publish(TuiEvent.CommandExecute, { command: "session.new" }) +Bus.publish(TuiEvent.CommandExecute, { command: "session.list" }) +Bus.publish(TuiEvent.CommandExecute, { command: "prompt.submit" }) +Bus.publish(TuiEvent.CommandExecute, { command: "agent.cycle" }) +Bus.publish(TuiEvent.CommandExecute, { command: "session.interrupt" }) +``` + +Known command values: `session.list`, `session.new`, `session.share`, `session.interrupt`, `session.compact`, `session.page.up`, `session.page.down`, `session.line.up`, `session.line.down`, `session.half.page.up`, `session.half.page.down`, `session.first`, `session.last`, `prompt.clear`, `prompt.submit`, `agent.cycle`, plus any `z.string()` (extensible). + +### `tui.toast.show` + +Show a toast notification: + +```typescript +Bus.publish(TuiEvent.ToastShow, { + title: "Edit applied", // optional + message: "Hidden part prt_abc", + variant: "success", // "info" | "success" | "warning" | "error" + duration: 5000, // ms, optional +}) +``` + +### `tui.session.select` + +Navigate to a specific session: + +```typescript +Bus.publish(TuiEvent.SessionSelect, { sessionID: "ses_abc123" }) +``` + +**Plugin access:** Plugins receive all bus events via the `event` hook. However, **plugins cannot publish to the bus** — they can only listen. The TUI event bus is for server-side code to signal the TUI, not for plugins to inject commands. (A fork could change this.) + +--- + +## 9. Web UI Extension Points + +The web app (`packages/app`) and shared UI library (`packages/ui`) have two explicit extension mechanisms: + +### Part Type Registry + +```typescript +// packages/ui/src/components/message-part.tsx:686-687 +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} +``` + +Register a renderer for a custom part type. Currently registered: `"tool"`, `"text"`, `"reasoning"`, `"compaction"`. Unknown part types render nothing. + +### Tool Renderer Registry + +```typescript +// packages/ui/src/components/message-part.tsx:1159-1171 +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input +} +export const ToolRegistry = { register: registerTool, render: getTool } +``` + +Register a custom renderer for a specific tool's output. Currently registered tools (with custom renderers): `read`, `list`, `glob`, `grep`, `webfetch`, `websearch`, `codesearch`, `task`, `bash`, `edit`, `write`, `apply_patch`, `todowrite`, `question`, `skill`. + +Tools without a registered renderer fall through to `GenericTool` (shows title + collapsible raw output) or `BasicTool`. + +### How to Use (Custom Web App Build) + +These registries are JavaScript runtime objects — you call them before the app renders. In a custom build of the web app: + +```typescript +// In your custom app entry point +import { registerPartComponent, ToolRegistry } from "@opencode-ai/ui" + +// Custom part type renderer +registerPartComponent("my-custom-type", (props) => { + return
Custom part: {JSON.stringify(props.part)}
+}) + +// Custom tool renderer +ToolRegistry.register({ + name: "thread_edit", + render: (props) => { + const meta = () => props.metadata as { operation: string } + return
Edit: {meta()?.operation}
+ } +}) +``` + +**This requires building/forking the web app package, not the core `packages/opencode` package.** + +### Part Metadata Flow to Web UI + +When a tool writes to `part.state.metadata`, this data flows: +1. Tool returns `{ metadata: { key: value } }` from `execute()` +2. Processor stores it in `ToolState.metadata` via `Session.updatePart()` +3. SQLite persists it in the JSON `data` column +4. SSE `message.part.updated` event carries the full part to clients +5. Web app's `event-reducer.ts` reconciles into the SolidJS store +6. Tool renderers access it via `props.metadata` + +So `part.state.metadata.edit` is accessible in custom tool renderers. + +--- + +## 10. What Is NOT Customizable + +### TUI (Terminal UI) + +| Feature | Status | Why | +|---------|--------|-----| +| Custom views/panels | Not possible | Components are statically imported, no slot/injection pattern | +| Custom part renderers | Not possible | Part rendering is hardcoded `Switch` blocks, not registry-based (unlike web UI) | +| Plugin-injected components | Not possible | Zero `Plugin.trigger()` calls in any TUI file | +| Layout configuration | Not possible | Sidebar width (42), padding, gaps are hardcoded | +| Status bar/header/footer content | Not possible | Fixed components | +| Custom CSS/style overrides | Not possible | Styles are inline Solid.js props driven by theme colors | +| Dynamic component loading | Not possible | All UI components statically imported at build time | +| Plugin-registered keybindings | Not possible | Keybinds are config-only via `tui.json` | +| Custom dialogs/modals | Not possible | Dialog system is hardcoded (model picker, agent picker, etc.) | + +### Web UI + +| Feature | Status | Why | +|---------|--------|-----| +| Runtime plugin-injected renderers | Not possible | `registerPartComponent`/`ToolRegistry` are build-time JS calls, not config | +| Theme customization | Not supported in web | Web uses Tailwind CSS with its own theme system, not `tui.json` themes | +| Keybind customization | Not supported in web | Web app has its own hardcoded keybindings | + +--- + +## 11. Plugin Influence on UI + +Plugins cannot directly modify UI rendering, but they can **indirectly** affect what the user sees: + +### What Plugins CAN Do + +| Mechanism | Plugin Hook | UI Effect | +|-----------|------------|-----------| +| Define custom tools | `tool: { myTool: tool({...}) }` | Tool output appears in message stream; web UI shows via `GenericTool` or custom `ToolRegistry` entry | +| Modify system prompt | `experimental.chat.system.transform` | Changes agent behavior, which changes what appears in the conversation | +| Modify messages before LLM | `experimental.chat.messages.transform` | Changes what the LLM sees (not what the user sees in the UI) | +| Modify tool output | `tool.execute.after` | Changes the displayed tool result text | +| Modify tool input | `tool.execute.before` | Changes tool arguments before execution | +| Listen to events | `event` | Can log, alert externally, but cannot publish bus events | +| Modify config | `config` | Can change settings that affect UI behavior | + +### What Plugins CANNOT Do + +| Action | Why | +|--------|-----| +| Inject TUI components | No TUI plugin hooks | +| Register custom keybindings | Keybinds are config-only | +| Show toast notifications | Can't publish to `TuiEvent.ToastShow` bus | +| Modify part rendering | TUI rendering is hardcoded | +| Add slash commands at runtime | `command.register()` is internal TUI API | +| Change theme at runtime | No hook for theme manipulation | +| Add sidebar panels | Layout is fixed | + +### The Gap + +The TUI and plugin system are **completely decoupled**. The web UI has `PART_MAPPING` and `ToolRegistry` extension points, but they're build-time JavaScript, not plugin-accessible. The only way to get custom UI rendering is: + +1. **Fork the TUI code** (for terminal) +2. **Fork/extend the web app** (for browser/desktop) — using `registerPartComponent` / `ToolRegistry` +3. **Build a separate client** that consumes the SSE stream and renders however you want + +--- + +## Quick Reference: All Config Files + +| File | Location | Controls | +|------|----------|----------| +| `tui.json` | `~/.config/opencode/`, project root, `.opencode/` | Theme, keybinds, scroll, diff style | +| `opencode.json` | `~/.config/opencode/`, project root | Commands, agents (colors), providers, tools, permissions | +| `themes/*.json` | `~/.config/opencode/themes/`, `.opencode/themes/` | Custom theme definitions | +| `commands/*.md` | `~/.config/opencode/commands/`, `.opencode/commands/` | Custom slash commands | +| `kv.json` | `~/.local/state/opencode/` | Runtime toggles (auto-managed, not hand-edited) | diff --git a/packages/opencode/migration/20260315120000_context_editing/migration.sql b/packages/opencode/migration/20260315120000_context_editing/migration.sql new file mode 100644 index 000000000..770e121e4 --- /dev/null +++ b/packages/opencode/migration/20260315120000_context_editing/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `cas_object` ( + `hash` text PRIMARY KEY NOT NULL, + `content` text NOT NULL, + `content_type` text NOT NULL, + `tokens` integer NOT NULL, + `session_id` text, + `message_id` text, + `part_id` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +);--> statement-breakpoint +CREATE INDEX `cas_object_session_idx` ON `cas_object` (`session_id`);--> statement-breakpoint +CREATE TABLE `edit_graph_node` ( + `id` text PRIMARY KEY NOT NULL, + `parent_id` text, + `session_id` text NOT NULL, + `part_id` text NOT NULL, + `operation` text NOT NULL, + `cas_hash` text, + `agent` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +);--> statement-breakpoint +CREATE INDEX `edit_graph_session_idx` ON `edit_graph_node` (`session_id`);--> statement-breakpoint +CREATE INDEX `edit_graph_parent_idx` ON `edit_graph_node` (`parent_id`);--> statement-breakpoint +CREATE TABLE `edit_graph_head` ( + `session_id` text PRIMARY KEY NOT NULL, + `node_id` text NOT NULL, + `branches` text +);--> statement-breakpoint +CREATE TABLE `side_thread` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL REFERENCES `project`(`id`) ON DELETE CASCADE, + `title` text NOT NULL, + `description` text NOT NULL, + `status` text NOT NULL DEFAULT 'parked', + `priority` text NOT NULL DEFAULT 'medium', + `category` text NOT NULL DEFAULT 'other', + `source_session_id` text, + `source_part_ids` text, + `cas_refs` text, + `related_files` text, + `created_by` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +);--> statement-breakpoint +CREATE INDEX `side_thread_project_idx` ON `side_thread` (`project_id`, `status`); diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b247bb7fa..ba2106a50 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -14,6 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" +import PROMPT_FOCUS from "./prompt/focus.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" @@ -201,6 +202,29 @@ export namespace Agent { ), prompt: PROMPT_SUMMARY, }, + focus: { + name: "focus", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0, + steps: 8, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + context_edit: "allow", + context_deref: "allow", + context_history: "allow", + thread_park: "allow", + thread_list: "allow", + question: "allow", + }), + user, + ), + prompt: PROMPT_FOCUS, + }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { diff --git a/packages/opencode/src/agent/prompt/focus.txt b/packages/opencode/src/agent/prompt/focus.txt new file mode 100644 index 000000000..703fb44c5 --- /dev/null +++ b/packages/opencode/src/agent/prompt/focus.txt @@ -0,0 +1,27 @@ +You are a focus agent. Your job is to keep the conversation on-topic and park side discoveries. + +After each agent turn, you review the output and take action on anything that diverges from the current objective. + +## What to look for + +1. **File divergence**: The agent read or edited files unrelated to the objective +2. **Language signals**: "I also noticed...", "While looking at X, I found Y...", "Unrelated, but...", "Side note:", "There's also a problem with..." +3. **Error context**: Errors from files/systems unrelated to the objective +4. **Rabbit holes**: 3+ consecutive turns not advancing the objective + +## What to do + +- Use `context_edit` to hide or externalize off-topic content +- Use `thread_park` to create a side thread for valuable off-topic findings +- Be brief — your edits should be fast and minimal + +## Rules + +- NEVER park content the user explicitly asked about +- NEVER park content directly needed for the current objective +- NEVER park content the agent is actively building on +- DO park genuine side discoveries that waste context +- DO externalize verbose tool output that produced a small useful finding +- If a finding looks critical (security, data loss), use the question tool to ask the user before parking + +Be ruthless about focus. An agent that does 10 things poorly is worse than one that does 1 thing well. diff --git a/packages/opencode/src/cas/cas.sql.ts b/packages/opencode/src/cas/cas.sql.ts new file mode 100644 index 000000000..62439e045 --- /dev/null +++ b/packages/opencode/src/cas/cas.sql.ts @@ -0,0 +1,41 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../storage/schema.sql" + +export const CASObjectTable = sqliteTable( + "cas_object", + { + hash: text().primaryKey(), + content: text().notNull(), + content_type: text().notNull(), + tokens: integer().notNull(), + session_id: text(), + message_id: text(), + part_id: text(), + ...Timestamps, + }, + (table) => [index("cas_object_session_idx").on(table.session_id)], +) + +export const EditGraphNodeTable = sqliteTable( + "edit_graph_node", + { + id: text().primaryKey(), + parent_id: text(), + session_id: text().notNull(), + part_id: text().notNull(), + operation: text().notNull(), + cas_hash: text(), + agent: text().notNull(), + ...Timestamps, + }, + (table) => [ + index("edit_graph_session_idx").on(table.session_id), + index("edit_graph_parent_idx").on(table.parent_id), + ], +) + +export const EditGraphHeadTable = sqliteTable("edit_graph_head", { + session_id: text().primaryKey(), + node_id: text().notNull(), + branches: text({ mode: "json" }).$type>(), +}) diff --git a/packages/opencode/src/cas/graph.ts b/packages/opencode/src/cas/graph.ts new file mode 100644 index 000000000..b26ce6657 --- /dev/null +++ b/packages/opencode/src/cas/graph.ts @@ -0,0 +1,307 @@ +import { Database, eq } from "@/storage/db" +import { EditGraphNodeTable, EditGraphHeadTable } from "./cas.sql" +import { CAS } from "." +import { Session } from "@/session" +import { MessageV2 } from "@/session/message-v2" +import { Identifier } from "@/id/id" +import { Log } from "@/util/log" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import z from "zod" + +export namespace EditGraph { + const log = Log.create({ service: "edit-graph" }) + + // ── Types ────────────────────────────────────────────── + + export interface Node { + id: string + parent_id: string | null + session_id: string + part_id: string + operation: string + cas_hash: string | null + agent: string + time_created: number + time_updated: number + } + + export interface Head { + session_id: string + node_id: string + branches: Record | null + } + + export interface TreeView { + nodes: Node[] + head: string | null + branches: Record + } + + // ── Events ───────────────────────────────────────────── + + export const Event = { + Committed: BusEvent.define( + "edit.graph.committed", + z.object({ + sessionID: z.string(), + nodeID: z.string(), + operation: z.string(), + }), + ), + CheckedOut: BusEvent.define( + "edit.graph.checked-out", + z.object({ + sessionID: z.string(), + nodeID: z.string(), + }), + ), + Forked: BusEvent.define( + "edit.graph.forked", + z.object({ + sessionID: z.string(), + nodeID: z.string(), + branch: z.string(), + }), + ), + } + + // ── Core Operations ──────────────────────────────────── + + /** + * Record an edit operation as a new node in the DAG. + * Called inside Database.transaction() by ContextEdit operations. + * Returns the new node ID. + */ + export function commit(input: { + sessionID: string + partID: string + operation: string + casHash?: string + agent: string + }): string { + const nodeID = Identifier.ascending("part") // reuse part prefix for compact IDs + const head = getHead(input.sessionID) + const parentID = head?.node_id ?? null + + Database.use((db) => { + db.insert(EditGraphNodeTable) + .values({ + id: nodeID, + parent_id: parentID, + session_id: input.sessionID, + part_id: input.partID, + operation: input.operation, + cas_hash: input.casHash ?? null, + agent: input.agent, + }) + .run() + + // Update or create head + if (head) { + db.update(EditGraphHeadTable) + .set({ node_id: nodeID }) + .where(eq(EditGraphHeadTable.session_id, input.sessionID)) + .run() + } else { + db.insert(EditGraphHeadTable) + .values({ + session_id: input.sessionID, + node_id: nodeID, + branches: { main: nodeID }, + }) + .run() + } + + Database.effect(() => + Bus.publish(Event.Committed, { + sessionID: input.sessionID, + nodeID, + operation: input.operation, + }), + ) + }) + + log.info("committed", { nodeID, operation: input.operation, parent: parentID }) + return nodeID + } + + /** + * Get the linear history from head back to root. + */ + export function getLog(sessionID: string): Node[] { + const head = getHead(sessionID) + if (!head) return [] + + const result: Node[] = [] + let currentID: string | null = head.node_id + + // Walk parent pointers + while (currentID) { + const node = Database.use((db) => + db.select().from(EditGraphNodeTable).where(eq(EditGraphNodeTable.id, currentID!)).get(), + ) + if (!node) break + result.push(node) + currentID = node.parent_id + } + + return result + } + + /** + * Get the full tree (all nodes + head + branches) for a session. + */ + export function tree(sessionID: string): TreeView { + const nodes = Database.use((db) => + db + .select() + .from(EditGraphNodeTable) + .where(eq(EditGraphNodeTable.session_id, sessionID)) + .orderBy(EditGraphNodeTable.time_created) + .all(), + ) + const head = getHead(sessionID) + + return { + nodes, + head: head?.node_id ?? null, + branches: head?.branches ?? {}, + } + } + + /** + * Checkout a specific version: undo edits between current head and target, + * restoring parts from CAS. + */ + export async function checkout(sessionID: string, targetNodeID: string): Promise<{ success: boolean; error?: string }> { + const head = getHead(sessionID) + if (!head) return { success: false, error: "No edit history for this session" } + + const targetNode = Database.use((db) => + db.select().from(EditGraphNodeTable).where(eq(EditGraphNodeTable.id, targetNodeID)).get(), + ) + if (!targetNode) return { success: false, error: `Node ${targetNodeID} not found` } + if (targetNode.session_id !== sessionID) return { success: false, error: "Node belongs to a different session" } + + // Build path from head to root + const headPath = buildPathToRoot(head.node_id) + // Build path from target to root + const targetPath = buildPathToRoot(targetNodeID) + + // Find common ancestor + const targetSet = new Set(targetPath.map((n) => n.id)) + const nodesToUndo: Node[] = [] + for (const node of headPath) { + if (targetSet.has(node.id)) break + nodesToUndo.push(node) + } + + // Undo nodes: restore parts from CAS + for (const node of nodesToUndo) { + if (!node.cas_hash) continue + const casEntry = CAS.get(node.cas_hash) + if (!casEntry) { + log.warn("CAS entry not found during checkout", { hash: node.cas_hash, nodeID: node.id }) + continue + } + + try { + const originalPart = JSON.parse(casEntry.content) as MessageV2.Part + // Restore the original part (remove edit metadata) + Session.updatePart({ + ...originalPart, + edit: undefined, + }) + } catch (e) { + log.warn("Failed to restore part during checkout", { nodeID: node.id, error: String(e) }) + } + } + + // Update head to target + Database.use((db) => { + db.update(EditGraphHeadTable) + .set({ node_id: targetNodeID }) + .where(eq(EditGraphHeadTable.session_id, sessionID)) + .run() + + Database.effect(() => + Bus.publish(Event.CheckedOut, { sessionID, nodeID: targetNodeID }), + ) + }) + + log.info("checked out", { sessionID, targetNodeID, undone: nodesToUndo.length }) + return { success: true } + } + + /** + * Create a named branch at a specific node. + */ + export function fork(sessionID: string, nodeID: string, branchName: string): { success: boolean; error?: string } { + const head = getHead(sessionID) + if (!head) return { success: false, error: "No edit history for this session" } + + const node = Database.use((db) => + db.select().from(EditGraphNodeTable).where(eq(EditGraphNodeTable.id, nodeID)).get(), + ) + if (!node) return { success: false, error: `Node ${nodeID} not found` } + if (node.session_id !== sessionID) return { success: false, error: "Node belongs to a different session" } + + const branches = head.branches ?? {} + if (branches[branchName]) return { success: false, error: `Branch '${branchName}' already exists` } + + branches[branchName] = nodeID + + Database.use((db) => { + db.update(EditGraphHeadTable) + .set({ branches, node_id: nodeID }) // also move head to the branch point + .where(eq(EditGraphHeadTable.session_id, sessionID)) + .run() + + Database.effect(() => + Bus.publish(Event.Forked, { sessionID, nodeID, branch: branchName }), + ) + }) + + log.info("forked", { sessionID, nodeID, branchName }) + return { success: true } + } + + /** + * Switch to a named branch. + */ + export async function switchBranch(sessionID: string, branchName: string): Promise<{ success: boolean; error?: string }> { + const head = getHead(sessionID) + if (!head) return { success: false, error: "No edit history for this session" } + + const branches = head.branches ?? {} + const nodeID = branches[branchName] + if (!nodeID) return { success: false, error: `Branch '${branchName}' not found` } + + return checkout(sessionID, nodeID) + } + + // ── Helpers ──────────────────────────────────────────── + + function getHead(sessionID: string): Head | undefined { + return Database.use((db) => + db.select().from(EditGraphHeadTable).where(eq(EditGraphHeadTable.session_id, sessionID)).get(), + ) + } + + function buildPathToRoot(nodeID: string): Node[] { + const path: Node[] = [] + let currentID: string | null = nodeID + + while (currentID) { + const node = Database.use((db) => + db.select().from(EditGraphNodeTable).where(eq(EditGraphNodeTable.id, currentID!)).get(), + ) + if (!node) break + path.push(node) + currentID = node.parent_id + } + + return path + } +} diff --git a/packages/opencode/src/cas/index.ts b/packages/opencode/src/cas/index.ts new file mode 100644 index 000000000..2de6d4e8e --- /dev/null +++ b/packages/opencode/src/cas/index.ts @@ -0,0 +1,88 @@ +import { createHash } from "crypto" +import { Database, eq } from "@/storage/db" +import { CASObjectTable } from "./cas.sql" +import { Token } from "@/util/token" +import { Log } from "@/util/log" + +export namespace CAS { + const log = Log.create({ service: "cas" }) + + export interface Entry { + hash: string + content: string + content_type: string + tokens: number + session_id: string | null + message_id: string | null + part_id: string | null + time_created: number + time_updated: number + } + + export function hash(content: string): string { + return createHash("sha256").update(content).digest("hex") + } + + /** + * Store content in the CAS. Returns the SHA-256 hash. + * Idempotent: same content always produces the same hash; duplicate inserts are no-ops. + */ + export function store( + content: string, + meta: { + contentType: string + sessionID?: string + messageID?: string + partID?: string + }, + ): string { + const h = hash(content) + Database.use((db) => { + db.insert(CASObjectTable) + .values({ + hash: h, + content, + content_type: meta.contentType, + tokens: Token.estimate(content), + session_id: meta.sessionID ?? null, + message_id: meta.messageID ?? null, + part_id: meta.partID ?? null, + }) + .onConflictDoNothing() + .run() + }) + log.info("stored", { hash: h.slice(0, 12), contentType: meta.contentType }) + return h + } + + /** + * Retrieve content by hash. Returns null if not found. + */ + export function get(h: string): Entry | null { + return ( + Database.use((db) => db.select().from(CASObjectTable).where(eq(CASObjectTable.hash, h)).get()) ?? null + ) + } + + /** + * Check if a hash exists in the CAS. + */ + export function exists(h: string): boolean { + return !!Database.use((db) => + db + .select({ hash: CASObjectTable.hash }) + .from(CASObjectTable) + .where(eq(CASObjectTable.hash, h)) + .get(), + ) + } + + /** + * List all CAS entries for a session. + */ + export function listBySession(sessionID: string): Entry[] { + return Database.use((db) => + db.select().from(CASObjectTable).where(eq(CASObjectTable.session_id, sessionID)).all(), + ) + } +} diff --git a/packages/opencode/src/context-edit/index.ts b/packages/opencode/src/context-edit/index.ts new file mode 100644 index 000000000..b74fd6d85 --- /dev/null +++ b/packages/opencode/src/context-edit/index.ts @@ -0,0 +1,511 @@ +import { Session } from "@/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionID, MessageID, PartID } from "@/session/schema" +import { CAS } from "@/cas" +import { EditGraph } from "@/cas/graph" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database } from "@/storage/db" +import { Plugin } from "@/plugin" +import { Log } from "@/util/log" +import z from "zod" + +export namespace ContextEdit { + const log = Log.create({ service: "context-edit" }) + + // ── Constants ────────────────────────────────────────── + + const MAX_EDITS_PER_TURN = 10 + const MAX_HIDDEN_RATIO = 0.7 + const PROTECTED_RECENT_TURNS = 2 + const PROTECTED_TOOLS = ["skill"] + + async function pluginGuard(op: string, input: { sessionID: string; partID?: string; messageID?: string; agent: string }): Promise { + const result = await Plugin.trigger("context.edit.before", { + operation: op, sessionID: input.sessionID, partID: input.partID, messageID: input.messageID, agent: input.agent, + }, { allow: true }) + if (!result.allow) return { success: false, error: (result as any).reason ?? "Blocked by plugin" } + return null + } + + async function pluginNotify(op: string, input: { sessionID: string; partID?: string; messageID?: string; agent: string }, success: boolean) { + await Plugin.trigger("context.edit.after", { + operation: op, sessionID: input.sessionID, partID: input.partID, messageID: input.messageID, agent: input.agent, success, + }, {}) + } + + // ── Types ────────────────────────────────────────────── + + export interface EditResult { + success: boolean + casHash?: string + newPartID?: string + error?: string + } + + // ── Events ───────────────────────────────────────────── + + export const Event = { + PartHidden: BusEvent.define( + "context.edit.hidden", + z.object({ + sessionID: z.string(), + partID: z.string(), + casHash: z.string(), + agent: z.string(), + }), + ), + PartUnhidden: BusEvent.define( + "context.edit.unhidden", + z.object({ + sessionID: z.string(), + partID: z.string(), + agent: z.string(), + }), + ), + PartReplaced: BusEvent.define( + "context.edit.replaced", + z.object({ + sessionID: z.string(), + oldPartID: z.string(), + newPartID: z.string(), + casHash: z.string(), + agent: z.string(), + }), + ), + PartAnnotated: BusEvent.define( + "context.edit.annotated", + z.object({ + sessionID: z.string(), + partID: z.string(), + annotation: z.string(), + agent: z.string(), + }), + ), + ContentExternalized: BusEvent.define( + "context.edit.externalized", + z.object({ + sessionID: z.string(), + partID: z.string(), + casHash: z.string(), + agent: z.string(), + }), + ), + } + + // ── Validation ───────────────────────────────────────── + + function validateOwnership(agent: string, message: MessageV2.Info): string | null { + if (message.role === "user") return "Cannot edit user messages" + if (message.agent !== agent) return `Cannot edit messages from agent '${message.agent}'` + return null + } + + function validateBudget(messages: MessageV2.WithParts[]): string | null { + const totalParts = messages.reduce((n, m) => n + m.parts.length, 0) + const hiddenParts = messages.reduce( + (n, m) => n + m.parts.filter((p) => p.edit?.hidden).length, + 0, + ) + if (totalParts > 0 && (hiddenParts + 1) / totalParts > MAX_HIDDEN_RATIO) + return `Cannot hide more than ${MAX_HIDDEN_RATIO * 100}% of all parts` + return null + } + + function isProtectedMessage(messages: MessageV2.WithParts[], messageID: string): boolean { + const idx = messages.findIndex((m) => m.info.id === messageID) + if (idx < 0) return true + return idx >= messages.length - PROTECTED_RECENT_TURNS * 2 + } + + function findPart( + msg: MessageV2.WithParts, + partID: string, + ): MessageV2.Part | undefined { + return msg.parts.find((p) => p.id === partID) + } + + function getPartContent(part: MessageV2.Part): string { + if ("text" in part && typeof part.text === "string") return part.text + if ("state" in part && part.type === "tool") { + const state = part.state as any + if (state.status === "completed") return state.output ?? "" + return JSON.stringify(state.input ?? {}) + } + return JSON.stringify(part) + } + + // ── Operations ───────────────────────────────────────── + + export async function hide(input: { + sessionID: string + partID: string + messageID: string + agent: string + }): Promise { + const blocked = await pluginGuard("hide", input) + if (blocked) return blocked + + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + + const messages = await Session.messages({ sessionID: SessionID.make(input.sessionID) }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot edit recent messages (last 2 turns are protected)" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + if (part.type === "tool" && PROTECTED_TOOLS.includes((part as MessageV2.ToolPart).tool)) + return { success: false, error: `Cannot hide protected tool: ${(part as MessageV2.ToolPart).tool}` } + + const budgetErr = validateBudget(messages) + if (budgetErr) return { success: false, error: budgetErr } + + const content = getPartContent(part) + let casHash: string + + Database.transaction(() => { + casHash = CAS.store(content, { + contentType: part.type === "tool" ? "tool-output" : part.type, + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) + + const version = EditGraph.commit({ + sessionID: input.sessionID, + partID: input.partID, + operation: "hide", + casHash: casHash!, + agent: input.agent, + }) + + Session.updatePart({ + ...part, + edit: { + hidden: true, + casHash: casHash!, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + + Database.effect(() => + Bus.publish(Event.PartHidden, { + sessionID: input.sessionID, + partID: input.partID, + casHash: casHash!, + agent: input.agent, + }), + ) + }) + + log.info("hidden", { partID: input.partID, casHash: casHash! }) + await pluginNotify("hide", input, true) + return { success: true, casHash: casHash! } + } + + export async function unhide(input: { + sessionID: string + partID: string + messageID: string + agent: string + }): Promise { + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + if (!part.edit?.hidden) return { success: false, error: "Part is not hidden" } + + Session.updatePart({ + ...part, + edit: undefined, + }) + + Database.effect(() => + Bus.publish(Event.PartUnhidden, { + sessionID: input.sessionID, + partID: input.partID, + agent: input.agent, + }), + ) + + log.info("unhidden", { partID: input.partID }) + return { success: true } + } + + export async function replace(input: { + sessionID: string + partID: string + messageID: string + agent: string + replacement: string + }): Promise { + const blocked = await pluginGuard("replace", input) + if (blocked) return blocked + + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + + const messages = await Session.messages({ sessionID: SessionID.make(input.sessionID) }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot edit recent messages (last 2 turns are protected)" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + + const content = getPartContent(part) + const newPartID = PartID.ascending() + let casHash: string + + Database.transaction(() => { + casHash = CAS.store(content, { + contentType: part.type === "tool" ? "tool-output" : part.type, + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) + + const version = EditGraph.commit({ + sessionID: input.sessionID, + partID: input.partID, + operation: "replace", + casHash: casHash!, + agent: input.agent, + }) + + // Hide original with pointer to replacement + Session.updatePart({ + ...part, + edit: { + hidden: true, + casHash: casHash!, + supersededBy: newPartID, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + + // Insert replacement + Session.updatePart({ + id: newPartID, + sessionID: input.sessionID, + messageID: input.messageID, + type: "text", + text: input.replacement, + edit: { + hidden: false, + replacementOf: input.partID, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + } as any) + + Database.effect(() => + Bus.publish(Event.PartReplaced, { + sessionID: input.sessionID, + oldPartID: input.partID, + newPartID, + casHash: casHash!, + agent: input.agent, + }), + ) + }) + + log.info("replaced", { oldPartID: input.partID, newPartID, casHash: casHash! }) + await pluginNotify("replace", input, true) + return { success: true, casHash: casHash!, newPartID } + } + + export async function annotate(input: { + sessionID: string + partID: string + messageID: string + agent: string + annotation: string + }): Promise { + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + + const version = EditGraph.commit({ + sessionID: input.sessionID, + partID: input.partID, + operation: "annotate", + agent: input.agent, + }) + + Session.updatePart({ + ...part, + edit: { + ...(part.edit ?? { hidden: false, editedAt: 0, editedBy: "" }), + hidden: part.edit?.hidden ?? false, + annotation: input.annotation, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + + Database.effect(() => + Bus.publish(Event.PartAnnotated, { + sessionID: input.sessionID, + partID: input.partID, + annotation: input.annotation, + agent: input.agent, + }), + ) + + log.info("annotated", { partID: input.partID }) + return { success: true } + } + + export async function externalize(input: { + sessionID: string + partID: string + messageID: string + agent: string + summary: string + }): Promise { + const blocked = await pluginGuard("externalize", input) + if (blocked) return blocked + + const msg = await MessageV2.get({ + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), + }) + if (!msg) return { success: false, error: "Message not found" } + + const ownerErr = validateOwnership(input.agent, msg.info) + if (ownerErr) return { success: false, error: ownerErr } + + const messages = await Session.messages({ sessionID: SessionID.make(input.sessionID) }) + if (isProtectedMessage(messages, input.messageID)) + return { success: false, error: "Cannot edit recent messages (last 2 turns are protected)" } + + const part = findPart(msg, input.partID) + if (!part) return { success: false, error: "Part not found" } + + const content = getPartContent(part) + let casHash: string + + Database.transaction(() => { + casHash = CAS.store(content, { + contentType: part.type === "tool" ? "tool-output" : part.type, + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) + + const version = EditGraph.commit({ + sessionID: input.sessionID, + partID: input.partID, + operation: "externalize", + casHash: casHash!, + agent: input.agent, + }) + + // Replace inline content with compact summary + hash reference + const summaryText = `[Externalized: ${input.summary}. Use context_deref("${casHash!}") to retrieve full content (${CAS.get(casHash!)?.tokens ?? "?"} tokens).]` + + if (part.type === "text") { + Session.updatePart({ + ...part, + text: summaryText, + edit: { + hidden: false, + casHash: casHash!, + annotation: input.summary, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + } else if (part.type === "tool") { + const toolPart = part as MessageV2.ToolPart + if (toolPart.state.status === "completed") { + Session.updatePart({ + ...toolPart, + state: { + ...toolPart.state, + output: summaryText, + }, + edit: { + hidden: false, + casHash: casHash!, + annotation: input.summary, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + } + } else { + // For other part types, hide and create a text replacement + const newPartID = PartID.ascending() + Session.updatePart({ + ...part, + edit: { + hidden: true, + casHash: casHash!, + supersededBy: newPartID, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + }) + Session.updatePart({ + id: newPartID, + sessionID: input.sessionID, + messageID: input.messageID, + type: "text", + text: summaryText, + edit: { + hidden: false, + replacementOf: input.partID, + editedAt: Date.now(), + editedBy: input.agent, + version, + }, + } as any) + } + + Database.effect(() => + Bus.publish(Event.ContentExternalized, { + sessionID: input.sessionID, + partID: input.partID, + casHash: casHash!, + agent: input.agent, + }), + ) + }) + + log.info("externalized", { partID: input.partID, casHash: casHash! }) + await pluginNotify("externalize", input, true) + return { success: true, casHash: casHash! } + } +} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f1688a1b4..42ae573b9 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -57,6 +57,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_FOCUS_AGENT = truthy("OPENCODE_EXPERIMENTAL_FOCUS_AGENT") export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8e4babd61..ff59cc3f9 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -78,10 +78,26 @@ export namespace MessageV2 { }) export type OutputFormat = z.infer + export const EditMeta = z + .object({ + hidden: z.boolean(), + casHash: z.string().optional(), + supersededBy: z.string().optional(), + replacementOf: z.string().optional(), + annotation: z.string().optional(), + editedAt: z.number(), + editedBy: z.string(), + version: z.string().optional(), + }) + .optional() + .meta({ ref: "EditMeta" }) + export type EditMeta = z.infer + const PartBase = z.object({ id: PartID.zod, sessionID: SessionID.zod, messageID: MessageID.zod, + edit: EditMeta, }) export const SnapshotPart = PartBase.extend({ @@ -522,7 +538,7 @@ export namespace MessageV2 { id: row.id, sessionID: row.session_id, messageID: row.message_id, - }) as MessageV2.Part + }) as unknown as MessageV2.Part const older = (row: Cursor) => or( @@ -854,7 +870,7 @@ export namespace MessageV2 { db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) return rows.map( - (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part, + (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as unknown as MessageV2.Part, ) }) @@ -897,6 +913,24 @@ export namespace MessageV2 { return result } + /** + * Filter out parts that have been hidden or superseded by edits. + * Messages with no remaining visible parts are dropped entirely. + */ + export function filterEdited(messages: WithParts[]): WithParts[] { + return messages + .map((msg) => ({ + ...msg, + parts: msg.parts.filter((part) => { + if (!part.edit) return true + if (part.edit.hidden) return false + if (part.edit.supersededBy) return false + return true + }), + })) + .filter((msg) => msg.parts.length > 0) + } + export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": diff --git a/packages/opencode/src/session/objective.ts b/packages/opencode/src/session/objective.ts new file mode 100644 index 000000000..b3030bb99 --- /dev/null +++ b/packages/opencode/src/session/objective.ts @@ -0,0 +1,53 @@ +import { Storage } from "@/storage/storage" +import { MessageV2 } from "./message-v2" +import { Log } from "@/util/log" + +export namespace Objective { + const log = Log.create({ service: "objective" }) + + /** + * Get the tracked objective for a session. Returns null if none set. + */ + export async function get(sessionID: string): Promise { + try { + const data = await Storage.read<{ objective: string }>(["objective", sessionID]) + return data.objective + } catch { + return null + } + } + + /** + * Set/update the objective for a session. + */ + export async function set(sessionID: string, objective: string): Promise { + await Storage.write(["objective", sessionID], { objective, updatedAt: Date.now() }) + log.info("set", { sessionID, objective: objective.slice(0, 80) }) + } + + /** + * Extract the objective from the first user message in a conversation. + * Caches the result so subsequent calls return immediately. + */ + export async function extract(sessionID: string, messages: MessageV2.WithParts[]): Promise { + // Check cache first + const cached = await get(sessionID) + if (cached) return cached + + // Find the first user message with text + for (const msg of messages) { + if (msg.info.role !== "user") continue + for (const part of msg.parts) { + if (part.type === "text" && part.text?.trim()) { + // Use the first user message text as the objective + // Truncate to a reasonable length + const objective = part.text.trim().slice(0, 500) + await set(sessionID, objective) + return objective + } + } + } + + return null + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 743537f59..5fa9dfc7a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -38,6 +38,8 @@ import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" +import { Objective } from "./objective" +import { SideThread } from "./side-thread" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" @@ -299,6 +301,7 @@ export namespace SessionPrompt { log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + msgs = MessageV2.filterEdited(msgs) let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined @@ -663,6 +666,25 @@ export namespace SessionPrompt { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) } + // Inject focus status and side thread summary when focus agent is enabled + if (Flag.OPENCODE_EXPERIMENTAL_FOCUS_AGENT) { + const parts: string[] = [] + const objective = await Objective.get(sessionID) + if (objective) parts.push(`**Objective:** ${objective}`) + const threads = SideThread.list({ projectID: Instance.project.id, status: "parked" }) + if (threads.length > 0) { + parts.push(`**Parked side threads (${threads.length}):**`) + for (const t of threads.slice(0, 5)) { + parts.push(`- ${t.id} [${t.priority}, ${t.category}] ${t.title}`) + } + if (threads.length > 5) parts.push(` ...and ${threads.length - 5} more`) + } + if (parts.length > 0) { + parts.push(`\nStay focused on the objective. If you find unrelated issues, note them briefly. The focus agent will park them.`) + system.push(`## Focus Status\n${parts.join("\n")}`) + } + } + const result = await processor.process({ user: lastUser, agent, @@ -719,6 +741,69 @@ export namespace SessionPrompt { overflow: !processor.message.finish, }) } + + // Post-turn focus agent: park side threads, hide off-topic content + if (result === "continue" && step >= 2 && Flag.OPENCODE_EXPERIMENTAL_FOCUS_AGENT) { + try { + const focusAgent = await Agent.get("focus") + if (focusAgent) { + const objective = await Objective.extract(sessionID, msgs) + const focusPrompt = [ + focusAgent.prompt ?? "", + objective ? `\n## Current Objective\n${objective}` : "", + ] + .filter(Boolean) + .join("\n") + const focusMsg = (await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: lastUser.id, + sessionID, + agent: "focus", + mode: "focus", + variant: lastUser.variant, + summary: false, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.id, + providerID: model.providerID, + time: { created: Date.now() }, + })) as MessageV2.Assistant + const focusProcessor = SessionProcessor.create({ + assistantMessage: focusMsg, + sessionID, + model, + abort, + }) + const focusTools = await resolveTools({ + agent: focusAgent, + session, + model, + tools: {}, + processor: focusProcessor, + bypassAgentCheck: true, + messages: msgs, + }) + await focusProcessor.process({ + user: lastUser, + agent: focusAgent, + abort, + sessionID, + system: [focusPrompt], + messages: MessageV2.toModelMessages( + MessageV2.filterEdited(msgs), + model, + ), + tools: focusTools, + model, + }) + } + } catch (e) { + log.warn("focus agent error", { error: String(e) }) + } + } + continue } SessionCompaction.prune({ sessionID }) @@ -988,10 +1073,11 @@ export namespace SessionPrompt { using _ = defer(() => InstructionPrompt.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) + const assign = (part: Draft): MessageV2.Part => + ({ + ...part, + id: part.id ? PartID.make(part.id) : PartID.ascending(), + }) as unknown as MessageV2.Part const parts = await Promise.all( input.parts.map(async (part): Promise[]> => { diff --git a/packages/opencode/src/session/side-thread.sql.ts b/packages/opencode/src/session/side-thread.sql.ts new file mode 100644 index 000000000..fe9b3debe --- /dev/null +++ b/packages/opencode/src/session/side-thread.sql.ts @@ -0,0 +1,27 @@ +import { sqliteTable, text, index } from "drizzle-orm/sqlite-core" +import { ProjectTable } from "../project/project.sql" +import { Timestamps } from "../storage/schema.sql" +import type { ProjectID } from "../project/schema" + +export const SideThreadTable = sqliteTable( + "side_thread", + { + id: text().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + title: text().notNull(), + description: text().notNull(), + status: text().notNull().$default(() => "parked"), + priority: text().notNull().$default(() => "medium"), + category: text().notNull().$default(() => "other"), + source_session_id: text(), + source_part_ids: text({ mode: "json" }).$type(), + cas_refs: text({ mode: "json" }).$type(), + related_files: text({ mode: "json" }).$type(), + created_by: text().notNull(), + ...Timestamps, + }, + (table) => [index("side_thread_project_idx").on(table.project_id, table.status)], +) diff --git a/packages/opencode/src/session/side-thread.ts b/packages/opencode/src/session/side-thread.ts new file mode 100644 index 000000000..6d0c35293 --- /dev/null +++ b/packages/opencode/src/session/side-thread.ts @@ -0,0 +1,173 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and } from "@/storage/db" +import { SideThreadTable } from "./side-thread.sql" +import { Identifier } from "@/id/id" +import { Log } from "@/util/log" +import z from "zod" + +export namespace SideThread { + const log = Log.create({ service: "side-thread" }) + + export const Info = z + .object({ + id: z.string(), + projectID: z.string(), + title: z.string(), + description: z.string(), + status: z.enum(["parked", "investigating", "resolved", "deferred"]), + priority: z.enum(["low", "medium", "high", "critical"]), + category: z.enum(["bug", "tech-debt", "security", "performance", "test", "other"]), + sourceSessionID: z.string().optional(), + sourcePartIDs: z.string().array().optional(), + casRefs: z.string().array().optional(), + relatedFiles: z.string().array().optional(), + createdBy: z.string(), + timeCreated: z.number(), + timeUpdated: z.number(), + }) + .meta({ ref: "SideThread" }) + export type Info = z.infer + + export const Event = { + Created: BusEvent.define( + "side-thread.created", + z.object({ thread: Info }), + ), + Updated: BusEvent.define( + "side-thread.updated", + z.object({ thread: Info }), + ), + } + + function rowToInfo(row: typeof SideThreadTable.$inferSelect): Info { + return { + id: row.id, + projectID: row.project_id, + title: row.title, + description: row.description, + status: row.status as Info["status"], + priority: row.priority as Info["priority"], + category: row.category as Info["category"], + sourceSessionID: row.source_session_id ?? undefined, + sourcePartIDs: row.source_part_ids ?? undefined, + casRefs: row.cas_refs ?? undefined, + relatedFiles: row.related_files ?? undefined, + createdBy: row.created_by, + timeCreated: row.time_created, + timeUpdated: row.time_updated, + } + } + + export function create(input: { + projectID: string + title: string + description: string + priority?: Info["priority"] + category?: Info["category"] + sourceSessionID?: string + sourcePartIDs?: string[] + casRefs?: string[] + relatedFiles?: string[] + createdBy: string + }): Info { + const id = "thr_" + Identifier.ascending("part").slice(4) // thr_ prefix + const now = Date.now() + + Database.use((db) => { + db.insert(SideThreadTable) + .values({ + id, + project_id: input.projectID as any, + title: input.title, + description: input.description, + status: "parked", + priority: input.priority ?? "medium", + category: input.category ?? "other", + source_session_id: input.sourceSessionID ?? null, + source_part_ids: input.sourcePartIDs ?? null, + cas_refs: input.casRefs ?? null, + related_files: input.relatedFiles ?? null, + created_by: input.createdBy, + }) + .run() + }) + + const thread: Info = { + id, + projectID: input.projectID, + title: input.title, + description: input.description, + status: "parked", + priority: input.priority ?? "medium", + category: input.category ?? "other", + sourceSessionID: input.sourceSessionID, + sourcePartIDs: input.sourcePartIDs, + casRefs: input.casRefs, + relatedFiles: input.relatedFiles, + createdBy: input.createdBy, + timeCreated: now, + timeUpdated: now, + } + + Database.effect(() => Bus.publish(Event.Created, { thread })) + log.info("created", { id, title: input.title }) + return thread + } + + export function get(id: string): Info | null { + const row = Database.use((db) => + db.select().from(SideThreadTable).where(eq(SideThreadTable.id, id)).get(), + ) + return row ? rowToInfo(row) : null + } + + export function list(input: { + projectID: string + status?: Info["status"] | "all" + }): Info[] { + const rows = Database.use((db) => { + if (input.status && input.status !== "all") { + return db + .select() + .from(SideThreadTable) + .where( + and( + eq(SideThreadTable.project_id, input.projectID as any), + eq(SideThreadTable.status, input.status), + ), + ) + .all() + } + return db + .select() + .from(SideThreadTable) + .where(eq(SideThreadTable.project_id, input.projectID as any)) + .all() + }) + return rows.map(rowToInfo) + } + + export function update( + id: string, + fields: Partial>, + ): Info | null { + Database.use((db) => { + const updates: Record = {} + if (fields.status !== undefined) updates.status = fields.status + if (fields.priority !== undefined) updates.priority = fields.priority + if (fields.title !== undefined) updates.title = fields.title + if (fields.description !== undefined) updates.description = fields.description + if (Object.keys(updates).length > 0) { + db.update(SideThreadTable).set(updates).where(eq(SideThreadTable.id, id)).run() + } + }) + + const updated = get(id) + if (updated) { + Database.effect(() => Bus.publish(Event.Updated, { thread: updated })) + log.info("updated", { id, fields: Object.keys(fields) }) + } + return updated + } +} diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee62..332b2daef 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -3,3 +3,5 @@ export { ProjectTable } from "../project/project.sql" export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" export { WorkspaceTable } from "../control-plane/workspace.sql" +export { CASObjectTable, EditGraphNodeTable, EditGraphHeadTable } from "../cas/cas.sql" +export { SideThreadTable } from "../session/side-thread.sql" diff --git a/packages/opencode/src/tool/context-deref.ts b/packages/opencode/src/tool/context-deref.ts new file mode 100644 index 000000000..932c8fa65 --- /dev/null +++ b/packages/opencode/src/tool/context-deref.ts @@ -0,0 +1,30 @@ +import { Tool } from "./tool" +import { CAS } from "@/cas" +import z from "zod" + +export const ContextDerefTool = Tool.define("context_deref", { + description: `Retrieve externalized content from the content-addressable store by hash. +Use this when you see a reference like 'Use context_deref("abc123") to retrieve full content.' +Returns the original content before it was externalized.`, + + parameters: z.object({ + hash: z.string().describe("The content hash of the externalized content"), + }), + + async execute(args, _ctx) { + const entry = CAS.get(args.hash) + + if (!entry) + return { + title: "Not found", + metadata: { hash: args.hash, tokens: 0 }, + output: `No content found for hash: ${args.hash}`, + } + + return { + title: `Retrieved (${entry.tokens} tokens)`, + metadata: { hash: args.hash, tokens: entry.tokens }, + output: entry.content, + } + }, +}) diff --git a/packages/opencode/src/tool/context-edit.ts b/packages/opencode/src/tool/context-edit.ts new file mode 100644 index 000000000..3ae3c8ae8 --- /dev/null +++ b/packages/opencode/src/tool/context-edit.ts @@ -0,0 +1,100 @@ +import { Tool } from "./tool" +import { ContextEdit } from "@/context-edit" +import z from "zod" + +export const ContextEditTool = Tool.define("context_edit", { + description: `Edit the conversation context to correct mistakes, remove stale content, or compress verbose output. + +Operations: +- hide(partID, messageID): Remove a part from your context window. Original preserved in content store. +- unhide(partID, messageID): Restore a previously hidden part. +- replace(partID, messageID, replacement): Replace a part with corrected text. Original preserved. +- externalize(partID, messageID, summary): Move content to store, leave compact summary + hash reference inline. Use context_deref to retrieve later. +- annotate(partID, messageID, annotation): Add a note to a part without changing its content. + +Constraints: +- You can only edit your own assistant messages, not user messages +- You cannot edit the 2 most recent turns +- Maximum 10 edits per turn, cannot hide more than 70% of all parts`, + + parameters: z.object({ + operation: z + .enum(["hide", "unhide", "replace", "annotate", "externalize"]) + .describe("The edit operation to perform"), + partID: z.string().describe("Target part ID"), + messageID: z.string().describe("Parent message ID"), + replacement: z.string().optional().describe("Replacement text (for replace operation)"), + annotation: z.string().optional().describe("Annotation text (for annotate operation)"), + summary: z + .string() + .optional() + .describe("Summary of externalized content (for externalize operation)"), + }), + + async execute(args, ctx) { + const base = { + sessionID: ctx.sessionID, + agent: ctx.agent, + partID: args.partID, + messageID: args.messageID, + } + + let result: ContextEdit.EditResult + + switch (args.operation) { + case "hide": + result = await ContextEdit.hide(base) + break + + case "unhide": + result = await ContextEdit.unhide(base) + break + + case "replace": + if (!args.replacement) + return { + title: "Error", + metadata: { operation: args.operation }, + output: "replacement is required for replace operation", + } + result = await ContextEdit.replace({ ...base, replacement: args.replacement }) + break + + case "annotate": + if (!args.annotation) + return { + title: "Error", + metadata: { operation: args.operation }, + output: "annotation is required for annotate operation", + } + result = await ContextEdit.annotate({ ...base, annotation: args.annotation }) + break + + case "externalize": + if (!args.summary) + return { + title: "Error", + metadata: { operation: args.operation }, + output: "summary is required for externalize operation", + } + result = await ContextEdit.externalize({ ...base, summary: args.summary }) + break + + default: + return { title: "Error", metadata: { operation: args.operation }, output: `Unknown operation: ${args.operation}` } + } + + if (!result.success) + return { + title: "Edit failed", + metadata: { operation: args.operation }, + output: `Error: ${result.error}`, + } + + return { + title: `Context edit: ${args.operation}`, + metadata: { operation: args.operation }, + output: `Successfully applied ${args.operation} on part ${args.partID}.${result.casHash ? ` Original preserved: ${result.casHash.slice(0, 16)}...` : ""}`, + } + }, +}) diff --git a/packages/opencode/src/tool/context-history.ts b/packages/opencode/src/tool/context-history.ts new file mode 100644 index 000000000..fd71027c9 --- /dev/null +++ b/packages/opencode/src/tool/context-history.ts @@ -0,0 +1,97 @@ +import { Tool } from "./tool" +import { EditGraph } from "@/cas/graph" +import z from "zod" + +export const ContextHistoryTool = Tool.define("context_history", { + description: `Navigate the edit history of the conversation context. + +Operations: +- log: Show linear history from current head back to first edit +- tree: Show full DAG with all branches +- checkout(nodeID): Restore context to a previous edit version (undoes edits between head and target) +- fork(nodeID, branch): Create a named branch at a specific edit point + +The edit history forms a DAG (like git commits). Each edit creates a node with a parent pointer. +Branches allow exploring alternative edit paths without losing the original.`, + + parameters: z.object({ + operation: z.enum(["log", "tree", "checkout", "fork"]).describe("The history operation"), + nodeID: z.string().optional().describe("Target node ID (for checkout and fork)"), + branch: z.string().optional().describe("Branch name (for fork)"), + }), + + async execute(args, ctx) { + switch (args.operation) { + case "log": { + const nodes = EditGraph.getLog(ctx.sessionID) + if (nodes.length === 0) + return { title: "No edit history", metadata: { count: 0, branches: [] as string[] }, output: "No edits have been made in this session." } + + const lines = nodes.map((n, i) => { + const marker = i === 0 ? " (HEAD)" : "" + const date = new Date(n.time_created).toISOString().slice(11, 19) + return `${n.id.slice(0, 12)}${marker} ${n.operation} on ${n.part_id.slice(0, 12)} by ${n.agent} [${date}]${n.cas_hash ? ` cas:${n.cas_hash.slice(0, 8)}` : ""}` + }) + return { + title: `${nodes.length} edits`, + metadata: { count: nodes.length, branches: [] as string[] }, + output: lines.join("\n"), + } + } + + case "tree": { + const { nodes, head, branches } = EditGraph.tree(ctx.sessionID) + if (nodes.length === 0) + return { title: "No edit history", metadata: { count: 0, branches: [] as string[] }, output: "No edits have been made in this session." } + + const branchLabels = new Map() + for (const [name, nodeID] of Object.entries(branches)) { + const existing = branchLabels.get(nodeID) ?? [] + existing.push(name) + branchLabels.set(nodeID, existing) + } + + const lines = nodes.map((n) => { + const isHead = n.id === head ? " <- HEAD" : "" + const branchTags = branchLabels.get(n.id)?.map((b) => ` [${b}]`).join("") ?? "" + const parent = n.parent_id ? ` parent:${n.parent_id.slice(0, 12)}` : " (root)" + return `${n.id.slice(0, 12)} ${n.operation} on ${n.part_id.slice(0, 12)} by ${n.agent}${parent}${branchTags}${isHead}` + }) + return { + title: `${nodes.length} nodes, ${Object.keys(branches).length} branches`, + metadata: { count: nodes.length, branches: Object.keys(branches) }, + output: lines.join("\n"), + } + } + + case "checkout": { + if (!args.nodeID) + return { title: "Error", metadata: { count: 0, branches: [] as string[] }, output: "nodeID is required for checkout" } + const result = await EditGraph.checkout(ctx.sessionID, args.nodeID) + if (!result.success) + return { title: "Checkout failed", metadata: { count: 0, branches: [] as string[] }, output: `Error: ${result.error}` } + return { + title: `Checked out ${args.nodeID.slice(0, 12)}`, + metadata: { count: 0, branches: [] as string[] }, + output: `Context restored to version ${args.nodeID}. Edits after this point have been undone.`, + } + } + + case "fork": { + if (!args.nodeID || !args.branch) + return { title: "Error", metadata: { count: 0, branches: [] as string[] }, output: "nodeID and branch are required for fork" } + const result = EditGraph.fork(ctx.sessionID, args.nodeID, args.branch) + if (!result.success) + return { title: "Fork failed", metadata: { count: 0, branches: [] as string[] }, output: `Error: ${result.error}` } + return { + title: `Forked: ${args.branch}`, + metadata: { count: 0, branches: [args.branch] }, + output: `Branch '${args.branch}' created at node ${args.nodeID}. HEAD moved to branch point.`, + } + } + + default: + return { title: "Error", metadata: { count: 0, branches: [] as string[] }, output: `Unknown operation: ${args.operation}` } + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3ea242a29..dae52bb5b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -29,6 +29,11 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { ApplyPatchTool } from "./apply_patch" +import { ContextEditTool } from "./context-edit" +import { ContextDerefTool } from "./context-deref" +import { ContextHistoryTool } from "./context-history" +import { ThreadParkTool } from "./thread-park" +import { ThreadListTool } from "./thread-list" import { Glob } from "../util/glob" import { pathToFileURL } from "url" @@ -118,6 +123,11 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, + ContextEditTool, + ContextDerefTool, + ContextHistoryTool, + ThreadParkTool, + ThreadListTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/opencode/src/tool/thread-list.ts b/packages/opencode/src/tool/thread-list.ts new file mode 100644 index 000000000..ad74937f6 --- /dev/null +++ b/packages/opencode/src/tool/thread-list.ts @@ -0,0 +1,40 @@ +import { Tool } from "./tool" +import { SideThread } from "@/session/side-thread" +import { Instance } from "@/project/instance" +import z from "zod" + +export const ThreadListTool = Tool.define("thread_list", { + description: `List side threads for this project. Shows parked, investigating, resolved, and deferred threads with their status, priority, and summaries.`, + + parameters: z.object({ + status: z + .enum(["parked", "investigating", "resolved", "deferred", "all"]) + .default("all") + .describe("Filter by status"), + }), + + async execute(args, _ctx) { + const threads = SideThread.list({ + projectID: Instance.project.id, + status: args.status as any, + }) + + if (threads.length === 0) + return { + title: "No threads", + metadata: { count: 0 }, + output: `No side threads found${args.status !== "all" ? ` with status '${args.status}'` : ""}.`, + } + + const lines = threads.map((t) => { + const files = t.relatedFiles?.length ? `\n Files: ${t.relatedFiles.join(", ")}` : "" + return `${t.id} [${t.status}, ${t.priority}, ${t.category}] "${t.title}"\n ${t.description}${files}` + }) + + return { + title: `${threads.length} threads`, + metadata: { count: threads.length }, + output: lines.join("\n\n"), + } + }, +}) diff --git a/packages/opencode/src/tool/thread-park.ts b/packages/opencode/src/tool/thread-park.ts new file mode 100644 index 000000000..05e97b3d3 --- /dev/null +++ b/packages/opencode/src/tool/thread-park.ts @@ -0,0 +1,62 @@ +import { Tool } from "./tool" +import { SideThread } from "@/session/side-thread" +import { Instance } from "@/project/instance" +import z from "zod" + +export const ThreadParkTool = Tool.define("thread_park", { + description: `Park a side discovery as a side thread for later investigation. +Use this when you notice something worth investigating but not part of the current objective. +The thread persists at the project level and survives across sessions. + +Examples of when to park: +- You discover a bug in an unrelated module while reading code +- A tool result reveals a security issue outside your current scope +- You notice tech debt or outdated dependencies tangential to your task`, + + parameters: z.object({ + title: z.string().describe("Short title (under 80 chars)"), + description: z.string().describe("2-3 sentence summary: what was found, why it matters"), + priority: z + .enum(["low", "medium", "high", "critical"]) + .default("medium") + .describe("Priority level"), + category: z + .enum(["bug", "tech-debt", "security", "performance", "test", "other"]) + .default("other") + .describe("Category of the finding"), + sourcePartIDs: z + .string() + .array() + .optional() + .describe("Part IDs containing the relevant finding"), + relatedFiles: z.string().array().optional().describe("File paths involved"), + }), + + async execute(args, ctx) { + const thread = SideThread.create({ + projectID: Instance.project.id, + title: args.title, + description: args.description, + priority: args.priority, + category: args.category, + sourceSessionID: ctx.sessionID, + sourcePartIDs: args.sourcePartIDs, + relatedFiles: args.relatedFiles, + createdBy: ctx.agent, + }) + + return { + title: `Parked: ${args.title}`, + metadata: { threadID: thread.id, priority: args.priority }, + output: [ + `[Side thread ${thread.id} parked]`, + `Title: ${args.title}`, + `Priority: ${args.priority} | Category: ${args.category}`, + `Description: ${args.description}`, + args.relatedFiles?.length ? `Files: ${args.relatedFiles.join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"), + } + }, +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index b78bcae17..c2e2d7be0 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -231,4 +231,12 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + "context.edit.before"?: ( + input: { operation: string; sessionID: string; partID?: string; messageID?: string; agent: string }, + output: { allow: boolean; reason?: string }, + ) => Promise + "context.edit.after"?: ( + input: { operation: string; sessionID: string; partID?: string; messageID?: string; agent: string; success: boolean }, + output: {}, + ) => Promise }