diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 9ee75aff..f53db6a6 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -203,3 +203,17 @@ pandada8 pr npupko issue chrisvariety pr + +maximilianzuern pr + +brianmichel pr + +abhinavmathur-atlan pr + +mattiacerutti pr + +josephyoung pr + +mbazso pr + +AJM10565 pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index fcff2104..ccc4eab8 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -24,11 +24,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ env.RELEASE_TAG }} + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 with: - bun-version: 1.2.20 + bun-version: 1.3.10 - name: Setup Node.js uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -69,6 +70,7 @@ jobs: pi-linux-x64.tar.gz \ pi-linux-arm64.tar.gz \ pi-windows-x64.zip \ + pi-windows-arm64.zip \ 2>/dev/null || \ gh release upload "${RELEASE_TAG}" \ pi-darwin-arm64.tar.gz \ @@ -76,4 +78,5 @@ jobs: pi-linux-x64.tar.gz \ pi-linux-arm64.tar.gz \ pi-windows-x64.zip \ + pi-windows-arm64.zip \ --clobber diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef5b18da..a40a894c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: sudo ln -s $(which fdfind) /usr/local/bin/fd - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build run: npm run build diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 62018399..62663940 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -17,11 +17,8 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const ISSUE_GATE_MESSAGE_MODE = 'refactor'; // Switch to 'normal' to restore the standard auto-close message. const issueAuthor = context.payload.issue.user.login; const defaultBranch = context.payload.repository.default_branch; - const issueCreatedDay = new Date(context.payload.issue.created_at).getUTCDay(); - const isFridayThroughSunday = issueCreatedDay === 5 || issueCreatedDay === 6 || issueCreatedDay === 0; if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { console.log(`Skipping bot: ${issueAuthor}`); @@ -97,36 +94,15 @@ jobs: return; } - function buildNormalGateMessage() { - return [ - 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', - ...(isFridayThroughSunday - ? [ - 'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx', - ] - : []), - '', - `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, - '', - 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.', - '', - `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, - ].join('\n'); - } - - function buildRefactorGateMessage() { - return [ - 'This issue was auto-closed. All issues will be closed until 2026-05-17 while the project refactor is being completed.', - '', - `The refactor is happening on \`${defaultBranch}\`: https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${defaultBranch}`, - '', - 'Issues closed during this period will not be reviewed. The reason is that the refactor will not get done otherwise, because issue triage has been taking about 8 hours per day.', - '', - 'In case of emergency, ask on Discord: https://discord.com/invite/3cU7Bz4UPx', - ].join('\n'); - } - - const message = ISSUE_GATE_MESSAGE_MODE === 'refactor' ? buildRefactorGateMessage() : buildNormalGateMessage(); + const message = [ + 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', + '', + `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, @@ -135,33 +111,6 @@ jobs: body: message, }); - const labelsToAdd = []; - if (isFridayThroughSunday) labelsToAdd.push('closed-because-weekend'); - if (ISSUE_GATE_MESSAGE_MODE === 'refactor') labelsToAdd.push('closed-because-refactor'); - - if (labelsToAdd.includes('closed-because-refactor')) { - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'closed-because-refactor', - color: '5319e7', - description: 'Closed while the project refactor is in progress', - }); - } catch (error) { - if (error.status !== 422) throw error; - } - } - - if (labelsToAdd.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: labelsToAdd, - }); - } - await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/npm-audit.yml b/.github/workflows/npm-audit.yml new file mode 100644 index 00000000..021b81ca --- /dev/null +++ b/.github/workflows/npm-audit.yml @@ -0,0 +1,31 @@ +name: npm audit + +on: + schedule: + - cron: '37 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Audit production vulnerabilities + run: npm audit --omit=dev --audit-level=moderate + + - name: Verify registry signatures + run: npm audit signatures --omit=dev diff --git a/.husky/pre-commit b/.husky/pre-commit index 27d18fbc..2b21ec96 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,6 +3,11 @@ # Get list of staged files before running check STAGED_FILES=$(git diff --cached --name-only) +node scripts/check-lockfile-commit.mjs +if [ $? -ne 0 ]; then + exit 1 +fi + # Run the check script (formatting, linting, and type checking) echo "Running formatting, linting, and type checking..." npm run check diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..72acb9ad --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +min-release-age=2 diff --git a/.pi/prompts/is.md b/.pi/prompts/is.md new file mode 100644 index 00000000..ccdb27e1 --- /dev/null +++ b/.pi/prompts/is.md @@ -0,0 +1,25 @@ +--- +description: Analyze GitHub issues (bugs or feature requests) +argument-hint: "" +--- +Analyze GitHub issue(s): $ARGUMENTS + +For each issue: + +1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue. +2. Read the issue in full, including all comments and linked issues/PRs. +3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path. + +4. **For bugs**: + - Ignore any root cause analysis in the issue (likely wrong) + - Read all related code files in full (no truncation) + - Trace the code path and identify the actual root cause + - Propose a fix + +5. **For feature requests**: + - Do not trust implementation proposals in the issue without verification + - Read all related code files in full (no truncation) + - Propose the most concise implementation approach + - List affected files and changes needed + +Do NOT implement unless explicitly asked. Analyze and propose only. diff --git a/.pi/prompts/pr.md b/.pi/prompts/pr.md new file mode 100644 index 00000000..b1b2617e --- /dev/null +++ b/.pi/prompts/pr.md @@ -0,0 +1,37 @@ +--- +description: Review PRs from URLs with structured issue and code analysis +argument-hint: "" +--- +You are given one or more GitHub PR URLs: $@ + +For each PR URL, do the following in order: +1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue. +2. Read the PR page in full. Include description, all comments, all commits, and all changed files. +3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments. +4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior. +5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging. +6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added. +7. Provide a structured review with these sections: + - What it does: one short paragraph describing the change and its intent. + - Good: solid choices or improvements. + - Bad: concrete issues, regressions, missing tests, or risks. + - Ugly: subtle or high impact problems. + - Tests: what is covered, what is missing, and whether existing tests are adequate. + - Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none. + +Output format per PR: +PR: +What it does: +- ... +Good: +- ... +Bad: +- ... +Ugly: +- ... +Tests: +- ... +Open questions for you: +- ... + +If no issues are found, say so under Bad and Ugly. diff --git a/.pi/prompts/wr.md b/.pi/prompts/wr.md new file mode 100644 index 00000000..a2e42b2c --- /dev/null +++ b/.pi/prompts/wr.md @@ -0,0 +1,35 @@ +--- +description: Finish the current task end-to-end with changelog, commit, and push +argument-hint: "[instructions]" +--- +Wrap it. + +Additional instructions: $ARGUMENTS + +Determine context from the conversation history first. + +Rules for context detection: +- If the conversation already mentions a GitHub issue or PR, use that existing context. +- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done. +- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work. + +Unless I explicitly override something in this request, do the following in order: + +1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules. +2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations: + + ```text + This comment is AI-generated by `/wr` + ``` +3. Commit only files you changed in this session. +4. If this task is tied to exactly one GitHub issue, include `closes #` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message. +5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so. +6. Push the current branch. + +Constraints: +- Never stage unrelated files. +- Never use `git add .` or `git add -A`. +- Run required checks before committing if code changed. +- Do not open a PR unless I explicitly ask. +- If this is not GitHub issue or PR work, do not post a GitHub comment. +- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask. diff --git a/.pi/skills/add-llm-provider.md b/.pi/skills/add-llm-provider.md new file mode 100644 index 00000000..97cd9a3e --- /dev/null +++ b/.pi/skills/add-llm-provider.md @@ -0,0 +1,57 @@ +--- +name: add-llm-provider +description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs. +--- + +# Adding a New LLM Provider (packages/ai) + +A new provider touches multiple files. Work through these steps in order. + +## 1. Core Types (`packages/ai/src/types.ts`) + +- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`). +- Create options interface extending `StreamOptions`. +- Add mapping to `ApiOptionsMap`. +- Add provider name to `KnownProvider` type union. + +## 2. Provider Implementation (`packages/ai/src/providers/`) + +Create a provider file exporting: + +- `stream()` returning `AssistantMessageEventStream`. +- `streamSimple()` for `SimpleStreamOptions` mapping. +- Provider-specific options interface. +- Message/tool conversion functions. +- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`). + +## 3. Provider Exports and Lazy Registration + +- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/.js`. +- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry. +- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there. +- Add credential detection in `packages/ai/src/env-api-keys.ts`. + +## 4. Model Generation (`packages/ai/scripts/generate-models.ts`) + +- Add logic to fetch/parse models from the provider source. +- Map to the standardized `Model` interface. + +## 5. Tests (`packages/ai/test/`) + +- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`. +- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. +- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family. +- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection. + +## 6. Coding Agent (`packages/coding-agent/`) + +- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`. +- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. +- `src/cli/args.ts`: add env var documentation. +- `README.md`: add provider setup instructions. +- `docs/providers.md`: add setup instructions, env var, and `auth.json` key. + +## 7. Documentation + +- `packages/ai/README.md`: add to providers table, document options/auth, add env vars. +- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`. diff --git a/AGENTS.md b/AGENTS.md index f9205e03..144d2c60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,244 +4,156 @@ - Keep answers short and concise - No emojis in commits, issues, PR comments, or code -- No fluff or cheerful filler text -- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") +- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") +- Technical prose only, be direct +- When the user asks a question, answer it first before making edits or running implementation commands. ## Code Quality -- Read files in full before making wide-ranging changes, before editing files you have not already fully inspected, and when the user asks you to investigate or audit something. Do not rely only on search snippets for broad changes. -- No `any` types unless absolutely necessary -- Check node_modules for external API type definitions instead of guessing -- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. -- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead -- Always ask before removing functionality or code that appears to be intentional -- Do not preserve backward compatibility unless the user explicitly asks for it -- Never hardcode key checks with, eg. `matchesKey(keyData, "ctrl+x")`. All keybindings must be configurable. Add default to matching object (`DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS`) -- NEVER modify `packages/ai/src/models.generated.ts` directly. Update `packages/ai/scripts/generate-models.ts` instead. +- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes. +- No `any` unless absolutely necessary. +- Inline single-line helpers that have only one call site. +- Check node_modules for external API types; don't guess. +- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only. +- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead. +- Use only erasable TypeScript syntax (Node strip-only mode) in code checked by the root config (`packages/*/src`, `packages/*/test`, `packages/coding-agent/examples`): no parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other constructs needing JS emit. Use explicit fields with constructor assignments. +- Always ask before removing functionality or code that appears intentional. +- Do not preserve backward compatibility unless the user asks for it. +- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable. +- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead. ## Commands -- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing. -- Note: `npm run check` does not run tests. -- NEVER run: `npm run dev`, `npm run build`, `npm test` -- Only run specific tests if user instructs: `npx tsx ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts` -- Run tests from the package root, not the repo root. -- If you create or modify a test file, you MUST run that test file and iterate until it passes. -- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed. -- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` plus the faux provider. Do not use real provider APIs, real API keys, or paid tokens. -- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` and name them `-.test.ts`. -- NEVER commit unless user asks +- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests. +- Never run `npm run build` or `npm test` unless requested by the user. +- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. +- If you create or modify a test file, run it and iterate on test or implementation until it passes. +- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens. +- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `-.test.ts`. +- For ad-hoc scripts, `write` them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands. +- Never commit unless the user asks. -## Contribution Gate +## Dependency and Install Security -- New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml` -- New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml` -- Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml` -- Maintainers review auto-closed issues daily -- Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply -- `lgtmi` approves future issues -- `lgtm` approves future issues and rights to submit PRs +- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions. +- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks. +- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`. +- If `packages/coding-agent/npm-shrinkwrap.json` needs regen, run `node scripts/generate-coding-agent-shrinkwrap.mjs` (verify with `--check` or `npm run check`). New deps with lifecycle scripts require review and an explicit allowlist entry in that script; never add one silently. +- Pre-commit blocks lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1`. Don't bypass unless the user wants the lockfile change committed. + +## Git + +Multiple pi sessions may be running in this cwd at the same time, each modifying different files. Git operations that touch unstaged, staged, or untracked files outside your own changes will stomp on other sessions' work. Follow these rules: + +Committing: + +- Only commit files YOU changed in THIS session. +- Stage explicit paths (`git add `); never `git add -A` / `git add .`. +- Before committing, run `git status` and verify you are only staging your files. +- `packages/ai/src/models.generated.ts` may always be included alongside your files. + +Never run (destroys other agents' work or bypasses checks): + +- `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`. + +If rebase conflicts occur: + +- Resolve conflicts only in files you modified. +- If a conflict is in a file you did not modify, abort and ask the user. +- Never force push. + +## Issues and PRs + +See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar). When creating issues: -- Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`, `pkg:web-ui` -- If an issue spans multiple packages, add all relevant labels +- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply. When posting issue/PR comments: -- Write the full comment to a temp file and use `gh issue comment --body-file` or `gh pr comment --body-file` -- Never pass multi-line markdown directly via `--body` in shell commands -- Preview the exact comment text before posting -- Post exactly one final comment unless the user explicitly asks for multiple comments -- If a comment is malformed, delete it immediately, then post one corrected comment -- Keep comments concise, technical, and in the user's tone +- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`). +- Keep comments concise, technical, in the user's tone. +- End every AI-posted comment with the AI-generated disclaimer line specified by the originating prompt (e.g. `This comment is AI-generated by `/wr``). When closing issues via commit: -- Include `fixes #` or `closes #` in the commit message -- This automatically closes the issue when the commit is merged - -## PR Workflow - -- Analyze PRs without pulling locally first -- If the user approves: create a feature branch, pull PR, rebase on main, apply adjustments, commit, merge into main, push, close PR, and leave a comment in the user's tone -- You never open PRs yourself. We work in feature branches until everything is according to the user's requirements, then merge into main, and push. +- Include `fixes #` or `closes #` in the message so merging auto-closes the issue. For multiple issues, repeat the keyword per issue (`closes #1, closes #2`); a shared keyword (`closes #1, #2`) only closes the first. ## Testing pi Interactive Mode with tmux -To test pi's TUI in a controlled terminal environment: +Run the TUI in a controlled terminal (from the repo root): ```bash -# Create tmux session with specific dimensions tmux new-session -d -s pi-test -x 80 -y 24 - -# Start pi from source -tmux send-keys -t pi-test "cd /Users/badlogic/workspaces/pi-mono && ./pi-test.sh" Enter - -# Wait for startup, then capture output -sleep 3 && tmux capture-pane -t pi-test -p - -# Send input +tmux send-keys -t pi-test "./pi-test.sh" Enter +sleep 3 && tmux capture-pane -t pi-test -p # capture after startup tmux send-keys -t pi-test "your prompt here" Enter - -# Send special keys -tmux send-keys -t pi-test Escape -tmux send-keys -t pi-test C-o # ctrl+o - -# Cleanup +tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.) tmux kill-session -t pi-test ``` ## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) +Location: `packages/*/CHANGELOG.md` (one per package). -### Format +Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`. -Use these sections under `## [Unreleased]`: +Rules: -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features +- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. +- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. -### Rules +Attribution: -- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist -- New entries ALWAYS go under `## [Unreleased]` section -- Append to existing subsections (e.g., `### Fixed`), do not create duplicates -- NEVER modify already-released version sections (e.g., `## [0.12.2]`) -- Each version section is immutable once released - -### Attribution - -- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))` -- **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))` - -## Adding a New LLM Provider (packages/ai) - -Adding a new provider requires changes across multiple files: - -### 1. Core Types (`packages/ai/src/types.ts`) - -- Add API identifier to `Api` type union (e.g., `"bedrock-converse-stream"`) -- Create options interface extending `StreamOptions` -- Add mapping to `ApiOptionsMap` -- Add provider name to `KnownProvider` type union - -### 2. Provider Implementation (`packages/ai/src/providers/`) - -Create provider file exporting: - -- `stream()` function returning `AssistantMessageEventStream` -- `streamSimple()` for `SimpleStreamOptions` mapping -- Provider-specific options interface -- Message/tool conversion functions -- Response parsing emitting standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`) - -### 3. Provider Exports and Lazy Registration - -- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/.js` -- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry -- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers, do not statically import provider implementation modules there -- Add credential detection in `packages/ai/src/env-api-keys.ts` - -### 4. Model Generation (`packages/ai/scripts/generate-models.ts`) - -- Add logic to fetch/parse models from provider source -- Map to standardized `Model` interface - -### 5. Tests (`packages/ai/test/`) - -- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`. -- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. -- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family. -- For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection. - -### 6. Coding Agent (`packages/coding-agent/`) - -- `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider` -- `src/core/provider-display-names.ts`: Add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. -- `src/cli/args.ts`: Add env var documentation -- `README.md`: Add provider setup instructions -- `docs/providers.md`: Add setup instructions, env var, and `auth.json` key - -### 7. Documentation - -- `packages/ai/README.md`: Add to providers table, document options/auth, add env vars -- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]` +- Internal (from issues): `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))` +- External contributions: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))` ## Releasing -**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together. +**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases. -**Version semantics** (no major releases): +1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing. -- `patch`: Bug fixes and new features -- `minor`: API breaking changes - -### Steps - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md - -2. **Run release script**: +2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files): ```bash - npm run release:patch # Fixes and additions - npm run release:minor # API breaking changes + npm run release:local -- --out /tmp/pi-local-release --force + cd /tmp + /tmp/pi-local-release/node/pi --help + /tmp/pi-local-release/node/pi --version + /tmp/pi-local-release/node/pi + /tmp/pi-local-release/bun/pi --help + /tmp/pi-local-release/bun/pi --version + ``` + Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk. + +3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: + + ``` + Before I run the release script, read this carefully: + + - `npm publish` uses WebAuthn 2FA. + - A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits. + - You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue. + - This may happen more than once during the release. + + Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do. ``` -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. + Do not proceed to step 4 until the user explicitly confirms. -## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** +4. **Run the release script**: + ```bash + npm run release:patch # fixes + additions + npm run release:minor # breaking changes + ``` + Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own. -Multiple agents may work on different files in the same worktree simultaneously. You MUST follow these rules: +5. **After publish succeeds**: + - Add fresh `## [Unreleased]` sections to package changelogs. + - Commit with `Add [Unreleased] section for next cycle`. + - Push `main` and the release tag. -### Committing +## User Override -- **ONLY commit files YOU changed in THIS session** -- ALWAYS include `fixes #` or `closes #` in the commit message when there is a related issue or PR -- NEVER use `git add -A` or `git add .` - these sweep up changes from other agents -- ALWAYS use `git add ` listing only files you modified -- Before committing, run `git status` and verify you are only staging YOUR files -- Track which files you created/modified/deleted during the session -- It is always fine to include `packages/ai/src/models.generated.ts` in a commit alongside the actual files you want to commit - -### Forbidden Git Operations - -These commands can destroy other agents' work: - -- `git reset --hard` - destroys uncommitted changes -- `git checkout .` - destroys uncommitted changes -- `git clean -fd` - deletes untracked files -- `git stash` - stashes ALL changes including other agents' work -- `git add -A` / `git add .` - stages other agents' uncommitted work -- `git commit --no-verify` - bypasses required checks and is never allowed - -### Safe Workflow - -```bash -# 1. Check status first -git status - -# 2. Add ONLY your specific files -git add packages/ai/src/providers/transform-messages.ts -git add packages/ai/CHANGELOG.md - -# 3. Commit -git commit -m "fix(ai): description" - -# 4. Push (pull --rebase if needed, but NEVER reset/checkout) -git pull --rebase && git push -``` - -### If Rebase Conflicts Occur - -- Resolve conflicts in YOUR files only -- If conflict is in a file you didn't modify, abort and ask the user -- NEVER force push - -### User override - -If the user instructions conflict with rules set out here, ask for confirmation that they want to override the rules. Only then execute their instructions. +If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions. diff --git a/README.md b/README.md index e33ac5b0..4b00e321 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,7 @@ | `packages/agent` | Agent 运行时、工具调用和状态管理 | | `packages/ai` | 多模型、多 provider 的 LLM 接入层 | | `packages/tui` | 终端 UI 渲染库 | -| `packages/web-ui` | Web UI 组件 | -| `.pi/extensions/webui` | 本地扩展的网页对话入口 | +| `.pi/agent/extensions/webui` | 本地扩展的网页对话入口 | ## 常用命令 @@ -53,6 +52,66 @@ npm run check 这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。 +To learn more about pi: + +* [Visit pi.dev](https://pi.dev), the project website with demos +* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself + +## Share your OSS coding agent sessions + +If you use pi or other coding agents for open source work, please share your sessions. + +Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks. + +For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911). + +To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`. + +You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions. + +I regularly publish my own `pi-mono` work sessions here: + +- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono) + +## All Packages + +| Package | Description | +|---------|-------------| +| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) | +| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management | +| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI | +| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering | + +For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents). + +## Development + +```bash +npm install --ignore-scripts # Install all dependencies without running lifecycle scripts +npm run build # Build all packages +npm run check # Lint, format, and type check +./test.sh # Run tests (skips LLM-dependent tests without API keys) +./pi-test.sh # Run pi from sources (can be run from any directory) +``` + +## Supply-chain hardening + +We treat npm dependency changes as reviewed code changes. + +- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged. +- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution. +- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. +- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap. +- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users. +- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing. +- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported. +- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`. +- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed. + ## License MIT diff --git a/biome.json b/biome.json index 147d081a..7c89d187 100644 --- a/biome.json +++ b/biome.json @@ -28,12 +28,9 @@ "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", "packages/coding-agent/examples/**/*.ts", - "packages/web-ui/src/**/*.ts", - "packages/web-ui/example/**/*.ts", "!**/node_modules/**/*", "!**/test-sessions.ts", "!**/models.generated.ts", - "!packages/web-ui/src/app.css", "!packages/mom/data/**/*", "!!**/node_modules" ] diff --git a/package-lock.json b/package-lock.json index 2796779d..3e936140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,35 +9,45 @@ "version": "0.0.3", "workspaces": [ "packages/*", - "packages/web-ui/example", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "packages/coding-agent/examples/extensions/sandbox" ], - "dependencies": { - "@earendil-works/pi-coding-agent": "^0.30.2", - "get-east-asian-width": "^1.4.0" - }, "devDependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.26", + "@anthropic-ai/sandbox-runtime": "0.0.26", "@biomejs/biome": "2.3.5", - "@types/node": "^22.10.5", + "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "concurrently": "^9.2.1", - "husky": "^9.1.7", - "jiti": "^2.7.0", - "shx": "^0.4.0", - "tsx": "^4.20.3", - "typescript": "^5.9.2" + "esbuild": "0.28.0", + "husky": "9.1.7", + "jiti": "2.7.0", + "shx": "0.4.0", + "tsx": "4.22.1", + "typescript": "5.9.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@anthropic-ai/sandbox-runtime": { "version": "0.0.26", - "dev": true, + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz", + "integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", @@ -54,16 +64,10 @@ "node": ">=18.0.0" } }, - "node_modules/@anthropic-ai/sandbox-runtime/node_modules/zod": { - "version": "3.25.76", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@anthropic-ai/sdk": { "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -82,6 +86,8 @@ }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -94,6 +100,8 @@ }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -105,40 +113,10 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -151,6 +129,8 @@ }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -158,6 +138,8 @@ }, "node_modules/@aws-crypto/util": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -165,88 +147,25 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1044.0", + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-node": "^3.972.39", - "@aws-sdk/eventstream-handler-node": "^3.972.14", - "@aws-sdk/middleware-eventstream": "^3.972.10", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/middleware-websocket": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1044.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -254,22 +173,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.8", + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.22", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -277,12 +192,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.34", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -291,18 +208,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.36", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -310,21 +226,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.38", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-login": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -333,15 +250,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.38", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -350,19 +267,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.39", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.34", - "@aws-sdk/credential-provider-http": "^3.972.36", - "@aws-sdk/credential-provider-ini": "^3.972.38", - "@aws-sdk/credential-provider-process": "^3.972.34", - "@aws-sdk/credential-provider-sso": "^3.972.38", - "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", "@aws-sdk/types": "^3.973.8", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -371,13 +289,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.34", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", + "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -386,31 +305,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.38", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1041.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -419,14 +323,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.38", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -435,11 +340,13 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.14", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@smithy/eventstream-codec": "^4.2.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -448,11 +355,13 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.10", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -460,100 +369,18 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.10", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.10", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.11", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.37", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.38", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.16", + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.974.11", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-format-url": "^3.972.10", - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -561,60 +388,19 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.6", + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.24", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.7", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.13", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.17", - "@smithy/node-config-provider": "^4.3.14", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -623,13 +409,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.25", + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -638,14 +425,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1044.0", + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.8", - "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -655,6 +443,8 @@ }, "node_modules/@aws-sdk/types": { "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -664,45 +454,10 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.8", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.10", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -711,46 +466,15 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.10", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.24", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.38", - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -759,20 +483,86 @@ }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@biomejs/biome": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.5.tgz", + "integrity": "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -798,6 +588,8 @@ }, "node_modules/@biomejs/cli-darwin-arm64": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.5.tgz", + "integrity": "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw==", "cpu": [ "arm64" ], @@ -930,14 +722,6 @@ "node": ">=14.21.3" } }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@earendil-works/pi-agent-core": { "resolved": "packages/agent", "link": true @@ -954,17 +738,14 @@ "resolved": "packages/tui", "link": true }, - "node_modules/@earendil-works/pi-web-ui": { - "resolved": "packages/web-ui", - "link": true - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -975,12 +756,13 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -991,12 +773,13 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1007,12 +790,13 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1023,10 +807,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1037,12 +824,13 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1053,12 +841,13 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1069,12 +858,13 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1085,12 +875,13 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1101,12 +892,13 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1117,12 +909,13 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1133,12 +926,13 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,12 +943,13 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1165,12 +960,13 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1181,12 +977,13 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1197,12 +994,13 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1213,12 +1011,13 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1229,12 +1028,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1245,12 +1045,13 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1261,12 +1062,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1277,12 +1079,13 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1293,12 +1096,13 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1309,12 +1113,13 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1325,12 +1130,13 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1341,12 +1147,13 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1357,12 +1164,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1374,6 +1182,8 @@ }, "node_modules/@google/genai": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1394,24 +1204,135 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1419,98 +1340,48 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lit-labs/ssr-dom-shim": { - "version": "1.5.1", - "license": "BSD-3-Clause" - }, - "node_modules/@lit/reactive-element": { - "version": "2.1.2", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0" - } - }, - "node_modules/@lmstudio/lms-isomorphic": { - "version": "0.4.6", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.16.0" - } - }, - "node_modules/@lmstudio/sdk": { - "version": "1.5.0", - "license": "Apache-2.0", - "dependencies": { - "@lmstudio/lms-isomorphic": "^0.4.6", - "chalk": "^4.1.2", - "jsonschema": "^1.5.0", - "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.5" - } - }, - "node_modules/@lmstudio/sdk/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@lmstudio/sdk/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@lmstudio/sdk/node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.5", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.2", - "@mariozechner/clipboard-darwin-universal": "0.3.2", - "@mariozechner/clipboard-darwin-x64": "0.3.2", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-musl": "0.3.2", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.2", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", "cpu": [ "arm64" ], @@ -1524,7 +1395,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.2", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", "license": "MIT", "optional": true, "os": [ @@ -1535,9 +1408,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz", - "integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", "cpu": [ "x64" ], @@ -1551,9 +1424,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz", - "integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", "cpu": [ "arm64" ], @@ -1567,9 +1440,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz", - "integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", "cpu": [ "arm64" ], @@ -1583,9 +1456,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz", - "integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", "cpu": [ "riscv64" ], @@ -1599,9 +1472,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz", - "integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", "cpu": [ "x64" ], @@ -1615,9 +1488,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz", - "integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", "cpu": [ "x64" ], @@ -1631,9 +1504,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz", - "integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", "cpu": [ "arm64" ], @@ -1647,9 +1520,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz", - "integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", "cpu": [ "x64" ], @@ -1662,44 +1535,10 @@ "node": ">= 10" } }, - "node_modules/@mariozechner/mini-lit": { - "version": "0.2.1", - "dependencies": { - "@preact/signals-core": "^1.12.1", - "class-variance-authority": "^0.7.1", - "diff": "^8.0.2", - "highlight.js": "^11.11.1", - "html-parse-string": "^0.0.9", - "katex": "^0.16.22", - "lucide": "^0.544.0", - "marked": "^16.3.0", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^3.1.1", - "uhtml": "^5.0.9" - }, - "peerDependencies": { - "lit": "^3.3.1" - } - }, - "node_modules/@mariozechner/mini-lit/node_modules/highlight.js": { - "version": "11.11.1", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@mariozechner/mini-lit/node_modules/marked": { - "version": "16.4.2", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", "license": "Apache-2.0", "dependencies": { "ws": "^8.18.0", @@ -1707,254 +1546,10 @@ "zod-to-json-schema": "^3.25.0" } }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.100", - "license": "MIT", - "optional": true, - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.100", - "@napi-rs/canvas-darwin-arm64": "0.1.100", - "@napi-rs/canvas-darwin-x64": "0.1.100", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", - "@napi-rs/canvas-linux-arm64-musl": "0.1.100", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-musl": "0.1.100", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", - "@napi-rs/canvas-win32-x64-msvc": "0.1.100" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", - "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.100", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", - "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", - "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", - "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", - "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", - "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", - "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", - "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", - "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", - "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", "funding": [ { "type": "github", @@ -1965,6 +1560,8 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -1977,6 +1574,8 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -1985,6 +1584,8 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1995,386 +1596,94 @@ "node": ">= 8" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14" } }, "node_modules/@pondwader/socks5-server": { "version": "1.0.10", - "dev": true, + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", "license": "MIT" }, - "node_modules/@preact/signals-core": { - "version": "1.14.1", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.1", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2382,12 +1691,13 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2395,10 +1705,13 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2406,12 +1719,13 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2419,12 +1733,13 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2432,12 +1747,13 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2445,12 +1761,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2458,12 +1775,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2471,12 +1789,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2484,12 +1803,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2497,12 +1817,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2510,12 +1831,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2523,12 +1845,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2536,12 +1859,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2549,12 +1873,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2562,12 +1887,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2575,12 +1901,13 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2588,12 +1915,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2601,12 +1929,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2614,12 +1943,13 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2627,12 +1957,13 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2640,12 +1971,13 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2653,12 +1985,13 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2666,12 +1999,13 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2679,12 +2013,13 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2693,36 +2028,18 @@ }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", "license": "Apache-2.0" }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.17", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/core": { - "version": "3.23.17", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2730,73 +2047,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.14", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.14", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2804,37 +2061,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.17", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2842,173 +2075,25 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.32", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-middleware": "^4.2.14", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.5.7", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.3.1", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.6", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.20", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "4.6.1", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/querystring-builder": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.3.1", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.9", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -3016,32 +2101,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.14", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.13", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.17", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.25", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -3049,51 +2115,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3103,497 +2127,35 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", + "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.49", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.54", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.17", - "@smithy/credential-provider-imds": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.4.2", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.14", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.14", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.3.8", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.3.1", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.25", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@tailwindcss/cli": { - "version": "4.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.5.1", - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "enhanced-resolve": "^5.19.0", - "mri": "^1.2.0", - "picocolors": "^1.1.1", - "tailwindcss": "4.2.4" - }, - "bin": { - "tailwindcss": "dist/index.mjs" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" + "node": ">=14.0.0" } }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "license": "MIT" - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -3601,50 +2163,70 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/diff": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/hosted-git-info": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hosted-git-info/-/hosted-git-info-3.0.5.tgz", + "integrity": "sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==", "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { "version": "4.17.24", - "dev": true, + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, "node_modules/@types/lodash-es": { "version": "4.17.12", - "dev": true, + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", "dependencies": { "@types/lodash": "*" } }, - "node_modules/@types/mime-types": { - "version": "2.1.4", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.17", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3652,6 +2234,8 @@ }, "node_modules/@types/proper-lockfile": { "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3660,23 +2244,15 @@ }, "node_modules/@types/retry": { "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "license": "MIT" - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3694,6 +2270,8 @@ }, "node_modules/@typescript/native-preview-darwin-arm64": { "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-r3pWFuR2H7mn6ScwpH5jJljKQqKto0npVuJSk6pRwFwexpTyxOGmJTZJ1V0AWiisaNxU2+CNAqWFJSJYIE/QTg==", "cpu": [ "arm64" ], @@ -3788,8 +2366,44 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { @@ -3805,6 +2419,8 @@ }, "node_modules/@vitest/mocker": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3830,6 +2446,8 @@ }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -3841,6 +2459,8 @@ }, "node_modules/@vitest/runner": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3854,6 +2474,8 @@ }, "node_modules/@vitest/snapshot": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3867,6 +2489,8 @@ }, "node_modules/@vitest/spy": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { @@ -3878,6 +2502,8 @@ }, "node_modules/@vitest/utils": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { @@ -3889,46 +2515,44 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webreflection/alien-signals": { - "version": "0.3.2", - "license": "MIT", - "dependencies": { - "alien-signals": "^2.0.6" - } - }, "node_modules/@xterm/headless": { "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", + "integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==", "dev": true, "license": "MIT" }, "node_modules/@xterm/xterm": { "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "dev": true, "license": "MIT" }, "node_modules/agent-base": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" } }, - "node_modules/alien-signals": { - "version": "2.0.8", - "license": "MIT" - }, "node_modules/ansi-regex": { - "version": "6.2.2", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3940,30 +2564,32 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.13.4", + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, "node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -3971,6 +2597,8 @@ }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -3987,15 +2615,10 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -4003,6 +2626,8 @@ }, "node_modules/bl": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", "dependencies": { @@ -4013,10 +2638,14 @@ }, "node_modules/bowser": { "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4027,6 +2656,8 @@ }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -4038,6 +2669,8 @@ }, "node_modules/buffer": { "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -4059,19 +2692,16 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, "node_modules/cac": { "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { @@ -4080,6 +2710,8 @@ }, "node_modules/canvas": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4093,6 +2725,8 @@ }, "node_modules/chai": { "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { @@ -4108,6 +2742,8 @@ }, "node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4118,6 +2754,8 @@ }, "node_modules/check-error": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -4126,97 +2764,16 @@ }, "node_modules/chownr": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true, "license": "ISC" }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4227,151 +2784,38 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "12.1.0", - "dev": true, + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/cliui": { - "version": "8.0.1", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/concurrently/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/concurrently/node_modules/yargs": { - "version": "17.7.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/concurrently/node_modules/yargs-parser": { - "version": "21.1.1", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "license": "MIT" - }, "node_modules/cross-spawn": { - "version": "6.0.6", - "dev": true, + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=4.8" - } - }, - "node_modules/cross-spawn/node_modules/semver": { - "version": "5.7.2", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" + "node": ">= 8" } }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { "node": ">= 12" @@ -4379,6 +2823,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4394,6 +2840,8 @@ }, "node_modules/decompress-response": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4408,6 +2856,8 @@ }, "node_modules/deep-eql": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", "engines": { @@ -4416,26 +2866,19 @@ }, "node_modules/deep-extend": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "license": "MIT", "engines": { "node": ">=4.0.0" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/detect-libc": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4443,20 +2886,24 @@ }, "node_modules/diff": { "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, - "node_modules/docx-preview": { - "version": "0.3.7", - "license": "Apache-2.0", - "dependencies": { - "jszip": ">=3.0.0" - } + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -4464,28 +2911,25 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.21.0", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -4494,11 +2938,16 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4508,95 +2957,48 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/execa": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", "dev": true, "license": "MIT", "dependencies": { @@ -4612,19 +3014,83 @@ "node": ">=6" } }, - "node_modules/execa/node_modules/get-stream": { - "version": "4.1.0", + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { - "pump": "^3.0.0" + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" }, "engines": { - "node": ">=6" + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" } }, "node_modules/expand-template": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "dev": true, "license": "(MIT OR WTFPL)", "engines": { @@ -4633,6 +3099,8 @@ }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4641,28 +3109,14 @@ }, "node_modules/extend": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-glob": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -4677,7 +3131,9 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.1.9", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -4686,11 +3142,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.7.2", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -4700,7 +3159,7 @@ "license": "MIT", "dependencies": { "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", + "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -4710,21 +3169,18 @@ }, "node_modules/fastq": { "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { "type": "github", @@ -4744,24 +3200,10 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/file-type": { - "version": "21.3.4", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -4771,8 +3213,40 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -4783,6 +3257,8 @@ }, "node_modules/fs-constants": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, "license": "MIT" }, @@ -4790,6 +3266,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4802,6 +3279,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -4810,6 +3289,8 @@ }, "node_modules/gaxios": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -4822,6 +3303,8 @@ }, "node_modules/gcp-metadata": { "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -4832,15 +3315,10 @@ "node": ">=18" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { - "version": "1.5.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -4850,55 +3328,29 @@ } }, "node_modules/get-stream": { - "version": "5.2.0", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "devOptional": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "license": "MIT", - "engines": { - "node": ">= 14" + "node": ">=6" } }, "node_modules/github-from-package": { "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "dev": true, "license": "MIT" }, "node_modules/glob": { "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -4914,6 +3366,8 @@ }, "node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -4925,6 +3379,8 @@ }, "node_modules/google-auth-library": { "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -4940,6 +3396,8 @@ }, "node_modules/google-logging-utils": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -4947,10 +3405,15 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4958,6 +3421,8 @@ }, "node_modules/hasown": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4969,6 +3434,8 @@ }, "node_modules/highlight.js": { "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", "license": "BSD-3-Clause", "engines": { "node": "*" @@ -4976,6 +3443,8 @@ }, "node_modules/hosted-git-info": { "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "license": "ISC", "dependencies": { "lru-cache": "^11.1.0" @@ -4984,12 +3453,17 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/html-parse-string": { - "version": "0.0.9", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, "license": "MIT" }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -5001,6 +3475,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5012,6 +3488,8 @@ }, "node_modules/husky": { "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", "bin": { @@ -5026,6 +3504,9 @@ }, "node_modules/ieee754": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -5044,41 +3525,41 @@ }, "node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "license": "MIT" - }, "node_modules/inherits": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, "node_modules/interpret": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-core-module": { "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -5093,6 +3574,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -5101,6 +3584,9 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5108,6 +3594,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -5119,6 +3607,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -5127,35 +3617,123 @@ }, "node_modules/is-stream": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { - "version": "9.0.1", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/json-bigint": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -5163,6 +3741,8 @@ }, "node_modules/json-schema-to-ts": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -5172,49 +3752,10 @@ "node": ">=16" } }, - "node_modules/jsonschema": { - "version": "1.5.0", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/jszip/node_modules/safe-buffer": { - "version": "5.1.2", - "license": "MIT" - }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/jwa": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -5224,354 +3765,84 @@ }, "node_modules/jws": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, - "node_modules/katex": { - "version": "0.16.45", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/koffi": { - "version": "2.16.2", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lit": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "@lit/reactive-element": "^2.1.0", - "lit-element": "^4.2.0", - "lit-html": "^3.3.0" - } - }, - "node_modules/lit-element": { - "version": "4.2.2", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0", - "@lit/reactive-element": "^2.1.0", - "lit-html": "^3.3.0" - } - }, - "node_modules/lit-html": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, "node_modules/lodash-es": { "version": "4.18.1", - "dev": true, + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/long": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/loupe": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.6", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/lucide": { - "version": "0.544.0", - "license": "ISC" - }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -5582,6 +3853,8 @@ }, "node_modules/merge2": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -5590,6 +3863,8 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -5600,29 +3875,10 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mimic-response": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, "license": "MIT", "engines": { @@ -5634,6 +3890,8 @@ }, "node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -5647,6 +3905,8 @@ }, "node_modules/minimist": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", "funding": { @@ -5655,6 +3915,8 @@ }, "node_modules/minipass": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -5662,32 +3924,22 @@ }, "node_modules/mkdirp-classic": { "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true, "license": "MIT" }, - "node_modules/mri": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, "funding": [ { "type": "github", @@ -5704,23 +3956,22 @@ }, "node_modules/napi-build-utils": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nice-try": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true, "license": "MIT" }, "node_modules/node-abi": { "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5732,11 +3983,16 @@ }, "node_modules/node-addon-api": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT" }, "node_modules/node-domexception": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "funding": [ { "type": "github", @@ -5754,6 +4010,8 @@ }, "node_modules/node-fetch": { "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -5770,6 +4028,8 @@ }, "node_modules/npm-run-path": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", "dev": true, "license": "MIT", "dependencies": { @@ -5779,22 +4039,21 @@ "node": ">=4" } }, - "node_modules/object-assign": { - "version": "4.1.1", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ollama": { - "version": "0.6.3", - "license": "MIT", - "dependencies": { - "whatwg-fetch": "^3.6.20" + "node": ">=4" } }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5802,6 +4061,8 @@ }, "node_modules/openai": { "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -5821,6 +4082,8 @@ }, "node_modules/p-finally": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", "dev": true, "license": "MIT", "engines": { @@ -5829,6 +4092,8 @@ }, "node_modules/p-retry": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", "license": "MIT", "dependencies": { "@types/retry": "0.12.0", @@ -5840,61 +4105,27 @@ }, "node_modules/p-retry/node_modules/@types/retry": { "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse5": { - "version": "5.1.1", - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "license": "MIT" + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" }, "node_modules/partial-json": { "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, "node_modules/path-expression-matcher": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -5907,20 +4138,25 @@ } }, "node_modules/path-key": { - "version": "2.0.1", - "dev": true, + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -5935,31 +4171,21 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 14.16" } }, - "node_modules/pdfjs-dist": { - "version": "5.4.394", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.81" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "license": "MIT" - }, "node_modules/pi-extension-custom-provider-anthropic": { "resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic", "link": true @@ -5976,16 +4202,17 @@ "resolved": "packages/coding-agent/examples/extensions/with-deps", "link": true }, - "node_modules/pi-web-ui-example": { - "resolved": "packages/web-ui/example", - "link": true - }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5997,6 +4224,9 @@ }, "node_modules/postcss": { "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6023,6 +4253,9 @@ }, "node_modules/prebuild-install": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "dev": true, "license": "MIT", "dependencies": { @@ -6046,12 +4279,10 @@ "node": ">=10" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "license": "MIT" - }, "node_modules/proper-lockfile": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -6061,13 +4292,17 @@ }, "node_modules/proper-lockfile/node_modules/retry": { "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/protobufjs": { - "version": "7.5.6", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6075,9 +4310,9 @@ "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -6088,36 +4323,11 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -6126,6 +4336,8 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -6145,6 +4357,8 @@ }, "node_modules/rc": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { @@ -6159,6 +4373,8 @@ }, "node_modules/readable-stream": { "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "dependencies": { @@ -6172,6 +4388,8 @@ }, "node_modules/rechoir": { "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", "dev": true, "dependencies": { "resolve": "^1.1.6" @@ -6180,15 +4398,10 @@ "node": ">= 0.10" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { @@ -6207,16 +4420,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/retry": { "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", "engines": { "node": ">= 4" @@ -6224,6 +4431,8 @@ }, "node_modules/reusify": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -6232,7 +4441,10 @@ } }, "node_modules/rollup": { - "version": "4.60.3", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -6245,40 +4457,45 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, "node_modules/rollup/node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/run-parallel": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -6299,16 +4516,10 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -6326,7 +4537,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -6336,32 +4549,31 @@ "node": ">=10" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "license": "MIT" - }, "node_modules/shebang-command": { - "version": "1.2.0", - "dev": true, + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "shebang-regex": "^1.0.0" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/shebang-regex": { - "version": "1.0.0", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/shell-quote": { "version": "1.8.3", - "dev": true, + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6372,6 +4584,8 @@ }, "node_modules/shelljs": { "version": "0.9.2", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.9.2.tgz", + "integrity": "sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6389,6 +4603,8 @@ }, "node_modules/shx": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/shx/-/shx-0.4.0.tgz", + "integrity": "sha512-Z0KixSIlGPpijKgcH6oCMCbltPImvaKy0sGH8AkLRXw1KyzpKtaCTizP2xen+hNDqVF4xxgvA0KXSb9o4Q6hnA==", "dev": true, "license": "MIT", "dependencies": { @@ -6404,15 +4620,21 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/simple-concat": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", "dev": true, "funding": [ { @@ -6432,6 +4654,8 @@ }, "node_modules/simple-get": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", "dev": true, "funding": [ { @@ -6454,48 +4678,11 @@ "simple-concat": "^1.0.0" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.8", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6503,16 +4690,22 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", "dependencies": { @@ -6521,6 +4714,9 @@ }, "node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6531,15 +4727,27 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.1", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { "node": ">=8" } }, - "node_modules/string-width/node_modules/strip-ansi": { + "node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6548,21 +4756,24 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/strip-eof": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", "dev": true, "license": "MIT", "engines": { @@ -6571,6 +4782,8 @@ }, "node_modules/strip-json-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", "engines": { @@ -6579,6 +4792,8 @@ }, "node_modules/strip-literal": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6588,8 +4803,17 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strnum": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -6598,36 +4822,10 @@ ], "license": "MIT" }, - "node_modules/strtok3": { - "version": "10.3.5", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { @@ -6637,50 +4835,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwind-merge": { - "version": "3.5.0", - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwind-variants": { - "version": "3.2.2", - "license": "MIT", - "engines": { - "node": ">=16.x", - "pnpm": ">=7.x" - }, - "peerDependencies": { - "tailwind-merge": ">=3.0.0", - "tailwindcss": "*" - }, - "peerDependenciesMeta": { - "tailwind-merge": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "4.2.4", - "license": "MIT", - "peer": true - }, - "node_modules/tapable": { - "version": "2.3.3", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar-fs": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6692,6 +4850,8 @@ }, "node_modules/tar-stream": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6705,35 +4865,119 @@ "node": ">=6" } }, - "node_modules/thenify": { - "version": "3.3.1", - "license": "MIT", + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=0.8" + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6748,6 +4992,9 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6763,8 +5010,10 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6774,6 +5023,8 @@ }, "node_modules/tinypool": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -6782,6 +5033,8 @@ }, "node_modules/tinyrainbow": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -6790,6 +5043,8 @@ }, "node_modules/tinyspy": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -6798,6 +5053,8 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6807,46 +5064,26 @@ "node": ">=8.0" } }, - "node_modules/token-types": { - "version": "6.1.2", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-algebra": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "devOptional": true, + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", + "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -6860,6 +5097,8 @@ }, "node_modules/tunnel-agent": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6871,10 +5110,14 @@ }, "node_modules/typebox": { "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "license": "MIT" }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6885,53 +5128,34 @@ "node": ">=14.17" } }, - "node_modules/uhtml": { - "version": "5.0.9", - "license": "MIT", - "dependencies": { - "@webreflection/alien-signals": "^0.3.2" - } - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/undici": { - "version": "7.25.0", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "14.0.0", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/vite": { "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7003,6 +5227,8 @@ }, "node_modules/vite-node": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { @@ -7022,8 +5248,495 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7039,8 +5752,10 @@ }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7050,6 +5765,8 @@ }, "node_modules/vitest": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { @@ -7121,6 +5838,8 @@ }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -7132,28 +5851,32 @@ }, "node_modules/web-streams-polyfill": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { "node": ">= 8" } }, - "node_modules/whatwg-fetch": { - "version": "3.6.20", - "license": "MIT" - }, "node_modules/which": { - "version": "1.3.1", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, "bin": { - "which": "bin/which" + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -7167,8 +5890,12 @@ "node": ">=8" } }, - "node_modules/wrap-ansi": { + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7182,29 +5909,17 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7222,27 +5937,25 @@ } } }, - "node_modules/xlsx": { - "version": "0.20.3", - "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", - "license": "Apache-2.0", - "bin": { - "xlsx": "bin/xlsx.njs" - }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "engines": { - "node": ">=0.8" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "license": "ISC", - "engines": { - "node": ">=10" + "node": ">=16.0.0" } }, "node_modules/yaml": { - "version": "2.8.4", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -7254,47 +5967,19 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yargs": { - "version": "16.2.0", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/zod": { - "version": "4.4.3", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" @@ -7302,25 +5987,28 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.74.0", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.74.0", - "ignore": "^7.0.5", - "typebox": "^1.1.24", - "yaml": "^2.8.2" + "@earendil-works/pi-ai": "^0.75.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" }, "devDependencies": { - "@types/node": "^24.3.0", - "typescript": "^5.7.3", - "vitest": "^3.2.4" + "@types/node": "24.12.4", + "@vitest/coverage-v8": "3.2.4", + "typescript": "5.9.3", + "vitest": "3.2.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" } }, "packages/agent/node_modules/@types/node": { - "version": "24.12.2", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", "dependencies": { @@ -7329,40 +6017,42 @@ }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.74.0", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "chalk": "^5.6.2", + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" + "partial-json": "0.1.7", + "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" }, "devDependencies": { - "@types/node": "^24.3.0", - "canvas": "^3.2.0", - "vitest": "^3.2.4" + "@types/node": "24.12.4", + "canvas": "3.2.3", + "vitest": "3.2.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" } }, "packages/ai/node_modules/@types/node": { - "version": "24.12.2", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", "dependencies": { @@ -7371,154 +6061,96 @@ }, "packages/ai/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.74.0", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.74.0", - "@earendil-works/pi-ai": "^0.74.0", - "@earendil-works/pi-tui": "^0.74.0", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", - "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", - "glob": "^13.0.1", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "jiti": "^2.7.0", - "marked": "^15.0.12", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "uuid": "^14.0.0", - "yaml": "^2.8.2" + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" }, "bin": { "pi": "dist/cli.js" }, "devDependencies": { - "@types/diff": "^7.0.2", - "@types/hosted-git-info": "^3.0.5", - "@types/ms": "^2.1.0", - "@types/node": "^24.3.0", - "@types/proper-lockfile": "^4.1.4", - "shx": "^0.4.0", - "typescript": "^5.7.3", - "vitest": "^3.2.4" + "@types/cross-spawn": "6.0.6", + "@types/diff": "7.0.2", + "@types/hosted-git-info": "3.0.5", + "@types/ms": "2.1.0", + "@types/node": "24.12.4", + "@types/proper-lockfile": "4.1.4", + "shx": "0.4.0", + "typescript": "5.9.3", + "vitest": "3.2.4" }, "engines": { - "node": ">=20.6.0" + "node": ">=22.19.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.5" + "@mariozechner/clipboard": "0.3.6" } }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.74.0", + "version": "0.75.4", "dependencies": { - "@anthropic-ai/sdk": "^0.52.0" + "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.74.0" + "version": "0.75.4" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.4.0", + "version": "1.5.4", "dependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.26" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.26", - "license": "Apache-2.0", - "dependencies": { - "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", - "commander": "^12.1.0", - "lodash-es": "^4.17.21", - "shell-quote": "^1.8.3", - "zod": "^3.24.1" - }, - "bin": { - "srt": "dist/cli.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@pondwader/socks5-server": { - "version": "1.0.10", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@types/lodash": { - "version": "4.17.23", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/@types/lodash-es": { - "version": "4.17.12", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/commander": { - "version": "12.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/lodash-es": { - "version": "4.18.1", - "license": "MIT" - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/shell-quote": { - "version": "1.8.3", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "packages/coding-agent/examples/extensions/sandbox/node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.74.0", + "version": "0.75.4", "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.3" }, "devDependencies": { - "@types/ms": "^2.1.0" + "@types/ms": "2.1.0" } }, "packages/coding-agent/node_modules/@anthropic-ai/sdk": { "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", "license": "MIT", "bin": { "anthropic-ai-sdk": "bin/cli" } }, "packages/coding-agent/node_modules/@types/node": { - "version": "24.12.2", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", "dependencies": { @@ -7527,72 +6159,26 @@ }, "packages/coding-agent/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.74.0", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1" + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" }, "devDependencies": { - "@xterm/headless": "^5.5.0", - "@xterm/xterm": "^5.5.0" + "@xterm/headless": "5.5.0", + "@xterm/xterm": "5.5.0", + "chalk": "5.6.2" }, "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" - } - }, - "packages/web-ui": { - "name": "@earendil-works/pi-web-ui", - "version": "0.74.0", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.74.0", - "@earendil-works/pi-tui": "^0.74.0", - "@lmstudio/sdk": "^1.5.0", - "docx-preview": "^0.3.7", - "jszip": "^3.10.1", - "lucide": "^0.544.0", - "ollama": "^0.6.0", - "pdfjs-dist": "5.4.394", - "typebox": "^1.1.24", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" - }, - "devDependencies": { - "@mariozechner/mini-lit": "^0.2.0", - "@tailwindcss/cli": "^4.0.0-beta.14", - "concurrently": "^9.2.1", - "typescript": "^5.7.3" - }, - "peerDependencies": { - "@mariozechner/mini-lit": "^0.2.0", - "lit": "^3.3.1" - } - }, - "packages/web-ui/example": { - "name": "pi-web-ui-example", - "version": "0.74.0", - "dependencies": { - "@earendil-works/pi-ai": "file:../../ai", - "@earendil-works/pi-web-ui": "file:../", - "@mariozechner/mini-lit": "^0.2.0", - "@tailwindcss/vite": "^4.1.17", - "lit": "^3.3.1", - "lucide": "^0.544.0" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vite": "^7.1.6" + "node": ">=22.19.0" } } } diff --git a/package.json b/package.json index 2b5b8cae..7a62003d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "type": "module", "workspaces": [ "packages/*", - "packages/web-ui/example", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", @@ -12,46 +11,45 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build", - "dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"", - "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"", - "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", + "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", + "check:pinned-deps": "node scripts/check-pinned-deps.mjs", + "check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check", + "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", - "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", - "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", - "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", + "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", + "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", + "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", "version:set": "npm version -ws", "prepublishOnly": "npm run clean && npm run build && npm run check", "publish": "npm run prepublishOnly && npm publish -ws --access public", "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "release:local": "node scripts/local-release.mjs", + "shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs", "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", "prepare": "husky" }, "devDependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.26", + "@anthropic-ai/sandbox-runtime": "0.0.26", "@biomejs/biome": "2.3.5", - "@types/node": "^22.10.5", + "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "concurrently": "^9.2.1", - "husky": "^9.1.7", - "jiti": "^2.7.0", - "tsx": "^4.20.3", - "typescript": "^5.9.2", - "shx": "^0.4.0" + "esbuild": "0.28.0", + "husky": "9.1.7", + "jiti": "2.7.0", + "shx": "0.4.0", + "tsx": "4.22.1", + "typescript": "5.9.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" }, "version": "0.0.3", - "dependencies": { - "@earendil-works/pi-coding-agent": "^0.30.2", - "get-east-asian-width": "^1.4.0" - }, "overrides": { "rimraf": "6.1.2", "gaxios": { diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bcd8f292..4ec02d9a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,33 @@ ## [Unreleased] +## [0.75.4] - 2026-05-20 + +### Changed + +- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks. +- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources. + +### Fixed + +- Fixed tool-call preflight to stop preparing sibling tool calls after the run is aborted ([#4276](https://github.com/earendil-works/pi/issues/4276)). +- Fixed tail truncation for oversized single-line output that ends with a trailing newline ([#4715](https://github.com/earendil-works/pi/issues/4715)). +- Fixed Windows Node execution environment command spawns to hide helper console windows from background processes ([#4699](https://github.com/earendil-works/pi/issues/4699)). + +## [0.75.3] - 2026-05-18 + +## [0.75.2] - 2026-05-18 + +## [0.75.1] - 2026-05-18 + +## [0.75.0] - 2026-05-17 + +### Breaking Changes + +- Raised the minimum supported Node.js version to 22.19.0. + +## [0.74.1] - 2026-05-16 + ## [0.74.0] - 2026-05-07 ## [0.73.1] - 2026-05-07 diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index c88cf7aa..65828889 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -1,6 +1,6 @@ # AgentHarness lifecycle -`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics. +`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics. This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly. @@ -15,9 +15,22 @@ The intended rule is: - runtime config setters update future snapshots without mutating the current provider request - session writes made while busy are durably queued and flushed in deterministic order - getters return latest harness config, not in-flight snapshots +- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead. + +`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement. A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite. +## Error handling + +The current split is: + +- low-level capabilities and helpers use `Result` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers +- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored +- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause` + +Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`. + ## State model The harness separates state into four categories. @@ -31,6 +44,7 @@ Harness config is the latest runtime configuration set by the application or ext - tools - active tool names - resources +- stream options - system prompt or system prompt provider Getters return harness config. They do not return the snapshot used by an in-flight provider request. @@ -52,15 +66,21 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c - thinking level - all tools - active tools +- stream options +- derived session id Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot. Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied. +Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot. + ### Session The session contains persisted entries only. Session reads return persisted state and do not include queued writes. +Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry. + ### Pending session writes Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`). @@ -85,7 +105,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase - `compact` - `navigateTree` -Starting another structural operation while the harness is not idle throws. +Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`. The following operations are allowed during a turn where appropriate: @@ -112,8 +132,8 @@ Phase/settlement semantics are still provisional and need a full lifecycle pass. Queue modes are live, not turn-snapshotted: -- `steeringMode` -- `followUpMode` +- `getSteeringMode()` / `setSteeringMode()` +- `getFollowUpMode()` / `setFollowUpMode()` Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points. @@ -125,9 +145,9 @@ At a save point the harness: 1. flushes pending session writes after the agent-emitted messages for that turn 2. creates a fresh turn snapshot if the low-level loop may continue -3. applies the fresh context/model/thinking-level state before the next provider request +3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request -This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points. +This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points. The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary: @@ -136,21 +156,26 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review. -If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message. +If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message. ## Hooks and events -Current hooks receive only the event payload. There is no extension context object yet. +The target hook system is described in [hooks.md](./hooks.md). -Event payloads describe what is happening. Harness getters describe latest config for future snapshots. +Summary: -The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events. +- `AgentHarness` emits typed hook events and consumes typed results. +- A single hooks implementation owns registration, cleanup, provenance, and result reducers. +- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result. +- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events. +- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types. +- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes. -A future extension context may expose the harness and a queued-write session facade. +Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing. ## Planned session facade -Extensions should eventually interact with a harness-scoped session facade rather than the raw session. +Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes. Planned read semantics: @@ -172,7 +197,9 @@ Agent-emitted messages are persisted on `message_end` to preserve transcript ord ## Abort -Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues. +Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues. + +Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn. Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup. @@ -188,17 +215,271 @@ Branch summary generation is part of the tree navigation operation. Auto-compaction and retry decision points are not implemented in `AgentHarness` yet. -## Final lifecycle hardening todo +## Test organization -Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event: +Harness tests should stay focused by area instead of growing one large catch-all file. -- runtime config setters from low-level lifecycle events and harness events -- resource/tool/model/thinking updates during active turns and save points -- session writes from listeners and hooks, including writes from `settled` -- queue operations from turn events, tool events, and provider hooks -- rejected structural operations while busy -- abort from listeners/hooks -- getter behavior during active operations -- deterministic ordering of agent-emitted messages and pending listener writes -- no deadlocks when async listeners call harness APIs and await them -- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation +Current structure: + +- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior. +- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics. + +Preferred future structure: + +- `agent-harness-resources.test.ts`: resource snapshot/loading semantics. +- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events. +- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior. + +Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access. + +Harness coverage is configured separately from the default package test run: + +```bash +npm run test:harness +npm run coverage:harness +``` + +`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage. + +## Implementation todo + +This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom. + +### 1. Add explicit tool registry read/update semantics + +Status: In progress + +Done: + +- Added `setTools(tools, activeToolNames?)`. +- Added `setActiveTools(toolNames)`. +- Invalid active tool names reject with `AgentHarnessError`. +- Added generic app tool shape via `AgentHarness`. +- Exported `QueueMode` from core types. +- Added `AgentHarnessOptions.steeringMode` and `followUpMode`. +- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. + +Remaining: + +- Add `getTools()` semantics. +- Add `getActiveTools()` semantics. +- Decide and implement tool update observability events. +- Include active-tool-only updates in the runtime config observability plan. + +Notes: + +- Observability design: [observability.md](./observability.md) + +### 2. Design per-`AgentHarness` model registry + +Status: Planned + +Done: + +- Current `setModel()` behavior is preserved. + +Remaining: + +- Decide how applications supply the model registry. +- Decide whether the harness stores concrete `Model` objects, model references, or both. +- Validate model selection against the registry. +- Define model change semantics during active turns and save points. + +### 3. Full `AgentHarness` lifecycle/state pass + +Status: In progress + +Done: + +- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`. +- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`. +- Added explicit `phase` in place of boolean idle state. +- Save points refresh context, model, thinking level, stream options, and session snapshot state. +- Pending session writes use session-entry shapes without generated fields. +- Pending session writes flush at save points, settlement, and failure cleanup. +- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images. +- `nextTurn` messages are inserted before the new user prompt. +- Structural compaction/tree operations restore phase with `finally`. +- Public harness failures normalize subsystem causes to `AgentHarnessError`. +- Pending session writes flush one-by-one and are not dropped on failure. +- Queue drains roll back if queue-update notification fails. +- `message_end` persistence happens before subscriber notification. +- `abort()` signals cancellation before notifications and still waits for idle through notification errors. +- Idle model/thinking/tool updates validate and persist before committing in-memory state. +- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen. + +Remaining: + +- Finalize phase/idle semantics. +- Audit whether `settled` can fire too early. +- Make session writes inside `settled` callbacks deterministic. +- Audit follow-up behavior around `agent_end`. +- Implement auto-compaction decision point. +- Implement retry handling. +- Verify `before_agent_start` hook semantics against coding-agent. +- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets. +- Document or change runtime config event timing while busy. +- Audit `abort()` barrier semantics. + +### 4. Implement generic hook/event extension mechanism + +Status: Designed in [hooks.md](./hooks.md), not implemented + +Done: + +- Removed `AgentHarnessContext`. +- Hooks receive only event payloads. +- `emitHook(event)` derives the hook type from `event.type`. +- Provider request/payload hooks have ordered transform semantics. + +Remaining: + +- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation. +- Move result chaining out of `AgentHarness` into reducer functions. +- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics. +- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks. +- Define the initial harness/context facades exposed through hook context. +- Preserve current provider hook behavior, including stream option patch deletion semantics. +- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage. + +Notes: + +- Hook design: [hooks.md](./hooks.md) + +### 5. Spike semi-durable harness/session recovery + +Status: Planned + +Done: + +- Wrote durability design: [durable-harness.md](./durable-harness.md) + +Remaining: + +- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs. +- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls. +- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers. +- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation. +- Prototype reducer-based recovery from session entries. +- Decide whether interrupted operations append user-visible messages or only internal operation entries. + +Notes: + +- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted. +- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior. + +### 6. Final lifecycle hardening suite + +Status: Planned + +Done: + +- None. + +Remaining: + +- Add broad listener/hook reentrancy tests across relevant events. +- Test runtime config setters from low-level lifecycle events and harness events. +- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options. +- Test resource/tool/model/thinking/stream-option updates during active turns and save points. +- Test session writes from listeners and hooks, including `settled` writes. +- Test queue operations from turn events, tool events, and provider hooks. +- Test rejected structural operations while busy. +- Test abort from listeners/hooks. +- Test getter behavior during active operations. +- Test deterministic ordering of agent-emitted messages and pending listener writes. +- Test no deadlocks when async listeners call harness APIs and await them. +- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation. + +### 7. Later coding-agent migration plan + +Status: Planned + +Done: + +- None. + +Remaining: + +- Map coding-agent resources to sourced loaders. +- Keep app-level resource dedupe/provenance outside the harness. +- Adapt extension loading to the future hook/session facade. +- Preserve UI/session behavior outside core. +- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks. + +--- + +## Completed implementation todo + +### 8. Remove `Agent` dependency from `AgentHarness` + +Status: Done + +Done: + +- `AgentHarness` calls `runAgentLoop()` directly. +- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots. +- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping. + +Remaining: + +- None. + +Notes: + +- Broader listener/hook reentrancy coverage is tracked in item 6. + +### 9. Finish curated provider/stream configuration + +Status: Done + +Done: + +- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`. +- Stream options, headers, metadata, and derived session id are snapshotted per turn. +- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop. +- `getApiKeyAndHeaders()` resolves credentials per provider request. +- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented. +- Stream option patching supports explicit field deletion and ordered hook chaining. +- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior. + +Remaining: + +- None. + +### 10. Complete low-level `Result` cleanup + +Status: Done + +Done: + +- Added generic `Result` plus helpers. +- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. +- Split filesystem and shell capabilities. +- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. +- Added `ExecutionEnv.appendFile()` for streaming append use cases. +- Updated skill and prompt-template loaders to consume `ExecutionEnv` results. +- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`. +- Removed `NodeExecutionEnv` from browser-safe root exports. +- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. +- Converted compaction and branch-summary helpers to typed result returns. +- Added `readTextLines()` so JSONL metadata loading reads only the header line. +- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful. +- Mapped filesystem errors crossing the session boundary to typed `SessionError`. +- Added typed branch-summary errors and cause-aware public harness error normalization. +- Resource loaders report structured diagnostics for non-`not_found` filesystem failures. +- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill. + +Remaining: + +- None. + +Notes: + +- Keep low-level capability/helper APIs non-throwing where they return `Result`. +- Keep session storage/repo/session APIs throwing typed `SessionError`. +- Keep public structural harness failures normalized to `AgentHarnessError`. +- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points. +- Audit generic harness utilities for Node globals as APIs are added. +- Audit package exports so browser/generic imports do not pull Node-only modules. +- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve. diff --git a/packages/agent/docs/durable-harness.md b/packages/agent/docs/durable-harness.md new file mode 100644 index 00000000..64e313df --- /dev/null +++ b/packages/agent/docs/durable-harness.md @@ -0,0 +1,181 @@ +# Durable AgentHarness and session design + + + +Durable AgentHarness / session design notes. + +## Framing + +A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app: + +- tool implementations +- model/auth providers +- extensions and hook handlers +- resource loaders +- system-prompt callbacks/modifiers + +The practical target is a semi-durable harness: + +- session is the durable append-only state tree +- harness persists the state it owns into session entries +- the host app is responsible for recreating compatible non-persistable dependencies on resume +- recovery restarts from durable boundaries, not from an in-flight provider stream + +## Session owns durable state + +Treat session as all durable agent state, not just transcript history. + +Existing session state already includes harness state: + +- model changes +- thinking-level changes +- leaf entries +- labels +- compactions and branch summaries +- custom messages and custom entries + +That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference. + +## What the app must provide on resume + +The app must recreate compatible runtime dependencies: + +- model registry / model objects +- tool registry +- extension set, versions, and ordering +- resource loaders +- system prompt providers/hooks +- auth providers +- app-specific hooks + +Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself. + +## What harness should persist + +Minimum useful durability entries: + +- queued steer/followUp/nextTurn messages +- queue consumption tied to a turn +- pending session writes accepted during active operations +- pending write application status +- operation start/finish/interruption +- turn start/finish +- provider request start/finish, if needed for recovery diagnostics +- tool call start/finish, if we want safe tool recovery + +Potential entries: + +```ts +type DurableHarnessEntry = + | QueueEnqueuedEntry + | QueueConsumedEntry + | PendingWriteEnqueuedEntry + | PendingWriteAppliedEntry + | OperationStartedEntry + | OperationFinishedEntry + | OperationInterruptedEntry + | TurnStartedEntry + | TurnFinishedEntry + | ProviderRequestStartedEntry + | ProviderRequestFinishedEntry + | ToolCallStartedEntry + | ToolCallFinishedEntry; +``` + +Every accepted mutation must be durable before the public API resolves. + +## Recovery model + +On startup: + +1. Host app registers tools/models/extensions/resources/auth/hooks. +2. Harness opens session. +3. Harness reduces session entries into: + - current leaf + - conversation branch + - harness config + - queues + - pending writes + - active operation/turn/tool state +4. Harness validates required runtime dependencies. +5. Harness reconciles unfinished operation state. + +Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted. + +## Recovery policies + +Default conservative policy: + +- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle +- unfinished provider request: mark interrupted; do not retry automatically +- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent +- unfinished compaction: rerun if no compaction entry exists +- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe + +Optional policy: + +```ts +recovery: "mark_interrupted" | "retry_unfinished" +``` + +`retry_unfinished` must be guarded around non-idempotent tool calls. + +## Critical scenarios + +### Queues + +- Crash before `queue_enqueued`: message was not accepted. +- Crash after `queue_enqueued`: message is restored. +- Crash after queue drain but before durable turn record: risk of loss/duplication. +- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed. + +### Pending writes + +- Crash before `pending_write_enqueued`: write was not accepted. +- Crash after enqueue before apply: recovery applies it. +- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied. + +### Agent loop turn + +- Crash before provider request: retry or mark interrupted. +- Crash during provider request: mark interrupted by default. +- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled. +- Crash after assistant message persisted: recover from durable message. + +### Tool calls + +- Crash after tool call starts but before result: external side effects may already have happened. +- Default recovery should not rerun non-idempotent tools. +- Tool calls need stable IDs and retry-safety metadata for automatic recovery. + +### Compaction + +- Crash before summary generation: rerun preparation/summary. +- Crash after generated summary but before compaction entry: rerun unless summary was journaled. +- Crash after compaction entry: operation is complete; append finish marker if missing. + +### Branch summary / tree navigation + +- Crash before summary: rerun or mark interrupted. +- Crash after summary entry before leaf entry: append missing leaf entry. +- Crash after leaf entry: operation is complete; append finish marker if missing. + +## Minimum viable spike + +1. Add durable queue entries. +2. Add durable pending write entries with deterministic target IDs. +3. Add operation start/finish/interrupted entries. +4. Add turn start with consumed queue IDs. +5. Recover by reducing the session log. +6. Mark unfinished agent turns interrupted by default. +7. Rerun unfinished compaction/tree operations only when no final entry exists. +8. Do not retry unfinished tool calls unless tool metadata says retry-safe. + +## Open questions + +- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs? +- Should resolved system prompt text be snapshotted per turn for audit/debug? +- Do we require strict dependency ID/version matching on resume? +- How much provider request data should be journaled? +- Should recovery append user-visible assistant interruption messages or only internal operation entries? +- Should storage support truncating a final partial JSONL line during recovery? diff --git a/packages/agent/docs/hooks.md b/packages/agent/docs/hooks.md new file mode 100644 index 00000000..de7230e3 --- /dev/null +++ b/packages/agent/docs/hooks.md @@ -0,0 +1,445 @@ +# AgentHarness hooks design + + + +Final design. + +## Core model + +Events carry their result type as a type-only phantom: + +```ts +declare const HookResult: unique symbol; + +interface HookEvent { + type: TType; + readonly [HookResult]?: TResult; +} + +type ResultOf = E extends { readonly [HookResult]?: infer R } ? R : void; + +type HookHandler = ( + event: E, + ctx: Ctx, + signal?: AbortSignal, +) => ResultOf | void | Promise | void>; + +type HookObserver = ( + event: E, + ctx: Ctx, + signal?: AbortSignal, +) => void | Promise; +``` + +Example: + +```ts +interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> { + type: "context"; + messages: AgentMessage[]; +} + +interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> { + type: "tool_call"; + toolName: string; + input: Record; +} + +interface MessageEndEvent extends HookEvent<"message_end"> { + type: "message_end"; + message: AgentMessage; +} +``` + +No result map. No spec table. The event type defines its own result. + +## Hooks interface + +```ts +interface AgentHarnessHooks, Ctx> { + context: Ctx; + + setContext(ctx: Ctx): void; + + observe(handler: HookObserver): () => void; + + on( + type: TType, + handler: HookHandler, Ctx>, + ): () => void; + + emit( + event: TEvent, + signal?: AbortSignal, + ): Promise | undefined>; + + addCleanup(cleanup: () => void | Promise): () => void; + + clear(): Promise; + dispose(): Promise; +} +``` + +Important split: + +- `observe()` sees all events, read-only, return ignored. +- `on(type, handler)` participates in that event’s semantics. +- `emit(event)` is the only thing `AgentHarness` calls. +- `clear()` removes observers/handlers and runs cleanups. + +## Default implementation internals + +```ts +class DefaultAgentHarnessHooks, Ctx> + implements AgentHarnessHooks { + context: Ctx; + + private observers = new Set>(); + private handlers = new Map>>(); + private cleanups = new Set<() => void | Promise>(); + + constructor(ctx: Ctx) { + this.context = ctx; + } + + setContext(ctx: Ctx): void { + this.context = ctx; + } + + observe(handler: HookObserver): () => void { + this.observers.add(handler); + return () => this.observers.delete(handler); + } + + on(type, handler): () => void { + let handlers = this.handlers.get(type); + if (!handlers) { + handlers = new Set(); + this.handlers.set(type, handlers); + } + handlers.add(handler); + return () => handlers.delete(handler); + } + + async emit(event, signal?) { + for (const observer of this.observers) { + await observer(event, this.context, signal); + } + + switch (event.type) { + case "context": + return this.emitContext(event, signal); + case "before_provider_request": + return this.emitBeforeProviderRequest(event, signal); + case "before_provider_payload": + return this.emitBeforeProviderPayload(event, signal); + case "before_agent_start": + return this.emitBeforeAgentStart(event, signal); + case "tool_call": + return this.emitToolCall(event, signal); + case "tool_result": + return this.emitToolResult(event, signal); + case "session_before_compact": + case "session_before_tree": + return this.emitFirstCancelOrLast(event, signal); + default: + await this.emitObservationHandlers(event, signal); + return undefined; + } + } +} +``` + +Internal casts are acceptable inside the implementation because `Map` loses specificity. Public API remains typed. + +## Mutation semantics + +### Observation + +```ts +await hooks.emit({ type: "message_end", message }, signal); +``` + +Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type. + +### Context transform + +Handlers run in order. Each sees current messages. + +```ts +let current = event; + +for (const handler of handlers("context")) { + const result = await handler(current, ctx, signal); + if (result?.messages) { + current = { ...current, messages: result.messages }; + } +} + +return current.messages === event.messages ? undefined : { messages: current.messages }; +``` + +### Provider request / payload + +Sequential transform. Each handler sees previous output. + +```ts +let current = event; + +for (const handler of handlers("before_provider_payload")) { + const result = await handler(current, ctx, signal); + if (result !== undefined) { + current = { ...current, payload: result.payload }; + } +} + +return changed ? { payload: current.payload } : undefined; +``` + +### Before agent start + +Collect injected messages, chain system prompt. + +```ts +let systemPrompt = event.systemPrompt; +const messages = []; + +for (const handler of handlers("before_agent_start")) { + const result = await handler({ ...event, systemPrompt }, ctx, signal); + if (result?.messages) messages.push(...result.messages); + if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt; +} + +return messages.length || systemPrompt !== event.systemPrompt + ? { messages, systemPrompt } + : undefined; +``` + +### Tool call + +Sequential, early exit on block. + +```ts +for (const handler of handlers("tool_call")) { + const result = await handler(event, ctx, signal); + if (result?.block) return result; +} +``` + +### Tool result + +Sequential patch accumulation. Each handler sees current patched result. + +```ts +let current = event; +let modified = false; + +for (const handler of handlers("tool_result")) { + const result = await handler(current, ctx, signal); + if (!result) continue; + + current = { + ...current, + content: result.content ?? current.content, + details: result.details ?? current.details, + isError: result.isError ?? current.isError, + }; + + modified = true; +} + +return modified + ? { content: current.content, details: current.details, isError: current.isError } + : undefined; +``` + +### Session-before events + +Sequential, early exit on cancel. + +```ts +let last; + +for (const handler of handlers(event.type)) { + const result = await handler(event, ctx, signal); + if (!result) continue; + last = result; + if (result.cancel) return result; +} + +return last; +``` + +## Harness usage + +Harness only does this: + +```ts +await this.hooks.emit(event, signal); +``` + +or: + +```ts +const result = await this.hooks.emit({ type: "context", messages }, signal); +return result?.messages ?? messages; +``` + +Harness does not store handlers, chain listeners, or know extension policy. + +## Context + +Context is a normal object, not rebuilt per emit. + +```ts +const hooks = new CodingAgentHooks({ + harness: harnessFacade, + session: sessionFacade, + ui: noUiFacade, +}); +``` + +Later: + +```ts +hooks.setContext({ + ...hooks.context, + ui: tuiFacade, +}); +``` + +For dynamic state, prefer stable facades/methods over getter maze: + +```ts +interface CodingAgentHookContext { + harness: HarnessFacade; + session: SessionFacade; + ui: UiFacade; + models: ModelFacade; +} +``` + +Per-run `signal` is passed as the third handler arg. + +## Extension loading later + +Extension loading can live next to harness and construct hooks: + +```ts +const hooks = await loadExtensions({ + paths, + context, + hooks: new CodingAgentHooks(context), +}); +const harness = new AgentHarness({ ..., hooks }); +``` + +The loader registers into hooks: + +```ts +hooks.on("context", handler); +hooks.on("tool_call", handler); +hooks.addCleanup(cleanup); +``` + +For reload: + +```ts +await hooks.clear(); +const nextHooks = await loadExtensions(...); +harness.setHooks(nextHooks); // idle-only if supported +``` + +## Poking holes + +### 1. Error policy must be explicit + +Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely: + +```ts +errorMode: "continue" | "throw" +onError(error) +``` + +For coding-agent, default should be `"continue"`. + +### 2. Source metadata matters + +Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes. + +Probably needed: + +```ts +const scope = hooks.createScope({ sourceInfo }); +scope.on("context", handler); +scope.addCleanup(...); +``` + +Or `on(type, handler, { sourceInfo })`. + +### 3. Some extension capabilities are registries, not hooks + +These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host: + +- tools +- commands +- shortcuts +- flags +- message renderers +- provider registrations +- OAuth providers +- custom model providers + +That is fine. They do not belong in `AgentHarness`. + +### 4. Existing coding-agent events can be represented + +No blocker for: + +- `context` +- `before_provider_request` +- `after_provider_response` +- `before_agent_start` +- `message_end` +- `tool_call` +- `tool_result` +- `input` +- `user_bash` +- `resources_discover` +- `session_before_*` +- `session_*` +- model/thinking selection events +- agent/turn/message/tool lifecycle events + +They become additional event types handled by `CodingAgentHooks`. + +### 5. Need to preserve exact old semantics + +When porting coding-agent, special cases must be copied: + +- `input`: transform chain, `handled` short-circuits. +- `user_bash`: first meaningful result wins. +- `message_end`: replacement must keep same role. +- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt. +- `resources_discover`: aggregate paths and keep extension source. +- `tool_call`: argument mutation remains visible to later handlers. +- `tool_result`: later handlers see prior patches. + +The design allows all of that, but the default/coding hooks implementation must encode it. + +### 6. `emit()` switch can miss custom mutation events + +If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially. + +### 7. Observer semantics are intentionally limited + +Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler. + +## Verdict + +This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics. + +--- Comments --- + +Thread hn2xk0tzhj on "addCleanup(cleanup" + [tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe diff --git a/packages/agent/docs/observability.md b/packages/agent/docs/observability.md new file mode 100644 index 00000000..2f77b3fc --- /dev/null +++ b/packages/agent/docs/observability.md @@ -0,0 +1,376 @@ + + +# Pi Observability Design Notes + +## Goal + +Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor. + +Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry. + +## Mental model + +A trace is one causal tree of work, e.g. one user turn. + +A span is one timed operation in that tree. It is normally represented by IDs, not object pointers: + +```ts +interface SpanRecord { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + startTime: number; + endTime?: number; + attributes: Record; + status: "ok" | "error"; +} +``` + +Example tree: + +```text +traceId=t1 spanId=s1 parent=- name=pi.agent.prompt +traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn +traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request +traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call +traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry +``` + +## Async context + +JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency. + +`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts: + +```ts +await Promise.all([ + runWithPiContext({ userId: "alice" }, () => harness.prompt("A")), + runWithPiContext({ userId: "bob" }, () => harness.prompt("B")), +]); +``` + +Deep code can then read the correct current context for the active async chain. + +Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter. + +## Core design + +Pi owns a small runtime-agnostic observability abstraction: + +```ts +export interface PiObservabilityContext { + traceId?: string; + currentSpanId?: string; + userContext?: Record; +} + +export interface PiObservabilityEvent { + type: "start" | "end" | "error" | "event"; + name: string; + traceId: string; + spanId?: string; + parentSpanId?: string; + timestamp: number; + durationMs?: number; + context?: Record; + payload?: Record; + error?: { name: string; message: string }; +} + +export interface PiObservability { + getContext(): PiObservabilityContext | undefined; + runWithContext(context: PiObservabilityContext, fn: () => T): T; + emit(event: PiObservabilityEvent): void; + hasSubscribers(): boolean; +} +``` + +Public API: + +```ts +export function configurePiObservability(observability: PiObservability): void; +export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void; +export function runWithPiContext(userContext: Record, fn: () => T): T; +export function traceOperation(name: string, payload: Record, fn: () => T): T; +``` + +`traceOperation()`: + +1. reads the current context +2. creates `traceId` if missing +3. creates a new `spanId` +4. uses current span as `parentSpanId` +5. emits `start` +6. runs callback under child context +7. emits `end` or `error` +8. rethrows on error + +Pseudo-code: + +```ts +function traceOperation(name: string, payload: Record, fn: () => T): T { + const parent = getContext(); + const traceId = parent?.traceId ?? createId(); + const spanId = createId(); + const parentSpanId = parent?.currentSpanId; + + const child = { ...parent, traceId, currentSpanId: spanId }; + + emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload }); + + return runWithContext(child, () => { + try { + const result = fn(); + // Promise-aware implementation emits end/error after settlement. + emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload }); + return result; + } catch (error) { + emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) }); + throw error; + } + }); +} +``` + +## Runtime adapters + +Core packages should not import Node-only APIs. + +Possible implementations: + +- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing. +- Browser/workers fallback: local subscriber set and limited/manual context propagation. +- Bun/Deno adapters: use runtime-specific async context if available. + +For Node, diagnostics channels can be used as a passive event bus: + +```ts +import { channel } from "diagnostics_channel"; +channel("pi.observability").publish(event); +``` + +Subscribers can create OTel/Sentry spans without monkey-patching pi. + +## What pi emits + +Pi emits what happened. It does not create OTel/Sentry spans directly. + +Initial minimal event names: + +```text +pi.agent.prompt +pi.agent.skill +pi.agent.prompt_template +pi.agent.compaction +pi.agent.branch_navigation +pi.agent.session.append_entry +pi.ai.provider.request +``` + +Each operation emits: + +```text +start +end +error +``` + +Later additions: + +```text +pi.agent.turn +pi.agent.tool_call +pi.agent.queue_update +pi.ai.provider.retry +pi.ai.provider.first_token +pi.ai.provider.usage +pi.session.read +pi.session.write +``` + +## Minimal instrumentation points + +### packages/agent + +Wrap: + +- `AgentHarness.prompt()` +- `AgentHarness.skill()` +- `AgentHarness.promptFromTemplate()` +- `AgentHarness.compact()` +- `AgentHarness.navigateTree()` +- `Session.appendTypedEntry()` or storage append facade + +Example: + +```ts +return traceOperation( + "pi.agent.prompt", + { + sessionId: turnState.sessionId, + provider: turnState.model.provider, + model: turnState.model.id, + promptLength: text.length, + imageCount: options?.images?.length ?? 0, + }, + () => this.executeTurn(turnState, text, options), +); +``` + +Session write: + +```ts +return traceOperation( + "pi.agent.session.append_entry", + { entryType: entry.type }, + async () => { + await this.unwrap(this.storage.appendEntry(entry)); + return entry.id; + }, +); +``` + +### packages/ai + +Wrap common provider boundaries: + +- `streamSimple()` +- `completeSimple()` + +Example: + +```ts +return traceOperation( + "pi.ai.provider.request", + { + api: model.api, + provider: model.provider, + model: model.id, + sessionId: options.sessionId, + reasoning: options.reasoning, + }, + () => actualStreamSimple(model, context, options), +); +``` + +End/error payloads can include safe metadata: + +- stop reason +- status code +- retry count +- input/output/total tokens +- cost total +- aborted/timeout flag + +## Safety and redaction + +Default payloads must be safe. + +Safe by default: + +- provider +- model +- API identifier +- session id +- entry type +- tool name +- status code +- stop reason +- token counts +- costs +- durations + +Unsafe by default: + +- prompts +- completions +- tool args +- tool results +- shell output +- file contents +- provider request payloads +- provider response bodies +- API keys +- headers + +Content capture can be opt-in later with explicit redaction hooks. + +## Listener behavior + +Observability must never affect pi execution. + +Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not. + +## User context + +Users can associate arbitrary context with a turn: + +```ts +await runWithPiContext( + { + userId: "u123", + orgId: "acme", + region: "eu", + }, + () => harness.prompt("fix this"), +); +``` + +Every emitted event inside that async chain includes the context: + +```ts +{ + type: "start", + name: "pi.ai.provider.request", + traceId: "t1", + spanId: "s3", + parentSpanId: "s1", + context: { + userId: "u123", + orgId: "acme", + region: "eu", + }, + payload: { + provider: "anthropic", + model: "claude-sonnet-4", + }, +} +``` + +An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON. + +## Package story + +Minimal initial package: + +```text +packages/observability + runtime-agnostic context + traceOperation + subscribe +``` + +Then: + +```text +packages/ai + emits pi.ai.* events + +packages/agent + emits pi.agent.* / pi.session.* events +``` + +Optional later: + +```text +packages/observability-node + AsyncLocalStorage + diagnostics_channel bridge + +packages/otel + subscribes to pi events and creates OpenTelemetry spans +``` + +## Thesis + +Pi defines a stable, safe event contract. Adapters define where events go. + +This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching. diff --git a/packages/agent/package.json b/packages/agent/package.json index 853b8180..f5d823e7 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,10 +1,21 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.74.0", + "version": "0.75.4", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "README.md" @@ -12,15 +23,16 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json", - "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", "test": "vitest --run", + "test:harness": "vitest --run --config vitest.harness.config.ts", + "coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.74.0", - "ignore": "^7.0.5", - "typebox": "^1.1.24", - "yaml": "^2.8.2" + "@earendil-works/pi-ai": "^0.75.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" }, "keywords": [ "ai", @@ -37,11 +49,12 @@ "directory": "packages/agent" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "^24.3.0", - "typescript": "^5.7.3", - "vitest": "^3.2.4" + "@types/node": "24.12.4", + "@vitest/coverage-v8": "3.2.4", + "typescript": "5.9.3", + "vitest": "3.2.4" } } diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 7226082a..28f037f5 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -20,7 +20,7 @@ import type { AgentToolCall, AgentToolResult, StreamFn, -} from "./types.js"; +} from "./types.ts"; export type AgentEventSink = (event: AgentEvent) => Promise | void; @@ -436,6 +436,10 @@ async function executeToolCallsSequential( await emitToolResultMessage(toolResultMessage, emit); finalizedCalls.push(finalized); messages.push(toolResultMessage); + + if (signal?.aborted) { + break; + } } return { @@ -471,6 +475,9 @@ async function executeToolCallsParallel( } satisfies FinalizedToolCallOutcome; await emitToolExecutionEnd(finalized, emit); finalizedCalls.push(finalized); + if (signal?.aborted) { + break; + } continue; } @@ -487,6 +494,9 @@ async function executeToolCallsParallel( await emitToolExecutionEnd(finalized, emit); return finalized; }); + if (signal?.aborted) { + break; + } } const orderedFinalizedCalls = await Promise.all( @@ -578,6 +588,13 @@ async function prepareToolCall( }, signal, ); + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } if (beforeResult?.block) { return { kind: "immediate", @@ -586,6 +603,13 @@ async function prepareToolCall( }; } } + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } return { kind: "prepared", toolCall, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 6eafd030..db6684a8 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -8,7 +8,7 @@ import { type ThinkingBudgets, type Transport, } from "@earendil-works/pi-ai"; -import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import type { AfterToolCallContext, AfterToolCallResult, @@ -21,9 +21,12 @@ import type { AgentTool, BeforeToolCallContext, BeforeToolCallResult, + QueueMode, StreamFn, ToolExecutionMode, -} from "./types.js"; +} from "./types.ts"; + +export type { QueueMode } from "./types.ts"; function defaultConvertToLlm(messages: AgentMessage[]): Message[] { return messages.filter( @@ -53,8 +56,6 @@ const DEFAULT_MODEL = { maxTokens: 0, } satisfies Model; -export type QueueMode = "all" | "one-at-a-time"; - type MutableAgentState = Omit & { isStreaming: boolean; streamingMessage?: AgentMessage; @@ -116,8 +117,11 @@ export interface AgentOptions { class PendingMessageQueue { private messages: AgentMessage[] = []; + public mode: QueueMode; - constructor(public mode: QueueMode) {} + constructor(mode: QueueMode) { + this.mode = mode; + } enqueue(message: AgentMessage): void { this.messages.push(message); diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 33b96b37..80535e8e 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,10 +1,26 @@ -import type { AssistantMessage, ImageContent, Model, UserMessage } from "@earendil-works/pi-ai"; -import { Agent, type QueueMode } from "../agent.js"; -import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js"; -import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.js"; -import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.js"; -import { formatPromptTemplateInvocation } from "./prompt-templates.js"; -import { formatSkillInvocation } from "./skills.js"; +import { + type AssistantMessage, + type ImageContent, + type Model, + streamSimple, + type UserMessage, +} from "@earendil-works/pi-ai"; +import { runAgentLoop } from "../agent-loop.ts"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + QueueMode, + StreamFn, + ThinkingLevel, +} from "../types.ts"; +import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.ts"; +import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.ts"; +import { convertToLlm } from "./messages.ts"; +import { formatPromptTemplateInvocation } from "./prompt-templates.ts"; +import { formatSkillInvocation } from "./skills.ts"; import type { AbortResult, AgentHarnessEvent, @@ -13,14 +29,16 @@ import type { AgentHarnessOwnEvent, AgentHarnessPhase, AgentHarnessResources, - AgentHarnessTurnState, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, ExecutionEnv, NavigateTreeResult, PendingSessionWrite, PromptTemplate, Session, Skill, -} from "./types.js"; +} from "./types.ts"; +import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.ts"; function createUserMessage(text: string, images?: ImageContent[]): UserMessage { const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; @@ -28,140 +46,250 @@ function createUserMessage(text: string, images?: ImageContent[]): UserMessage { return { role: "user", content, timestamp: Date.now() }; } +function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { + return { + ...streamOptions, + headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, + metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, + }; +} + +function mergeHeaders(...headers: Array | undefined>): Record | undefined { + const merged: Record = {}; + let hasHeaders = false; + for (const entry of headers) { + if (!entry) continue; + Object.assign(merged, entry); + hasHeaders = true; + } + return hasHeaders ? merged : undefined; +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch?: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const result = cloneStreamOptions(base); + if (!patch) return result; + + if (Object.hasOwn(patch, "transport")) result.transport = patch.transport; + if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs; + if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries; + if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs; + if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention; + + if (Object.hasOwn(patch, "headers")) { + if (patch.headers === undefined) { + result.headers = undefined; + } else { + const headers = { ...(result.headers ?? {}) }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) delete headers[key]; + else headers[key] = value; + } + result.headers = Object.keys(headers).length > 0 ? headers : undefined; + } + } + + if (Object.hasOwn(patch, "metadata")) { + if (patch.metadata === undefined) { + result.metadata = undefined; + } else { + const metadata = { ...(result.metadata ?? {}) }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) delete metadata[key]; + else metadata[key] = value; + } + result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; + } + } + + return result; +} + +const SUBSCRIBER_EVENT_TYPE = "*"; + +type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise | any; + +function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError { + if (error instanceof AgentHarnessError) return error; + const cause = toError(error); + if (cause instanceof SessionError) return new AgentHarnessError("session", cause.message, cause); + if (cause instanceof CompactionError) return new AgentHarnessError("compaction", cause.message, cause); + if (cause instanceof BranchSummaryError) return new AgentHarnessError("branch_summary", cause.message, cause); + return new AgentHarnessError(fallbackCode, cause.message, cause); +} + +function normalizeHookError(error: unknown): AgentHarnessError { + return normalizeHarnessError(error, "hook"); +} + +interface AgentHarnessTurnState< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + messages: AgentMessage[]; + resources: AgentHarnessResources; + streamOptions: AgentHarnessStreamOptions; + sessionId: string; + systemPrompt: string; + model: Model; + thinkingLevel: ThinkingLevel; + tools: TTool[]; + activeTools: TTool[]; +} + export class AgentHarness< TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, TTool extends AgentTool = AgentTool, > { - readonly agent: Agent; readonly env: ExecutionEnv; private session: Session; + private phase: AgentHarnessPhase = "idle"; + private runAbortController?: AbortController; + private runPromise?: Promise; + private pendingSessionWrites: PendingSessionWrite[] = []; private model: Model; private thinkingLevel: ThinkingLevel; - private activeToolNames: string[]; - private nextTurnQueue: AgentMessage[] = []; - private phase: AgentHarnessPhase = "idle"; - private steerQueue: UserMessage[] = []; - private followUpQueue: UserMessage[] = []; - private pendingSessionWrites: PendingSessionWrite[] = []; - private resources: AgentHarnessResources; private systemPrompt: AgentHarnessOptions["systemPrompt"]; + private streamOptions: AgentHarnessStreamOptions; private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"]; + private resources: AgentHarnessResources; private tools = new Map(); - private listeners = new Set< - (event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void - >(); - private hooks = new Map Promise | any>>(); + private activeToolNames: string[]; + private steerQueue: UserMessage[] = []; + private steeringQueueMode: QueueMode; + private followUpQueue: UserMessage[] = []; + private followUpQueueMode: QueueMode; + private nextTurnQueue: AgentMessage[] = []; + private handlers = new Map>(); constructor(options: AgentHarnessOptions) { - this.agent = new Agent({ - initialState: { - model: options.model, - thinkingLevel: options.thinkingLevel, - tools: options.tools ?? [], - }, - steeringMode: options.steeringMode, - followUpMode: options.followUpMode, - }); this.env = options.env; this.session = options.session; this.resources = options.resources ?? {}; + this.streamOptions = cloneStreamOptions(options.streamOptions); this.systemPrompt = options.systemPrompt; this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; for (const tool of options.tools ?? []) { this.tools.set(tool.name, tool); } this.model = options.model; - this.thinkingLevel = options.thinkingLevel ?? this.agent.state.thinkingLevel; + this.thinkingLevel = options.thinkingLevel ?? "off"; this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); - this.agent.state.model = this.model; - this.agent.state.thinkingLevel = this.thinkingLevel; - this.agent.getApiKey = async (provider) => { - const model = this.model; - if (!this.getApiKeyAndHeaders || model.provider !== provider) return undefined; - return (await this.getApiKeyAndHeaders(model))?.apiKey; - }; - this.agent.transformContext = async (messages) => { - const result = await this.emitHook({ type: "context", messages: [...messages] }); - return result?.messages ?? messages; - }; - this.agent.beforeToolCall = async ({ toolCall, args }) => { - const result = await this.emitHook({ - type: "tool_call", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - }); - return result ? { block: result.block, reason: result.reason } : undefined; - }; - this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { - const patch = await this.emitHook({ - type: "tool_result", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - content: result.content, - details: result.details, - isError, - }); - return patch - ? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate } - : undefined; - }; - this.agent.onPayload = async (payload) => { - const result = await this.emitHook({ type: "before_provider_request", payload }); - return result?.payload ?? payload; - }; - this.agent.onResponse = async (response) => { - const headers = { ...(response.headers as Record) }; - await this.emitOwn({ type: "after_provider_response", status: response.status, headers }, this.agent.signal); - }; - this.agent.prepareNextTurn = async () => { - await this.flushPendingSessionWrites(); - const turnState = await this.createTurnState(); - this.applyTurnState(turnState); - return { - context: { - systemPrompt: turnState.systemPrompt, - messages: turnState.messages.slice(), - tools: turnState.activeTools.slice(), - }, - model: turnState.model, - thinkingLevel: turnState.thinkingLevel, - }; - }; - this.agent.subscribe(async (event, signal) => { - await this.handleAgentEvent(event, signal); - }); + this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; + this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; + } + + private getHandlers(type: string): Set | undefined { + return this.handlers.get(type); } private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise { - for (const listener of this.listeners) { - await listener(event, signal); + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } } } private async emitAny(event: AgentHarnessEvent, signal?: AbortSignal): Promise { - for (const listener of this.listeners) { - await listener(event, signal); + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } } } private async emitHook( event: Extract, ): Promise { - const handlers = this.hooks.get(event.type as TType); + const handlers = this.getHandlers(event.type as TType); if (!handlers || handlers.size === 0) return undefined; let lastResult: AgentHarnessEventResultMap[TType] | undefined; for (const handler of handlers) { - const result = await handler(event); - if (result !== undefined) { - lastResult = result; + try { + const result = await handler(event); + if (result !== undefined) { + lastResult = result; + } + } catch (error) { + throw normalizeHookError(error); } } return lastResult; } + private async emitBeforeProviderRequest( + model: Model, + sessionId: string, + streamOptions: AgentHarnessStreamOptions, + ): Promise { + const handlers = this.getHandlers("before_provider_request"); + let current = cloneStreamOptions(streamOptions); + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ + type: "before_provider_request", + model, + sessionId, + streamOptions: cloneStreamOptions(current), + }); + if (result?.streamOptions) { + current = applyStreamOptionsPatch(current, result.streamOptions); + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { + const handlers = this.getHandlers("before_provider_payload"); + let current = payload; + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ type: "before_provider_payload", model, payload: current }); + if (result !== undefined) { + current = result.payload; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + private async emitQueueUpdate(): Promise { await this.emitOwn({ type: "queue_update", @@ -171,9 +299,21 @@ export class AgentHarness< }); } + private startRunPromise(): () => void { + let finish = () => {}; + this.runPromise = new Promise((resolve) => { + finish = resolve; + }); + return () => { + this.runPromise = undefined; + finish(); + }; + } + private async createTurnState(): Promise> { const context = await this.session.buildContext(); const resources = this.getResources(); + const sessionMetadata = await this.session.getMetadata(); const tools = [...this.tools.values()]; const activeTools = this.activeToolNames .map((name) => this.tools.get(name)) @@ -194,6 +334,8 @@ export class AgentHarness< return { messages: context.messages, resources, + streamOptions: cloneStreamOptions(this.streamOptions), + sessionId: sessionMetadata.id, systemPrompt, model: this.model, thinkingLevel: this.thinkingLevel, @@ -202,23 +344,121 @@ export class AgentHarness< }; } - private applyTurnState(turnState: AgentHarnessTurnState): void { - this.agent.state.messages = turnState.messages; - this.agent.state.systemPrompt = turnState.systemPrompt; - this.agent.state.model = turnState.model; - this.agent.state.thinkingLevel = turnState.thinkingLevel; - this.agent.state.tools = turnState.activeTools; + private createContext( + turnState: AgentHarnessTurnState, + systemPrompt?: string, + ): AgentContext { + return { + systemPrompt: systemPrompt ?? turnState.systemPrompt, + messages: turnState.messages.slice(), + tools: turnState.activeTools.slice(), + }; } - private validateToolNames(toolNames: string[]): void { - const missing = toolNames.filter((name) => !this.tools.has(name)); - if (missing.length > 0) throw new Error(`Unknown tool(s): ${missing.join(", ")}`); + private createStreamFn(getTurnState: () => AgentHarnessTurnState): StreamFn { + return async (model, context, streamOptions) => { + const turnState = getTurnState(); + const auth = await this.getApiKeyAndHeaders?.(model); + const snapshotOptions: AgentHarnessStreamOptions = { + ...turnState.streamOptions, + headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers), + }; + const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions); + return streamSimple(model, context, { + cacheRetention: requestOptions.cacheRetention, + headers: requestOptions.headers, + maxRetries: requestOptions.maxRetries, + maxRetryDelayMs: requestOptions.maxRetryDelayMs, + metadata: requestOptions.metadata, + onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), + onResponse: async (response) => { + const headers = { ...(response.headers as Record) }; + await this.emitOwn( + { type: "after_provider_response", status: response.status, headers }, + streamOptions?.signal, + ); + }, + reasoning: streamOptions?.reasoning, + signal: streamOptions?.signal, + sessionId: turnState.sessionId, + timeoutMs: requestOptions.timeoutMs, + transport: requestOptions.transport, + apiKey: auth?.apiKey, + }); + }; + } + + private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise { + const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); + if (messages.length === 0) return messages; + try { + await this.emitQueueUpdate(); + return messages; + } catch (error) { + queue.unshift(...messages); + throw normalizeHookError(error); + } + } + + private createLoopConfig( + getTurnState: () => AgentHarnessTurnState, + setTurnState: (turnState: AgentHarnessTurnState) => void, + ): AgentLoopConfig { + const turnState = getTurnState(); + return { + model: turnState.model, + reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, + convertToLlm, + transformContext: async (messages) => { + const result = await this.emitHook({ type: "context", messages: [...messages] }); + return result?.messages ?? messages; + }, + beforeToolCall: async ({ toolCall, args }) => { + const result = await this.emitHook({ + type: "tool_call", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }, + afterToolCall: async ({ toolCall, args, result, isError }) => { + const patch = await this.emitHook({ + type: "tool_result", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + return patch + ? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate } + : undefined; + }, + prepareNextTurn: async () => { + await this.flushPendingSessionWrites(); + const nextTurnState = await this.createTurnState(); + setTurnState(nextTurnState); + return { + context: this.createContext(nextTurnState), + model: nextTurnState.model, + thinkingLevel: nextTurnState.thinkingLevel, + }; + }, + getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), + getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), + }; + } + + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + const missing = toolNames.filter((name) => !tools.has(name)); + if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); } private async flushPendingSessionWrites(): Promise { - const writes = this.pendingSessionWrites; - this.pendingSessionWrites = []; - for (const write of writes) { + while (this.pendingSessionWrites.length > 0) { + const write = this.pendingSessionWrites[0]!; if (write.type === "message") { await this.session.appendMessage(write.message); } else if (write.type === "model_change") { @@ -233,41 +473,54 @@ export class AgentHarness< await this.session.appendLabel(write.targetId, write.label); } else if (write.type === "session_info") { await this.session.appendSessionName(write.name ?? ""); + } else if (write.type === "leaf") { + await this.session.getStorage().setLeafId(write.targetId); } + this.pendingSessionWrites.shift(); } } private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { - await this.emitAny(event, signal); - if (event.type === "message_start" && event.message.role === "user") { - const steerIndex = this.steerQueue.indexOf(event.message); - if (steerIndex !== -1) { - this.steerQueue.splice(steerIndex, 1); - await this.emitQueueUpdate(); - } else { - const followUpIndex = this.followUpQueue.indexOf(event.message); - if (followUpIndex !== -1) { - this.followUpQueue.splice(followUpIndex, 1); - await this.emitQueueUpdate(); - } - } - } if (event.type === "message_end") { await this.session.appendMessage(event.message); + await this.emitAny(event, signal); + return; } if (event.type === "turn_end") { + let eventError: unknown; + try { + await this.emitAny(event, signal); + } catch (error) { + eventError = error; + } const hadPendingMutations = this.pendingSessionWrites.length > 0; await this.flushPendingSessionWrites(); - await this.emitOwn({ - type: "save_point", - hadPendingMutations, - }); + if (eventError) throw eventError; + await this.emitOwn({ type: "save_point", hadPendingMutations }); + return; } if (event.type === "agent_end") { await this.flushPendingSessionWrites(); this.phase = "idle"; + await this.emitAny(event, signal); await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); + return; } + await this.emitAny(event, signal); + } + + private async emitRunFailure( + model: Model, + error: unknown, + aborted: boolean, + signal: AbortSignal, + ): Promise { + const failureMessage = createFailureMessage(model, error, aborted); + await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal); + await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); + return [failureMessage]; } private async executeTurn( @@ -275,13 +528,17 @@ export class AgentHarness< text: string, options?: { images?: ImageContent[] }, ): Promise { - this.applyTurnState(turnState); - const beforeLength = this.agent.state.messages.length; + let activeTurnState = turnState; let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; if (this.nextTurnQueue.length > 0) { - messages = [...this.nextTurnQueue, messages[0]!]; - this.nextTurnQueue = []; - await this.emitQueueUpdate(); + const queuedMessages = this.nextTurnQueue.splice(0); + try { + await this.emitQueueUpdate(); + } catch (error) { + this.nextTurnQueue.unshift(...queuedMessages); + throw normalizeHookError(error); + } + messages = [...queuedMessages, messages[0]!]; } const beforeResult = await this.emitHook({ type: "before_agent_start", @@ -290,300 +547,354 @@ export class AgentHarness< systemPrompt: turnState.systemPrompt, resources: turnState.resources, }); - if (beforeResult?.messages) messages = [...beforeResult.messages, ...messages]; - if (beforeResult?.systemPrompt) this.agent.state.systemPrompt = beforeResult.systemPrompt; + if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages]; + + const abortController = new AbortController(); + const getTurnState = () => activeTurnState; + const setTurnState = (nextTurnState: AgentHarnessTurnState) => { + activeTurnState = nextTurnState; + }; + this.runAbortController = abortController; + const runResultPromise = (async () => { + try { + return await runAgentLoop( + messages, + this.createContext(turnState, beforeResult?.systemPrompt), + this.createLoopConfig(getTurnState, setTurnState), + (event) => this.handleAgentEvent(event, abortController.signal), + abortController.signal, + this.createStreamFn(getTurnState), + ); + } catch (error) { + try { + return await this.emitRunFailure( + activeTurnState.model, + error, + abortController.signal.aborted, + abortController.signal, + ); + } catch (failureError) { + const cause = new AggregateError( + [toError(error), toError(failureError)], + "Agent run failed and failure reporting failed", + ); + throw new AgentHarnessError("unknown", cause.message, cause); + } + } + })(); try { - await this.agent.prompt(messages); + const newMessages = await runResultPromise; + for (let i = newMessages.length - 1; i >= 0; i--) { + const message = newMessages[i]!; + if (message.role === "assistant") { + return message; + } + } + throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message"); } finally { - await this.flushPendingSessionWrites(); - } - let response: AssistantMessage | undefined; - const newMessages = this.agent.state.messages.slice(beforeLength); - for (let i = newMessages.length - 1; i >= 0; i--) { - const message = newMessages[i]!; - if (message.role === "assistant") { - response = message; - break; + try { + await this.flushPendingSessionWrites(); + } finally { + this.runAbortController = undefined; } } - if (!response) throw new Error("AgentHarness prompt completed without an assistant message"); - return response; } async prompt(text: string, options?: { images?: ImageContent[] }): Promise { - if (this.phase !== "idle") throw new Error("AgentHarness is busy"); + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); try { const turnState = await this.createTurnState(); return await this.executeTurn(turnState, text, options); } catch (error) { this.phase = "idle"; - throw error; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); } } async skill(name: string, additionalInstructions?: string): Promise { - if (this.phase !== "idle") throw new Error("AgentHarness is busy"); + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); try { const turnState = await this.createTurnState(); const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); - if (!skill) throw new Error(`Unknown skill: ${name}`); + if (!skill) throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions)); } catch (error) { this.phase = "idle"; - throw error; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); } } async promptFromTemplate(name: string, args: string[] = []): Promise { - if (this.phase !== "idle") throw new Error("AgentHarness is busy"); + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); try { const turnState = await this.createTurnState(); const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name); - if (!template) throw new Error(`Unknown prompt template: ${name}`); + if (!template) throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args)); } catch (error) { this.phase = "idle"; - throw error; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); } } - steer(text: string, options?: { images?: ImageContent[] }): void { - if (this.phase === "idle") throw new Error("Cannot steer while idle"); - const message = createUserMessage(text, options?.images); - this.steerQueue.push(message); - this.agent.steer(message); - void this.emitQueueUpdate(); + async steer(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); + this.steerQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); } - followUp(text: string, options?: { images?: ImageContent[] }): void { - if (this.phase === "idle") throw new Error("Cannot follow up while idle"); - const message = createUserMessage(text, options?.images); - this.followUpQueue.push(message); - this.agent.followUp(message); - void this.emitQueueUpdate(); + async followUp(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); + this.followUpQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); } - nextTurn(text: string, options?: { images?: ImageContent[] }): void { + async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { this.nextTurnQueue.push(createUserMessage(text, options?.images)); - void this.emitQueueUpdate(); + await this.emitQueueUpdate(); } async appendMessage(message: AgentMessage): Promise { - if (this.phase === "idle") { - await this.session.appendMessage(message); - } else { - this.pendingSessionWrites.push({ type: "message", message }); + try { + if (this.phase === "idle") { + await this.session.appendMessage(message); + } else { + this.pendingSessionWrites.push({ type: "message", message }); + } + } catch (error) { + throw normalizeHarnessError(error, "session"); } } async compact( customInstructions?: string, ): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> { - if (this.phase !== "idle") throw new Error("compact() requires idle harness"); + if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); this.phase = "compaction"; - const model = this.model; - if (!model) throw new Error("No model set for compaction"); - const auth = await this.getApiKeyAndHeaders?.(model); - if (!auth) throw new Error("No auth available for compaction"); - const branchEntries = await this.session.getBranch(); - const preparation = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); - if (!preparation) throw new Error("Nothing to compact"); - const hookResult = await this.emitHook({ - type: "session_before_compact", - preparation, - branchEntries, - customInstructions, - signal: new AbortController().signal, - }); - if (hookResult?.cancel) { - this.phase = "idle"; - throw new Error("Compaction cancelled"); - } - const provided = hookResult?.compaction; - const result = - provided ?? - (await compact( + try { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction"); + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) throw new AgentHarnessError("auth", "No auth available for compaction"); + const branchEntries = await this.session.getBranch(); + const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); + if (!preparationResult.ok) throw preparationResult.error; + const preparation = preparationResult.value; + if (!preparation) throw new AgentHarnessError("compaction", "Nothing to compact"); + const hookResult = await this.emitHook({ + type: "session_before_compact", preparation, - model, - auth.apiKey, - auth.headers, + branchEntries, customInstructions, - undefined, - this.thinkingLevel, - )); - const entryId = await this.session.appendCompaction( - result.summary, - result.firstKeptEntryId, - result.tokensBefore, - result.details, - provided !== undefined, - ); - const entry = await this.session.getEntry(entryId); - if (entry?.type === "compaction") { - await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); + signal: new AbortController().signal, + }); + if (hookResult?.cancel) throw new AgentHarnessError("compaction", "Compaction cancelled"); + const provided = hookResult?.compaction; + const compactResult = provided + ? { ok: true as const, value: provided } + : await compact( + preparation, + model, + auth.apiKey, + auth.headers, + customInstructions, + undefined, + this.thinkingLevel, + ); + if (!compactResult.ok) throw compactResult.error; + const result = compactResult.value; + const entryId = await this.session.appendCompaction( + result.summary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + provided !== undefined, + ); + const entry = await this.session.getEntry(entryId); + if (entry?.type === "compaction") { + await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); + } + return result; + } catch (error) { + throw normalizeHarnessError(error, "compaction"); + } finally { + this.phase = "idle"; } - this.phase = "idle"; - return result; } async navigateTree( targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, ): Promise { - if (this.phase !== "idle") throw new Error("navigateTree() requires idle harness"); + if (this.phase !== "idle") throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); this.phase = "branch_summary"; - const oldLeafId = await this.session.getLeafId(); - if (oldLeafId === targetId) { - this.phase = "idle"; - return { cancelled: false }; - } - const targetEntry = await this.session.getEntry(targetId); - if (!targetEntry) throw new Error(`Entry ${targetId} not found`); - const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); - const preparation = { - targetId, - oldLeafId, - commonAncestorId, - entriesToSummarize: entries, - userWantsSummary: options?.summarize ?? false, - customInstructions: options?.customInstructions, - replaceInstructions: options?.replaceInstructions, - label: options?.label, - }; - const signal = new AbortController().signal; - const hookResult = await this.emitHook({ - type: "session_before_tree", - preparation, - signal, - }); - if (hookResult?.cancel) { - this.phase = "idle"; - return { cancelled: true }; - } - let summaryEntry: any | undefined; - let summaryText: string | undefined = hookResult?.summary?.summary; - let summaryDetails: unknown = hookResult?.summary?.details; - if (!summaryText && options?.summarize && entries.length > 0) { - const model = this.model; - if (!model) throw new Error("No model set for branch summary"); - const auth = await this.getApiKeyAndHeaders?.(model); - if (!auth) throw new Error("No auth available for branch summary"); - const branchSummary = await generateBranchSummary(entries, { - model, - apiKey: auth.apiKey, - headers: auth.headers, - signal: new AbortController().signal, - customInstructions: hookResult?.customInstructions ?? options?.customInstructions, - replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, - }); - if (branchSummary.aborted) { - this.phase = "idle"; - return { cancelled: true }; - } - if (branchSummary.error) throw new Error(branchSummary.error); - summaryText = branchSummary.summary; - summaryDetails = { - readFiles: branchSummary.readFiles ?? [], - modifiedFiles: branchSummary.modifiedFiles ?? [], + try { + const oldLeafId = await this.session.getLeafId(); + if (oldLeafId === targetId) return { cancelled: false }; + const targetEntry = await this.session.getEntry(targetId); + if (!targetEntry) throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); + const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); + const preparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize: entries, + userWantsSummary: options?.summarize ?? false, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, }; + const signal = new AbortController().signal; + const hookResult = await this.emitHook({ type: "session_before_tree", preparation, signal }); + if (hookResult?.cancel) return { cancelled: true }; + let summaryEntry: NavigateTreeResult["summaryEntry"]; + let summaryText: string | undefined = hookResult?.summary?.summary; + let summaryDetails: unknown = hookResult?.summary?.details; + if (!summaryText && options?.summarize && entries.length > 0) { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); + const auth = await this.getApiKeyAndHeaders?.(model); + if (!auth) throw new AgentHarnessError("auth", "No auth available for branch summary"); + const branchSummary = await generateBranchSummary(entries, { + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: new AbortController().signal, + customInstructions: hookResult?.customInstructions ?? options?.customInstructions, + replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + }); + if (!branchSummary.ok) { + if (branchSummary.error.code === "aborted") return { cancelled: true }; + throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); + } + summaryText = branchSummary.value.summary; + summaryDetails = { + readFiles: branchSummary.value.readFiles, + modifiedFiles: branchSummary.value.modifiedFiles, + }; + } + let editorText: string | undefined; + let newLeafId: string | null; + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + newLeafId = targetEntry.parentId; + const content = targetEntry.message.content; + editorText = + typeof content === "string" + ? content + : content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else if (targetEntry.type === "custom_message") { + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + newLeafId = targetId; + } + const summaryId = await this.session.moveTo( + newLeafId, + summaryText + ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined } + : undefined, + ); + if (summaryId) { + const entry = await this.session.getEntry(summaryId); + if (entry?.type === "branch_summary") summaryEntry = entry; + } + await this.emitOwn({ + type: "session_tree", + newLeafId: await this.session.getLeafId(), + oldLeafId, + summaryEntry, + fromHook: hookResult?.summary !== undefined, + }); + return { cancelled: false, editorText, summaryEntry }; + } catch (error) { + throw normalizeHarnessError(error, "branch_summary"); + } finally { + this.phase = "idle"; } - let editorText: string | undefined; - let newLeafId: string | null; - if (targetEntry.type === "message" && targetEntry.message.role === "user") { - newLeafId = targetEntry.parentId; - const content = targetEntry.message.content; - editorText = - typeof content === "string" - ? content - : content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } else if (targetEntry.type === "custom_message") { - newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } else { - newLeafId = targetId; - } - const summaryId = await this.session.moveTo( - newLeafId, - summaryText - ? { - summary: summaryText, - details: summaryDetails, - fromHook: hookResult?.summary !== undefined, - } - : undefined, - ); - if (summaryId) { - summaryEntry = await this.session.getEntry(summaryId); - } - await this.emitOwn({ - type: "session_tree", - newLeafId: await this.session.getLeafId(), - oldLeafId, - summaryEntry, - fromHook: hookResult?.summary !== undefined, - }); - this.phase = "idle"; - return { cancelled: false, editorText, summaryEntry }; + } + + getModel(): Model { + return this.model; + } + + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; } async setModel(model: Model): Promise { - const previousModel = this.model; - this.model = model; - if (this.phase === "idle") { - this.agent.state.model = model; - await this.session.appendModelChange(model.provider, model.id); - } else { - this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); + try { + const previousModel = this.model; + if (this.phase === "idle") { + await this.session.appendModelChange(model.provider, model.id); + } else { + this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); + } + this.model = model; + await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + } catch (error) { + throw normalizeHarnessError(error, "session"); } - await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); } async setThinkingLevel(level: ThinkingLevel): Promise { - const previousLevel = this.thinkingLevel; - this.thinkingLevel = level; - if (this.phase === "idle") { - this.agent.state.thinkingLevel = level; - await this.session.appendThinkingLevelChange(level); - } else { - this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + try { + const previousLevel = this.thinkingLevel; + if (this.phase === "idle") { + await this.session.appendThinkingLevelChange(level); + } else { + this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + } + this.thinkingLevel = level; + await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + } catch (error) { + throw normalizeHarnessError(error, "session"); } - await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); } async setActiveTools(toolNames: string[]): Promise { - this.validateToolNames(toolNames); - this.activeToolNames = [...toolNames]; - if (this.phase === "idle") { - this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!); + try { + this.validateToolNames(toolNames); + this.activeToolNames = [...toolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); } } - get steeringMode(): QueueMode { - return this.agent.steeringMode; + getSteeringMode(): QueueMode { + return this.steeringQueueMode; } - set steeringMode(mode: QueueMode) { - this.agent.steeringMode = mode; + async setSteeringMode(mode: QueueMode): Promise { + this.steeringQueueMode = mode; } - get followUpMode(): QueueMode { - return this.agent.followUpMode; + getFollowUpMode(): QueueMode { + return this.followUpQueueMode; } - set followUpMode(mode: QueueMode) { - this.agent.followUpMode = mode; + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpQueueMode = mode; } getResources(): AgentHarnessResources { @@ -602,16 +913,23 @@ export class AgentHarness< await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources }); } + getStreamOptions(): AgentHarnessStreamOptions { + return cloneStreamOptions(this.streamOptions); + } + + async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { + this.streamOptions = cloneStreamOptions(streamOptions); + } + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { - this.tools = new Map(tools.map((tool) => [tool.name, tool])); - if (activeToolNames) { - this.validateToolNames(activeToolNames); - this.activeToolNames = [...activeToolNames]; - } else { - this.validateToolNames(this.activeToolNames); - } - if (this.phase === "idle") { - this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!); + try { + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); } } @@ -620,23 +938,44 @@ export class AgentHarness< const clearedFollowUp = [...this.followUpQueue]; this.steerQueue = []; this.followUpQueue = []; - this.agent.clearAllQueues(); - await this.emitQueueUpdate(); - this.agent.abort(); - await this.agent.waitForIdle(); - await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + this.runAbortController?.abort(); + const errors: Error[] = []; + try { + await this.emitQueueUpdate(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.waitForIdle(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + } catch (error) { + errors.push(toError(error)); + } + if (errors.length > 0) { + const cause = errors.length === 1 ? errors[0]! : new AggregateError(errors, "Abort completed with errors"); + throw normalizeHarnessError(cause, "hook"); + } return { clearedSteer, clearedFollowUp }; } async waitForIdle(): Promise { - await this.agent.waitForIdle(); + await this.runPromise; } subscribe( listener: (event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void, ): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); + let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); + if (!handlers) { + handlers = new Set(); + this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); + } + handlers.add(listener as AgentHarnessHandler); + return () => handlers!.delete(listener as AgentHarnessHandler); } on( @@ -645,12 +984,12 @@ export class AgentHarness< event: Extract, ) => Promise | AgentHarnessEventResultMap[TType], ): () => void { - let handlers = this.hooks.get(type); + let handlers = this.handlers.get(type); if (!handlers) { handlers = new Set(); - this.hooks.set(type, handlers); + this.handlers.set(type, handlers); } - handlers.add(handler as any); - return () => handlers!.delete(handler as any); + handlers.add(handler as AgentHarnessHandler); + return () => handlers!.delete(handler as AgentHarnessHandler); } } diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index e44bf458..6ca4a430 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,115 +1,81 @@ -/** - * Branch summarization for tree navigation. - * - * When navigating to a different point in the session tree, this generates - * a summary of the branch being left so context isn't lost. - */ - -import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; +import type { Model } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../types.js"; +import type { AgentMessage } from "../../types.ts"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "../messages.js"; -import type { Session, SessionTreeEntry } from "../types.js"; -import { estimateTokens } from "./compaction.js"; +} from "../messages.ts"; +import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts"; +import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts"; +import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { computeFileLists, createFileOps, extractFileOpsFromMessage, type FileOperations, formatFileOperations, - SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, -} from "./utils.js"; +} from "./utils.ts"; -// ============================================================================ -// Types -// ============================================================================ - -export interface BranchSummaryResult { - summary?: string; - readFiles?: string[]; - modifiedFiles?: string[]; - aborted?: boolean; - error?: string; -} - -/** Details stored in BranchSummaryEntry.details for file tracking */ +/** File-operation details stored on generated branch summary entries. */ export interface BranchSummaryDetails { + /** Files read while exploring the summarized branch. */ readFiles: string[]; + /** Files modified while exploring the summarized branch. */ modifiedFiles: string[]; } -export type { FileOperations } from "./utils.js"; +export type { FileOperations } from "./utils.ts"; +/** Prepared branch content for summarization. */ export interface BranchPreparation { - /** Messages extracted for summarization, in chronological order */ + /** Messages selected for the branch summary. */ messages: AgentMessage[]; - /** File operations extracted from tool calls */ + /** File operations extracted from the branch. */ fileOps: FileOperations; - /** Total estimated tokens in messages */ + /** Estimated token count for selected messages. */ totalTokens: number; } +/** Entries selected for branch summarization. */ export interface CollectEntriesResult { - /** Entries to summarize, in chronological order */ + /** Entries to summarize in chronological order. */ entries: SessionTreeEntry[]; - /** Common ancestor between old and new position, if any */ + /** Deepest common ancestor between the previous leaf and target entry. */ commonAncestorId: string | null; } +/** Options for generating a branch summary. */ export interface GenerateBranchSummaryOptions { - /** Model to use for summarization */ + /** Model used for summarization. */ model: Model; - /** API key for the model */ + /** API key forwarded to the provider. */ apiKey: string; - /** Request headers for the model */ + /** Optional request headers forwarded to the provider. */ headers?: Record; - /** Abort signal for cancellation */ + /** Abort signal for the summarization request. */ signal: AbortSignal; - /** Optional custom instructions for summarization */ + /** Optional instructions appended to or replacing the default prompt. */ customInstructions?: string; - /** If true, customInstructions replaces the default prompt instead of being appended */ + /** Replace the default prompt with custom instructions instead of appending them. */ replaceInstructions?: boolean; - /** Tokens reserved for prompt + LLM response (default 16384) */ + /** Tokens reserved for prompt and model output. Defaults to 16384. */ reserveTokens?: number; } -// ============================================================================ -// Entry Collection -// ============================================================================ - -/** - * Collect entries that should be summarized when navigating from one position to another. - * - * Walks from oldLeafId back to the common ancestor with targetId, collecting entries - * along the way. Does NOT stop at compaction boundaries - those are included and their - * summaries become context. - * - * @param session - Session manager (read-only access) - * @param oldLeafId - Current position (where we're navigating from) - * @param targetId - Target position (where we're navigating to) - * @returns Entries to summarize and the common ancestor - */ +/** Collect entries that should be summarized before navigating to a different session tree entry. */ export async function collectEntriesForBranchSummary( session: Session, oldLeafId: string | null, targetId: string, ): Promise { - // If no old position, nothing to summarize if (!oldLeafId) { return { entries: [], commonAncestorId: null }; } - - // Find common ancestor (deepest node that's on both paths) const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); const targetPath = await session.getBranch(targetId); - - // targetPath is root-first, so iterate backwards to find deepest common ancestor let commonAncestorId: string | null = null; for (let i = targetPath.length - 1; i >= 0; i--) { if (oldPath.has(targetPath[i].id)) { @@ -117,85 +83,48 @@ export async function collectEntriesForBranchSummary( break; } } - - // Collect entries from old leaf back to common ancestor const entries: SessionTreeEntry[] = []; let current: string | null = oldLeafId; while (current && current !== commonAncestorId) { const entry = await session.getEntry(current); - if (!entry) break; + if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`); entries.push(entry as SessionTreeEntry); current = entry.parentId; } - - // Reverse to get chronological order entries.reverse(); return { entries, commonAncestorId }; } - -// ============================================================================ -// Entry to Message Conversion -// ============================================================================ - -/** - * Extract AgentMessage from a session entry. - * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries. - */ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { switch (entry.type) { case "message": - // Skip tool results - context is in assistant's tool call if (entry.message.role === "toolResult") return undefined; - return entry.message as AgentMessage; + return entry.message; case "custom_message": - return createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); case "compaction": return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); - - // These don't contribute to conversation content case "thinking_level_change": case "model_change": case "custom": case "label": case "session_info": + case "leaf": return undefined; } } -/** - * Prepare entries for summarization with token budget. - * - * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. - * This ensures we keep the most recent context when the branch is too long. - * - * Also collects file operations from: - * - Tool calls in assistant messages - * - Existing branch_summary entries' details (for cumulative tracking) - * - * @param entries - Entries in chronological order - * @param tokenBudget - Maximum tokens to include (0 = no limit) - */ +/** Prepare branch entries for summarization within an optional token budget. */ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation { const messages: AgentMessage[] = []; const fileOps = createFileOps(); let totalTokens = 0; - - // First pass: collect file ops from ALL entries (even if they don't fit in token budget) - // This ensures we capture cumulative file tracking from nested branch summaries - // Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones for (const entry of entries) { if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { const details = entry.details as BranchSummaryDetails; @@ -203,35 +132,26 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n for (const f of details.readFiles) fileOps.read.add(f); } if (Array.isArray(details.modifiedFiles)) { - // Modified files go into both edited and written for proper deduplication for (const f of details.modifiedFiles) { fileOps.edited.add(f); } } } } - - // Second pass: walk from newest to oldest, adding messages until token budget for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const message = getMessageFromEntry(entry); if (!message) continue; - - // Extract file ops from assistant messages (tool calls) extractFileOpsFromMessage(message, fileOps); const tokens = estimateTokens(message); - - // Check budget before adding if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { - // If this is a summary entry, try to fit it anyway as it's important context if (entry.type === "compaction" || entry.type === "branch_summary") { if (totalTokens < tokenBudget * 0.9) { messages.unshift(message); totalTokens += tokens; } } - // Stop - we've hit the budget break; } @@ -242,10 +162,6 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n return { messages, fileOps, totalTokens }; } -// ============================================================================ -// Summary Generation -// ============================================================================ - const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. Summary of that exploration: @@ -280,34 +196,22 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -/** - * Generate a summary of abandoned branch entries. - * - * @param entries - Session entries to summarize (chronological order) - * @param options - Generation options - */ +/** Generate a summary for abandoned branch entries. */ export async function generateBranchSummary( entries: SessionTreeEntry[], options: GenerateBranchSummaryOptions, -): Promise { +): Promise> { const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; - - // Token budget = context window minus reserved space for prompt + response const contextWindow = model.contextWindow || 128000; const tokenBudget = contextWindow - reserveTokens; const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); if (messages.length === 0) { - return { summary: "No content to summarize" }; + return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] }); } - - // Transform to LLM-compatible messages, then serialize to text - // Serialization prevents the model from treating it as a conversation to continue const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); - - // Build prompt let instructions: string; if (replaceInstructions && customInstructions) { instructions = customInstructions; @@ -325,37 +229,34 @@ export async function generateBranchSummary( timestamp: Date.now(), }, ]; - - // Call LLM for summarization const response = await completeSimple( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { apiKey, headers, signal, maxTokens: 2048 }, ); - - // Check if aborted or errored if (response.stopReason === "aborted") { - return { aborted: true }; + return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted")); } if (response.stopReason === "error") { - return { error: response.errorMessage || "Summarization failed" }; + return err( + new BranchSummaryError( + "summarization_failed", + `Branch summary failed: ${response.errorMessage || "Unknown error"}`, + ), + ); } let summary = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); - - // Prepend preamble to provide context about the branch summary summary = BRANCH_SUMMARY_PREAMBLE + summary; - - // Compute file lists and append to summary const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); - return { + return ok({ summary: summary || "No summary generated", readFiles, modifiedFiles, - }; + }); } diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 0776222b..aa7b32c6 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,56 +1,47 @@ -/** - * Context compaction for long sessions. - * - * Pure functions for compaction logic. The session manager handles I/O, - * and after compaction the session is reloaded. - */ - import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; -import type { AgentMessage, ThinkingLevel } from "../../types.js"; +import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "../messages.js"; -import { buildSessionContext } from "../session/session.js"; -import type { CompactionEntry, SessionTreeEntry } from "../types.js"; +} from "../messages.ts"; +import { buildSessionContext } from "../session/session.ts"; +import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts"; import { computeFileLists, createFileOps, extractFileOpsFromMessage, type FileOperations, formatFileOperations, - SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, -} from "./utils.js"; +} from "./utils.ts"; -// ============================================================================ -// File Operation Tracking -// ============================================================================ - -/** Details stored in CompactionEntry.details for file tracking */ +/** File-operation details stored on generated compaction entries. */ export interface CompactionDetails { + /** Files read in the compacted history. */ readFiles: string[]; + /** Files modified in the compacted history. */ modifiedFiles: string[]; } +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} -/** - * Extract file operations from messages and previous compaction entries. - */ function extractFileOperations( messages: AgentMessage[], entries: SessionTreeEntry[], prevCompactionIndex: number, ): FileOperations { const fileOps = createFileOps(); - - // Collect from previous compaction's details (if pi-generated) if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; if (!prevCompaction.fromHook && prevCompaction.details) { - // fromHook field kept for session file compatibility const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -60,23 +51,12 @@ function extractFileOperations( } } } - - // Extract from tool calls in messages for (const msg of messages) { extractFileOpsFromMessage(msg, fileOps); } return fileOps; } - -// ============================================================================ -// Message Extraction -// ============================================================================ - -/** - * Extract AgentMessage from an entry if it produces one. - * Returns undefined for entries that don't contribute to LLM context. - */ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { if (entry.type === "message") { return entry.message as AgentMessage; @@ -106,47 +86,39 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage return getMessageFromEntry(entry); } -/** Result from compact() - SessionManager adds uuid/parentUuid when saving */ +/** Generated compaction data ready to be persisted as a compaction entry. */ export interface CompactionResult { + /** Summary text that replaces compacted history in future context. */ summary: string; + /** Entry id where retained history starts. */ firstKeptEntryId: string; + /** Estimated context tokens before compaction. */ tokensBefore: number; - /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + /** Optional implementation-specific details stored with the compaction entry. */ details?: T; } -// ============================================================================ -// Types -// ============================================================================ - +/** Compaction thresholds and retention settings. */ export interface CompactionSettings { + /** Enable automatic compaction decisions. */ enabled: boolean; + /** Tokens reserved for summary prompt and output. */ reserveTokens: number; + /** Approximate recent-context tokens to keep after compaction. */ keepRecentTokens: number; } +/** Default compaction settings used by the harness. */ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, }; -// ============================================================================ -// Token calculation -// ============================================================================ - -/** - * Calculate total context tokens from usage. - * Uses the native totalTokens field when available, falls back to computing from components. - */ +/** Calculate total context tokens from provider usage. */ export function calculateContextTokens(usage: Usage): number { return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; } - -/** - * Get usage from an assistant message if available. - * Skips aborted and error messages as they don't have valid usage data. - */ function getAssistantUsage(msg: AgentMessage): Usage | undefined { if (msg.role === "assistant" && "usage" in msg) { const assistantMsg = msg as AssistantMessage; @@ -157,9 +129,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined { return undefined; } -/** - * Find the last non-aborted assistant message usage from session entries. - */ +/** Return usage from the last successful assistant message in session entries. */ export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -171,10 +141,15 @@ export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | unde return undefined; } +/** Estimated context-token usage for a message list. */ export interface ContextUsageEstimate { + /** Estimated total context tokens. */ tokens: number; + /** Tokens reported by the most recent assistant usage block. */ usageTokens: number; + /** Estimated tokens after the most recent assistant usage block. */ trailingTokens: number; + /** Index of the message that provided usage, or null when none exists. */ lastUsageIndex: number | null; } @@ -186,10 +161,7 @@ function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; in return undefined; } -/** - * Estimate context tokens from messages, using the last assistant usage when available. - * If there are messages after the last usage, estimate their tokens with estimateTokens. - */ +/** Estimate context tokens for messages using provider usage when available. */ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { const usageInfo = getLastAssistantUsageInfo(messages); @@ -220,22 +192,13 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst }; } -/** - * Check if compaction should trigger based on context usage. - */ +/** Return whether context usage exceeds the configured compaction threshold. */ export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { if (!settings.enabled) return false; return contextTokens > contextWindow - settings.reserveTokens; } -// ============================================================================ -// Cut point detection -// ============================================================================ - -/** - * Estimate token count for a message using chars/4 heuristic. - * This is conservative (overestimates tokens). - */ +/** Estimate token count for one message using a conservative character heuristic. */ export function estimateTokens(message: AgentMessage): number { let chars = 0; @@ -261,7 +224,7 @@ export function estimateTokens(message: AgentMessage): number { } else if (block.type === "thinking") { chars += block.thinking.length; } else if (block.type === "toolCall") { - chars += block.name.length + JSON.stringify(block.arguments).length; + chars += block.name.length + safeJsonStringify(block.arguments).length; } } return Math.ceil(chars / 4); @@ -276,7 +239,7 @@ export function estimateTokens(message: AgentMessage): number { chars += block.text.length; } if (block.type === "image") { - chars += 4800; // Estimate images as 4000 chars, or 1200 tokens + chars += 4800; } } } @@ -295,14 +258,6 @@ export function estimateTokens(message: AgentMessage): number { return 0; } - -/** - * Find valid cut points: indices of user, assistant, custom, or bashExecution messages. - * Never cut at tool results (they must follow their tool call). - * When we cut at an assistant message with tool calls, its tool results follow it - * and will be kept. - * BashExecutionMessage is treated like a user message (user-initiated context). - */ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] { const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { @@ -332,10 +287,9 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end case "custom_message": case "label": case "session_info": + case "leaf": break; } - - // branch_summary and custom_message are user-role messages, valid cut points if (entry.type === "branch_summary" || entry.type === "custom_message") { cutPoints.push(i); } @@ -343,15 +297,10 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end return cutPoints; } -/** - * Find the user message (or bashExecution) that starts the turn containing the given entry index. - * Returns -1 if no turn start found before the index. - * BashExecutionMessage is treated like a user message for turn boundaries. - */ +/** Find the user-visible message that starts the turn containing an entry. */ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number { for (let i = entryIndex; i >= startIndex; i--) { const entry = entries[i]; - // branch_summary and custom_message are user-role messages, can start a turn if (entry.type === "branch_summary" || entry.type === "custom_message") { return i; } @@ -365,31 +314,17 @@ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: numb return -1; } +/** Cut point selected for compaction. */ export interface CutPointResult { - /** Index of first entry to keep */ + /** Index of the first entry retained after compaction. */ firstKeptEntryIndex: number; - /** Index of user message that starts the turn being split, or -1 if not splitting */ + /** Index of the turn-start entry when the cut splits a turn, otherwise -1. */ turnStartIndex: number; - /** Whether this cut splits a turn (cut point is not a user message) */ + /** Whether the selected cut point splits an in-progress turn. */ isSplitTurn: boolean; } -/** - * Find the cut point in session entries that keeps approximately `keepRecentTokens`. - * - * Algorithm: Walk backwards from newest, accumulating estimated message sizes. - * Stop when we've accumulated >= keepRecentTokens. Cut at that point. - * - * Can cut at user OR assistant messages (never tool results). When cutting at an - * assistant message with tool calls, its tool results come after and will be kept. - * - * Returns CutPointResult with: - * - firstKeptEntryIndex: the entry index to start keeping from - * - turnStartIndex: if cutting mid-turn, the user message that started that turn - * - isSplitTurn: whether we're cutting in the middle of a turn - * - * Only considers entries between `startIndex` and `endIndex` (exclusive). - */ +/** Find the compaction cut point that keeps approximately the requested recent-token budget. */ export function findCutPoint( entries: SessionTreeEntry[], startIndex: number, @@ -401,22 +336,15 @@ export function findCutPoint( if (cutPoints.length === 0) { return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; } - - // Walk backwards from newest, accumulating estimated message sizes let accumulatedTokens = 0; - let cutIndex = cutPoints[0]; // Default: keep from first message (not header) + let cutIndex = cutPoints[0]; for (let i = endIndex - 1; i >= startIndex; i--) { const entry = entries[i]; if (entry.type !== "message") continue; - - // Estimate this message's size const messageTokens = estimateTokens(entry.message as AgentMessage); accumulatedTokens += messageTokens; - - // Check if we've exceeded the budget if (accumulatedTokens >= keepRecentTokens) { - // Find the closest valid cut point at or after this entry for (let c = 0; c < cutPoints.length; c++) { if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; @@ -426,23 +354,16 @@ export function findCutPoint( break; } } - - // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.) while (cutIndex > startIndex) { const prevEntry = entries[cutIndex - 1]; - // Stop at session header or compaction boundaries if (prevEntry.type === "compaction") { break; } if (prevEntry.type === "message") { - // Stop if we hit any message break; } - // Include this non-message entry (bash, settings change, etc.) cutIndex--; } - - // Determine if this is a split turn const cutEntry = entries[cutIndex]; const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); @@ -454,9 +375,9 @@ export function findCutPoint( }; } -// ============================================================================ -// Summarization -// ============================================================================ +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. @@ -530,10 +451,7 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -/** - * Generate a summary of the conversation using the LLM. - * If previousSummary is provided, uses the update prompt to merge. - */ +/** Generate or update a conversation summary for compaction. */ export async function generateSummary( currentMessages: AgentMessage[], model: Model, @@ -544,21 +462,17 @@ export async function generateSummary( customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, -): Promise { - const maxTokens = Math.floor(0.8 * reserveTokens); - - // Use update prompt if we have a previous summary, otherwise initial prompt +): Promise> { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - - // Serialize conversation to text so model doesn't try to continue it - // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) const llmMessages = convertToLlm(currentMessages); const conversationText = serializeConversation(llmMessages); - - // Build the prompt with conversation wrapped in tags let promptText = `\n${conversationText}\n\n\n`; if (previousSummary) { promptText += `\n${previousSummary}\n\n\n`; @@ -583,9 +497,16 @@ export async function generateSummary( { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, ); - + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); + } if (response.stopReason === "error") { - throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + return err( + new CompactionError( + "summarization_failed", + `Summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); } const textContent = response.content @@ -593,37 +514,36 @@ export async function generateSummary( .map((c) => c.text) .join("\n"); - return textContent; + return ok(textContent); } -// ============================================================================ -// Compaction Preparation (for extensions) -// ============================================================================ - +/** Prepared inputs for a compaction run. */ export interface CompactionPreparation { - /** UUID of first entry to keep */ + /** Entry id where retained history starts. */ firstKeptEntryId: string; - /** Messages that will be summarized and discarded */ + /** Messages summarized into the history summary. */ messagesToSummarize: AgentMessage[]; - /** Messages that will be turned into turn prefix summary (if splitting) */ + /** Prefix messages summarized separately when compaction splits a turn. */ turnPrefixMessages: AgentMessage[]; - /** Whether this is a split turn (cut point in middle of turn) */ + /** Whether compaction splits a turn. */ isSplitTurn: boolean; + /** Estimated context tokens before compaction. */ tokensBefore: number; - /** Summary from previous compaction, for iterative update */ + /** Previous compaction summary used for iterative updates. */ previousSummary?: string; - /** File operations extracted from messagesToSummarize */ + /** File operations extracted from summarized history. */ fileOps: FileOperations; - /** Compaction settions from settings.jsonl */ + /** Settings used to prepare compaction. */ settings: CompactionSettings; } +/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ export function prepareCompaction( pathEntries: SessionTreeEntry[], settings: CompactionSettings, -): CompactionPreparation | undefined { - if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { - return undefined; +): Result { + if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { + return ok(undefined); } let prevCompactionIndex = -1; @@ -647,24 +567,18 @@ export function prepareCompaction( const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); - - // Get UUID of first kept entry const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; if (!firstKeptEntry?.id) { - return undefined; // Session needs migration + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); } const firstKeptEntryId = firstKeptEntry.id; const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; - - // Messages to summarize (will be discarded after summary) const messagesToSummarize: AgentMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { const msg = getMessageFromEntryForCompaction(pathEntries[i]); if (msg) messagesToSummarize.push(msg); } - - // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { @@ -672,18 +586,14 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } - - // Extract file operations from messages and previous compaction const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); - - // Also extract file ops from turn prefix if splitting if (cutPoint.isSplitTurn) { for (const msg of turnPrefixMessages) { extractFileOpsFromMessage(msg, fileOps); } } - return { + return ok({ firstKeptEntryId, messagesToSummarize, turnPrefixMessages, @@ -692,13 +602,9 @@ export function prepareCompaction( previousSummary, fileOps, settings, - }; + }); } -// ============================================================================ -// Main compaction function -// ============================================================================ - const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. Summarize the prefix to provide context for the retained suffix: @@ -714,15 +620,9 @@ Summarize the prefix to provide context for the retained suffix: Be concise. Focus on what's needed to understand the kept suffix.`; -/** - * Generate summaries for compaction using prepared data. - * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. - * - * @param preparation - Pre-calculated preparation from prepareCompaction() - * @param customInstructions - Optional custom focus for the summary - */ -export { serializeConversation } from "./utils.js"; +export { serializeConversation } from "./utils.ts"; +/** Generate compaction summary data from prepared session history. */ export async function compact( preparation: CompactionPreparation, model: Model, @@ -731,7 +631,7 @@ export async function compact( customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, -): Promise { +): Promise> { const { firstKeptEntryId, messagesToSummarize, @@ -743,11 +643,13 @@ export async function compact( settings, } = preparation; - // Generate summaries (can be parallel if both needed) and merge into one + if (!firstKeptEntryId) { + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); + } + let summary: string; if (isSplitTurn && turnPrefixMessages.length > 0) { - // Generate both summaries in parallel const [historyResult, turnPrefixResult] = await Promise.all([ messagesToSummarize.length > 0 ? generateSummary( @@ -761,7 +663,7 @@ export async function compact( previousSummary, thinkingLevel, ) - : Promise.resolve("No prior history."), + : Promise.resolve(ok("No prior history.")), generateTurnPrefixSummary( turnPrefixMessages, model, @@ -772,11 +674,11 @@ export async function compact( thinkingLevel, ), ]); - // Merge into single summary - summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`; + if (!historyResult.ok) return err(historyResult.error); + if (!turnPrefixResult.ok) return err(turnPrefixResult.error); + summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; } else { - // Just generate history summary - summary = await generateSummary( + const summaryResult = await generateSummary( messagesToSummarize, model, settings.reserveTokens, @@ -787,27 +689,20 @@ export async function compact( previousSummary, thinkingLevel, ); + if (!summaryResult.ok) return err(summaryResult.error); + summary = summaryResult.value; } - // Compute file lists and append to summary const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); - if (!firstKeptEntryId) { - throw new Error("First kept entry has no UUID - session may need migration"); - } - - return { + return ok({ summary, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles } as CompactionDetails, - }; + }); } - -/** - * Generate a summary for a turn prefix (when splitting a turn). - */ async function generateTurnPrefixSummary( messages: AgentMessage[], model: Model, @@ -816,8 +711,11 @@ async function generateTurnPrefixSummary( headers?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, -): Promise { - const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix +): Promise> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; @@ -836,13 +734,22 @@ async function generateTurnPrefixSummary( ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } : { maxTokens, signal, apiKey, headers }, ); - + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted")); + } if (response.stopReason === "error") { - throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + return err( + new CompactionError( + "summarization_failed", + `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); } - return response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + return ok( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); } diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 35cd1938..07535b79 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -1,20 +1,17 @@ -/** - * Shared utilities for compaction and branch summarization. - */ - import type { Message } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../types.js"; - -// ============================================================================ -// File Operation Tracking -// ============================================================================ +import type { AgentMessage } from "../../types.ts"; +/** File paths touched by a session branch or compaction range. */ export interface FileOperations { + /** Files read but not necessarily modified. */ read: Set; + /** Files written by full-file write operations. */ written: Set; + /** Files modified by edit operations. */ edited: Set; } +/** Create an empty file-operation accumulator. */ export function createFileOps(): FileOperations { return { read: new Set(), @@ -23,9 +20,7 @@ export function createFileOps(): FileOperations { }; } -/** - * Extract file operations from tool calls in an assistant message. - */ +/** Add file operations from assistant tool calls to an accumulator. */ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { if (message.role !== "assistant") return; if (!("content" in message) || !Array.isArray(message.content)) return; @@ -55,10 +50,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp } } -/** - * Compute final file lists from file operations. - * Returns readFiles (files only read, not modified) and modifiedFiles. - */ +/** Compute sorted read-only and modified file lists from accumulated operations. */ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { const modified = new Set([...fileOps.edited, ...fileOps.written]); const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); @@ -66,9 +58,7 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[] return { readFiles: readOnly, modifiedFiles }; } -/** - * Format file operations as XML tags for summary. - */ +/** Format file lists as summary metadata tags. */ export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { const sections: string[] = []; if (readFiles.length > 0) { @@ -81,31 +71,23 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[ return `\n\n${sections.join("\n\n")}`; } -// ============================================================================ -// Message Serialization -// ============================================================================ - -/** Maximum characters for a tool result in serialized summaries. */ const TOOL_RESULT_MAX_CHARS = 2000; -/** - * Truncate text to a maximum character length for summarization. - * Keeps the beginning and appends a truncation marker. - */ +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + function truncateForSummary(text: string, maxChars: number): string { if (text.length <= maxChars) return text; const truncatedChars = text.length - maxChars; return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; } -/** - * Serialize LLM messages to text for summarization. - * This prevents the model from treating it as a conversation to continue. - * Call convertToLlm() first to handle custom message types. - * - * Tool results are truncated to keep the summarization request within - * reasonable token budgets. Full content is not needed for summarization. - */ +/** Serialize LLM messages to plain text for summarization prompts. */ export function serializeConversation(messages: Message[]): string { const parts: string[] = []; @@ -132,7 +114,7 @@ export function serializeConversation(messages: Message[]): string { } else if (block.type === "toolCall") { const args = block.arguments as Record; const argsStr = Object.entries(args) - .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .map(([k, v]) => `${k}=${safeJsonStringify(v)}`) .join(", "); toolCalls.push(`${block.name}(${argsStr})`); } @@ -160,11 +142,3 @@ export function serializeConversation(messages: Message[]): string { return parts.join("\n\n"); } - -// ============================================================================ -// Summarization System Prompt -// ============================================================================ - -export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. - -Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index d4b6c0a9..e56e7aeb 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -1,33 +1,61 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { constants } from "node:fs"; -import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { constants, createReadStream } from "node:fs"; +import { + access, + appendFile, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; -import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js"; +import { createInterface } from "node:readline"; +import { + type ExecutionEnv, + ExecutionError, + err, + FileError, + type FileInfo, + type FileKind, + ok, + type Result, + toError, +} from "../types.ts"; function resolvePath(cwd: string, path: string): string { return isAbsolute(path) ? path : resolve(cwd, path); } -function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind { +function fileKindFromStats(stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): FileKind | undefined { if (stats.isFile()) return "file"; if (stats.isDirectory()) return "directory"; if (stats.isSymbolicLink()) return "symlink"; - throw new FileError("invalid", "Unsupported file type"); + return undefined; } function fileInfoFromStats( path: string, stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number }, -): FileInfo { - return { +): Result { + const kind = fileKindFromStats(stats); + if (!kind) return err(new FileError("invalid", "Unsupported file type", path)); + return ok({ name: path.replace(/\/+$/, "").split("/").pop() ?? path, path, - kind: fileKindFromStats(stats), + kind, size: stats.size, mtimeMs: stats.mtimeMs, - }; + }); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { @@ -36,23 +64,30 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException { function toFileError(error: unknown, path?: string): FileError { if (error instanceof FileError) return error; + const cause = toError(error); if (isNodeError(error)) { const message = error.message; switch (error.code) { + case "ABORT_ERR": + return new FileError("aborted", message, path, cause); case "ENOENT": - return new FileError("not_found", message, path, { cause: error }); + return new FileError("not_found", message, path, cause); case "EACCES": case "EPERM": - return new FileError("permission_denied", message, path, { cause: error }); + return new FileError("permission_denied", message, path, cause); case "ENOTDIR": - return new FileError("not_directory", message, path, { cause: error }); + return new FileError("not_directory", message, path, cause); case "EISDIR": - return new FileError("is_directory", message, path, { cause: error }); + return new FileError("is_directory", message, path, cause); case "EINVAL": - return new FileError("invalid", message, path, { cause: error }); + return new FileError("invalid", message, path, cause); } } - return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error }); + return new FileError("unknown", cause.message, path, cause); +} + +function abortResult(signal: AbortSignal | undefined, path?: string): Result | undefined { + return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined; } async function pathExists(path: string): Promise { @@ -71,7 +106,16 @@ async function runCommand( ): Promise<{ stdout: string; status: number | null }> { return await new Promise((resolve) => { let stdout = ""; - const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] }); + let child: ReturnType; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + } catch { + resolve({ stdout: "", status: null }); + return; + } const timeout = setTimeout(() => { if (child.pid) killProcessTree(child.pid); }, timeoutMs); @@ -100,12 +144,14 @@ async function findBashOnPath(): Promise { return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; } -async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> { +async function getShellConfig( + customShellPath?: string, +): Promise> { if (customShellPath) { if (await pathExists(customShellPath)) { - return { shell: customShellPath, args: ["-c"] }; + return ok({ shell: customShellPath, args: ["-c"] }); } - throw new Error(`Custom shell path not found: ${customShellPath}`); + return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`)); } if (process.platform === "win32") { const candidates: string[] = []; @@ -115,24 +161,24 @@ async function getShellConfig(customShellPath?: string): Promise<{ shell: string if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); for (const candidate of candidates) { if (await pathExists(candidate)) { - return { shell: candidate, args: ["-c"] }; + return ok({ shell: candidate, args: ["-c"] }); } } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return ok({ shell: bashOnPath, args: ["-c"] }); } - throw new Error("No bash shell found"); + return err(new ExecutionError("shell_unavailable", "No bash shell found")); } if (await pathExists("/bin/bash")) { - return { shell: "/bin/bash", args: ["-c"] }; + return ok({ shell: "/bin/bash", args: ["-c"] }); } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return ok({ shell: bashOnPath, args: ["-c"] }); } - return { shell: "sh", args: ["-c"] }; + return ok({ shell: "sh", args: ["-c"] }); } function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record): NodeJS.ProcessEnv { @@ -149,6 +195,7 @@ function killProcessTree(pid: number): void { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, + windowsHide: true, }); } catch { // Ignore errors. @@ -178,52 +225,83 @@ export class NodeExecutionEnv implements ExecutionEnv { this.shellEnv = options.shellEnv; } + async absolutePath(path: string): Promise> { + return ok(resolvePath(this.cwd, path)); + } + + async joinPath(parts: string[]): Promise> { + return ok(join(...parts)); + } + async exec( command: string, options?: { cwd?: string; env?: Record; timeout?: number; - signal?: AbortSignal; + abortSignal?: AbortSignal; onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void; }, - ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; - const { shell, args } = await getShellConfig(this.shellPath); + ): Promise> { + if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted")); - return await new Promise((resolvePromise, reject) => { + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const shellConfig = await getShellConfig(this.shellPath); + if (!shellConfig.ok) return shellConfig; + + return await new Promise((resolvePromise) => { let stdout = ""; let stderr = ""; let settled = false; let timedOut = false; - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: getShellEnv(this.shellEnv, options?.env), - stdio: ["ignore", "pipe", "pipe"], - }); + let callbackError: ExecutionError | undefined; + let child: ReturnType | undefined; + let timeoutId: ReturnType | undefined; - const timeoutId = + const onAbort = () => { + if (child?.pid) { + killProcessTree(child.pid); + } + }; + + const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + resolvePromise(result); + }; + + try { + child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + const cause = toError(error); + settle(err(new ExecutionError("spawn_error", cause.message, cause))); + return; + } + + timeoutId = typeof options?.timeout === "number" ? setTimeout(() => { timedOut = true; - if (child.pid) { + if (child?.pid) { killProcessTree(child.pid); } }, options.timeout * 1000) : undefined; - const onAbort = () => { - if (child.pid) { - killProcessTree(child.pid); - } - }; - if (options?.signal) { - if (options.signal.aborted) { + if (options?.abortSignal) { + if (options.abortSignal.aborted) { onAbort(); } else { - options.signal.addEventListener("abort", onAbort, { once: true }); + options.abortSignal.addEventListener("abort", onAbort, { once: true }); } } @@ -231,137 +309,217 @@ export class NodeExecutionEnv implements ExecutionEnv { child.stderr?.setEncoding("utf8"); child.stdout?.on("data", (chunk: string) => { stdout += chunk; - options?.onStdout?.(chunk); + try { + options?.onStdout?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } }); child.stderr?.on("data", (chunk: string) => { stderr += chunk; - options?.onStderr?.(chunk); + try { + options?.onStderr?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } }); child.on("error", (error) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) options.signal.removeEventListener("abort", onAbort); - if (settled) return; - settled = true; - reject(error); + settle(err(new ExecutionError("spawn_error", error.message, error))); }); child.on("close", (code) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) options.signal.removeEventListener("abort", onAbort); - if (settled) return; - settled = true; - if (options?.signal?.aborted) { - reject(new Error("aborted")); + if (callbackError) { + settle(err(callbackError)); return; } if (timedOut) { - reject(new Error(`timeout:${options?.timeout}`)); + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); return; } - resolvePromise({ stdout, stderr, exitCode: code ?? 0 }); + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); }); }); } - async readTextFile(path: string): Promise { + async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; try { - return await readFile(resolved, "utf8"); + return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal })); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async readBinaryFile(path: string): Promise { + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(options?.abortSignal, resolved); + if (aborted) return aborted; + if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]); + let stream: ReturnType | undefined; + let lineReader: ReturnType | undefined; try { - return await readFile(resolved); + stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal }); + lineReader = createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + for await (const line of lineReader) { + const loopAbort = abortResult(options?.abortSignal, resolved); + if (loopAbort) return loopAbort; + lines.push(line); + if (options?.maxLines !== undefined && lines.length >= options.maxLines) break; + } + const afterReadAbort = abortResult(options?.abortSignal, resolved); + if (afterReadAbort) return afterReadAbort; + return ok(lines); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); + } finally { + lineReader?.close(); + stream?.destroy(); } } - async writeFile(path: string, content: string | Uint8Array): Promise { + async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + return ok(await readFile(resolved, { signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult(abortSignal, resolved); + if (afterMkdirAbort) return afterMkdirAbort; + await writeFile(resolved, content, { signal: abortSignal }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise> { const resolved = resolvePath(this.cwd, path); try { await mkdir(resolve(resolved, ".."), { recursive: true }); - await writeFile(resolved, content); + await appendFile(resolved, content); + return ok(undefined); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async fileInfo(path: string): Promise { + async fileInfo(path: string): Promise> { const resolved = resolvePath(this.cwd, path); try { return fileInfoFromStats(resolved, await lstat(resolved)); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async listDir(path: string): Promise { + async listDir(path: string, abortSignal?: AbortSignal): Promise> { const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; try { const entries = await readdir(resolved, { withFileTypes: true }); const infos: FileInfo[] = []; for (const entry of entries) { + const loopAbort = abortResult(abortSignal, resolved); + if (loopAbort) return loopAbort; const entryPath = resolve(resolved, entry.name); try { - infos.push(fileInfoFromStats(entryPath, await lstat(entryPath))); + const info = fileInfoFromStats(entryPath, await lstat(entryPath)); + if (info.ok) infos.push(info.value); } catch (error) { - if (error instanceof FileError && error.code === "invalid") continue; - throw error; + return err(toFileError(error, entryPath)); } } - return infos; + return ok(infos); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async realPath(path: string): Promise { + async canonicalPath(path: string): Promise> { const resolved = resolvePath(this.cwd, path); try { - return await realpath(resolved); + return ok(await realpath(resolved)); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async exists(path: string): Promise { + async exists(path: string): Promise> { + const result = await this.fileInfo(path); + if (result.ok) return ok(true); + if (result.error.code === "not_found") return ok(false); + return err(result.error); + } + + async createDir(path: string, options?: { recursive?: boolean }): Promise> { + const resolved = resolvePath(this.cwd, path); try { - await this.fileInfo(path); - return true; + await mkdir(resolved, { recursive: options?.recursive ?? true }); + return ok(undefined); } catch (error) { - if (error instanceof FileError && error.code === "not_found") return false; - throw error; + return err(toFileError(error, resolved)); } } - async createDir(path: string, options?: { recursive?: boolean }): Promise { - await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive }); - } - - async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise> { const resolved = resolvePath(this.cwd, path); try { await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false }); + return ok(undefined); } catch (error) { - throw toFileError(error, resolved); + return err(toFileError(error, resolved)); } } - async createTempDir(prefix: string = "tmp-"): Promise { - return await mkdtemp(join(tmpdir(), prefix)); + async createTempDir(prefix: string = "tmp-"): Promise> { + try { + return ok(await mkdtemp(join(tmpdir(), prefix))); + } catch (error) { + return err(toFileError(error)); + } } - async createTempFile(options?: { prefix?: string; suffix?: string }): Promise { + async createTempFile(options?: { prefix?: string; suffix?: string }): Promise> { const dir = await this.createTempDir("tmp-"); - const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); - await writeFile(filePath, ""); - return filePath; + if (!dir.ok) return dir; + const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); + try { + await writeFile(filePath, ""); + return ok(filePath); + } catch (error) { + return err(toFileError(error, filePath)); + } } async cleanup(): Promise { diff --git a/packages/agent/src/harness/execution-env.ts b/packages/agent/src/harness/execution-env.ts deleted file mode 100644 index 786586e5..00000000 --- a/packages/agent/src/harness/execution-env.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { NodeExecutionEnv } from "./env/nodejs.js"; -export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js"; -export { FileError } from "./types.js"; diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 615bf1e4..36ce96a1 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -1,5 +1,5 @@ import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../types.js"; +import type { AgentMessage } from "../types.ts"; export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: @@ -51,7 +51,7 @@ export interface CompactionSummaryMessage { timestamp: number; } -declare module "../types.js" { +declare module "../types.ts" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; custom: CustomMessage; diff --git a/packages/agent/src/harness/prompt-templates.ts b/packages/agent/src/harness/prompt-templates.ts index e77682b8..6c1b6be0 100644 --- a/packages/agent/src/harness/prompt-templates.ts +++ b/packages/agent/src/harness/prompt-templates.ts @@ -1,10 +1,14 @@ import { parse } from "yaml"; -import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js"; +import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts"; + +export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed"; /** Warning produced while loading prompt templates. */ export interface PromptTemplateDiagnostic { /** Diagnostic severity. Currently only warnings are emitted. */ type: "warning"; + /** Stable diagnostic code. */ + code: PromptTemplateDiagnosticCode; /** Human-readable diagnostic message. */ message: string; /** Path associated with the diagnostic. */ @@ -30,9 +34,20 @@ export async function loadPromptTemplates( const promptTemplates: PromptTemplate[] = []; const diagnostics: PromptTemplateDiagnostic[] = []; for (const path of Array.isArray(paths) ? paths : [paths]) { - const info = await safeFileInfo(env, path); - if (!info) continue; - const kind = await resolveKind(env, info); + const infoResult = await env.fileInfo(path); + if (!infoResult.ok) { + if (infoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: infoResult.error.message, + path, + }); + } + continue; + } + const info = infoResult.value; + const kind = await resolveKind(env, info, diagnostics); if (kind === "directory") { const result = await loadTemplatesFromDir(env, info.path); promptTemplates.push(...result.promptTemplates); @@ -83,20 +98,20 @@ async function loadTemplatesFromDir( ): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { const promptTemplates: PromptTemplate[] = []; const diagnostics: PromptTemplateDiagnostic[] = []; - let entries: FileInfo[]; - try { - entries = await env.listDir(dir); - } catch (error) { + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { diagnostics.push({ type: "warning", - message: errorMessage(error, "failed to list prompt template directory"), + code: "list_failed", + message: entriesResult.error.message, path: dir, }); return { promptTemplates, diagnostics }; } + const entries = entriesResult.value; for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - const kind = await resolveKind(env, entry); + const kind = await resolveKind(env, entry, diagnostics); if (kind !== "file" || !entry.name.endsWith(".md")) continue; const result = await loadTemplateFromFile(env, entry.path); if (result.promptTemplate) promptTemplates.push(result.promptTemplate); @@ -110,60 +125,92 @@ async function loadTemplateFromFile( filePath: string, ): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { const diagnostics: PromptTemplateDiagnostic[] = []; - try { - const rawContent = await env.readTextFile(filePath); - const { frontmatter, body } = parseFrontmatter(rawContent); - const firstLine = body.split("\n").find((line) => line.trim()); - let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; - if (!description && firstLine) { - description = firstLine.slice(0, 60); - if (firstLine.length > 60) description += "..."; - } - return { - promptTemplate: { - name: basenameEnvPath(filePath).replace(/\.md$/i, ""), - description, - content: body, - }, - diagnostics, - }; - } catch (error) { + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { diagnostics.push({ type: "warning", - message: errorMessage(error, "failed to load prompt template"), + code: "read_failed", + message: rawContent.error.message, path: filePath, }); return { promptTemplate: null, diagnostics }; } -} -async function safeFileInfo(env: ExecutionEnv, path: string): Promise { - try { - return await env.fileInfo(path); - } catch { - return undefined; + const parsed = parseFrontmatter(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; } + + const { frontmatter, body } = parsed.value; + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + return { + promptTemplate: { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }, + diagnostics, + }; } -async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> { +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: PromptTemplateDiagnostic[], +): Promise<"file" | "directory" | undefined> { if (info.kind === "file" || info.kind === "directory") return info.kind; - try { - const realPath = await env.realPath(info.path); - const target = await env.fileInfo(realPath); - return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; - } catch { + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } return undefined; } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; } -function parseFrontmatter>(content: string): { frontmatter: T; body: string } { - const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) return { frontmatter: {} as T, body: normalized }; - const yamlString = normalized.slice(4, endIndex); - const body = normalized.slice(endIndex + 4).trim(); - return { frontmatter: (parse(yamlString) ?? {}) as T, body }; +function parseFrontmatter>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; + } } function basenameEnvPath(path: string): string { @@ -172,10 +219,6 @@ function basenameEnvPath(path: string): string { return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); } -function errorMessage(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback; -} - /** Parse an argument string using simple shell-style single and double quotes. */ export function parseCommandArgs(argsString: string): string[] { const args: string[] = []; diff --git a/packages/agent/src/harness/session/jsonl-repo.ts b/packages/agent/src/harness/session/jsonl-repo.ts new file mode 100644 index 00000000..1a08d4d3 --- /dev/null +++ b/packages/agent/src/harness/session/jsonl-repo.ts @@ -0,0 +1,177 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types.ts"; +import { SessionError, toError } from "../types.ts"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts"; +import { + createSessionId, + createTimestamp, + getEntriesToFork, + getFileSystemResultOrThrow, + toSession, +} from "./repo-utils.ts"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "readTextLines" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise { + if (!this.sessionsRoot) { + this.sessionsRoot = getFileSystemResultOrThrow( + await this.fs.absolutePath(this.sessionsRootInput), + `Failed to resolve sessions root ${this.sessionsRootInput}`, + ); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), + `Failed to resolve session directory for ${cwd}`, + ); + } + + private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + `Failed to resolve session file path for ${sessionId}`, + ); + } + + async create(options: JsonlSessionCreateOptions): Promise> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise> { + if ( + !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) + ) { + throw new SessionError("not_found", `Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise { + const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) { + continue; + } + const files = getFileSystemResultOrThrow( + await this.fs.listDir(dir), + `Failed to list sessions in ${dir}`, + ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch (error) { + const cause = toError(error); + if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause; + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise { + getFileSystemResultOrThrow( + await this.fs.remove(metadata.path, { force: true }), + `Failed to delete session ${metadata.path}`, + ); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise { + const sessionsRoot = await this.getSessionsRoot(); + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(sessionsRoot), + `Failed to check sessions root ${sessionsRoot}`, + ) + ) { + return []; + } + const entries = getFileSystemResultOrThrow( + await this.fs.listDir(sessionsRoot), + `Failed to list sessions root ${sessionsRoot}`, + ); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/packages/agent/src/harness/session/jsonl-storage.ts b/packages/agent/src/harness/session/jsonl-storage.ts new file mode 100644 index 00000000..54ad9eb0 --- /dev/null +++ b/packages/agent/src/harness/session/jsonl-storage.ts @@ -0,0 +1,293 @@ +import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts"; +import { SessionError, toError } from "../types.ts"; +import { getFileSystemResultOrThrow } from "./repo-utils.ts"; +import { uuidv7 } from "./uuid.ts"; + +type JsonlSessionStorageFileSystem = Pick; + +interface SessionHeader { + type: "session"; + version: 3; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") return; + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) return id; + } + return uuidv7(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function invalidSession(filePath: string, message: string, cause?: Error): SessionError { + return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause); +} + +function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError { + return new SessionError( + "invalid_entry", + `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, + cause, + ); +} + +function parseHeaderLine(line: string, filePath: string): SessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidSession(filePath, "first line is not a valid session header", toError(error)); + } + if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id"); + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidSession(filePath, "session header is missing timestamp"); + } + if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd"); + if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") { + throw invalidSession(filePath, "session header parentSession must be a string"); + } + return { + type: "session", + version: 3, + id: parsed.id, + timestamp: parsed.timestamp, + cwd: parsed.cwd, + parentSession: parsed.parentSession, + }; +} + +function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); + } + if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); + if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id"); + if (parsed.parentId !== null && typeof parsed.parentId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid parentId"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidEntry(filePath, lineNumber, "is missing timestamp"); + } + if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid targetId"); + } + return parsed as unknown as SessionTreeEntry; +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path, + parentSessionPath: header.parentSession, + }; +} + +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise { + const lines = getFileSystemResultOrThrow( + await fs.readTextLines(filePath, { maxLines: 1 }), + `Failed to read session header ${filePath}`, + ); + const line = lines[0]; + if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); + throw invalidSession(filePath, "missing session header"); +} + +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ + header: SessionHeader; + entries: SessionTreeEntry[]; + leafId: string | null; +}> { + const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`); + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) { + throw invalidSession(filePath, "missing session header"); + } + + const header = parseHeaderLine(lines[0]!, filePath); + const entries: SessionTreeEntry[] = []; + let leafId: string | null = null; + for (let i = 1; i < lines.length; i++) { + const entry = parseEntryLine(lines[i]!, filePath, i + 1); + entries.push(entry); + leafId = leafIdAfterEntry(entry); + } + return { header, entries, leafId }; +} + +export class JsonlSessionStorage implements SessionStorage { + private readonly fs: JsonlSessionStorageFileSystem; + private readonly filePath: string; + private readonly metadata: JsonlSessionMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private currentLeafId: string | null; + + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; + this.metadata = headerToSessionMetadata(header, this.filePath); + this.entries = entries; + this.byId = new Map(entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(entries); + this.currentLeafId = leafId; + } + + static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); + } + + static async create( + fs: JsonlSessionStorageFileSystem, + filePath: string, + options: { + cwd: string; + sessionId: string; + parentSessionPath?: string; + }, + ): Promise { + const header: SessionHeader = { + type: "session", + version: 3, + id: options.sessionId, + timestamp: new Date().toISOString(), + cwd: options.cwd, + parentSession: options.parentSessionPath, + }; + getFileSystemResultOrThrow( + await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), + `Failed to create session ${filePath}`, + ); + return new JsonlSessionStorage(fs, filePath, header, [], null); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { + throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); + } + return this.currentLeafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.currentLeafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session leaf ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.currentLeafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session entry ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.currentLeafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter((entry): entry is Extract => entry.type === type); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); + while (current) { + path.unshift(current); + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/packages/agent/src/harness/session/repo/memory.ts b/packages/agent/src/harness/session/memory-repo.ts similarity index 82% rename from packages/agent/src/harness/session/repo/memory.ts rename to packages/agent/src/harness/session/memory-repo.ts index 3846ae2a..145bc2b7 100644 --- a/packages/agent/src/harness/session/repo/memory.ts +++ b/packages/agent/src/harness/session/memory-repo.ts @@ -1,6 +1,6 @@ -import type { Session, SessionMetadata, SessionRepo } from "../../types.js"; -import { InMemorySessionStorage } from "../storage/memory.js"; -import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js"; +import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.ts"; +import { InMemorySessionStorage } from "./memory-storage.ts"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.ts"; export class InMemorySessionRepo implements SessionRepo { private sessions = new Map>(); @@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo> { const session = this.sessions.get(metadata.id); if (!session) { - throw new Error(`Session not found: ${metadata.id}`); + throw new SessionError("not_found", `Session not found: ${metadata.id}`); } return session; } @@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo, entry: SessionTreeEntry): void { if (entry.type !== "label") return; @@ -22,42 +27,61 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map { function generateEntryId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { - const id = randomUUID().slice(0, 8); + const id = uuidv7().slice(0, 8); if (!byId.has(id)) return id; } - return randomUUID(); + return uuidv7(); } -export class InMemorySessionStorage implements SessionStorage { - private readonly metadata: SessionMetadata; +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +export class InMemorySessionStorage + implements SessionStorage +{ + private readonly metadata: TMetadata; private entries: SessionTreeEntry[]; private byId: Map; private labelsById: Map; private leafId: string | null; - constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) { + constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { this.entries = options?.entries ? [...options.entries] : []; this.byId = new Map(this.entries.map((entry) => [entry.id, entry])); this.labelsById = buildLabelsById(this.entries); - this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null; + this.leafId = null; + for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry); if (this.leafId !== null && !this.byId.has(this.leafId)) { - throw new Error(`Entry ${this.leafId} not found`); + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); } - this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() }; + this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata); } - async getMetadata(): Promise { + async getMetadata(): Promise { return this.metadata; } async getLeafId(): Promise { + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } return this.leafId; } async setLeafId(leafId: string | null): Promise { if (leafId !== null && !this.byId.has(leafId)) { - throw new Error(`Entry ${leafId} not found`); + throw new SessionError("not_found", `Entry ${leafId} not found`); } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + this.entries.push(entry); + this.byId.set(entry.id, entry); this.leafId = leafId; } @@ -69,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage { this.entries.push(entry); this.byId.set(entry.id, entry); updateLabelCache(this.labelsById, entry); - this.leafId = entry.id; + this.leafId = leafIdAfterEntry(entry); } async getEntry(id: string): Promise { @@ -90,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage { if (leafId === null) return []; const path: SessionTreeEntry[] = []; let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); while (current) { path.unshift(current); - current = current.parentId ? this.byId.get(current.parentId) : undefined; + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; } return path; } diff --git a/packages/agent/src/harness/session/repo/shared.ts b/packages/agent/src/harness/session/repo-utils.ts similarity index 54% rename from packages/agent/src/harness/session/repo/shared.ts rename to packages/agent/src/harness/session/repo-utils.ts index eb359fc2..a25b62de 100644 --- a/packages/agent/src/harness/session/repo/shared.ts +++ b/packages/agent/src/harness/session/repo-utils.ts @@ -1,6 +1,13 @@ -import { v7 as uuidv7 } from "uuid"; -import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; -import { Session } from "../session.js"; +import { + type FileError, + type Result, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.ts"; +import { Session } from "./session.ts"; +import { uuidv7 } from "./uuid.ts"; export function createSessionId(): string { return uuidv7(); @@ -14,6 +21,14 @@ export function toSession(storage: SessionSto return new Session(storage); } +export function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + export async function getEntriesToFork( storage: SessionStorage, options: { entryId?: string; position?: "before" | "at" }, @@ -21,14 +36,14 @@ export async function getEntriesToFork( if (!options.entryId) return storage.getEntries(); const target = await storage.getEntry(options.entryId); if (!target) { - throw new Error(`Entry ${options.entryId} not found`); + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`); } let effectiveLeafId: string | null; if ((options.position ?? "before") === "at") { effectiveLeafId = target.id; } else { if (target.type !== "message" || target.message.role !== "user") { - throw new Error(`Entry ${options.entryId} is not a user message`); + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`); } effectiveLeafId = target.parentId; } diff --git a/packages/agent/src/harness/session/repo/jsonl.ts b/packages/agent/src/harness/session/repo/jsonl.ts deleted file mode 100644 index a4c5ec5b..00000000 --- a/packages/agent/src/harness/session/repo/jsonl.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { constants } from "node:fs"; -import { access, mkdir, readdir, rm } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import type { - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - JsonlSessionRepoApi, - Session, -} from "../../types.js"; -import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js"; -import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js"; - -async function exists(path: string): Promise { - try { - await access(path, constants.F_OK); - return true; - } catch { - return false; - } -} - -function encodeCwd(cwd: string): string { - return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; -} - -export class JsonlSessionRepo implements JsonlSessionRepoApi { - private sessionsRoot: string; - - constructor(options: { sessionsRoot: string }) { - this.sessionsRoot = resolve(options.sessionsRoot); - } - - private getSessionDir(cwd: string): string { - return join(this.sessionsRoot, encodeCwd(cwd)); - } - - private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string { - return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`); - } - - async create(options: JsonlSessionCreateOptions): Promise> { - await mkdir(this.sessionsRoot, { recursive: true }); - const id = options.id ?? createSessionId(); - const createdAt = createTimestamp(); - const filePath = this.createSessionFilePath(options.cwd, id, createdAt); - const storage = await JsonlSessionStorage.create(filePath, { - cwd: options.cwd, - sessionId: id, - parentSessionPath: options.parentSessionPath, - }); - return toSession(storage); - } - - async open(metadata: JsonlSessionMetadata): Promise> { - if (!(await exists(metadata.path))) { - throw new Error(`Session not found: ${metadata.path}`); - } - const storage = await JsonlSessionStorage.open(metadata.path); - return toSession(storage); - } - - async list(options: JsonlSessionListOptions = {}): Promise { - const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs(); - const sessions: JsonlSessionMetadata[] = []; - for (const dir of dirs) { - if (!(await exists(dir))) continue; - const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file)); - for (const filePath of files) { - try { - sessions.push(await loadJsonlSessionMetadata(filePath)); - } catch { - // Ignore invalid session files when listing a directory. - } - } - } - sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - return sessions; - } - - async delete(metadata: JsonlSessionMetadata): Promise { - await rm(metadata.path, { force: true }); - } - - async fork( - sourceMetadata: JsonlSessionMetadata, - options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, - ): Promise> { - const source = await this.open(sourceMetadata); - const forkedEntries = await getEntriesToFork(source.getStorage(), options); - const id = options.id ?? createSessionId(); - const createdAt = createTimestamp(); - const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), { - cwd: options.cwd, - sessionId: id, - parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, - }); - for (const entry of forkedEntries) { - await storage.appendEntry(entry); - } - return toSession(storage); - } - - private async listSessionDirs(): Promise { - if (!(await exists(this.sessionsRoot))) return []; - const entries = await readdir(this.sessionsRoot, { withFileTypes: true }); - return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name)); - } -} diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index f0b42ecf..4baf50ec 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,6 +1,6 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../types.js"; -import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.js"; +import type { AgentMessage } from "../../types.ts"; +import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { BranchSummaryEntry, CompactionEntry, @@ -15,7 +15,8 @@ import type { SessionStorage, SessionTreeEntry, ThinkingLevelChangeEntry, -} from "../types.js"; +} from "../types.ts"; +import { SessionError } from "../types.ts"; export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { let thinkingLevel = "off"; @@ -206,7 +207,7 @@ export class Session { async appendLabel(targetId: string, label: string | undefined): Promise { if (!(await this.storage.getEntry(targetId))) { - throw new Error(`Entry ${targetId} not found`); + throw new SessionError("not_found", `Entry ${targetId} not found`); } return this.appendTypedEntry({ type: "label", @@ -233,7 +234,7 @@ export class Session { summary?: { summary: string; details?: unknown; fromHook?: boolean }, ): Promise { if (entryId !== null && !(await this.storage.getEntry(entryId))) { - throw new Error(`Entry ${entryId} not found`); + throw new SessionError("not_found", `Entry ${entryId} not found`); } await this.storage.setLeafId(entryId); if (!summary) return undefined; diff --git a/packages/agent/src/harness/session/storage/jsonl.ts b/packages/agent/src/harness/session/storage/jsonl.ts deleted file mode 100644 index 72ce6ce3..00000000 --- a/packages/agent/src/harness/session/storage/jsonl.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { createInterface } from "node:readline"; -import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; - -interface SessionHeader { - type: "session"; - version: 3; - id: string; - timestamp: string; - cwd: string; - parentSession?: string; -} - -function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { - if (entry.type !== "label") return; - const label = entry.label?.trim(); - if (label) { - labelsById.set(entry.targetId, label); - } else { - labelsById.delete(entry.targetId); - } -} - -function buildLabelsById(entries: SessionTreeEntry[]): Map { - const labelsById = new Map(); - for (const entry of entries) { - updateLabelCache(labelsById, entry); - } - return labelsById; -} - -function generateEntryId(byId: { has(id: string): boolean }): string { - for (let i = 0; i < 100; i++) { - const id = randomUUID().slice(0, 8); - if (!byId.has(id)) return id; - } - return randomUUID(); -} - -function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { - return { - id: header.id, - createdAt: header.timestamp, - cwd: header.cwd, - path, - parentSessionPath: header.parentSession, - }; -} - -export async function loadJsonlSessionMetadata(filePath: string): Promise { - const stream = createReadStream(filePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); - try { - for await (const line of lines) { - if (!line.trim()) break; - try { - const header = JSON.parse(line) as SessionHeader; - return headerToSessionMetadata(header, resolve(filePath)); - } catch { - throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`); - } - } - throw new Error(`Invalid JSONL session file ${filePath}: missing session header`); - } finally { - lines.close(); - stream.destroy(); - } -} - -async function loadJsonlStorage(filePath: string): Promise<{ - header: SessionHeader; - entries: SessionTreeEntry[]; - leafId: string | null; -}> { - const content = await readFile(filePath, "utf8"); - const lines = content.split("\n").filter((line) => line.trim()); - if (lines.length === 0) { - throw new Error(`Invalid JSONL session file ${filePath}: missing session header`); - } - - let header: SessionHeader; - try { - header = JSON.parse(lines[0]!) as SessionHeader; - } catch { - throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`); - } - - const entries: SessionTreeEntry[] = []; - let leafId: string | null = null; - for (const line of lines.slice(1)) { - try { - const entry = JSON.parse(line) as SessionTreeEntry; - entries.push(entry); - leafId = entry.id; - } catch { - // ignore malformed entry lines - } - } - return { header, entries, leafId }; -} - -export class JsonlSessionStorage implements SessionStorage { - private readonly filePath: string; - private readonly metadata: JsonlSessionMetadata; - private entries: SessionTreeEntry[]; - private byId: Map; - private labelsById: Map; - private currentLeafId: string | null; - - private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) { - this.filePath = resolve(filePath); - this.metadata = headerToSessionMetadata(header, this.filePath); - this.entries = entries; - this.byId = new Map(entries.map((entry) => [entry.id, entry])); - this.labelsById = buildLabelsById(entries); - this.currentLeafId = leafId; - } - - static async open(filePath: string): Promise { - const resolvedPath = resolve(filePath); - const loaded = await loadJsonlStorage(resolvedPath); - return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId); - } - - static async create( - filePath: string, - options: { - cwd: string; - sessionId: string; - parentSessionPath?: string; - }, - ): Promise { - const resolvedPath = resolve(filePath); - const header: SessionHeader = { - type: "session", - version: 3, - id: options.sessionId, - timestamp: new Date().toISOString(), - cwd: options.cwd, - parentSession: options.parentSessionPath, - }; - await mkdir(dirname(resolvedPath), { recursive: true }); - await writeFile(resolvedPath, `${JSON.stringify(header)}\n`); - return new JsonlSessionStorage(resolvedPath, header, [], null); - } - - async getMetadata(): Promise { - return this.metadata; - } - - async getLeafId(): Promise { - return this.currentLeafId; - } - - async setLeafId(leafId: string | null): Promise { - if (leafId !== null && !this.byId.has(leafId)) { - throw new Error(`Entry ${leafId} not found`); - } - this.currentLeafId = leafId; - } - - async createEntryId(): Promise { - return generateEntryId(this.byId); - } - - async appendEntry(entry: SessionTreeEntry): Promise { - await appendFile(this.filePath, `${JSON.stringify(entry)}\n`); - this.entries.push(entry); - this.byId.set(entry.id, entry); - updateLabelCache(this.labelsById, entry); - this.currentLeafId = entry.id; - } - - async getEntry(id: string): Promise { - return this.byId.get(id); - } - - async findEntries( - type: TType, - ): Promise>> { - return this.entries.filter((entry): entry is Extract => entry.type === type); - } - - async getLabel(id: string): Promise { - return this.labelsById.get(id); - } - - async getPathToRoot(leafId: string | null): Promise { - if (leafId === null) return []; - const path: SessionTreeEntry[] = []; - let current = this.byId.get(leafId); - while (current) { - path.unshift(current); - current = current.parentId ? this.byId.get(current.parentId) : undefined; - } - return path; - } - - async getEntries(): Promise { - return [...this.entries]; - } -} diff --git a/packages/agent/src/harness/session/uuid.ts b/packages/agent/src/harness/session/uuid.ts new file mode 100644 index 00000000..0c4cef8e --- /dev/null +++ b/packages/agent/src/harness/session/uuid.ts @@ -0,0 +1,54 @@ +let lastTimestamp = -Infinity; +let sequence = 0; + +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + +export function uuidv7(): string { + const random = new Uint8Array(16); + fillRandomBytes(random); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) >>> 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index db03d93c..a1a4c0c4 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -1,6 +1,6 @@ import ignore from "ignore"; import { parse } from "yaml"; -import type { ExecutionEnv, Skill } from "./types.js"; +import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts"; const MAX_NAME_LENGTH = 64; const MAX_DESCRIPTION_LENGTH = 1024; @@ -8,10 +8,19 @@ const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; type IgnoreMatcher = ReturnType; +export type SkillDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed" + | "invalid_metadata"; + /** Warning produced while loading skills. */ export interface SkillDiagnostic { /** Diagnostic severity. Currently only warnings are emitted. */ type: "warning"; + /** Stable diagnostic code. */ + code: SkillDiagnosticCode; /** Human-readable diagnostic message. */ message: string; /** Path associated with the diagnostic. */ @@ -44,8 +53,20 @@ export async function loadSkills( const skills: Skill[] = []; const diagnostics: SkillDiagnostic[] = []; for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { - const rootInfo = await safeFileInfo(env, dir); - if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue; + const rootInfoResult = await env.fileInfo(dir); + if (!rootInfoResult.ok) { + if (rootInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: rootInfoResult.error.message, + path: dir, + }); + } + continue; + } + const rootInfo = rootInfoResult.value; + if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue; const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path); skills.push(...result.skills); diagnostics.push(...result.diagnostics); @@ -89,23 +110,34 @@ async function loadSkillsFromDirInternal( const skills: Skill[] = []; const diagnostics: SkillDiagnostic[] = []; - if (!(await env.exists(dir))) return { skills, diagnostics }; - const dirInfo = await safeFileInfo(env, dir); - if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics }; - - await addIgnoreRules(env, ignoreMatcher, dir, rootDir); - - let entries: Awaited>; - try { - entries = await env.listDir(dir); - } catch { + const dirInfoResult = await env.fileInfo(dir); + if (!dirInfoResult.ok) { + if (dirInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: dirInfoResult.error.message, + path: dir, + }); + } return { skills, diagnostics }; } + const dirInfo = dirInfoResult.value; + if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics }; + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics); + + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir }); + return { skills, diagnostics }; + } + const entries = entriesResult.value; for (const entry of entries) { if (entry.name !== "SKILL.md") continue; const fullPath = entry.path; - const kind = await resolveKind(env, entry); + const kind = await resolveKind(env, entry, diagnostics); if (kind !== "file") continue; const relPath = relativeEnvPath(rootDir, fullPath); if (ignoreMatcher.ignores(relPath)) continue; @@ -119,7 +151,7 @@ async function loadSkillsFromDirInternal( for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const fullPath = entry.path; - const kind = await resolveKind(env, entry); + const kind = await resolveKind(env, entry, diagnostics); if (!kind) continue; const relPath = relativeEnvPath(rootDir, fullPath); @@ -142,22 +174,41 @@ async function loadSkillsFromDirInternal( return { skills, diagnostics }; } -async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise { +async function addIgnoreRules( + env: ExecutionEnv, + ig: IgnoreMatcher, + dir: string, + rootDir: string, + diagnostics: SkillDiagnostic[], +): Promise { const relativeDir = relativeEnvPath(rootDir, dir); const prefix = relativeDir ? `${relativeDir}/` : ""; for (const filename of IGNORE_FILE_NAMES) { const ignorePath = joinEnvPath(dir, filename); - const info = await safeFileInfo(env, ignorePath); - if (info?.kind !== "file") continue; - try { - const content = await env.readTextFile(ignorePath); - const patterns = content - .split(/\r?\n/) - .map((line) => prefixIgnorePattern(line, prefix)) - .filter((line): line is string => Boolean(line)); - if (patterns.length > 0) ig.add(patterns); - } catch {} + const info = await env.fileInfo(ignorePath); + if (!info.ok) { + if (info.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: info.error.message, + path: ignorePath, + }); + } + continue; + } + if (info.value.kind !== "file") continue; + const content = await env.readTextFile(ignorePath); + if (!content.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath }); + continue; + } + const patterns = content.value + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) ig.add(patterns); } } @@ -184,40 +235,47 @@ async function loadSkillFromFile( filePath: string, ): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { const diagnostics: SkillDiagnostic[] = []; - try { - const rawContent = await env.readTextFile(filePath); - const { frontmatter, body } = parseFrontmatter(rawContent); - const skillDir = dirnameEnvPath(filePath); - const parentDirName = basenameEnvPath(skillDir); - - for (const error of validateDescription(frontmatter.description)) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } - - const name = frontmatter.name || parentDirName; - for (const error of validateName(name, parentDirName)) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } - - if (!frontmatter.description || frontmatter.description.trim() === "") { - return { skill: null, diagnostics }; - } - - return { - skill: { - name, - description: frontmatter.description, - content: body, - filePath, - disableModelInvocation: frontmatter["disable-model-invocation"] === true, - }, - diagnostics, - }; - } catch (error) { - const message = error instanceof Error ? error.message : "failed to parse skill file"; - diagnostics.push({ type: "warning", message, path: filePath }); + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); return { skill: null, diagnostics }; } + + const parsed = parseFrontmatter(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + return { skill: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + for (const error of validateDescription(description)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + if (!description || description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; } function validateName(name: string, parentDirName: string): string[] { @@ -242,39 +300,53 @@ function validateDescription(description: string | undefined): string[] { return errors; } -function parseFrontmatter>(content: string): { frontmatter: T; body: string } { - const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized }; - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) return { frontmatter: {} as T, body: normalized }; - const yamlString = normalized.slice(4, endIndex); - const body = normalized.slice(endIndex + 4).trim(); - return { frontmatter: (parse(yamlString) ?? {}) as T, body }; -} - -async function safeFileInfo( - env: ExecutionEnv, - path: string, -): Promise> | undefined> { +function parseFrontmatter>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { try { - return await env.fileInfo(path); - } catch { - return undefined; + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; } } async function resolveKind( env: ExecutionEnv, - info: Awaited>, + info: FileInfo, + diagnostics: SkillDiagnostic[], ): Promise<"file" | "directory" | undefined> { if (info.kind === "file" || info.kind === "directory") return info.kind; - try { - const realPath = await env.realPath(info.path); - const target = await env.fileInfo(realPath); - return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; - } catch { + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } return undefined; } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; } function joinEnvPath(base: string, child: string): string { diff --git a/packages/agent/src/harness/system-prompt.ts b/packages/agent/src/harness/system-prompt.ts index 44b8f623..ec121da2 100644 --- a/packages/agent/src/harness/system-prompt.ts +++ b/packages/agent/src/harness/system-prompt.ts @@ -1,4 +1,4 @@ -import type { Skill } from "./types.js"; +import type { Skill } from "./types.ts"; export function formatSkillsForSystemPrompt(skills: Skill[]): string { const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 24b5feeb..2d9096c4 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,7 +1,41 @@ -import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; -import type { QueueMode } from "../agent.js"; -import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js"; -import type { Session } from "./session/session.js"; +import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts"; +import type { Session } from "./session/session.ts"; + +/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +/** Create a successful {@link Result}. */ +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err(error: TError): Result { + return { ok: false, error }; +} + +/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */ +export function getOrThrow(result: Result): TValue { + if (!result.ok) throw result.error; + return result.value; +} + +/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */ +export function getOrUndefined(result: Result): TValue | undefined { + return result.ok ? result.value : undefined; +} + +/** Normalize unknown thrown values into Error instances before using them as typed error causes. */ +export function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "string") return new Error(error); + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error(String(error)); + } +} /** * Skill loaded from a `SKILL.md` file or provided by an application. @@ -43,11 +77,39 @@ export interface AgentHarnessResources< skills?: TSkill[]; } -/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */ +/** Curated provider request options owned by the harness and snapshotted per turn. */ +export interface AgentHarnessStreamOptions { + /** Preferred transport forwarded to the stream function. */ + transport?: Transport; + /** Provider request timeout in milliseconds. */ + timeoutMs?: number; + /** Maximum provider retry attempts. */ + maxRetries?: number; + /** Optional cap for provider-requested retry delays. */ + maxRetryDelayMs?: number; + /** Additional request headers merged with auth and lifecycle headers. */ + headers?: Record; + /** Provider metadata forwarded with requests. */ + metadata?: SimpleStreamOptions["metadata"]; + /** Provider cache retention hint. */ + cacheRetention?: SimpleStreamOptions["cacheRetention"]; +} + +/** Per-request stream option patch returned by provider hooks. */ +export interface AgentHarnessStreamOptionsPatch + extends Omit, "headers" | "metadata"> { + /** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */ + headers?: Record; + /** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */ + metadata?: Record; +} + +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ export type FileKind = "file" | "directory" | "symlink"; -/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */ +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ export type FileErrorCode = + | "aborted" | "not_found" | "permission_denied" | "not_directory" @@ -56,28 +118,121 @@ export type FileErrorCode = | "not_supported" | "unknown"; -/** Error thrown by {@link ExecutionEnv} file operations. */ +/** Error returned by {@link FileSystem} file operations. */ export class FileError extends Error { - constructor( - /** Backend-independent error code. */ - public code: FileErrorCode, - message: string, - /** Absolute addressed path associated with the failure, when available. */ - public path?: string, - options?: ErrorOptions, - ) { - super(message, options); + /** Backend-independent error code. */ + public code: FileErrorCode; + /** Absolute addressed path associated with the failure, when available. */ + public path?: string; + + constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); this.name = "FileError"; + this.code = code; + this.path = path; } } -/** Metadata for one filesystem object in an {@link ExecutionEnv}. */ +/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */ +export type ExecutionErrorCode = + | "aborted" + | "timeout" + | "shell_unavailable" + | "spawn_error" + | "callback_error" + | "unknown"; + +/** Error returned by {@link ExecutionEnv.exec}. */ +export class ExecutionError extends Error { + /** Backend-independent error code. */ + public code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "ExecutionError"; + this.code = code; + } +} + +/** Stable compaction error codes returned by compaction helpers. */ +export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; + +/** Error returned by compaction helpers. */ +export class CompactionError extends Error { + /** Backend-independent error code. */ + public code: CompactionErrorCode; + + constructor(code: CompactionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "CompactionError"; + this.code = code; + } +} + +/** Stable branch-summary error codes returned by branch summarization helpers. */ +export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; + +/** Error returned by branch summarization helpers. */ +export class BranchSummaryError extends Error { + /** Backend-independent error code. */ + public code: BranchSummaryErrorCode; + + constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BranchSummaryError"; + this.code = code; + } +} + +export type SessionErrorCode = + | "not_found" + | "invalid_session" + | "invalid_entry" + | "invalid_fork_target" + | "storage" + | "unknown"; + +/** Error thrown by session storage, repositories, and session tree operations. */ +export class SessionError extends Error { + /** Session subsystem error code. */ + public code: SessionErrorCode; + + constructor(code: SessionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SessionError"; + this.code = code; + } +} + +export type AgentHarnessErrorCode = + | "busy" + | "invalid_state" + | "invalid_argument" + | "session" + | "hook" + | "auth" + | "compaction" + | "branch_summary" + | "unknown"; + +/** Public AgentHarness failure with a stable top-level classification. */ +export class AgentHarnessError extends Error { + public code: AgentHarnessErrorCode; + + constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AgentHarnessError"; + this.code = code; + } +} + +/** Metadata for one filesystem object in a {@link FileSystem}. */ export interface FileInfo { /** Basename of {@link path}. */ name: string; /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ path: string; - /** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */ + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ kind: FileKind; /** Size in bytes for the addressed filesystem object. */ size: number; @@ -85,16 +240,16 @@ export interface FileInfo { mtimeMs: number; } -/** Options for {@link ExecutionEnv.exec}. */ +/** Options for {@link Shell.exec}. */ export interface ExecutionEnvExecOptions { - /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */ + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ cwd?: string; - /** Additional environment variables for the command. Values override the environment defaults. */ + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ env?: Record; - /** Timeout in seconds. Implementations should reject when the command exceeds this duration. */ + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ timeout?: number; - /** Abort signal used to terminate the command. */ - signal?: AbortSignal; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; /** Called with stdout chunks as they are produced. */ onStdout?: (chunk: string) => void; /** Called with stderr chunks as they are produced. */ @@ -102,50 +257,80 @@ export interface ExecutionEnvExecOptions { } /** - * Filesystem and process execution environment used by the harness. + * Filesystem capability used by the harness. * - * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute - * addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}. + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. * - * File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors. + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. */ -export interface ExecutionEnv { - /** Current working directory for relative paths and command execution. */ +export interface FileSystem { + /** Current working directory for relative paths. */ cwd: string; - /** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */ + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; + /** Read a UTF-8 text file. */ + readTextFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */ + readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise>; + /** Read a binary file. */ + readBinaryFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Create or overwrite a file, creating parent directories when supported. */ + writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + /** Create or append to a file, creating parent directories when supported. */ + appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + /** Return metadata for the addressed path without following symlinks. */ + fileInfo(path: string, abortSignal?: AbortSignal): Promise>; + /** List direct children of a directory without following symlinks. */ + listDir(path: string, abortSignal?: AbortSignal): Promise>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; + /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ + exists(path: string, abortSignal?: AbortSignal): Promise>; + /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + createDir( + path: string, + options?: { recursive?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */ + remove( + path: string, + options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ + createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; + /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ + createTempFile(options?: { + prefix?: string; + suffix?: string; + abortSignal?: AbortSignal; + }): Promise>; + + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ exec( command: string, options?: ExecutionEnvExecOptions, - ): Promise<{ stdout: string; stderr: string; exitCode: number }>; - - /** Read a UTF-8 text file. Throws {@link FileError}. */ - readTextFile(path: string): Promise; - /** Read a binary file. Throws {@link FileError}. */ - readBinaryFile(path: string): Promise; - /** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */ - writeFile(path: string, content: string | Uint8Array): Promise; - /** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */ - fileInfo(path: string): Promise; - /** List direct children of a directory without following symlinks. Throws {@link FileError}. */ - listDir(path: string): Promise; - /** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */ - realPath(path: string): Promise; - /** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */ - exists(path: string): Promise; - /** Create a directory. */ - createDir(path: string, options?: { recursive?: boolean }): Promise; - /** Remove a file or directory. */ - remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; - /** Create a temporary directory and return its absolute path. */ - createTempDir(prefix?: string): Promise; - /** Create a temporary file and return its absolute path. */ - createTempFile(options?: { prefix?: string; suffix?: string }): Promise; - - /** Release resources owned by the environment. */ + ): Promise>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ cleanup(): Promise; } +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + export interface SessionTreeEntryBase { type: string; id: string; @@ -211,6 +396,11 @@ export interface SessionInfoEntry extends SessionTreeEntryBase { name?: string; } +export interface LeafEntry extends SessionTreeEntryBase { + type: "leaf"; + targetId: string | null; +} + export type SessionTreeEntry = | MessageEntry | ThinkingLevelChangeEntry @@ -220,7 +410,8 @@ export type SessionTreeEntry = | CustomEntry | CustomMessageEntry | LabelEntry - | SessionInfoEntry; + | SessionInfoEntry + | LeafEntry; export interface SessionContext { messages: AgentMessage[]; @@ -242,6 +433,7 @@ export interface JsonlSessionMetadata extends SessionMetadata { export interface SessionStorage { getMetadata(): Promise; getLeafId(): Promise; + /** Persist a leaf entry that records the active session-tree leaf. */ setLeafId(leafId: string | null): Promise; createEntryId(): Promise; appendEntry(entry: SessionTreeEntry): Promise; @@ -254,7 +446,7 @@ export interface SessionStorage; } -export type { Session } from "./session/session.js"; +export type { Session } from "./session/session.ts"; export interface SessionCreateOptions { id?: string; @@ -298,20 +490,6 @@ export type PendingSessionWrite = SessionTreeEntry extends infer TEntry : never : never; -export interface AgentHarnessTurnState< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentTool = AgentTool, -> { - messages: AgentMessage[]; - resources: AgentHarnessResources; - systemPrompt: string; - model: Model; - thinkingLevel: ThinkingLevel; - tools: TTool[]; - activeTools: TTool[]; -} - export interface QueueUpdateEvent { type: "queue_update"; steer: AgentMessage[]; @@ -353,6 +531,14 @@ export interface ContextEvent { export interface BeforeProviderRequestEvent { type: "before_provider_request"; + model: Model; + sessionId: string; + streamOptions: AgentHarnessStreamOptions; +} + +export interface BeforeProviderPayloadEvent { + type: "before_provider_payload"; + model: Model; payload: unknown; } @@ -440,6 +626,7 @@ export type AgentHarnessOwnEvent< | BeforeAgentStartEvent | ContextEvent | BeforeProviderRequestEvent + | BeforeProviderPayloadEvent | AfterProviderResponseEvent | ToolCallEvent | ToolResultEvent @@ -465,6 +652,10 @@ export interface ContextResult { } export interface BeforeProviderRequestResult { + streamOptions?: AgentHarnessStreamOptionsPatch; +} + +export interface BeforeProviderPayloadResult { payload: unknown; } @@ -497,6 +688,7 @@ export type AgentHarnessEventResultMap = { before_agent_start: BeforeAgentStartResult | undefined; context: ContextResult | undefined; before_provider_request: BeforeProviderRequestResult | undefined; + before_provider_payload: BeforeProviderPayloadResult | undefined; after_provider_response: undefined; tool_call: ToolCallResult | undefined; tool_result: ToolResultPatch | undefined; @@ -580,11 +772,9 @@ export interface GenerateBranchSummaryOptions { } export interface BranchSummaryResult { - summary?: string; - readFiles?: string[]; - modifiedFiles?: string[]; - aborted?: boolean; - error?: string; + summary: string; + readFiles: string[]; + modifiedFiles: string[]; } export interface AgentHarnessOptions< @@ -613,6 +803,8 @@ export interface AgentHarnessOptions< getApiKeyAndHeaders?: ( model: Model, ) => Promise<{ apiKey: string; headers?: Record } | undefined>; + /** Curated stream/provider request options. Snapshotted at turn start. */ + streamOptions?: AgentHarnessStreamOptions; model: Model; thinkingLevel?: ThinkingLevel; activeToolNames?: string[]; @@ -620,4 +812,4 @@ export interface AgentHarnessOptions< followUpMode?: QueueMode; } -export type { AgentHarness } from "./agent-harness.js"; +export type { AgentHarness } from "./agent-harness.ts"; diff --git a/packages/agent/src/harness/utils/shell-output.ts b/packages/agent/src/harness/utils/shell-output.ts index d31d7a06..fea46d95 100644 --- a/packages/agent/src/harness/utils/shell-output.ts +++ b/packages/agent/src/harness/utils/shell-output.ts @@ -1,9 +1,13 @@ -import { randomBytes } from "node:crypto"; -import { createWriteStream, type WriteStream } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ExecutionEnv, ExecutionEnvExecOptions } from "../types.js"; -import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js"; +import { + type ExecutionEnv, + type ExecutionEnvExecOptions, + ExecutionError, + err, + ok, + type Result, + toError, +} from "../types.ts"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts"; export interface ShellCaptureOptions extends Omit { onChunk?: (chunk: string) => void; @@ -17,6 +21,12 @@ export interface ShellCaptureResult { fullOutputPath?: string; } +function toExecutionError(error: unknown): ExecutionError { + if (error instanceof ExecutionError) return error; + const cause = toError(error); + return new ExecutionError("unknown", cause.message, cause); +} + export function sanitizeBinaryOutput(str: string): string { return Array.from(str) .filter((char) => { @@ -34,41 +44,62 @@ export async function executeShellWithCapture( env: ExecutionEnv, command: string, options?: ShellCaptureOptions, -): Promise { +): Promise> { const outputChunks: string[] = []; let outputBytes = 0; const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + const encoder = new TextEncoder(); - let tempFilePath: string | undefined; - let tempFileStream: WriteStream | undefined; let totalBytes = 0; + let fullOutputPath: string | undefined; + let writeChain: Promise> = Promise.resolve(ok(undefined)); + let captureError: ExecutionError | undefined; - const ensureTempFile = () => { - if (tempFilePath) return; - const id = randomBytes(8).toString("hex"); - tempFilePath = join(tmpdir(), `bash-${id}.log`); - tempFileStream = createWriteStream(tempFilePath); - for (const chunk of outputChunks) { - tempFileStream.write(chunk); - } + const appendFullOutput = (text: string): void => { + if (!fullOutputPath || captureError) return; + const path = fullOutputPath; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const appendResult = await env.appendFile(path, text, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const ensureFullOutputFile = (initialContent: string): void => { + if (fullOutputPath || captureError) return; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const tempFile = await env.createTempFile({ + prefix: "bash-", + suffix: ".log", + abortSignal: options?.abortSignal, + }); + if (!tempFile.ok) return err(toExecutionError(tempFile.error)); + fullOutputPath = tempFile.value; + const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); }; const onChunk = (chunk: string) => { - totalBytes += Buffer.byteLength(chunk, "utf-8"); - const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); - if (totalBytes > DEFAULT_MAX_BYTES) { - ensureTempFile(); + try { + totalBytes += encoder.encode(chunk).byteLength; + const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); + if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) { + ensureFullOutputFile(outputChunks.join("") + text); + } else { + appendFullOutput(text); + } + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + options?.onChunk?.(text); + } catch (error) { + captureError = toExecutionError(error); } - if (tempFileStream) { - tempFileStream.write(text); - } - outputChunks.push(text); - outputBytes += text.length; - while (outputBytes > maxOutputBytes && outputChunks.length > 1) { - const removed = outputChunks.shift()!; - outputBytes -= removed.length; - } - options?.onChunk?.(text); }; try { @@ -77,37 +108,36 @@ export async function executeShellWithCapture( onStdout: onChunk, onStderr: onChunk, }); - const fullOutput = outputChunks.join(""); - const truncationResult = truncateTail(fullOutput); - if (truncationResult.truncated) { - ensureTempFile(); + const tailOutput = outputChunks.join(""); + const truncationResult = truncateTail(tailOutput); + if (truncationResult.truncated && !fullOutputPath) { + ensureFullOutputFile(tailOutput); } - tempFileStream?.end(); - const cancelled = options?.signal?.aborted ?? false; - return { - output: truncationResult.truncated ? truncationResult.content : fullOutput, - exitCode: cancelled ? undefined : result.exitCode, + const writeResult = await writeChain; + if (!writeResult.ok) return err(writeResult.error); + if (captureError) return err(captureError); + + if (!result.ok) { + if (result.error.code === "aborted" || options?.abortSignal?.aborted) { + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } + return err(result.error); + } + const cancelled = options?.abortSignal?.aborted ?? false; + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: cancelled ? undefined : result.value.exitCode, cancelled, truncated: truncationResult.truncated, - fullOutputPath: tempFilePath, - }; - } catch (err) { - if (options?.signal?.aborted) { - const fullOutput = outputChunks.join(""); - const truncationResult = truncateTail(fullOutput); - if (truncationResult.truncated) { - ensureTempFile(); - } - tempFileStream?.end(); - return { - output: truncationResult.truncated ? truncationResult.content : fullOutput, - exitCode: undefined, - cancelled: true, - truncated: truncationResult.truncated, - fullOutputPath: tempFilePath, - }; - } - tempFileStream?.end(); - throw err; + fullOutputPath, + }); + } catch (error) { + return err(toExecutionError(error)); } } diff --git a/packages/agent/src/harness/utils/truncate.ts b/packages/agent/src/harness/utils/truncate.ts index 18ac5d74..2372a941 100644 --- a/packages/agent/src/harness/utils/truncate.ts +++ b/packages/agent/src/harness/utils/truncate.ts @@ -44,6 +44,64 @@ export interface TruncationOptions { maxBytes?: number; } +interface RuntimeBuffer { + byteLength(content: string, encoding: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +const nonAsciiPattern = /[^\x00-\x7f]/; + +function utf8ByteLength(content: string): number { + if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8"); + + const firstNonAscii = content.search(nonAsciiPattern); + if (firstNonAscii === -1) return content.length; + + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function replaceUnpairedSurrogates(content: string): string { + let output = ""; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + output += content[i] + content[i + 1]; + i++; + continue; + } + } + output += "�"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + output += "�"; + } else { + output += content[i]; + } + } + return output; +} + /** * Format bytes as human-readable size. */ @@ -68,7 +126,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - const totalBytes = Buffer.byteLength(content, "utf-8"); + const totalBytes = utf8ByteLength(content); const lines = content.split("\n"); const totalLines = lines.length; @@ -90,7 +148,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): } // Check if first line alone exceeds byte limit - const firstLineBytes = Buffer.byteLength(lines[0], "utf-8"); + const firstLineBytes = utf8ByteLength(lines[0]); if (firstLineBytes > maxBytes) { return { content: "", @@ -114,7 +172,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): for (let i = 0; i < lines.length && i < maxLines; i++) { const line = lines[i]; - const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; @@ -131,7 +189,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): } const outputContent = outputLinesArr.join("\n"); - const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + const finalOutputBytes = utf8ByteLength(outputContent); return { content: outputContent, @@ -158,8 +216,9 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - const totalBytes = Buffer.byteLength(content, "utf-8"); + const totalBytes = utf8ByteLength(content); const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); const totalLines = lines.length; // Check if no truncation needed @@ -187,7 +246,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { const line = lines[i]; - const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; @@ -196,7 +255,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): if (outputLinesArr.length === 0) { const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); outputLinesArr.unshift(truncatedLine); - outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + outputBytesCount = utf8ByteLength(truncatedLine); lastLinePartial = true; } break; @@ -212,7 +271,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): } const outputContent = outputLinesArr.join("\n"); - const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + const finalOutputBytes = utf8ByteLength(outputContent); return { content: outputContent, @@ -234,20 +293,40 @@ export function truncateTail(content: string, options: TruncationOptions = {}): * Handles multi-byte UTF-8 characters correctly. */ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { - const buf = Buffer.from(str, "utf-8"); - if (buf.length <= maxBytes) { - return str; + if (maxBytes <= 0) return ""; + + let outputBytes = 0; + let start = str.length; + let needsReplacement = false; + for (let i = str.length; i > 0; ) { + let characterStart = i - 1; + const code = str.charCodeAt(characterStart); + let characterBytes: number; + let unpairedSurrogate = false; + if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) { + const previous = str.charCodeAt(characterStart - 1); + if (previous >= 0xd800 && previous <= 0xdbff) { + characterStart--; + characterBytes = 4; + } else { + characterBytes = 3; + unpairedSurrogate = true; + } + } else if (code >= 0xd800 && code <= 0xdfff) { + characterBytes = 3; + unpairedSurrogate = true; + } else { + characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3; + } + if (outputBytes + characterBytes > maxBytes) break; + outputBytes += characterBytes; + start = characterStart; + needsReplacement ||= unpairedSurrogate; + i = characterStart; } - // Start from the end, skip maxBytes back - let start = buf.length - maxBytes; - - // Find a valid UTF-8 boundary (start of a character) - while (start < buf.length && (buf[start] & 0xc0) === 0x80) { - start++; - } - - return buf.slice(start).toString("utf-8"); + const output = str.slice(start); + return needsReplacement ? replaceUnpairedSurrogates(output) : output; } /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 7b6018c1..fd3c2b9f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,13 +1,16 @@ // Core Agent -export * from "./agent.js"; +export * from "./agent.ts"; // Loop functions -export * from "./agent-loop.js"; -export * from "./harness/agent-harness.js"; +export * from "./agent-loop.ts"; +export * from "./harness/agent-harness.ts"; export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, collectEntriesForBranchSummary, generateBranchSummary, prepareBranchEntries, -} from "./harness/compaction/branch-summarization.js"; +} from "./harness/compaction/branch-summarization.ts"; export { calculateContextTokens, compact, @@ -21,21 +24,21 @@ export { prepareCompaction, serializeConversation, shouldCompact, -} from "./harness/compaction/compaction.js"; -export * from "./harness/execution-env.js"; -export * from "./harness/messages.js"; -export * from "./harness/prompt-templates.js"; -export * from "./harness/session/repo/jsonl.js"; -export * from "./harness/session/repo/memory.js"; -export * from "./harness/session/repo/shared.js"; -export * from "./harness/session/session.js"; -export * from "./harness/skills.js"; -export * from "./harness/system-prompt.js"; +} from "./harness/compaction/compaction.ts"; +export * from "./harness/messages.ts"; +export * from "./harness/prompt-templates.ts"; +export * from "./harness/session/jsonl-repo.ts"; +export * from "./harness/session/memory-repo.ts"; +export * from "./harness/session/repo-utils.ts"; +export * from "./harness/session/session.ts"; +export { uuidv7 } from "./harness/session/uuid.ts"; +export * from "./harness/skills.ts"; +export * from "./harness/system-prompt.ts"; // Harness -export * from "./harness/types.js"; -export * from "./harness/utils/shell-output.js"; -export * from "./harness/utils/truncate.js"; +export * from "./harness/types.ts"; +export * from "./harness/utils/shell-output.ts"; +export * from "./harness/utils/truncate.ts"; // Proxy utilities -export * from "./proxy.js"; +export * from "./proxy.ts"; // Types -export * from "./types.js"; +export * from "./types.ts"; diff --git a/packages/agent/src/node.ts b/packages/agent/src/node.ts new file mode 100644 index 00000000..b6f53a5e --- /dev/null +++ b/packages/agent/src/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs.ts"; +export * from "./index.ts"; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 285c1b02..f24d2496 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -35,6 +35,14 @@ export type StreamFn = ( */ export type ToolExecutionMode = "sequential" | "parallel"; +/** + * Controls how many queued user messages are injected when the agent loop reaches a queue drain point. + * + * - "all": drain and inject every queued message at that point. + * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points. + */ +export type QueueMode = "all" | "one-at-a-time"; + /** A single tool call content block emitted by an assistant message. */ export type AgentToolCall = Extract; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 261b42fc..62eadf81 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -8,8 +8,8 @@ import { } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { agentLoop, agentLoopContinue } from "../src/agent-loop.js"; -import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js"; +import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts"; +import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts"; // Mock stream for testing - mimics MockAssistantStream class MockAssistantStream extends EventStream { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 9c38ae8c..82cc58de 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,6 +1,6 @@ import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { Agent } from "../src/index.js"; +import { Agent } from "../src/index.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index b75645b0..309536ad 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -11,8 +11,8 @@ import { type UserMessage, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { Agent, type AgentEvent } from "../src/index.js"; -import { calculateTool } from "./utils/calculate.js"; +import { Agent, type AgentEvent } from "../src/index.ts"; +import { calculateTool } from "./utils/calculate.ts"; const registrations: FauxProviderRegistration[] = []; diff --git a/packages/agent/test/harness/agent-harness-stream.test.ts b/packages/agent/test/harness/agent-harness-stream.test.ts new file mode 100644 index 00000000..ee79564b --- /dev/null +++ b/packages/agent/test/harness/agent-harness-stream.test.ts @@ -0,0 +1,206 @@ +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentHarness } from "../../src/harness/agent-harness.ts"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; +import { Session } from "../../src/harness/session/session.ts"; +import { calculateTool } from "../utils/calculate.ts"; + +const registrations: Array<{ unregister(): void }> = []; + +afterEach(() => { + for (const registration of registrations.splice(0)) { + registration.unregister(); + } +}); + +function createHarness(options: ConstructorParameters[0]): AgentHarness { + return new AgentHarness(options); +} + +function captureOptions(options: StreamOptions | undefined): StreamOptions { + return { + ...options, + headers: options?.headers ? { ...options.headers } : undefined, + metadata: options?.metadata ? { ...options.metadata } : undefined, + }; +} + +describe("AgentHarness stream configuration", () => { + it("snapshots stream options and merges auth headers before provider request hooks", async () => { + let capturedOptions: StreamOptions | undefined; + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([ + (_context, options) => { + capturedOptions = options; + return fauxAssistantMessage("ok"); + }, + ]); + + const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } })); + const harness = createHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + streamOptions: { + timeoutMs: 1000, + maxRetries: 2, + maxRetryDelayMs: 3000, + headers: { "x-base": "base" }, + metadata: { base: true }, + cacheRetention: "none", + }, + getApiKeyAndHeaders: async () => ({ apiKey: "secret", headers: { "x-auth": "auth" } }), + }); + + harness.on("before_provider_request", (event) => { + expect(event.sessionId).toBe("session-1"); + expect(event.streamOptions.headers).toEqual({ "x-base": "base", "x-auth": "auth" }); + return { + streamOptions: { + headers: { "x-hook": "hook" }, + metadata: { hook: true }, + }, + }; + }); + + await harness.prompt("hello"); + + expect(capturedOptions).toMatchObject({ + apiKey: "secret", + timeoutMs: 1000, + maxRetries: 2, + maxRetryDelayMs: 3000, + sessionId: "session-1", + cacheRetention: "none", + }); + expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-auth": "auth", "x-hook": "hook" }); + expect(capturedOptions?.metadata).toEqual({ base: true, hook: true }); + }); + + it("chains provider request patches and supports deletion semantics", async () => { + let capturedOptions: StreamOptions | undefined; + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([ + (_context, options) => { + capturedOptions = options; + return fauxAssistantMessage("ok"); + }, + ]); + + const harness = createHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + streamOptions: { + timeoutMs: 1000, + maxRetries: 2, + headers: { keep: "base", remove: "base" }, + metadata: { keep: "base", remove: "base" }, + }, + }); + + harness.on("before_provider_request", (event) => { + expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" }); + return { + streamOptions: { + headers: { first: "1", remove: undefined }, + metadata: { first: 1, remove: undefined }, + }, + }; + }); + harness.on("before_provider_request", (event) => { + expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" }); + expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 }); + return { + streamOptions: { + timeoutMs: undefined, + headers: { second: "2" }, + metadata: undefined, + }, + }; + }); + + await harness.prompt("hello"); + + expect(capturedOptions?.timeoutMs).toBeUndefined(); + expect(capturedOptions?.maxRetries).toBe(2); + expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" }); + expect(capturedOptions?.metadata).toBeUndefined(); + }); + + it("uses updated stream options for save-point snapshots without mutating the active request", async () => { + const capturedOptions: StreamOptions[] = []; + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([ + (_context, options) => { + capturedOptions.push(captureOptions(options)); + return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { + stopReason: "toolUse", + }); + }, + (_context, options) => { + capturedOptions.push(captureOptions(options)); + return fauxAssistantMessage("done"); + }, + ]); + + const harness = createHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + tools: [calculateTool], + streamOptions: { timeoutMs: 1000, headers: { turn: "first" } }, + }); + + harness.subscribe((event) => { + if (event.type === "tool_execution_start") { + harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } }); + } + }); + + await harness.prompt("hello"); + + expect(capturedOptions).toHaveLength(2); + expect(capturedOptions[0].timeoutMs).toBe(1000); + expect(capturedOptions[0].headers).toEqual({ turn: "first" }); + expect(capturedOptions[1].timeoutMs).toBe(2000); + expect(capturedOptions[1].headers).toEqual({ turn: "second" }); + }); + + it("chains provider payload hooks", async () => { + const seenPayloads: unknown[] = []; + let finalPayload: unknown; + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([ + async (_context, options, _state, model) => { + finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model); + return fauxAssistantMessage("ok"); + }, + ]); + + const harness = createHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + }); + + harness.on("before_provider_payload", (event) => { + seenPayloads.push(event.payload); + return { payload: { steps: ["provider", "first"] } }; + }); + harness.on("before_provider_payload", (event) => { + seenPayloads.push(event.payload); + return { payload: { steps: ["provider", "first", "second"] } }; + }); + + await harness.prompt("hello"); + + expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]); + expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] }); + }); +}); diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index 453b1297..8a395532 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -1,11 +1,13 @@ -import { getModel } from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; -import { AgentHarness } from "../../src/harness/agent-harness.js"; -import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; -import { Session } from "../../src/harness/session/session.js"; -import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; -import type { PromptTemplate, Skill } from "../../src/harness/types.js"; -import type { AgentTool } from "../../src/types.js"; +import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentHarness } from "../../src/harness/agent-harness.ts"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; +import { Session } from "../../src/harness/session/session.ts"; +import type { PromptTemplate, Skill } from "../../src/harness/types.ts"; +import type { AgentMessage, AgentTool } from "../../src/types.ts"; +import { calculateTool } from "../utils/calculate.ts"; +import { getCurrentTimeTool } from "../utils/get-current-time.ts"; interface AppSkill extends Skill { source: "project" | "user"; @@ -19,6 +21,39 @@ interface AppTool extends AgentTool { source: "builtin" | "extension"; } +const registrations: Array<{ unregister(): void }> = []; + +function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] { + return messages.flatMap((message) => { + if (message.role !== "user") return []; + if (typeof message.content === "string") return [message.content]; + if (!Array.isArray(message.content)) return []; + return message.content.flatMap((part) => { + if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return []; + return "text" in part && typeof part.text === "string" ? [part.text] : []; + }); + }); +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function getReasoning(options: unknown): unknown { + if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined; + return options.reasoning; +} + +afterEach(() => { + for (const registration of registrations.splice(0)) { + registration.unregister(); + } +}); + describe("AgentHarness", () => { it("constructs directly and exposes queue modes", () => { const session = new Session(new InMemorySessionStorage()); @@ -28,18 +63,399 @@ describe("AgentHarness", () => { env, session, model: initialModel, + thinkingLevel: "high", systemPrompt: "You are helpful.", steeringMode: "all", followUpMode: "all", }); expect(harness.env).toBe(env); - expect(harness.agent.state.model).toBe(initialModel); - expect(harness.steeringMode).toBe("all"); - expect(harness.followUpMode).toBe("all"); - harness.steeringMode = "one-at-a-time"; - harness.followUpMode = "one-at-a-time"; - expect(harness.agent.steeringMode).toBe("one-at-a-time"); - expect(harness.agent.followUpMode).toBe("one-at-a-time"); + expect(harness.getModel()).toBe(initialModel); + expect(harness.getThinkingLevel()).toBe("high"); + expect(harness.getSteeringMode()).toBe("all"); + expect(harness.getFollowUpMode()).toBe("all"); + harness.setSteeringMode("one-at-a-time"); + harness.setFollowUpMode("one-at-a-time"); + expect(harness.getSteeringMode()).toBe("one-at-a-time"); + expect(harness.getFollowUpMode()).toBe("one-at-a-time"); + }); + + it("drains one queued steering message at a time and emits queue updates", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + const userCounts: number[] = []; + registration.setResponses([ + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("first"); + }, + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("second"); + }, + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("third"); + }, + ]); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + steeringMode: "one-at-a-time", + }); + const steerQueueLengths: number[] = []; + let queued = false; + harness.subscribe((event) => { + if (event.type === "queue_update") { + steerQueueLengths.push(event.steer.length); + } + if (event.type === "message_start" && event.message.role === "assistant" && !queued) { + queued = true; + harness.steer("one"); + harness.steer("two"); + } + }); + + await harness.prompt("hello"); + + expect(userCounts).toEqual([1, 2, 3]); + expect(steerQueueLengths).toEqual([1, 2, 1, 0]); + }); + + it("appends before_agent_start messages and persists them", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + let requestText: string[] = []; + registration.setResponses([ + (context) => { + requestText = textFromUserMessages(context.messages); + return fauxAssistantMessage("ok"); + }, + ]); + const session = new Session(new InMemorySessionStorage()); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + harness.on("before_agent_start", () => ({ + messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }], + })); + + await harness.prompt("hello"); + + const persistedText = (await session.getEntries()).flatMap((entry) => { + if (entry.type !== "message" || entry.message.role !== "user") return []; + const content = entry.message.content; + if (typeof content === "string") return [content]; + return content.flatMap((part) => (part.type === "text" ? [part.text] : [])); + }); + expect(requestText).toEqual(["hello", "hook"]); + expect(persistedText).toEqual(["hello", "hook"]); + }); + + it("abort clears steer and follow-up queues but preserves next-turn messages", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + let releaseFirstResponse: (() => void) | undefined; + let abortedSignal: AbortSignal | undefined; + const firstResponseReleased = new Promise((resolve) => { + releaseFirstResponse = resolve; + }); + const secondRequestText: string[] = []; + registration.setResponses([ + async (_context, options) => { + abortedSignal = options?.signal; + await firstResponseReleased; + return fauxAssistantMessage("aborted-ish"); + }, + (context) => { + secondRequestText.push(...textFromUserMessages(context.messages)); + return fauxAssistantMessage("second"); + }, + ]); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + }); + const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = []; + harness.subscribe((event) => { + if (event.type === "queue_update") { + queueUpdates.push({ + steer: event.steer.length, + followUp: event.followUp.length, + nextTurn: event.nextTurn.length, + }); + } + }); + + const firstPrompt = harness.prompt("first"); + await new Promise((resolve) => setTimeout(resolve, 0)); + harness.steer("steer"); + harness.followUp("follow"); + harness.nextTurn("next"); + const abortResultPromise = harness.abort(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(abortedSignal?.aborted).toBe(true); + releaseFirstResponse?.(); + const abortResult = await abortResultPromise; + await firstPrompt; + await harness.prompt("second"); + + expect(abortResult.clearedSteer).toHaveLength(1); + expect(abortResult.clearedFollowUp).toHaveLength(1); + expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 }); + expect(secondRequestText).toEqual(["first", "next", "second"]); + }); + + it("drains follow-up messages one at a time after the agent would otherwise stop", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + const userCounts: number[] = []; + registration.setResponses([ + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("first"); + }, + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("second"); + }, + (context) => { + userCounts.push(context.messages.filter((message) => message.role === "user").length); + return fauxAssistantMessage("third"); + }, + ]); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + followUpMode: "one-at-a-time", + }); + const followUpQueueLengths: number[] = []; + let queued = false; + harness.subscribe((event) => { + if (event.type === "queue_update") { + followUpQueueLengths.push(event.followUp.length); + } + if (event.type === "message_start" && event.message.role === "assistant" && !queued) { + queued = true; + harness.followUp("one"); + harness.followUp("two"); + } + }); + + await harness.prompt("hello"); + + expect(userCounts).toEqual([1, 2, 3]); + expect(followUpQueueLengths).toEqual([1, 2, 1, 0]); + }); + + it("settles thrown hook failures with persisted assistant error messages", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([() => fauxAssistantMessage("should not be used")]); + const session = new Session(new InMemorySessionStorage()); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + const events: string[] = []; + harness.subscribe((event) => { + events.push(event.type); + }); + harness.on("context", () => { + throw new Error("context exploded"); + }); + + const response = await harness.prompt("hello"); + await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" }); + + const entries = await session.getEntries(); + const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : [])); + expect(response.stopReason).toBe("error"); + expect(response.errorMessage).toBe("context exploded"); + expect(messages[0]?.role).toBe("user"); + expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" }); + expect(events).toContain("agent_end"); + expect(events).toContain("settled"); + }); + + it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => { + const registration = registerFauxProvider({ + models: [ + { id: "first", reasoning: true }, + { id: "second", reasoning: true }, + ], + }); + registrations.push(registration); + const secondModel = registration.getModel("second"); + if (!secondModel) throw new Error("missing second faux model"); + const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = []; + registration.setResponses([ + (context, options, _state, model) => { + captured.push({ + modelId: model.id, + reasoning: getReasoning(options), + systemPrompt: context.systemPrompt ?? "", + tools: context.tools?.map((tool) => tool.name) ?? [], + }); + return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { + stopReason: "toolUse", + }); + }, + (context, options, _state, model) => { + captured.push({ + modelId: model.id, + reasoning: getReasoning(options), + systemPrompt: context.systemPrompt ?? "", + tools: context.tools?.map((tool) => tool.name) ?? [], + }); + return fauxAssistantMessage("done"); + }, + ]); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + thinkingLevel: "off", + resources: { + skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }], + }, + systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt", + tools: [calculateTool], + }); + harness.subscribe((event) => { + if (event.type === "tool_execution_start") { + void harness.setModel(secondModel); + void harness.setThinkingLevel("high"); + void harness.setResources({ + skills: [ + { name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" }, + ], + }); + void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]); + } + }); + + await harness.prompt("hello"); + + expect(captured).toEqual([ + { modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] }, + { modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] }, + ]); + }); + + it("orders pending listener session writes after agent-emitted messages", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([() => fauxAssistantMessage("ok")]); + const session = new Session(new InMemorySessionStorage()); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + let wrotePendingMessage = false; + harness.subscribe(async (event) => { + if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) { + wrotePendingMessage = true; + await harness.appendMessage({ + role: "custom", + customType: "listener", + content: "listener write", + display: true, + timestamp: Date.now(), + } as AgentMessage); + } + }); + + await harness.prompt("hello"); + + const entries = await session.getEntries(); + const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : [])); + expect(roles).toEqual(["user", "assistant", "custom"]); + }); + + it("waitForIdle waits for external run settlement and awaited listeners", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([() => fauxAssistantMessage("ok")]); + const barrier = deferred(); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session: new Session(new InMemorySessionStorage()), + model: registration.getModel(), + }); + let listenerFinished = false; + harness.subscribe(async (event) => { + if (event.type === "agent_end") { + await barrier.promise; + listenerFinished = true; + } + }); + + const promptPromise = harness.prompt("hello"); + let idleResolved = false; + const idlePromise = harness.waitForIdle().then(() => { + idleResolved = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(idleResolved).toBe(false); + expect(listenerFinished).toBe(false); + barrier.resolve(); + await Promise.all([promptPromise, idlePromise]); + expect(idleResolved).toBe(true); + expect(listenerFinished).toBe(true); + }); + + it("runs tool_call and tool_result hooks through the direct loop", async () => { + const registration = registerFauxProvider(); + registrations.push(registration); + registration.setResponses([ + () => + fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), { + stopReason: "toolUse", + }), + ]); + const session = new Session(new InMemorySessionStorage()); + const harness = new AgentHarness({ + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + tools: [calculateTool], + }); + const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = []; + harness.on("tool_call", (event) => { + seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression }); + return undefined; + }); + harness.on("tool_result", (event) => { + expect(event.toolCallId).toBe("call-1"); + expect(event.toolName).toBe("calculate"); + return { + content: [{ type: "text", text: "patched result" }], + details: { patched: true }, + terminate: true, + }; + }); + + await harness.prompt("hello"); + + const toolResult = (await session.getEntries()).find( + (entry) => entry.type === "message" && entry.message.role === "toolResult", + ); + expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]); + expect(toolResult).toMatchObject({ + type: "message", + message: { + role: "toolResult", + content: [{ type: "text", text: "patched result" }], + details: { patched: true }, + }, + }); }); it("preserves app resource types for getters and update events", async () => { diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index d73517f0..b694d9f5 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -2,32 +2,40 @@ import { type AssistantMessage, type FauxProviderRegistration, fauxAssistantMessage, + type Message, type Model, registerFauxProvider, type Usage, } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + type CompactionPreparation, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, + estimateTokens, findCutPoint, + findTurnStartIndex, generateSummary, + getLastAssistantUsage, prepareCompaction, serializeConversation, shouldCompact, -} from "../../src/harness/compaction/compaction.js"; -import { buildSessionContext } from "../../src/harness/session/session.js"; +} from "../../src/harness/compaction/compaction.ts"; +import { buildSessionContext } from "../../src/harness/session/session.ts"; import type { + BranchSummaryEntry, CompactionEntry, CompactionSettings, + CustomMessageEntry, MessageEntry, ModelChangeEntry, SessionTreeEntry, ThinkingLevelChangeEntry, -} from "../../src/harness/types.js"; -import type { AgentMessage } from "../../src/types.js"; +} from "../../src/harness/types.ts"; +import { getOrThrow } from "../../src/harness/types.ts"; +import type { AgentMessage } from "../../src/types.ts"; let nextId = 0; function createId(): string { @@ -113,14 +121,17 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str }; } -function createFauxModel(reasoning: boolean): { faux: FauxProviderRegistration; model: Model } { +function createFauxModel( + reasoning: boolean, + maxTokens = 8192, +): { faux: FauxProviderRegistration; model: Model } { const faux = registerFauxProvider({ models: [ { id: reasoning ? "reasoning-model" : "non-reasoning-model", reasoning, contextWindow: 200000, - maxTokens: 8192, + maxTokens, }, ], }); @@ -175,6 +186,133 @@ describe("harness compaction", () => { expect(entries[result.firstKeptEntryIndex]?.type).toBe("message"); }); + it("covers cut-point and turn-start edge cases", () => { + const thinking = createThinkingLevelEntry("high"); + const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id); + expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({ + firstKeptEntryIndex: 0, + turnStartIndex: -1, + isSplitTurn: false, + }); + + const branchSummary: BranchSummaryEntry = { + type: "branch_summary", + id: createId(), + parentId: modelChange.id, + timestamp: new Date().toISOString(), + fromId: "branch", + summary: "branch summary", + }; + const customMessage: CustomMessageEntry = { + type: "custom_message", + id: createId(), + parentId: branchSummary.id, + timestamp: new Date().toISOString(), + customType: "note", + content: "custom content", + display: true, + }; + expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1); + expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1); + expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1); + + const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1); + expect(result.firstKeptEntryIndex).toBe(0); + + const toolResult = createMessageEntry({ + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [{ type: "text", text: "tool output" }], + isError: false, + timestamp: Date.now(), + }); + expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({ + firstKeptEntryIndex: 0, + turnStartIndex: -1, + isSplitTurn: false, + }); + + const user = createMessageEntry(createUserMessage("user")); + const compaction = createCompactionEntry("summary", user.id, user.id); + const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id); + expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2); + }); + + it("estimates tokens and context usage across supported message roles", () => { + const usage = createMockUsage(10, 5, 3, 2); + const assistant = createAssistantMessage("assistant", usage); + const assistantWithThinkingAndTool: AssistantMessage = { + ...assistant, + content: [ + { type: "thinking", thinking: "thinking" }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } }, + ], + }; + const customString: AgentMessage = { + role: "custom", + customType: "note", + content: "custom text", + display: true, + timestamp: Date.now(), + }; + const toolResultWithImage: AgentMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [ + { type: "text", text: "tool text" }, + { type: "image", mimeType: "image/png", data: "abc" }, + ], + isError: false, + timestamp: Date.now(), + }; + const bashExecution: AgentMessage = { + role: "bashExecution", + command: "npm run check", + output: "ok", + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: Date.now(), + }; + const branchSummaryMessage: AgentMessage = { + role: "branchSummary", + summary: "branch", + fromId: "x", + timestamp: Date.now(), + }; + const compactionSummaryMessage: AgentMessage = { + role: "compactionSummary", + summary: "compact", + tokensBefore: 123, + timestamp: Date.now(), + }; + + expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0); + expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0); + expect(estimateTokens(customString)).toBeGreaterThan(0); + expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000); + expect(estimateTokens(bashExecution)).toBeGreaterThan(0); + expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0); + expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0); + expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0); + expect( + getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]), + ).toBe(usage); + expect( + getLastAssistantUsage([ + createMessageEntry({ ...assistant, stopReason: "aborted" }), + createMessageEntry({ ...assistant, stopReason: "error" }), + ]), + ).toBeUndefined(); + expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull(); + expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({ + usageTokens: 20, + lastUsageIndex: 0, + }); + }); + it("builds session context with a compaction entry", () => { const u1 = createMessageEntry(createUserMessage("1")); const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); @@ -207,13 +345,78 @@ describe("harness compaction", () => { const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id); const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id); const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3]; - const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS); + const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS)); expect(preparation).toBeDefined(); expect(preparation?.previousSummary).toBe("First summary"); expect(preparation?.firstKeptEntryId).toBeTruthy(); expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens); }); + it("prepares split-turn compaction with prior file-operation details", () => { + const u1 = createMessageEntry(createUserMessage("user msg 1")); + const assistantMessage: AssistantMessage = { + ...createAssistantMessage("assistant msg 1"), + content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }], + }; + const a1 = createMessageEntry(assistantMessage, u1.id); + const compaction1: CompactionEntry = { + ...createCompactionEntry("First summary", u1.id, a1.id), + details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] }, + }; + const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id); + const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id); + const preparation = getOrThrow( + prepareCompaction([u1, a1, compaction1, u2, a2], { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 1, + }), + ); + + expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true }); + expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]); + expect([...preparation!.fileOps.read]).toContain("old-read.ts"); + expect([...preparation!.fileOps.edited]).toContain("old-edit.ts"); + expect([...preparation!.fileOps.written]).toContain("written.ts"); + }); + + it("prepares custom and branch summary entries for summarization", () => { + const branchSummary: BranchSummaryEntry = { + type: "branch_summary", + id: createId(), + parentId: null, + timestamp: new Date().toISOString(), + fromId: "branch", + summary: "branch summary", + }; + const customMessage: CustomMessageEntry = { + type: "custom_message", + id: createId(), + parentId: branchSummary.id, + timestamp: new Date().toISOString(), + customType: "note", + content: "custom content", + display: true, + }; + const user = createMessageEntry(createUserMessage("keep"), customMessage.id); + const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id); + const preparation = getOrThrow( + prepareCompaction([branchSummary, customMessage, user, assistant], { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 1, + }), + ); + + expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]); + }); + + it("does not prepare compaction when there is nothing valid to compact", () => { + const compaction = createCompactionEntry("already compacted", "entry-keep"); + expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); + expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); + }); + it("serializes conversation with truncated tool results", () => { const longContent = "x".repeat(5000); const messages = convertMessages([ @@ -241,16 +444,18 @@ describe("harness compaction", () => { return fauxAssistantMessage("## Goal\nTest summary"); }, ]); - await generateSummary( - messages, - reasoningModel, - 2000, - "test-key", - undefined, - undefined, - undefined, - undefined, - "medium", + getOrThrow( + await generateSummary( + messages, + reasoningModel, + 2000, + "test-key", + undefined, + undefined, + undefined, + undefined, + "medium", + ), ); expect(seenOptions[0]).toMatchObject({ reasoning: "medium", apiKey: "test-key" }); @@ -261,7 +466,9 @@ describe("harness compaction", () => { return fauxAssistantMessage("## Goal\nTest summary"); }, ]); - await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"); + getOrThrow( + await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"), + ); expect(seenOptions[1]).not.toHaveProperty("reasoning"); const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false); @@ -271,20 +478,177 @@ describe("harness compaction", () => { return fauxAssistantMessage("## Goal\nTest summary"); }, ]); - await generateSummary( - messages, - nonReasoningModel, - 2000, - "test-key", - undefined, - undefined, - undefined, - undefined, - "medium", + getOrThrow( + await generateSummary( + messages, + nonReasoningModel, + 2000, + "test-key", + undefined, + undefined, + undefined, + undefined, + "medium", + ), ); expect(seenOptions[2]).not.toHaveProperty("reasoning"); }); + it("includes previous summaries and custom instructions in generateSummary prompts", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + let promptText = ""; + const { faux, model } = createFauxModel(false); + faux.setResponses([ + (context) => { + const message = context.messages[0]; + const content = message?.role === "user" ? message.content : []; + promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : ""; + return fauxAssistantMessage("## Goal\nTest summary"); + }, + ]); + + const summary = getOrThrow( + await generateSummary( + messages, + model, + 2000, + "test-key", + { "x-test": "yes" }, + undefined, + "focus", + "old summary", + ), + ); + + expect(summary).toContain("Test summary"); + expect(promptText).toContain("\nold summary\n"); + expect(promptText).toContain("Additional focus: focus"); + }); + + it("returns error results for failed or aborted summary generations", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const { faux: errorFaux, model: errorModel } = createFauxModel(false); + errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]); + const errorResult = await generateSummary(messages, errorModel, 2000, "test-key"); + expect(errorResult).toMatchObject({ + ok: false, + error: { code: "summarization_failed", message: "Summarization failed: boom" }, + }); + + const { faux: abortedFaux, model: abortedModel } = createFauxModel(false); + abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]); + const abortedResult = await generateSummary(messages, abortedModel, 2000, "test-key"); + expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } }); + }); + + it("clamps compaction summary maxTokens to the model output cap", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const seenOptions: Array | undefined> = []; + const { faux, model } = createFauxModel(false, 128000); + faux.setResponses([ + (_context, options) => { + seenOptions.push(options as Record | undefined); + return fauxAssistantMessage("## Goal\nTest summary"); + }, + (_context, options) => { + seenOptions.push(options as Record | undefined); + return fauxAssistantMessage("## Goal\nTest summary"); + }, + ]); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 600000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 }, + }; + + getOrThrow(await compact(preparation, model, "test-key")); + + expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]); + }); + + it("returns compaction error results without throwing", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: [], + isSplitTurn: false, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + const { faux: historyFaux, model: historyModel } = createFauxModel(false); + historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]); + expect(await compact(preparation, historyModel, "test-key")).toMatchObject({ + ok: false, + error: { code: "summarization_failed", message: "Summarization failed: history failed" }, + }); + + const { model: invalidModel } = createFauxModel(false); + const invalidResult = await compact( + { ...preparation, messagesToSummarize: [], firstKeptEntryId: "" }, + invalidModel, + "test-key", + ); + expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } }); + }); + + it("passes reasoning through turn-prefix summaries when enabled", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const seenOptions: Array | undefined> = []; + const { faux, model } = createFauxModel(true); + faux.setResponses([ + (_context, options) => { + seenOptions.push(options as Record | undefined); + return fauxAssistantMessage("## Original Request\nTest summary"); + }, + ]); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + getOrThrow(await compact(preparation, model, "test-key", undefined, undefined, undefined, "high")); + + expect(seenOptions[0]).toMatchObject({ reasoning: "high" }); + }); + + it("returns turn-prefix compaction errors without throwing", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + const { faux, model } = createFauxModel(false); + faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]); + + expect(await compact(preparation, model, "test-key")).toMatchObject({ + ok: false, + error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" }, + }); + + const { faux: abortedFaux, model: abortedModel } = createFauxModel(false); + abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]); + expect(await compact(preparation, abortedModel, "test-key")).toMatchObject({ + ok: false, + error: { code: "aborted", message: "prefix stopped" }, + }); + }); + it("returns a compaction result with file details", async () => { const u1 = createMessageEntry(createUserMessage("read a file")); const assistantMessage: AssistantMessage = { @@ -294,17 +658,17 @@ describe("harness compaction", () => { const a1 = createMessageEntry(assistantMessage, u1.id); const u2 = createMessageEntry(createUserMessage("continue"), a1.id); const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id); - const preparation = prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS); + const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS)); expect(preparation).toBeDefined(); const { faux, model } = createFauxModel(false); faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); - const result = await compact(preparation!, model, "test-key"); + const result = getOrThrow(await compact(preparation!, model, "test-key")); expect(result.summary.length).toBeGreaterThan(0); expect(result.firstKeptEntryId).toBeTruthy(); expect(result.details).toBeDefined(); }); }); -function convertMessages(messages: any[]): any[] { +function convertMessages(messages: Message[]): Message[] { return messages; } diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 2059c075..758d5f59 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -1,8 +1,10 @@ import { access, chmod, realpath, symlink } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js"; -import { createTempDir } from "./session-test-utils.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { FileError, getOrThrow } from "../../src/harness/types.ts"; +import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts"; +import { createTempDir } from "./session-test-utils.ts"; const chmodRestorePaths: string[] = []; @@ -19,61 +21,69 @@ describe("NodeExecutionEnv", () => { it("reads, writes, lists, and removes files and directories", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - await env.createDir("nested", { recursive: true }); - await env.writeFile("nested/file.txt", "hello"); - expect(await env.readTextFile("nested/file.txt")).toBe("hello"); - expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello"); + expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child")); + expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child")); + getOrThrow(await env.createDir("nested/child")); + getOrThrow(await env.writeFile("nested/child/file.txt", "hel")); + getOrThrow(await env.appendFile("nested/child/file.txt", "lo")); + expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello"); + expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]); + expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello"); - const entries = await env.listDir("nested"); + const entries = getOrThrow(await env.listDir("nested/child")); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ name: "file.txt", - path: join(root, "nested/file.txt"), + path: join(root, "nested/child/file.txt"), kind: "file", size: 5, }); expect(typeof entries[0]!.mtimeMs).toBe("number"); - expect(await env.exists("nested/file.txt")).toBe(true); - await env.remove("nested/file.txt"); - expect(await env.exists("nested/file.txt")).toBe(false); + expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true); + getOrThrow(await env.remove("nested/child/file.txt")); + expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false); }); it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - await env.createDir("dir", { recursive: true }); - await env.writeFile("dir/file.txt", "hello"); + getOrThrow(await env.createDir("dir", { recursive: true })); + getOrThrow(await env.writeFile("dir/file.txt", "hello")); await symlink(join(root, "dir/file.txt"), join(root, "file-link")); await symlink(join(root, "dir"), join(root, "dir-link")); - expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" }); - expect(await env.fileInfo("dir/file.txt")).toMatchObject({ + expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({ + name: "dir", + path: join(root, "dir"), + kind: "directory", + }); + expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({ name: "file.txt", path: join(root, "dir/file.txt"), kind: "file", size: 5, }); - expect(await env.fileInfo("file-link")).toMatchObject({ + expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({ name: "file-link", path: join(root, "file-link"), kind: "symlink", }); - expect(await env.fileInfo("dir-link")).toMatchObject({ + expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({ name: "dir-link", path: join(root, "dir-link"), kind: "symlink", }); - expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt"))); + expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt"))); }); it("lists symlinks as symlinks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - await env.writeFile("target.txt", "hello"); + getOrThrow(await env.writeFile("target.txt", "hello")); await symlink(join(root, "target.txt"), join(root, "link.txt")); - const entries = await env.listDir("."); + const entries = getOrThrow(await env.listDir(".")); expect( entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)), ).toEqual([ @@ -82,41 +92,112 @@ describe("NodeExecutionEnv", () => { ]); }); - it("throws FileError for missing paths and keeps exists false for missing paths", async () => { + it("stops reading text lines at the requested limit", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({ - name: "FileError", - code: "not_found", - path: join(root, "missing.txt"), - }); - expect(await env.exists("missing.txt")).toBe(false); + getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree")); + expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]); }); - it("throws FileError for listing non-directories", async () => { + it("returns FileError for missing paths and keeps exists false for missing paths", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - await env.writeFile("file.txt", "hello"); - await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError); - await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" }); + const info = await env.fileInfo("missing.txt"); + expect(info.ok).toBe(false); + if (!info.ok) { + expect(info.error).toBeInstanceOf(FileError); + expect(info.error).toMatchObject({ + name: "FileError", + code: "not_found", + path: join(root, "missing.txt"), + }); + } + expect(getOrThrow(await env.exists("missing.txt"))).toBe(false); + }); + + it("returns FileError for listing non-directories", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.writeFile("file.txt", "hello")); + const result = await env.listDir("file.txt"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(FileError); + expect(result.error).toMatchObject({ code: "not_directory" }); + } + }); + + it("appends to new files and creates parent directories", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.appendFile("new/nested/file.txt", "a")); + getOrThrow(await env.appendFile("new/nested/file.txt", "b")); + expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab"); }); it("creates temporary directories and files", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - const tempDir = await env.createTempDir("node-env-test-"); + const tempDir = getOrThrow(await env.createTempDir("node-env-test-")); await expect(access(tempDir)).resolves.toBeUndefined(); - const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }); + const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" })); await expect(access(tempFile)).resolves.toBeUndefined(); expect(tempFile.endsWith(".txt")).toBe(true); }); + it("honors createDir recursive false and remove recursive/force options", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const createResult = await env.createDir("missing/child", { recursive: false }); + expect(createResult.ok).toBe(false); + if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" }); + + getOrThrow(await env.writeFile("dir/child/file.txt", "hello")); + const removeDirectory = await env.remove("dir", { recursive: false }); + expect(removeDirectory.ok).toBe(false); + getOrThrow(await env.remove("dir", { recursive: true })); + expect(getOrThrow(await env.exists("dir"))).toBe(false); + + const removeMissing = await env.remove("missing", { force: false }); + expect(removeMissing.ok).toBe(false); + getOrThrow(await env.remove("missing", { force: true })); + }); + + it("returns aborted results for pre-aborted cancellable file operations", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.writeFile("file.txt", "hello")); + const controller = new AbortController(); + controller.abort(); + const signal = controller.signal; + + const results = await Promise.all([ + env.readTextFile("file.txt", signal), + env.readTextLines("file.txt", { abortSignal: signal }), + env.readBinaryFile("file.txt", signal), + env.writeFile("other.txt", "hello", signal), + env.listDir(".", signal), + ]); + for (const result of results) { + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" }); + } + }); + + it("cleanup is best-effort", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await expect(env.cleanup()).resolves.toBeUndefined(); + }); + it("executes commands in cwd with env overrides", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); - const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', { - env: { NODE_ENV_TEST: "ok" }, - }); + const result = getOrThrow( + await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', { + env: { NODE_ENV_TEST: "ok" }, + }), + ); expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); @@ -125,25 +206,83 @@ describe("NodeExecutionEnv", () => { const env = new NodeExecutionEnv({ cwd: root }); let stdout = ""; let stderr = ""; - const result = await env.exec("printf out; printf err >&2", { - onStdout: (chunk) => { - stdout += chunk; - }, - onStderr: (chunk) => { - stderr += chunk; - }, - }); + const result = getOrThrow( + await env.exec("printf out; printf err >&2", { + onStdout: (chunk) => { + stdout += chunk; + }, + onStderr: (chunk) => { + stderr += chunk; + }, + }), + ); expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 }); expect(stdout).toBe("out"); expect(stderr).toBe("err"); }); - it("rejects aborted commands", async () => { + it("returns non-zero command exit codes as successful execution results", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = getOrThrow(await env.exec("exit 7")); + expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 }); + }); + + it("returns timeout errors for commands exceeding the timeout", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = await env.exec("sleep 5", { timeout: 0.01 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" }); + }); + + it("returns callback errors from exec stream handlers", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = await env.exec("printf out", { + onStdout: () => { + throw new Error("callback failed"); + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" }); + }); + + it("returns shell unavailable and spawn errors", async () => { + const root = createTempDir(); + const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") }); + const missingShell = await missingShellEnv.exec("printf ok"); + expect(missingShell.ok).toBe(false); + if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" }); + + const shellPath = join(root, "not-executable-shell"); + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.writeFile(shellPath, "not executable")); + const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath }); + const spawnError = await spawnErrorEnv.exec("printf ok"); + expect(spawnError.ok).toBe(false); + if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" }); + }); + + it("returns an aborted result for aborted commands", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const controller = new AbortController(); - const promise = env.exec("sleep 5", { signal: controller.signal }); + const promise = env.exec("sleep 5", { abortSignal: controller.signal }); controller.abort(); - await expect(promise).rejects.toThrow("aborted"); + const result = await promise; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" }); + }); + + it("captures large shell output to a full output file through the execution env", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000")); + expect(result.truncated).toBe(true); + expect(result.fullOutputPath).toBeDefined(); + const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!)); + expect(fullOutput.split("\n").length).toBeGreaterThan(10000); + expect(result.output.length).toBeLessThan(fullOutput.length); }); }); diff --git a/packages/agent/test/harness/prompt-templates.test.ts b/packages/agent/test/harness/prompt-templates.test.ts index 62ae9aee..8e118ad4 100644 --- a/packages/agent/test/harness/prompt-templates.test.ts +++ b/packages/agent/test/harness/prompt-templates.test.ts @@ -1,13 +1,13 @@ import { symlink } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { formatPromptTemplateInvocation, loadPromptTemplates, loadSourcedPromptTemplates, -} from "../../src/harness/prompt-templates.js"; -import { createTempDir } from "./session-test-utils.js"; +} from "../../src/harness/prompt-templates.ts"; +import { createTempDir } from "./session-test-utils.ts"; describe("loadPromptTemplates", () => { it("loads markdown templates non-recursively from one or more dirs", async () => { diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts index 310d8898..b768fc1a 100644 --- a/packages/agent/test/harness/repo.test.ts +++ b/packages/agent/test/harness/repo.test.ts @@ -1,8 +1,9 @@ import { existsSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js"; -import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts"; +import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts"; +import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; describe("InMemorySessionRepo", () => { it("opens, deletes, and forks by metadata", async () => { @@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => { describe("JsonlSessionRepo", () => { it("stores sessions below encoded cwd directories and lists by cwd", async () => { const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); const cwd = "/tmp/my-project"; const otherCwd = "/tmp/other-project"; - const repo = new JsonlSessionRepo({ sessionsRoot: root }); + const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" }); const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" }); const metadata = await session.getMetadata(); @@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => { it("opens, deletes, and forks by metadata", async () => { const root = createTempDir(); - const repo = new JsonlSessionRepo({ sessionsRoot: root }); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); const source = await repo.create({ cwd: "/tmp/source", id: "source-session" }); const sourceMetadata = await source.getMetadata(); const user1 = await source.appendMessage(createUserMessage("one")); diff --git a/packages/agent/test/harness/resource-formatting.test.ts b/packages/agent/test/harness/resource-formatting.test.ts index fe3fed40..45d4553f 100644 --- a/packages/agent/test/harness/resource-formatting.test.ts +++ b/packages/agent/test/harness/resource-formatting.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.js"; -import { formatSkillInvocation } from "../../src/harness/skills.js"; +import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.ts"; +import { formatSkillInvocation } from "../../src/harness/skills.ts"; describe("resource formatting helpers", () => { it("formats skill invocations with additional instructions", () => { diff --git a/packages/agent/test/harness/session-uuid.test.ts b/packages/agent/test/harness/session-uuid.test.ts new file mode 100644 index 00000000..b8d571f4 --- /dev/null +++ b/packages/agent/test/harness/session-uuid.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { uuidv7 } from "../../src/harness/session/uuid.ts"; + +const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const TIMESTAMP = 0x0123456789ab; + +function parseTimestamp(uuid: string): number { + return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("uuidv7", () => { + it("uses the RFC 9562 layout and preserves monotonic order", () => { + const randomValues = [ + new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]), + new Uint8Array(16), + new Uint8Array(16), + ]; + const getRandomValues = vi.fn((bytes: Uint8Array) => { + bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length)); + return bytes; + }); + vi.stubGlobal("crypto", { getRandomValues }); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP); + + try { + const first = uuidv7(); + const second = uuidv7(); + const third = uuidv7(); + + expect(first).toBe("01234567-89ab-7fff-bfff-f91122334455"); + expect(second).toBe("01234567-89ab-7fff-bfff-fc0000000000"); + expect(third).toBe("01234567-89ac-7000-8000-000000000000"); + expect(first).toMatch(UUID_V7_RE); + expect(second).toMatch(UUID_V7_RE); + expect(third).toMatch(UUID_V7_RE); + expect(parseTimestamp(first)).toBe(TIMESTAMP); + expect(parseTimestamp(second)).toBe(TIMESTAMP); + expect(parseTimestamp(third)).toBe(TIMESTAMP + 1); + expect(first < second).toBe(true); + expect(second < third).toBe(true); + expect(getRandomValues).toHaveBeenCalledTimes(3); + } finally { + dateNow.mockRestore(); + } + }); +}); diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts index d6b7f365..c9598da8 100644 --- a/packages/agent/test/harness/session.test.ts +++ b/packages/agent/test/harness/session.test.ts @@ -1,11 +1,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { Session } from "../../src/harness/session/session.js"; -import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js"; -import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; -import type { SessionStorage } from "../../src/harness/types.js"; -import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; +import { Session } from "../../src/harness/session/session.ts"; +import type { SessionStorage } from "../../src/harness/types.ts"; +import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts"; async function runSessionSuite( name: string, @@ -128,7 +129,8 @@ runSessionSuite( "Session with JSONL storage", async () => { const dir = createTempDir(); - return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" }); + const env = new NodeExecutionEnv({ cwd: dir }); + return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" }); }, () => { const dir = getLatestTempDir(); @@ -138,10 +140,10 @@ runSessionSuite( const header = JSON.parse(lines[0]!); expect(header.type).toBe("session"); expect(header.version).toBe(3); - for (const line of lines.slice(1)) { - const entry = JSON.parse(line); + const entries = lines.slice(1).map((line) => JSON.parse(line)); + expect(entries.some((entry) => entry.type === "leaf")).toBe(true); + for (const entry of entries) { expect(entry.type).not.toBe("entry"); - expect(entry.type).not.toBe("leaf"); expect(typeof entry.id).toBe("string"); } }, diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts index f20c70bd..bd769ea9 100644 --- a/packages/agent/test/harness/skills.test.ts +++ b/packages/agent/test/harness/skills.test.ts @@ -1,9 +1,9 @@ import { symlink } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/execution-env.js"; -import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js"; -import { createTempDir } from "./session-test-utils.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts"; +import { createTempDir } from "./session-test-utils.ts"; describe("loadSkills", () => { it("loads SKILL.md files through the execution environment", async () => { @@ -93,6 +93,7 @@ Use this skill. expect(diagnostics).toEqual([ { type: "warning", + code: "invalid_metadata", message: "description is required", path: join(root, "user/broken/SKILL.md"), source: { type: "user" }, diff --git a/packages/agent/test/harness/storage.test.ts b/packages/agent/test/harness/storage.test.ts index 8c05702b..e8383ce4 100644 --- a/packages/agent/test/harness/storage.test.ts +++ b/packages/agent/test/harness/storage.test.ts @@ -1,10 +1,11 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js"; -import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; -import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; +import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts"; +import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; describe("InMemorySessionStorage", () => { it("returns configured session metadata", async () => { @@ -13,7 +14,7 @@ describe("InMemorySessionStorage", () => { expect(await storage.getMetadata()).toEqual(metadata); }); - it("copies initial entries and tracks leaf independently", async () => { + it("copies initial entries and persists leaf changes", async () => { const entry: MessageEntry = { type: "message", id: "entry-1", @@ -28,12 +29,12 @@ describe("InMemorySessionStorage", () => { expect(await storage.getLeafId()).toBe("entry-1"); await storage.setLeafId(null); expect(await storage.getLeafId()).toBeNull(); + expect((await storage.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: null }); }); it("rejects invalid leaf ids", async () => { const storage = new InMemorySessionStorage(); await expect(storage.setLeafId("missing")).rejects.toThrow("Entry missing not found"); - expect(() => new InMemorySessionStorage({ leafId: "missing" })).toThrow("Entry missing not found"); }); it("finds entries by type", async () => { @@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => { describe("JsonlSessionStorage", () => { it("throws for missing files when opening", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" }); }); it("writes the header on create", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" }); + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); expect(existsSync(filePath)).toBe(true); expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1); expect(await storage.getLeafId()).toBeNull(); @@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => { it("throws for malformed session headers", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); writeFileSync(filePath, "not json\n"); - await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header"); + await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header"); }); - it("ignores malformed entry lines", async () => { + it("throws for malformed entry lines", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); const header = { type: "session", @@ -152,15 +157,14 @@ describe("JsonlSessionStorage", () => { message: createUserMessage("one"), }; writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`); - const storage = await JsonlSessionStorage.open(filePath); - expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]); - expect(await storage.getLeafId()).toBe("entry-1"); + await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "invalid_entry" }); }); it("creates and reads session metadata from the header", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - const storage = await JsonlSessionStorage.create(filePath, { + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1", parentSessionPath: "/tmp/parent.jsonl", @@ -179,13 +183,14 @@ describe("JsonlSessionStorage", () => { timestamp: "2026-01-01T00:00:00.000Z", message: createUserMessage("one"), }); - expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata); + expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata); }); it("loads existing entries and reconstructs leaf", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" }); + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); const root: MessageEntry = { type: "message", id: "root", @@ -201,16 +206,21 @@ describe("JsonlSessionStorage", () => { }; await storage.appendEntry(root); await storage.appendEntry(child); - const loaded = await JsonlSessionStorage.open(filePath); + const loaded = await JsonlSessionStorage.open(env, filePath); expect(await loaded.getLeafId()).toBe("child"); expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]); + await loaded.setLeafId("root"); + const reloaded = await JsonlSessionStorage.open(env, filePath); + expect(await reloaded.getLeafId()).toBe("root"); + expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" }); expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]); }); it("finds entries by type", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" }); + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); await storage.appendEntry({ type: "message", id: "entry-1", @@ -224,8 +234,9 @@ describe("JsonlSessionStorage", () => { it("maintains label lookup", async () => { const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); const filePath = join(dir, "session.jsonl"); - const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" }); + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); await storage.appendEntry({ type: "message", id: "entry-1", @@ -252,11 +263,11 @@ describe("JsonlSessionStorage", () => { label: undefined, }); expect(await storage.getLabel("entry-1")).toBeUndefined(); - const loaded = await JsonlSessionStorage.open(filePath); + const loaded = await JsonlSessionStorage.open(env, filePath); expect(await loaded.getLabel("entry-1")).toBeUndefined(); }); - it("reads session metadata from only the first JSONL line", async () => { + it("reads session metadata through the line-reading filesystem operation", async () => { const dir = createTempDir(); const filePath = join(dir, "session.jsonl"); const header = { @@ -266,9 +277,18 @@ describe("JsonlSessionStorage", () => { timestamp: "2026-01-01T00:00:00.000Z", cwd: dir, }; - const malformedSecondLine = "{".repeat(10000); - writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`); - expect(await loadJsonlSessionMetadata(filePath)).toEqual({ + const metadata = await loadJsonlSessionMetadata( + { + readTextLines: async () => ok([JSON.stringify(header)]), + readTextFile: async () => { + throw new Error("readTextFile should not be called for metadata"); + }, + writeFile: async () => ok(undefined), + appendFile: async () => ok(undefined), + }, + filePath, + ); + expect(metadata).toEqual({ id: "session-1", createdAt: "2026-01-01T00:00:00.000Z", cwd: dir, diff --git a/packages/agent/test/harness/system-prompt.test.ts b/packages/agent/test/harness/system-prompt.test.ts index 46ce884b..f20fbd16 100644 --- a/packages/agent/test/harness/system-prompt.test.ts +++ b/packages/agent/test/harness/system-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js"; +import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.ts"; const visibleSkill = { name: "visible", diff --git a/packages/agent/test/harness/truncate.test.ts b/packages/agent/test/harness/truncate.test.ts new file mode 100644 index 00000000..3784a52c --- /dev/null +++ b/packages/agent/test/harness/truncate.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts"; + +const encoder = new TextEncoder(); + +function byteLength(content: string): number { + return encoder.encode(content).length; +} + +function bufferTail(content: string, maxBytes: number): string { + const bytes = Buffer.from(content, "utf8"); + if (bytes.length <= maxBytes) return content; + let start = bytes.length - maxBytes; + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++; + return bytes.subarray(start).toString("utf8"); +} + +function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void { + const totalBytes = Buffer.byteLength(input, "utf8"); + const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes); + for (const maxBytes of values) { + const result = truncateTail(input, { maxBytes, maxLines: 10 }); + const expected = bufferTail(input, maxBytes); + if (result.content !== expected) { + throw new Error( + `tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`, + ); + } + const outputBytes = Buffer.byteLength(result.content, "utf8"); + if (outputBytes > maxBytes) { + throw new Error( + `tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`, + ); + } + } +} + +function sampledByteLimits(input: string): number[] { + const totalBytes = Buffer.byteLength(input, "utf8"); + const candidates = [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + Math.floor(totalBytes / 2) - 1, + Math.floor(totalBytes / 2), + Math.floor(totalBytes / 2) + 1, + totalBytes - 8, + totalBytes - 5, + totalBytes - 4, + totalBytes - 3, + totalBytes - 2, + totalBytes - 1, + totalBytes, + totalBytes + 1, + totalBytes + 4, + ]; + return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b); +} + +describe("truncate utilities", () => { + it("counts UTF-8 bytes without Node Buffer", () => { + const content = "aé🙂\nb"; + const result = truncateHead(content, { maxBytes: 100, maxLines: 10 }); + + expect(result.truncated).toBe(false); + expect(result.totalBytes).toBe(byteLength(content)); + expect(result.outputBytes).toBe(byteLength(content)); + expect(result.totalBytes).toBe(9); + }); + + it("truncates head on UTF-8 byte limits without partial lines", () => { + const content = "éé\nabc"; + const result = truncateHead(content, { maxBytes: 4, maxLines: 10 }); + + expect(result.content).toBe("éé"); + expect(result.truncated).toBe(true); + expect(result.truncatedBy).toBe("bytes"); + expect(result.outputBytes).toBe(4); + expect(result.firstLineExceedsLimit).toBe(false); + }); + + it("reports head truncation when the first line exceeds the byte limit", () => { + const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 }); + + expect(result.content).toBe(""); + expect(result.truncated).toBe(true); + expect(result.truncatedBy).toBe("bytes"); + expect(result.firstLineExceedsLimit).toBe(true); + }); + + it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => { + const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 }); + + expect(result.content).toBe("🙂b"); + expect(result.truncated).toBe(true); + expect(result.truncatedBy).toBe("bytes"); + expect(result.lastLinePartial).toBe(true); + expect(result.outputBytes).toBe(5); + }); + + it("truncates an oversized single line with a trailing newline", () => { + const input = `${"X".repeat(300_000)}\n`; + const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 }); + + expect(result.content).toBe("X".repeat(1024)); + expect(result.outputBytes).toBe(1024); + expect(result.outputLines).toBe(1); + expect(result.lastLinePartial).toBe(true); + expect(result.truncatedBy).toBe("bytes"); + }); + + it("drops an oversized trailing character when it cannot fit in tail byte limit", () => { + const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 }); + + expect(result.content).toBe(""); + expect(result.truncated).toBe(true); + expect(result.truncatedBy).toBe("bytes"); + expect(result.lastLinePartial).toBe(true); + expect(result.outputBytes).toBe(0); + }); + + it("matches Buffer tail truncation semantics for surrogate edge cases", () => { + const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩‍💻"]; + for (const input of inputs) assertMatchesBufferTail(input); + }); + + it("matches Buffer tail truncation semantics across deterministic fuzz cases", () => { + const alphabet = [ + "a", + "\u007f", + "\u0080", + "é", + "\u07ff", + "\u0800", + "中", + "\ud7ff", + "\ud800", + "\ud83d", + "\udc00", + "\ude42", + "🙂", + "\ue000", + "\uffff", + ]; + + function checkExhaustive(prefix: string, depth: number): void { + assertMatchesBufferTail(prefix, sampledByteLimits(prefix)); + if (depth === 0) return; + for (const character of alphabet) checkExhaustive(prefix + character, depth - 1); + } + checkExhaustive("", 3); + + let seed = 0x12345678; + function random(): number { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + } + for (let i = 0; i < 1_000; i++) { + let input = ""; + const length = Math.floor(random() * 80); + for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)]; + assertMatchesBufferTail(input, sampledByteLimits(input)); + } + }); +}); diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts index c417740f..de6ba3a2 100644 --- a/packages/agent/test/scratch/simple.ts +++ b/packages/agent/test/scratch/simple.ts @@ -1,23 +1,24 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; -import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; import { AgentHarness, formatSkillsForSystemPrompt, loadSourcedPromptTemplates, loadSourcedSkills, - NodeExecutionEnv, type PromptTemplate, Session, type Skill, -} from "../../src/index.js"; +} from "../../src/index.ts"; type Source = { type: "project" | "user" | "path"; dir: string }; type SourcedSkill = Skill & { source: Source }; type SourcedPromptTemplate = PromptTemplate & { source: Source }; const env = new NodeExecutionEnv({ cwd: process.cwd() }); + const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } }); const { skills: sourcedSkills } = await loadSourcedSkills( env, diff --git a/packages/agent/test/utils/calculate.ts b/packages/agent/test/utils/calculate.ts index cd2a3777..6158efbd 100644 --- a/packages/agent/test/utils/calculate.ts +++ b/packages/agent/test/utils/calculate.ts @@ -1,5 +1,5 @@ import { type Static, Type } from "typebox"; -import type { AgentTool, AgentToolResult } from "../../src/types.js"; +import type { AgentTool, AgentToolResult } from "../../src/types.ts"; export interface CalculateResult extends AgentToolResult { content: Array<{ type: "text"; text: string }>; diff --git a/packages/agent/test/utils/get-current-time.ts b/packages/agent/test/utils/get-current-time.ts index b7075d9b..a83a82af 100644 --- a/packages/agent/test/utils/get-current-time.ts +++ b/packages/agent/test/utils/get-current-time.ts @@ -1,5 +1,5 @@ import { type Static, Type } from "typebox"; -import type { AgentTool, AgentToolResult } from "../../src/types.js"; +import type { AgentTool, AgentToolResult } from "../../src/types.ts"; export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {} diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts new file mode 100644 index 00000000..9421e5a9 --- /dev/null +++ b/packages/agent/vitest.harness.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + include: ["test/harness/**/*.test.ts"], + coverage: { + provider: "v8", + all: true, + include: ["src/harness/**/*.ts", "src/agent.ts", "src/agent-loop.ts"], + exclude: ["src/**/*.d.ts"], + reporter: ["text", "html", "lcov"], + reportsDirectory: "coverage/harness", + }, + }, +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 67c402e6..841f3fed 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,13 +2,78 @@ ## [Unreleased] +### Fixed + +- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). + +## [0.75.4] - 2026-05-20 + +### Changed + +- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks. +- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources. + +### Fixed + +- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)). +- Fixed OpenAI prompt cache keys to clamp session-derived values to the 64-character API limit across OpenAI Responses, Chat Completions, Codex Responses, and Azure OpenAI Responses ([#4720](https://github.com/earendil-works/pi/issues/4720)). + +## [0.75.3] - 2026-05-18 + +## [0.75.2] - 2026-05-18 + +### Fixed + +- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests ([#4678](https://github.com/earendil-works/pi/issues/4678)). + +## [0.75.1] - 2026-05-18 + +### Fixed + +- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo ([#4342](https://github.com/earendil-works/pi/issues/4342)). +- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream ([#4223](https://github.com/earendil-works/pi/issues/4223)). +- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier ([#4232](https://github.com/earendil-works/pi/issues/4232)). +- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)). +- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)). + +### Removed + +- Removed non-working OpenAI Codex fast model variants. + +## [0.75.0] - 2026-05-17 + +### Breaking Changes + +- Raised the minimum supported Node.js version to 22.19.0. + +### Fixed + +- Fixed OpenAI Codex generated model metadata to use the current upstream model list ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)). +- Fixed GitHub Copilot GPT model thinking metadata to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)). +- Fixed `streamSimple()` defaults for models whose advertised output limit is effectively their full context window to avoid impossible default requests ([#4614](https://github.com/earendil-works/pi/issues/4614)). + +## [0.74.1] - 2026-05-16 + ### Added +- Added image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)). - Added Together AI as a built-in OpenAI-compatible provider with generated model metadata and `TOGETHER_API_KEY` authentication ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)). ### Fixed +- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)). +- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)). +- Fixed Fireworks provider caching compatibility by adding session affinity headers and model metadata compat settings ([#4358](https://github.com/earendil-works/pi-mono/pull/4358) by [@yanirz](https://github.com/yanirz)). +- Fixed OpenAI Codex WebSocket transport to respect proxy environment variables under Bun ([#4354](https://github.com/earendil-works/pi-mono/pull/4354) by [@haoqixu](https://github.com/haoqixu)). +- Fixed OpenRouter cache usage normalization to preserve cached-token semantics without treating cached tokens as cache writes. +- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents. +- Fixed compiled Bun binaries failing to start outside the repo when Bedrock proxy support tried to resolve `proxy-from-env` from external `node_modules` ([#4513](https://github.com/earendil-works/pi/issues/4513)). +- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID. - Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off. +- Fixed Inception Mercury 2 tool calling on OpenRouter by marking `off` as unsupported in `thinkingLevelMap`, so the openai-completions provider omits the reasoning param instead of defaulting to `{reasoning:{effort:"none"}}` (which puts Mercury 2 in instant mode, disabling tool calls). +- Fixed OpenAI Codex SSE retries to honor `retry-after-ms` and `retry-after` headers before falling back to exponential backoff. +- Fixed context overflow detection for LiteLLM-wrapped OpenAI-compatible errors using `exceeds the model's maximum context length of ... tokens` wording ([#4563](https://github.com/earendil-works/pi/issues/4563)). +- Fixed `streamSimple()` defaults to respect model output limits above 32000 tokens instead of clamping provider requests to 32000 ([#4539](https://github.com/earendil-works/pi/issues/4539)). ## [0.74.0] - 2026-05-07 diff --git a/packages/ai/README.md b/packages/ai/README.md index e88de28e..1790a64c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1122,7 +1122,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | -| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` | +| GitHub Copilot | `COPILOT_GITHUB_TOKEN` | When set, the library automatically uses these keys: diff --git a/packages/ai/package.json b/packages/ai/package.json index f8d7b260..a10a23df 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.74.0", + "version": "0.75.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -60,26 +60,22 @@ ], "scripts": { "clean": "shx rm -rf dist", - "generate-models": "npx tsx scripts/generate-models.ts", - "generate-image-models": "npx tsx scripts/generate-image-models.ts", + "generate-models": "node scripts/generate-models.ts", + "generate-image-models": "node scripts/generate-image-models.ts", "build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json", - "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", - "dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "typebox": "^1.1.24", - "chalk": "^5.6.2", + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" + "partial-json": "0.1.7", + "typebox": "1.1.38" }, "keywords": [ "ai", @@ -99,11 +95,11 @@ "directory": "packages/ai" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "^24.3.0", - "canvas": "^3.2.0", - "vitest": "^3.2.4" + "@types/node": "24.12.4", + "canvas": "3.2.3", + "vitest": "3.2.4" } } diff --git a/packages/ai/scripts/generate-image-models.ts b/packages/ai/scripts/generate-image-models.ts index bd0c6727..2744da48 100644 --- a/packages/ai/scripts/generate-image-models.ts +++ b/packages/ai/scripts/generate-image-models.ts @@ -1,9 +1,9 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node import { writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import type { ImagesModel } from "../src/types.js"; +import type { ImagesModel } from "../src/types.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -109,7 +109,7 @@ function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): st return `// This file is auto-generated by scripts/generate-image-models.ts // Do not edit manually - run 'npm run generate-image-models' to update -import type { ImagesApi, ImagesModel } from "./types.js"; +import type { ImagesApi, ImagesModel } from "./types.ts"; export const IMAGE_MODELS = { ${providerEntries} diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e1d5c3ed..0c025c68 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node import { writeFileSync } from "fs"; import { join, dirname } from "path"; @@ -8,14 +8,8 @@ import { CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL, CLOUDFLARE_WORKERS_AI_BASE_URL, -} from "../src/providers/cloudflare.js"; -import { - Api, - type AnthropicMessagesCompat, - KnownProvider, - Model, - type OpenAICompletionsCompat, -} from "../src/types.js"; +} from "../src/providers/cloudflare.ts"; +import type { AnthropicMessagesCompat, Api, KnownProvider, Model, OpenAICompletionsCompat } from "../src/types.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -203,6 +197,9 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: null }); } + if (model.provider === "github-copilot" && model.id.startsWith("gpt-5")) { + mergeThinkingLevelMap(model, { minimal: "low" }); + } if ( model.api === "openai-responses" && model.provider === "openai" && @@ -237,8 +234,12 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { minimal: "low" }); } - if (model.provider === "openai-codex" && model.id === "gpt-5.1-codex-mini") { - mergeThinkingLevelMap(model, { minimal: "medium", low: "medium", medium: "medium", high: "high" }); + if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) { + // Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling. + // Mark "off" unsupported so the openai-completions provider omits the reasoning param + // instead of defaulting to {reasoning:{effort:"none"}} (see openai-completions.ts:575). + // Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary. + mergeThinkingLevelMap(model, { off: null }); } } @@ -764,6 +765,16 @@ async function loadModelsDevData(): Promise[]> { }, contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, + // Fireworks prompt caching uses automatic prefix matching + session affinity. + // x-session-affinity routes requests to the same replica for cache hits. + // cache_control on tools and eager_input_streaming are not supported. + // See: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility + compat: { + sendSessionAffinityHeaders: true, + supportsEagerToolInputStreaming: false, + supportsCacheControlOnTools: false, + supportsLongCacheRetention: false, + }, }); } } @@ -1056,11 +1067,15 @@ async function loadModelsDevData(): Promise[]> { // Built-in `xiaomi` targets the API billing endpoint (single stable URL, // keys from platform.xiaomimimo.com). The three `xiaomi-token-plan-*` // providers cover prepaid Token Plan endpoints in cn / ams / sgp. + const xiaomiCompat: OpenAICompletionsCompat = { + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + }; const xiaomiVariants = [ - { provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/anthropic" }, - { provider: "xiaomi-token-plan-cn", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic" }, - { provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic" }, - { provider: "xiaomi-token-plan-sgp", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic" }, + { provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/v1" }, + { provider: "xiaomi-token-plan-cn", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1" }, + { provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1" }, + { provider: "xiaomi-token-plan-sgp", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1" }, ] as const; if (data.xiaomi?.models) { @@ -1072,9 +1087,10 @@ async function loadModelsDevData(): Promise[]> { models.push({ id: modelId, name: m.name || modelId, - api: "anthropic-messages", + api: "openai-completions", provider, baseUrl, + compat: xiaomiCompat, reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -1480,42 +1496,6 @@ async function generateModels() { const CODEX_CONTEXT = 272000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ - { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1 Codex Mini", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, { id: "gpt-5.2", name: "GPT-5.2", @@ -1528,18 +1508,6 @@ async function generateModels() { contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, - { - id: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", @@ -1552,6 +1520,18 @@ async function generateModels() { contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, + { + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: CODEX_BASE_URL, + reasoning: true, + input: ["text"], + cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, + contextWindow: CODEX_CONTEXT, + maxTokens: CODEX_MAX_TOKENS, + }, { id: "gpt-5.4", name: "GPT-5.4", @@ -1564,6 +1544,18 @@ async function generateModels() { contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: CODEX_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, + contextWindow: CODEX_CONTEXT, + maxTokens: CODEX_MAX_TOKENS, + }, { id: "gpt-5.5", name: "GPT-5.5", @@ -1576,36 +1568,36 @@ async function generateModels() { contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, - { - id: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: CODEX_MAX_TOKENS, - }, ]; allModels.push(...codexModels); // Add missing Grok models - if (!allModels.some(m => m.provider === "xai" && m.id === "grok-code-fast-1")) { - allModels.push({ + const missingGrokModels: Model<"openai-completions">[] = [ + { + id: "grok-3", + name: "Grok 3", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + provider: "xai", + reasoning: false, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.75, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + }, + { + id: "grok-3-fast", + name: "Grok 3 Fast", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + provider: "xai", + reasoning: false, + input: ["text"], + cost: { input: 5, output: 25, cacheRead: 1.25, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + }, + { id: "grok-code-fast-1", name: "Grok Code Fast 1", api: "openai-completions", @@ -1621,7 +1613,12 @@ async function generateModels() { }, contextWindow: 32768, maxTokens: 8192, - }); + }, + ]; + for (const model of missingGrokModels) { + if (!allModels.some(m => m.provider === model.provider && m.id === model.id)) { + allModels.push(model); + } } // Add missing Mistral Medium 3.5 model until models.dev includes it @@ -1860,7 +1857,7 @@ async function generateModels() { let output = `// This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update -import type { Model } from "./types.js"; +import type { Model } from "./types.ts"; export const MODELS = { `; diff --git a/packages/ai/scripts/generate-test-image.ts b/packages/ai/scripts/generate-test-image.ts index 29a473d4..da3df266 100644 --- a/packages/ai/scripts/generate-test-image.ts +++ b/packages/ai/scripts/generate-test-image.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node import { createCanvas } from "canvas"; -import { writeFileSync } from "fs"; +import { mkdirSync, writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -27,7 +27,6 @@ const buffer = canvas.toBuffer("image/png"); const outputPath = join(__dirname, "..", "test", "data", "red-circle.png"); // Ensure the directory exists -import { mkdirSync } from "fs"; mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true }); writeFileSync(outputPath, buffer); diff --git a/packages/ai/src/api-registry.ts b/packages/ai/src/api-registry.ts index ca0e0610..d86dd3e9 100644 --- a/packages/ai/src/api-registry.ts +++ b/packages/ai/src/api-registry.ts @@ -6,7 +6,7 @@ import type { SimpleStreamOptions, StreamFunction, StreamOptions, -} from "./types.js"; +} from "./types.ts"; export type ApiStreamFunction = ( model: Model, diff --git a/packages/ai/src/bedrock-provider.ts b/packages/ai/src/bedrock-provider.ts index 07305418..cf08b33e 100644 --- a/packages/ai/src/bedrock-provider.ts +++ b/packages/ai/src/bedrock-provider.ts @@ -1,4 +1,4 @@ -import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.js"; +import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts"; export const bedrockProviderModule = { streamBedrock, diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 7a4ff1fd..38ee7e34 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -2,8 +2,8 @@ import { createInterface } from "node:readline"; import { existsSync, readFileSync, writeFileSync } from "fs"; -import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.js"; -import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.js"; +import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts"; +import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts"; const AUTH_FILE = "auth.json"; const PROVIDERS = getOAuthProviders(); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 6ed76d41..a00e8470 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -1,4 +1,4 @@ -// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui) +// NEVER convert to top-level imports - breaks browser/Vite builds let _existsSync: typeof import("node:fs").existsSync | null = null; let _homedir: typeof import("node:os").homedir | null = null; let _join: typeof import("node:path").join | null = null; @@ -23,7 +23,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import type { KnownProvider } from "./types.js"; +import type { KnownProvider } from "./types.ts"; let _procEnvCache: Map | null = null; diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index e71c7ba1..83667e50 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -1,7 +1,7 @@ // This file is auto-generated by scripts/generate-image-models.ts // Do not edit manually - run 'npm run generate-image-models' to update -import type { ImagesApi, ImagesModel } from "./types.js"; +import type { ImagesApi, ImagesModel } from "./types.ts"; export const IMAGE_MODELS = { "openrouter": { @@ -230,6 +230,171 @@ export const IMAGE_MODELS = { "cacheWrite": 0 } } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v3": { + id: "recraft/recraft-v3", + name: "Recraft: Recraft V3", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4": { + id: "recraft/recraft-v4", + name: "Recraft: Recraft V4", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4-pro": { + id: "recraft/recraft-v4-pro", + name: "Recraft: Recraft V4 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4-pro-vector": { + id: "recraft/recraft-v4-pro-vector", + name: "Recraft: Recraft V4 Pro Vector", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4-vector": { + id: "recraft/recraft-v4-vector", + name: "Recraft: Recraft V4 Vector", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1": { + id: "recraft/recraft-v4.1", + name: "Recraft: Recraft V4.1", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1-pro": { + id: "recraft/recraft-v4.1-pro", + name: "Recraft: Recraft V4.1 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1-pro-vector": { + id: "recraft/recraft-v4.1-pro-vector", + name: "Recraft: Recraft V4.1 Pro Vector", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1-utility": { + id: "recraft/recraft-v4.1-utility", + name: "Recraft: Recraft V4.1 Utility", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1-utility-pro": { + id: "recraft/recraft-v4.1-utility-pro", + name: "Recraft: Recraft V4.1 Utility Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "recraft/recraft-v4.1-vector": { + id: "recraft/recraft-v4.1-vector", + name: "Recraft: Recraft V4.1 Vector", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "sourceful/riverflow-v2-fast": { id: "sourceful/riverflow-v2-fast", name: "Sourceful: Riverflow V2 Fast", @@ -305,5 +470,20 @@ export const IMAGE_MODELS = { "cacheWrite": 0 } } satisfies ImagesModel<"openrouter-images">, + "x-ai/grok-imagine-image-quality": { + id: "x-ai/grok-imagine-image-quality", + name: "xAI: Grok Imagine Image Quality", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, }, } as const satisfies Record>>; diff --git a/packages/ai/src/image-models.ts b/packages/ai/src/image-models.ts index afa4f415..e8eb59bf 100644 --- a/packages/ai/src/image-models.ts +++ b/packages/ai/src/image-models.ts @@ -1,5 +1,5 @@ -import { IMAGE_MODELS } from "./image-models.generated.js"; -import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.js"; +import { IMAGE_MODELS } from "./image-models.generated.ts"; +import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts"; const imageModelRegistry: Map>> = new Map(); diff --git a/packages/ai/src/images-api-registry.ts b/packages/ai/src/images-api-registry.ts index 2a0c0fd9..4758439b 100644 --- a/packages/ai/src/images-api-registry.ts +++ b/packages/ai/src/images-api-registry.ts @@ -1,4 +1,4 @@ -import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.js"; +import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts"; export type ImagesApiFunction = ( model: ImagesModel, diff --git a/packages/ai/src/images.ts b/packages/ai/src/images.ts index 88eb09f2..47d0eecc 100644 --- a/packages/ai/src/images.ts +++ b/packages/ai/src/images.ts @@ -1,7 +1,7 @@ -import "./providers/images/register-builtins.js"; +import "./providers/images/register-builtins.ts"; -import { getImagesApiProvider } from "./images-api-registry.js"; -import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.js"; +import { getImagesApiProvider } from "./images-api-registry.ts"; +import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts"; function resolveImagesApiProvider(api: ImagesApi) { const provider = getImagesApiProvider(api); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 0a5ce4ed..fd06fe81 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,34 +1,34 @@ export type { Static, TSchema } from "typebox"; export { Type } from "typebox"; -export * from "./api-registry.js"; -export * from "./env-api-keys.js"; -export * from "./image-models.js"; -export * from "./images.js"; -export * from "./images-api-registry.js"; -export * from "./models.js"; -export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js"; -export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js"; -export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js"; -export * from "./providers/faux.js"; -export type { GoogleOptions } from "./providers/google.js"; -export type { GoogleThinkingLevel } from "./providers/google-shared.js"; -export type { GoogleVertexOptions } from "./providers/google-vertex.js"; -export * from "./providers/images/register-builtins.js"; -export type { MistralOptions } from "./providers/mistral.js"; +export * from "./api-registry.ts"; +export * from "./env-api-keys.ts"; +export * from "./image-models.ts"; +export * from "./images.ts"; +export * from "./images-api-registry.ts"; +export * from "./models.ts"; +export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts"; +export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts"; +export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts"; +export * from "./providers/faux.ts"; +export type { GoogleOptions } from "./providers/google.ts"; +export type { GoogleThinkingLevel } from "./providers/google-shared.ts"; +export type { GoogleVertexOptions } from "./providers/google-vertex.ts"; +export * from "./providers/images/register-builtins.ts"; +export type { MistralOptions } from "./providers/mistral.ts"; export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats, -} from "./providers/openai-codex-responses.js"; -export type { OpenAICompletionsOptions } from "./providers/openai-completions.js"; -export type { OpenAIResponsesOptions } from "./providers/openai-responses.js"; -export * from "./providers/register-builtins.js"; -export * from "./session-resources.js"; -export * from "./stream.js"; -export * from "./types.js"; -export * from "./utils/diagnostics.js"; -export * from "./utils/event-stream.js"; -export * from "./utils/json-parse.js"; +} from "./providers/openai-codex-responses.ts"; +export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts"; +export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts"; +export * from "./providers/register-builtins.ts"; +export * from "./session-resources.ts"; +export * from "./stream.ts"; +export * from "./types.ts"; +export * from "./utils/diagnostics.ts"; +export * from "./utils/event-stream.ts"; +export * from "./utils/json-parse.ts"; export type { OAuthAuthInfo, OAuthCredentials, @@ -40,7 +40,7 @@ export type { OAuthProviderInterface, OAuthSelectOption, OAuthSelectPrompt, -} from "./utils/oauth/types.js"; -export * from "./utils/overflow.js"; -export * from "./utils/typebox-helpers.js"; -export * from "./utils/validation.js"; +} from "./utils/oauth/types.ts"; +export * from "./utils/overflow.ts"; +export * from "./utils/typebox-helpers.ts"; +export * from "./utils/validation.ts"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 445a7e38..782c4b97 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1,7 +1,7 @@ // This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update -import type { Model } from "./types.js"; +import type { Model } from "./types.ts"; export const MODELS = { "amazon-bedrock": { @@ -911,8 +911,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 256000, + contextWindow: 262143, + maxTokens: 16000, } satisfies Model<"bedrock-converse-stream">, "moonshotai.kimi-k2.5": { id: "moonshotai.kimi-k2.5", @@ -928,8 +928,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 256000, + contextWindow: 262143, + maxTokens: 16000, } satisfies Model<"bedrock-converse-stream">, "nvidia.nemotron-nano-12b-v2": { id: "nvidia.nemotron-nano-12b-v2", @@ -1014,7 +1014,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-20b-1:0": { id: "openai.gpt-oss-20b-1:0", @@ -1031,7 +1031,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-safeguard-120b": { id: "openai.gpt-oss-safeguard-120b", @@ -1048,7 +1048,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-safeguard-20b": { id: "openai.gpt-oss-safeguard-20b", @@ -1065,7 +1065,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, "qwen.qwen3-235b-a22b-2507-v1:0": { id: "qwen.qwen3-235b-a22b-2507-v1:0", @@ -3278,6 +3278,42 @@ export const MODELS = { contextWindow: 256000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/ibm-granite/granite-4.0-h-micro": { + id: "@cf/ibm-granite/granite-4.0-h-micro", + name: "Granite 4.0 H Micro", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.017, + output: 0.112, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 131000, + } satisfies Model<"openai-completions">, + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": { + id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + name: "Llama 3.3 70B Instruct fp8 Fast", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.293, + output: 2.253, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 24000, + maxTokens: 24000, + } satisfies Model<"openai-completions">, "@cf/meta/llama-4-scout-17b-16e-instruct": { id: "@cf/meta/llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout 17B 16E Instruct", @@ -3293,9 +3329,27 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 131000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/mistralai/mistral-small-3.1-24b-instruct": { + id: "@cf/mistralai/mistral-small-3.1-24b-instruct", + name: "Mistral Small 3.1 24B Instruct", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.351, + output: 0.555, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "@cf/moonshotai/kimi-k2.5": { id: "@cf/moonshotai/kimi-k2.5", name: "Kimi K2.5", @@ -3329,7 +3383,7 @@ export const MODELS = { cacheRead: 0.16, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 262144, maxTokens: 256000, } satisfies Model<"openai-completions">, "@cf/nvidia/nemotron-3-120b-a12b": { @@ -3386,6 +3440,24 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/qwen/qwen3-30b-a3b-fp8": { + id: "@cf/qwen/qwen3-30b-a3b-fp8", + name: "Qwen3 30B A3b fp8", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.0509, + output: 0.335, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "@cf/zai-org/glm-4.7-flash": { id: "@cf/zai-org/glm-4.7-flash", name: "GLM-4.7-Flash", @@ -3396,7 +3468,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06, + input: 0.0605, output: 0.4, cacheRead: 0, cacheWrite: 0, @@ -3452,6 +3524,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3469,6 +3542,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3480,12 +3554,31 @@ export const MODELS = { contextWindow: 160000, maxTokens: 160000, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/deepseek-v4-flash": { + id: "accounts/fireworks/models/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/deepseek-v4-pro": { id: "accounts/fireworks/models/deepseek-v4-pro", name: "DeepSeek V4 Pro", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3503,6 +3596,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3520,6 +3614,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3537,6 +3632,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3554,6 +3650,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3571,6 +3668,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3588,6 +3686,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3605,6 +3704,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3622,6 +3722,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: false, input: ["text"], cost: { @@ -3639,6 +3740,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3656,6 +3758,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -3673,6 +3776,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -3690,6 +3794,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3707,6 +3812,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3724,6 +3830,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -3741,6 +3848,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -3758,6 +3866,7 @@ export const MODELS = { api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -3986,7 +4095,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null}, + thinkingLevelMap: {"off":null,"minimal":"low"}, input: ["text", "image"], cost: { input: 0, @@ -4005,7 +4114,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4024,7 +4133,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4043,7 +4152,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4062,7 +4171,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4081,7 +4190,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4100,7 +4209,7 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 0, @@ -4132,57 +4241,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "google": { - "gemini-1.5-flash": { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-flash-8b": { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.0375, - output: 0.15, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, "gemini-2.0-flash": { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", @@ -4202,7 +4260,7 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, "gemini-2.0-flash-lite": { id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", + name: "Gemini 2.0 Flash-Lite", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4236,7 +4294,7 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, "gemini-2.5-flash-lite": { id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite", + name: "Gemini 2.5 Flash-Lite", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4245,92 +4303,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-06-17": { - id: "gemini-2.5-flash-lite-preview-06-17", - name: "Gemini 2.5 Flash Lite Preview 06-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-09-2025": { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-04-17": { - id: "gemini-2.5-flash-preview-04-17", - name: "Gemini 2.5 Flash Preview 04-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-05-20": { - id: "gemini-2.5-flash-preview-05-20", - name: "Gemini 2.5 Flash Preview 05-20", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-09-2025": { - id: "gemini-2.5-flash-preview-09-2025", - name: "Gemini 2.5 Flash Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 1048576, @@ -4353,40 +4326,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-05-06": { - id: "gemini-2.5-pro-preview-05-06", - name: "Gemini 2.5 Pro Preview 05-06", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-06-05": { - id: "gemini-2.5-pro-preview-06-05", - name: "Gemini 2.5 Pro Preview 06-05", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, "gemini-3-flash-preview": { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview", @@ -4420,8 +4359,8 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 64000, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"google-generative-ai">, "gemini-3.1-flash-lite": { id: "gemini-3.1-flash-lite", @@ -4436,7 +4375,7 @@ export const MODELS = { input: 0.25, output: 1.5, cacheRead: 0.025, - cacheWrite: 1, + cacheWrite: 0, }, contextWindow: 1048576, maxTokens: 65536, @@ -4454,7 +4393,7 @@ export const MODELS = { input: 0.25, output: 1.5, cacheRead: 0.025, - cacheWrite: 1, + cacheWrite: 0, }, contextWindow: 1048576, maxTokens: 65536, @@ -4495,6 +4434,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, "gemini-flash-latest": { id: "gemini-flash-latest", name: "Gemini Flash Latest", @@ -4529,60 +4486,9 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash": { - id: "gemini-live-2.5-flash", - name: "Gemini Live 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8000, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash-preview-native-audio": { - id: "gemini-live-2.5-flash-preview-native-audio", - name: "Gemini Live 2.5 Flash Preview Native Audio", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemma-3-27b-it": { - id: "gemma-3-27b-it", - name: "Gemma 3 27B", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, "gemma-4-26b-a4b-it": { id: "gemma-4-26b-a4b-it", - name: "Gemma 4 26B", + name: "Gemma 4 26B A4B IT", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4595,12 +4501,12 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"google-generative-ai">, "gemma-4-31b-it": { id: "gemma-4-31b-it", - name: "Gemma 4 31B", + name: "Gemma 4 31B IT", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4613,8 +4519,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"google-generative-ai">, }, "google-vertex": { @@ -7136,58 +7042,6 @@ export const MODELS = { } satisfies Model<"openai-responses">, }, "openai-codex": { - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, - "gpt-5.1-codex-mini": { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1 Codex Mini", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"minimal":"medium","low":"medium","medium":"medium","high":"high"}, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, "gpt-5.2": { id: "gpt-5.2", name: "GPT-5.2", @@ -7206,24 +7060,6 @@ export const MODELS = { contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, - "gpt-5.2-codex": { - id: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, "gpt-5.3-codex": { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", @@ -7252,12 +7088,12 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.75, + output: 14, + cacheRead: 0.175, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.4": { @@ -7280,7 +7116,7 @@ export const MODELS = { } satisfies Model<"openai-codex-responses">, "gpt-5.4-mini": { id: "gpt-5.4-mini", - name: "GPT-5.4 Mini", + name: "GPT-5.4 mini", api: "openai-codex-responses", provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", @@ -7487,8 +7323,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 384000, + contextWindow: 200000, + maxTokens: 128000, } satisfies Model<"openai-completions">, "gemini-3-flash": { id: "gemini-3-flash", @@ -7526,6 +7362,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, "glm-5": { id: "glm-5", name: "GLM-5", @@ -7848,6 +7702,23 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, "kimi-k2.5": { id: "kimi-k2.5", name: "Kimi K2.5", @@ -7899,23 +7770,6 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "minimax-m2.5-free": { - id: "minimax-m2.5-free", - name: "MiniMax M2.5 Free", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, "minimax-m2.7": { id: "minimax-m2.7", name: "MiniMax M2.7", @@ -7984,23 +7838,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"anthropic-messages">, - "ring-2.6-1t-free": { - id: "ring-2.6-1t-free", - name: "Ring 2.6 1T Free", - api: "openai-completions", + "qwen3.6-plus-free": { + id: "qwen3.6-plus-free", + name: "Qwen3.6 Plus Free", + api: "anthropic-messages", provider: "opencode", - baseUrl: "https://opencode.ai/zen/v1", + baseUrl: "https://opencode.ai/zen", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262000, - maxTokens: 66000, - } satisfies Model<"openai-completions">, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, }, "opencode-go": { "deepseek-v4-flash": { @@ -8146,9 +8000,9 @@ export const MODELS = { "minimax-m2.5": { id: "minimax-m2.5", name: "MiniMax M2.5", - api: "openai-completions", + api: "anthropic-messages", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go/v1", + baseUrl: "https://opencode.ai/zen/go", reasoning: true, input: ["text"], cost: { @@ -8159,7 +8013,7 @@ export const MODELS = { }, contextWindow: 204800, maxTokens: 65536, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "minimax-m2.7": { id: "minimax-m2.7", name: "MiniMax M2.7", @@ -8490,6 +8344,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.7-fast": { + id: "anthropic/claude-opus-4.7-fast", + name: "Anthropic: Claude Opus 4.7 (Fast)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 150, + cacheRead: 3, + cacheWrite: 37.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Anthropic: Claude Sonnet 4", @@ -8541,23 +8413,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "arcee-ai/trinity-large-preview": { - id: "arcee-ai/trinity-large-preview", - name: "Arcee AI: Trinity Large Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.44999999999999996, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "arcee-ai/trinity-large-thinking": { id: "arcee-ai/trinity-large-thinking", name: "Arcee AI: Trinity Large Thinking", @@ -8674,7 +8529,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 120000, + contextWindow: 131072, maxTokens: 8000, } satisfies Model<"openai-completions">, "baidu/ernie-4.5-vl-28b-a3b": { @@ -8691,7 +8546,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 30000, + contextWindow: 131072, maxTokens: 8000, } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6": { @@ -8861,7 +8716,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 64000, + contextWindow: 163840, maxTokens: 16000, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-0528": { @@ -8943,9 +8798,28 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, + input: 0.112, + output: 0.224, + cacheRead: 0.022, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-flash:free": { + id: "deepseek/deepseek-v4-flash:free", + name: "DeepSeek: DeepSeek V4 Flash (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, @@ -9001,7 +8875,7 @@ export const MODELS = { cacheRead: 0.024999999999999998, cacheWrite: 0.08333333333333334, }, - contextWindow: 1048576, + contextWindow: 1000000, maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { @@ -9205,6 +9079,23 @@ export const MODELS = { cacheRead: 0.19999999999999998, cacheWrite: 0.375, }, + contextWindow: 1048756, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.5-flash": { + id: "google/gemini-3.5-flash", + name: "Google: Gemini 3.5 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0.08333333333333334, + }, contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"openai-completions">, @@ -9334,6 +9225,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text"], cost: { input: 0.25, @@ -9353,9 +9245,9 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.06, + input: 0.075, + output: 0.625, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 262144, @@ -9370,26 +9262,26 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.08, - output: 0.24, - cacheRead: 0.016, + input: 0.01, + output: 0.03, + cacheRead: 0.002, cacheWrite: 0, }, contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, - "inclusionai/ring-2.6-1t:free": { - id: "inclusionai/ring-2.6-1t:free", - name: "inclusionAI: Ring-2.6-1T (free)", + "inclusionai/ring-2.6-1t": { + id: "inclusionai/ring-2.6-1t", + name: "inclusionAI: Ring-2.6-1T", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.075, + output: 0.625, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 262144, @@ -9443,7 +9335,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 16384, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct": { @@ -9477,7 +9369,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 65536, + contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-4-scout": { @@ -9494,7 +9386,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 327680, + contextWindow: 10000000, maxTokens: 16384, } satisfies Model<"openai-completions">, "minimax/minimax-m1": { @@ -9528,7 +9420,7 @@ export const MODELS = { cacheRead: 0.03, cacheWrite: 0, }, - contextWindow: 196608, + contextWindow: 204800, maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m2.1": { @@ -9545,7 +9437,7 @@ export const MODELS = { cacheRead: 0.03, cacheWrite: 0, }, - contextWindow: 196608, + contextWindow: 204800, maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m2.5": { @@ -9562,7 +9454,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 196608, + contextWindow: 204800, maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m2.5:free": { @@ -9579,7 +9471,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 196608, + contextWindow: 204800, maxTokens: 8192, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { @@ -9591,12 +9483,12 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.29900000000000004, + input: 0.27899999999999997, output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 196608, + contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { @@ -9982,8 +9874,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 2, + input: 0.6, + output: 2.5, cacheRead: 0, cacheWrite: 0, }, @@ -10001,7 +9893,7 @@ export const MODELS = { cost: { input: 0.6, output: 2.5, - cacheRead: 0.15, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, @@ -10033,13 +9925,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.74, - output: 3.5, + input: 0.73, + output: 3.49, cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 32768, - maxTokens: 32768, + contextWindow: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "nex-agi/deepseek-v3.1-nex-n1": { id: "nex-agi/deepseek-v3.1-nex-n1", @@ -10140,7 +10032,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-super-120b-a12b:free": { @@ -10157,7 +10049,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 262144, } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-12b-v2-vl:free": { @@ -11308,7 +11200,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32768, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen-2.5-7b-instruct": { @@ -11325,26 +11217,9 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32768, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen-max": { - id: "qwen/qwen-max", - name: "Qwen: Qwen-Max ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.04, - output: 4.16, - cacheRead: 0.20800000000000002, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "qwen/qwen-plus": { id: "qwen/qwen-plus", name: "Qwen: Qwen-Plus", @@ -11396,40 +11271,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen-turbo": { - id: "qwen/qwen-turbo", - name: "Qwen: Qwen-Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.0325, - output: 0.13, - cacheRead: 0.006500000000000001, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-vl-max": { - id: "qwen/qwen-vl-max", - name: "Qwen: Qwen VL Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.52, - output: 2.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "qwen/qwen3-14b": { id: "qwen/qwen3-14b", name: "Qwen: Qwen3 14B", @@ -11439,12 +11280,12 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06, + input: 0.09999999999999999, output: 0.24, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 131702, maxTokens: 40960, } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b": { @@ -11495,7 +11336,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { @@ -11512,7 +11353,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 131072, maxTokens: 20000, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { @@ -11563,7 +11404,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-8b": { @@ -11580,7 +11421,7 @@ export const MODELS = { cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 131072, maxTokens: 8192, } satisfies Model<"openai-completions">, "qwen/qwen3-coder": { @@ -11597,7 +11438,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3-coder-30b-a3b-instruct": { @@ -11682,7 +11523,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262000, + contextWindow: 1048576, maxTokens: 262000, } satisfies Model<"openai-completions">, "qwen/qwen3-max": { @@ -11767,7 +11608,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-instruct": { @@ -11818,7 +11659,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-30b-a3b-thinking": { @@ -11852,7 +11693,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-instruct": { @@ -11869,7 +11710,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 256000, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-thinking": { @@ -11886,7 +11727,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 256000, maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3.5-122b-a10b": { @@ -11932,13 +11773,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.14, + input: 0.13899999999999998, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -11951,7 +11792,7 @@ export const MODELS = { cost: { input: 0.39, output: 2.34, - cacheRead: 0.195, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, @@ -12017,8 +11858,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 2.4, + input: 0.3, + output: 1.7999999999999998, cacheRead: 0, cacheWrite: 0, }, @@ -12034,13 +11875,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.32, + input: 0.317, output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -12068,10 +11909,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.25, - output: 1.5, + input: 0.1875, + output: 1.125, cacheRead: 0, - cacheWrite: 0.3125, + cacheWrite: 0.234375, }, contextWindow: 1000000, maxTokens: 65536, @@ -12110,6 +11951,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, + "qwen/qwen3.7-max": { + id: "qwen/qwen3.7-max", + name: "Qwen: Qwen3.7 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 3.125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "rekaai/reka-edge": { id: "rekaai/reka-edge", name: "Reka Edge", @@ -12187,13 +12045,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.09, output: 0.3, - cacheRead: 0, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 16384, } satisfies Model<"openai-completions">, "tencent/hy3-preview": { id: "tencent/hy3-preview", @@ -12263,125 +12121,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "x-ai/grok-3": { - id: "x-ai/grok-3", - name: "xAI: Grok 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-beta": { - id: "x-ai/grok-3-beta", - name: "xAI: Grok 3 Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini": { - id: "x-ai/grok-3-mini", - name: "xAI: Grok 3 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini-beta": { - id: "x-ai/grok-3-mini-beta", - name: "xAI: Grok 3 Mini Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-4": { - id: "x-ai/grok-4", - name: "xAI: Grok 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-4-fast": { - id: "x-ai/grok-4-fast", - name: "xAI: Grok 4 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "x-ai/grok-4.1-fast": { - id: "x-ai/grok-4.1-fast", - name: "xAI: Grok 4.1 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, "x-ai/grok-4.20": { id: "x-ai/grok-4.20", name: "xAI: Grok 4.20", @@ -12416,22 +12155,22 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "x-ai/grok-code-fast-1": { - id: "x-ai/grok-code-fast-1", - name: "xAI: Grok Code Fast 1", + "x-ai/grok-build-0.1": { + id: "x-ai/grok-build-0.1", + name: "xAI: Grok Build 0.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.19999999999999998, - output: 1.5, - cacheRead: 0.02, + input: 1, + output: 2, + cacheRead: 0.19999999999999998, cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 10000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "xiaomi/mimo-v2-flash": { id: "xiaomi/mimo-v2-flash", @@ -12612,13 +12351,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39, - output: 1.9, - cacheRead: 0, + input: 0.43, + output: 1.74, + cacheRead: 0.08, cacheWrite: 0, }, - contextWindow: 204800, - maxTokens: 204800, + contextWindow: 202752, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-4.6v": { id: "z-ai/glm-4.6v", @@ -12799,9 +12538,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.5, - output: 3, - cacheRead: 0.049999999999999996, + input: 1.5, + output: 9, + cacheRead: 0.15, cacheWrite: 0.08333333333333334, }, contextWindow: 1048576, @@ -12833,13 +12572,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.74, - output: 3.5, + input: 0.73, + output: 3.49, cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 32768, - maxTokens: 32768, + contextWindow: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -13505,6 +13244,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.7-max": { + id: "alibaba/qwen3.7-max", + name: "Qwen 3.7 Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0.5, + cacheWrite: 3.125, + }, + contextWindow: 991000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-3-haiku": { id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku", @@ -14068,6 +13824,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "google/gemini-3.5-flash": { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "google/gemma-4-26b-a4b-it": { id: "google/gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B IT", @@ -14527,6 +14300,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "mistral/mistral-medium-3.5": { + id: "mistral/mistral-medium-3.5", + name: "Mistral Medium Latest", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "mistral/mistral-small": { id: "mistral/mistral-small", name: "Mistral Small", @@ -15221,7 +15011,7 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", - name: "GPT OSS 120B", + name: "GPT OSS 20B", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", @@ -15389,125 +15179,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8000, } satisfies Model<"anthropic-messages">, - "xai/grok-3": { - id: "xai/grok-3", - name: "Grok 3 Beta", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "xai/grok-3-fast": { - id: "xai/grok-3-fast", - name: "Grok 3 Fast Beta", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "xai/grok-3-mini": { - id: "xai/grok-3-mini", - name: "Grok 3 Mini Beta", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "xai/grok-3-mini-fast": { - id: "xai/grok-3-mini-fast", - name: "Grok 3 Mini Fast Beta", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "xai/grok-4": { - id: "xai/grok-4", - name: "Grok 4", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, - "xai/grok-4-fast-non-reasoning": { - id: "xai/grok-4-fast-non-reasoning", - name: "Grok 4 Fast Non-Reasoning", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, - "xai/grok-4-fast-reasoning": { - id: "xai/grok-4-fast-reasoning", - name: "Grok 4 Fast Reasoning", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, "xai/grok-4.1-fast-non-reasoning": { id: "xai/grok-4.1-fast-non-reasoning", name: "Grok 4.1 Fast Non-Reasoning", @@ -15522,8 +15193,8 @@ export const MODELS = { cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 2000000, - maxTokens: 30000, + contextWindow: 1000000, + maxTokens: 1000000, } satisfies Model<"anthropic-messages">, "xai/grok-4.1-fast-reasoning": { id: "xai/grok-4.1-fast-reasoning", @@ -15539,8 +15210,8 @@ export const MODELS = { cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 2000000, - maxTokens: 30000, + contextWindow: 1000000, + maxTokens: 1000000, } satisfies Model<"anthropic-messages">, "xai/grok-4.20-multi-agent": { id: "xai/grok-4.20-multi-agent", @@ -15661,18 +15332,18 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, - "xai/grok-code-fast-1": { - id: "xai/grok-code-fast-1", - name: "Grok Code Fast 1", + "xai/grok-build-0.1": { + id: "xai/grok-build-0.1", + name: "Grok Build 0.1", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.19999999999999998, - output: 1.5, - cacheRead: 0.02, + input: 1, + output: 2, + cacheRead: 0.19999999999999998, cacheWrite: 0, }, contextWindow: 256000, @@ -15969,108 +15640,6 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, "xai": { - "grok-2": { - id: "grok-2", - name: "Grok 2", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-1212": { - id: "grok-2-1212", - name: "Grok 2 (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-latest": { - id: "grok-2-latest", - name: "Grok 2 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-vision": { - id: "grok-2-vision", - name: "Grok 2 Vision", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-1212": { - id: "grok-2-vision-1212", - name: "Grok 2 Vision (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-latest": { - id: "grok-2-vision-latest", - name: "Grok 2 Vision Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "grok-3": { id: "grok-3", name: "Grok 3", @@ -16105,193 +15674,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 8192, } satisfies Model<"openai-completions">, - "grok-3-fast-latest": { - id: "grok-3-fast-latest", - name: "Grok 3 Fast Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-latest": { - id: "grok-3-latest", - name: "Grok 3 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini": { - id: "grok-3-mini", - name: "Grok 3 Mini", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini-fast": { - id: "grok-3-mini-fast", - name: "Grok 3 Mini Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini-fast-latest": { - id: "grok-3-mini-fast-latest", - name: "Grok 3 Mini Fast Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini-latest": { - id: "grok-3-mini-latest", - name: "Grok 3 Mini Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-4": { - id: "grok-4", - name: "Grok 4", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "grok-4-1-fast": { - id: "grok-4-1-fast", - name: "Grok 4.1 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-1-fast-non-reasoning": { - id: "grok-4-1-fast-non-reasoning", - name: "Grok 4.1 Fast (Non-Reasoning)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-fast": { - id: "grok-4-fast", - name: "Grok 4 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-fast-non-reasoning": { - id: "grok-4-fast-non-reasoning", - name: "Grok 4 Fast (Non-Reasoning)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, "grok-4.20-0309-non-reasoning": { id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20 (Non-Reasoning)", @@ -16301,8 +15683,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 2, - output: 6, + input: 1.25, + output: 2.5, cacheRead: 0.2, cacheWrite: 0, }, @@ -16318,8 +15700,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 2, - output: 6, + input: 1.25, + output: 2.5, cacheRead: 0.2, cacheWrite: 0, }, @@ -16343,22 +15725,22 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "grok-beta": { - id: "grok-beta", - name: "Grok Beta", + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", api: "openai-completions", provider: "xai", baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 5, - output: 15, - cacheRead: 5, + input: 1, + output: 2, + cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"openai-completions">, "grok-code-fast-1": { id: "grok-code-fast-1", @@ -16366,7 +15748,7 @@ export const MODELS = { api: "openai-completions", provider: "xai", baseUrl: "https://api.x.ai/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { input: 0.2, @@ -16374,34 +15756,18 @@ export const MODELS = { cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 10000, - } satisfies Model<"openai-completions">, - "grok-vision-beta": { - id: "grok-vision-beta", - name: "Grok Vision Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 5, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, + contextWindow: 32768, + maxTokens: 8192, } satisfies Model<"openai-completions">, }, "xiaomi": { "mimo-v2-flash": { id: "mimo-v2-flash", name: "MiMo-V2-Flash", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi", - baseUrl: "https://api.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16412,13 +15778,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi", - baseUrl: "https://api.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16429,13 +15796,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-pro": { id: "mimo-v2-pro", name: "MiMo-V2-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi", - baseUrl: "https://api.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16446,13 +15814,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo-V2.5", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi", - baseUrl: "https://api.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16463,13 +15832,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5-pro": { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi", - baseUrl: "https://api.xiaomimimo.com/anthropic", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16480,15 +15850,16 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-ams": { "mimo-v2-flash": { id: "mimo-v2-flash", name: "MiMo-V2-Flash", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16499,13 +15870,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16516,13 +15888,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-pro": { id: "mimo-v2-pro", name: "MiMo-V2-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16533,13 +15906,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo-V2.5", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16550,13 +15924,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5-pro": { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16567,15 +15942,16 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-cn": { "mimo-v2-flash": { id: "mimo-v2-flash", name: "MiMo-V2-Flash", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16586,13 +15962,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16603,13 +15980,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-pro": { id: "mimo-v2-pro", name: "MiMo-V2-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16620,13 +15998,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo-V2.5", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16637,13 +16016,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5-pro": { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16654,15 +16034,16 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-sgp": { "mimo-v2-flash": { id: "mimo-v2-flash", name: "MiMo-V2-Flash", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16673,13 +16054,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16690,13 +16072,14 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2-pro": { id: "mimo-v2-pro", name: "MiMo-V2-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16707,13 +16090,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo-V2.5", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -16724,13 +16108,14 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "mimo-v2.5-pro": { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", - api: "anthropic-messages", + api: "openai-completions", provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -16741,7 +16126,7 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, "zai": { "glm-4.5-air": { diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 2125e685..e14a6c2f 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1,5 +1,5 @@ -import { MODELS } from "./models.generated.js"; -import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.js"; +import { MODELS } from "./models.generated.ts"; +import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts"; const modelRegistry: Map>> = new Map(); diff --git a/packages/ai/src/oauth.ts b/packages/ai/src/oauth.ts index d768a0fe..487816d8 100644 --- a/packages/ai/src/oauth.ts +++ b/packages/ai/src/oauth.ts @@ -1 +1 @@ -export * from "./utils/oauth/index.js"; +export * from "./utils/oauth/index.ts"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 1812d3c8..429098a6 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -20,8 +20,9 @@ import { type ToolConfiguration, ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; +import { NodeHttpHandler } from "@smithy/node-http-handler"; import type { DocumentType } from "@smithy/types"; -import { calculateCost } from "../models.js"; +import { calculateCost } from "../models.ts"; import type { Api, AssistantMessage, @@ -39,12 +40,13 @@ import type { Tool, ToolCall, ToolResultMessage, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { parseStreamingJson } from "../utils/json-parse.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js"; -import { transformMessages } from "./transform-messages.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts"; +import { transformMessages } from "./transform-messages.ts"; export type BedrockThinkingDisplay = "summarized" | "omitted"; @@ -156,30 +158,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt }; } - if ( - process.env.HTTP_PROXY || - process.env.HTTPS_PROXY || - process.env.NO_PROXY || - process.env.http_proxy || - process.env.https_proxy || - process.env.no_proxy - ) { - const nodeHttpHandler = await import("@smithy/node-http-handler"); - const proxyAgent = await import("proxy-agent"); - - const agent = new proxyAgent.ProxyAgent(); - + const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl); + if (proxyAgents) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. - // Use NodeHttpHandler to support http agent. - config.requestHandler = new nodeHttpHandler.NodeHttpHandler({ - httpAgent: agent, - httpsAgent: agent, - }); + // Use NodeHttpHandler to support HTTP(S) proxy agents. + config.requestHandler = new NodeHttpHandler(proxyAgents); } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { // Some custom endpoints require HTTP/1.1 instead of HTTP/2 - const nodeHttpHandler = await import("@smithy/node-http-handler"); - config.requestHandler = new nodeHttpHandler.NodeHttpHandler(); + config.requestHandler = new NodeHttpHandler(); } } else { // Non-Node environment (browser): fall back to us-east-1 since @@ -196,12 +183,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt try { const client = new BedrockRuntimeClient(config); const cacheRetention = resolveCacheRetention(options.cacheRetention); + const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined); let commandInput = { modelId: model.id, messages: convertMessages(context, model, cacheRetention), system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), inferenceConfig: { - ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }), + ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), }, toolConfig: convertToolConfig(context.tools, options.toolChoice), @@ -327,8 +315,10 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp } satisfies BedrockOptions); } + // Undefined means the caller did not request an output cap; let the helper use the model cap. + // Do not coerce to 0 here, or the thinking budget would become the entire maxTokens value. const adjusted = adjustMaxTokensForThinking( - base.maxTokens || 0, + base.maxTokens, model.maxTokens, options.reasoning, options.thinkingBudgets, @@ -631,24 +621,31 @@ function convertMessages( const m = transformedMessages[i]; switch (m.role) { - case "user": + case "user": { + const content: ContentBlock[] = []; + if (typeof m.content === "string") { + content.push({ text: sanitizeSurrogates(m.content) }); + } else { + for (const c of m.content) { + switch (c.type) { + case "text": + content.push({ text: sanitizeSurrogates(c.text) }); + break; + case "image": + content.push({ image: createImageBlock(c.mimeType, c.data) }); + break; + default: + continue; + } + } + } + if (content.length === 0) continue; result.push({ role: ConversationRole.USER, - content: - typeof m.content === "string" - ? [{ text: sanitizeSurrogates(m.content) }] - : m.content.map((c) => { - switch (c.type) { - case "text": - return { text: sanitizeSurrogates(c.text) }; - case "image": - return { image: createImageBlock(c.mimeType, c.data) }; - default: - throw new Error("Unknown user content type"); - } - }), + content, }); break; + } case "assistant": { // Skip assistant messages with empty content (e.g., from aborted requests) // Bedrock rejects messages with empty content arrays @@ -699,7 +696,7 @@ function convertMessages( } break; default: - throw new Error("Unknown assistant content type"); + continue; } } // Skip if all content blocks were filtered out @@ -758,7 +755,7 @@ function convertMessages( break; } default: - throw new Error("Unknown message role"); + continue; } } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 62febfc3..a594a479 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -6,8 +6,8 @@ import type { MessageParam, RawMessageStreamEvent, } from "@anthropic-ai/sdk/resources/messages.js"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { calculateCost } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, Api, @@ -26,16 +26,16 @@ import type { Tool, ToolCall, ToolResultMessage, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { headersToRecord } from "../utils/headers.js"; -import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveCloudflareBaseUrl } from "./cloudflare.js"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; -import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; -import { transformMessages } from "./transform-messages.js"; +import { resolveCloudflareBaseUrl } from "./cloudflare.ts"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; +import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.ts"; +import { transformMessages } from "./transform-messages.ts"; /** * Resolve cache retention preference. @@ -165,9 +165,16 @@ const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14 const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; function getAnthropicCompat(model: Model<"anthropic-messages">): Required { + // Auto-detect session affinity and cache control support from provider + const isFireworks = model.provider === "fireworks"; + const isCloudflareAiGatewayAnthropic = + model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic"); return { - supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true, - supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, + supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks, + supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks, + sendSessionAffinityHeaders: + model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic), + supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks, }; } @@ -463,6 +470,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }); } + const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const created = createClient( model, apiKey, @@ -470,6 +480,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti shouldUseFineGrainedToolStreamingBeta(model, context), options?.headers, copilotDynamicHeaders, + cacheSessionId, ); client = created.client; isOAuth = created.isOAuthToken; @@ -740,8 +751,10 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS } satisfies AnthropicOptions); } + // Undefined means the caller did not request an output cap; let the helper use the model cap. + // Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value. const adjusted = adjustMaxTokensForThinking( - base.maxTokens || 0, + base.maxTokens, model.maxTokens, options.reasoning, options.thinkingBudgets, @@ -766,6 +779,7 @@ function createClient( useFineGrainedToolStreamingBeta: boolean, optionsHeaders?: Record, dynamicHeaders?: Record, + sessionId?: string, ): { client: Anthropic; isOAuthToken: boolean } { // Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in. // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. @@ -847,8 +861,11 @@ function createClient( } // API key auth + const sessionAffinityHeaders: Record = + sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; const client = new Anthropic({ apiKey, + authToken: null, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( @@ -857,6 +874,7 @@ function createClient( "anthropic-dangerous-direct-browser-access": "true", ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), }, + sessionAffinityHeaders, model.headers, optionsHeaders, ), @@ -875,7 +893,7 @@ function buildParams( const params: MessageCreateParamsStreaming = { model: model.id, messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), - max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0, + max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -912,11 +930,12 @@ function buildParams( } if (context.tools && context.tools.length > 0) { + const compat = getAnthropicCompat(model); params.tools = convertTools( context.tools, isOAuthToken, - getAnthropicCompat(model).supportsEagerToolInputStreaming, - cacheControl, + compat.supportsEagerToolInputStreaming, + compat.supportsCacheControlOnTools ? cacheControl : undefined, ); } diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 5d4bc9c6..60601a38 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -1,7 +1,7 @@ import { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { clampThinkingLevel } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, @@ -10,11 +10,12 @@ import type { SimpleStreamOptions, StreamFunction, StreamOptions, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { headersToRecord } from "../utils/headers.js"; -import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; -import { buildBaseOptions } from "./simple-options.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; +import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; const DEFAULT_AZURE_API_VERSION = "v1"; const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]); @@ -40,6 +41,22 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: return mappedDeployment || model.id; } +function formatAzureOpenAIError(error: unknown): string { + if (error instanceof Error) { + const status = (error as Error & { status?: unknown }).status; + const statusCode = typeof status === "number" ? status : undefined; + if (statusCode !== undefined) { + return `Azure OpenAI API error (${statusCode}): ${error.message}`; + } + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + // Azure OpenAI Responses-specific options export interface AzureOpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -119,7 +136,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" delete (block as { partialJson?: string }).partialJson; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatAzureOpenAIError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } @@ -245,7 +262,7 @@ function buildParams( model: deploymentName, input: messages, stream: true, - prompt_cache_key: options?.sessionId, + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), }; if (options?.maxTokens) { diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts index 8e9d44cb..cd0a8159 100644 --- a/packages/ai/src/providers/cloudflare.ts +++ b/packages/ai/src/providers/cloudflare.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "../types.js"; +import type { Api, Model } from "../types.ts"; /** Workers AI direct endpoint. */ export const CLOUDFLARE_WORKERS_AI_BASE_URL = diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index 7bba1b72..7e629847 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -1,4 +1,4 @@ -import { registerApiProvider, unregisterApiProviders } from "../api-registry.js"; +import { registerApiProvider, unregisterApiProviders } from "../api-registry.ts"; import type { AssistantMessage, AssistantMessageEventStream, @@ -14,8 +14,8 @@ import type { ToolCall, ToolResultMessage, Usage, -} from "../types.js"; -import { createAssistantMessageEventStream } from "../utils/event-stream.js"; +} from "../types.ts"; +import { createAssistantMessageEventStream } from "../utils/event-stream.ts"; const DEFAULT_API = "faux"; const DEFAULT_PROVIDER = "faux"; diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 4f01a9d2..602750c4 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,4 +1,4 @@ -import type { Message } from "../types.js"; +import type { Message } from "../types.ts"; // Copilot expects X-Initiator to indicate whether the request is user-initiated // or agent-initiated (e.g. follow-up after assistant/tool messages). diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 9f0ab180..1559b20b 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -3,9 +3,9 @@ */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { transformMessages } from "./transform-messages.js"; +import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { transformMessages } from "./transform-messages.ts"; type GoogleApiType = "google-generative-ai" | "google-vertex"; diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index e29adcb1..e5f18dab 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -7,7 +7,7 @@ import { type ThinkingConfig, ThinkingLevel, } from "@google/genai"; -import { calculateCost, clampThinkingLevel } from "../models.js"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, @@ -21,10 +21,10 @@ import type { ThinkingBudgets, ThinkingContent, ToolCall, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import type { GoogleThinkingLevel } from "./google-shared.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import type { GoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, @@ -32,8 +32,8 @@ import { mapStopReason, mapToolChoice, retainThoughtSignature, -} from "./google-shared.js"; -import { buildBaseOptions } from "./simple-options.js"; +} from "./google-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; export interface GoogleVertexOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index fd1adfdd..d0ddf673 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -4,8 +4,8 @@ import { GoogleGenAI, type ThinkingConfig, } from "@google/genai"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { calculateCost, clampThinkingLevel } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, @@ -19,10 +19,10 @@ import type { ThinkingContent, ThinkingLevel, ToolCall, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import type { GoogleThinkingLevel } from "./google-shared.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import type { GoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, @@ -30,8 +30,8 @@ import { mapStopReason, mapToolChoice, retainThoughtSignature, -} from "./google-shared.js"; -import { buildBaseOptions } from "./simple-options.js"; +} from "./google-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; export interface GoogleOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/providers/images/openrouter.ts index 70c95a8c..01e81a60 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/providers/images/openrouter.ts @@ -6,7 +6,7 @@ import type { ChatCompletionContentPartText, ChatCompletionCreateParamsNonStreaming, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../../env-api-keys.js"; +import { getEnvApiKey } from "../../env-api-keys.ts"; import type { AssistantImages, ImageContent, @@ -15,9 +15,9 @@ import type { ImagesModel, ImagesOptions, TextContent, -} from "../../types.js"; -import { headersToRecord } from "../../utils/headers.js"; -import { sanitizeSurrogates } from "../../utils/sanitize-unicode.js"; +} from "../../types.ts"; +import { headersToRecord } from "../../utils/headers.ts"; +import { sanitizeSurrogates } from "../../utils/sanitize-unicode.ts"; interface OpenRouterGeneratedImage { image_url?: string | { url?: string }; diff --git a/packages/ai/src/providers/images/register-builtins.ts b/packages/ai/src/providers/images/register-builtins.ts index 04338f20..e3decbb9 100644 --- a/packages/ai/src/providers/images/register-builtins.ts +++ b/packages/ai/src/providers/images/register-builtins.ts @@ -1,6 +1,6 @@ -import { registerImagesApiProvider } from "../../images-api-registry.js"; -import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.js"; -import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.js"; +import { registerImagesApiProvider } from "../../images-api-registry.ts"; +import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts"; +import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts"; interface OpenRouterImagesProviderModule { generateImagesOpenRouter: typeof generateImagesOpenRouterFunction; @@ -21,7 +21,7 @@ function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, erro } function loadOpenRouterImagesProviderModule(): Promise { - openRouterImagesProviderModulePromise ||= import("./openrouter.js").then( + openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then( (module) => module as OpenRouterImagesProviderModule, ); return openRouterImagesProviderModulePromise; diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index c1500dbf..a15f141a 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -6,8 +6,8 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { calculateCost, clampThinkingLevel } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, Context, @@ -21,13 +21,13 @@ import type { ThinkingContent, Tool, ToolCall, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { shortHash } from "../utils/hash.js"; -import { parseStreamingJson } from "../utils/json-parse.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { buildBaseOptions } from "./simple-options.js"; -import { transformMessages } from "./transform-messages.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { shortHash } from "../utils/hash.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { buildBaseOptions } from "./simple-options.ts"; +import { transformMessages } from "./transform-messages.ts"; const MISTRAL_TOOL_CALL_ID_LENGTH = 9; const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index dc6b6463..70df0810 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -6,7 +6,7 @@ import type { ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -// NEVER convert to top-level runtime imports - breaks browser/Vite builds (web-ui) +// NEVER convert to top-level runtime imports - breaks browser/Vite builds let _os: typeof NodeOs | null = null; type DynamicImport = (specifier: string) => Promise; @@ -20,9 +20,9 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import { getEnvApiKey } from "../env-api-keys.js"; -import { clampThinkingLevel } from "../models.js"; -import { registerSessionResourceCleanup } from "../session-resources.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { clampThinkingLevel } from "../models.ts"; +import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { Api, AssistantMessage, @@ -32,16 +32,17 @@ import type { StreamFunction, StreamOptions, Usage, -} from "../types.js"; +} from "../types.ts"; import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic, formatThrownValue, -} from "../utils/diagnostics.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { headersToRecord } from "../utils/headers.js"; -import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; -import { buildBaseOptions } from "./simple-options.js"; +} from "../utils/diagnostics.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; +import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; // ============================================================================ // Configuration @@ -254,7 +255,29 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" const errorText = await response.text(); if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - const delayMs = BASE_DELAY_MS * 2 ** attempt; + let delayMs = BASE_DELAY_MS * 2 ** attempt; + + const retryAfterMs = response.headers.get("retry-after-ms"); + if (retryAfterMs !== null) { + const millis = Number(retryAfterMs); + if (Number.isFinite(millis)) { + delayMs = Math.max(0, millis); + } + } else { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + delayMs = Math.max(0, seconds * 1000); + } else { + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + delayMs = Math.max(0, date - Date.now()); + } + } + } + } + await sleep(delayMs, options?.signal); continue; } @@ -356,7 +379,7 @@ function buildRequestBody( input: messages, text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], - prompt_cache_key: options?.sessionId, + prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), tool_choice: "auto", parallel_tool_calls: true, }; @@ -711,7 +734,35 @@ type WebSocketConstructor = new ( protocols?: string | string[] | { headers?: Record }, ) => WebSocketLike; -function getWebSocketConstructor(): WebSocketConstructor | null { +let _cachedWebsocket: WebSocketConstructor | null = null; +async function getWebSocketConstructor(): Promise { + if (_cachedWebsocket) return _cachedWebsocket; + + // bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489 + // TODO: remove this when bun supports proxy envs in websocket. + if ( + process?.versions?.bun && + (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) + ) { + const m = await dynamicImport("proxy-from-env"); + const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl; + + _cachedWebsocket = class extends WebSocket { + constructor(url: string | URL, options?: string | string[] | Record) { + let _opts: Record = {}; + if (Array.isArray(options) || typeof options === "string") { + _opts = { protocols: options }; + } else { + _opts = { ...options }; + } + + const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:")); + super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any); + } + }; + return _cachedWebsocket; + } + const ctor = (globalThis as { WebSocket?: unknown }).WebSocket; if (typeof ctor !== "function") return null; return ctor as unknown as WebSocketConstructor; @@ -760,7 +811,7 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke } async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise { - const WebSocketCtor = getWebSocketConstructor(); + const WebSocketCtor = await getWebSocketConstructor(); if (!WebSocketCtor) { throw new Error("WebSocket transport is not available in this runtime"); } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 01bc6e78..12b6a508 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -10,8 +10,8 @@ import type { ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { calculateCost, clampThinkingLevel } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, CacheRetention, @@ -29,15 +29,16 @@ import type { Tool, ToolCall, ToolResultMessage, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { headersToRecord } from "../utils/headers.js"; -import { parseStreamingJson } from "../utils/json-parse.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; -import { buildBaseOptions } from "./simple-options.js"; -import { transformMessages } from "./transform-messages.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; +import { buildBaseOptions } from "./simple-options.ts"; +import { transformMessages } from "./transform-messages.ts"; /** * Check if conversation messages contain tool calls or tool results. @@ -165,6 +166,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA let textBlock: TextContent | null = null; let thinkingBlock: ThinkingContent | null = null; + let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); const blocks = output.content as StreamingBlock[]; @@ -288,6 +290,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (finishReasonResult.errorMessage) { output.errorMessage = finishReasonResult.errorMessage; } + hasFinishReason = true; } if (choice.delta) { @@ -324,7 +327,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (foundReasoningField) { const delta = deltaFields[foundReasoningField]; if (typeof delta === "string" && delta.length > 0) { - const block = ensureThinkingBlock(foundReasoningField); + const thinkingSignature = + model.provider === "opencode-go" && foundReasoningField === "reasoning" + ? "reasoning_content" + : foundReasoningField; + const block = ensureThinkingBlock(thinkingSignature); block.thinking += delta; stream.push({ type: "thinking_delta", @@ -390,6 +397,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (output.stopReason === "error") { throw new Error(output.errorMessage || "Provider returned an error stop reason"); } + if (!hasFinishReason) { + throw new Error("Stream ended without finish_reason"); + } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); @@ -507,7 +517,7 @@ function buildParams( prompt_cache_key: (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || (cacheRetention === "long" && compat.supportsLongCacheRetention) - ? options?.sessionId + ? clampOpenAIPromptCacheKey(options?.sessionId) : undefined, prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, }; @@ -839,7 +849,10 @@ export function convertMessages( } // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - const signature = nonEmptyThinkingBlocks[0].thinkingSignature; + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } if (signature && signature.length > 0) { (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); } @@ -997,17 +1010,17 @@ function parseChunkUsage( model: Model<"openai-completions">, ): AssistantMessage["usage"] { const promptTokens = rawUsage.prompt_tokens || 0; - const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; - // Normalize to pi-ai semantics: - // - cacheRead: hits from cache created by previous requests only - // - cacheWrite: tokens written to cache in this request - // Some OpenAI-compatible providers (observed on OpenRouter) report cached_tokens - // as (previous hits + current writes). In that case, remove cacheWrite from cacheRead. - const cacheReadTokens = - cacheWriteTokens > 0 ? Math.max(0, reportedCachedTokens - cacheWriteTokens) : reportedCachedTokens; - + // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read + // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // OpenRouter-compatible providers can include it as a separate write count. + // OpenRouter's own provider/tests affirm the separate mapping: + // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 + // Do not subtract writes from cached_tokens, otherwise spec-compliant + // providers are under-reported. DS4 mirrors this contract too: + // https://github.com/antirez/ds4/pull/29 const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens); // OpenAI completion_tokens already includes reasoning_tokens. const outputTokens = rawUsage.completion_tokens || 0; diff --git a/packages/ai/src/providers/openai-prompt-cache.ts b/packages/ai/src/providers/openai-prompt-cache.ts new file mode 100644 index 00000000..1c3355d5 --- /dev/null +++ b/packages/ai/src/providers/openai-prompt-cache.ts @@ -0,0 +1,8 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined { + if (key === undefined) return undefined; + const chars = Array.from(key); + if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key; + return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 0d68cd7f..b7bcd192 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -12,7 +12,7 @@ import type { ResponseReasoningItem, ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -import { calculateCost } from "../models.js"; +import { calculateCost } from "../models.ts"; import type { Api, AssistantMessage, @@ -26,12 +26,12 @@ import type { Tool, ToolCall, Usage, -} from "../types.js"; -import type { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { shortHash } from "../utils/hash.js"; -import { parseStreamingJson } from "../utils/json-parse.js"; -import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { transformMessages } from "./transform-messages.js"; +} from "../types.ts"; +import type { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { shortHash } from "../utils/hash.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { transformMessages } from "./transform-messages.ts"; // ============================================================================= // Utilities diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 8cc446d3..e2d1ca80 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,7 +1,7 @@ import OpenAI from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.js"; -import { clampThinkingLevel } from "../models.js"; +import { getEnvApiKey } from "../env-api-keys.ts"; +import { clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, @@ -13,13 +13,14 @@ import type { StreamFunction, StreamOptions, Usage, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import { headersToRecord } from "../utils/headers.js"; -import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; -import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; -import { buildBaseOptions } from "./simple-options.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; +import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); @@ -51,6 +52,22 @@ function getPromptCacheRetention( return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined; } +function formatOpenAIResponsesError(error: unknown): string { + if (error instanceof Error) { + const status = (error as Error & { status?: unknown }).status; + const statusCode = typeof status === "number" ? status : undefined; + if (statusCode !== undefined) { + return `OpenAI API error (${statusCode}): ${error.message}`; + } + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + // OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -130,7 +147,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes delete (block as { partialJson?: string }).partialJson; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatOpenAIResponsesError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } @@ -224,7 +241,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options model: model.id, input: messages, stream: true, - prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId, + prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), store: false, }; diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index 64475680..8fdcaaf0 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -1,4 +1,4 @@ -import { clearApiProviders, registerApiProvider } from "../api-registry.js"; +import { clearApiProviders, registerApiProvider } from "../api-registry.ts"; import type { Api, AssistantMessage, @@ -8,17 +8,17 @@ import type { SimpleStreamOptions, StreamFunction, StreamOptions, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import type { BedrockOptions } from "./amazon-bedrock.js"; -import type { AnthropicOptions } from "./anthropic.js"; -import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.js"; -import type { GoogleOptions } from "./google.js"; -import type { GoogleVertexOptions } from "./google-vertex.js"; -import type { MistralOptions } from "./mistral.js"; -import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.js"; -import type { OpenAICompletionsOptions } from "./openai-completions.js"; -import type { OpenAIResponsesOptions } from "./openai-responses.js"; +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import type { BedrockOptions } from "./amazon-bedrock.ts"; +import type { AnthropicOptions } from "./anthropic.ts"; +import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.ts"; +import type { GoogleOptions } from "./google.ts"; +import type { GoogleVertexOptions } from "./google-vertex.ts"; +import type { MistralOptions } from "./mistral.ts"; +import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.ts"; +import type { OpenAICompletionsOptions } from "./openai-completions.ts"; +import type { OpenAIResponsesOptions } from "./openai-responses.ts"; interface LazyProviderModule< TApi extends Api, @@ -86,7 +86,10 @@ interface BedrockProviderModule { ) => AsyncIterable; } -const importNodeOnlyProvider = (specifier: string): Promise => import(specifier); +const importNodeOnlyProvider = (specifier: string): Promise => { + const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; + return import(runtimeSpecifier); +}; let anthropicProviderModulePromise: | Promise> @@ -203,7 +206,7 @@ function createLazySimpleStream< function loadAnthropicProviderModule(): Promise< LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> > { - anthropicProviderModulePromise ||= import("./anthropic.js").then((module) => { + anthropicProviderModulePromise ||= import("./anthropic.ts").then((module) => { const provider = module as AnthropicProviderModule; return { stream: provider.streamAnthropic, @@ -216,7 +219,7 @@ function loadAnthropicProviderModule(): Promise< function loadAzureOpenAIResponsesProviderModule(): Promise< LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> > { - azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.js").then((module) => { + azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.ts").then((module) => { const provider = module as AzureOpenAIResponsesProviderModule; return { stream: provider.streamAzureOpenAIResponses, @@ -229,7 +232,7 @@ function loadAzureOpenAIResponsesProviderModule(): Promise< function loadGoogleProviderModule(): Promise< LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> > { - googleProviderModulePromise ||= import("./google.js").then((module) => { + googleProviderModulePromise ||= import("./google.ts").then((module) => { const provider = module as GoogleProviderModule; return { stream: provider.streamGoogle, @@ -242,7 +245,7 @@ function loadGoogleProviderModule(): Promise< function loadGoogleVertexProviderModule(): Promise< LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions> > { - googleVertexProviderModulePromise ||= import("./google-vertex.js").then((module) => { + googleVertexProviderModulePromise ||= import("./google-vertex.ts").then((module) => { const provider = module as GoogleVertexProviderModule; return { stream: provider.streamGoogleVertex, @@ -255,7 +258,7 @@ function loadGoogleVertexProviderModule(): Promise< function loadMistralProviderModule(): Promise< LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions> > { - mistralProviderModulePromise ||= import("./mistral.js").then((module) => { + mistralProviderModulePromise ||= import("./mistral.ts").then((module) => { const provider = module as MistralProviderModule; return { stream: provider.streamMistral, @@ -268,7 +271,7 @@ function loadMistralProviderModule(): Promise< function loadOpenAICodexResponsesProviderModule(): Promise< LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> > { - openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.js").then((module) => { + openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.ts").then((module) => { const provider = module as OpenAICodexResponsesProviderModule; return { stream: provider.streamOpenAICodexResponses, @@ -281,7 +284,7 @@ function loadOpenAICodexResponsesProviderModule(): Promise< function loadOpenAICompletionsProviderModule(): Promise< LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> > { - openAICompletionsProviderModulePromise ||= import("./openai-completions.js").then((module) => { + openAICompletionsProviderModulePromise ||= import("./openai-completions.ts").then((module) => { const provider = module as OpenAICompletionsProviderModule; return { stream: provider.streamOpenAICompletions, @@ -294,7 +297,7 @@ function loadOpenAICompletionsProviderModule(): Promise< function loadOpenAIResponsesProviderModule(): Promise< LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> > { - openAIResponsesProviderModulePromise ||= import("./openai-responses.js").then((module) => { + openAIResponsesProviderModulePromise ||= import("./openai-responses.ts").then((module) => { const provider = module as OpenAIResponsesProviderModule; return { stream: provider.streamOpenAIResponses, @@ -310,7 +313,7 @@ function loadBedrockProviderModule(): Promise< if (bedrockProviderModuleOverride) { return Promise.resolve(bedrockProviderModuleOverride); } - bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.js").then((module) => { + bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.ts").then((module) => { const provider = module as BedrockProviderModule; return { stream: provider.streamBedrock, diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index 914ba5e7..7f0aeea7 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -1,9 +1,9 @@ -import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types.js"; +import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types.ts"; -export function buildBaseOptions(model: Model, options?: SimpleStreamOptions, apiKey?: string): StreamOptions { +export function buildBaseOptions(_model: Model, options?: SimpleStreamOptions, apiKey?: string): StreamOptions { return { temperature: options?.temperature, - maxTokens: options?.maxTokens ?? (model.maxTokens > 0 ? Math.min(model.maxTokens, 32000) : undefined), + maxTokens: options?.maxTokens, signal: options?.signal, apiKey: apiKey || options?.apiKey, transport: options?.transport, @@ -24,7 +24,8 @@ export function clampReasoning(effort: ThinkingLevel | undefined): Exclude implements AsyncIterable { @@ -7,11 +7,12 @@ export class EventStream implements AsyncIterable { private done = false; private finalResultPromise: Promise; private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; - constructor( - private isComplete: (event: T) => boolean, - private extractResult: (event: T) => R, - ) { + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; this.finalResultPromise = new Promise((resolve) => { this.resolveFinalResult = resolve; }); diff --git a/packages/ai/src/utils/node-http-proxy.ts b/packages/ai/src/utils/node-http-proxy.ts new file mode 100644 index 00000000..7b5f4750 --- /dev/null +++ b/packages/ai/src/utils/node-http-proxy.ts @@ -0,0 +1,123 @@ +import type { Agent as HttpAgent } from "node:http"; +import type { Agent as HttpsAgent } from "node:https"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +const DEFAULT_PROXY_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +export interface NodeHttpProxyAgents { + httpAgent: HttpAgent; + httpsAgent: HttpsAgent; +} + +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; + +function getProxyEnv(key: string): string { + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +} + +function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { + if (targetUrl instanceof URL) { + return targetUrl; + } + + try { + return new URL(targetUrl); + } catch { + return undefined; + } +} + +function shouldProxyHostname(hostname: string, port: number): boolean { + const noProxy = getProxyEnv("no_proxy").toLowerCase(); + if (!noProxy) { + return true; + } + if (noProxy === "*") { + return false; + } + + return noProxy.split(/[,\s]/).every((proxy) => { + if (!proxy) { + return true; + } + + const parsedProxy = proxy.match(/^(.+):(\d+)$/); + let proxyHostname = parsedProxy ? parsedProxy[1] : proxy; + const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2]!, 10) : 0; + if (proxyPort && proxyPort !== port) { + return true; + } + + if (!/^[.*]/.test(proxyHostname)) { + return hostname !== proxyHostname; + } + + if (proxyHostname.startsWith("*")) { + proxyHostname = proxyHostname.slice(1); + } + return !hostname.endsWith(proxyHostname); + }); +} + +function getProxyForUrl(targetUrl: string | URL): string { + const parsedUrl = parseProxyTargetUrl(targetUrl); + if (!parsedUrl?.protocol || !parsedUrl.host) { + return ""; + } + + const protocol = parsedUrl.protocol.split(":", 1)[0]!; + const hostname = parsedUrl.host.replace(/:\d*$/, ""); + const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; + if (!shouldProxyHostname(hostname, port)) { + return ""; + } + + let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + if (proxy && !proxy.includes("://")) { + proxy = `${protocol}://${proxy}`; + } + return proxy; +} + +export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { + const proxy = getProxyForUrl(targetUrl); + if (!proxy) { + return undefined; + } + + let proxyUrl: URL; + try { + proxyUrl = new URL(proxy); + } catch (error) { + throw new Error( + `Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { + throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`); + } + + return proxyUrl; +} + +export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined { + const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl); + if (!proxyUrl) { + return undefined; + } + + return { + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, + }; +} diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 1d404266..feeb34fa 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -6,9 +6,9 @@ */ import type { Server } from "node:http"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; -import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; type CallbackServerInfo = { server: Server; diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index dcacb43d..6182ab91 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -2,9 +2,9 @@ * GitHub Copilot OAuth flow */ -import { getModels } from "../../models.js"; -import type { Api, Model } from "../../types.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; +import { getModels } from "../../models.ts"; +import type { Api, Model } from "../../types.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; type CopilotCredentials = OAuthCredentials & { enterpriseUrl?: string; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index f5f180bf..3a3a01b1 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -8,7 +8,7 @@ */ // Anthropic -export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; +export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; // GitHub Copilot export { getGitHubCopilotBaseUrl, @@ -16,20 +16,20 @@ export { loginGitHubCopilot, normalizeDomain, refreshGitHubCopilotToken, -} from "./github-copilot.js"; +} from "./github-copilot.ts"; // OpenAI Codex (ChatGPT OAuth) -export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.js"; +export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts"; -export * from "./types.js"; +export * from "./types.ts"; // ============================================================================ // Provider Registry // ============================================================================ -import { anthropicOAuthProvider } from "./anthropic.js"; -import { githubCopilotOAuthProvider } from "./github-copilot.js"; -import { openaiCodexOAuthProvider } from "./openai-codex.js"; -import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.js"; +import { anthropicOAuthProvider } from "./anthropic.ts"; +import { githubCopilotOAuthProvider } from "./github-copilot.ts"; +import { openaiCodexOAuthProvider } from "./openai-codex.ts"; +import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ anthropicOAuthProvider, diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 62c4c352..7dd1fbb8 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -5,7 +5,7 @@ * It is only intended for CLI use, not browser environments. */ -// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui) +// NEVER convert to top-level imports - breaks browser/Vite builds let _randomBytes: typeof import("node:crypto").randomBytes | null = null; let _http: typeof import("node:http") | null = null; if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { @@ -17,9 +17,9 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js"; -import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 03cbf101..a1426d81 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "../../types.js"; +import type { Api, Model } from "../../types.ts"; export type OAuthCredentials = { refresh: string; diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index bf879dc4..8b908e85 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage } from "../types.js"; +import type { AssistantMessage } from "../types.ts"; /** * Regex patterns to detect context overflow errors from different providers. @@ -11,6 +11,7 @@ import type { AssistantMessage } from "../types.js"; * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" * - OpenAI: "Your input exceeds the context window of this model" + * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" * - Groq: "Please reduce the length of the messages or completion" @@ -34,6 +35,7 @@ const OVERFLOW_PATTERNS = [ /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) /input is too long for requested model/i, // Amazon Bedrock /exceeds the context window/i, // OpenAI (Completions & Responses API) + /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM) /input token count.*exceeds the maximum/i, // Google (Gemini) /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq @@ -81,7 +83,7 @@ const NON_OVERFLOW_PATTERNS = [ * * **Reliable detection (returns error with detectable message):** * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" - * - OpenAI (Completions & Responses): "exceeds the context window" + * - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens" * - Google Gemini: "input token count exceeds the maximum" * - xAI (Grok): "maximum prompt length is X but request contains Y" * - Groq: "reduce the length of the messages" diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 6fee2868..060898c7 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -1,7 +1,7 @@ import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { Value } from "typebox/value"; -import type { Tool, ToolCall } from "../types.js"; +import type { Tool, ToolCall } from "../types.ts"; const validatorCache = new WeakMap>(); const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 57bf39d1..27c274aa 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete, stream } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete, stream } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const [openaiCodexToken] = await Promise.all([resolveApiKey("openai-codex")]); @@ -276,12 +276,12 @@ describe("AI Providers Abort Tests", () => { describe("OpenAI Codex Provider Abort", () => { it.skipIf(!openaiCodexToken)("should abort mid-stream", { retry: 3 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testAbortSignal(llm, { apiKey: openaiCodexToken }); }); it.skipIf(!openaiCodexToken)("should handle immediate abort", { retry: 3 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testImmediateAbort(llm, { apiKey: openaiCodexToken }); }); }); diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index e73a00a0..c6df9c3e 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -2,8 +2,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import type { AddressInfo } from "node:net"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { streamAnthropic } from "../src/providers/anthropic.js"; -import type { Context, Model, Tool } from "../src/types.js"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context, Model, Tool } from "../src/types.ts"; interface CapturedRequest { headers: IncomingMessage["headers"]; diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts index 3fed7ce2..2483e1a4 100644 --- a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -1,10 +1,10 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getEnvApiKey } from "../src/env-api-keys.js"; -import { getModels, getProviders } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { getModels, getProviders } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts"; +import { resolveApiKey } from "./oauth.ts"; const githubCopilotToken = await resolveApiKey("github-copilot"); diff --git a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts index d900aa71..2b7667d7 100644 --- a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts +++ b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getEnvApiKey } from "../src/env-api-keys.js"; -import { getModels, getProviders } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { getModels, getProviders } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts"; +import { resolveApiKey } from "./oauth.ts"; const githubCopilotToken = await resolveApiKey("github-copilot"); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index f8e066c6..36585a5f 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.js"; +import { loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { diff --git a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts b/packages/ai/test/anthropic-opus-4-7-smoke.test.ts index 29626d1f..7314f8af 100644 --- a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts +++ b/packages/ai/test/anthropic-opus-4-7-smoke.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; -import type { Context } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context } from "../src/types.ts"; interface AnthropicThinkingPayload { thinking?: { type: string }; diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 5bbc7d72..03024966 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -1,9 +1,9 @@ import type Anthropic from "@anthropic-ai/sdk"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamAnthropic } from "../src/providers/anthropic.js"; -import type { Context, ToolCall } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context, ToolCall } from "../src/types.ts"; function createSseResponse(events: Array<{ event: string; data: string }>): Response { const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index 8f871514..ea53043c 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; -import type { Context, Model, SimpleStreamOptions } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface AnthropicThinkingPayload { thinking?: { type: string; budget_tokens?: number; display?: string }; output_config?: { effort?: string }; } +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + function makePayloadCaptureContext(): Context { return { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], @@ -29,7 +36,7 @@ async function capturePayload( apiKey: "fake-key", onPayload: (payload) => { capturedPayload = payload as AnthropicThinkingPayload; - return payload; + throw new PayloadCaptured(); }, }); diff --git a/packages/ai/test/anthropic-tool-name-normalization.test.ts b/packages/ai/test/anthropic-tool-name-normalization.test.ts index 1ea6bdb2..bb454081 100644 --- a/packages/ai/test/anthropic-tool-name-normalization.test.ts +++ b/packages/ai/test/anthropic-tool-name-normalization.test.ts @@ -1,9 +1,9 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Context, Tool } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models.ts"; +import { stream } from "../src/stream.ts"; +import type { Context, Tool } from "../src/types.ts"; +import { resolveApiKey } from "./oauth.ts"; const oauthToken = await resolveApiKey("anthropic"); diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 3a8e9c19..530c5f47 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses.js"; -import type { Context } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses.ts"; +import type { Context } from "../src/types.ts"; interface CapturedAzureClientOptions { apiKey: string; @@ -11,14 +11,20 @@ interface CapturedAzureClientOptions { baseURL: string; } +interface CapturedAzureResponsesPayload { + prompt_cache_key?: string; +} + const azureMock = vi.hoisted(() => ({ constructorCalls: [] as CapturedAzureClientOptions[], + lastParams: undefined as CapturedAzureResponsesPayload | undefined, })); vi.mock("openai", () => { class AzureOpenAI { responses = { - create: () => { + create: (params: CapturedAzureResponsesPayload) => { + azureMock.lastParams = params; throw new Error("mock create"); }, }; @@ -42,6 +48,7 @@ const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY; beforeEach(() => { azureMock.constructorCalls.length = 0; + azureMock.lastParams = undefined; delete process.env.AZURE_OPENAI_BASE_URL; delete process.env.AZURE_OPENAI_RESOURCE_NAME; delete process.env.AZURE_OPENAI_API_VERSION; @@ -126,6 +133,17 @@ describe("azure-openai-responses base URL normalization", () => { expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL"); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + sessionId: "x".repeat(67), + }).result(); + + expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; const model = getModel("azure-openai-responses", "gpt-4o-mini"); diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts new file mode 100644 index 00000000..d62abd1b --- /dev/null +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; + +const bedrockMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, +})); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + constructor(config: Record) { + bedrockMock.constructorCalls.push(config); + } + + send(): Promise { + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { getModel } from "../src/models.ts"; +import { streamBedrock } from "../src/providers/amazon-bedrock.ts"; +import type { Context, Message } from "../src/types.ts"; + +const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); + +async function capturePayload(context: Context): Promise { + let capturedPayload: unknown; + const s = streamBedrock(baseModel, context, { + cacheRetention: "none", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload; + return payload; + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + return capturedPayload; +} + +describe("bedrock convertMessages skips unknown content types", () => { + it("skips unknown user content blocks instead of throwing", async () => { + const messages: Message[] = [ + { + role: "user", + content: [ + { type: "text", text: "hello" }, + { type: "unknown", data: "foo" }, + ] as any, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toHaveLength(1); + expect(p.messages[0].content[0]).toEqual({ text: "hello" }); + }); + + it("skips unknown assistant content blocks instead of throwing", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "hello" }, + { type: "unknown", data: "foo" }, + ] as any, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toHaveLength(1); + expect(p.messages[0].content[0]).toEqual({ text: "hello" }); + }); + + it("skips user messages with only unknown content blocks", async () => { + const messages: Message[] = [ + { + role: "user", + content: [{ type: "unknown", data: "foo" }] as any, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(0); + }); + + it("skips assistant messages with only unknown content blocks", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [{ type: "unknown", data: "foo" }] as any, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(0); + }); +}); diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 374599a4..a45bc6cf 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -44,9 +44,9 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }; }); -import { getModel } from "../src/models.js"; -import { streamBedrock } from "../src/providers/amazon-bedrock.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamBedrock } from "../src/providers/amazon-bedrock.ts"; +import type { Context, Model } from "../src/types.ts"; const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], diff --git a/packages/ai/test/bedrock-models.test.ts b/packages/ai/test/bedrock-models.test.ts index 453a3953..2cfd8fb9 100644 --- a/packages/ai/test/bedrock-models.test.ts +++ b/packages/ai/test/bedrock-models.test.ts @@ -17,10 +17,10 @@ */ import { describe, expect, it } from "vitest"; -import { getModels } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Context } from "../src/types.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; +import { getModels } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Context } from "../src/types.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; describe("Amazon Bedrock Models", () => { const models = getModels("amazon-bedrock"); diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 63ed8845..46a5277b 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.ts"; +import type { Context, Model } from "../src/types.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; interface BedrockThinkingPayload { additionalModelRequestFields?: { @@ -11,6 +12,13 @@ interface BedrockThinkingPayload { }; } +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + function makeContext(): Context { return { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], @@ -25,10 +33,9 @@ async function capturePayload( const s = streamBedrock(model, makeContext(), { ...options, reasoning: options?.reasoning ?? "high", - signal: AbortSignal.abort(), onPayload: (payload) => { capturedPayload = payload as BedrockThinkingPayload; - return payload; + throw new PayloadCaptured(); }, }); @@ -106,6 +113,39 @@ describe("Bedrock thinking payload", () => { }); }); +describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => { + it( + "uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models", + { retry: 2, timeout: 180000 }, + async () => { + const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-6"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + maxTokens: 6000, + }; + + const response = await streamBedrock( + model, + { + systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.", + messages: [ + { + role: "user", + content: + "Output exactly 5200 repetitions of the token alpha, separated by single spaces. Do not number them. Do not use markdown. Do not add any other text.", + timestamp: Date.now(), + }, + ], + }, + { reasoning: "low" }, + ).result(); + + expect(response.stopReason, response.errorMessage).not.toBe("error"); + expect(response.usage.output).toBeGreaterThan(4096); + }, + ); +}); + describe("Application inference profile support", () => { it("uses adaptive thinking when model.name contains the model name but ARN does not", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); @@ -137,10 +177,9 @@ describe("Application inference profile support", () => { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], }, { - signal: AbortSignal.abort(), onPayload: (payload) => { capturedPayload = payload; - return payload; + throw new PayloadCaptured(); }, }, ); diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 2f234ab0..8828ba86 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -1,7 +1,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import { streamOpenAIResponses } from "../src/providers/openai-responses.ts"; +import { stream } from "../src/stream.ts"; +import type { Context, Model } from "../src/types.ts"; + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function stopAfterPayload(capture: (payload: TPayload) => void): (payload: unknown) => never { + return (payload: unknown): never => { + capture(payload as TPayload); + throw new PayloadCaptured(); + }; +} describe("Cache Retention (PI_CACHE_RETENTION)", () => { const originalEnv = process.env.PI_CACHE_RETENTION; @@ -31,9 +48,9 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; const s = stream(model, context, { - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // Consume the stream to trigger the request @@ -54,9 +71,9 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; const s = stream(model, context, { - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // Consume the stream to trigger the request @@ -88,14 +105,13 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { // Since we can't easily test this without mocking, we'll skip the actual API call // and just verify the helper logic works correctly - const { streamAnthropic } = await import("../src/providers/anthropic.js"); try { const s = streamAnthropic(proxyModel, context, { apiKey: "fake-key", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // This will fail since we're using a fake key and fake proxy, but the payload should be captured @@ -119,15 +135,13 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }; let capturedPayload: any = null; - const { streamAnthropic } = await import("../src/providers/anthropic.js"); - try { const s = streamAnthropic(proxyModel, context, { apiKey: "fake-key", cacheRetention: "long", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -145,15 +159,13 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { const baseModel = getModel("anthropic", "claude-haiku-4-5"); let capturedPayload: any = null; - const { streamAnthropic } = await import("../src/providers/anthropic.js"); - try { const s = streamAnthropic(baseModel, context, { apiKey: "fake-key", cacheRetention: "none", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -171,14 +183,12 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { const baseModel = getModel("anthropic", "claude-haiku-4-5"); let capturedPayload: any = null; - const { streamAnthropic } = await import("../src/providers/anthropic.js"); - try { const s = streamAnthropic(baseModel, context, { apiKey: "fake-key", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -199,15 +209,13 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { const baseModel = getModel("anthropic", "claude-haiku-4-5"); let capturedPayload: any = null; - const { streamAnthropic } = await import("../src/providers/anthropic.js"); - try { const s = streamAnthropic(baseModel, context, { apiKey: "fake-key", cacheRetention: "long", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -230,9 +238,9 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; const s = stream(model, context, { - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // Consume the stream to trigger the request @@ -253,9 +261,9 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; const s = stream(model, context, { - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // Consume the stream to trigger the request @@ -280,14 +288,12 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; - const { streamOpenAIResponses } = await import("../src/providers/openai-responses.js"); - try { const s = streamOpenAIResponses(proxyModel, context, { apiKey: "fake-key", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); // This will fail since we're using a fake key and fake proxy, but the payload should be captured @@ -309,16 +315,14 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }; let capturedPayload: any = null; - const { streamOpenAIResponses } = await import("../src/providers/openai-responses.js"); - try { const s = streamOpenAIResponses(model, context, { apiKey: "fake-key", cacheRetention: "long", sessionId: "session-compat-false", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -336,16 +340,14 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { const model = getModel("openai", "gpt-4o-mini"); let capturedPayload: any = null; - const { streamOpenAIResponses } = await import("../src/providers/openai-responses.js"); - try { const s = streamOpenAIResponses(model, context, { apiKey: "fake-key", cacheRetention: "none", sessionId: "session-1", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -364,16 +366,14 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { const model = getModel("openai", "gpt-4o-mini"); let capturedPayload: any = null; - const { streamOpenAIResponses } = await import("../src/providers/openai-responses.js"); - try { const s = streamOpenAIResponses(model, context, { apiKey: "fake-key", cacheRetention: "long", sessionId: "session-2", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -408,16 +408,15 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => { let capturedPayload: any = null; - const { streamOpenAICompletions } = await import("../src/providers/openai-completions.js"); try { const s = streamOpenAICompletions(createCompletionsModel(), context, { apiKey: "fake-key", cacheRetention: "long", sessionId: "session-completions", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { @@ -434,16 +433,15 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { it("should omit prompt_cache_retention when supportsLongCacheRetention is false", async () => { let capturedPayload: any = null; - const { streamOpenAICompletions } = await import("../src/providers/openai-completions.js"); try { const s = streamOpenAICompletions(createCompletionsModel({ supportsLongCacheRetention: false }), context, { apiKey: "fake-key", cacheRetention: "long", sessionId: "session-completions-false", - onPayload: (payload) => { + onPayload: stopAfterPayload((payload) => { capturedPayload = payload; - }, + }), }); for await (const event of s) { diff --git a/packages/ai/test/codex-websocket-cached-probe.ts b/packages/ai/test/codex-websocket-cached-probe.ts index ccc875f8..7317035c 100644 --- a/packages/ai/test/codex-websocket-cached-probe.ts +++ b/packages/ai/test/codex-websocket-cached-probe.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node /** * Live probe for OpenAI Codex Responses websocket-cached mode. * @@ -9,15 +9,15 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Type } from "typebox"; -import { AuthStorage } from "../../coding-agent/src/core/auth-storage.js"; -import { getModel } from "../src/models.js"; +import { AuthStorage } from "../../coding-agent/src/core/auth-storage.ts"; +import { getModel } from "../src/models.ts"; import { closeOpenAICodexWebSocketSessions, getOpenAICodexWebSocketDebugStats, resetOpenAICodexWebSocketDebugStats, streamOpenAICodexResponses, -} from "../src/providers/openai-codex-responses.js"; -import type { AssistantMessage, Context, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.js"; +} from "../src/providers/openai-codex-responses.ts"; +import type { AssistantMessage, Context, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.ts"; type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -85,7 +85,7 @@ function required(value: string | undefined, flag: string): string { } function printHelp(): void { - console.log(`Usage: npx tsx test/codex-websocket-cached-probe.ts [options] + console.log(`Usage: node test/codex-websocket-cached-probe.ts [options] Options: --turns Number of user turns. Default: ${DEFAULT_TURNS} diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 56d6620d..164ca75d 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,13 +14,13 @@ import type { ChildProcess } from "child_process"; import { execSync, spawn } from "child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { AssistantMessage, Context, Model, Usage } from "../src/types.js"; -import { isContextOverflow } from "../src/utils/overflow.js"; -import { hasAzureOpenAICredentials } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts"; +import { isContextOverflow } from "../src/utils/overflow.ts"; +import { hasAzureOpenAICredentials } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([resolveApiKey("github-copilot"), resolveApiKey("openai-codex")]); @@ -143,7 +143,7 @@ describe("Context overflow error handling", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should detect overflow via isContextOverflow", async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getModel("github-copilot", "claude-sonnet-4.6"); const result = await testContextOverflow(model, githubCopilotToken!); logResult(result); @@ -228,9 +228,9 @@ describe("Context overflow error handling", () => { describe("OpenAI Codex (OAuth)", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should detect overflow via isContextOverflow", + "gpt-5.5 - should detect overflow via isContextOverflow", async () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + const model = getModel("openai-codex", "gpt-5.5"); const result = await testContextOverflow(model, openaiCodexToken!); logResult(result); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 34325f89..23593394 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -25,12 +25,12 @@ import { writeFileSync } from "fs"; import { Type } from "typebox"; import { beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { completeSimple, getEnvApiKey } from "../src/stream.js"; -import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.js"; -import { hasAzureOpenAICredentials } from "./azure-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models.ts"; +import { completeSimple, getEnvApiKey } from "../src/stream.ts"; +import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.ts"; +import { hasAzureOpenAICredentials } from "./azure-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Simple tool for testing const testToolSchema = Type.Object({ @@ -67,7 +67,7 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "openai", model: "gpt-5-mini", label: "openai-responses-gpt-5-mini" }, { provider: "azure-openai-responses", model: "gpt-4o-mini", label: "azure-openai-responses-gpt-4o-mini" }, // OpenAI Codex - { provider: "openai-codex", model: "gpt-5.2-codex", label: "openai-codex-gpt-5.2-codex" }, + { provider: "openai-codex", model: "gpt-5.5", label: "openai-codex-gpt-5.5" }, // GitHub Copilot { provider: "github-copilot", model: "claude-sonnet-4.5", label: "copilot-claude-sonnet-4.5" }, { provider: "github-copilot", model: "gpt-5.1-codex", label: "copilot-gpt-5.1-codex" }, diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index b14e9e55..7df7de2c 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ @@ -669,7 +669,7 @@ describe("AI Providers Empty Message Tests", () => { "claude-sonnet-4 - should handle empty content array", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testEmptyMessage(llm, { apiKey: githubCopilotToken }); }, ); @@ -678,7 +678,7 @@ describe("AI Providers Empty Message Tests", () => { "claude-sonnet-4 - should handle empty string content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testEmptyStringMessage(llm, { apiKey: githubCopilotToken }); }, ); @@ -687,7 +687,7 @@ describe("AI Providers Empty Message Tests", () => { "claude-sonnet-4 - should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken }); }, ); @@ -696,7 +696,7 @@ describe("AI Providers Empty Message Tests", () => { "claude-sonnet-4 - should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken }); }, ); @@ -704,37 +704,37 @@ describe("AI Providers Empty Message Tests", () => { describe("OpenAI Codex Provider Empty Messages", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle empty content array", + "gpt-5.5 - should handle empty content array", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testEmptyMessage(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle empty string content", + "gpt-5.5 - should handle empty string content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testEmptyStringMessage(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle whitespace-only content", + "gpt-5.5 - should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testWhitespaceOnlyMessage(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle empty assistant message in conversation", + "gpt-5.5 - should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testEmptyAssistantMessage(llm, { apiKey: openaiCodexToken }); }, ); diff --git a/packages/ai/test/env-api-keys.test.ts b/packages/ai/test/env-api-keys.test.ts new file mode 100644 index 00000000..61fec912 --- /dev/null +++ b/packages/ai/test/env-api-keys.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; + +const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN; +const originalGhToken = process.env.GH_TOKEN; +const originalGitHubToken = process.env.GITHUB_TOKEN; + +afterEach(() => { + if (originalCopilotGitHubToken === undefined) { + delete process.env.COPILOT_GITHUB_TOKEN; + } else { + process.env.COPILOT_GITHUB_TOKEN = originalCopilotGitHubToken; + } + + if (originalGhToken === undefined) { + delete process.env.GH_TOKEN; + } else { + process.env.GH_TOKEN = originalGhToken; + } + + if (originalGitHubToken === undefined) { + delete process.env.GITHUB_TOKEN; + } else { + process.env.GITHUB_TOKEN = originalGitHubToken; + } +}); + +describe("environment API keys", () => { + it("does not treat generic GitHub tokens as GitHub Copilot credentials", () => { + delete process.env.COPILOT_GITHUB_TOKEN; + process.env.GH_TOKEN = "gh-token"; + process.env.GITHUB_TOKEN = "github-token"; + + expect(findEnvKeys("github-copilot")).toBeUndefined(); + expect(getEnvApiKey("github-copilot")).toBeUndefined(); + }); + + it("resolves GitHub Copilot credentials from COPILOT_GITHUB_TOKEN", () => { + process.env.COPILOT_GITHUB_TOKEN = "copilot-token"; + process.env.GH_TOKEN = "gh-token"; + process.env.GITHUB_TOKEN = "github-token"; + + expect(findEnvKeys("github-copilot")).toEqual(["COPILOT_GITHUB_TOKEN"]); + expect(getEnvApiKey("github-copilot")).toBe("copilot-token"); + }); +}); diff --git a/packages/ai/test/faux-provider.test.ts b/packages/ai/test/faux-provider.test.ts index 16f33883..4f110c54 100644 --- a/packages/ai/test/faux-provider.test.ts +++ b/packages/ai/test/faux-provider.test.ts @@ -8,8 +8,8 @@ import { registerFauxProvider, stream, Type, -} from "../src/index.js"; -import type { AssistantMessageEvent, Context } from "../src/types.js"; +} from "../src/index.ts"; +import type { AssistantMessageEvent, Context } from "../src/types.ts"; async function collectEvents(streamResult: ReturnType): Promise { const events: AssistantMessageEvent[] = []; diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 7fec9899..0260a910 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -1,6 +1,11 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.js"; -import { getModel } from "../src/models.js"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context, Model, Tool } from "../src/types.ts"; const originalFireworksApiKey = process.env.FIREWORKS_API_KEY; @@ -47,4 +52,197 @@ describe("Fireworks models", () => { expect(findEnvKeys("fireworks")).toEqual(["FIREWORKS_API_KEY"]); expect(getEnvApiKey("fireworks")).toBe("test-fireworks-key"); }); + + it("sets Fireworks-specific compat for session affinity and unsupported tool fields", () => { + const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6"); + + expect(model.compat).toBeDefined(); + expect(model.compat?.sendSessionAffinityHeaders).toBe(true); + expect(model.compat?.supportsEagerToolInputStreaming).toBe(false); + expect(model.compat?.supportsCacheControlOnTools).toBe(false); + expect(model.compat?.supportsLongCacheRetention).toBe(false); + }); +}); + +// --- Integration tests for Fireworks Anthropic session affinity and tool compat --- + +interface CapturedRequest { + headers: IncomingMessage["headers"]; + body: Record; +} + +const tool: Tool = { + name: "lookup", + description: "Look up a value", + parameters: Type.Object({ value: Type.String() }), +}; + +function createFireworksModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + id: "accounts/fireworks/models/kimi-k2p6", + name: "Kimi K2.6", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest + reasoning: true, + input: ["text", "image"], + cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, + contextWindow: 262000, + maxTokens: 262000, + compat, + }; +} + +function createAnthropicModel(): Model<"anthropic-messages"> { + return { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + }; +} + +function createContext(tools: Tool[] = [tool]): Context { + return { + messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }], + ...(tools.length > 0 ? { tools } : {}), + }; +} + +async function readRequestBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; +} + +function writeEmptySseResponse(response: ServerResponse): void { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(); +} + +async function captureAnthropicRequest( + model: Model<"anthropic-messages">, + context: Context, + options?: { sessionId?: string; cacheRetention?: string }, +): Promise { + let capturedRequest: CapturedRequest | undefined; + + const server = createServer(async (request, response) => { + capturedRequest = { + headers: request.headers, + body: await readRequestBody(request), + }; + writeEmptySseResponse(response); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + + try { + // Override the model's baseUrl to point to the local test server + const localModel = { ...model, baseUrl: `http://127.0.0.1:${address.port}` }; + + const stream = streamAnthropic(localModel, context, { + apiKey: "test-key", + cacheRetention: (options?.cacheRetention as "none" | "short" | "long") ?? "short", + sessionId: options?.sessionId, + }); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + if (!capturedRequest) { + throw new Error("Anthropic request was not captured"); + } + return capturedRequest; +} + +function getTools(body: Record): Record[] { + const tools = body.tools; + if (!Array.isArray(tools)) { + throw new Error("Expected tools in request body"); + } + return tools as Record[]; +} + +describe("Fireworks Anthropic session affinity and tool compat", () => { + it("sends x-session-affinity header for Fireworks models", async () => { + const model = createFireworksModel(); + // Need a real port, capture will assign one + const request = await captureAnthropicRequest(model, createContext(), { + sessionId: "fireworks-session-1", + }); + + expect(request.headers["x-session-affinity"]).toBe("fireworks-session-1"); + }); + + it("omits x-session-affinity header for native Anthropic models", async () => { + const model = createAnthropicModel(); + const request = await captureAnthropicRequest(model, createContext(), { + sessionId: "anthropic-session-1", + }); + + expect(request.headers["x-session-affinity"]).toBeUndefined(); + }); + + it("omits x-session-affinity header when cacheRetention is none", async () => { + const model = createFireworksModel(); + const request = await captureAnthropicRequest(model, createContext(), { + sessionId: "fireworks-session-2", + cacheRetention: "none", + }); + + expect(request.headers["x-session-affinity"]).toBeUndefined(); + }); + + it("omits cache_control on tools for Fireworks models", async () => { + const model = createFireworksModel(); + const request = await captureAnthropicRequest(model, createContext()); + + const tools = getTools(request.body); + const lastTool = tools[tools.length - 1]; + expect(lastTool.cache_control).toBeUndefined(); + }); + + it("omits eager_input_streaming on tools for Fireworks models", async () => { + const model = createFireworksModel(); + const request = await captureAnthropicRequest(model, createContext()); + + const tools = getTools(request.body); + for (const t of tools) { + expect(t.eager_input_streaming).toBeUndefined(); + } + }); + + it("sends cache_control on tools for native Anthropic models", async () => { + const model = createAnthropicModel(); + const request = await captureAnthropicRequest(model, createContext()); + + const tools = getTools(request.body); + const lastTool = tools[tools.length - 1]; + expect(lastTool.cache_control).toBeDefined(); + expect((lastTool.cache_control as { type: string }).type).toBe("ephemeral"); + }); + + it("sends eager_input_streaming on tools for native Anthropic models", async () => { + const model = createAnthropicModel(); + const request = await captureAnthropicRequest(model, createContext()); + + const tools = getTools(request.body); + expect(tools[0].eager_input_streaming).toBe(true); + }); }); diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index 2fb44f23..ee3de3e8 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import type { Context } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ constructorOpts: undefined as Record | undefined, @@ -54,10 +55,9 @@ describe("Copilot Claude via Anthropic Messages", () => { }; it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getModel("github-copilot", "claude-sonnet-4.6"); expect(model.api).toBe("anthropic-messages"); - const { streamAnthropic } = await import("../src/providers/anthropic.js"); const s = streamAnthropic(model, context, { apiKey: "tid_copilot_session_test_token" }); for await (const event of s) { if (event.type === "error") break; @@ -85,15 +85,14 @@ describe("Copilot Claude via Anthropic Messages", () => { // Payload is valid Anthropic Messages format const params = mockState.createParams!; - expect(params.model).toBe("claude-sonnet-4"); + expect(params.model).toBe("claude-sonnet-4.6"); expect(params.stream).toBe(true); - expect(params.max_tokens).toBeGreaterThan(0); + expect(params.max_tokens).toBe(model.maxTokens); expect(Array.isArray(params.messages)).toBe(true); }); - it("includes interleaved-thinking beta when reasoning is enabled", async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); - const { streamAnthropic } = await import("../src/providers/anthropic.js"); + it("omits interleaved-thinking beta for adaptive-thinking models", async () => { + const model = getModel("github-copilot", "claude-sonnet-4.6"); const s = streamAnthropic(model, context, { apiKey: "tid_copilot_session_test_token", interleavedThinking: true, @@ -103,6 +102,6 @@ describe("Copilot Claude via Anthropic Messages", () => { } const headers = mockState.constructorOpts!.defaultHeaders as Record; - expect(headers["anthropic-beta"]).toContain("interleaved-thinking-2025-05-14"); + expect(headers["anthropic-beta"] ?? "").not.toContain("interleaved-thinking-2025-05-14"); }); }); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 0367892c..085b1cf0 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.js"; +import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { diff --git a/packages/ai/test/google-shared-convert-tools.test.ts b/packages/ai/test/google-shared-convert-tools.test.ts index 6cce3175..c7b10b41 100644 --- a/packages/ai/test/google-shared-convert-tools.test.ts +++ b/packages/ai/test/google-shared-convert-tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { convertTools } from "../src/providers/google-shared.js"; -import type { Tool } from "../src/types.js"; +import { convertTools } from "../src/providers/google-shared.ts"; +import type { Tool } from "../src/types.ts"; function makeTool(parameters: Record): Tool { return { diff --git a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts index 1822e9dd..920c6a4e 100644 --- a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts +++ b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { convertMessages } from "../src/providers/google-shared.js"; -import type { Context, Model } from "../src/types.js"; +import { convertMessages } from "../src/providers/google-shared.ts"; +import type { Context, Model } from "../src/types.ts"; function makeGemini3Model( api: TApi, diff --git a/packages/ai/test/google-shared-image-tool-result-routing.test.ts b/packages/ai/test/google-shared-image-tool-result-routing.test.ts index 27150dba..1430a084 100644 --- a/packages/ai/test/google-shared-image-tool-result-routing.test.ts +++ b/packages/ai/test/google-shared-image-tool-result-routing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { convertMessages } from "../src/providers/google-shared.js"; -import type { Context, Model } from "../src/types.js"; +import { convertMessages } from "../src/providers/google-shared.ts"; +import type { Context, Model } from "../src/types.ts"; function makeModel( api: TApi, diff --git a/packages/ai/test/google-thinking-disable.test.ts b/packages/ai/test/google-thinking-disable.test.ts index e8720ff8..30df3638 100644 --- a/packages/ai/test/google-thinking-disable.test.ts +++ b/packages/ai/test/google-thinking-disable.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; -import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.ts"; type SimpleOptionsWithExtras = SimpleStreamOptions & Record; diff --git a/packages/ai/test/google-thinking-signature.test.ts b/packages/ai/test/google-thinking-signature.test.ts index c90f7caa..83b17ccc 100644 --- a/packages/ai/test/google-thinking-signature.test.ts +++ b/packages/ai/test/google-thinking-signature.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared.js"; +import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared.ts"; describe("Google thinking detection (thoughtSignature)", () => { it("treats part.thought === true as thinking", () => { diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index d05702c3..5f66649c 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -45,9 +45,9 @@ vi.mock("@google/genai", () => { }; }); -import { getModel } from "../src/models.js"; -import { streamGoogleVertex } from "../src/providers/google-vertex.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamGoogleVertex } from "../src/providers/google-vertex.ts"; +import type { Context, Model } from "../src/types.ts"; const model = getModel("google-vertex", "gemini-3-flash-preview"); const context: Context = { diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 0477b162..542b2385 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -2,15 +2,15 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.js"; -import { complete, getModel } from "../src/index.js"; -import type { StreamOptions } from "../src/types.js"; +import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.ts"; +import { complete, getModel } from "../src/index.ts"; +import type { StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ @@ -464,7 +464,7 @@ describe("Tool Results with Images", () => { "claude-sonnet-4 - should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await handleToolWithImageResult(llm, { apiKey: githubCopilotToken }); }, ); @@ -473,7 +473,7 @@ describe("Tool Results with Images", () => { "claude-sonnet-4 - should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken }); }, ); @@ -481,19 +481,19 @@ describe("Tool Results with Images", () => { describe("OpenAI Codex Provider", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle tool result with only image", + "gpt-5.5 - should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await handleToolWithImageResult(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle tool result with text and image", + "gpt-5.5 - should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await handleToolWithTextAndImageResult(llm, { apiKey: openaiCodexToken }); }, ); diff --git a/packages/ai/test/images.test.ts b/packages/ai/test/images.test.ts index 59912c70..c45cc215 100644 --- a/packages/ai/test/images.test.ts +++ b/packages/ai/test/images.test.ts @@ -2,9 +2,9 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { getImageModel } from "../src/image-models.js"; -import { generateImages } from "../src/images.js"; -import type { ImageContent, ImagesContext, ImagesModel, ProviderImagesOptions } from "../src/types.js"; +import { getImageModel } from "../src/image-models.ts"; +import { generateImages } from "../src/images.ts"; +import type { ImageContent, ImagesContext, ImagesModel, ProviderImagesOptions } from "../src/types.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/packages/ai/test/interleaved-thinking.test.ts b/packages/ai/test/interleaved-thinking.test.ts index fabc3c40..4cf387ce 100644 --- a/packages/ai/test/interleaved-thinking.test.ts +++ b/packages/ai/test/interleaved-thinking.test.ts @@ -1,11 +1,11 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getEnvApiKey } from "../src/env-api-keys.js"; -import { getModel } from "../src/models.js"; -import { completeSimple } from "../src/stream.js"; -import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.js"; -import { StringEnum } from "../src/utils/typebox-helpers.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; +import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { getModel } from "../src/models.ts"; +import { completeSimple } from "../src/stream.ts"; +import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts"; +import { StringEnum } from "../src/utils/typebox-helpers.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; const calculatorSchema = Type.Object({ a: Type.Number({ description: "First number" }), diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index c94669ab..e21f0d12 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -1,11 +1,8 @@ import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -const require = createRequire(import.meta.url); -const tsxLoader = require.resolve("tsx/esm"); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href; @@ -42,7 +39,7 @@ function runProbe(action: string): ProbeResult { console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)] })); `; - const result = spawnSync(process.execPath, ["--import", tsxLoader, "--input-type=module", "--eval", script], { + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], { cwd: packageRoot, encoding: "utf8", }); diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts index 28ff5208..35a5b96b 100644 --- a/packages/ai/test/mistral-reasoning-mode.test.ts +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; -import type { Context, Model, SimpleStreamOptions } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface MistralPayload { promptMode?: "reasoning"; diff --git a/packages/ai/test/mistral-tool-schema.test.ts b/packages/ai/test/mistral-tool-schema.test.ts index 623e9a3a..c6898fc6 100644 --- a/packages/ai/test/mistral-tool-schema.test.ts +++ b/packages/ai/test/mistral-tool-schema.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Context, Model } from "../src/types.ts"; interface MistralToolPayload { tools?: Array<{ diff --git a/packages/ai/test/node-http-proxy.test.ts b/packages/ai/test/node-http-proxy.test.ts new file mode 100644 index 00000000..f4c9b735 --- /dev/null +++ b/packages/ai/test/node-http-proxy.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveHttpProxyUrlForTarget, UNSUPPORTED_PROXY_PROTOCOL_MESSAGE } from "../src/utils/node-http-proxy.ts"; + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "npm_config_http_proxy", + "npm_config_https_proxy", + "npm_config_proxy", + "npm_config_no_proxy", +] as const; + +const originalEnv = new Map(); +for (const key of PROXY_ENV_KEYS) { + originalEnv.set(key, process.env[key]); +} + +function resetProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } +} + +afterEach(() => { + resetProxyEnv(); + for (const [key, value] of originalEnv) { + if (value !== undefined) { + process.env[key] = value; + } + } +}); + +describe("node HTTP proxy resolution", () => { + it("respects NO_PROXY exclusions", () => { + resetProxyEnv(); + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + process.env.NO_PROXY = "bedrock-runtime.us-east-1.amazonaws.com"; + + expect(resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")).toBeUndefined(); + }); + + it("resolves HTTP and HTTPS proxy URLs", () => { + resetProxyEnv(); + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + + expect(resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")?.toString()).toBe( + "http://proxy.example:8080/", + ); + }); + + it("rejects SOCKS and PAC proxy URLs explicitly", () => { + resetProxyEnv(); + process.env.HTTPS_PROXY = "socks5://proxy.example:1080"; + + expect(() => resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com")).toThrow( + UNSUPPORTED_PROXY_PROTOCOL_MESSAGE, + ); + }); +}); diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index bd32f3d6..8ee7c94d 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -8,8 +8,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; -import { getOAuthApiKey } from "../src/utils/oauth/index.js"; -import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.js"; +import { getOAuthApiKey } from "../src/utils/oauth/index.ts"; +import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.ts"; const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json"); @@ -74,7 +74,12 @@ export async function resolveApiKey(provider: string): Promise { it.skipIf(!codexToken)("handles SSE requests with aligned cache-affinity identifiers", async () => { - const model = getModel("openai-codex", "gpt-5.3-codex"); + const model = getModel("openai-codex", "gpt-5.5"); const sessionId = "0195d6e4-4cf9-7f44-a2d8-f8f7f49ee9d3"; const context: Context = { systemPrompt: "You are a helpful assistant. Reply exactly as requested.", diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 276abe1b..0157f6f6 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js"; +import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.ts"; describe("OpenAI Codex OAuth", () => { afterEach(() => { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index f3d19896..e50111c9 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -7,22 +7,20 @@ import { resetOpenAICodexWebSocketDebugStats, streamOpenAICodexResponses, streamSimpleOpenAICodexResponses, -} from "../src/providers/openai-codex-responses.js"; -import type { Context, Model } from "../src/types.js"; +} from "../src/providers/openai-codex-responses.ts"; +import type { Context, Model } from "../src/types.ts"; -const originalFetch = global.fetch; -const originalWebSocket = globalThis.WebSocket; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; afterEach(() => { - global.fetch = originalFetch; - globalThis.WebSocket = originalWebSocket; + vi.unstubAllGlobals(); if (originalAgentDir === undefined) { delete process.env.PI_CODING_AGENT_DIR; } else { process.env.PI_CODING_AGENT_DIR = originalAgentDir; } resetOpenAICodexWebSocketDebugStats(); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -155,7 +153,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -175,7 +173,7 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token }); + const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }); let sawTextDelta = false; let sawDone = false; @@ -206,7 +204,7 @@ describe("openai-codex streaming", () => { }, }); - global.fetch = vi.fn(async (input: string | URL) => { + const fetchMock = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.github.com/repos/openai/codex/releases/latest") { return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); @@ -221,7 +219,8 @@ describe("openai-codex streaming", () => { }); } return new Response("not found", { status: 404 }); - }) as typeof fetch; + }); + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -265,7 +264,7 @@ describe("openai-codex streaming", () => { }, }); - global.fetch = vi.fn(async (input: string | URL) => { + const fetchMock = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.github.com/repos/openai/codex/releases/latest") { return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); @@ -280,7 +279,8 @@ describe("openai-codex streaming", () => { }); } return new Response("not found", { status: 404 }); - }) as typeof fetch; + }); + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -387,7 +387,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -407,10 +407,60 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId }); + const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId, transport: "sse" }); await streamResult.result(); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const token = mockToken(); + const sessionId = "x".repeat(67); + let capturedPayload: { prompt_cache_key?: string } | undefined; + const encoder = new TextEncoder(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" }))); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + ), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + await streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + sessionId, + onPayload: (payload) => { + capturedPayload = payload as { prompt_cache_key?: string }; + }, + }).result(); + + expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("preserves gpt-5.5 xhigh reasoning effort from simple options", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -425,7 +475,7 @@ describe("openai-codex streaming", () => { }); let requestedReasoning: unknown; - global.fetch = vi.fn(async (input: string | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.github.com/repos/openai/codex/releases/latest") { return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); @@ -442,7 +492,8 @@ describe("openai-codex streaming", () => { }); } return new Response("not found", { status: 404 }); - }) as typeof fetch; + }); + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.5", @@ -462,7 +513,11 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - await streamSimpleOpenAICodexResponses(model, context, { apiKey: token, reasoning: "xhigh" }).result(); + await streamSimpleOpenAICodexResponses(model, context, { + apiKey: token, + reasoning: "xhigh", + transport: "sse", + }).result(); expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" }); }); @@ -508,6 +563,7 @@ describe("openai-codex streaming", () => { })}`, ].join("\n\n")}\n\n`; + let requestedReasoning: unknown; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { @@ -526,7 +582,7 @@ describe("openai-codex streaming", () => { } if (url === "https://chatgpt.com/backend-api/codex/responses") { const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : null; - expect(body?.reasoning).toEqual({ effort: "low", summary: "auto" }); + requestedReasoning = body?.reasoning; return new Response(stream, { status: 200, @@ -536,7 +592,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: modelId, @@ -545,6 +601,7 @@ describe("openai-codex streaming", () => { provider: "openai-codex", baseUrl: "https://chatgpt.com/backend-api", reasoning: true, + thinkingLevelMap: { minimal: "low" }, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 400000, @@ -559,8 +616,10 @@ describe("openai-codex streaming", () => { const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, reasoningEffort: "minimal", + transport: "sse", }); await streamResult.result(); + expect(requestedReasoning).toEqual({ effort: "low", summary: "auto" }); }); it.each([ @@ -614,7 +673,7 @@ describe("openai-codex streaming", () => { }, }); - global.fetch = vi.fn(async (input: string | URL) => { + const fetchMock = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.github.com/repos/openai/codex/releases/latest") { return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); @@ -629,7 +688,8 @@ describe("openai-codex streaming", () => { }); } return new Response("not found", { status: 404 }); - }) as typeof fetch; + }); + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: modelId, @@ -649,7 +709,11 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const result = await streamOpenAICodexResponses(model, context, { apiKey: token, serviceTier }).result(); + const result = await streamOpenAICodexResponses(model, context, { + apiKey: token, + serviceTier, + transport: "sse", + }).result(); expect(result.usage.cost.input).toBe(1 * multiplier); expect(result.usage.cost.output).toBe(2 * multiplier); @@ -728,7 +792,7 @@ describe("openai-codex streaming", () => { return new Response("not found", { status: 404 }); }); - global.fetch = fetchMock as typeof fetch; + vi.stubGlobal("fetch", fetchMock); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -749,14 +813,15 @@ describe("openai-codex streaming", () => { }; // No sessionId provided - const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token }); + const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }); await streamResult.result(); }); it("forwards auto transport from streamSimple options and uses cached websocket context", async () => { const token = mockToken(); const sentBodies: unknown[] = []; - global.fetch = vi.fn(async () => new Response("unexpected fetch", { status: 500 })) as typeof fetch; + const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); class MockWebSocket { private listeners = new Map void>>(); @@ -826,7 +891,7 @@ describe("openai-codex streaming", () => { } } - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + vi.stubGlobal("WebSocket", MockWebSocket); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -949,7 +1014,7 @@ describe("openai-codex streaming", () => { } } - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + vi.stubGlobal("WebSocket", MockWebSocket); const model: Model<"openai-codex-responses"> = { id: "gpt-5.1-codex", @@ -1005,4 +1070,142 @@ describe("openai-codex streaming", () => { lastPreviousResponseId: "resp_1", }); }); + + it.each([ + ["retry-after-ms", () => ({ "content-type": "application/json", "retry-after-ms": "1500" }), 1500], + ["retry-after seconds", () => ({ "content-type": "application/json", "retry-after": "60" }), 60_000], + [ + "retry-after HTTP date", + () => ({ "content-type": "application/json", "retry-after": new Date(Date.now() + 45_000).toUTCString() }), + 45_000, + ], + ] as const)("uses %s for SSE retries", async (_name, makeHeaders, expectedDelay) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-13T00:00:00Z")); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + let codexRequests = 0; + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + codexRequests++; + if (codexRequests === 1) { + return new Response(JSON.stringify({ error: { code: "rate_limit_exceeded", message: "rate limited" } }), { + status: 429, + headers: makeHeaders(), + }); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + await vi.advanceTimersByTimeAsync(0); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelay); + + await vi.advanceTimersToNextTimerAsync(); + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(codexRequests).toBe(2); + }); + + it("uses exponential backoff across repeated SSE retries without retry headers", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-13T00:00:00Z")); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + let codexRequests = 0; + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + codexRequests++; + if (codexRequests <= 3) { + return new Response(JSON.stringify({ error: { code: "rate_limit_exceeded", message: "rate limited" } }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + await vi.advanceTimersByTimeAsync(0); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 1000); + + await vi.advanceTimersToNextTimerAsync(); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 2000); + + await vi.advanceTimersToNextTimerAsync(); + expect(setTimeoutSpy).toHaveBeenNthCalledWith(3, expect.any(Function), 4000); + + await vi.advanceTimersToNextTimerAsync(); + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(codexRequests).toBe(4); + }); }); diff --git a/packages/ai/test/openai-completions-cache-control-format.test.ts b/packages/ai/test/openai-completions-cache-control-format.test.ts index de69d02a..7d8ee9c4 100644 --- a/packages/ai/test/openai-completions-cache-control-format.test.ts +++ b/packages/ai/test/openai-completions-cache-control-format.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamOpenAICompletions } from "../src/providers/openai-completions.js"; -import type { Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import type { Model } from "../src/types.ts"; interface CacheControl { type: "ephemeral"; diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts index 8197efb0..a743351c 100644 --- a/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; // Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible // backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with @@ -93,6 +93,40 @@ describe("openai-completions empty tools handling", () => { expect("tools" in (params as object)).toBe(false); }); + it("does not send default max token fields", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBeUndefined(); + expect(params.max_completion_tokens).toBeUndefined(); + }); + + it("sends explicit maxTokens", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { apiKey: "test", maxTokens: 1234 }, + ).result(); + + const params = mockState.lastParams as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBeUndefined(); + expect(params.max_completion_tokens).toBe(1234); + }); + it("uses conservative OpenAI-compatible fields for Cloudflare AI Gateway /compat models", async () => { process.env.CLOUDFLARE_ACCOUNT_ID = "account-id"; process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id"; @@ -104,7 +138,7 @@ describe("openai-completions empty tools handling", () => { systemPrompt: "You are helpful.", messages: [{ role: "user", content: "hi", timestamp: Date.now() }], }, - { apiKey: "test", reasoning: "high" }, + { apiKey: "test", maxTokens: 1234, reasoning: "high" }, ).result(); const params = mockState.lastParams as { @@ -115,7 +149,7 @@ describe("openai-completions empty tools handling", () => { store?: boolean; }; expect(params.messages[0].role).toBe("system"); - expect(params.max_tokens).toBeDefined(); + expect(params.max_tokens).toBe(1234); expect(params.max_completion_tokens).toBeUndefined(); expect(params.reasoning_effort).toBeUndefined(); expect(params.store).toBeUndefined(); diff --git a/packages/ai/test/openai-completions-prompt-cache.test.ts b/packages/ai/test/openai-completions-prompt-cache.test.ts index 147097ef..75098fc1 100644 --- a/packages/ai/test/openai-completions-prompt-cache.test.ts +++ b/packages/ai/test/openai-completions-prompt-cache.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamOpenAICompletions } from "../src/providers/openai-completions.js"; -import type { Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import type { Model } from "../src/types.ts"; interface FakeOpenAIClientOptions { apiKey: string; @@ -125,6 +125,13 @@ describe("openai-completions prompt caching", () => { expect(payload?.prompt_cache_retention).toBe("24h"); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const sessionId = "x".repeat(67); + const { payload } = await captureRequest({ sessionId }); + + expect(payload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("omits prompt cache fields when cacheRetention is none", async () => { const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" }); diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts index 32d4edf7..aab20dea 100644 --- a/packages/ai/test/openai-completions-response-model.test.ts +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { complete } from "../src/stream.js"; -import type { Model } from "../src/types.js"; +import { complete } from "../src/stream.ts"; +import type { Model } from "../src/types.ts"; // Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the // requested id and surface the routed concrete id on `responseModel`. diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index a0bb6519..1c49aad3 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -2,7 +2,7 @@ import { once } from "node:events"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; -import { convertMessages, streamOpenAICompletions } from "../src/providers/openai-completions.js"; +import { convertMessages, streamOpenAICompletions } from "../src/providers/openai-completions.ts"; import type { AssistantMessage, AssistantMessageEvent, @@ -10,7 +10,7 @@ import type { Model, OpenAICompletionsCompat, Usage, -} from "../src/types.js"; +} from "../src/types.ts"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c9c8ec9f..ec98427b 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1,8 +1,9 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamSimple } from "../src/stream.js"; -import type { Tool } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { convertMessages } from "../src/providers/openai-completions.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, @@ -441,6 +442,38 @@ describe("openai-completions tool_choice", () => { expect(response.content).toEqual([{ type: "text", text: "OK" }]); }); + it("errors when a stream ends after only null finish_reason chunks", async () => { + mockState.chunks = [ + { + id: "chatcmpl-truncated", + choices: [{ delta: { content: "partial answer" }, finish_reason: null }], + }, + { + id: "chatcmpl-truncated", + choices: [{ delta: { content: "partial answer" }, finish_reason: null }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const response = await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Reply with a longer sentence", + timestamp: Date.now(), + }, + ], + }, + { apiKey: "test" }, + ).result(); + + expect(response.stopReason).toBe("error"); + expect(response.errorMessage).toBe("Stream ended without finish_reason"); + }); + it("coalesces tool call deltas by stable index when provider mutates ids mid-stream", async () => { mockState.chunks = [ { @@ -784,6 +817,184 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); + it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => { + const providers = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; + + for (const provider of providers) { + const model = getModel(provider, "mimo-v2.5-pro")!; + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true); + expect(model.compat?.thinkingFormat).toBe("deepseek"); + expect(model.compat?.maxTokensField).toBeUndefined(); + expect(model.compat?.supportsDeveloperRole).toBeUndefined(); + } + }); + + it("replays Xiaomi MiMo assistant tool calls with empty reasoning_content when thinking is missing", async () => { + const model = getModel("xiaomi", "mimo-v2.5-pro")!; + const assistantMessage: AssistantMessage = { + role: "assistant", + api: "openai-completions", + provider: "xiaomi", + model: "mimo-v2.5-pro", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "contents" }], + isError: false, + timestamp: Date.now(), + }; + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { role: "user", content: "Read README.md", timestamp: Date.now() }, + assistantMessage, + toolResult, + ], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { + messages?: Array>; + thinking?: { type?: string }; + reasoning_effort?: string; + }; + const replayedAssistant = params.messages?.find((message) => message.role === "assistant"); + expect(replayedAssistant).toMatchObject({ role: "assistant", reasoning_content: "" }); + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBe("high"); + }); + + it("normalizes OpenCode Go reasoning deltas to reasoning_content for replay", async () => { + mockState.chunks = [ + { + id: "chatcmpl-opencode-go-reasoning", + choices: [{ delta: { reasoning: "think" }, finish_reason: "stop" }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const response = await streamSimple( + model, + { + messages: [{ role: "user", content: "Use reasoning.", timestamp: Date.now() }], + }, + { apiKey: "test" }, + ).result(); + + expect(response.content).toEqual([ + { + type: "thinking", + thinking: "think", + thinkingSignature: "reasoning_content", + }, + ]); + }); + + it("keeps non-OpenCode Go reasoning deltas on the original reasoning field", async () => { + mockState.chunks = [ + { + id: "chatcmpl-reasoning", + choices: [{ delta: { reasoning: "think" }, finish_reason: "stop" }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const response = await streamSimple( + model, + { + messages: [{ role: "user", content: "Use reasoning.", timestamp: Date.now() }], + }, + { apiKey: "test" }, + ).result(); + + expect(response.content).toEqual([ + { + type: "thinking", + thinking: "think", + thinkingSignature: "reasoning", + }, + ]); + }); + + it("replays OpenCode Go reasoning thinking blocks as reasoning_content", () => { + const { compat: _compat, ...baseModel } = getModel("opencode-go", "kimi-k2.6")!; + const model = { ...baseModel, api: "openai-completions" } as Model<"openai-completions">; + const messages = convertMessages( + model, + { + messages: [ + { + role: "assistant", + api: "openai-completions", + provider: "opencode-go", + model: "kimi-k2.6", + content: [ + { type: "thinking", thinking: "think", thinkingSignature: "reasoning" }, + { type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ], + }, + { + ...model.compat, + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + supportsUsageInStreaming: true, + maxTokensField: "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: false, + thinkingFormat: "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + zaiToolStream: false, + supportsStrictMode: true, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: true, + }, + ); + + expect(messages[0]).toMatchObject({ role: "assistant", reasoning_content: "think" }); + expect(messages[0]).not.toHaveProperty("reasoning"); + }); + it("does not double-count reasoning tokens in completion usage", async () => { mockState.chunks = [ { @@ -819,7 +1030,7 @@ describe("openai-completions tool_choice", () => { expect(response.usage.totalTokens).toBe(43); }); - it("preserves prompt_tokens_details.cache_write_tokens from chunk usage", async () => { + it("preserves prompt_tokens_details cache read/write fields from chunk usage", async () => { mockState.chunks = [ { id: "chatcmpl-cache-write", @@ -853,13 +1064,14 @@ describe("openai-completions tool_choice", () => { { apiKey: "test" }, ).result(); - expect(response.usage.input).toBe(50); - expect(response.usage.cacheRead).toBe(20); + // cached_tokens is documented as cache reads; cache_write_tokens is separate. + expect(response.usage.input).toBe(20); + expect(response.usage.cacheRead).toBe(50); expect(response.usage.cacheWrite).toBe(30); expect(response.usage.totalTokens).toBe(105); }); - it("preserves prompt_tokens_details.cache_write_tokens from choice usage fallback", async () => { + it("preserves prompt_tokens_details cache read/write fields from choice usage fallback", async () => { mockState.chunks = [ { id: "chatcmpl-cache-write-choice", @@ -898,8 +1110,9 @@ describe("openai-completions tool_choice", () => { { apiKey: "test" }, ).result(); - expect(response.usage.input).toBe(50); - expect(response.usage.cacheRead).toBe(20); + // cached_tokens is documented as cache reads; cache_write_tokens is separate. + expect(response.usage.input).toBe(20); + expect(response.usage.cacheRead).toBe(50); expect(response.usage.cacheWrite).toBe(30); expect(response.usage.totalTokens).toBe(105); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index cd4d6436..3510f396 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { convertMessages } from "../src/providers/openai-completions.js"; +import { getModel } from "../src/models.ts"; +import { convertMessages } from "../src/providers/openai-completions.ts"; import type { AssistantMessage, Context, @@ -8,7 +8,7 @@ import type { OpenAICompletionsCompat, ToolResultMessage, Usage, -} from "../src/types.js"; +} from "../src/types.ts"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts index a2fd0d0e..d173694f 100644 --- a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Context } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Context } from "../src/types.ts"; describe.skipIf(!process.env.OPENAI_API_KEY)("openai responses cache affinity e2e", () => { it("handles direct OpenAI Responses requests with aligned cache-affinity identifiers", { retry: 2 }, async () => { diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index c4140a5c..04236fed 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.js"; -import { streamOpenAIResponses } from "../src/providers/openai-responses.js"; -import type { Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { streamOpenAIResponses } from "../src/providers/openai-responses.ts"; +import type { Model } from "../src/types.ts"; type CapturedHeaders = Headers | string[][] | Record | undefined; @@ -171,6 +171,38 @@ describe("openai-responses provider defaults", () => { expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); }); + it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { + const sessionId = "x".repeat(67); + let capturedPayload: { prompt_cache_key?: string } | undefined; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + getModel("openai", "gpt-5.4"), + { + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test-key", + sessionId, + onPayload: (payload) => { + capturedPayload = payload as { prompt_cache_key?: string }; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64)); + }); + it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => { const proxyModel: Model<"openai-responses"> = { ...getModel("openai", "gpt-5.4"), diff --git a/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts b/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts index c50fcc88..10f06377 100644 --- a/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts +++ b/packages/ai/test/openai-responses-foreign-toolcall-id.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { convertResponsesMessages } from "../src/providers/openai-responses-shared.js"; -import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.js"; -import { shortHash } from "../src/utils/hash.js"; +import { getModel } from "../src/models.ts"; +import { convertResponsesMessages } from "../src/providers/openai-responses-shared.ts"; +import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.ts"; +import { shortHash } from "../src/utils/hash.ts"; const COPILOT_RAW_TOOL_CALL_ID = "call_4VnzVawQXPB9MgYib7CiQFEY|I9b95oN1wD/cHXKTw3PpRkL6KkCtzTJhUxMouMWYwHeTo2j3htzfSk7YPx2vifiIM4g3A8XXyOj8q4Bt6SLUG7gqY1E3ELkrkVQNHglRfUmWj84lqxJY+Puieb3VKyX0FB+83TUzn91cDMF/4gzt990IzqVrc+nIb9RRscRD070Du16q1glydVjWR0SBJsE6TbY/esOjFpqplogQqrajm1eI++f3eLi73R6q7hVusY0QbeFySVxABCjhN0lXB04caBe1rzHjYzul6MAXj7uq+0r17VLq+yrtyYhN12wkmFqHeqTyEei6EFPbMy24Nc+IbJlkP0OCg02W+gOnyBFcbi2ctvJFSOhSjt1CqBdqCnnhwUqXjbWiT0wh3DmLScRgTHmGkaI+oAcQQjfic65nxj+TnEkReA=="; @@ -18,7 +18,7 @@ const usage: Usage = { describe("OpenAI Responses foreign tool call ID normalization", () => { it("hashes foreign Copilot tool item IDs into a bounded Codex-safe fc_ shape", () => { - const model = getModel("openai-codex", "gpt-5.3-codex"); + const model = getModel("openai-codex", "gpt-5.5"); const assistant: AssistantMessage = { role: "assistant", content: [ @@ -31,7 +31,7 @@ describe("OpenAI Responses foreign tool call ID normalization", () => { ], api: "openai-responses", provider: "github-copilot", - model: "gpt-5.3-codex", + model: "gpt-5.5", usage, stopReason: "toolUse", timestamp: Date.now() - 2000, diff --git a/packages/ai/test/openai-responses-partial-json-cleanup.test.ts b/packages/ai/test/openai-responses-partial-json-cleanup.test.ts index c34350de..76b16ad3 100644 --- a/packages/ai/test/openai-responses-partial-json-cleanup.test.ts +++ b/packages/ai/test/openai-responses-partial-json-cleanup.test.ts @@ -1,8 +1,8 @@ import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; import { describe, expect, it, vi } from "vitest"; -import { processResponsesStream } from "../src/providers/openai-responses-shared.js"; -import type { AssistantMessage, AssistantMessageEvent, Model } from "../src/types.js"; -import { AssistantMessageEventStream } from "../src/utils/event-stream.js"; +import { processResponsesStream } from "../src/providers/openai-responses-shared.ts"; +import type { AssistantMessage, AssistantMessageEvent, Model } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; function createOutput(model: Model<"openai-responses">): AssistantMessage { return { diff --git a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts index 2f78ddc7..aa6f2e23 100644 --- a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +++ b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete, getEnvApiKey } from "../src/stream.js"; -import type { AssistantMessage, Context, Message, Tool, ToolCall } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete, getEnvApiKey } from "../src/stream.ts"; +import type { AssistantMessage, Context, Message, Tool, ToolCall } from "../src/types.ts"; const testToolSchema = Type.Object({ value: Type.Number({ description: "A number to double" }), diff --git a/packages/ai/test/openai-responses-tool-result-images.test.ts b/packages/ai/test/openai-responses-tool-result-images.test.ts index 05d9c16f..c6131d91 100644 --- a/packages/ai/test/openai-responses-tool-result-images.test.ts +++ b/packages/ai/test/openai-responses-tool-result-images.test.ts @@ -4,10 +4,10 @@ import { fileURLToPath } from "node:url"; import type { ResponseFunctionCallOutputItemList } from "openai/resources/responses/responses.js"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import type { Api, Context, Model, StreamOptions, Tool, ToolResultMessage } from "../src/index.js"; -import { complete, getModel } from "../src/index.js"; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import type { Api, Context, Model, StreamOptions, Tool, ToolResultMessage } from "../src/index.ts"; +import { complete, getModel } from "../src/index.ts"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -183,8 +183,8 @@ describe("Responses API tool result images", () => { ); }); - describe("OpenAI Codex Responses Provider (gpt-5.2-codex)", () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + describe("OpenAI Codex Responses Provider (gpt-5.5)", () => { + const model = getModel("openai-codex", "gpt-5.5"); it.skipIf(!openaiCodexToken)( "should send tool result images in function_call_output", diff --git a/packages/ai/test/openrouter-cache-write-repro.test.ts b/packages/ai/test/openrouter-cache-write-repro.test.ts index ca492967..4bdeb286 100644 --- a/packages/ai/test/openrouter-cache-write-repro.test.ts +++ b/packages/ai/test/openrouter-cache-write-repro.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { completeSimple } from "../src/stream.js"; +import { getModel } from "../src/models.ts"; +import { completeSimple } from "../src/stream.ts"; function createLongSystemPrompt(): string { const nonce = `${Date.now()}-${Math.random()}`; diff --git a/packages/ai/test/openrouter-images.test.ts b/packages/ai/test/openrouter-images.test.ts index de2ddbba..31882bec 100644 --- a/packages/ai/test/openrouter-images.test.ts +++ b/packages/ai/test/openrouter-images.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateImages } from "../src/images.js"; -import type { ImagesContext, ImagesModel } from "../src/types.js"; +import { generateImages } from "../src/images.ts"; +import type { ImagesContext, ImagesModel } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index 1b50b831..b26948e2 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AssistantMessage } from "../src/types.js"; -import { isContextOverflow } from "../src/utils/overflow.js"; +import type { AssistantMessage } from "../src/types.ts"; +import { isContextOverflow } from "../src/utils/overflow.ts"; function createErrorMessage(errorMessage: string): AssistantMessage { return { @@ -42,6 +42,13 @@ describe("isContextOverflow", () => { expect(isContextOverflow(message, 262144)).toBe(true); }); + it("detects LiteLLM-wrapped OpenAI maximum context length errors", () => { + const message = createErrorMessage( + "Error: 503 litellm.ServiceUnavailableError: litellm.MidStreamFallbackError: litellm.APIConnectionError: APIConnectionError: OpenAIException - Requested token count exceeds the model's maximum context length of 131072 tokens.", + ); + expect(isContextOverflow(message, 131072)).toBe(true); + }); + it("does not treat generic non-overflow Ollama errors as overflow", () => { const message = createErrorMessage("500 `model runner crashed unexpectedly`"); expect(isContextOverflow(message, 32768)).toBe(false); diff --git a/packages/ai/test/responseid.test.ts b/packages/ai/test/responseid.test.ts index 6181ffa3..d250f5f4 100644 --- a/packages/ai/test/responseid.test.ts +++ b/packages/ai/test/responseid.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions } from "../src/types.js"; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -106,7 +106,7 @@ describe("responseId E2E Tests", () => { "Anthropic path should expose responseId", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await expectResponseId(llm, { apiKey: githubCopilotToken }); }, ); @@ -114,7 +114,7 @@ describe("responseId E2E Tests", () => { describe("OpenAI Codex Provider", () => { it.skipIf(!openaiCodexToken)("should expose responseId", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await expectResponseId(llm, { apiKey: openaiCodexToken }); }); }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 68d2810c..298104dd 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -4,17 +4,17 @@ import { dirname, join } from "path"; import { Type } from "typebox"; import { fileURLToPath } from "url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete, stream } from "../src/stream.js"; -import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResultMessage } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete, stream } from "../src/stream.ts"; +import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { StringEnum } from "../src/utils/typebox-helpers.js"; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { StringEnum } from "../src/utils/typebox-helpers.ts"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -1245,7 +1245,7 @@ describe("Generate E2E Tests", () => { }); describe("GitHub Copilot Provider (claude-sonnet-4 via Anthropic Messages)", () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); it.skipIf(!githubCopilotToken)("should complete basic text generation", { retry: 3 }, async () => { await basicTextGeneration(llm, { apiKey: githubCopilotToken }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 6a66ee94..4fb6f3b2 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getModel, getSupportedThinkingLevels } from "../src/models.js"; +import { getModel, getSupportedThinkingLevels } from "../src/models.ts"; describe("getSupportedThinkingLevels", () => { it("includes xhigh for Anthropic Opus 4.6 on anthropic-messages API", () => { diff --git a/packages/ai/test/together-models.test.ts b/packages/ai/test/together-models.test.ts index 14031447..cb5ea943 100644 --- a/packages/ai/test/together-models.test.ts +++ b/packages/ai/test/together-models.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.js"; -import { getModel } from "../src/models.js"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; +import { getModel } from "../src/models.ts"; const originalTogetherApiKey = process.env.TOGETHER_API_KEY; diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index a63d9321..f8da26a8 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { stream } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ @@ -301,7 +301,7 @@ describe("Token Statistics on Abort", () => { "claude-sonnet-4 - should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testTokensOnAbort(llm, { apiKey: githubCopilotToken }); }, ); @@ -309,10 +309,10 @@ describe("Token Statistics on Abort", () => { describe("OpenAI Codex Provider", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should include token stats when aborted mid-stream", + "gpt-5.5 - should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testTokensOnAbort(llm, { apiKey: openaiCodexToken }); }, ); diff --git a/packages/ai/test/tool-call-id-normalization.test.ts b/packages/ai/test/tool-call-id-normalization.test.ts index 0655f754..0672181b 100644 --- a/packages/ai/test/tool-call-id-normalization.test.ts +++ b/packages/ai/test/tool-call-id-normalization.test.ts @@ -12,10 +12,10 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { completeSimple, getEnvApiKey } from "../src/stream.js"; -import type { AssistantMessage, Message, Tool, ToolResultMessage } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models.ts"; +import { completeSimple, getEnvApiKey } from "../src/stream.ts"; +import type { AssistantMessage, Message, Tool, ToolResultMessage } from "../src/types.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve API keys const copilotToken = await resolveApiKey("github-copilot"); @@ -38,7 +38,7 @@ const echoTool: Tool = { * * 1. Use github-copilot gpt-5.2-codex to generate a tool call * 2. Switch to openrouter openai/gpt-5.2-codex and complete - * 3. Switch to openai-codex gpt-5.2-codex and complete + * 3. Switch to openai-codex gpt-5.5 and complete * * Both should succeed without "call_id too long" errors. */ @@ -117,7 +117,7 @@ describe("Tool Call ID Normalization - Live Handoff", () => { "github-copilot -> openai-codex should normalize pipe-separated IDs", async () => { const copilotModel = getModel("github-copilot", "gpt-5.2-codex"); - const codexModel = getModel("openai-codex", "gpt-5.2-codex"); + const codexModel = getModel("openai-codex", "gpt-5.5"); // Step 1: Generate tool call with github-copilot const userMessage: Message = { @@ -266,7 +266,7 @@ describe("Tool Call ID Normalization - Prefilled Context", () => { it.skipIf(!codexToken)( "openai-codex should handle prefilled context with long pipe-separated IDs", async () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + const model = getModel("openai-codex", "gpt-5.5"); const messages = buildPrefilledMessages(); const response = await completeSimple( diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 3f375e47..2b1950b9 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,15 +1,15 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions, Tool } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions, Tool } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ @@ -309,7 +309,7 @@ describe("Tool Call Without Result Tests", () => { "claude-sonnet-4 - should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getModel("github-copilot", "claude-sonnet-4.6"); await testToolCallWithoutResult(model, { apiKey: githubCopilotToken }); }, ); @@ -317,10 +317,10 @@ describe("Tool Call Without Result Tests", () => { describe("OpenAI Codex Provider", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should filter out tool calls without corresponding tool results", + "gpt-5.5 - should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + const model = getModel("openai-codex", "gpt-5.5"); await testToolCallWithoutResult(model, { apiKey: openaiCodexToken }); }, ); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 3e956ff0..bd782c2c 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,16 +13,16 @@ */ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions, Usage } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions, Usage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ @@ -726,7 +726,7 @@ describe("totalTokens field", () => { "claude-sonnet-4 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); console.log(`\nGitHub Copilot / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken }); @@ -771,10 +771,10 @@ describe("totalTokens field", () => { describe("OpenAI Codex (OAuth)", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should return totalTokens equal to sum of components", + "gpt-5.5 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); console.log(`\nOpenAI Codex / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: openaiCodexToken }); diff --git a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts index 111bd8d4..4d858835 100644 --- a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts +++ b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { transformMessages } from "../src/providers/transform-messages.js"; -import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.js"; +import { transformMessages } from "../src/providers/transform-messages.ts"; +import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.ts"; // Normalize function matching what anthropic.ts uses function anthropicNormalizeToolCallId( @@ -13,8 +13,8 @@ function anthropicNormalizeToolCallId( function makeCopilotClaudeModel(): Model<"anthropic-messages"> { return { - id: "claude-sonnet-4", - name: "Claude Sonnet 4", + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 75fbf19a..afe48d5c 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,15 +1,15 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, StreamOptions, ToolResultMessage } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { complete } from "../src/stream.ts"; +import type { Api, Context, Model, StreamOptions, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; -import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; -import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; -import { resolveApiKey } from "./oauth.js"; +import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; +import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; +import { resolveApiKey } from "./oauth.ts"; // Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist const emptySchema = Type.Object({}); @@ -426,7 +426,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { "claude-sonnet-4 - should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testEmojiInToolResults(llm, { apiKey: githubCopilotToken }); }, ); @@ -435,7 +435,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { "claude-sonnet-4 - should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken }); }, ); @@ -444,7 +444,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { "claude-sonnet-4 - should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getModel("github-copilot", "claude-sonnet-4.6"); await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken }); }, ); @@ -746,28 +746,28 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { describe("OpenAI Codex Provider Unicode Handling", () => { it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle emoji in tool results", + "gpt-5.5 - should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testEmojiInToolResults(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle real-world LinkedIn comment data with emoji", + "gpt-5.5 - should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testRealWorldLinkedInData(llm, { apiKey: openaiCodexToken }); }, ); it.skipIf(!openaiCodexToken)( - "gpt-5.2-codex - should handle unpaired high surrogate (0xD83D) in tool results", + "gpt-5.5 - should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getModel("openai-codex", "gpt-5.5"); await testUnpairedHighSurrogate(llm, { apiKey: openaiCodexToken }); }, ); diff --git a/packages/ai/test/validation.test.ts b/packages/ai/test/validation.test.ts index fd9d0164..dcbe9ebe 100644 --- a/packages/ai/test/validation.test.ts +++ b/packages/ai/test/validation.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import type { Tool, ToolCall } from "../src/types.js"; -import { validateToolArguments } from "../src/utils/validation.js"; +import type { Tool, ToolCall } from "../src/types.ts"; +import { validateToolArguments } from "../src/utils/validation.ts"; function createToolCallWithPlainSchema( schema: Tool["parameters"], diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index ef487369..3c279863 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models.ts"; +import { stream } from "../src/stream.ts"; +import type { Context, Model } from "../src/types.ts"; function makeContext(): Context { return { diff --git a/packages/ai/test/zen.test.ts b/packages/ai/test/zen.test.ts index 567af861..8ca80014 100644 --- a/packages/ai/test/zen.test.ts +++ b/packages/ai/test/zen.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { MODELS } from "../src/models.generated.js"; -import { complete } from "../src/stream.js"; -import type { Model } from "../src/types.js"; +import { MODELS } from "../src/models.generated.ts"; +import { complete } from "../src/stream.ts"; +import type { Model } from "../src/types.ts"; describe.skipIf(!process.env.OPENCODE_API_KEY)("OpenCode Models Smoke Test", () => { const providers = [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3136e113..ce69a3aa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,10 +4,146 @@ ### Added -- Added Together AI to built-in provider setup, `/login` API-key auth, and default model resolution ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)). +- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). + +### Changed + +- Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)). ### Fixed +- Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). +- Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). +- Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). +- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). + +## [0.75.4] - 2026-05-20 + +### New Features + +- **Hardened npm install and release path** - Pi now ships the CLI with a generated shrinkwrap for transitive dependencies, blocks accidental lockfile changes, verifies dependency pinning and lifecycle-script allowlists in checks, disables lifecycle scripts for self-update and local release installs where supported, and smoke-tests isolated npm and Bun installs before release. See [Supply-chain hardening](../../README.md#supply-chain-hardening). + +### Added + +- Added interactive update notes after `pi update` runs, so users can see the installed version's changelog before continuing ([#4724](https://github.com/earendil-works/pi-mono/pull/4724) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Exported image resize utilities from the package root for SDK consumers ([#4775](https://github.com/earendil-works/pi-mono/pull/4775) by [@xl0](https://github.com/xl0)). + +### Changed + +- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping core sources compatible with Node.js strip-only TypeScript checks. +- Removed web UI workspace references from the CLI package and dropped the package-level development watch script. +- Published npm installs now include an `npm-shrinkwrap.json` to lock transitive dependencies for the CLI package. +- Improved terminal theme detection for light/dark and truecolor handling. +- Changed self-update package-manager commands to disable lifecycle scripts during reinstall. + +### Fixed + +- Fixed the system prompt to tell models to resolve pi docs and examples under the absolute package paths before reading topic-specific relative references ([#4752](https://github.com/earendil-works/pi/issues/4752)). +- Fixed extension `ctx.abort()` during tool-call preflight to stop later confirmations and restore queued interactive input like Escape ([#4276](https://github.com/earendil-works/pi/issues/4276)). +- Fixed AgentSession retry, compaction, and event settlement to use the awaited agent lifecycle instead of a separate event queue, and added `willRetry` to `agent_end` session events. +- Fixed forked session runtime state to keep the active session id aligned with the fork target ([#4799](https://github.com/earendil-works/pi-mono/pull/4799) by [@Perlence](https://github.com/Perlence)). +- Fixed the subagent extension's parallel mode to return useful per-task output and failed-task diagnostics to the parent model instead of 100-character previews ([#4710](https://github.com/earendil-works/pi/issues/4710)). +- Fixed Windows local bash execution to hide helper console windows when launched from background SDK processes ([#4699](https://github.com/earendil-works/pi/issues/4699)). +- Fixed managed npm extension folders to set cloud-sync ignore metadata where supported ([#4763](https://github.com/earendil-works/pi/issues/4763)). +- Fixed HTTP idle timeout configuration so long-running provider streams can avoid premature idle disconnects ([#4759](https://github.com/earendil-works/pi-mono/pull/4759) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed default system prompt boundaries to use explicit XML tags for clearer file separation ([#4709](https://github.com/earendil-works/pi-mono/pull/4709) by [@herrnel](https://github.com/herrnel)). +- Fixed HTML share/export sidebar clicks for shared tool entries to scroll to the rendered tool call ([#4664](https://github.com/earendil-works/pi-mono/pull/4664) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed theme palettes to set explicit text colors and avoid terminal-default color drift. +- Fixed truecolor detection to align terminal image rendering and interactive theme decisions. +- Fixed loader indicator startup inherited from `@earendil-works/pi-tui` so initialization cannot run before frames are available. +- Fixed OpenAI-compatible default output token requests inherited from `@earendil-works/pi-ai` to avoid reserving impossible context windows on servers such as vLLM ([#4675](https://github.com/earendil-works/pi/issues/4675)). +- Fixed OpenAI prompt cache keys inherited from `@earendil-works/pi-ai` to stay within the 64-character provider limit ([#4720](https://github.com/earendil-works/pi/issues/4720)). +- Fixed Windows npm-family package commands for fnm-managed Node.js installs that expose both extensionless Unix scripts and `.cmd` shims ([#4793](https://github.com/earendil-works/pi/issues/4793)). + +## [0.75.3] - 2026-05-18 + +### Fixed + +- Fixed undici 8 HTTP/2 destroyed-session races crashing the Node CLI by preserving the previous HTTP/1.1-only fetch dispatcher behavior ([#4681](https://github.com/earendil-works/pi/issues/4681)). + +## [0.75.2] - 2026-05-18 + +### Fixed + +- Fixed Bun-compiled release binaries failing to start when Bun's built-in undici shim lacks npm undici's `install` export ([#4661](https://github.com/earendil-works/pi-mono/pull/4661) by [@dmasiero](https://github.com/dmasiero)). +- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests, inherited from `@earendil-works/pi-ai` ([#4678](https://github.com/earendil-works/pi/issues/4678)). +- Fixed Windows external editor handoff so vim/nvim can receive input after opening from the TUI ([#4612](https://github.com/earendil-works/pi/issues/4612)). +- Fixed Windows npm self-updates to move loaded native dependency packages out of the active install before reinstalling pi ([#4157](https://github.com/earendil-works/pi/issues/4157)). +- Fixed `pi update --self` detection for pnpm v11 global installs whose package path resolves through the pnpm store ([#4647](https://github.com/earendil-works/pi/issues/4647)). +- Fixed Windows pnpm self-updates to resolve pnpm command shims and run through pnpm instead of requiring manual updates ([#4157](https://github.com/earendil-works/pi/issues/4157)). +- Fixed Windows npm-family command execution to use cross-spawn instead of parsing `.cmd` shim internals ([#4665](https://github.com/earendil-works/pi/issues/4665)). + +## [0.75.1] - 2026-05-18 + +### Fixed + +- Fixed config selectors to scale their visible row count to terminal height ([#4243](https://github.com/earendil-works/pi-mono/pull/4243) by [@samjonester](https://github.com/samjonester)). +- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo inherited from `@earendil-works/pi-ai` ([#4342](https://github.com/earendil-works/pi/issues/4342)). +- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream, inherited from `@earendil-works/pi-ai` ([#4223](https://github.com/earendil-works/pi/issues/4223)). +- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier inherited from `@earendil-works/pi-ai` ([#4232](https://github.com/earendil-works/pi/issues/4232)). +- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only, inherited from `@earendil-works/pi-ai` ([#4251](https://github.com/earendil-works/pi/issues/4251)). +- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions inherited from `@earendil-works/pi-ai` ([#4505](https://github.com/earendil-works/pi/issues/4505)). +- Fixed JSON parse failures for compressed fetch responses under Node 26.0 by installing undici fetch globals alongside pi's global dispatcher ([#4650](https://github.com/earendil-works/pi/issues/4650), [#4652](https://github.com/earendil-works/pi/issues/4652), [#4653](https://github.com/earendil-works/pi/issues/4653)). +- Fixed npm-family package commands on Windows to avoid shell argument splitting when install prefixes contain spaces ([#4623](https://github.com/earendil-works/pi/issues/4623)). + +### Removed + +- Removed non-working OpenAI Codex fast model variants inherited from `@earendil-works/pi-ai`. + +## [0.75.0] - 2026-05-17 + +### Breaking Changes + +- Raised the minimum supported Node.js version to 22.19.0. + +### Fixed + +- Fixed compaction summary calls to use custom agent stream functions, preserving proxy-backed LLM routing ([#4484](https://github.com/earendil-works/pi/issues/4484)). +- Fixed system prompt and context file boundaries to use explicit XML tags instead of Markdown headings, reducing inconsistent boundary ingestion by models ([#4541](https://github.com/earendil-works/pi-mono/pull/4541) by [@herrnel](https://github.com/herrnel)). +- Fixed OpenAI Codex generated model metadata to use the current upstream model list inherited from `@earendil-works/pi-ai` ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)). +- Fixed GitHub Copilot GPT model thinking metadata inherited from `@earendil-works/pi-ai` to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)). +- Fixed user-scoped npm pi packages to install under `~/.pi/agent/npm/` instead of npm's global package root, avoiding permission errors with system-managed Node installs ([#4587](https://github.com/earendil-works/pi/issues/4587)). +- Fixed Mistral requests failing after the global fetch proxy/timeout workaround by removing the custom fetch override and using undici 8 dispatcher support instead ([#4619](https://github.com/earendil-works/pi/issues/4619)). +- Fixed default output token requests for models whose advertised output limit is effectively their full context window, avoiding impossible provider requests inherited from `@earendil-works/pi-ai` ([#4614](https://github.com/earendil-works/pi/issues/4614)). + +## [0.74.1] - 2026-05-16 + +### New Features + +- **Image generation support** - Added image generation APIs, generated image model metadata, and built-in OpenRouter image generation support inherited from `@earendil-works/pi-ai`. +- **Together AI provider** - Added Together AI as a built-in provider with `/login` API-key auth, default model resolution, and setup docs. See [README.md#providers--models](README.md#providers--models) and [docs/providers.md](docs/providers.md). +- **Windows ARM64 standalone binaries** - Added standalone release artifacts for Windows ARM64. +- **Improved terminal and markdown rendering** - Added markdown list indentation, task-list checkbox rendering, large markdown robustness, and inline image placement fixes inherited from `@earendil-works/pi-tui`. + +### Added + +- Added image generation support from `@earendil-works/pi-ai`, including image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Added Together AI to built-in provider setup, `/login` API-key auth, and default model resolution ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)). +- Added Windows ARM64 standalone binary release artifacts ([#4458](https://github.com/earendil-works/pi/pull/4458) by [@brianmichel](https://github.com/brianmichel)). + +### Fixed + +- Fixed Node 26 OpenAI-compatible streams timing out after five idle minutes by routing global fetch through pi's undici dispatcher ([#4519](https://github.com/earendil-works/pi/issues/4519)). +- Fixed pnpm global package installs by resolving the global package root from pnpm's layout. +- Fixed macOS clipboard access errors under sandboxed pasteboard denial so they do not abort the process ([#4492](https://github.com/earendil-works/pi/issues/4492)). +- Fixed the scoped model startup hint to show the configured model-cycle keybinding ([#4508](https://github.com/earendil-works/pi/issues/4508)). +- Fixed resource path display to disambiguate package/resource names that collide across package locations. +- Fixed `fd` auto-download on macOS x86_64 by pinning the last release that ships an Intel macOS binary ([#4559](https://github.com/earendil-works/pi/issues/4559)). +- Fixed skill diagnostics to stop warning when a skill name differs from its parent directory ([#4534](https://github.com/earendil-works/pi/issues/4534)). +- Fixed prompt template argument parsing to split unquoted multiline input on newlines ([#4553](https://github.com/earendil-works/pi/issues/4553)). +- Fixed `--resume` session listing to cap in-flight session metadata loads and avoid OOM on large session histories ([#4583](https://github.com/earendil-works/pi/issues/4583)). +- Fixed interactive error messages to render with trailing spacing so reload errors do not run into resource listings ([#4510](https://github.com/earendil-works/pi/issues/4510)). +- Fixed `.agents` package provenance metadata to survive package-manager scans. +- Fixed nested code fences in the Termux setup documentation so the example AGENTS.md renders correctly ([#4503](https://github.com/earendil-works/pi/issues/4503)). +- Fixed tool output expansion while extension confirmation dialogs are focused ([#4429](https://github.com/earendil-works/pi/issues/4429)). +- Fixed auto-retry for Anthropic streams that end before `message_stop` ([#4433](https://github.com/earendil-works/pi/issues/4433)). +- Fixed compaction summary calls to clamp requested output tokens to model limits. +- Fixed uncaught interactive-mode exceptions to restore the terminal before exiting ([#4426](https://github.com/earendil-works/pi-mono/pull/4426) by [@ofa1](https://github.com/ofa1)). +- Fixed ANSI stripping to match `strip-ansi` behavior after dependency removal. +- Fixed UUIDv7 sequence generation shared by session IDs after dependency removal. +- Fixed OpenRouter cached-token usage accounting, Fireworks caching compatibility, and OpenAI Codex WebSocket proxy handling inherited from `@earendil-works/pi-ai`. +- Fixed markdown list wrapping, task-list checkboxes, large markdown rendering, WezTerm Kitty keyboard escape handling, and short-viewport inline image placement inherited from `@earendil-works/pi-tui`. +- Fixed theme sharing across package scopes so extensions do not crash with `Theme not initialized` ([#4333](https://github.com/earendil-works/pi/issues/4333)). - Fixed keybinding hints to show Option instead of Alt on macOS ([#4289](https://github.com/earendil-works/pi/issues/4289)). - Fixed the interactive update notification to render the changelog as an OSC 8 hyperlink when the terminal supports hyperlinks ([#4280](https://github.com/earendil-works/pi/issues/4280)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 44e1bce1..00fca81e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -68,13 +68,15 @@ I regularly publish my own `pi-mono` work sessions here: ## Quick Start ```bash -curl -fsSL https://pi.dev/install.sh | sh +npm install -g --ignore-scripts @earendil-works/pi-coding-agent ``` -Or with npm: +`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs. + +Installer alternative: ```bash -npm install -g @earendil-works/pi-coding-agent +curl -fsSL https://pi.dev/install.sh | sh ``` Authenticate with an API key: @@ -406,7 +408,7 @@ pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.pi/agent/git/` (git) or global npm. Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. +Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding a `pi` key to `package.json`: diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index be80ade4..09eb6e6e 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -23,6 +23,7 @@ See these complete provider examples: - [Unregister Provider](#unregister-provider) - [OAuth Support](#oauth-support) - [Custom Streaming API](#custom-streaming-api) +- [Context Overflow Errors](#context-overflow-errors) - [Testing Your Implementation](#testing-your-implementation) - [Config Reference](#config-reference) - [Model Definition Reference](#model-definition-reference) @@ -506,6 +507,60 @@ output.usage.totalTokens = output.usage.input + output.usage.output + calculateCost(model, output.usage); ``` +### Context Overflow Errors + +When a request exceeds the model's context window, pi can recover automatically by compacting the conversation and retrying. This recovery only kicks in if pi recognizes the failure as an overflow. + +Detection runs on the finalized assistant message: + +- `stopReason === "error"` +- `errorMessage` matches one of pi's known overflow patterns (see [`packages/ai/src/utils/overflow.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/utils/overflow.ts)) + +If your provider returns overflow errors with a message pi does not recognize, normalize the error from the same extension that registers the provider. Use a `message_end` handler to rewrite the assistant message so its `errorMessage` starts with a phrase pi recognizes. The generic fallback `context_length_exceeded` is the safest choice. + +```typescript +const MY_PROVIDER_OVERFLOW_PATTERN = /your provider's overflow phrase/i; + +export default function (pi: ExtensionAPI) { + pi.registerProvider("my-provider", { /* ... */ }); + + pi.on("message_end", (event, ctx) => { + const message = event.message; + if (message.role !== "assistant") return; + if (message.stopReason !== "error") return; + if ( + message.provider !== "my-provider" && + ctx.model?.provider !== "my-provider" + ) + return; + + const errorMessage = message.errorMessage ?? ""; + if (errorMessage.includes("context_length_exceeded")) return; + if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return; + + return { + message: { + ...message, + errorMessage: `context_length_exceeded: ${errorMessage}`, + }, + }; + }); +} +``` + +`message_end` runs before pi tracks the assistant message for auto-compaction, so the rewritten `errorMessage` is what pi checks. With this in place, pi will: + +1. Detect the overflow from `errorMessage`. +2. Drop the failed assistant message from live context. +3. Run compaction. +4. Retry the request once. + +Guard the rewrite carefully: + +- Scope it to your provider (`message.provider` and `ctx.model?.provider`) so unrelated errors from other providers are untouched. +- Match a provider-specific pattern, not pi's generic overflow patterns. Rewriting rate-limit or throttling errors (`rate limit`, `too many requests`) would falsely trigger compaction instead of pi's normal retry-with-backoff path. +- Skip when `errorMessage` already includes `context_length_exceeded` so the handler is idempotent. + ### Registration Register your stream function: diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 998a650d..ce922f2a 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -162,7 +162,7 @@ export default function (pi: ExtensionAPI) { pi.on("event_name", async (event, ctx) => { // ctx.ui for user interaction const ok = await ctx.ui.confirm("Title", "Are you sure?"); - ctx.ui.notify("Done!", "success"); + ctx.ui.notify("Done!", "info"); ctx.ui.setStatus("my-ext", "Processing..."); // Footer status ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) }); diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index a4294d3a..75a527d6 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -4,18 +4,28 @@ Pi is a minimal terminal coding harness. It is designed to stay small at the cor ## Quick start -On linux or mac you can install Pi with curl: +Install Pi with npm: + +```bash +npm install -g --ignore-scripts @earendil-works/pi-coding-agent +``` + +`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs. + +On Linux or macOS, you can also use the installer: ```bash curl -fsSL https://pi.dev/install.sh | sh ``` -Or alternatively with npm: +To uninstall pi itself, use npm for curl and npm installs: ```bash -npm install -g @earendil-works/pi-coding-agent +npm uninstall -g @earendil-works/pi-coding-agent ``` +For pnpm, Yarn, or Bun installs, use the matching global remove command: `pnpm remove -g @earendil-works/pi-coding-agent`, `yarn global remove @earendil-works/pi-coding-agent`, or `bun uninstall -g @earendil-works/pi-coding-agent`. + Then run it in a project directory: ```bash diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index e0a2f2c2..1f3c69a6 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -36,7 +36,9 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -By default, `install` and `remove` write to global settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup. +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). + +By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup. To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: @@ -57,7 +59,7 @@ npm:pkg ``` - Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`). -- Global installs use `npm install -g`. +- User installs go under `~/.pi/agent/npm/`. - Project installs go under `.pi/npm/`. - Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. @@ -83,9 +85,9 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. -- Refs pin the package and skip package updates (`pi update`, `pi update --extensions`). +- Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). -- Runs `npm install` after clone or pull if `package.json` exists. +- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists. **SSH examples:** ```bash diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 55c6e346..c7266346 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -7,9 +7,31 @@ This page gets you from install to a useful first pi session. Pi is distributed as an npm package: ```bash -npm install -g @earendil-works/pi-coding-agent +npm install -g --ignore-scripts @earendil-works/pi-coding-agent ``` +`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs. + +### Uninstall + +Use the package manager that installed pi. The curl installer uses npm globally, so curl and npm installs are removed with npm: + +```bash +# curl installer or npm install -g +npm uninstall -g @earendil-works/pi-coding-agent + +# pnpm +pnpm remove -g @earendil-works/pi-coding-agent + +# Yarn +yarn global remove @earendil-works/pi-coding-agent + +# Bun +bun uninstall -g @earendil-works/pi-coding-agent +``` + +Uninstalling pi leaves settings, credentials, sessions, and installed pi packages in `~/.pi/agent/`. + Then start pi in the project directory you want it to work on: ```bash diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 6db72279..8377185e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -54,7 +54,7 @@ The main factory function for a single `AgentSession`. `createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery. ```typescript -import { createAgentSession } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; // Minimal: defaults with DefaultResourceLoader const { session } = await createAgentSession(); @@ -62,7 +62,7 @@ const { session } = await createAgentSession(); // Custom: override specific options const { session } = await createAgentSession({ model: myModel, - tools: [readTool, bashTool], + tools: ["read", "bash"], sessionManager: SessionManager.inMemory(), }); ``` @@ -466,64 +466,52 @@ const { session } = await createAgentSession({ resourceLoader: loader }); ### Tools -```typescript -import { - codingTools, // read, bash, edit, write (default) - readOnlyTools, // read, grep, find, ls - readTool, bashTool, editTool, writeTool, - grepTool, findTool, lsTool, -} from "@earendil-works/pi-coding-agent"; +Specify which built-in tools to enable: -// Use built-in tool set +- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +- Default built-ins: `read`, `bash`, `edit`, `write` +- `noTools: "all"` disables all tools +- `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled + +The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers. + +```typescript +import { createAgentSession } from "@earendil-works/pi-coding-agent"; + +// Read-only mode const { session } = await createAgentSession({ - tools: readOnlyTools, + tools: ["read", "grep", "find", "ls"], }); // Pick specific tools const { session } = await createAgentSession({ - tools: [readTool, bashTool, grepTool], + tools: ["read", "bash", "grep"], }); ``` #### Tools with Custom cwd -**Important:** The pre-built tool instances (`readTool`, `bashTool`, etc.) use `process.cwd()` for path resolution. When you specify a custom `cwd` AND provide explicit `tools`, you must use the tool factory functions to ensure paths resolve correctly: +When you pass a custom `cwd`, `createAgentSession()` builds selected built-in tools for that cwd. ```typescript -import { - createCodingTools, // Creates [read, bash, edit, write] for specific cwd - createReadOnlyTools, // Creates [read, grep, find, ls] for specific cwd - createReadTool, - createBashTool, - createEditTool, - createWriteTool, - createGrepTool, - createFindTool, - createLsTool, -} from "@earendil-works/pi-coding-agent"; +import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; const cwd = "/path/to/project"; -// Use factory for tool sets +// Use default tools for custom cwd const { session } = await createAgentSession({ cwd, - tools: createCodingTools(cwd), // Tools resolve paths relative to cwd + sessionManager: SessionManager.inMemory(cwd), }); -// Or pick specific tools +// Or pick specific tools for custom cwd const { session } = await createAgentSession({ cwd, - tools: [createReadTool(cwd), createBashTool(cwd), createGrepTool(cwd)], + tools: ["read", "bash", "grep"], + sessionManager: SessionManager.inMemory(cwd), }); ``` -**When you don't need factories:** -- If you omit `tools`, pi automatically creates them with the correct `cwd` -- If you use `process.cwd()` as your `cwd`, the pre-built instances work fine - -**When you must use factories:** -- When you specify both `cwd` (different from `process.cwd()`) AND `tools` - > See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts) ### Custom Tools @@ -556,6 +544,8 @@ Use `defineTool()` for standalone definitions and arrays like `customTools: [myT Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`. +If you pass `tools`, include each custom or extension tool name you want enabled, for example `tools: ["read", "bash", "my_tool"]`. + > See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts) ### Extensions @@ -885,12 +875,10 @@ import { getModel } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { AuthStorage, - bashTool, createAgentSession, DefaultResourceLoader, defineTool, ModelRegistry, - readTool, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent"; @@ -944,7 +932,7 @@ const { session } = await createAgentSession({ authStorage, modelRegistry, - tools: [readTool, bashTool], + tools: ["read", "bash", "status"], customTools: [statusTool], resourceLoader: loader, @@ -1123,13 +1111,7 @@ defineTool SessionManager SettingsManager -// Built-in tools (use process.cwd()) -codingTools -readOnlyTools -readTool, bashTool, editTool, writeTool -grepTool, findTool, lsTool - -// Tool factories (for custom cwd) +// Tool factories createCodingTools createReadOnlyTools createReadTool, createBashTool, createEditTool, createWriteTool diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 05b38fa1..befd4da3 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -153,9 +153,7 @@ When a provider requests a retry delay longer than `retry.provider.maxRetryDelay } ``` -`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers. - -Normally the package manager's global modules location is queried using `root -g`. As a special case, if the first element of `npmCommand` is `"bun"`, the modules location will instead be queried with `pm bin -g`. +`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers. ### Sessions diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 746d549e..cb96dd97 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -4,7 +4,7 @@ Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. -Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about violations but remaining lenient. +Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about most violations but remaining lenient. Pi allows skill names to differ from their parent directory even though the standard disallows it; that rule is suboptimal for shared skill directories used across multiple agent harnesses. ## Table of Contents @@ -140,7 +140,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm | Field | Required | Description | |-------|----------|-------------| -| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Must match parent directory. | +| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Unlike the standard, Pi does not require this to match the parent directory because that standard requirement is suboptimal for shared skill directories. | | `description` | Yes | Max 1024 chars. What the skill does and when to use it. | | `license` | No | License name or reference to bundled file. | | `compatibility` | No | Max 500 chars. Environment requirements. | @@ -154,7 +154,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm - Lowercase letters, numbers, hyphens only - No leading/trailing hyphens - No consecutive hyphens -- Must match parent directory name +Pi does not require the name to match the parent directory. The Agent Skills standard does, but that requirement is suboptimal for shared skill directories used by multiple tools. Valid: `pdf-processing`, `data-analysis`, `code-review` Invalid: `PDF-Processing`, `-pdf`, `pdf--processing` @@ -177,7 +177,6 @@ description: Helps with PDFs. Pi validates skills against the Agent Skills standard. Most issues produce warnings but still load the skill: -- Name doesn't match parent directory - Name exceeds 64 characters or contains invalid characters - Name starts/ends with hyphen or has consecutive hyphens - Description exceeds 1024 characters diff --git a/packages/coding-agent/docs/termux.md b/packages/coding-agent/docs/termux.md index cb16a283..aba6e292 100644 --- a/packages/coding-agent/docs/termux.md +++ b/packages/coding-agent/docs/termux.md @@ -17,7 +17,7 @@ pkg update && pkg upgrade pkg install nodejs termux-api git # Install pi -npm install -g @earendil-works/pi-coding-agent +npm install -g --ignore-scripts @earendil-works/pi-coding-agent # Create config directory mkdir -p ~/.pi/agent @@ -36,7 +36,7 @@ Image clipboard is not supported on Termux (the `ctrl+v` image paste feature wil Create `~/.pi/agent/AGENTS.md` to help the agent understand the Termux environment: -```markdown +````markdown # Agent Environment: Termux on Android ## Location @@ -91,7 +91,7 @@ termux-camera-photo out.jpg # Take photo - Termux:API app must be installed for `termux-*` commands - Use `pkg install termux-api` for the command-line tools - Storage permission needed for `/storage/emulated/0` access -``` +```` ## Limitations diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 487e72ac..65081090 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -137,6 +137,8 @@ pi list # List installed packages pi config # Enable/disable package resources ``` +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). + See [Pi Packages](packages.md) for package sources and security notes. ### Modes diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 0b479264..c78ae920 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.74.0", + "version": "0.75.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.74.0", + "version": "0.75.4", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 1c96be83..89aa420a 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.74.0", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", @@ -14,6 +14,6 @@ ] }, "dependencies": { - "@anthropic-ai/sdk": "^0.52.0" + "@anthropic-ai/sdk": "0.52.0" } } diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 3f5084d9..2db431cf 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.74.0", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/test.ts b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/test.ts index c7963732..0077aca8 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/test.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/test.ts @@ -12,7 +12,7 @@ import { type Api, type Context, type Model, registerApiProvider, streamSimple } import { readFileSync } from "fs"; import { getAgentDir } from "packages/coding-agent/src/config.js"; import { join } from "path"; -import { MODELS, streamGitLabDuo } from "./index.js"; +import { MODELS, streamGitLabDuo } from "./index.ts"; const MODEL_MAP = new Map(MODELS.map((m) => [m.id, m])); diff --git a/packages/coding-agent/examples/extensions/doom-overlay/doom-component.ts b/packages/coding-agent/examples/extensions/doom-overlay/doom-component.ts index dbc795b9..ddc2e98f 100644 --- a/packages/coding-agent/examples/extensions/doom-overlay/doom-component.ts +++ b/packages/coding-agent/examples/extensions/doom-overlay/doom-component.ts @@ -7,8 +7,8 @@ import type { Component } from "@earendil-works/pi-tui"; import { isKeyRelease, type TUI } from "@earendil-works/pi-tui"; -import type { DoomEngine } from "./doom-engine.js"; -import { DoomKeys, mapKeyToDoom } from "./doom-keys.js"; +import type { DoomEngine } from "./doom-engine.ts"; +import { DoomKeys, mapKeyToDoom } from "./doom-keys.ts"; function renderHalfBlock( rgba: Uint8Array, diff --git a/packages/coding-agent/examples/extensions/doom-overlay/index.ts b/packages/coding-agent/examples/extensions/doom-overlay/index.ts index 434f0be1..9d9dad13 100644 --- a/packages/coding-agent/examples/extensions/doom-overlay/index.ts +++ b/packages/coding-agent/examples/extensions/doom-overlay/index.ts @@ -10,9 +10,9 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { DoomOverlayComponent } from "./doom-component.js"; -import { DoomEngine } from "./doom-engine.js"; -import { ensureWadFile } from "./wad-finder.js"; +import { DoomOverlayComponent } from "./doom-component.ts"; +import { DoomEngine } from "./doom-engine.ts"; +import { ensureWadFile } from "./wad-finder.ts"; // Persistent engine instance - survives between invocations let activeEngine: DoomEngine | null = null; diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index b13f43a9..306c139b 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -303,7 +303,11 @@ function sleep(ms: number): Promise { // Base overlay component with common rendering abstract class BaseOverlay { - constructor(protected theme: Theme) {} + protected theme: Theme; + + constructor(theme: Theme) { + this.theme = theme; + } protected box(lines: string[], width: number, title?: string): string[] { const th = this.theme; @@ -330,12 +334,13 @@ abstract class BaseOverlay { // Anchor position test class AnchorTestComponent extends BaseOverlay { - constructor( - theme: Theme, - private anchor: OverlayAnchor, - private done: (result: "next" | "confirm" | "cancel") => void, - ) { + private anchor: OverlayAnchor; + private done: (result: "next" | "confirm" | "cancel") => void; + + constructor(theme: Theme, anchor: OverlayAnchor, done: (result: "next" | "confirm" | "cancel") => void) { super(theme); + this.anchor = anchor; + this.done = done; } handleInput(data: string): void { @@ -368,12 +373,17 @@ class AnchorTestComponent extends BaseOverlay { // Margin/offset test class MarginTestComponent extends BaseOverlay { + private config: { name: string; options: OverlayOptions }; + private done: (result: "next" | "close") => void; + constructor( theme: Theme, - private config: { name: string; options: OverlayOptions }, - private done: (result: "next" | "close") => void, + config: { name: string; options: OverlayOptions }, + done: (result: "next" | "close") => void, ) { super(theme); + this.config = config; + this.done = done; } handleInput(data: string): void { @@ -403,13 +413,15 @@ class MarginTestComponent extends BaseOverlay { // Stacked overlay test class StackOverlayComponent extends BaseOverlay { - constructor( - theme: Theme, - private num: number, - private position: string, - private done: (result: string) => void, - ) { + private num: number; + private position: string; + private done: (result: string) => void; + + constructor(theme: Theme, num: number, position: string, done: (result: string) => void) { super(theme); + this.num = num; + this.position = position; + this.done = done; } handleInput(data: string): void { @@ -446,19 +458,19 @@ class StackOverlayComponent extends BaseOverlay { // Streaming overflow test - spawns real process with colored output (original crash scenario) class StreamingOverflowComponent extends BaseOverlay { + private tui: TUI; private lines: string[] = []; private proc: ReturnType | null = null; private scrollOffset = 0; private maxVisibleLines = 15; private finished = false; private disposed = false; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; this.startProcess(); } @@ -579,11 +591,11 @@ class StreamingOverflowComponent extends BaseOverlay { // Edge position test class EdgeTestComponent extends BaseOverlay { - constructor( - theme: Theme, - private done: () => void, - ) { + private done: () => void; + + constructor(theme: Theme, done: () => void) { super(theme); + this.done = done; } handleInput(data: string): void { @@ -614,12 +626,17 @@ class EdgeTestComponent extends BaseOverlay { // Percentage positioning test class PercentTestComponent extends BaseOverlay { + private config: { name: string; row: number; col: number }; + private done: (result: "next" | "close") => void; + constructor( theme: Theme, - private config: { name: string; row: number; col: number }, - private done: (result: "next" | "close") => void, + config: { name: string; row: number; col: number }, + done: (result: "next" | "close") => void, ) { super(theme); + this.config = config; + this.done = done; } handleInput(data: string): void { @@ -649,11 +666,11 @@ class PercentTestComponent extends BaseOverlay { // MaxHeight test - renders 20 lines, truncated to 10 by maxHeight class MaxHeightTestComponent extends BaseOverlay { - constructor( - theme: Theme, - private done: () => void, - ) { + private done: () => void; + + constructor(theme: Theme, done: () => void) { super(theme); + this.done = done; } handleInput(data: string): void { @@ -684,15 +701,15 @@ class MaxHeightTestComponent extends BaseOverlay { // Responsive sidepanel - demonstrates percentage width and visibility callback class SidepanelComponent extends BaseOverlay { + private tui: TUI; private items = ["Dashboard", "Messages", "Settings", "Help", "About"]; private selectedIndex = 0; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; } handleInput(data: string): void { @@ -745,18 +762,18 @@ class SidepanelComponent extends BaseOverlay { // Animation demo - proves overlays can handle real-time updates like pi-doom class AnimationDemoComponent extends BaseOverlay { + private tui: TUI; private frame = 0; private interval: ReturnType | null = null; private fps = 0; private lastFpsUpdate = Date.now(); private framesSinceLastFps = 0; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; this.startAnimation(); } @@ -860,15 +877,15 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] { // Toggle demo - demonstrates OverlayHandle.setHidden() via onHandle callback class ToggleDemoComponent extends BaseOverlay { + private tui: TUI; private toggleCount = 0; private isToggling = false; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; } handleInput(data: string): void { @@ -923,19 +940,19 @@ class ToggleDemoComponent extends BaseOverlay { class PassiveDemoController extends BaseOverlay { focused = false; + private tui: TUI; private typed = ""; private timerComponent: TimerPanel; private timerHandle: OverlayHandle | null = null; private interval: ReturnType | null = null; private inputCount = 0; private lastInputDebug = ""; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; this.timerComponent = new TimerPanel(theme); this.timerHandle = this.tui.showOverlay(this.timerComponent, { nonCapturing: true, @@ -1015,16 +1032,16 @@ class TimerPanel extends BaseOverlay { // === Focus cycling demo === class FocusDemoController extends BaseOverlay { + private tui: TUI; private panels: FocusPanel[] = []; private handles: OverlayHandle[] = []; private focusIndex = -1; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; const colors = ["error", "success", "accent"] as const; const labels = ["Alpha", "Beta", "Gamma"]; @@ -1107,16 +1124,22 @@ class FocusDemoController extends BaseOverlay { class FocusPanel extends BaseOverlay { handle: OverlayHandle | null = null; readonly label: string; + private color: "error" | "success" | "accent"; + private onTab: () => void; + private onClose: () => void; constructor( theme: Theme, label: string, - private color: "error" | "success" | "accent", - private onTab: () => void, - private onClose: () => void, + color: "error" | "success" | "accent", + onTab: () => void, + onClose: () => void, ) { super(theme); this.label = label; + this.color = color; + this.onTab = onTab; + this.onClose = onClose; } handleInput(data: string): void { @@ -1155,19 +1178,19 @@ class FocusPanel extends BaseOverlay { // === Streaming input panel test (/overlay-streaming) === class StreamingInputController extends BaseOverlay { + private tui: TUI; private panels: StreamingInputPanel[] = []; private handles: OverlayHandle[] = []; private focusIndex = -1; // -1 = controller focused, 0-2 = panel focused private streamLines: string[] = []; private streamInterval: ReturnType | null = null; private lineCount = 0; + private done: () => void; - constructor( - private tui: TUI, - theme: Theme, - private done: () => void, - ) { + constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); + this.tui = tui; + this.done = done; // Create 3 input panels as non-capturing overlays const colors = ["error", "success", "accent"] as const; @@ -1287,17 +1310,25 @@ class StreamingInputController extends BaseOverlay { class StreamingInputPanel implements Component { handle: OverlayHandle | null = null; + private theme: Theme; private typed = ""; readonly label: string; + private color: "error" | "success" | "accent"; + private onTab: () => void; + private onClose: () => void; constructor( - private theme: Theme, + theme: Theme, label: string, - private color: "error" | "success" | "accent", - private onTab: () => void, - private onClose: () => void, + color: "error" | "success" | "accent", + onTab: () => void, + onClose: () => void, ) { + this.theme = theme; this.label = label; + this.color = color; + this.onTab = onTab; + this.onClose = onClose; } handleInput(data: string): void { diff --git a/packages/coding-agent/examples/extensions/overlay-test.ts b/packages/coding-agent/examples/extensions/overlay-test.ts index 02f8204b..c51cde1a 100644 --- a/packages/coding-agent/examples/extensions/overlay-test.ts +++ b/packages/coding-agent/examples/extensions/overlay-test.ts @@ -42,10 +42,13 @@ class OverlayTestComponent implements Focusable { { label: "Cancel", hasInput: false, text: "", cursor: 0 }, ]; - constructor( - private theme: Theme, - private done: (result: { action: string; query?: string } | undefined) => void, - ) {} + private theme: Theme; + private done: (result: { action: string; query?: string } | undefined) => void; + + constructor(theme: Theme, done: (result: { action: string; query?: string } | undefined) => void) { + this.theme = theme; + this.done = done; + } handleInput(data: string): void { if (matchesKey(data, "escape")) { diff --git a/packages/coding-agent/examples/extensions/plan-mode/index.ts b/packages/coding-agent/examples/extensions/plan-mode/index.ts index 709741d1..40db408c 100644 --- a/packages/coding-agent/examples/extensions/plan-mode/index.ts +++ b/packages/coding-agent/examples/extensions/plan-mode/index.ts @@ -16,7 +16,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Key } from "@earendil-works/pi-tui"; -import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js"; +import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts"; // Tools const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"]; diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 90ef1c2f..ef29b150 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.4.0", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.4.0", + "version": "1.5.4", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 688fe224..c33d2d60 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.4.0", + "version": "1.5.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", @@ -14,6 +14,6 @@ ] }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.26" + "@anthropic-ai/sandbox-runtime": "0.0.26" } } diff --git a/packages/coding-agent/examples/extensions/subagent/README.md b/packages/coding-agent/examples/extensions/subagent/README.md index 8599679f..f98b6458 100644 --- a/packages/coding-agent/examples/extensions/subagent/README.md +++ b/packages/coding-agent/examples/extensions/subagent/README.md @@ -113,6 +113,8 @@ Use a chain: first have scout find the read tool, then have planner suggest impr - Shows all tasks with live status (⏳ running, ✓ done, ✗ failed) - Updates as each task makes progress - Shows "2/3 done, 1 running" status +- Returns each completed task's final output to the parent model, capped at 50 KB per task +- Returns failure diagnostics from stderr/error messages when a child exits before producing output **Tool call formatting** (mimics built-in tools): - `$ command` for bash @@ -168,5 +170,6 @@ Project agents override user agents with the same name when `agentScope: "both"` ## Limitations - Output truncated to last 10 items in collapsed view (expand to see all) +- Parallel model-visible output is capped at 50 KB per task; full results remain in tool details - Agents discovered fresh on each invocation (allows editing mid-session) - Parallel mode limited to 8 tasks, 4 concurrent diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index a9b6da42..92b471a3 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -22,11 +22,12 @@ import { StringEnum } from "@earendil-works/pi-ai"; import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; -import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; +import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; const COLLAPSED_ITEM_COUNT = 10; +const PER_TASK_OUTPUT_CAP = 50 * 1024; function formatTokens(count: number): string { if (count < 1000) return count.toString(); @@ -172,6 +173,28 @@ function getFinalOutput(messages: Message[]): string { return ""; } +function isFailedResult(result: SingleResult): boolean { + return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; +} + +function getResultOutput(result: SingleResult): string { + if (isFailedResult(result)) { + return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + } + return getFinalOutput(result.messages) || "(no output)"; +} + +function truncateParallelOutput(output: string): string { + const byteLength = Buffer.byteLength(output, "utf8"); + if (byteLength <= PER_TASK_OUTPUT_CAP) return output; + + let truncated = output.slice(0, PER_TASK_OUTPUT_CAP); + while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) { + truncated = truncated.slice(0, -1); + } + return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`; +} + type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; function getDisplayItems(messages: Message[]): DisplayItem[] { @@ -534,11 +557,9 @@ export default function (pi: ExtensionAPI) { ); results.push(result); - const isError = - result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + const isError = isFailedResult(result); if (isError) { - const errorMsg = - result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + const errorMsg = getResultOutput(result); return { content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }], details: makeDetails("chain")(results), @@ -617,17 +638,19 @@ export default function (pi: ExtensionAPI) { return result; }); - const successCount = results.filter((r) => r.exitCode === 0).length; + const successCount = results.filter((r) => !isFailedResult(r)).length; const summaries = results.map((r) => { - const output = getFinalOutput(r.messages); - const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); - return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; + const output = truncateParallelOutput(getResultOutput(r)); + const status = isFailedResult(r) + ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}` + : "completed"; + return `### [${r.agent}] ${status}\n\n${output}`; }); return { content: [ { type: "text", - text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, + text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`, }, ], details: makeDetails("parallel")(results), @@ -646,10 +669,9 @@ export default function (pi: ExtensionAPI) { onUpdate, makeDetails("single"), ); - const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + const isError = isFailedResult(result); if (isError) { - const errorMsg = - result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + const errorMsg = getResultOutput(result); return { content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }], details: makeDetails("single")([result]), @@ -740,7 +762,7 @@ export default function (pi: ExtensionAPI) { if (details.mode === "single" && details.results.length === 1) { const r = details.results[0]; - const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; + const isError = isFailedResult(r); const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const displayItems = getDisplayItems(r.messages); const finalOutput = getFinalOutput(r.messages); @@ -893,8 +915,8 @@ export default function (pi: ExtensionAPI) { if (details.mode === "parallel") { const running = details.results.filter((r) => r.exitCode === -1).length; - const successCount = details.results.filter((r) => r.exitCode === 0).length; - const failCount = details.results.filter((r) => r.exitCode > 0).length; + const successCount = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length; + const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).length; const isRunning = running > 0; const icon = isRunning ? theme.fg("warning", "⏳") @@ -916,7 +938,7 @@ export default function (pi: ExtensionAPI) { ); for (const r of details.results) { - const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const rIcon = isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓"); const displayItems = getDisplayItems(r.messages); const finalOutput = getFinalOutput(r.messages); @@ -963,9 +985,9 @@ export default function (pi: ExtensionAPI) { const rIcon = r.exitCode === -1 ? theme.fg("warning", "⏳") - : r.exitCode === 0 - ? theme.fg("success", "✓") - : theme.fg("error", "✗"); + : isFailedResult(r) + ? theme.fg("error", "✗") + : theme.fg("success", "✓"); const displayItems = getDisplayItems(r.messages); text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`; if (displayItems.length === 0) diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 89eab8ac..526356f4 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.74.0", + "version": "0.75.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.74.0", + "version": "0.75.4", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 1a8fa142..45f1d3f2 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.74.0", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", @@ -14,9 +14,9 @@ ] }, "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.3" }, "devDependencies": { - "@types/ms": "^2.1.0" + "@types/ms": "2.1.0" } } diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts index be35bab2..a01f9c1a 100644 --- a/packages/coding-agent/examples/sdk/01-minimal.ts +++ b/packages/coding-agent/examples/sdk/01-minimal.ts @@ -9,14 +9,18 @@ import { createAgentSession } from "@earendil-works/pi-coding-agent"; const { session } = await createAgentSession(); -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); +try { + session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); -await session.prompt("What files are in the current directory?"); -session.state.messages.forEach((msg) => { - console.log(msg); -}); -console.log(); + await session.prompt("What files are in the current directory?"); + session.state.messages.forEach((msg) => { + console.log(msg); + }); + console.log(); +} finally { + session.dispose(); +} diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts index ff1b4287..531a51c8 100644 --- a/packages/coding-agent/examples/sdk/02-custom-model.ts +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -38,12 +38,16 @@ if (available.length > 0) { modelRegistry, }); - session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } - }); + try { + session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); - await session.prompt("Say hello in one sentence."); - console.log(); + await session.prompt("Say hello in one sentence."); + console.log(); + } finally { + session.dispose(); + } } diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts index 7c3ea5ff..ad1fdaa1 100644 --- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -30,15 +30,19 @@ const { session: session1 } = await createAgentSession({ sessionManager: SessionManager.inMemory(), }); -session1.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); +try { + session1.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); -console.log("=== Replace prompt ==="); -await session1.prompt("What is 2 + 2?"); -console.log("\n"); + console.log("=== Replace prompt ==="); + await session1.prompt("What is 2 + 2?"); + console.log("\n"); +} finally { + session1.dispose(); +} // Option 2: Append instructions to the default prompt const loader2 = new DefaultResourceLoader({ @@ -56,12 +60,16 @@ const { session: session2 } = await createAgentSession({ sessionManager: SessionManager.inMemory(), }); -session2.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); +try { + session2.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); -console.log("=== Modify prompt ==="); -await session2.prompt("List 3 benefits of TypeScript."); -console.log(); + console.log("=== Modify prompt ==="); + await session2.prompt("List 3 benefits of TypeScript."); + console.log(); +} finally { + session2.dispose(); +} diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 0aadc19e..3c0887cb 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -47,9 +47,9 @@ if (diagnostics.length > 0) { console.log("Warnings:", diagnostics); } -await createAgentSession({ +const { session } = await createAgentSession({ resourceLoader: loader, sessionManager: SessionManager.inMemory(), }); - console.log("Session created with filtered skills"); +session.dispose(); diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index 4a2e49ce..5f6f710d 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -13,32 +13,36 @@ import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; // Read-only mode (no edit/write) -await createAgentSession({ +const { session: readOnlySession } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], sessionManager: SessionManager.inMemory(), }); console.log("Read-only session created"); +readOnlySession.dispose(); // Custom tool selection -await createAgentSession({ +const { session: customToolsSession } = await createAgentSession({ tools: ["read", "bash", "grep"], sessionManager: SessionManager.inMemory(), }); console.log("Custom tools session created"); +customToolsSession.dispose(); // With custom cwd const customCwd = "/path/to/project"; -await createAgentSession({ +const { session: customCwdSession } = await createAgentSession({ cwd: customCwd, tools: ["read", "bash", "edit", "write"], sessionManager: SessionManager.inMemory(customCwd), }); console.log("Custom cwd session created"); +customCwdSession.dispose(); // Or pick specific tools for custom cwd -await createAgentSession({ +const { session: specificToolsSession } = await createAgentSession({ cwd: customCwd, tools: ["read", "bash", "grep"], sessionManager: SessionManager.inMemory(customCwd), }); console.log("Specific tools with custom cwd session created"); +specificToolsSession.dispose(); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index 8e55a80a..6a8e6a11 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -42,14 +42,18 @@ const { session } = await createAgentSession({ sessionManager: SessionManager.inMemory(), }); -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); +try { + session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); -await session.prompt("List files in the current directory."); -console.log(); + await session.prompt("List files in the current directory."); + console.log(); +} finally { + session.dispose(); +} // Example extension file (./my-logging-extension.ts): /* diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts index c8ce77a1..c97b4887 100644 --- a/packages/coding-agent/examples/sdk/07-context-files.ts +++ b/packages/coding-agent/examples/sdk/07-context-files.ts @@ -39,9 +39,9 @@ for (const file of discovered) { console.log(` - ${file.path} (${file.content.length} chars)`); } -await createAgentSession({ +const { session } = await createAgentSession({ resourceLoader: loader, sessionManager: SessionManager.inMemory(), }); - console.log(`Session created with ${discovered.length + 1} context files`); +session.dispose(); diff --git a/packages/coding-agent/examples/sdk/08-prompt-templates.ts b/packages/coding-agent/examples/sdk/08-prompt-templates.ts index 814e3230..b52de6ca 100644 --- a/packages/coding-agent/examples/sdk/08-prompt-templates.ts +++ b/packages/coding-agent/examples/sdk/08-prompt-templates.ts @@ -43,9 +43,9 @@ for (const template of discovered) { console.log(` /${template.name}: ${template.description}`); } -await createAgentSession({ +const { session } = await createAgentSession({ resourceLoader: loader, sessionManager: SessionManager.inMemory(), }); - console.log(`Session created with ${discovered.length + 1} prompt templates`); +session.dispose(); diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 127e5933..120662e8 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -11,38 +11,42 @@ import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from " const authStorage = AuthStorage.create(); const modelRegistry = ModelRegistry.create(authStorage); -await createAgentSession({ +const { session: defaultAuthSession } = await createAgentSession({ sessionManager: SessionManager.inMemory(), authStorage, modelRegistry, }); console.log("Session with default auth storage and model registry"); +defaultAuthSession.dispose(); // Custom auth storage location const customAuthStorage = AuthStorage.create("/tmp/my-app/auth.json"); const customModelRegistry = ModelRegistry.create(customAuthStorage, "/tmp/my-app/models.json"); -await createAgentSession({ +const { session: customAuthSession } = await createAgentSession({ sessionManager: SessionManager.inMemory(), authStorage: customAuthStorage, modelRegistry: customModelRegistry, }); console.log("Session with custom auth storage location"); +customAuthSession.dispose(); // Runtime API key override (not persisted to disk) authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key"); -await createAgentSession({ +const { session: runtimeKeySession } = await createAgentSession({ sessionManager: SessionManager.inMemory(), authStorage, modelRegistry, }); console.log("Session with runtime API key override"); +runtimeKeySession.dispose(); // No models.json - only built-in models const simpleRegistry = ModelRegistry.inMemory(authStorage); -await createAgentSession({ +const { session: builtInModelsSession } = await createAgentSession({ sessionManager: SessionManager.inMemory(), authStorage, modelRegistry: simpleRegistry, }); console.log("Session with only built-in models"); +builtInModelsSession.dispose(); diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index 9b6f93d8..93f7baf9 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -19,12 +19,12 @@ settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, }); -await createAgentSession({ +const { session: customSettingsSession } = await createAgentSession({ settingsManager, sessionManager: SessionManager.inMemory(), }); - console.log("Session created with custom settings"); +customSettingsSession.dispose(); // Setters update memory immediately and queue persistence writes. // Call flush() when you need a durability boundary. @@ -45,9 +45,9 @@ const inMemorySettings = SettingsManager.inMemory({ retry: { enabled: false }, }); -await createAgentSession({ +const { session: testSession } = await createAgentSession({ settingsManager: inMemorySettings, sessionManager: SessionManager.inMemory(), }); - console.log("Test session created with in-memory settings"); +testSession.dispose(); diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index 414db8b6..975a6bb1 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -11,12 +11,14 @@ const { session: inMemory } = await createAgentSession({ sessionManager: SessionManager.inMemory(), }); console.log("In-memory session:", inMemory.sessionFile ?? "(none)"); +inMemory.dispose(); // New persistent session const { session: newSession } = await createAgentSession({ sessionManager: SessionManager.create(process.cwd()), }); console.log("New session file:", newSession.sessionFile); +newSession.dispose(); // Continue most recent session (or create new if none) const { session: continued, modelFallbackMessage } = await createAgentSession({ @@ -24,6 +26,7 @@ const { session: continued, modelFallbackMessage } = await createAgentSession({ }); if (modelFallbackMessage) console.log("Note:", modelFallbackMessage); console.log("Continued session:", continued.sessionFile); +continued.dispose(); // List and open specific session const sessions = await SessionManager.list(process.cwd()); @@ -37,6 +40,7 @@ if (sessions.length > 0) { sessionManager: SessionManager.open(sessions[0].path), }); console.log(`\nOpened: ${opened.sessionId}`); + opened.dispose(); } // Custom session directory (no cwd encoding) diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 4bcc0726..cd8ba343 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -63,11 +63,15 @@ const { session } = await createAgentSession({ settingsManager, }); -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); +try { + session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } + }); -await session.prompt("List files in the current directory."); -console.log(); + await session.prompt("List files in the current directory."); + console.log(); +} finally { + session.dispose(); +} diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index 0fd95cf0..a8011aae 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -12,7 +12,7 @@ The runtime example shows how to build a recreate function that closes over proc | `02-custom-model.ts` | Select model and thinking level | | `03-custom-prompt.ts` | Replace or modify system prompt | | `04-skills.ts` | Discover, filter, or replace skills | -| `05-tools.ts` | Built-in tools, custom tools | +| `05-tools.ts` | Built-in tool allowlists | | `06-extensions.ts` | Logging, blocking, result modification | | `07-context-files.ts` | AGENTS.md context files | | `08-slash-commands.ts` | File-based slash commands | @@ -40,9 +40,6 @@ import { ModelRegistry, SessionManager, SettingsManager, - codingTools, - readOnlyTools, - readTool, bashTool, editTool, writeTool, } from "@earendil-works/pi-coding-agent"; // Auth and models setup @@ -64,7 +61,7 @@ await loader.reload(); const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry }); // Read-only -const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry }); +const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], authStorage, modelRegistry }); // In-memory const { session } = await createAgentSession({ @@ -92,8 +89,8 @@ const { session } = await createAgentSession({ authStorage: customAuth, modelRegistry: customRegistry, resourceLoader, - tools: [readTool, bashTool], - customTools: [{ tool: myTool }], + tools: ["read", "bash", "my_tool"], + customTools: [myTool], sessionManager: SessionManager.inMemory(), settingsManager: SettingsManager.inMemory(), }); @@ -117,7 +114,7 @@ await session.prompt("Hello"); | `agentDir` | `~/.pi/agent` | Config directory | | `model` | From settings/first available | Model to use | | `thinkingLevel` | From settings/"off" | off, low, medium, high | -| `tools` | `codingTools` | Built-in tools | +| `tools` | `["read", "bash", "edit", "write"]` built-ins | Allowlist tool names across built-in, extension, and custom tools | | `customTools` | `[]` | Additional tool definitions | | `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes | | `sessionManager` | `SessionManager.create(cwd)` | Persistence | diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json new file mode 100644 index 00000000..85a22b1a --- /dev/null +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -0,0 +1,1789 @@ +{ + "name": "@earendil-works/pi-coding-agent", + "version": "0.75.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@earendil-works/pi-coding-agent", + "version": "0.75.4", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.6" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.75.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "engines": { + "node": ">=20.0.0" + }, + "hasInstallScript": true + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "license": "MIT", + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + }, + "engines": { + "node": ">= 10" + }, + "optional": true + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ] + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ] + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "license": "MIT", + "engines": { + "node": ">=10.5.0" + }, + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ] + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "bin": { + "openai": "bin/cli" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "hasInstallScript": true + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index a046a3c9..0c19d304 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.74.0", + "version": "0.75.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -25,40 +25,37 @@ "dist", "docs", "examples", - "CHANGELOG.md" + "CHANGELOG.md", + "npm-shrinkwrap.json" ], "scripts": { "clean": "shx rm -rf dist", - "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", - "prepublishOnly": "npm run clean && npm run build" + "shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs", + "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.74.0", - "@earendil-works/pi-ai": "^0.74.0", - "@earendil-works/pi-tui": "^0.74.0", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", - "diff": "^8.0.2", - "extract-zip": "^2.0.1", - "file-type": "^21.1.1", - "glob": "^13.0.1", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "jiti": "^2.7.0", - "marked": "^15.0.12", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "strip-ansi": "^7.1.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "uuid": "^14.0.0", - "yaml": "^2.8.2" + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" }, "overrides": { "rimraf": "6.1.2", @@ -67,17 +64,18 @@ } }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.5" + "@mariozechner/clipboard": "0.3.6" }, "devDependencies": { - "@types/diff": "^7.0.2", - "@types/hosted-git-info": "^3.0.5", - "@types/ms": "^2.1.0", - "@types/node": "^24.3.0", - "@types/proper-lockfile": "^4.1.4", - "shx": "^0.4.0", - "typescript": "^5.7.3", - "vitest": "^3.2.4" + "@types/cross-spawn": "6.0.6", + "@types/diff": "7.0.2", + "@types/hosted-git-info": "3.0.5", + "@types/ms": "2.1.0", + "@types/node": "24.12.4", + "@types/proper-lockfile": "4.1.4", + "shx": "0.4.0", + "typescript": "5.9.3", + "vitest": "3.2.4" }, "keywords": [ "coding-agent", @@ -95,6 +93,6 @@ "directory": "packages/coding-agent" }, "engines": { - "node": ">=20.6.0" + "node": ">=22.19.0" } } diff --git a/packages/coding-agent/src/bun/cli.ts b/packages/coding-agent/src/bun/cli.ts index 0aacb95b..fa68a0ba 100644 --- a/packages/coding-agent/src/bun/cli.ts +++ b/packages/coding-agent/src/bun/cli.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { APP_NAME } from "../config.js"; +import { APP_NAME } from "../config.ts"; process.title = APP_NAME; process.emitWarning = (() => {}) as typeof process.emitWarning; -import { restoreSandboxEnv } from "./restore-sandbox-env.js"; +import { restoreSandboxEnv } from "./restore-sandbox-env.ts"; restoreSandboxEnv(); -await import("./register-bedrock.js"); -await import("../cli.js"); +await import("./register-bedrock.ts"); +await import("../cli.ts"); diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 6e0e8403..b0b11ca5 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -5,18 +5,16 @@ * * Test with: npx tsx src/cli-new.ts [args...] */ -import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; -import { APP_NAME } from "./config.js"; -import { main } from "./main.js"; +import { APP_NAME } from "./config.ts"; +import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; +import { main } from "./main.ts"; process.title = APP_NAME; process.env.PI_CODING_AGENT = "true"; process.emitWarning = (() => {}) as typeof process.emitWarning; -// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls -// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream -// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own -// AbortController-based deadlines via retry.provider.timeoutMs. -setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 })); +// Configure undici's global dispatcher before provider SDKs issue requests. +// Runtime settings are applied once SettingsManager has loaded global/project settings. +configureHttpDispatcher(); main(process.argv.slice(2)); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 7d91a9be..e4cb27dd 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -4,8 +4,8 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import chalk from "chalk"; -import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.js"; -import type { ExtensionFlag } from "../core/extensions/types.js"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; +import type { ExtensionFlag } from "../core/extensions/types.ts"; export type Mode = "text" | "json" | "rpc"; diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index cfd82583..74265a10 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -3,10 +3,10 @@ */ import { ProcessTerminal, TUI } from "@earendil-works/pi-tui"; -import type { ResolvedPaths } from "../core/package-manager.js"; -import type { SettingsManager } from "../core/settings-manager.js"; -import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.js"; -import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.js"; +import type { ResolvedPaths } from "../core/package-manager.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.ts"; +import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts"; export interface ConfigSelectorOptions { resolvedPaths: ResolvedPaths; @@ -43,6 +43,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise ui.requestRender(), + ui.terminal.rows, ); ui.addChild(selector); diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index c9ab5495..fe0e32eb 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -6,9 +6,9 @@ import { access, readFile, stat } from "node:fs/promises"; import type { ImageContent } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { resolve } from "path"; -import { resolveReadPath } from "../core/tools/path-utils.js"; -import { formatDimensionNote, resizeImage } from "../utils/image-resize.js"; -import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js"; +import { resolveReadPath } from "../core/tools/path-utils.ts"; +import { formatDimensionNote, resizeImage } from "../utils/image-resize.ts"; +import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts"; export interface ProcessedFiles { text: string; diff --git a/packages/coding-agent/src/cli/initial-message.ts b/packages/coding-agent/src/cli/initial-message.ts index a3a7f1b7..c73f18a2 100644 --- a/packages/coding-agent/src/cli/initial-message.ts +++ b/packages/coding-agent/src/cli/initial-message.ts @@ -1,5 +1,5 @@ import type { ImageContent } from "@earendil-works/pi-ai"; -import type { Args } from "./args.js"; +import type { Args } from "./args.ts"; export interface InitialMessageInput { parsed: Args; diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index 7d538573..b648fb96 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -5,8 +5,8 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { fuzzyFilter } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { formatNoModelsAvailableMessage } from "../core/auth-guidance.js"; -import type { ModelRegistry } from "../core/model-registry.js"; +import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts"; +import type { ModelRegistry } from "../core/model-registry.ts"; /** * Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M") diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index f84b6a08..42dcb808 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -3,9 +3,9 @@ */ import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; -import { KeybindingsManager } from "../core/keybindings.js"; -import type { SessionInfo, SessionListProgress } from "../core/session-manager.js"; -import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts"; +import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts"; type SessionsLoader = (onProgress?: SessionListProgress) => Promise; diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index bf268a77..dd091804 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,9 +1,9 @@ -import { spawnSync } from "child_process"; import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; -import { shouldUseWindowsShell } from "./utils/child-process.js"; +import { spawnProcessSync } from "./utils/child-process.ts"; +import { normalizePath } from "./utils/paths.ts"; // ============================================================================= // Package Detection @@ -111,21 +111,21 @@ function getSelfUpdateCommandForMethod( return undefined; case "pnpm": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("pnpm", ["install", "-g", updatePackageName]), + makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), ); case "yarn": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("yarn", ["global", "add", updatePackageName]), + makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]), ); case "bun": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("bun", ["install", "-g", updatePackageName]), + makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), @@ -134,7 +134,13 @@ function getSelfUpdateCommandForMethod( const [command = "npm", ...npmArgs] = npmCommand ?? []; const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(); const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])]; - const installStep = makeSelfUpdateCommandStep(command, [...prefixArgs, "install", "-g", updatePackageName]); + const installStep = makeSelfUpdateCommandStep(command, [ + ...prefixArgs, + "install", + "-g", + "--ignore-scripts", + updatePackageName, + ]); const uninstallStep = updatePackageName === installedPackageName ? undefined @@ -151,10 +157,9 @@ function readCommandOutput( args: string[], options: { requireSuccess?: boolean } = {}, ): string | undefined { - const result = spawnSync(command, args, { + const result = spawnProcessSync(command, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], - shell: shouldUseWindowsShell(command), }); if (result.status === 0) return result.stdout.trim() || undefined; if (options.requireSuccess) { @@ -207,16 +212,18 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC } } -function normalizeExistingPathForComparison(path: string): string | undefined { +function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined { const resolvedPath = resolve(path); if (!existsSync(resolvedPath)) { return undefined; } - let normalizedPath: string; - try { - normalizedPath = realpathSync(resolvedPath); - } catch { - return undefined; + let normalizedPath = resolvedPath; + if (resolveSymlinks) { + try { + normalizedPath = realpathSync(resolvedPath); + } catch { + return undefined; + } } if (process.platform === "win32") { normalizedPath = normalizedPath.toLowerCase(); @@ -224,6 +231,29 @@ function normalizeExistingPathForComparison(path: string): string | undefined { return normalizedPath; } +function getPathComparisonCandidates(path: string): string[] { + return Array.from( + new Set( + [normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter( + (candidate): candidate is string => !!candidate, + ), + ), + ); +} + +function getEntrypointPackageDir(): string | undefined { + const entrypoint = process.argv[1]; + if (!entrypoint) return undefined; + let dir = dirname(entrypoint); + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + return dir; + } + dir = dirname(dir); + } + return undefined; +} + function isSelfUpdatePathWritable(): boolean { const packageDir = getPackageDir(); try { @@ -236,17 +266,14 @@ function isSelfUpdatePathWritable(): boolean { } function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean { - const packageDir = normalizeExistingPathForComparison(getPackageDir()); - return ( - !!packageDir && - getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { - const normalizedRoot = normalizeExistingPathForComparison(root); - return ( - !!normalizedRoot && - packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`) - ); - }) - ); + const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir); + const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir)); + return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { + return getPathComparisonCandidates(root).some((normalizedRoot) => { + const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix)); + }); + }); } export function getSelfUpdateCommand( @@ -304,9 +331,7 @@ export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { - if (envDir === "~") return homedir(); - if (envDir.startsWith("~/")) return homedir() + envDir.slice(1); - return envDir; + return normalizePath(envDir); } if (isBunBinary) { @@ -428,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; export function expandTildePath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); - return path; + return normalizePath(path); } const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index b64def1b..baab11b0 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -1,12 +1,13 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; -import type { AgentSession } from "./agent-session.js"; -import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js"; -import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.js"; -import { emitSessionShutdownEvent } from "./extensions/runner.js"; -import type { CreateAgentSessionResult } from "./sdk.js"; -import { assertSessionCwdExists } from "./session-cwd.js"; -import { SessionManager } from "./session-manager.js"; +import { resolvePath } from "../utils/paths.ts"; +import type { AgentSession } from "./agent-session.ts"; +import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; +import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts"; +import { emitSessionShutdownEvent } from "./extensions/runner.ts"; +import type { CreateAgentSessionResult } from "./sdk.ts"; +import { assertSessionCwdExists } from "./session-cwd.ts"; +import { SessionManager } from "./session-manager.ts"; /** * Result returned by runtime creation. @@ -67,14 +68,25 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s export class AgentSessionRuntime { private rebindSession?: (session: AgentSession) => Promise; private beforeSessionInvalidate?: () => void; + private _session: AgentSession; + private _services: AgentSessionServices; + private readonly createRuntime: CreateAgentSessionRuntimeFactory; + private _diagnostics: AgentSessionRuntimeDiagnostic[]; + private _modelFallbackMessage?: string; constructor( - private _session: AgentSession, - private _services: AgentSessionServices, - private readonly createRuntime: CreateAgentSessionRuntimeFactory, - private _diagnostics: AgentSessionRuntimeDiagnostic[] = [], - private _modelFallbackMessage?: string, - ) {} + _session: AgentSession, + _services: AgentSessionServices, + createRuntime: CreateAgentSessionRuntimeFactory, + _diagnostics: AgentSessionRuntimeDiagnostic[] = [], + _modelFallbackMessage?: string, + ) { + this._session = _session; + this._services = _services; + this.createRuntime = createRuntime; + this._diagnostics = _diagnostics; + this._modelFallbackMessage = _modelFallbackMessage; + } get services(): AgentSessionServices { return this._services; @@ -281,12 +293,11 @@ export class AgentSessionRuntime { return { cancelled: false, selectedText }; } - const sourceManager = SessionManager.open(currentSessionFile, sessionDir); - const forkedSessionPath = sourceManager.createBranchedSession(targetLeafId); + const sessionManager = SessionManager.open(currentSessionFile, sessionDir); + const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId); if (!forkedSessionPath) { throw new Error("Failed to create forked session"); } - const sessionManager = SessionManager.open(forkedSessionPath, sessionDir); await this.teardownCurrent("fork", sessionManager.getSessionFile()); this.apply( await this.createRuntime({ @@ -327,7 +338,7 @@ export class AgentSessionRuntime { * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. */ async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { - const resolvedPath = resolve(inputPath); + const resolvedPath = resolvePath(inputPath); if (!existsSync(resolvedPath)) { throw new SessionImportFileNotFoundError(resolvedPath); } @@ -406,4 +417,4 @@ export { type CreateAgentSessionServicesOptions, createAgentSessionFromServices, createAgentSessionServices, -} from "./agent-session-services.js"; +} from "./agent-session-services.ts"; diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 061399ed..adf6f9e0 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -1,14 +1,15 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; -import { getAgentDir } from "../config.js"; -import { AuthStorage } from "./auth-storage.js"; -import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; -import { ModelRegistry } from "./model-registry.js"; -import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js"; -import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.js"; -import type { SessionManager } from "./session-manager.js"; -import { SettingsManager } from "./settings-manager.js"; +import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { AuthStorage } from "./auth-storage.ts"; +import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { ModelRegistry } from "./model-registry.ts"; +import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts"; +import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts"; +import type { SessionManager } from "./session-manager.ts"; +import { SettingsManager } from "./settings-manager.ts"; /** * Non-fatal issues collected while creating services or sessions. @@ -129,8 +130,8 @@ function applyExtensionFlagValues( export async function createAgentSessionServices( options: CreateAgentSessionServicesOptions, ): Promise { - const cwd = options.cwd; - const agentDir = options.agentDir ?? getAgentDir(); + const cwd = resolvePath(options.cwd); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 3f08569f..b52ab910 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname } from "node:path"; import type { Agent, AgentEvent, @@ -31,12 +31,14 @@ import { isContextOverflow, modelsAreEqual, resetApiProviders, + streamSimple, } from "@earendil-works/pi-ai"; -import { theme } from "../modes/interactive/theme/theme.js"; -import { stripFrontmatter } from "../utils/frontmatter.js"; -import { sleep } from "../utils/sleep.js"; -import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js"; -import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; +import { theme } from "../modes/interactive/theme/theme.ts"; +import { stripFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { sleep } from "../utils/sleep.ts"; +import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; +import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { type CompactionResult, calculateContextTokens, @@ -46,10 +48,10 @@ import { generateBranchSummary, prepareCompaction, shouldCompact, -} from "./compaction/index.js"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; -import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js"; -import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; +} from "./compaction/index.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; +import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { type ContextUsage, type ExtensionCommandContextActions, @@ -74,21 +76,21 @@ import { type TurnEndEvent, type TurnStartEvent, wrapRegisteredTools, -} from "./extensions/index.js"; -import { emitSessionShutdownEvent } from "./extensions/runner.js"; -import type { BashExecutionMessage, CustomMessage } from "./messages.js"; -import type { ModelRegistry } from "./model-registry.js"; -import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; -import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js"; -import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; -import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; -import type { SettingsManager } from "./settings-manager.js"; -import type { SlashCommandInfo } from "./slash-commands.js"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; -import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.js"; -import { type BashOperations, createLocalBashOperations } from "./tools/bash.js"; -import { createAllToolDefinitions } from "./tools/index.js"; -import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; +} from "./extensions/index.ts"; +import { emitSessionShutdownEvent } from "./extensions/runner.ts"; +import type { BashExecutionMessage, CustomMessage } from "./messages.ts"; +import type { ModelRegistry } from "./model-registry.ts"; +import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; +import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; +import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts"; +import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; +import type { SettingsManager } from "./settings-manager.ts"; +import type { SlashCommandInfo } from "./slash-commands.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; +import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; +import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; +import { createAllToolDefinitions } from "./tools/index.ts"; +import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; // ============================================================================ // Skill Block Parsing @@ -119,7 +121,12 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null { /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = - | AgentEvent + | Exclude + | { + type: "agent_end"; + messages: AgentMessage[]; + willRetry: boolean; + } | { type: "queue_update"; steering: readonly string[]; @@ -179,6 +186,7 @@ export interface AgentSessionConfig { export interface ExtensionBindings { uiContext?: ExtensionUIContext; commandContextActions?: ExtensionCommandContextActions; + abortHandler?: () => void; shutdownHandler?: ShutdownHandler; onError?: ExtensionErrorListener; } @@ -251,7 +259,6 @@ export class AgentSession { // Event subscription state private _unsubscribeAgent?: () => void; private _eventListeners: AgentSessionEventListener[] = []; - private _agentEventQueue: Promise = Promise.resolve(); /** Tracks pending steering messages for UI display. Removed when delivered. */ private _steeringMessages: string[] = []; @@ -271,8 +278,6 @@ export class AgentSession { // Retry state private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; - private _retryPromise: Promise | undefined = undefined; - private _retryResolve: (() => void) | undefined = undefined; // Bash execution state private _bashAbortController: AbortController | undefined = undefined; @@ -293,6 +298,7 @@ export class AgentSession { private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; private _extensionCommandContextActions?: ExtensionCommandContextActions; + private _extensionAbortHandler?: () => void; private _extensionShutdownHandler?: ShutdownHandler; private _extensionErrorListener?: ExtensionErrorListener; private _extensionErrorUnsubscriber?: () => void; @@ -367,6 +373,18 @@ export class AgentSession { throw new Error(formatNoApiKeyFoundMessage(model.provider)); } + private async _getCompactionRequestAuth(model: Model): Promise<{ + apiKey?: string; + headers?: Record; + }> { + if (this.agent.streamFn === streamSimple) { + return this._getRequiredRequestAuth(model); + } + + const result = await this._modelRegistry.getApiKeyAndHeaders(model); + return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {}; + } + /** * Install tool hooks once on the Agent instance. * @@ -382,8 +400,6 @@ export class AgentSession { return undefined; } - await this._agentEventQueue; - try { return await runner.emitToolCall({ type: "tool_call", @@ -450,54 +466,7 @@ export class AgentSession { private _lastAssistantMessage: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ - private _handleAgentEvent = (event: AgentEvent): void => { - // Create retry promise synchronously before queueing async processing. - // Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry() - // as soon as agent.prompt() resolves. If _retryPromise is created only inside - // _processAgentEvent, slow earlier queued events can delay agent_end processing - // and waitForRetry() can miss the in-flight retry. - this._createRetryPromiseForAgentEnd(event); - - this._agentEventQueue = this._agentEventQueue.then( - () => this._processAgentEvent(event), - () => this._processAgentEvent(event), - ); - - // Keep queue alive if an event handler fails - this._agentEventQueue.catch(() => {}); - }; - - private _createRetryPromiseForAgentEnd(event: AgentEvent): void { - if (event.type !== "agent_end" || this._retryPromise) { - return; - } - - const settings = this.settingsManager.getRetrySettings(); - if (!settings.enabled) { - return; - } - - const lastAssistant = this._findLastAssistantInMessages(event.messages); - if (!lastAssistant || !this._isRetryableError(lastAssistant)) { - return; - } - - this._retryPromise = new Promise((resolve) => { - this._retryResolve = resolve; - }); - } - - private _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.role === "assistant") { - return message as AssistantMessage; - } - } - return undefined; - } - - private async _processAgentEvent(event: AgentEvent): Promise { + private _handleAgentEvent = async (event: AgentEvent): Promise => { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { @@ -524,7 +493,7 @@ export class AgentSession { await this._emitExtensionEvent(event); // Notify all listeners - this._emit(event); + this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); // Handle session persistence if (event.type === "message_end") { @@ -568,30 +537,21 @@ export class AgentSession { } } } + }; - // Check auto-retry and auto-compaction after agent completes - if (event.type === "agent_end" && this._lastAssistantMessage) { - const msg = this._lastAssistantMessage; - this._lastAssistantMessage = undefined; + private _willRetryAfterAgentEnd(event: Extract): boolean { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled || this._retryAttempt >= settings.maxRetries) { + return false; + } - // Check for retryable errors first (overloaded, rate limit, server errors) - if (this._isRetryableError(msg)) { - const didRetry = await this._handleRetryableError(msg); - if (didRetry) return; // Retry was initiated, don't proceed to compaction + for (let i = event.messages.length - 1; i >= 0; i--) { + const message = event.messages[i]; + if (message.role === "assistant") { + return this._isRetryableError(message as AssistantMessage); } - - this._resolveRetry(); - await this._checkCompaction(msg); - } - } - - /** Resolve the pending retry promise */ - private _resolveRetry(): void { - if (this._retryResolve) { - this._retryResolve(); - this._retryResolve = undefined; - this._retryPromise = undefined; } + return false; } /** Extract text content from a message */ @@ -617,7 +577,7 @@ export class AgentSession { private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void { // Agent-core stores the finalized message object in its state before emitting message_end. - // SessionManager persistence happens later in _processAgentEvent() with event.message. + // SessionManager persistence happens later in _handleAgentEvent() with event.message. // Mutating this object in place keeps agent state, later turn/agent events, listeners, // and the eventual SessionManager.appendMessage(event.message) persistence in sync. if (target === replacement) { @@ -963,6 +923,41 @@ export class AgentSession { // Prompting // ========================================================================= + private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + try { + await this.agent.prompt(messages); + while (await this._handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this._flushPendingBashMessages(); + } + } + + private async _handlePostAgentRun(): Promise { + const msg = this._lastAssistantMessage; + this._lastAssistantMessage = undefined; + if (!msg) { + return false; + } + + if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) { + return true; + } + + if (msg.stopReason === "error" && this._retryAttempt > 0) { + this._emit({ + type: "auto_retry_end", + success: false, + attempt: this._retryAttempt, + finalError: msg.errorMessage, + }); + this._retryAttempt = 0; + } + + return await this._checkCompaction(msg); + } + /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming @@ -1053,8 +1048,15 @@ export class AgentSession { // Check if we need to compact before sending (catches aborted responses) const lastAssistant = this._findLastAssistantMessage(); - if (lastAssistant) { - await this._checkCompaction(lastAssistant, false); + if (lastAssistant && (await this._checkCompaction(lastAssistant, false))) { + try { + await this.agent.continue(); + while (await this._handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this._flushPendingBashMessages(); + } } // Build messages array (custom message if any, then user message) @@ -1114,8 +1116,7 @@ export class AgentSession { } preflightResult?.(true); - await this.agent.prompt(messages); - await this.waitForRetry(); + await this._runAgentPrompt(messages); } /** @@ -1301,7 +1302,7 @@ export class AgentSession { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { - await this.agent.prompt(appMessage); + await this._runAgentPrompt(appMessage); } else { this.agent.state.messages.push(appMessage); this.sessionManager.appendCustomMessageEntry( @@ -1626,7 +1627,7 @@ export class AgentSession { throw new Error(formatNoModelSelectedMessage()); } - const { apiKey, headers } = await this._getRequiredRequestAuth(this.model); + const { apiKey, headers } = await this._getCompactionRequestAuth(this.model); const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); @@ -1684,6 +1685,7 @@ export class AgentSession { customInstructions, this._compactionAbortController.signal, this.thinkingLevel, + this.agent.streamFn, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -1771,12 +1773,12 @@ export class AgentSession { * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true */ - private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { + private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(); - if (!settings.enabled) return; + if (!settings.enabled) return false; // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false - if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return; + if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; const contextWindow = this.model?.contextWindow ?? 0; @@ -1794,7 +1796,7 @@ export class AgentSession { const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); if (assistantIsFromBeforeCompaction) { - return; + return false; } // Case 1: Overflow - LLM returned context overflow error @@ -1809,7 +1811,7 @@ export class AgentSession { errorMessage: "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", }); - return; + return false; } this._overflowRecoveryAttempted = true; @@ -1819,8 +1821,7 @@ export class AgentSession { if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); } - await this._runAutoCompaction("overflow", true); - return; + return await this._runAutoCompaction("overflow", true); } // Case 2: Threshold - context is getting large @@ -1830,7 +1831,7 @@ export class AgentSession { if (assistantMessage.stopReason === "error") { const messages = this.agent.state.messages; const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex === null) return; // No usage data at all + if (estimate.lastUsageIndex === null) return false; // No usage data at all // Verify the usage source is post-compaction. Kept pre-compaction messages // have stale usage reflecting the old (larger) context and would falsely // trigger compaction right after one just finished. @@ -1840,21 +1841,22 @@ export class AgentSession { usageMsg.role === "assistant" && (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() ) { - return; + return false; } contextTokens = estimate.tokens; } else { contextTokens = calculateContextTokens(assistantMessage.usage); } if (shouldCompact(contextTokens, contextWindow, settings)) { - await this._runAutoCompaction("threshold", false); + return await this._runAutoCompaction("threshold", false); } + return false; } /** * Internal: Run auto-compaction with events. */ - private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { + private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); this._emit({ type: "compaction_start", reason }); @@ -1869,21 +1871,28 @@ export class AgentSession { aborted: false, willRetry: false, }); - return; + return false; } - const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); - if (!authResult.ok || !authResult.apiKey) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); - return; + let apiKey: string | undefined; + let headers: Record | undefined; + if (this.agent.streamFn === streamSimple) { + const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); + if (!authResult.ok || !authResult.apiKey) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + }); + return false; + } + apiKey = authResult.apiKey; + headers = authResult.headers; + } else { + ({ apiKey, headers } = await this._getCompactionRequestAuth(this.model)); } - const { apiKey, headers } = authResult; const pathEntries = this.sessionManager.getBranch(); @@ -1896,7 +1905,7 @@ export class AgentSession { aborted: false, willRetry: false, }); - return; + return false; } let extensionCompaction: CompactionResult | undefined; @@ -1919,7 +1928,7 @@ export class AgentSession { aborted: true, willRetry: false, }); - return; + return false; } if (extensionResult?.compaction) { @@ -1949,6 +1958,7 @@ export class AgentSession { undefined, this._autoCompactionAbortController.signal, this.thinkingLevel, + this.agent.streamFn, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -1964,7 +1974,7 @@ export class AgentSession { aborted: true, willRetry: false, }); - return; + return false; } this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); @@ -1999,17 +2009,12 @@ export class AgentSession { if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { this.agent.state.messages = messages.slice(0, -1); } - - setTimeout(() => { - this.agent.continue().catch(() => {}); - }, 100); - } else if (this.agent.hasQueuedMessages()) { - // Auto-compaction can complete while follow-up/steering/custom messages are waiting. - // Kick the loop so queued messages are actually delivered. - setTimeout(() => { - this.agent.continue().catch(() => {}); - }, 100); + return true; } + + // Auto-compaction can complete while follow-up/steering/custom messages are waiting. + // Continue once so queued messages are delivered. + return this.agent.hasQueuedMessages(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; this._emit({ @@ -2023,6 +2028,7 @@ export class AgentSession { ? `Context overflow recovery failed: ${errorMessage}` : `Auto-compaction failed: ${errorMessage}`, }); + return false; } finally { this._autoCompactionAbortController = undefined; } @@ -2047,6 +2053,9 @@ export class AgentSession { if (bindings.commandContextActions !== undefined) { this._extensionCommandContextActions = bindings.commandContextActions; } + if (bindings.abortHandler !== undefined) { + this._extensionAbortHandler = bindings.abortHandler; + } if (bindings.shutdownHandler !== undefined) { this._extensionShutdownHandler = bindings.shutdownHandler; } @@ -2211,7 +2220,13 @@ export class AgentSession { getModel: () => this.model, isIdle: () => !this.isStreaming, getSignal: () => this.agent.signal, - abort: () => this.abort(), + abort: () => { + if (this._extensionAbortHandler) { + this._extensionAbortHandler(); + return; + } + void this.abort(); + }, hasPendingMessages: () => this.pendingMessageCount > 0, shutdown: () => { this._extensionShutdownHandler?.(); @@ -2427,43 +2442,27 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; - // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, request ended without sending chunks, HTTP/2 closed before response, terminated, retry delay exceeded - return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( + // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded + return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( err, ); } /** - * Handle retryable errors with exponential backoff. - * @returns true if retry was initiated, false if max retries exceeded or disabled + * Prepare a retryable error for continuation with exponential backoff. + * @returns true if the caller should continue the agent, false otherwise */ - private async _handleRetryableError(message: AssistantMessage): Promise { + private async _prepareRetry(message: AssistantMessage): Promise { const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled) { - this._resolveRetry(); return false; } - // Retry promise is created synchronously in _handleAgentEvent for agent_end. - // Keep a defensive fallback here in case a future refactor bypasses that path. - if (!this._retryPromise) { - this._retryPromise = new Promise((resolve) => { - this._retryResolve = resolve; - }); - } - this._retryAttempt++; if (this._retryAttempt > settings.maxRetries) { - // Max retries exceeded, emit final failure and reset - this._emit({ - type: "auto_retry_end", - success: false, - attempt: this._retryAttempt - 1, - finalError: message.errorMessage, - }); - this._retryAttempt = 0; - this._resolveRetry(); // Resolve so waitForRetry() completes + // Preserve the completed attempt count so post-run handling can emit the final failure. + this._retryAttempt--; return false; } @@ -2491,24 +2490,16 @@ export class AgentSession { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; this._retryAttempt = 0; - this._retryAbortController = undefined; this._emit({ type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled", }); - this._resolveRetry(); return false; + } finally { + this._retryAbortController = undefined; } - this._retryAbortController = undefined; - - // Retry via continue() - use setTimeout to break out of event handler chain - setTimeout(() => { - this.agent.continue().catch(() => { - // Retry failed - will be caught by next agent_end - }); - }, 0); return true; } @@ -2518,26 +2509,11 @@ export class AgentSession { */ abortRetry(): void { this._retryAbortController?.abort(); - // Note: _retryAttempt is reset in the catch block of _autoRetry - this._resolveRetry(); - } - - /** - * Wait for any in-progress retry to complete. - * Returns immediately if no retry is in progress. - */ - private async waitForRetry(): Promise { - if (!this._retryPromise) { - return; - } - - await this._retryPromise; - await this.agent.waitForIdle(); } /** Whether auto-retry is currently in progress */ get isRetrying(): boolean { - return this._retryPromise !== undefined; + return this._retryAbortController !== undefined; } /** Whether auto-retry is enabled */ @@ -3026,7 +3002,10 @@ export class AgentSession { * @returns The resolved output file path. */ exportToJsonl(outputPath?: string): string { - const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`); + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); const dir = dirname(filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/packages/coding-agent/src/core/auth-guidance.ts b/packages/coding-agent/src/core/auth-guidance.ts index 567b603d..9782cdaf 100644 --- a/packages/coding-agent/src/core/auth-guidance.ts +++ b/packages/coding-agent/src/core/auth-guidance.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { getDocsPath } from "../config.js"; +import { getDocsPath } from "../config.ts"; const UNKNOWN_PROVIDER = "unknown"; diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 5b57424e..7630fc80 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -17,8 +17,9 @@ import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-w import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; -import { getAgentDir } from "../config.js"; -import { resolveConfigValue } from "./resolve-config-value.js"; +import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; +import { resolveConfigValue } from "./resolve-config-value.ts"; export type ApiKeyCredential = { type: "api_key"; @@ -50,7 +51,11 @@ export interface AuthStorageBackend { } export class FileAuthStorageBackend implements AuthStorageBackend { - constructor(private authPath: string = join(getAgentDir(), "auth.json")) {} + private authPath: string; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = normalizePath(authPath); + } private ensureParentDir(): void { const dir = dirname(this.authPath); @@ -194,8 +199,10 @@ export class AuthStorage { private fallbackResolver?: (provider: string) => string | undefined; private loadError: Error | null = null; private errors: Error[] = []; + private storage: AuthStorageBackend; - private constructor(private storage: AuthStorageBackend) { + private constructor(storage: AuthStorageBackend) { + this.storage = storage; this.reload(); } diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index ee55611e..bacb2ecc 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -10,10 +10,10 @@ import { randomBytes } from "node:crypto"; import { createWriteStream, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import stripAnsi from "strip-ansi"; -import { sanitizeBinaryOutput } from "../utils/shell.js"; -import type { BashOperations } from "./tools/bash.js"; -import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; +import { stripAnsi } from "../utils/ansi.ts"; +import { sanitizeBinaryOutput } from "../utils/shell.ts"; +import type { BashOperations } from "./tools/bash.ts"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.ts"; // ============================================================================ // Types diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 69925dda..0039bc79 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -13,9 +13,9 @@ import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "../messages.js"; -import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; -import { estimateTokens } from "./compaction.js"; +} from "../messages.ts"; +import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; +import { estimateTokens } from "./compaction.ts"; import { computeFileLists, createFileOps, @@ -24,7 +24,7 @@ import { formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, -} from "./utils.js"; +} from "./utils.ts"; // ============================================================================ // Types @@ -44,7 +44,7 @@ export interface BranchSummaryDetails { modifiedFiles: string[]; } -export type { FileOperations } from "./utils.js"; +export type { FileOperations } from "./utils.ts"; export interface BranchPreparation { /** Messages extracted for summarization, in chronological order */ diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index c11c0254..b8339fba 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -5,16 +5,16 @@ * and after compaction the session is reloaded. */ -import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai"; +import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "../messages.js"; -import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.js"; +} from "../messages.ts"; +import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.ts"; import { computeFileLists, createFileOps, @@ -23,7 +23,7 @@ import { formatFileOperations, SUMMARIZATION_SYSTEM_PROMPT, serializeConversation, -} from "./utils.js"; +} from "./utils.ts"; // ============================================================================ // File Operation Tracking @@ -523,6 +523,34 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; +function createSummarizationOptions( + model: Model, + maxTokens: number, + apiKey: string | undefined, + headers: Record | undefined, + signal: AbortSignal | undefined, + thinkingLevel: ThinkingLevel | undefined, +): SimpleStreamOptions { + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers }; + if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { + options.reasoning = thinkingLevel; + } + return options; +} + +async function completeSummarization( + model: Model, + context: Context, + options: SimpleStreamOptions, + streamFn?: StreamFn, +): Promise { + if (!streamFn) { + return completeSimple(model, context, options); + } + const stream = await streamFn(model, context, options); + return stream.result(); +} + /** * Generate a summary of the conversation using the LLM. * If previousSummary is provided, uses the update prompt to merge. @@ -531,14 +559,18 @@ export async function generateSummary( currentMessages: AgentMessage[], model: Model, reserveTokens: number, - apiKey: string, + apiKey: string | undefined, headers?: Record, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, ): Promise { - const maxTokens = Math.floor(0.8 * reserveTokens); + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Use update prompt if we have a previous summary, otherwise initial prompt let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; @@ -566,15 +598,13 @@ export async function generateSummary( }, ]; - const completionOptions = - model.reasoning && thinkingLevel && thinkingLevel !== "off" - ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } - : { maxTokens, signal, apiKey, headers }; + const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel); - const response = await completeSimple( + const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, completionOptions, + streamFn, ); if (response.stopReason === "error") { @@ -717,11 +747,12 @@ Be concise. Focus on what's needed to understand the kept suffix.`; export async function compact( preparation: CompactionPreparation, model: Model, - apiKey: string, + apiKey: string | undefined, headers?: Record, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, ): Promise { const { firstKeptEntryId, @@ -751,6 +782,7 @@ export async function compact( customInstructions, previousSummary, thinkingLevel, + streamFn, ) : Promise.resolve("No prior history."), generateTurnPrefixSummary( @@ -761,6 +793,7 @@ export async function compact( headers, signal, thinkingLevel, + streamFn, ), ]); // Merge into single summary @@ -777,6 +810,7 @@ export async function compact( customInstructions, previousSummary, thinkingLevel, + streamFn, ); } @@ -803,12 +837,16 @@ async function generateTurnPrefixSummary( messages: AgentMessage[], model: Model, reserveTokens: number, - apiKey: string, + apiKey: string | undefined, headers?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, ): Promise { - const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Smaller budget for turn prefix const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; @@ -820,12 +858,11 @@ async function generateTurnPrefixSummary( }, ]; - const response = await completeSimple( + const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - model.reasoning && thinkingLevel && thinkingLevel !== "off" - ? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } - : { maxTokens, signal, apiKey, headers }, + createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel), + streamFn, ); if (response.stopReason === "error") { diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index d8c92a67..7fae5f2c 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -2,6 +2,6 @@ * Compaction and summarization utilities. */ -export * from "./branch-summarization.js"; -export * from "./compaction.js"; -export * from "./utils.js"; +export * from "./branch-summarization.ts"; +export * from "./compaction.ts"; +export * from "./utils.ts"; diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 40f0453d..5afe9ec3 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -3,7 +3,7 @@ */ import { spawn } from "node:child_process"; -import { waitForChildProcess } from "../utils/child-process.js"; +import { waitForChildProcess } from "../utils/child-process.ts"; /** * Options for executing shell commands. diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index 6b5579b6..3762c264 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -1,11 +1,12 @@ import type { AgentState } from "@earendil-works/pi-agent-core"; import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; -import { APP_NAME, getExportTemplateDir } from "../../config.js"; -import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js"; -import type { ToolDefinition } from "../extensions/types.js"; -import type { SessionEntry } from "../session-manager.js"; -import { SessionManager } from "../session-manager.js"; +import { APP_NAME, getExportTemplateDir } from "../../config.ts"; +import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; +import type { ToolDefinition } from "../extensions/types.ts"; +import type { SessionEntry } from "../session-manager.ts"; +import { SessionManager } from "../session-manager.ts"; /** * Interface for rendering custom tools to HTML. @@ -270,7 +271,7 @@ export async function exportSessionToHtml( const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; @@ -286,12 +287,13 @@ export async function exportSessionToHtml( */ export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + const resolvedInputPath = resolvePath(inputPath); - if (!existsSync(inputPath)) { - throw new Error(`File not found: ${inputPath}`); + if (!existsSync(resolvedInputPath)) { + throw new Error(`File not found: ${resolvedInputPath}`); } - const sm = SessionManager.open(inputPath); + const sm = SessionManager.open(resolvedInputPath); const sessionData: SessionData = { header: sm.getHeader(), @@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { - const inputBasename = basename(inputPath, ".jsonl"); + const inputBasename = basename(resolvedInputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index cf28b935..07ab3e25 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -910,7 +910,8 @@ ''; }; - let html = `
`; + const toolDomId = `tool-call-${escapeHtml(call.id)}`; + let html = `
`; const args = call.arguments || {}; const name = call.name; @@ -1445,6 +1446,16 @@ // Cache for rendered entry DOM nodes const entryCache = new Map(); + function getScrollTargetElementId(entryId) { + const entry = byId.get(entryId); + if (entry?.type === 'message' && entry.message.role === 'toolResult' && entry.message.toolCallId) { + // getElementById() matches the parsed DOM id attribute, whose HTML entities + // were already resolved from the escaped id rendered by renderToolCall(). + return `tool-call-${entry.message.toolCallId}`; + } + return `entry-${entryId}`; + } + function renderEntryToNode(entry) { // Check cache first if (entryCache.has(entry.id)) { @@ -1506,9 +1517,12 @@ if (scrollMode === 'bottom') { content.scrollTop = content.scrollHeight; } else if (scrollMode === 'target') { - // If scrollToEntryId is provided, scroll to that specific entry + // If scrollToEntryId is provided, scroll to that specific entry. + // Tool result entries are rendered inside their assistant tool-call block, + // so route them to the visible tool-call element instead. const scrollTargetId = scrollToEntryId || targetId; - const targetEl = document.getElementById(`entry-${scrollTargetId}`); + const targetEl = document.getElementById(getScrollTargetElementId(scrollTargetId)) || + document.getElementById(`entry-${scrollTargetId}`); if (targetEl) { targetEl.scrollIntoView({ block: 'center' }); // Briefly highlight the target message diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index ee4fcb86..c2030a27 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -7,9 +7,9 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { Component } from "@earendil-works/pi-tui"; -import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js"; -import { ansiLinesToHtml } from "./ansi-to-html.js"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderContext } from "../extensions/types.ts"; +import { ansiLinesToHtml } from "./ansi-to-html.ts"; export interface ToolHtmlRendererDeps { /** Function to look up tool definition by name */ diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index fdc235c6..acbc50b1 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -2,14 +2,14 @@ * Extension system for lifecycle events and custom tools. */ -export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.js"; -export type { SourceInfo } from "../source-info.js"; +export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.ts"; +export type { SourceInfo } from "../source-info.ts"; export { createExtensionRuntime, discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions, -} from "./loader.js"; +} from "./loader.ts"; export type { ExtensionErrorListener, ForkHandler, @@ -17,8 +17,8 @@ export type { NewSessionHandler, ShutdownHandler, SwitchSessionHandler, -} from "./runner.js"; -export { ExtensionRunner } from "./runner.js"; +} from "./runner.ts"; +export { ExtensionRunner } from "./runner.ts"; export type { AfterProviderResponseEvent, AgentEndEvent, @@ -156,7 +156,7 @@ export type { WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, -} from "./types.js"; +} from "./types.ts"; // Type guards export { defineTool, @@ -168,5 +168,5 @@ export { isReadToolResult, isToolCallEventType, isWriteToolResult, -} from "./types.js"; -export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.js"; +} from "./types.ts"; +export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts"; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 45fddb82..d6823a59 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -5,7 +5,6 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; -import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; @@ -20,14 +19,15 @@ import { createJiti } from "jiti"; import * as _bundledTypebox from "typebox"; import * as _bundledTypeboxCompile from "typebox/compile"; import * as _bundledTypeboxValue from "typebox/value"; -import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js"; +import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. -import * as _bundledPiCodingAgent from "../../index.js"; -import { createEventBus, type EventBus } from "../event-bus.js"; -import type { ExecOptions } from "../exec.js"; -import { execCommand } from "../exec.js"; -import { createSyntheticSourceInfo } from "../source-info.js"; +import * as _bundledPiCodingAgent from "../../index.ts"; +import { resolvePath } from "../../utils/paths.ts"; +import { createEventBus, type EventBus } from "../event-bus.ts"; +import type { ExecOptions } from "../exec.ts"; +import { execCommand } from "../exec.ts"; +import { createSyntheticSourceInfo } from "../source-info.ts"; import type { Extension, ExtensionAPI, @@ -38,7 +38,7 @@ import type { ProviderConfig, RegisteredCommand, ToolDefinition, -} from "./types.js"; +} from "./types.ts"; /** Modules available to extensions via virtualModules (for compiled Bun binary) */ const VIRTUAL_MODULES: Record = { @@ -115,31 +115,6 @@ function getAliases(): Record { return _aliases; } -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; - -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} - -function expandPath(p: string): string { - const normalized = normalizeUnicodeSpaces(p); - if (normalized.startsWith("~/")) { - return path.join(os.homedir(), normalized.slice(2)); - } - if (normalized.startsWith("~")) { - return path.join(os.homedir(), normalized.slice(1)); - } - return normalized; -} - -function resolvePath(extPath: string, cwd: string): string { - const expanded = expandPath(extPath); - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(cwd, expanded); -} - type HandlerFn = (...args: unknown[]) => Promise; /** @@ -236,7 +211,7 @@ function createExtensionAPI( shortcut: KeyId, options: { description?: string; - handler: (ctx: import("./types.js").ExtensionContext) => Promise | void; + handler: (ctx: import("./types.ts").ExtensionContext) => Promise | void; }, ): void { runtime.assertActive(); @@ -396,7 +371,7 @@ async function loadExtension( eventBus: EventBus, runtime: ExtensionRuntime, ): Promise<{ extension: Extension | null; error: string | null }> { - const resolvedPath = resolvePath(extensionPath, cwd); + const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { const factory = await loadExtensionModule(resolvedPath); @@ -426,7 +401,8 @@ export async function loadExtensionFromFactory( extensionPath = "", ): Promise { const extension = createExtension(extensionPath, extensionPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); + const resolvedCwd = resolvePath(cwd); + const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus); await factory(api); return extension; } @@ -437,11 +413,12 @@ export async function loadExtensionFromFactory( export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; + const resolvedCwd = resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); const runtime = createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); + const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime); if (error) { errors.push({ path: extPath, error }); @@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions( agentDir: string = getAgentDir(), eventBus?: EventBus, ): Promise { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); const allPaths: string[] = []; const seen = new Set(); @@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions( }; // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ - const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions"); + const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); addPaths(discoverExtensionsInDir(localExtDir)); // 2. Global extensions: agentDir/extensions/ - const globalExtDir = path.join(agentDir, "extensions"); + const globalExtDir = path.join(resolvedAgentDir, "extensions"); addPaths(discoverExtensionsInDir(globalExtDir)); // 3. Explicitly configured paths for (const p of configuredPaths) { - const resolved = resolvePath(p, cwd); + const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true }); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { // Check for package.json with pi manifest or index.ts const entries = resolveExtensionEntries(resolved); @@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions( addPaths([resolved]); } - return loadExtensions(allPaths, cwd, eventBus); + return loadExtensions(allPaths, resolvedCwd, eventBus); } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index c37a4588..167c52cc 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -5,12 +5,12 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ImageContent, Model } from "@earendil-works/pi-ai"; import type { KeyId } from "@earendil-works/pi-tui"; -import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; -import type { ResourceDiagnostic } from "../diagnostics.js"; -import type { KeybindingsConfig } from "../keybindings.js"; -import type { ModelRegistry } from "../model-registry.js"; -import type { SessionManager } from "../session-manager.js"; -import type { BuildSystemPromptOptions } from "../system-prompt.js"; +import { type Theme, theme } from "../../modes/interactive/theme/theme.ts"; +import type { ResourceDiagnostic } from "../diagnostics.ts"; +import type { KeybindingsConfig } from "../keybindings.ts"; +import type { ModelRegistry } from "../model-registry.ts"; +import type { SessionManager } from "../session-manager.ts"; +import type { BuildSystemPromptOptions } from "../system-prompt.ts"; import type { BeforeAgentStartEvent, BeforeAgentStartEventResult, @@ -55,7 +55,7 @@ import type { ToolResultEventResult, UserBashEvent, UserBashEventResult, -} from "./types.js"; +} from "./types.ts"; // Extension shortcuts compete with canonical keybinding ids from keybindings.json. // Only editor-global shortcuts are reserved here. Picker-specific bindings are not. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 4c1b6fca..58b85227 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -40,27 +40,27 @@ import type { TUI, } from "@earendil-works/pi-tui"; import type { Static, TSchema } from "typebox"; -import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { BashResult } from "../bash-executor.js"; -import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; -import type { EventBus } from "../event-bus.js"; -import type { ExecOptions, ExecResult } from "../exec.js"; -import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js"; -import type { KeybindingsManager } from "../keybindings.js"; -import type { CustomMessage } from "../messages.js"; -import type { ModelRegistry } from "../model-registry.js"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { BashResult } from "../bash-executor.ts"; +import type { CompactionPreparation, CompactionResult } from "../compaction/index.ts"; +import type { EventBus } from "../event-bus.ts"; +import type { ExecOptions, ExecResult } from "../exec.ts"; +import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts"; +import type { KeybindingsManager } from "../keybindings.ts"; +import type { CustomMessage } from "../messages.ts"; +import type { ModelRegistry } from "../model-registry.ts"; import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry, SessionManager, -} from "../session-manager.js"; -import type { SlashCommandInfo } from "../slash-commands.js"; -import type { SourceInfo } from "../source-info.js"; -import type { BuildSystemPromptOptions } from "../system-prompt.js"; -import type { BashOperations } from "../tools/bash.js"; -import type { EditToolDetails } from "../tools/edit.js"; +} from "../session-manager.ts"; +import type { SlashCommandInfo } from "../slash-commands.ts"; +import type { SourceInfo } from "../source-info.ts"; +import type { BuildSystemPromptOptions } from "../system-prompt.ts"; +import type { BashOperations } from "../tools/bash.ts"; +import type { EditToolDetails } from "../tools/edit.ts"; import type { BashToolDetails, BashToolInput, @@ -74,12 +74,12 @@ import type { ReadToolDetails, ReadToolInput, WriteToolInput, -} from "../tools/index.js"; +} from "../tools/index.ts"; -export type { ExecOptions, ExecResult } from "../exec.js"; -export type { BuildSystemPromptOptions } from "../system-prompt.js"; +export type { ExecOptions, ExecResult } from "../exec.ts"; +export type { BuildSystemPromptOptions } from "../system-prompt.ts"; export type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode }; -export type { AppKeybinding, KeybindingsManager } from "../keybindings.js"; +export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts"; // ============================================================================ // UI Context diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index 9b9d281c..6a84038c 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -6,9 +6,9 @@ */ import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.js"; -import type { ExtensionRunner } from "./runner.js"; -import type { RegisteredTool } from "./types.js"; +import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts"; +import type { ExtensionRunner } from "./runner.ts"; +import type { RegisteredTool } from "./types.ts"; /** * Wrap a RegisteredTool into an AgentTool. diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 5d729221..b7ea09b8 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -1,7 +1,7 @@ import { type ExecFileException, execFile, spawnSync } from "child_process"; import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs"; import { dirname, join, resolve } from "path"; -import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js"; +import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts"; type GitPaths = { repoDir: string; diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts new file mode 100644 index 00000000..12ce9fb0 --- /dev/null +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -0,0 +1,55 @@ +import * as undici from "undici"; + +export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; + +export const HTTP_IDLE_TIMEOUT_CHOICES = [ + { label: "30 sec", timeoutMs: 30_000 }, + { label: "1 min", timeoutMs: 60_000 }, + { label: "2 min", timeoutMs: 120_000 }, + { label: "5 min", timeoutMs: 300_000 }, + { label: "disabled", timeoutMs: 0 }, +] as const; + +export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "disabled") { + return 0; + } + if (trimmed.length === 0) { + return undefined; + } + return parseHttpIdleTimeoutMs(Number(trimmed)); + } + + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.floor(value); +} + +export function formatHttpIdleTimeoutMs(timeoutMs: number): string { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs); + if (choice) { + return choice.label; + } + return `${timeoutMs / 1000} sec`; +} + +export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { + const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); + if (normalizedTimeoutMs === undefined) { + throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); + } + undici.setGlobalDispatcher( + new undici.EnvHttpProxyAgent({ + allowH2: false, + bodyTimeout: normalizedTimeoutMs, + headersTimeout: normalizedTimeoutMs, + }), + ); + // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's + // bundled fetch can otherwise consume compressed responses through npm undici's + // dispatcher without decompressing them, causing response.json() failures. + undici.install?.(); +} diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 003e1394..71c45e9c 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -10,13 +10,13 @@ export { type ModelCycleResult, type PromptOptions, type SessionStats, -} from "./agent-session.js"; +} from "./agent-session.ts"; export { AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeResult, createAgentSessionRuntime, -} from "./agent-session-runtime.js"; +} from "./agent-session-runtime.ts"; export { type AgentSessionRuntimeDiagnostic, type AgentSessionServices, @@ -24,10 +24,10 @@ export { type CreateAgentSessionServicesOptions, createAgentSessionFromServices, createAgentSessionServices, -} from "./agent-session-services.js"; -export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.js"; -export type { CompactionResult } from "./compaction/index.js"; -export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js"; +} from "./agent-session-services.ts"; +export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; +export type { CompactionResult } from "./compaction/index.ts"; +export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; // Extensions system export { type AgentEndEvent, @@ -73,5 +73,5 @@ export { type TurnEndEvent, type TurnStartEvent, type WorkingIndicatorOptions, -} from "./extensions/index.js"; -export { createSyntheticSourceInfo } from "./source-info.js"; +} from "./extensions/index.ts"; +export { createSyntheticSourceInfo } from "./source-info.ts"; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index f1663b1a..7c0de643 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -8,7 +8,7 @@ import { } from "@earendil-works/pi-tui"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { getAgentDir } from "../config.js"; +import { getAgentDir } from "../config.ts"; export interface AppKeybindings { "app.interrupt": true; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index a30ee210..6e3a1031 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -24,15 +24,16 @@ import { join } from "path"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; -import { getAgentDir } from "../config.js"; -import type { AuthStatus, AuthStorage } from "./auth-storage.js"; -import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js"; +import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; +import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { clearConfigValueCache, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, -} from "./resolve-config-value.js"; +} from "./resolve-config-value.ts"; // Schema for OpenRouter routing preferences const PercentileCutoffsSchema = Type.Object({ @@ -334,11 +335,12 @@ export class ModelRegistry { private modelRequestHeaders: Map> = new Map(); private registeredProviders: Map = new Map(); private loadError: string | undefined = undefined; + readonly authStorage: AuthStorage; + private modelsJsonPath: string | undefined; - private constructor( - readonly authStorage: AuthStorage, - private modelsJsonPath: string | undefined, - ) { + private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { + this.authStorage = authStorage; + this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined; this.loadModels(); } diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 18a3eed8..764c06f5 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -6,9 +6,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { minimatch } from "minimatch"; -import { isValidThinkingLevel } from "../cli/args.js"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; -import type { ModelRegistry } from "./model-registry.js"; +import { isValidThinkingLevel } from "../cli/args.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 8cd1baa9..994adb56 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process"; +import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -27,12 +27,12 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { CONFIG_DIR_NAME } from "../config.js"; -import { shouldUseWindowsShell } from "../utils/child-process.js"; -import { type GitSource, parseGitUrl } from "../utils/git.js"; -import { canonicalizePath, isLocalPath } from "../utils/paths.js"; -import { isStdoutTakenOver } from "./output-guard.js"; -import type { PackageSource, SettingsManager } from "./settings-manager.js"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; +import { type GitSource, parseGitUrl } from "../utils/git.ts"; +import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; +import { isStdoutTakenOver } from "./output-guard.ts"; +import type { PackageSource, SettingsManager } from "./settings-manager.ts"; const NETWORK_TIMEOUT_MS = 10000; const UPDATE_CHECK_CONCURRENCY = 4; @@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager { private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager; } @@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager { scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const currentPackages = currentSettings.packages ?? []; const normalizedSource = this.normalizePackageSourceForSettings(source, scope); - const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope)); - if (exists) { - return false; + const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope)); + if (matchIndex !== -1) { + const existing = currentPackages[matchIndex]; + if (this.getPackageSourceString(existing) === normalizedSource) { + return false; + } + const nextPackages = [...currentPackages]; + nextPackages[matchIndex] = + typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource }; + if (scope === "project") { + this.settingsManager.setProjectPackages(nextPackages); + } else { + this.settingsManager.setPackages(nextPackages); + } + return true; } const nextPackages = [...currentPackages, normalizedSource]; if (scope === "project") { @@ -1084,7 +1096,7 @@ export class DefaultPackageManager implements PackageManager { } private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise { - const installedPath = this.getNpmInstallPath(source, scope); + const installedPath = this.getManagedNpmInstallPath(source, scope); const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; if (!installedVersion) { return true; @@ -1114,13 +1126,9 @@ export class DefaultPackageManager implements PackageManager { } private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise { - if (scope === "user") { - await this.runNpmCommand(["install", "-g", ...specs]); - return; - } const installRoot = this.getNpmInstallRoot(scope, false); this.ensureNpmProject(installRoot); - await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]); + await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot)); } async checkForAvailableUpdates(): Promise { @@ -1221,13 +1229,14 @@ export class DefaultPackageManager implements PackageManager { }; if (parsed.type === "npm") { - const installedPath = this.getNpmInstallPath(parsed, scope); + let installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = !existsSync(installedPath) || (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; + installedPath = this.getNpmInstallPath(parsed, scope); } metadata.baseDir = installedPath; this.collectPackageResources(installedPath, accumulator, filter, metadata); @@ -1668,6 +1677,14 @@ export class DefaultPackageManager implements PackageManager { return { command, args }; } + private getPackageManagerName(): string { + const npmCommand = this.getNpmCommand(); + const commandParts = [npmCommand.command, ...npmCommand.args]; + const separatorIndex = commandParts.lastIndexOf("--"); + const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command; + return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : ""; + } + private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise { const npmCommand = this.getNpmCommand(); await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options); @@ -1686,31 +1703,44 @@ export class DefaultPackageManager implements PackageManager { return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]); } - private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { - if (scope === "user" && !temporary) { - await this.runNpmCommand(["install", "-g", source.spec]); - return; + private getNpmInstallArgs(specs: string[], installRoot: string): string[] { + const packageManagerName = this.getPackageManagerName(); + if (packageManagerName === "bun") { + return ["install", ...specs, "--cwd", installRoot]; } + if (packageManagerName === "pnpm") { + return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"]; + } + return ["install", ...specs, "--prefix", installRoot]; + } + + private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { const installRoot = this.getNpmInstallRoot(scope, temporary); this.ensureNpmProject(installRoot); - await this.runNpmCommand(["install", source.spec, "--prefix", installRoot]); + await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot)); } private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise { - if (scope === "user") { - await this.runNpmCommand(["uninstall", "-g", source.name]); - return; - } const installRoot = this.getNpmInstallRoot(scope, false); if (!existsSync(installRoot)) { return; } + if (this.getPackageManagerName() === "bun") { + await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]); + return; + } await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]); } private async installGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); if (existsSync(targetDir)) { + if (source.ref) { + await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); + return; + } + const target = await this.getLocalGitUpdateTarget(targetDir); + await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); return; } const gitRoot = this.getGitInstallRoot(scope); @@ -1737,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager { } const target = await this.getLocalGitUpdateTarget(targetDir); + await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); + } + private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise { // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise. - await this.runCommand("git", target.fetchArgs, { cwd: targetDir }); + await this.runCommand("git", fetchArgs, { cwd: targetDir }); const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], { + const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - if (localHead.trim() === refreshedTargetHead.trim()) { + if (localHead.trim() === targetHead.trim()) { return; } - await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir }); + await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir }); // Clean untracked files (extensions should be pristine) await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); @@ -1810,6 +1843,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(installRoot)) { mkdirSync(installRoot, { recursive: true }); } + markPathIgnoredByCloudSync(installRoot); this.ensureGitIgnore(installRoot); const packageJsonPath = join(installRoot, "package.json"); if (!existsSync(packageJsonPath)) { @@ -1835,7 +1869,7 @@ export class DefaultPackageManager implements PackageManager { if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm"); } - return join(this.getGlobalNpmRoot(), ".."); + return join(this.agentDir, "npm"); } private getGlobalNpmRoot(): string { @@ -1844,8 +1878,7 @@ export class DefaultPackageManager implements PackageManager { if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) { return this.globalNpmRoot; } - const isBunPackageManager = npmCommand.command === "bun"; - if (isBunPackageManager) { + if (this.getPackageManagerName() === "bun") { const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim(); this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules"); } else { @@ -1855,14 +1888,45 @@ export class DefaultPackageManager implements PackageManager { return this.globalNpmRoot; } - private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { + private getPnpmGlobalPackagePath(packageName: string): string | undefined { + if (this.getPackageManagerName() !== "pnpm") { + return undefined; + } + + const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]); + const entries = JSON.parse(output) as Array<{ dependencies?: Record }>; + for (const entry of entries) { + const path = entry.dependencies?.[packageName]?.path; + if (path) return path; + } + return undefined; + } + + private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string { if (scope === "temporary") { return join(this.getTemporaryDir("npm"), "node_modules", source.name); } if (scope === "project") { return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } - return join(this.getGlobalNpmRoot(), source.name); + return join(this.agentDir, "npm", "node_modules", source.name); + } + + private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined { + try { + return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name); + } catch { + return undefined; + } + } + + private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { + const managedPath = this.getManagedNpmInstallPath(source, scope); + if (scope !== "user" || existsSync(managedPath)) { + return managedPath; + } + const legacyPath = this.getLegacyGlobalNpmInstallPath(source); + return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath; } private getGitInstallPath(source: GitSource, scope: SourceScope): string { @@ -1904,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager { } private resolvePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return getHomeDir(); - if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1)); - return resolve(this.cwd, trimmed); + return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); } private resolvePathFromBase(input: string, baseDir: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return getHomeDir(); - if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1)); - return resolve(baseDir, trimmed); + return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); } private collectPackageResources( @@ -2366,11 +2422,11 @@ export class DefaultPackageManager implements PackageManager { } private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { - return spawn(command, args, { + const env = getEnv(); + return spawnProcess(command, args, { cwd: options?.cwd, stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", - shell: shouldUseWindowsShell(command), - env: getEnv(), + env, }); } @@ -2380,11 +2436,11 @@ export class DefaultPackageManager implements PackageManager { options?: { cwd?: string; env?: Record }, ): ChildProcessByStdio { const baseEnv = getEnv(); - return spawn(command, args, { + const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv; + return spawnProcess(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], - shell: shouldUseWindowsShell(command), - env: options?.env ? { ...baseEnv, ...options.env } : baseEnv, + env, }); } @@ -2447,11 +2503,11 @@ export class DefaultPackageManager implements PackageManager { } private runCommandSync(command: string, args: string[]): string { - const result = spawnSync(command, args, { + const env = getEnv(); + const result = spawnProcessSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", - shell: shouldUseWindowsShell(command), - env: getEnv(), + env, }); if (result.error || result.status !== 0) { throw new Error( diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 8527f911..581a34eb 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,9 +1,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "path"; -import { CONFIG_DIR_NAME } from "../config.js"; -import { parseFrontmatter } from "../utils/frontmatter.js"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; +import { basename, dirname, join, resolve, sep } from "path"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; /** * Represents a prompt template loaded from a markdown file @@ -37,7 +37,7 @@ export function parseCommandArgs(argsString: string): string[] { } } else if (char === '"' || char === "'") { inQuote = char; - } else if (char === " " || char === "\t") { + } else if (/\s/.test(char)) { if (current) { args.push(current); current = ""; @@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions { includeDefaults: boolean; } -function normalizePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return homedir(); - if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); - return trimmed; -} - -function resolvePromptPath(p: string, cwd: string): string { - const normalized = normalizePath(p); - return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); -} - /** * Load all prompt templates from: * 1. Global: agentDir/prompts/ @@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string { * 3. Explicit prompt paths */ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const promptPaths = options.promptPaths; const includeDefaults = options.includeDefaults; const templates: PromptTemplate[] = []; - const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + const globalPromptsDir = join(resolvedAgentDir, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const isUnderPath = (target: string, root: string): boolean => { @@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt // 3. Load explicit prompt paths for (const rawPath of promptPaths) { - const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { continue; } @@ -282,9 +269,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { if (!text.startsWith("/")) return text; - const spaceIndex = text.indexOf(" "); - const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match) return text; + + const templateName = match[1]; + const argsString = match[2] ?? ""; const template = templates.find((t) => t.name === templateName); if (template) { diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 646042e1..215914ba 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -4,7 +4,7 @@ */ import { execSync, spawnSync } from "child_process"; -import { getShellConfig } from "../utils/shell.js"; +import { getShellConfig } from "../utils/shell.ts"; // Cache for shell command results (persists for process lifetime) const commandResultCache = new Map(); diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 338daf7e..eb043cd5 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -1,24 +1,23 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; import chalk from "chalk"; -import { CONFIG_DIR_NAME } from "../config.js"; -import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js"; -import type { ResourceDiagnostic } from "./diagnostics.js"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts"; +import type { ResourceDiagnostic } from "./diagnostics.ts"; -export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js"; +export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; -import { canonicalizePath, isLocalPath } from "../utils/paths.js"; -import { createEventBus, type EventBus } from "./event-bus.js"; -import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.js"; -import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; -import type { PromptTemplate } from "./prompt-templates.js"; -import { loadPromptTemplates } from "./prompt-templates.js"; -import { SettingsManager } from "./settings-manager.js"; -import type { Skill } from "./skills.js"; -import { loadSkills } from "./skills.js"; -import { createSourceInfo, type SourceInfo } from "./source-info.js"; +import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { createEventBus, type EventBus } from "./event-bus.ts"; +import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; +import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; +import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts"; +import type { PromptTemplate } from "./prompt-templates.ts"; +import { loadPromptTemplates } from "./prompt-templates.ts"; +import { SettingsManager } from "./settings-manager.ts"; +import type { Skill } from "./skills.ts"; +import { loadSkills } from "./skills.ts"; +import { createSourceInfo, type SourceInfo } from "./source-info.ts"; export interface ResourceExtensionPaths { skillPaths?: Array<{ path: string; metadata: PathMetadata }>; @@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: { cwd: string; agentDir: string; }): Array<{ path: string; content: string }> { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); @@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader { private lastThemePaths: string[]; constructor(options: DefaultResourceLoaderOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.eventBus = options.eventBus ?? createEventBus(); this.packageManager = new DefaultPackageManager({ @@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of this.additionalExtensionPaths) { - if (isLocalPath(p) && !existsSync(p)) { - extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved)) { + extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` }); + } } } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; @@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastSkillPaths = skillPaths; this.updateSkillsFromPaths(skillPaths, metadataByPath); for (const p of this.additionalSkillPaths) { - if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) { - this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) { + this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved }); + } } } @@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastPromptPaths = promptPaths; this.updatePromptsFromPaths(promptPaths, metadataByPath); for (const p of this.additionalPromptTemplatePaths) { - if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) { - this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) { + this.promptDiagnostics.push({ + type: "error", + message: "Prompt template path does not exist", + path: resolved, + }); + } } } @@ -447,8 +459,9 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastThemePaths = themePaths; this.updateThemesFromPaths(themePaths, metadataByPath); for (const p of this.additionalThemePaths) { - if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) { - this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p }); + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) { + this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved }); } } @@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader { private normalizeExtensionPaths( entries: Array<{ path: string; metadata: PathMetadata }>, ): Array<{ path: string; metadata: PathMetadata }> { - return entries.map((entry) => ({ - path: this.resolveResourcePath(entry.path), - metadata: entry.metadata, - })); + return entries.map((entry) => { + const metadata = entry.metadata.baseDir + ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) } + : entry.metadata; + return { + path: this.resolveResourcePath(entry.path), + metadata, + }; + }); } private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { @@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader { } private resolveResourcePath(p: string): string { - const trimmed = p.trim(); - let expanded = trimmed; - if (trimmed === "~") { - expanded = homedir(); - } else if (trimmed.startsWith("~/")) { - expanded = join(homedir(), trimmed.slice(2)); - } else if (trimmed.startsWith("~")) { - expanded = join(homedir(), trimmed.slice(1)); - } - return resolve(this.cwd, expanded); + return resolvePath(p, this.cwd, { trim: true }); } private loadThemes( @@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of paths) { - const resolved = resolve(this.cwd, p); + const resolved = this.resolveResourcePath(p); if (!existsSync(resolved)) { diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); continue; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8bc96072..f19581e4 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -1,21 +1,22 @@ import { join } from "node:path"; import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai"; -import { getAgentDir } from "../config.js"; -import { AgentSession } from "./agent-session.js"; -import { formatNoModelsAvailableMessage } from "./auth-guidance.js"; -import { AuthStorage } from "./auth-storage.js"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; -import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js"; -import { convertToLlm } from "./messages.js"; -import { ModelRegistry } from "./model-registry.js"; -import { findInitialModel } from "./model-resolver.js"; -import type { ResourceLoader } from "./resource-loader.js"; -import { DefaultResourceLoader } from "./resource-loader.js"; -import { getDefaultSessionDir, SessionManager } from "./session-manager.js"; -import { SettingsManager } from "./settings-manager.js"; -import { isInstallTelemetryEnabled } from "./telemetry.js"; -import { time } from "./timings.js"; +import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { AgentSession } from "./agent-session.ts"; +import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; +import { AuthStorage } from "./auth-storage.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { convertToLlm } from "./messages.ts"; +import { ModelRegistry } from "./model-registry.ts"; +import { findInitialModel } from "./model-resolver.ts"; +import type { ResourceLoader } from "./resource-loader.ts"; +import { DefaultResourceLoader } from "./resource-loader.ts"; +import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; +import { SettingsManager } from "./settings-manager.ts"; +import { isInstallTelemetryEnabled } from "./telemetry.ts"; +import { time } from "./timings.ts"; import { createBashTool, createCodingTools, @@ -28,7 +29,7 @@ import { createWriteTool, type ToolName, withFileMutationQueue, -} from "./tools/index.js"; +} from "./tools/index.ts"; export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: process.cwd() */ @@ -91,7 +92,7 @@ export interface CreateAgentSessionResult { // Re-exports -export * from "./agent-session-runtime.js"; +export * from "./agent-session-runtime.ts"; export type { ExtensionAPI, ExtensionCommandContext, @@ -100,10 +101,10 @@ export type { SlashCommandInfo, SlashCommandSource, ToolDefinition, -} from "./extensions/index.js"; -export type { PromptTemplate } from "./prompt-templates.js"; -export type { Skill } from "./skills.js"; -export type { Tool } from "./tools/index.js"; +} from "./extensions/index.ts"; +export type { PromptTemplate } from "./prompt-templates.ts"; +export type { Skill } from "./skills.ts"; +export type { Tool } from "./tools/index.ts"; export { withFileMutationQueue, @@ -184,15 +185,15 @@ function getAttributionHeaders( * await loader.reload(); * const { session } = await createAgentSession({ * model: myModel, - * tools: [readTool, bashTool], + * tools: ["read", "bash"], * resourceLoader: loader, * sessionManager: SessionManager.inMemory(), * }); * ``` */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { - const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd(); - const agentDir = options.agentDir ?? getDefaultAgentDir(); + const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); let resourceLoader = options.resourceLoader; // Use provided or create AuthStorage and ModelRegistry diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 8a0d286a..e7d12314 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { type AgentMessage, uuidv7 } from "@earendil-works/pi-agent-core"; import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; import { randomUUID } from "crypto"; import { @@ -15,15 +15,15 @@ import { } from "fs"; import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; -import { v7 as uuidv7 } from "uuid"; -import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; +import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, type CustomMessage, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, -} from "./messages.js"; +} from "./messages.ts"; export const CURRENT_SESSION_VERSION = 3; @@ -426,8 +426,10 @@ export function buildSessionContext( * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(agentDir, "sessions", safePath); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(resolvedAgentDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -436,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { - if (!existsSync(filePath)) return []; + const resolvedFilePath = normalizePath(filePath); + if (!existsSync(resolvedFilePath)) return []; - const content = readFileSync(filePath, "utf8"); + const content = readFileSync(resolvedFilePath, "utf8"); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); @@ -479,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean { /** Exported for testing */ export function findMostRecentSession(sessionDir: string): string | null { + const resolvedSessionDir = normalizePath(sessionDir); try { - const files = readdirSync(sessionDir) + const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(sessionDir, f)) + .map((f) => join(resolvedSessionDir, f)) .filter(isValidSessionFile) .map((path) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); @@ -618,6 +622,48 @@ async function buildSessionInfo(filePath: string): Promise { export type SessionListProgress = (loaded: number, total: number) => void; +const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; + +async function buildSessionInfosWithConcurrency( + files: string[], + onLoaded: () => void, +): Promise<(SessionInfo | null)[]> { + const results: (SessionInfo | null)[] = new Array(files.length).fill(null); + const inFlight = new Set>(); + let nextIndex = 0; + + const startNext = (): void => { + const index = nextIndex++; + const file = files[index]; + if (!file) return; + + let task: Promise; + task = buildSessionInfo(file) + .then((info) => { + results[index] = info; + }) + .catch(() => { + results[index] = null; + }) + .finally(() => { + inFlight.delete(task); + onLoaded(); + }); + inFlight.add(task); + }; + + while (nextIndex < files.length || inFlight.size > 0) { + while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) { + startNext(); + } + if (inFlight.size > 0) { + await Promise.race(inFlight); + } + } + + return results; +} + async function listSessionsFromDir( dir: string, onProgress?: SessionListProgress, @@ -635,14 +681,10 @@ async function listSessionsFromDir( const total = progressTotal ?? files.length; let loaded = 0; - const results = await Promise.all( - files.map(async (file) => { - const info = await buildSessionInfo(file); - loaded++; - onProgress?.(progressOffset + loaded, total); - return info; - }), - ); + const results = await buildSessionInfosWithConcurrency(files, () => { + loaded++; + onProgress?.(progressOffset + loaded, total); + }); for (const info of results) { if (info) { sessions.push(info); @@ -680,11 +722,11 @@ export class SessionManager { private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { - this.cwd = cwd; - this.sessionDir = sessionDir; + this.cwd = resolvePath(cwd); + this.sessionDir = normalizePath(sessionDir); this.persist = persist; - if (persist && sessionDir && !existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + if (persist && this.sessionDir && !existsSync(this.sessionDir)) { + mkdirSync(this.sessionDir, { recursive: true }); } if (sessionFile) { @@ -696,7 +738,7 @@ export class SessionManager { /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile: string): void { - this.sessionFile = resolve(sessionFile); + this.sessionFile = resolvePath(sessionFile); if (existsSync(this.sessionFile)) { this.fileEntries = loadEntriesFromFile(this.sessionFile); @@ -1267,7 +1309,7 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static create(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); return new SessionManager(cwd, dir, undefined, true); } @@ -1278,13 +1320,14 @@ export class SessionManager { * @param cwdOverride Optional cwd override instead of the session header cwd. */ static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + const resolvedPath = resolvePath(path); // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = loadEntriesFromFile(path); + const entries = loadEntriesFromFile(resolvedPath); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory - const dir = sessionDir ?? resolve(path, ".."); - return new SessionManager(cwd, dir, path, true); + const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); + return new SessionManager(cwd, dir, resolvedPath, true); } /** @@ -1293,7 +1336,7 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); @@ -1314,17 +1357,19 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { - const sourceEntries = loadEntriesFromFile(sourcePath); + const resolvedSourcePath = resolvePath(sourcePath); + const resolvedTargetCwd = resolvePath(targetCwd); + const sourceEntries = loadEntriesFromFile(resolvedSourcePath); if (sourceEntries.length === 0) { - throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); + throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`); } const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined; if (!sourceHeader) { - throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); + throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`); } - const dir = sessionDir ?? getDefaultSessionDir(targetCwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } @@ -1341,8 +1386,8 @@ export class SessionManager { version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, - cwd: targetCwd, - parentSession: sourcePath, + cwd: resolvedTargetCwd, + parentSession: resolvedSourcePath, }; appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); @@ -1353,7 +1398,7 @@ export class SessionManager { } } - return new SessionManager(targetCwd, dir, newSessionFile, true); + return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); } /** @@ -1363,7 +1408,7 @@ export class SessionManager { * @param onProgress Optional callback for progress updates (loaded, total) */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const sessions = await listSessionsFromDir(dir, onProgress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; @@ -1401,14 +1446,10 @@ export class SessionManager { const sessions: SessionInfo[] = []; const allFiles = dirFiles.flat(); - const results = await Promise.all( - allFiles.map(async (file) => { - const info = await buildSessionInfo(file); - loaded++; - onProgress?.(loaded, totalFiles); - return info; - }), - ); + const results = await buildSessionInfosWithConcurrency(allFiles, () => { + loaded++; + onProgress?.(loaded, totalFiles); + }); for (const info of results) { if (info) { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4db92056..40f97cd3 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,9 +1,10 @@ import type { Transport } from "@earendil-works/pi-ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; -import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -110,6 +111,7 @@ export interface Settings { markdown?: MarkdownSettings; warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -159,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage { private projectSettingsPath: string; constructor(cwd: string, agentDir: string) { - this.globalSettingsPath = join(agentDir, "settings.json"); - this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + this.globalSettingsPath = join(resolvedAgentDir, "settings.json"); + this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); } private acquireLockSyncWithRetry(path: string): () => void { @@ -575,16 +579,7 @@ export class SettingsManager { getSessionDir(): string | undefined { const sessionDir = this.settings.sessionDir; - if (!sessionDir) { - return sessionDir; - } - if (sessionDir === "~") { - return homedir(); - } - if (sessionDir.startsWith("~/")) { - return join(homedir(), sessionDir.slice(2)); - } - return sessionDir; + return sessionDir ? normalizePath(sessionDir) : sessionDir; } getDefaultProvider(): string | undefined { @@ -726,6 +721,27 @@ export class SettingsManager { }; } + getHttpIdleTimeoutMs(): number { + const value = this.settings.httpIdleTimeoutMs; + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`); + } + return DEFAULT_HTTP_IDLE_TIMEOUT_MS; + } + + setHttpIdleTimeoutMs(timeoutMs: number): void { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`); + } + this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs); + this.markModified("httpIdleTimeoutMs"); + this.save(); + } + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index c0647473..c104c856 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,12 +1,11 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path"; -import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; -import { parseFrontmatter } from "../utils/frontmatter.js"; -import { canonicalizePath } from "../utils/paths.js"; -import type { ResourceDiagnostic } from "./diagnostics.js"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; +import { basename, dirname, join, relative, resolve, sep } from "path"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import type { ResourceDiagnostic } from "./diagnostics.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; /** Max name length per spec */ const MAX_NAME_LENGTH = 64; @@ -90,13 +89,9 @@ export interface LoadSkillsResult { * Validate skill name per Agent Skills spec. * Returns array of validation error messages (empty if valid). */ -function validateName(name: string, parentDirName: string): string[] { +function validateName(name: string): string[] { const errors: string[] = []; - if (name !== parentDirName) { - errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); - } - if (name.length > MAX_NAME_LENGTH) { errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); } @@ -301,7 +296,7 @@ function loadSkillFromFile( const name = frontmatter.name || parentDirName; // Validate name - const nameErrors = validateName(name, parentDirName); + const nameErrors = validateName(name); for (const error of nameErrors) { diagnostics.push({ type: "warning", message: error, path: filePath }); } @@ -385,28 +380,16 @@ export interface LoadSkillsOptions { includeDefaults: boolean; } -function normalizePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return homedir(); - if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); - return trimmed; -} - -function resolveSkillPath(p: string, cwd: string): string { - const normalized = normalizePath(p); - return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); -} - /** * Load skills from all configured locations. * Returns skills and any validation diagnostics. */ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { - const { cwd, agentDir, skillPaths, includeDefaults } = options; + const { agentDir, skillPaths, includeDefaults } = options; // Resolve agentDir - if not provided, use default from config - const resolvedAgentDir = agentDir ?? getAgentDir(); + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir()); const skillMap = new Map(); const realPathSet = new Set(); @@ -446,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { if (includeDefaults) { addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); - addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); + addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true)); } const userSkillsDir = join(resolvedAgentDir, "skills"); - const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); + const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); const isUnderPath = (target: string, root: string): boolean => { const normalizedRoot = resolve(root); @@ -470,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { }; for (const rawPath of skillPaths) { - const resolvedPath = resolveSkillPath(rawPath, cwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); continue; diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index ffff92bb..e52892c8 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -1,5 +1,5 @@ -import { APP_NAME } from "../config.js"; -import type { SourceInfo } from "./source-info.js"; +import { APP_NAME } from "../config.ts"; +import type { SourceInfo } from "./source-info.ts"; export type SlashCommandSource = "extension" | "prompt" | "skill"; diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts index c75d3f94..c8c9837d 100644 --- a/packages/coding-agent/src/core/source-info.ts +++ b/packages/coding-agent/src/core/source-info.ts @@ -1,4 +1,4 @@ -import type { PathMetadata } from "./package-manager.js"; +import type { PathMetadata } from "./package-manager.ts"; export type SourceScope = "user" | "project" | "temporary"; export type SourceOrigin = "package" | "top-level"; diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 6e474992..8664d871 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -2,8 +2,8 @@ * System prompt construction and project context loading */ -import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js"; -import { formatSkillsForPrompt, type Skill } from "./skills.js"; +import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts"; +import { formatSkillsForPrompt, type Skill } from "./skills.ts"; export interface BuildSystemPromptOptions { /** Custom system prompt (replaces default). */ @@ -59,11 +59,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { // Append project context files if (contextFiles.length > 0) { - prompt += "\n\n# Project Context\n\n"; + prompt += "\n\n\n\n"; prompt += "Project-specific instructions and guidelines:\n\n"; for (const { path: filePath, content } of contextFiles) { - prompt += `## ${filePath}\n\n${content}\n\n`; + prompt += `\n${content}\n\n\n`; } + prompt += "\n"; } // Append skills section (only if read tool is available) @@ -142,6 +143,7 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi - Main documentation: ${readmePath} - Additional docs: ${docsPath} - Examples: ${examplesPath} (extensions, custom tools, SDK) +- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory - When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md) - When working on pi topics, read the docs and examples, and follow .md cross-references before implementing - Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; @@ -152,11 +154,12 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi // Append project context files if (contextFiles.length > 0) { - prompt += "\n\n# Project Context\n\n"; + prompt += "\n\n\n\n"; prompt += "Project-specific instructions and guidelines:\n\n"; for (const { path: filePath, content } of contextFiles) { - prompt += `## ${filePath}\n\n${content}\n\n`; + prompt += `\n${content}\n\n\n`; } + prompt += "\n"; } // Append skills section (only if read tool is available) diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index b563d34b..25111c2b 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -1,4 +1,4 @@ -import type { SettingsManager } from "./settings-manager.js"; +import type { SettingsManager } from "./settings-manager.ts"; function isTruthyEnvFlag(value: string | undefined): boolean { if (!value) return false; diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1d6591d4..b9e55142 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -3,22 +3,22 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; -import { theme } from "../../modes/interactive/theme/theme.js"; -import { waitForChildProcess } from "../../utils/child-process.js"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.ts"; +import { theme } from "../../modes/interactive/theme/theme.ts"; +import { waitForChildProcess } from "../../utils/child-process.ts"; import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, -} from "../../utils/shell.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { OutputAccumulator } from "./output-accumulator.js"; -import { getTextOutput, invalidArgText, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.js"; +} from "../../utils/shell.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { OutputAccumulator } from "./output-accumulator.ts"; +import { getTextOutput, invalidArgText, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts"; const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), @@ -76,6 +76,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas detached: process.platform !== "win32", env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; @@ -199,7 +200,15 @@ function rebuildBashResultRenderComponent( const state = component.state; component.clear(); - const output = getTextOutput(result as any, showImages).trim(); + let output = getTextOutput(result as any, showImages).trim(); + const truncation = result.details?.truncation; + const fullOutputPath = result.details?.fullOutputPath; + if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) { + const footerStart = output.lastIndexOf("\n\n["); + if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) { + output = output.slice(0, footerStart).trimEnd(); + } + } if (output) { const styledOutput = output @@ -235,8 +244,6 @@ function rebuildBashResultRenderComponent( } } - const truncation = result.details?.truncation; - const fullOutputPath = result.details?.fullOutputPath; if (truncation?.truncated || fullOutputPath) { const warnings: string[] = []; if (fullOutputPath) { diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index d8c4c003..f280bf19 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -6,7 +6,7 @@ import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; -import { resolveToCwd } from "./path-utils.js"; +import { resolveToCwd } from "./path-utils.ts"; export function detectLineEnding(content: string): "\r\n" | "\n" { const crlfIdx = content.indexOf("\r\n"); @@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent( return { baseContent, newContent }; } +/** Generate a standard unified patch. */ +export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { + return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { + context: contextLines, + headerOptions: Diff.FILE_HEADERS_ONLY, + }); +} + /** - * Generate a unified diff string with line numbers and context. + * Generate a display-oriented diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). */ export function generateDiffString( diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 6a5f960f..5d915d45 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -3,8 +3,8 @@ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; import { type Static, Type } from "typebox"; -import { renderDiff } from "../../modes/interactive/components/diff.js"; -import type { ToolDefinition } from "../extensions/types.js"; +import { renderDiff } from "../../modes/interactive/components/diff.ts"; +import type { ToolDefinition } from "../extensions/types.ts"; import { applyEditsToNormalizedContent, computeEditsDiff, @@ -13,14 +13,15 @@ import { type EditDiffError, type EditDiffResult, generateDiffString, + generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, -} from "./edit-diff.js"; -import { withFileMutationQueue } from "./file-mutation-queue.js"; -import { resolveToCwd } from "./path-utils.js"; -import { invalidArgText, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +} from "./edit-diff.ts"; +import { withFileMutationQueue } from "./file-mutation-queue.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; type EditPreview = EditDiffResult | EditDiffError; @@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & { }; export interface EditToolDetails { - /** Unified diff of the changes made */ + /** Display-oriented diff of the changes made */ diff: string; + /** Standard unified patch of the changes made */ + patch: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; } @@ -190,7 +193,7 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path function formatEditCall( args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): string { const invalidArg = invalidArgText(theme); const rawPath = str(args?.file_path ?? args?.path); @@ -203,7 +206,7 @@ function formatEditResult( args: RenderableEditArgs | undefined, preview: EditPreview | undefined, result: EditToolResultLike, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, isError: boolean, ): string | undefined { const rawPath = str(args?.file_path ?? args?.path); @@ -231,7 +234,7 @@ function formatEditResult( function getEditHeaderBg( preview: EditPreview | undefined, settledError: boolean | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): (text: string) => string { if (preview) { if ("error" in preview) { @@ -248,7 +251,7 @@ function getEditHeaderBg( function buildEditCallComponent( component: EditCallRenderComponent, args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): EditCallRenderComponent { component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); @@ -394,6 +397,7 @@ export function createEditToolDefinition( } const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); resolve({ content: [ { @@ -401,7 +405,7 @@ export function createEditToolDefinition( text: `Successfully replaced ${edits.length} block(s) in ${path}.`, }, ], - details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }, + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, }); } catch (error: unknown) { // Clean up abort handler. diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index e2d552d4..3fd2f683 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -5,13 +5,13 @@ import { spawn } from "child_process"; import { existsSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import { ensureTool } from "../../utils/tools-manager.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { resolveToCwd } from "./path-utils.js"; -import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; -import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; function toPosixPath(value: string): string { return value.split(path.sep).join("/"); @@ -58,7 +58,7 @@ export interface FindToolOptions { function formatFindCall( args: { pattern: string; path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); @@ -82,7 +82,7 @@ function formatFindResult( details?: FindToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 4d441b18..041b3148 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -5,12 +5,12 @@ import { spawn } from "child_process"; import { readFileSync, statSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import { ensureTool } from "../../utils/tools-manager.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { resolveToCwd } from "./path-utils.js"; -import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, @@ -18,7 +18,7 @@ import { type TruncationResult, truncateHead, truncateLine, -} from "./truncate.js"; +} from "./truncate.ts"; const grepSchema = Type.Object({ pattern: Type.String({ description: "Search pattern (regex or literal string)" }), @@ -66,7 +66,7 @@ export interface GrepToolOptions { function formatGrepCall( args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); @@ -90,7 +90,7 @@ function formatGrepResult( details?: GrepToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 659b5484..e55f9140 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -8,7 +8,7 @@ export { createBashTool, createBashToolDefinition, createLocalBashOperations, -} from "./bash.js"; +} from "./bash.ts"; export { createEditTool, createEditToolDefinition, @@ -16,8 +16,8 @@ export { type EditToolDetails, type EditToolInput, type EditToolOptions, -} from "./edit.js"; -export { withFileMutationQueue } from "./file-mutation-queue.js"; +} from "./edit.ts"; +export { withFileMutationQueue } from "./file-mutation-queue.ts"; export { createFindTool, createFindToolDefinition, @@ -25,7 +25,7 @@ export { type FindToolDetails, type FindToolInput, type FindToolOptions, -} from "./find.js"; +} from "./find.ts"; export { createGrepTool, createGrepToolDefinition, @@ -33,7 +33,7 @@ export { type GrepToolDetails, type GrepToolInput, type GrepToolOptions, -} from "./grep.js"; +} from "./grep.ts"; export { createLsTool, createLsToolDefinition, @@ -41,7 +41,7 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, -} from "./ls.js"; +} from "./ls.ts"; export { createReadTool, createReadToolDefinition, @@ -49,7 +49,7 @@ export { type ReadToolDetails, type ReadToolInput, type ReadToolOptions, -} from "./read.js"; +} from "./read.ts"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -59,24 +59,24 @@ export { truncateHead, truncateLine, truncateTail, -} from "./truncate.js"; +} from "./truncate.ts"; export { createWriteTool, createWriteToolDefinition, type WriteOperations, type WriteToolInput, type WriteToolOptions, -} from "./write.js"; +} from "./write.ts"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ToolDefinition } from "../extensions/types.js"; -import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.js"; -import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.js"; -import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.js"; -import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.js"; -import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.js"; -import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.js"; -import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.js"; +import type { ToolDefinition } from "../extensions/types.ts"; +import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.ts"; +import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.ts"; +import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; +import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; +import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; +import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index e6ae22c7..f3d22c1d 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -3,12 +3,12 @@ import { Text } from "@earendil-works/pi-tui"; import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { resolveToCwd } from "./path-utils.js"; -import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; -import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; const lsSchema = Type.Object({ path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })), @@ -50,7 +50,7 @@ export interface LsToolOptions { function formatLsCall( args: { path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): string { const rawPath = str(args?.path); const path = rawPath !== null ? shortenPath(rawPath || ".") : null; @@ -69,7 +69,7 @@ function formatLsResult( details?: LsToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); diff --git a/packages/coding-agent/src/core/tools/output-accumulator.ts b/packages/coding-agent/src/core/tools/output-accumulator.ts index 30f4d663..e13fe458 100644 --- a/packages/coding-agent/src/core/tools/output-accumulator.ts +++ b/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { createWriteStream, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts"; export interface OutputAccumulatorOptions { maxLines?: number; @@ -45,8 +45,10 @@ export class OutputAccumulator { private tailStartsAtLineBoundary = true; private totalRawBytes = 0; private totalDecodedBytes = 0; - private totalLines = 1; + private completedLines = 0; + private totalLines = 0; private currentLineBytes = 0; + private hasOpenLine = false; private finished = false; private tempFilePath: string | undefined; @@ -164,10 +166,14 @@ export class OutputAccumulator { } if (newlines === 0) { this.currentLineBytes += bytes; + this.hasOpenLine = true; } else { - this.totalLines += newlines; - this.currentLineBytes = byteLength(text.slice(lastNewline + 1)); + this.completedLines += newlines; + const tail = text.slice(lastNewline + 1); + this.currentLineBytes = byteLength(tail); + this.hasOpenLine = tail.length > 0; } + this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); } private trimTail(): void { diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 0c10b52e..1ed1e2bc 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,12 +1,7 @@ import { accessSync, constants } from "node:fs"; -import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} function tryMacOSScreenshotPath(filePath: string): string { return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`); @@ -32,19 +27,8 @@ function fileExists(filePath: string): boolean { } } -function normalizeAtPrefix(filePath: string): string { - return filePath.startsWith("@") ? filePath.slice(1) : filePath; -} - export function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); - if (normalized === "~") { - return os.homedir(); - } - if (normalized.startsWith("~/")) { - return os.homedir() + normalized.slice(1); - } - return normalized; + return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } /** @@ -52,11 +36,7 @@ export function expandPath(filePath: string): string { * Handles ~ expansion and absolute paths. */ export function resolveToCwd(filePath: string, cwd: string): string { - const expanded = expandPath(filePath); - if (isAbsolute(expanded)) { - return expanded; - } - return resolvePath(cwd, expanded); + return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } export function resolveReadPath(filePath: string, cwd: string): string { diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 867d04dc..cd3b41ed 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -5,17 +5,17 @@ import { Text } from "@earendil-works/pi-tui"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; import { type Static, Type } from "typebox"; -import { getReadmePath } from "../../config.js"; -import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js"; -import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.js"; -import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; -import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; -import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { resolveReadPath } from "./path-utils.js"; -import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; +import { getReadmePath } from "../../config.ts"; +import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.ts"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; +import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts"; +import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; +import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveReadPath } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 8cdfc475..1614ed64 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,8 +1,8 @@ import * as os from "node:os"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; -import { sanitizeBinaryOutput } from "../../utils/shell.js"; +import { stripAnsi } from "../../utils/ansi.ts"; +import { sanitizeBinaryOutput } from "../../utils/shell.ts"; export function shortenPath(path: unknown): string { if (typeof path !== "string") return ""; diff --git a/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts index 1aca3f30..c9da525a 100644 --- a/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts +++ b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts @@ -1,5 +1,5 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ExtensionContext, ToolDefinition } from "../extensions/types.js"; +import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; /** Wrap a ToolDefinition into an AgentTool for the core runtime. */ export function wrapToolDefinition( diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index 18ac5d74..c638ee48 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -44,6 +44,17 @@ export interface TruncationOptions { maxBytes?: number; } +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) { + return []; + } + const lines = content.split("\n"); + if (content.endsWith("\n")) { + lines.pop(); + } + return lines; +} + /** * Format bytes as human-readable size. */ @@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed @@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index e1aa40aa..5c07848e 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -3,13 +3,13 @@ import { Container, Text } from "@earendil-works/pi-tui"; import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; import { type Static, Type } from "typebox"; -import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; -import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; -import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js"; -import { withFileMutationQueue } from "./file-mutation-queue.js"; -import { resolveToCwd } from "./path-utils.js"; -import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js"; -import { wrapToolDefinition } from "./tool-definition-wrapper.js"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { withFileMutationQueue } from "./file-mutation-queue.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), @@ -131,7 +131,7 @@ function trimTrailingEmptyLines(lines: string[]): string[] { function formatWriteCall( args: { path?: string; file_path?: string; content?: string } | undefined, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, cache: WriteHighlightCache | undefined, ): string { const rawPath = str(args?.file_path ?? args?.path); @@ -163,7 +163,7 @@ function formatWriteCall( function formatWriteResult( result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }, - theme: typeof import("../../modes/interactive/theme/theme.js").theme, + theme: typeof import("../../modes/interactive/theme/theme.ts").theme, ): string | undefined { if (!result.isError) { return undefined; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8001509f..8627bdcd 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -1,7 +1,7 @@ // Core session management // Config paths -export { getAgentDir, VERSION } from "./config.js"; +export { getAgentDir, VERSION } from "./config.ts"; export { AgentSession, type AgentSessionConfig, @@ -12,7 +12,7 @@ export { type PromptOptions, parseSkillBlock, type SessionStats, -} from "./core/agent-session.js"; +} from "./core/agent-session.ts"; // Auth and model registry export { type ApiKeyCredential, @@ -23,7 +23,7 @@ export { FileAuthStorageBackend, InMemoryAuthStorageBackend, type OAuthCredential, -} from "./core/auth-storage.js"; +} from "./core/auth-storage.ts"; // Compaction export { type BranchPreparation, @@ -46,8 +46,8 @@ export { prepareBranchEntries, serializeConversation, shouldCompact, -} from "./core/compaction/index.js"; -export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js"; +} from "./core/compaction/index.ts"; +export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts"; // Extension system export type { AgentEndEvent, @@ -128,7 +128,7 @@ export type { WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, -} from "./core/extensions/index.js"; +} from "./core/extensions/index.ts"; export { createExtensionRuntime, defineTool, @@ -144,11 +144,11 @@ export { isWriteToolResult, wrapRegisteredTool, wrapRegisteredTools, -} from "./core/extensions/index.js"; +} from "./core/extensions/index.ts"; // Footer data provider (git branch + extension statuses - data not otherwise available to extensions) -export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; -export { convertToLlm } from "./core/messages.js"; -export { ModelRegistry } from "./core/model-registry.js"; +export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"; +export { convertToLlm } from "./core/messages.ts"; +export { ModelRegistry } from "./core/model-registry.ts"; export type { PackageManager, PathMetadata, @@ -156,10 +156,10 @@ export type { ProgressEvent, ResolvedPaths, ResolvedResource, -} from "./core/package-manager.js"; -export { DefaultPackageManager } from "./core/package-manager.js"; -export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js"; -export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.js"; +} from "./core/package-manager.ts"; +export { DefaultPackageManager } from "./core/package-manager.ts"; +export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts"; +export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts"; // SDK for programmatic usage export { AgentSessionRuntime, @@ -187,7 +187,7 @@ export { createReadTool, createWriteTool, type PromptTemplate, -} from "./core/sdk.js"; +} from "./core/sdk.ts"; export { type BranchSummaryEntry, buildSessionContext, @@ -210,14 +210,14 @@ export { SessionManager, type SessionMessageEntry, type ThinkingLevelChangeEntry, -} from "./core/session-manager.js"; +} from "./core/session-manager.ts"; export { type CompactionSettings, type ImageSettings, type PackageSource, type RetrySettings, SettingsManager, -} from "./core/settings-manager.js"; +} from "./core/settings-manager.ts"; // Skills export { formatSkillsForPrompt, @@ -227,8 +227,8 @@ export { loadSkillsFromDir, type Skill, type SkillFrontmatter, -} from "./core/skills.js"; -export { createSyntheticSourceInfo } from "./core/source-info.js"; +} from "./core/skills.ts"; +export { createSyntheticSourceInfo } from "./core/source-info.ts"; // Tools export { type BashOperations, @@ -278,9 +278,9 @@ export { type WriteToolInput, type WriteToolOptions, withFileMutationQueue, -} from "./core/tools/index.js"; +} from "./core/tools/index.ts"; // Main entry point -export { type MainOptions, main } from "./main.js"; +export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage export { InteractiveMode, @@ -295,7 +295,7 @@ export { type RpcSessionState, runPrintMode, runRpcMode, -} from "./modes/index.js"; +} from "./modes/index.ts"; // UI components for extensions export { ArminComponent, @@ -334,7 +334,7 @@ export { UserMessageComponent, UserMessageSelectorComponent, type VisualTruncateResult, -} from "./modes/interactive/components/index.js"; +} from "./modes/interactive/components/index.ts"; // Theme utilities for custom tools and extensions export { getLanguageFromPath, @@ -345,9 +345,10 @@ export { initTheme, Theme, type ThemeColor, -} from "./modes/interactive/theme/theme.js"; +} from "./modes/interactive/theme/theme.ts"; // Clipboard utilities -export { copyToClipboard } from "./utils/clipboard.js"; -export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js"; +export { copyToClipboard } from "./utils/clipboard.ts"; +export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; +export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; // Shell utilities -export { getShellConfig } from "./utils/shell.js"; +export { getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5a29a1fc..5395e045 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,47 +5,48 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import { resolve } from "node:path"; import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.js"; -import { processFileArguments } from "./cli/file-processor.js"; -import { buildInitialMessage } from "./cli/initial-message.js"; -import { listModels } from "./cli/list-models.js"; -import { selectSession } from "./cli/session-picker.js"; -import { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from "./config.js"; -import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.js"; +import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; +import { processFileArguments } from "./cli/file-processor.ts"; +import { buildInitialMessage } from "./cli/initial-message.ts"; +import { listModels } from "./cli/list-models.ts"; +import { selectSession } from "./cli/session-picker.ts"; +import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; +import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { type AgentSessionRuntimeDiagnostic, createAgentSessionFromServices, createAgentSessionServices, -} from "./core/agent-session-services.js"; -import { formatNoModelsAvailableMessage } from "./core/auth-guidance.js"; -import { AuthStorage } from "./core/auth-storage.js"; -import { exportFromFile } from "./core/export-html/index.js"; -import type { ExtensionFactory } from "./core/extensions/types.js"; -import { KeybindingsManager } from "./core/keybindings.js"; -import type { ModelRegistry } from "./core/model-registry.js"; -import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; -import { restoreStdout, takeOverStdout } from "./core/output-guard.js"; -import type { CreateAgentSessionOptions } from "./core/sdk.js"; +} from "./core/agent-session-services.ts"; +import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; +import { AuthStorage } from "./core/auth-storage.ts"; +import { exportFromFile } from "./core/export-html/index.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; +import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; +import { KeybindingsManager } from "./core/keybindings.ts"; +import type { ModelRegistry } from "./core/model-registry.ts"; +import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; +import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import { formatMissingSessionCwdPrompt, getMissingSessionCwdIssue, MissingSessionCwdError, type SessionCwdIssue, -} from "./core/session-cwd.js"; -import { SessionManager } from "./core/session-manager.js"; -import { SettingsManager } from "./core/settings-manager.js"; -import { printTimings, resetTimings, time } from "./core/timings.js"; -import { runMigrations, showDeprecationWarnings } from "./migrations.js"; -import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; -import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.js"; -import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js"; -import { isLocalPath } from "./utils/paths.js"; +} from "./core/session-cwd.ts"; +import { SessionManager } from "./core/session-manager.ts"; +import { SettingsManager } from "./core/settings-manager.ts"; +import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; +import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; +import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; +import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; +import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; +import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; /** * Read all content from piped stdin. @@ -145,9 +146,9 @@ type ResolvedSession = * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { - // If it looks like a file path, use as-is + // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { - return { type: "path", path: sessionArg }; + return { type: "path", path: resolvePath(sessionArg, cwd) }; } // Try to match as session ID in current project first @@ -379,7 +380,7 @@ function buildSessionOptions( } function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined { - return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value)); + return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } async function promptForMissingSessionCwd( @@ -428,6 +429,10 @@ export async function main(args: string[], options?: MainOptions) { process.env.PI_SKIP_VERSION_CHECK = "1"; } + if (process.platform === "win32") { + cleanupWindowsSelfUpdateQuarantine(getPackageDir()); + } + if (await handlePackageCommand(args)) { return; } @@ -495,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) { // sessionDir lookup during session selection. const envSessionDir = process.env[ENV_SESSION_DIR]; const sessionDir = - parsed.sessionDir ?? + (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ?? (envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? startupSettingsManager.getSessionDir(); let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); @@ -612,6 +617,7 @@ export async function main(args: string[], options?: MainOptions) { }); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRegistry, resourceLoader } = services; + configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { const extensionFlags = resourceLoader @@ -651,7 +657,6 @@ export async function main(args: string[], options?: MainOptions) { await showDeprecationWarnings(deprecationWarnings); } - const scopedModels = [...session.scopedModels]; time("resolveModelScope"); reportDiagnostics(runtime.diagnostics); if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) { @@ -674,16 +679,6 @@ export async function main(args: string[], options?: MainOptions) { printTimings(); await runRpcMode(runtime); } else if (appMode === "interactive") { - if (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) { - const modelList = scopedModels - .map((sm) => { - const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : ""; - return `${sm.model.id}${thinkingStr}`; - }) - .join(", "); - console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); - } - const interactiveMode = new InteractiveMode(runtime, { migratedProviders, modelFallbackMessage, diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 42851d08..39aeea04 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -5,8 +5,8 @@ import chalk from "chalk"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.js"; -import { migrateKeybindingsConfig } from "./core/keybindings.js"; +import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; +import { migrateKeybindingsConfig } from "./core/keybindings.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 205e9f54..02cab782 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -2,8 +2,8 @@ * Run modes for the coding agent. */ -export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.js"; -export { type PrintModeOptions, runPrintMode } from "./print-mode.js"; -export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.js"; -export { runRpcMode } from "./rpc/rpc-mode.js"; -export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.js"; +export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.ts"; +export { type PrintModeOptions, runPrintMode } from "./print-mode.ts"; +export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts"; +export { runRpcMode } from "./rpc/rpc-mode.ts"; +export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/armin.ts b/packages/coding-agent/src/modes/interactive/components/armin.ts index 794e5562..209ca167 100644 --- a/packages/coding-agent/src/modes/interactive/components/armin.ts +++ b/packages/coding-agent/src/modes/interactive/components/armin.ts @@ -3,7 +3,7 @@ */ import type { Component, TUI } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; +import { theme } from "../theme/theme.ts"; // XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground const WIDTH = 31; diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 7eb7e306..32276467 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -1,6 +1,6 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index bfe733f8..e53f5a5e 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -3,17 +3,17 @@ */ import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail, -} from "../../../core/tools/truncate.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint, keyText } from "./keybinding-hints.js"; -import { truncateToVisualLines } from "./visual-truncate.js"; +} from "../../../core/tools/truncate.ts"; +import { stripAnsi } from "../../../utils/ansi.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, keyText } from "./keybinding-hints.ts"; +import { truncateToVisualLines } from "./visual-truncate.ts"; // Preview line limit when not expanded (matches tool execution behavior) const PREVIEW_LINES = 20; diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index e9b13919..4d692ffc 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -1,7 +1,7 @@ import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import type { Theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import type { Theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; /** Loader wrapped with borders for extension UI */ export class BorderedLoader extends Container { diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index b140f496..af85115f 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; -import type { BranchSummaryMessage } from "../../../core/messages.js"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { keyText } from "./keybinding-hints.js"; +import type { BranchSummaryMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; /** * Component that renders a branch summary message with collapsed/expanded state. diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index e4b78f88..cad613d7 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; -import type { CompactionSummaryMessage } from "../../../core/messages.js"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { keyText } from "./keybinding-hints.js"; +import type { CompactionSummaryMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; /** * Component that renders a compaction message with collapsed/expanded state. diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 8c5cbfd7..d4f16726 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -15,12 +15,12 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { CONFIG_DIR_NAME } from "../../../config.js"; -import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js"; -import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { rawKeyHint } from "./keybinding-hints.js"; +import { CONFIG_DIR_NAME } from "../../../config.ts"; +import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts"; +import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { rawKeyHint } from "./keybinding-hints.ts"; type ResourceType = "extensions" | "skills" | "prompts" | "themes"; @@ -201,7 +201,7 @@ class ResourceList implements Component, Focusable { private filteredItems: FlatEntry[] = []; private selectedIndex = 0; private searchInput: Input; - private maxVisible = 15; + private maxVisible: number; private settingsManager: SettingsManager; private cwd: string; private agentDir: string; @@ -219,12 +219,21 @@ class ResourceList implements Component, Focusable { this.searchInput.focused = value; } - constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string, agentDir: string) { + constructor( + groups: ResourceGroup[], + settingsManager: SettingsManager, + cwd: string, + agentDir: string, + terminalHeight?: number, + ) { this.groups = groups; this.settingsManager = settingsManager; this.cwd = cwd; this.agentDir = agentDir; this.searchInput = new Input(); + // 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border + const chrome = 8; + this.maxVisible = Math.max(5, (terminalHeight ?? 24) - chrome); this.buildFlatList(); this.filteredItems = [...this.flatItems]; } @@ -588,6 +597,7 @@ export class ConfigSelectorComponent extends Container implements Focusable { onClose: () => void, onExit: () => void, requestRender: () => void, + terminalHeight?: number, ) { super(); @@ -601,7 +611,7 @@ export class ConfigSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); // Resource list - this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir); + this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir, terminalHeight); this.resourceList.onCancel = onClose; this.resourceList.onExit = onExit; this.resourceList.onToggle = () => requestRender(); diff --git a/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts b/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts index ac730644..73ec2734 100644 --- a/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts +++ b/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts @@ -7,13 +7,14 @@ import type { TUI } from "@earendil-works/pi-tui"; export class CountdownTimer { private intervalId: ReturnType | undefined; private remainingSeconds: number; + private tui: TUI | undefined; + private onTick: (seconds: number) => void; + private onExpire: () => void; - constructor( - timeoutMs: number, - private tui: TUI | undefined, - private onTick: (seconds: number) => void, - private onExpire: () => void, - ) { + constructor(timeoutMs: number, tui: TUI | undefined, onTick: (seconds: number) => void, onExpire: () => void) { + this.tui = tui; + this.onTick = onTick; + this.onExpire = onExpire; this.remainingSeconds = Math.ceil(timeoutMs / 1000); this.onTick(this.remainingSeconds); diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 18f7d27a..a9d25622 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,5 +1,5 @@ import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui"; -import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js"; +import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts"; /** * Custom editor that handles app-level keybindings for coding-agent. diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 5cf6bbe2..a8fffc6f 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -1,9 +1,9 @@ import type { TextContent } from "@earendil-works/pi-ai"; import type { Component } from "@earendil-works/pi-tui"; import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; -import type { MessageRenderer } from "../../../core/extensions/types.js"; -import type { CustomMessage } from "../../../core/messages.js"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; +import type { MessageRenderer } from "../../../core/extensions/types.ts"; +import type { CustomMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; /** * Component that renders a custom message entry from extensions. diff --git a/packages/coding-agent/src/modes/interactive/components/daxnuts.ts b/packages/coding-agent/src/modes/interactive/components/daxnuts.ts index bfc4dbaa..c855f146 100644 --- a/packages/coding-agent/src/modes/interactive/components/daxnuts.ts +++ b/packages/coding-agent/src/modes/interactive/components/daxnuts.ts @@ -5,7 +5,7 @@ */ import type { Component, TUI } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; +import { theme } from "../theme/theme.ts"; // 32x32 RGB image of dax, hex encoded (3 bytes per pixel) const DAX_HEX = diff --git a/packages/coding-agent/src/modes/interactive/components/diff.ts b/packages/coding-agent/src/modes/interactive/components/diff.ts index d575d63e..54e88273 100644 --- a/packages/coding-agent/src/modes/interactive/components/diff.ts +++ b/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -1,5 +1,5 @@ import * as Diff from "diff"; -import { theme } from "../theme/theme.js"; +import { theme } from "../theme/theme.ts"; /** * Parse diff line to extract prefix, line number, and content. diff --git a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts index d4fa67c4..77342b25 100644 --- a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -1,5 +1,5 @@ import type { Component } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; +import { theme } from "../theme/theme.ts"; /** * Dynamic border component that adjusts to viewport width. diff --git a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts index 59ec78f7..f2726d98 100644 --- a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts +++ b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs"; import { Container, Image, Spacer, Text } from "@earendil-works/pi-tui"; -import { getBundledInteractiveAssetPath } from "../../../config.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import { getBundledInteractiveAssetPath } from "../../../config.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; const BLOG_URL = "https://mariozechner.at/posts/2026-04-08-ive-sold-out/"; const IMAGE_FILENAME = "clankolas.png"; diff --git a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts index e1134ce9..e000fff4 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -3,7 +3,7 @@ * Supports Ctrl+G for external editor. */ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -17,10 +17,10 @@ import { Text, type TUI, } from "@earendil-works/pi-tui"; -import type { KeybindingsManager } from "../../../core/keybindings.js"; -import { getEditorTheme, theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import type { KeybindingsManager } from "../../../core/keybindings.ts"; +import { getEditorTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; export class ExtensionEditorComponent extends Container implements Focusable { private editor: Editor; @@ -110,7 +110,7 @@ export class ExtensionEditorComponent extends Container implements Focusable { this.editor.handleInput(keyData); } - private openExternalEditor(): void { + private async openExternalEditor(): Promise { const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { return; @@ -124,12 +124,21 @@ export class ExtensionEditorComponent extends Container implements Focusable { this.tui.stop(); const [editor, ...editorArgs] = editorCmd.split(" "); - const result = spawnSync(editor, [...editorArgs, tmpFile], { - stdio: "inherit", - shell: process.platform === "win32", + process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`); + + // Do not use spawnSync here. On Windows, synchronous child_process calls can keep + // Node/libuv's console input read active after tui.stop() pauses stdin, racing + // vim/nvim for the console input buffer until Ctrl+C cancels the pending read. + const status = await new Promise((resolve) => { + const child = spawn(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); }); - if (result.status === 0) { + if (status === 0) { const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); this.editor.setText(newContent); } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-input.ts b/packages/coding-agent/src/modes/interactive/components/extension-input.ts index 39f44f0f..581ef4f5 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-input.ts @@ -3,10 +3,10 @@ */ import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; -import { CountdownTimer } from "./countdown-timer.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import { theme } from "../theme/theme.ts"; +import { CountdownTimer } from "./countdown-timer.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; export interface ExtensionInputOptions { tui?: TUI; diff --git a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts index c2fa547e..d1dbdb4d 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -4,14 +4,15 @@ */ import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; -import { CountdownTimer } from "./countdown-timer.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint, rawKeyHint } from "./keybinding-hints.js"; +import { theme } from "../theme/theme.ts"; +import { CountdownTimer } from "./countdown-timer.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; export interface ExtensionSelectorOptions { tui?: TUI; timeout?: number; + onToggleToolsExpanded?: () => void; } export class ExtensionSelectorComponent extends Container { @@ -23,6 +24,7 @@ export class ExtensionSelectorComponent extends Container { private titleText: Text; private baseTitle: string; private countdown: CountdownTimer | undefined; + private onToggleToolsExpanded: (() => void) | undefined; constructor( title: string, @@ -36,6 +38,7 @@ export class ExtensionSelectorComponent extends Container { this.options = options; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; + this.onToggleToolsExpanded = opts?.onToggleToolsExpanded; this.baseTitle = title; this.addChild(new DynamicBorder()); @@ -87,7 +90,9 @@ export class ExtensionSelectorComponent extends Container { handleInput(keyData: string): void { const kb = getKeybindings(); - if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + if (kb.matches(keyData, "app.tools.expand")) { + this.onToggleToolsExpanded?.(); + } else if (kb.matches(keyData, "tui.select.up") || keyData === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 8f0653e7..a48898a5 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,7 +1,7 @@ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { AgentSession } from "../../../core/agent-session.js"; -import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js"; -import { theme } from "../theme/theme.js"; +import type { AgentSession } from "../../../core/agent-session.ts"; +import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; +import { theme } from "../theme/theme.ts"; /** * Sanitize text for display in a single-line status. @@ -16,7 +16,7 @@ function sanitizeStatusText(text: string): string { } /** - * Format token counts (similar to web-ui) + * Format token counts for compact footer display. */ function formatTokens(count: number): string { if (count < 1000) return count.toString(); @@ -32,11 +32,13 @@ function formatTokens(count: number): string { */ export class FooterComponent implements Component { private autoCompactEnabled = true; + private session: AgentSession; + private footerData: ReadonlyFooterDataProvider; - constructor( - private session: AgentSession, - private footerData: ReadonlyFooterDataProvider, - ) {} + constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) { + this.session = session; + this.footerData = footerData; + } setSession(session: AgentSession): void { this.session = session; diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 242d9667..d9b5d773 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -1,32 +1,32 @@ // UI Components for extensions -export { ArminComponent } from "./armin.js"; -export { AssistantMessageComponent } from "./assistant-message.js"; -export { BashExecutionComponent } from "./bash-execution.js"; -export { BorderedLoader } from "./bordered-loader.js"; -export { BranchSummaryMessageComponent } from "./branch-summary-message.js"; -export { CompactionSummaryMessageComponent } from "./compaction-summary-message.js"; -export { CustomEditor } from "./custom-editor.js"; -export { CustomMessageComponent } from "./custom-message.js"; -export { DaxnutsComponent } from "./daxnuts.js"; -export { type RenderDiffOptions, renderDiff } from "./diff.js"; -export { DynamicBorder } from "./dynamic-border.js"; -export { ExtensionEditorComponent } from "./extension-editor.js"; -export { ExtensionInputComponent } from "./extension-input.js"; -export { ExtensionSelectorComponent } from "./extension-selector.js"; -export { FooterComponent } from "./footer.js"; -export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.js"; -export { LoginDialogComponent } from "./login-dialog.js"; -export { ModelSelectorComponent } from "./model-selector.js"; -export { OAuthSelectorComponent } from "./oauth-selector.js"; -export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.js"; -export { SessionSelectorComponent } from "./session-selector.js"; -export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js"; -export { ShowImagesSelectorComponent } from "./show-images-selector.js"; -export { SkillInvocationMessageComponent } from "./skill-invocation-message.js"; -export { ThemeSelectorComponent } from "./theme-selector.js"; -export { ThinkingSelectorComponent } from "./thinking-selector.js"; -export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.js"; -export { TreeSelectorComponent } from "./tree-selector.js"; -export { UserMessageComponent } from "./user-message.js"; -export { UserMessageSelectorComponent } from "./user-message-selector.js"; -export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.js"; +export { ArminComponent } from "./armin.ts"; +export { AssistantMessageComponent } from "./assistant-message.ts"; +export { BashExecutionComponent } from "./bash-execution.ts"; +export { BorderedLoader } from "./bordered-loader.ts"; +export { BranchSummaryMessageComponent } from "./branch-summary-message.ts"; +export { CompactionSummaryMessageComponent } from "./compaction-summary-message.ts"; +export { CustomEditor } from "./custom-editor.ts"; +export { CustomMessageComponent } from "./custom-message.ts"; +export { DaxnutsComponent } from "./daxnuts.ts"; +export { type RenderDiffOptions, renderDiff } from "./diff.ts"; +export { DynamicBorder } from "./dynamic-border.ts"; +export { ExtensionEditorComponent } from "./extension-editor.ts"; +export { ExtensionInputComponent } from "./extension-input.ts"; +export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { FooterComponent } from "./footer.ts"; +export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; +export { LoginDialogComponent } from "./login-dialog.ts"; +export { ModelSelectorComponent } from "./model-selector.ts"; +export { OAuthSelectorComponent } from "./oauth-selector.ts"; +export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.ts"; +export { SessionSelectorComponent } from "./session-selector.ts"; +export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.ts"; +export { ShowImagesSelectorComponent } from "./show-images-selector.ts"; +export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts"; +export { ThemeSelectorComponent } from "./theme-selector.ts"; +export { ThinkingSelectorComponent } from "./thinking-selector.ts"; +export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; +export { TreeSelectorComponent } from "./tree-selector.ts"; +export { UserMessageComponent } from "./user-message.ts"; +export { UserMessageSelectorComponent } from "./user-message-selector.ts"; +export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts index bb018c18..22601abd 100644 --- a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts +++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -3,7 +3,7 @@ */ import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; +import { theme } from "../theme/theme.ts"; export interface KeyTextFormatOptions { capitalize?: boolean; diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 737e8b27..80560f14 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,9 +1,9 @@ import { getOAuthProviders } from "@earendil-works/pi-ai/oauth"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; import { exec } from "child_process"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; /** * Login dialog component - replaces editor during OAuth login flow @@ -15,6 +15,7 @@ export class LoginDialogComponent extends Container implements Focusable { private abortController = new AbortController(); private inputResolver?: (value: string) => void; private inputRejecter?: (error: Error) => void; + private onComplete: (success: boolean, message?: string) => void; // Focusable implementation - propagate to input for IME cursor positioning private _focused = false; @@ -29,12 +30,13 @@ export class LoginDialogComponent extends Container implements Focusable { constructor( tui: TUI, providerId: string, - private onComplete: (success: boolean, message?: string) => void, + onComplete: (success: boolean, message?: string) => void, providerNameOverride?: string, titleOverride?: string, ) { super(); this.tui = tui; + this.onComplete = onComplete; const providerInfo = getOAuthProviders().find((p) => p.id === providerId); const providerName = providerNameOverride || providerInfo?.name || providerId; diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 613ca9e1..b9f5ec77 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -9,11 +9,11 @@ import { Text, type TUI, } from "@earendil-works/pi-tui"; -import type { ModelRegistry } from "../../../core/model-registry.js"; -import type { SettingsManager } from "../../../core/settings-manager.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import type { ModelRegistry } from "../../../core/model-registry.ts"; +import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; interface ModelItem { provider: string; diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 93ce0e93..5910e700 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -7,9 +7,9 @@ import { Spacer, TruncatedText, } from "@earendil-works/pi-tui"; -import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; export type AuthSelectorProvider = { id: string; diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index c5a82d9a..06ce9169 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -10,9 +10,9 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyText } from "./keybinding-hints.js"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyText } from "./keybinding-hints.ts"; // EnabledIds: null = all enabled (no filter), string[] = explicit ordered list type EnabledIds = string[] | null; diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index e82a9c05..9b5bf232 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -1,5 +1,5 @@ import { fuzzyMatch } from "@earendil-works/pi-tui"; -import type { SessionInfo } from "../../../core/session-manager.js"; +import type { SessionInfo } from "../../../core/session-manager.ts"; export type SortMode = "threaded" | "recent" | "relevance"; diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index e7c0b072..74141e5e 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -13,13 +13,13 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { KeybindingsManager } from "../../../core/keybindings.js"; -import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; -import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint, keyText } from "./keybinding-hints.js"; -import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js"; +import { KeybindingsManager } from "../../../core/keybindings.ts"; +import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts"; +import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, keyText } from "./keybinding-hints.ts"; +import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.ts"; type SessionScope = "current" | "all"; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d9d9cdf..7d210028 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -11,10 +11,11 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; -import type { WarningSettings } from "../../../core/settings-manager.js"; -import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyDisplayText } from "./keybinding-hints.js"; +import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; +import type { WarningSettings } from "../../../core/settings-manager.ts"; +import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, @@ -40,6 +41,7 @@ export interface SettingsConfig { steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; transport: Transport; + httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; @@ -68,6 +70,7 @@ export interface SettingsCallbacks { onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; + onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; @@ -238,6 +241,14 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "http-idle-timeout", + label: "HTTP idle timeout", + description: + "Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.", + currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs), + values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label), + }, { id: "hide-thinking", label: "Hide thinking", @@ -482,6 +493,13 @@ export class SettingsSelectorComponent extends Container { case "transport": callbacks.onTransportChange(newValue as Transport); break; + case "http-idle-timeout": { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue); + if (choice) { + callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs); + } + break; + } case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); break; diff --git a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts index c0c7bc15..29c69f74 100644 --- a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts @@ -1,6 +1,6 @@ import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getSelectListTheme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import { getSelectListTheme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; const SHOW_IMAGES_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index 8714edc2..e09febc7 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui"; -import type { ParsedSkillBlock } from "../../../core/agent-session.js"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { keyText } from "./keybinding-hints.js"; +import type { ParsedSkillBlock } from "../../../core/agent-session.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; /** * Component that renders a skill invocation message with collapsed/expanded state. diff --git a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts index 29e6e779..07b37742 100644 --- a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts @@ -1,6 +1,6 @@ import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import { getAvailableThemes, getSelectListTheme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index 9eadb86f..91a3a626 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -1,7 +1,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getSelectListTheme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import { getSelectListTheme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 867a0bb5..b941dfc8 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,9 +1,9 @@ import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js"; -import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.js"; -import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js"; -import { convertToPng } from "../../../utils/image-convert.js"; -import { theme } from "../theme/theme.js"; +import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts"; +import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts"; +import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts"; +import { convertToPng } from "../../../utils/image-convert.ts"; +import { theme } from "../theme/theme.ts"; export interface ToolExecutionOptions { showImages?: boolean; diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 29962d7e..990e705d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -9,10 +9,10 @@ import { TruncatedText, truncateToWidth, } from "@earendil-works/pi-tui"; -import type { SessionTreeNode } from "../../../core/session-manager.js"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint, keyText } from "./keybinding-hints.js"; +import type { SessionTreeNode } from "../../../core/session-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, keyText } from "./keybinding-hints.ts"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -1056,7 +1056,11 @@ class TreeList implements Component { /** Component that displays the current search query */ class SearchLine implements Component { - constructor(private treeList: TreeList) {} + private treeList: TreeList; + + constructor(treeList: TreeList) { + this.treeList = treeList; + } invalidate(): void {} diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 6cf4223a..69fbbc82 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -1,6 +1,6 @@ import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui"; -import { theme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; interface UserMessageItem { id: string; // Entry ID in the session diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 969df02f..8fda83e9 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -1,5 +1,5 @@ import { Box, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; -import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 006ee7c5..aa8db52b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,9 +57,9 @@ import { getDocsPath, getShareViewerUrl, VERSION, -} from "../../config.js"; -import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; -import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; +} from "../../config.ts"; +import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; +import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -69,57 +69,58 @@ import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, -} from "../../core/extensions/index.js"; -import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js"; -import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js"; -import { createCompactionSummaryMessage } from "../../core/messages.js"; -import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js"; -import { DefaultPackageManager } from "../../core/package-manager.js"; -import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.js"; -import type { ResourceDiagnostic } from "../../core/resource-loader.js"; -import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js"; -import { type SessionContext, SessionManager } from "../../core/session-manager.js"; -import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; -import type { SourceInfo } from "../../core/source-info.js"; -import { isInstallTelemetryEnabled } from "../../core/telemetry.js"; -import type { TruncationResult } from "../../core/tools/truncate.js"; -import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; -import { copyToClipboard } from "../../utils/clipboard.js"; -import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; -import { parseGitUrl } from "../../utils/git.js"; -import { getCwdRelativePath } from "../../utils/paths.js"; -import { getPiUserAgent } from "../../utils/pi-user-agent.js"; -import { killTrackedDetachedChildren } from "../../utils/shell.js"; -import { ensureTool } from "../../utils/tools-manager.js"; -import { checkForNewPiVersion } from "../../utils/version-check.js"; -import { ArminComponent } from "./components/armin.js"; -import { AssistantMessageComponent } from "./components/assistant-message.js"; -import { BashExecutionComponent } from "./components/bash-execution.js"; -import { BorderedLoader } from "./components/bordered-loader.js"; -import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js"; -import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; -import { CountdownTimer } from "./components/countdown-timer.js"; -import { CustomEditor } from "./components/custom-editor.js"; -import { CustomMessageComponent } from "./components/custom-message.js"; -import { DaxnutsComponent } from "./components/daxnuts.js"; -import { DynamicBorder } from "./components/dynamic-border.js"; -import { EarendilAnnouncementComponent } from "./components/earendil-announcement.js"; -import { ExtensionEditorComponent } from "./components/extension-editor.js"; -import { ExtensionInputComponent } from "./components/extension-input.js"; -import { ExtensionSelectorComponent } from "./components/extension-selector.js"; -import { FooterComponent } from "./components/footer.js"; -import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; -import { LoginDialogComponent } from "./components/login-dialog.js"; -import { ModelSelectorComponent } from "./components/model-selector.js"; -import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.js"; -import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; -import { SessionSelectorComponent } from "./components/session-selector.js"; -import { SettingsSelectorComponent } from "./components/settings-selector.js"; -import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js"; -import { ToolExecutionComponent } from "./components/tool-execution.js"; -import { TreeSelectorComponent } from "./components/tree-selector.js"; -import { UserMessageComponent } from "./components/user-message.js"; -import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; +} from "../../core/extensions/index.ts"; +import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; +import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; +import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; +import { createCompactionSummaryMessage } from "../../core/messages.ts"; +import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts"; +import { DefaultPackageManager } from "../../core/package-manager.ts"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts"; +import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; +import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; +import { type SessionContext, SessionManager } from "../../core/session-manager.ts"; +import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; +import type { SourceInfo } from "../../core/source-info.ts"; +import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; +import type { TruncationResult } from "../../core/tools/truncate.ts"; +import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; +import { copyToClipboard } from "../../utils/clipboard.ts"; +import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; +import { parseGitUrl } from "../../utils/git.ts"; +import { getCwdRelativePath } from "../../utils/paths.ts"; +import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; +import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; +import { ArminComponent } from "./components/armin.ts"; +import { AssistantMessageComponent } from "./components/assistant-message.ts"; +import { BashExecutionComponent } from "./components/bash-execution.ts"; +import { BorderedLoader } from "./components/bordered-loader.ts"; +import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; +import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; +import { CountdownTimer } from "./components/countdown-timer.ts"; +import { CustomEditor } from "./components/custom-editor.ts"; +import { CustomMessageComponent } from "./components/custom-message.ts"; +import { DaxnutsComponent } from "./components/daxnuts.ts"; +import { DynamicBorder } from "./components/dynamic-border.ts"; +import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts"; +import { ExtensionEditorComponent } from "./components/extension-editor.ts"; +import { ExtensionInputComponent } from "./components/extension-input.ts"; +import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; +import { FooterComponent } from "./components/footer.ts"; +import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts"; +import { LoginDialogComponent } from "./components/login-dialog.ts"; +import { ModelSelectorComponent } from "./components/model-selector.ts"; +import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.ts"; +import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; +import { SessionSelectorComponent } from "./components/session-selector.ts"; +import { SettingsSelectorComponent } from "./components/settings-selector.ts"; +import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; +import { ToolExecutionComponent } from "./components/tool-execution.ts"; +import { TreeSelectorComponent } from "./components/tree-selector.ts"; +import { UserMessageComponent } from "./components/user-message.ts"; +import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -135,7 +136,7 @@ import { Theme, type ThemeColor, theme, -} from "./theme/theme.js"; +} from "./theme/theme.ts"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -147,14 +148,19 @@ function isExpandable(obj: unknown): obj is Expandable { } class ExpandableText extends Text implements Expandable { + private readonly getCollapsedText: () => string; + private readonly getExpandedText: () => string; + constructor( - private readonly getCollapsedText: () => string, - private readonly getExpandedText: () => string, + getCollapsedText: () => string, + getExpandedText: () => string, expanded = false, paddingX = 0, paddingY = 0, ) { super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY); + this.getCollapsedText = getCollapsedText; + this.getExpandedText = getExpandedText; } setExpanded(expanded: boolean): void { @@ -334,6 +340,8 @@ export class InteractiveMode { // Custom header from extension (undefined = use built-in header) private customHeader: (Component & { dispose?(): void }) | undefined = undefined; + private options: InteractiveModeOptions; + // Convenience accessors private get session(): AgentSession { return this.runtimeHost.session; @@ -348,11 +356,9 @@ export class InteractiveMode { return this.session.settingsManager; } - constructor( - runtimeHost: AgentSessionRuntime, - private options: InteractiveModeOptions = {}, - ) { + constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; + this.options = options; this.runtimeHost.setBeforeSessionInvalidate(() => { this.resetExtensionUI(); }); @@ -572,6 +578,21 @@ export class InteractiveMode { const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); this.fdPath = fdPath; + if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { + const modelList = this.session.scopedModels + .map((sm) => { + const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : ""; + return `${sm.model.id}${thinkingStr}`; + }) + .join(", "); + const cycleKeys = this.keybindings.getKeys("app.model.cycleForward"); + const cycleHint = + cycleKeys.length > 0 + ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`) + : ""; + console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); + } + // Add header container as first child this.ui.addChild(this.headerContainer); @@ -696,9 +717,9 @@ export class InteractiveMode { await this.init(); // Start version check asynchronously - checkForNewPiVersion(this.version).then((newVersion) => { - if (newVersion) { - this.showNewVersionNotification(newVersion); + checkForNewPiVersion(this.version).then((newRelease) => { + if (newRelease) { + this.showNewVersionNotification(newRelease); } }); @@ -1463,6 +1484,9 @@ export class InteractiveMode { const uiContext = this.createExtensionUIContext(); await this.session.bindExtensions({ uiContext, + abortHandler: () => { + this.restoreQueuedMessagesToEditor({ abort: true }); + }, commandContextActions: { waitForIdle: () => this.session.agent.waitForIdle(), newSession: async (options) => { @@ -1543,6 +1567,7 @@ export class InteractiveMode { } private applyRuntimeSettings(): void { + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.footer.setSession(this.session); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); @@ -1612,7 +1637,9 @@ export class InteractiveMode { model: this.session.model, isIdle: () => !this.session.isStreaming, signal: this.session.agent.signal, - abort: () => this.session.abort(), + abort: () => { + this.restoreQueuedMessagesToEditor({ abort: true }); + }, hasPendingMessages: () => this.session.pendingMessageCount > 0, shutdown: () => { this.shutdownRequested = true; @@ -2024,7 +2051,7 @@ export class InteractiveMode { this.hideExtensionSelector(); resolve(undefined); }, - { tui: this.ui, timeout: opts?.timeout }, + { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, ); this.editorContainer.clear(); @@ -3244,6 +3271,36 @@ export class InteractiveMode { process.exit(129); } + /** + * Last-resort handler for uncaught exceptions. The TUI puts stdin into raw + * mode and hides the cursor; without this handler, an uncaught throw from + * anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback) + * tears down the process while leaving the terminal in raw mode with no + * cursor, requiring `stty sane && reset` to recover. + * + * Unlike emergencyTerminalExit, the terminal is still alive here, so we + * call ui.stop() to restore cooked mode, the cursor, and disable bracketed + * paste / Kitty / modifyOtherKeys sequences. + */ + private uncaughtCrash(error: Error): never { + if (this.isShuttingDown) { + process.exit(1); + } + this.isShuttingDown = true; + try { + this.unregisterSignalHandlers(); + } catch {} + try { + killTrackedDetachedChildren(); + } catch {} + try { + this.ui.stop(); + } catch {} + console.error("pi exiting due to uncaughtException:"); + console.error(error); + process.exit(1); + } + /** * Check if shutdown was requested and perform shutdown if so. */ @@ -3282,6 +3339,13 @@ export class InteractiveMode { process.stderr.on("error", terminalErrorHandler); this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); + + // Restore the terminal before the process dies on any uncaught throw. + // Without this, an unhandled exception from extension code (or anywhere + // in pi) leaves the terminal in raw mode with no cursor. + const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error); + process.prependListener("uncaughtException", uncaughtExceptionHandler); + this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler)); } private unregisterSignalHandlers(): void { @@ -3445,7 +3509,7 @@ export class InteractiveMode { this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } - private openExternalEditor(): void { + private async openExternalEditor(): Promise { // Determine editor (respect $VISUAL, then $EDITOR) const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { @@ -3466,14 +3530,22 @@ export class InteractiveMode { // Split by space to support editor arguments (e.g., "code --wait") const [editor, ...editorArgs] = editorCmd.split(" "); - // Spawn editor synchronously with inherited stdio for interactive editing - const result = spawnSync(editor, [...editorArgs, tmpFile], { - stdio: "inherit", - shell: process.platform === "win32", + process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`); + + // Do not use spawnSync here. On Windows, synchronous child_process calls can keep + // Node/libuv's console input read active after ui.stop() pauses stdin, racing + // vim/nvim for the console input buffer until Ctrl+C cancels the pending read. + const status = await new Promise((resolve) => { + const child = spawn(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); }); // On successful exit (status 0), replace editor content - if (result.status === 0) { + if (status === 0) { const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); this.editor.setText(newContent); } @@ -3505,6 +3577,7 @@ export class InteractiveMode { showError(errorMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); + this.chatContainer.addChild(new Spacer(1)); this.ui.requestRender(); } @@ -3514,24 +3587,31 @@ export class InteractiveMode { this.ui.requestRender(); } - showNewVersionNotification(newVersion: string): void { + showNewVersionNotification(release: LatestPiRelease): void { const action = theme.fg("accent", `${APP_NAME} update`); - const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. Run `) + action; - const changelogUrl = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md"; + const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action; + const changelogUrl = "https://pi.dev/changelog"; const changelogLink = getCapabilities().hyperlinks ? hyperlink(theme.fg("accent", "open changelog"), changelogUrl) : theme.fg("accent", changelogUrl); const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink; + const note = release.note?.trim(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.chatContainer.addChild( - new Text( - `${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`, - 1, - 0, - ), + new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0), ); + if (note) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), { + color: (text) => theme.fg("muted", text), + }), + ); + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild(new Text(changelogLine, 1, 0)); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.ui.requestRender(); } @@ -3768,6 +3848,7 @@ export class InteractiveMode { steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), + httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", @@ -3826,6 +3907,11 @@ export class InteractiveMode { this.settingsManager.setTransport(transport); this.session.agent.transport = transport; }, + onHttpIdleTimeoutMsChange: (timeoutMs) => { + this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); + configureHttpDispatcher(timeoutMs); + this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); + }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); this.footer.invalidate(); @@ -4813,6 +4899,7 @@ export class InteractiveMode { try { await this.session.reload(); + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.keybindings.reload(); const activeHeader = this.customHeader ?? this.builtInHeader; if (isExpandable(activeHeader)) { diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index fc52fe68..c133e232 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -7,6 +7,7 @@ "green": "#b5bd68", "red": "#cc6666", "yellow": "#ffff00", + "text": "#d4d4d4", "gray": "#808080", "dimGray": "#666666", "darkGray": "#505050", @@ -28,19 +29,19 @@ "warning": "yellow", "muted": "gray", "dim": "dimGray", - "text": "", + "text": "text", "thinkingText": "gray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", - "userMessageText": "", + "userMessageText": "text", "customMessageBg": "customMsgBg", - "customMessageText": "", + "customMessageText": "text", "customMessageLabel": "#9575cd", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", - "toolTitle": "", + "toolTitle": "text", "toolOutput": "gray", "mdHeading": "#f0c674", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 65304407..0a417b56 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -7,6 +7,7 @@ "green": "#588458", "red": "#aa5555", "yellow": "#9a7326", + "text": "#1f2328", "mediumGray": "#6c6c6c", "dimGray": "#767676", "lightGray": "#b0b0b0", @@ -27,19 +28,19 @@ "warning": "yellow", "muted": "mediumGray", "dim": "dimGray", - "text": "", + "text": "text", "thinkingText": "mediumGray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", - "userMessageText": "", + "userMessageText": "text", "customMessageBg": "customMsgBg", - "customMessageText": "", + "customMessageText": "text", "customMessageLabel": "#7e57c2", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", - "toolTitle": "", + "toolTitle": "text", "toolOutput": "mediumGray", "mdHeading": "yellow", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 955fa44a..d9bcdf8c 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1,13 +1,19 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui"; +import { + type EditorTheme, + getCapabilities, + type MarkdownTheme, + type SelectListTheme, + type SettingsListTheme, +} from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { highlight, supportsLanguage } from "cli-highlight"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; -import { getCustomThemesDir, getThemesDir } from "../../../config.js"; -import type { SourceInfo } from "../../../core/source-info.js"; -import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.js"; +import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; +import type { SourceInfo } from "../../../core/source-info.ts"; +import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; +import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; // ============================================================================ // Types & Schema @@ -158,33 +164,6 @@ type ColorMode = "truecolor" | "256color"; // Color Utilities // ============================================================================ -function detectColorMode(): ColorMode { - const colorterm = process.env.COLORTERM; - if (colorterm === "truecolor" || colorterm === "24bit") { - return "truecolor"; - } - // Windows Terminal supports truecolor - if (process.env.WT_SESSION) { - return "truecolor"; - } - const term = process.env.TERM || ""; - // Fall back to 256color for truly limited terminals - if (term === "dumb" || term === "" || term === "linux") { - return "256color"; - } - // Terminal.app also doesn't support truecolor - if (process.env.TERM_PROGRAM === "Apple_Terminal") { - return "256color"; - } - // GNU screen doesn't support truecolor unless explicitly opted in via COLORTERM=truecolor. - // TERM under screen is typically "screen", "screen-256color", or "screen.xterm-256color". - if (term === "screen" || term.startsWith("screen-") || term.startsWith("screen.")) { - return "256color"; - } - // Assume truecolor for everything else - virtually all modern terminals support it - return "truecolor"; -} - function hexToRgb(hex: string): { r: number; g: number; b: number } { const cleaned = hex.replace("#", ""); if (cleaned.length !== 6) { @@ -461,20 +440,7 @@ function getBuiltinThemes(): Record { } export function getAvailableThemes(): string[] { - const themes = new Set(Object.keys(getBuiltinThemes())); - const customThemesDir = getCustomThemesDir(); - if (fs.existsSync(customThemesDir)) { - const files = fs.readdirSync(customThemesDir); - for (const file of files) { - if (file.endsWith(".json")) { - themes.add(file.slice(0, -5)); - } - } - } - for (const name of registeredThemes.keys()) { - themes.add(name); - } - return Array.from(themes).sort(); + return getAvailableThemesWithPaths().map(({ name }) => name); } export interface ThemeInfo { @@ -484,35 +450,58 @@ export interface ThemeInfo { export function getAvailableThemesWithPaths(): ThemeInfo[] { const themesDir = getThemesDir(); - const customThemesDir = getCustomThemesDir(); const result: ThemeInfo[] = []; + const seen = new Set(); + const addTheme = (themeInfo: ThemeInfo) => { + if (seen.has(themeInfo.name)) { + return; + } + seen.add(themeInfo.name); + result.push(themeInfo); + }; // Built-in themes for (const name of Object.keys(getBuiltinThemes())) { - result.push({ name, path: path.join(themesDir, `${name}.json`) }); + addTheme({ name, path: path.join(themesDir, `${name}.json`) }); } // Custom themes - if (fs.existsSync(customThemesDir)) { - for (const file of fs.readdirSync(customThemesDir)) { - if (file.endsWith(".json")) { - const name = file.slice(0, -5); - if (!result.some((t) => t.name === name)) { - result.push({ name, path: path.join(customThemesDir, file) }); - } - } - } + for (const themeInfo of getCustomThemeInfos()) { + addTheme(themeInfo); } for (const [name, theme] of registeredThemes.entries()) { - if (!result.some((t) => t.name === name)) { - result.push({ name, path: theme.sourcePath }); - } + addTheme({ name, path: theme.sourcePath }); } return result.sort((a, b) => a.name.localeCompare(b.name)); } +function getCustomThemeInfos(): ThemeInfo[] { + const customThemesDir = getCustomThemesDir(); + const result: ThemeInfo[] = []; + if (!fs.existsSync(customThemesDir)) { + return result; + } + + for (const file of fs.readdirSync(customThemesDir)) { + if (!file.endsWith(".json")) { + continue; + } + const themePath = path.join(customThemesDir, file); + try { + const customTheme = loadThemeFromPath(themePath); + if (customTheme.name) { + result.push({ name: customTheme.name, path: themePath }); + } + } catch { + // Invalid themes are ignored here; the resource loader reports them + // during normal startup/reload. + } + } + return result; +} + function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); @@ -585,7 +574,7 @@ function loadThemeJson(name: string): ThemeJson { } function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { - const colorMode = mode ?? detectColorMode(); + const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; @@ -633,23 +622,118 @@ export function getThemeByName(name: string): Theme | undefined { } } -function detectTerminalBackground(): "dark" | "light" { - const colorfgbg = process.env.COLORFGBG || ""; - if (colorfgbg) { - const parts = colorfgbg.split(";"); - if (parts.length >= 2) { - const bg = parseInt(parts[1], 10); - if (!Number.isNaN(bg)) { - const result = bg < 8 ? "dark" : "light"; - return result; - } - } - } - return "dark"; +export type TerminalTheme = "dark" | "light"; + +export interface RgbColor { + r: number; + g: number; + b: number; } -function getDefaultTheme(): string { - return detectTerminalBackground(); +export interface TerminalThemeDetection { + theme: TerminalTheme; + source: "terminal background" | "COLORFGBG" | "fallback"; + detail: string; + confidence: "high" | "low"; +} + +export interface TerminalThemeDetectionOptions { + env?: NodeJS.ProcessEnv; +} + +function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { + const parts = colorfgbg.split(";"); + for (let i = parts.length - 1; i >= 0; i--) { + const bg = parseInt(parts[i].trim(), 10); + if (Number.isInteger(bg) && bg >= 0 && bg <= 255) { + return bg; + } + } + return undefined; +} + +function getRgbColorLuminance({ r, g, b }: RgbColor): number { + const toLinear = (channel: number) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function getAnsiColorLuminance(index: number): number { + return getRgbColorLuminance(hexToRgb(ansi256ToHex(index))); +} + +export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { + return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i); + if (!match) { + return undefined; + } + + const value = match[1].trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} + +export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { + const env = options.env ?? process.env; + const colorfgbg = env.COLORFGBG || ""; + const bg = getColorFgBgBackgroundIndex(colorfgbg); + if (bg !== undefined) { + return { + theme: getAnsiColorLuminance(bg) >= 0.5 ? "light" : "dark", + source: "COLORFGBG", + detail: `background color index ${bg}`, + confidence: "high", + }; + } + + return { + theme: "dark", + source: "fallback", + detail: "no terminal background hint found", + confidence: "low", + }; +} + +export function getDefaultTheme(): string { + return detectTerminalBackground().theme; } // ============================================================================ @@ -658,6 +742,7 @@ function getDefaultTheme(): string { // Use globalThis to share theme across module loaders (tsx + jiti in dev mode) const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme"); +const THEME_KEY_OLD = Symbol.for("@mariozechner/pi-coding-agent:theme"); // Export theme as a getter that reads from globalThis // This ensures all module instances (tsx, jiti) see the same theme @@ -671,6 +756,7 @@ export const theme: Theme = new Proxy({} as Theme, { function setGlobalTheme(t: Theme): void { (globalThis as Record)[THEME_KEY] = t; + (globalThis as Record)[THEME_KEY_OLD] = t; } let currentThemeName: string | undefined; @@ -1130,7 +1216,7 @@ export function getEditorTheme(): EditorTheme { }; } -export function getSettingsListTheme(): import("@earendil-works/pi-tui").SettingsListTheme { +export function getSettingsListTheme(): SettingsListTheme { return { label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 2f9fb048..c9553c55 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -7,9 +7,9 @@ */ import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai"; -import type { AgentSessionRuntime } from "../core/agent-session-runtime.js"; -import { flushRawStdout, writeRawStdout } from "../core/output-guard.js"; -import { killTrackedDetachedChildren } from "../utils/shell.js"; +import type { AgentSessionRuntime } from "../core/agent-session-runtime.ts"; +import { flushRawStdout, writeRawStdout } from "../core/output-guard.ts"; +import { killTrackedDetachedChildren } from "../utils/shell.ts"; /** * Options for print mode. diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 00fd3cd9..53b96e64 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -7,11 +7,11 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { AgentEvent, AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent } from "@earendil-works/pi-ai"; -import type { SessionStats } from "../../core/agent-session.js"; -import type { BashResult } from "../../core/bash-executor.js"; -import type { CompactionResult } from "../../core/compaction/index.js"; -import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; -import type { RpcCommand, RpcResponse, RpcSessionState, RpcSlashCommand } from "./rpc-types.js"; +import type { SessionStats } from "../../core/agent-session.ts"; +import type { BashResult } from "../../core/bash-executor.ts"; +import type { CompactionResult } from "../../core/compaction/index.ts"; +import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; +import type { RpcCommand, RpcResponse, RpcSessionState, RpcSlashCommand } from "./rpc-types.ts"; // ============================================================================ // Types @@ -59,8 +59,11 @@ export class RpcClient { new Map(); private requestId = 0; private stderr = ""; + private options: RpcClientOptions; - constructor(private options: RpcClientOptions = {}) {} + constructor(options: RpcClientOptions = {}) { + this.options = options; + } /** * Start the RPC agent process. diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6204bf4e..c2fb872c 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -12,17 +12,17 @@ */ import * as crypto from "node:crypto"; -import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js"; +import type { AgentSessionRuntime } from "../../core/agent-session-runtime.ts"; import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, WorkingIndicatorOptions, -} from "../../core/extensions/index.js"; -import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; -import { killTrackedDetachedChildren } from "../../utils/shell.js"; -import { type Theme, theme } from "../interactive/theme/theme.js"; -import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; +} from "../../core/extensions/index.ts"; +import { takeOverStdout, writeRawStdout } from "../../core/output-guard.ts"; +import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { type Theme, theme } from "../interactive/theme/theme.ts"; +import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; import type { RpcCommand, RpcExtensionInfo, @@ -31,7 +31,7 @@ import type { RpcResponse, RpcSessionState, RpcSlashCommand, -} from "./rpc-types.js"; +} from "./rpc-types.ts"; // Re-export types for consumers export type { @@ -40,7 +40,7 @@ export type { RpcExtensionUIResponse, RpcResponse, RpcSessionState, -} from "./rpc-types.js"; +} from "./rpc-types.ts"; /** * Run in RPC mode. diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 340d945b..db8bfbc5 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -7,10 +7,10 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, Model } from "@earendil-works/pi-ai"; -import type { SessionStats } from "../../core/agent-session.js"; -import type { BashResult } from "../../core/bash-executor.js"; -import type { CompactionResult } from "../../core/compaction/index.js"; -import type { SourceInfo } from "../../core/source-info.js"; +import type { SessionStats } from "../../core/agent-session.ts"; +import type { BashResult } from "../../core/bash-executor.ts"; +import type { CompactionResult } from "../../core/compaction/index.ts"; +import type { SourceInfo } from "../../core/source-info.ts"; // ============================================================================ // RPC Commands (stdin) diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index a41fb7a6..f61d4ff2 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,24 +1,47 @@ +import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { spawn } from "child_process"; -import { selectConfig } from "./cli/config-selector.js"; +import { selectConfig } from "./cli/config-selector.ts"; import { APP_NAME, + detectInstallMethod, getAgentDir, + getPackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, type SelfUpdateCommand, VERSION, -} from "./config.js"; -import { DefaultPackageManager } from "./core/package-manager.js"; -import { SettingsManager } from "./core/settings-manager.js"; -import { shouldUseWindowsShell } from "./utils/child-process.js"; -import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js"; +} from "./config.ts"; +import { DefaultPackageManager } from "./core/package-manager.ts"; +import { SettingsManager } from "./core/settings-manager.ts"; +import { spawnProcess } from "./utils/child-process.ts"; +import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; +import { + cleanupWindowsSelfUpdateQuarantine, + quarantineWindowsNativeDependencies, +} from "./utils/windows-self-update.ts"; export type PackageCommand = "install" | "remove" | "update" | "list"; type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string }; +const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { + heading: (text) => chalk.bold(chalk.yellow(text)), + link: (text) => chalk.cyan(text), + linkUrl: (text) => chalk.dim(text), + code: (text) => chalk.yellow(text), + codeBlock: (text) => chalk.dim(text), + codeBlockBorder: (text) => chalk.dim(text), + quote: (text) => chalk.dim(text), + quoteBorder: (text) => chalk.dim(text), + hr: (text) => chalk.dim(text), + listBullet: (text) => chalk.yellow(text), + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), +}; + interface PackageCommandOptions { command: PackageCommand; source?: string; @@ -288,9 +311,30 @@ function printSelfUpdateFallback(command: SelfUpdateCommand): void { console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); } +function printSelfUpdateNote(note: string): void { + const trimmedNote = note.trim(); + if (!trimmedNote) { + return; + } + + console.log(); + console.log(chalk.bold(chalk.yellow("Update note"))); + try { + const width = Math.max(20, process.stdout.columns ?? 80); + const renderedLines = new Markdown(trimmedNote, 0, 0, SELF_UPDATE_NOTE_MARKDOWN_THEME) + .render(width) + .map((line) => line.trimEnd()); + console.log(renderedLines.join("\n")); + } catch { + console.log(trimmedNote); + } + console.log(); +} + interface SelfUpdatePlan { packageName: string; shouldRun: boolean; + note?: string; } async function getSelfUpdatePlan(force: boolean): Promise { @@ -302,7 +346,7 @@ async function getSelfUpdatePlan(force: boolean): Promise { const latestRelease = await getLatestPiRelease(VERSION); const packageName = latestRelease?.packageName ?? PACKAGE_NAME; if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { - return { packageName, shouldRun: true }; + return { packageName, shouldRun: true, ...(latestRelease?.note ? { note: latestRelease.note } : {}) }; } } catch { return { packageName: PACKAGE_NAME, shouldRun: true }; @@ -316,10 +360,8 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); for (const step of command.steps ?? [command]) { await new Promise((resolve, reject) => { - // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them. - const child = spawn(step.command, step.args, { + const child = spawnProcess(step.command, step.args, { stdio: "inherit", - shell: shouldUseWindowsShell(step.command), }); child.on("error", (error) => { reject(error); @@ -337,6 +379,16 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { } } +function prepareWindowsNpmSelfUpdate(): void { + if (process.platform !== "win32") { + return; + } + + const packageDir = getPackageDir(); + cleanupWindowsSelfUpdateQuarantine(packageDir); + quarantineWindowsNativeDependencies(packageDir); +} + export async function handleConfigCommand(args: string[]): Promise { if (args[0] !== "config") { return false; @@ -490,6 +542,15 @@ export async function handlePackageCommand(args: string[]): Promise { if (!selfUpdatePlan.shouldRun) { return true; } + const installMethod = detectInstallMethod(); + if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { + console.error( + chalk.red(`${APP_NAME} self-update on Windows is only supported for npm and pnpm installs.`), + ); + console.error(chalk.dim(`Detected install method: ${installMethod}. Update ${APP_NAME} manually.`)); + process.exitCode = 1; + return true; + } const selfUpdateCommand = getSelfUpdateCommand( PACKAGE_NAME, selfUpdateNpmCommand, @@ -500,7 +561,13 @@ export async function handlePackageCommand(args: string[]): Promise { process.exitCode = 1; return true; } + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } try { + if (installMethod === "npm") { + prepareWindowsNpmSelfUpdate(); + } await runSelfUpdate(selfUpdateCommand); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown package command error"; diff --git a/packages/coding-agent/src/utils/ansi.ts b/packages/coding-agent/src/utils/ansi.ts new file mode 100644 index 00000000..a95ded68 --- /dev/null +++ b/packages/coding-agent/src/utils/ansi.ts @@ -0,0 +1,60 @@ +/* + * Portions of this file are derived from: + * - ansi-regex (https://github.com/chalk/ansi-regex) + * - strip-ansi (https://github.com/chalk/strip-ansi) + * + * MIT License + * + * Copyright (c) Sindre Sorhus (https://sindresorhus.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +function ansiRegex({ onlyFirst = false }: { onlyFirst?: boolean } = {}): RegExp { + // Valid string terminator sequences are BEL, ESC\, and 0x9c + const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; + + // OSC sequences only: ESC ] ... ST (non-greedy until the first ST) + const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`; + + // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte + const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]"; + + const pattern = `${osc}|${csi}`; + + return new RegExp(pattern, onlyFirst ? undefined : "g"); +} + +const regex = ansiRegex(); + +export function stripAnsi(value: string): string { + if (typeof value !== "string") { + throw new TypeError(`Expected a \`string\`, got \`${typeof value}\``); + } + + // Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer + if (!value.includes("\u001B") && !value.includes("\u009B")) { + return value; + } + + // Even though the regex is global, we don't need to reset the `.lastIndex` + // because unlike `.exec()` and `.test()`, `.replace()` does it automatically + // and doing it manually has a performance penalty. + return value.replace(regex, ""); +} diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 79b17d0e..b9e8e35d 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -96,4 +96,4 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C } // Re-export getChangelogPath from paths.ts for convenience -export { getChangelogPath } from "../config.js"; +export { getChangelogPath } from "../config.ts"; diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index 9f75a285..3d97d3da 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -1,14 +1,38 @@ -import type { ChildProcess } from "node:child_process"; -import { basename } from "node:path"; +import { + type ChildProcess, + type ChildProcessByStdio, + spawn as nodeSpawn, + spawnSync as nodeSpawnSync, + type SpawnOptions, + type SpawnOptionsWithStdioTuple, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + type StdioNull, + type StdioPipe, +} from "node:child_process"; +import type { Readable } from "node:stream"; +import crossSpawn from "cross-spawn"; const EXIT_STDIO_GRACE_MS = 100; -const WINDOWS_SHELL_COMMANDS = new Set(["npm", "npx", "pnpm", "yarn", "yarnpkg", "corepack"]); +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptionsWithStdioTuple, +): ChildProcessByStdio; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess { + return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options); +} -export function shouldUseWindowsShell(command: string): boolean { - if (process.platform !== "win32") return false; - const commandName = basename(command).toLowerCase(); - return commandName.endsWith(".cmd") || commandName.endsWith(".bat") || WINDOWS_SHELL_COMMANDS.has(commandName); +export function spawnProcessSync( + command: string, + args: string[], + options: SpawnSyncOptionsWithStringEncoding, +): SpawnSyncReturns { + return process.platform === "win32" + ? crossSpawn.sync(command, args, options) + : nodeSpawnSync(command, args, options); } /** diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 4cf44908..44f8fb10 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -4,8 +4,8 @@ import { readFileSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { clipboard } from "./clipboard-native.js"; -import { loadPhoton } from "./photon.js"; +import { clipboard } from "./clipboard-native.ts"; +import { loadPhoton } from "./photon.ts"; export type ClipboardImage = { bytes: Uint8Array; diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 84166e95..26e1d018 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,7 +1,7 @@ import { execSync, spawn } from "child_process"; import { platform } from "os"; -import { isWaylandSession } from "./clipboard-image.js"; -import { clipboard } from "./clipboard-native.js"; +import { isWaylandSession } from "./clipboard-image.ts"; +import { clipboard } from "./clipboard-native.ts"; type NativeClipboardExecOptions = { input: string; diff --git a/packages/coding-agent/src/utils/exif-orientation.ts b/packages/coding-agent/src/utils/exif-orientation.ts index ac34c790..4b454afa 100644 --- a/packages/coding-agent/src/utils/exif-orientation.ts +++ b/packages/coding-agent/src/utils/exif-orientation.ts @@ -1,4 +1,4 @@ -import type { PhotonImageType } from "./photon.js"; +import type { PhotonImageType } from "./photon.ts"; type Photon = typeof import("@silvia-odwyer/photon-node"); diff --git a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts new file mode 100644 index 00000000..75e31da2 --- /dev/null +++ b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts @@ -0,0 +1,19 @@ +declare module "highlight.js/lib/index.js" { + interface HighlightResult { + value: string; + } + + interface HighlightOptions { + language: string; + ignoreIllegals?: boolean; + } + + interface HighlightJs { + highlight(code: string, options: HighlightOptions): HighlightResult; + highlightAuto(code: string, languageSubset?: string[]): HighlightResult; + getLanguage(name: string): unknown; + } + + const hljs: HighlightJs; + export default hljs; +} diff --git a/packages/coding-agent/src/utils/html.ts b/packages/coding-agent/src/utils/html.ts new file mode 100644 index 00000000..a13ad46f --- /dev/null +++ b/packages/coding-agent/src/utils/html.ts @@ -0,0 +1,51 @@ +export interface DecodedHtmlEntity { + text: string; + length: number; +} + +function decodeCodePoint(codePoint: number): string | undefined { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return undefined; + } + return String.fromCodePoint(codePoint); +} + +export function decodeHtmlEntity(entity: string): string | undefined { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + case "apos": + return "'"; + } + + if (entity.startsWith("#x") || entity.startsWith("#X")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16)); + } + + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10)); + } + + return undefined; +} + +export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined { + const semicolonIndex = html.indexOf(";", index + 1); + if (semicolonIndex === -1 || semicolonIndex - index > 16) { + return undefined; + } + + const entity = html.slice(index + 1, semicolonIndex); + const decoded = decodeHtmlEntity(entity); + if (decoded === undefined) { + return undefined; + } + + return { text: decoded, length: semicolonIndex - index + 1 }; +} diff --git a/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts index 670997c0..4d5f0204 100644 --- a/packages/coding-agent/src/utils/image-convert.ts +++ b/packages/coding-agent/src/utils/image-convert.ts @@ -1,5 +1,5 @@ -import { applyExifOrientation } from "./exif-orientation.js"; -import { loadPhoton } from "./photon.js"; +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; /** * Convert image to PNG format for terminal display. diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 84b4ac33..3eb17dae 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,6 +1,6 @@ import type { ImageContent } from "@earendil-works/pi-ai"; -import { applyExifOrientation } from "./exif-orientation.js"; -import { loadPhoton } from "./photon.js"; +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; export interface ImageResizeOptions { maxWidth?: number; // Default: 2000 diff --git a/packages/coding-agent/src/utils/mime.ts b/packages/coding-agent/src/utils/mime.ts index f9ded46e..7381d370 100644 --- a/packages/coding-agent/src/utils/mime.ts +++ b/packages/coding-agent/src/utils/mime.ts @@ -1,30 +1,74 @@ import { open } from "node:fs/promises"; -import { fileTypeFromBuffer } from "file-type"; -const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); +const IMAGE_TYPE_SNIFF_BYTES = 4100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; -const FILE_TYPE_SNIFF_BYTES = 4100; +export function detectSupportedImageMimeType(buffer: Uint8Array): string | null { + if (startsWith(buffer, [0xff, 0xd8, 0xff])) { + return buffer[3] === 0xf7 ? null : "image/jpeg"; + } + if (startsWith(buffer, PNG_SIGNATURE)) { + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null; + } + if (startsWithAscii(buffer, 0, "GIF")) { + return "image/gif"; + } + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) { + return "image/webp"; + } + return null; +} export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise { const fileHandle = await open(filePath, "r"); try { - const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES); - const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0); - if (bytesRead === 0) { - return null; - } - - const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead)); - if (!fileType) { - return null; - } - - if (!IMAGE_MIME_TYPES.has(fileType.mime)) { - return null; - } - - return fileType.mime; + const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0); + return detectSupportedImageMimeType(buffer.subarray(0, bytesRead)); } finally { await fileHandle.close(); } } + +function isPng(buffer: Uint8Array): boolean { + return ( + buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR") + ); +} + +function isAnimatedPng(buffer: Uint8Array): boolean { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true; + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false; + + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) return false; + offset = nextOffset; + } + return false; +} + +function readUint32BE(buffer: Uint8Array, offset: number): number { + return ( + (buffer[offset] ?? 0) * 0x1000000 + + ((buffer[offset + 1] ?? 0) << 16) + + ((buffer[offset + 2] ?? 0) << 8) + + (buffer[offset + 3] ?? 0) + ); +} + +function startsWith(buffer: Uint8Array, bytes: number[]): boolean { + if (buffer.length < bytes.length) return false; + return bytes.every((byte, index) => buffer[index] === byte); +} + +function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean { + if (buffer.length < offset + text.length) return false; + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) return false; + } + return true; +} diff --git a/packages/coding-agent/src/utils/paths.ts b/packages/coding-agent/src/utils/paths.ts index ef36de7a..7f101344 100644 --- a/packages/coding-agent/src/utils/paths.ts +++ b/packages/coding-agent/src/utils/paths.ts @@ -1,5 +1,23 @@ import { realpathSync } from "node:fs"; -import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnProcessSync } from "./child-process.ts"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +export interface PathInputOptions { + /** Trim leading/trailing whitespace before normalization. */ + trim?: boolean; + /** Expand leading `~` to a home directory. Defaults to true. */ + expandTilde?: boolean; + /** Home directory used for `~` expansion. Defaults to `os.homedir()`. */ + homeDir?: string; + /** Strip a leading `@`, used for CLI @file paths. */ + stripAtPrefix?: boolean; + /** Normalize unicode space variants to regular spaces. */ + normalizeUnicodeSpaces?: boolean; +} /** * Resolve a path to its canonical (real) form, following symlinks. @@ -17,12 +35,12 @@ export function canonicalizePath(path: string): string { /** * Returns true if the value is NOT a package source (npm:, git:, etc.) - * or a URL protocol. Bare names and relative paths without ./ prefix + * or a remote URL protocol. Bare names, relative paths, and file: URLs * are considered local. */ export function isLocalPath(value: string): boolean { const trimmed = value.trim(); - // Known non-local prefixes + // Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath(). if ( trimmed.startsWith("npm:") || trimmed.startsWith("git:") || @@ -36,13 +54,39 @@ export function isLocalPath(value: string): boolean { return true; } -function resolveAgainstCwd(filePath: string, cwd: string): string { - return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath); +export function normalizePath(input: string, options: PathInputOptions = {}): string { + let normalized = options.trim ? input.trim() : input; + if (options.normalizeUnicodeSpaces) { + normalized = normalized.replace(UNICODE_SPACES, " "); + } + if (options.stripAtPrefix && normalized.startsWith("@")) { + normalized = normalized.slice(1); + } + + if (options.expandTilde ?? true) { + const home = options.homeDir ?? homedir(); + if (normalized === "~") return home; + if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) { + return join(home, normalized.slice(2)); + } + } + + if (/^file:\/\//.test(normalized)) { + return fileURLToPath(normalized); + } + + return normalized; +} + +export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string { + const normalized = normalizePath(input, options); + const normalizedBaseDir = normalizePath(baseDir); + return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized); } export function getCwdRelativePath(filePath: string, cwd: string): string | undefined { const resolvedCwd = resolvePath(cwd); - const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd); + const resolvedPath = resolvePath(filePath, resolvedCwd); const relativePath = relative(resolvedCwd, resolvedPath); const isInsideCwd = relativePath === "" || @@ -52,6 +96,23 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde } export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string { - const absolutePath = resolveAgainstCwd(filePath, cwd); + const absolutePath = resolvePath(filePath, cwd); return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/"); } + +export function markPathIgnoredByCloudSync(path: string): void { + const attrs = + process.platform === "darwin" + ? ["com.dropbox.ignored", "com.apple.fileprovider.ignore#P"] + : process.platform === "linux" + ? ["user.com.dropbox.ignored"] + : []; + + for (const attr of attrs) { + if (process.platform === "darwin") { + spawnProcessSync("xattr", ["-w", attr, "1", path], { encoding: "utf-8", stdio: "ignore" }); + } else { + spawnProcessSync("setfattr", ["-n", attr, "-v", "1", path], { encoding: "utf-8", stdio: "ignore" }); + } + } +} diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 1c235802..817ba4f9 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { delimiter } from "node:path"; import { spawn, spawnSync } from "child_process"; -import { getBinDir } from "../config.js"; +import { getBinDir } from "../config.ts"; export interface ShellConfig { shell: string; @@ -15,7 +15,11 @@ function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("where", ["bash.exe"], { + encoding: "utf-8", + timeout: 5000, + windowsHide: true, + }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch && existsSync(firstMatch)) { @@ -190,6 +194,7 @@ export function killProcessTree(pid: number): void { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, + windowsHide: true, }); } catch { // Ignore errors if taskkill fails diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts new file mode 100644 index 00000000..bcc4add1 --- /dev/null +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -0,0 +1,146 @@ +import hljs from "highlight.js/lib/index.js"; +import { decodeHtmlEntityAt } from "./html.ts"; + +export type HighlightFormatter = (text: string) => string; +export type HighlightTheme = Partial>; + +export interface HighlightOptions { + language?: string; + ignoreIllegals?: boolean; + languageSubset?: string[]; + theme?: HighlightTheme; +} + +const SPAN_CLOSE = ""; +const HIGHLIGHT_CLASS_PREFIX = "hljs-"; + +function getScopeFromSpanTag(tag: string): string | undefined { + const match = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag); + const classValue = match?.[1] ?? match?.[2]; + if (!classValue) { + return undefined; + } + + for (const className of classValue.split(/\s+/)) { + if (className.startsWith(HIGHLIGHT_CLASS_PREFIX)) { + return className.slice(HIGHLIGHT_CLASS_PREFIX.length); + } + } + + return undefined; +} + +function getScopeFormatter(scope: string, theme: HighlightTheme): HighlightFormatter | undefined { + const exact = theme[scope]; + if (exact) { + return exact; + } + + const dotIndex = scope.indexOf("."); + if (dotIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dotIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + const dashIndex = scope.indexOf("-"); + if (dashIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dashIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + return undefined; +} + +function getActiveFormatter(scopes: Array, theme: HighlightTheme): HighlightFormatter | undefined { + for (let i = scopes.length - 1; i >= 0; i--) { + const scope = scopes[i]; + if (!scope) { + continue; + } + const formatter = getScopeFormatter(scope, theme); + if (formatter) { + return formatter; + } + } + return theme.default; +} + +function isSpanOpenTagStart(html: string, index: number): boolean { + if (!html.startsWith("" || nextChar === " " || nextChar === "\t" || nextChar === "\n" || nextChar === "\r"; +} + +export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string { + let output = ""; + let textBuffer = ""; + const scopes: Array = []; + + const flushText = () => { + if (!textBuffer) { + return; + } + const formatter = getActiveFormatter(scopes, theme); + output += formatter ? formatter(textBuffer) : textBuffer; + textBuffer = ""; + }; + + let index = 0; + while (index < html.length) { + if (isSpanOpenTagStart(html, index)) { + const tagEndIndex = html.indexOf(">", index + 5); + if (tagEndIndex !== -1) { + flushText(); + const tag = html.slice(index, tagEndIndex + 1); + const scope = getScopeFromSpanTag(tag); + scopes.push(scope); + index = tagEndIndex + 1; + continue; + } + } + + if (html.startsWith(SPAN_CLOSE, index)) { + flushText(); + if (scopes.length > 0) { + scopes.pop(); + } + index += SPAN_CLOSE.length; + continue; + } + + if (html[index] === "&") { + const decoded = decodeHtmlEntityAt(html, index); + if (decoded) { + textBuffer += decoded.text; + index += decoded.length; + continue; + } + } + + textBuffer += html[index]; + index++; + } + + flushText(); + return output; +} + +export function highlight(code: string, options: HighlightOptions = {}): string { + const html = options.language + ? hljs.highlight(code, { + language: options.language, + ignoreIllegals: options.ignoreIllegals, + }).value + : hljs.highlightAuto(code, options.languageSubset).value; + return renderHighlightedHtml(html, options.theme); +} + +export function supportsLanguage(name: string): boolean { + return hljs.getLanguage(name) !== undefined; +} diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index ba60341f..09d9a119 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,12 +1,11 @@ import chalk from "chalk"; -import { spawnSync } from "child_process"; -import extractZip from "extract-zip"; +import { type SpawnSyncReturns, spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; import { join } from "path"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; -import { APP_NAME, getBinDir } from "../config.js"; +import { APP_NAME, getBinDir } from "../config.ts"; const TOOLS_DIR = getBinDir(); const NETWORK_TIMEOUT_MS = 10_000; @@ -159,6 +158,85 @@ function findBinaryRecursively(rootDir: string, binaryFileName: string): string return null; } +function formatSpawnFailure(result: SpawnSyncReturns): string { + if (result.error?.message) { + return result.error.message; + } + const stderr = result.stderr?.toString().trim(); + if (stderr) { + return stderr; + } + const stdout = result.stdout?.toString().trim(); + if (stdout) { + return stdout; + } + return `exit status ${result.status ?? "unknown"}`; +} + +function runExtractionCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { stdio: "pipe" }); + if (!result.error && result.status === 0) { + return null; + } + return `${command}: ${formatSpawnFailure(result)}`; +} + +function extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void { + const failure = runExtractionCommand("tar", ["xzf", archivePath, "-C", extractDir]); + if (failure) { + throw new Error(`Failed to extract ${assetName}: ${failure}`); + } +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (systemRoot) { + const systemTar = join(systemRoot, "System32", "tar.exe"); + if (existsSync(systemTar)) { + return systemTar; + } + } + return "tar.exe"; +} + +function extractZipArchive(archivePath: string, extractDir: string, assetName: string): void { + const failures: string[] = []; + + if (platform() === "win32") { + // Windows ships bsdtar as tar.exe, which supports zip files. Prefer the + // System32 binary over Git Bash's GNU tar, which does not handle zip archives. + const tarFailure = runExtractionCommand(getWindowsTarCommand(), ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + + const script = + "& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }"; + const powershellFailure = runExtractionCommand("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + archivePath, + extractDir, + ]); + if (!powershellFailure) return; + failures.push(powershellFailure); + } else { + const unzipFailure = runExtractionCommand("unzip", ["-q", archivePath, "-d", extractDir]); + if (!unzipFailure) return; + failures.push(unzipFailure); + + const tarFailure = runExtractionCommand("tar", ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + } + + throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`); +} + // Download and install a tool async function downloadTool(tool: "fd" | "rg"): Promise { const config = TOOLS[tool]; @@ -168,7 +246,10 @@ async function downloadTool(tool: "fd" | "rg"): Promise { const architecture = arch(); // Get latest version - const version = await getLatestVersion(config.repo); + let version = await getLatestVersion(config.repo); + if (tool === "fd" && plat === "darwin" && architecture === "x64") { + version = "10.3.0"; + } // Get asset name for this platform const assetName = config.getAssetName(version, plat, architecture); @@ -197,13 +278,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise { try { if (assetName.endsWith(".tar.gz")) { - const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); - if (extractResult.error || extractResult.status !== 0) { - const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error"; - throw new Error(`Failed to extract ${assetName}: ${errMsg}`); - } + extractTarGzArchive(archivePath, extractDir, assetName); } else if (assetName.endsWith(".zip")) { - await extractZip(archivePath, { dir: extractDir }); + extractZipArchive(archivePath, extractDir, assetName); } else { throw new Error(`Unsupported archive format: ${assetName}`); } diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index ec969469..aec25bdb 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -1,4 +1,4 @@ -import { getPiUserAgent } from "./pi-user-agent.js"; +import { getPiUserAgent } from "./pi-user-agent.ts"; const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000; @@ -6,6 +6,7 @@ const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000; export interface LatestPiRelease { version: string; packageName?: string; + note?: string; } interface ParsedVersion { @@ -67,13 +68,22 @@ export async function getLatestPiRelease( }); if (!response.ok) return undefined; - const data = (await response.json()) as { packageName?: unknown; version?: unknown }; + const data = (await response.json()) as { + packageName?: unknown; + version?: unknown; + note?: unknown; + }; if (typeof data.version !== "string" || !data.version.trim()) { return undefined; } const packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined; - return { version: data.version.trim(), packageName }; + const note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : undefined; + return { + version: data.version.trim(), + packageName, + ...(note ? { note } : {}), + }; } export async function getLatestPiVersion( @@ -83,11 +93,11 @@ export async function getLatestPiVersion( return (await getLatestPiRelease(currentVersion, options))?.version; } -export async function checkForNewPiVersion(currentVersion: string): Promise { +export async function checkForNewPiVersion(currentVersion: string): Promise { try { - const latestVersion = await getLatestPiVersion(currentVersion); - if (latestVersion && isNewerPackageVersion(latestVersion, currentVersion)) { - return latestVersion; + const latestRelease = await getLatestPiRelease(currentVersion); + if (latestRelease && isNewerPackageVersion(latestRelease.version, currentVersion)) { + return latestRelease; } return undefined; } catch { diff --git a/packages/coding-agent/src/utils/windows-self-update.ts b/packages/coding-agent/src/utils/windows-self-update.ts new file mode 100644 index 00000000..c837d491 --- /dev/null +++ b/packages/coding-agent/src/utils/windows-self-update.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { basename, dirname, join, relative, resolve, toNamespacedPath } from "node:path"; +import { getCwdRelativePath } from "./paths.ts"; + +const QUARANTINE_DIR_NAME = ".pi-native-quarantine"; + +function normalizePath(path: string): string { + return toNamespacedPath(resolve(path)); +} + +function getQuarantineRoot(packageDir: string): string | undefined { + let current = resolve(packageDir); + while (true) { + if (basename(current).toLowerCase() === "node_modules") { + return join(current, QUARANTINE_DIR_NAME); + } + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function getLoadedSharedObjectsInPackageDir(packageDir: string): string[] { + const sharedObjects = (process.report.getReport() as { sharedObjects?: unknown }).sharedObjects; + if (!Array.isArray(sharedObjects)) { + return []; + } + + const root = normalizePath(packageDir).toLowerCase(); + const seen = new Set(); + const loadedFiles: string[] = []; + for (const value of sharedObjects) { + if (typeof value !== "string") { + continue; + } + const filePath = normalizePath(value); + const comparisonPath = filePath.toLowerCase(); + if (getCwdRelativePath(comparisonPath, root) === undefined || seen.has(comparisonPath)) { + continue; + } + seen.add(comparisonPath); + loadedFiles.push(filePath); + } + return loadedFiles; +} + +export function cleanupWindowsSelfUpdateQuarantine(packageDir: string): void { + const quarantineRoot = getQuarantineRoot(packageDir); + if (!quarantineRoot) { + return; + } + try { + rmSync(quarantineRoot, { recursive: true, force: true }); + } catch { + // A previous pi process may still be exiting and holding a native addon. + } +} + +export function quarantineWindowsNativeDependencies(packageDir: string): void { + const resolvedPackageDir = normalizePath(packageDir); + const quarantineRoot = getQuarantineRoot(resolvedPackageDir); + if (!quarantineRoot) { + return; + } + + const loadedFiles = getLoadedSharedObjectsInPackageDir(resolvedPackageDir); + if (loadedFiles.length === 0) { + return; + } + + const quarantineRunDir = join(quarantineRoot, `${Date.now()}-${process.pid}-${randomUUID()}`); + for (const loadedFile of loadedFiles) { + if (!existsSync(loadedFile)) { + continue; + } + const quarantinePath = join(quarantineRunDir, relative(resolvedPackageDir, loadedFile)); + mkdirSync(dirname(quarantinePath), { recursive: true }); + renameSync(loadedFile, quarantinePath); + copyFileSync(quarantinePath, loadedFile); + } +} diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index f5343170..1abe3e28 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -4,12 +4,12 @@ import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createTestResourceLoader } from "./utilities.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createTestResourceLoader } from "./utilities.ts"; vi.mock("../src/core/compaction/index.js", () => ({ calculateContextTokens: (usage: { @@ -113,14 +113,13 @@ describe("AgentSession auto-compaction queue resume", () => { const runAutoCompaction = ( session as unknown as { - _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; } )._runAutoCompaction.bind(session); - await runAutoCompaction("threshold", false); - await vi.advanceTimersByTimeAsync(100); + await expect(runAutoCompaction("threshold", false)).resolves.toBe(true); - expect(continueSpy).toHaveBeenCalledTimes(1); + expect(continueSpy).not.toHaveBeenCalled(); }); it("should not compact repeatedly after overflow recovery already attempted", async () => { diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 8590ad74..f516e6d6 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -12,17 +12,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { AgentSession } from "../src/core/agent-session.js"; +import type { AgentSession } from "../src/core/agent-session.ts"; import { type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, -} from "../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { API_KEY } from "./utilities.js"; +} from "../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { API_KEY } from "./utilities.ts"; describe.skipIf(!API_KEY)("AgentSession forking", () => { let session: AgentSession; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index da52aeca..5724d684 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -13,13 +13,13 @@ import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import { getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createCodingTools } from "../src/index.js"; -import { API_KEY, createTestResourceLoader } from "./utilities.js"; +import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createCodingTools } from "../src/index.ts"; +import { API_KEY, createTestResourceLoader } from "./utilities.ts"; describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { let session: AgentSession; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 876883f2..81f400ea 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -16,13 +16,13 @@ import { } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import type { BuildSystemPromptOptions } from "../src/core/system-prompt.js"; -import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import type { BuildSystemPromptOptions } from "../src/core/system-prompt.ts"; +import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { diff --git a/packages/coding-agent/test/agent-session-dynamic-provider.test.ts b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts index e527ca35..a6da8c9e 100644 --- a/packages/coding-agent/test/agent-session-dynamic-provider.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts @@ -3,12 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { DefaultResourceLoader } from "../src/core/resource-loader.js"; -import type { ExtensionFactory } from "../src/core/sdk.js"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; +import type { ExtensionFactory } from "../src/core/sdk.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("AgentSession dynamic provider registration", () => { let tempDir: string; diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index 6c020def..e58ea22f 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultResourceLoader } from "../src/core/resource-loader.js"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("AgentSession dynamic tool registration", () => { let tempDir: string; diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index 25dfd209..ebba143b 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -5,12 +5,12 @@ import { Agent, type AgentEvent, type AgentTool } from "@earendil-works/pi-agent import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createTestResourceLoader } from "./utilities.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createTestResourceLoader } from "./utilities.ts"; class MockAssistantStream extends EventStream { constructor() { diff --git a/packages/coding-agent/test/agent-session-runtime-events.test.ts b/packages/coding-agent/test/agent-session-runtime-events.test.ts index dcd70577..42b01fe1 100644 --- a/packages/coding-agent/test/agent-session-runtime-events.test.ts +++ b/packages/coding-agent/test/agent-session-runtime-events.test.ts @@ -8,16 +8,16 @@ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, -} from "../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { SessionManager } from "../src/core/session-manager.js"; +} from "../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; import type { ExtensionFactory, SessionBeforeForkEvent, SessionBeforeSwitchEvent, SessionShutdownEvent, SessionStartEvent, -} from "../src/index.js"; +} from "../src/index.ts"; type RecordedSessionEvent = | SessionBeforeSwitchEvent diff --git a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts index 27788b8f..b435246e 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -1,12 +1,12 @@ import { Agent } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, getModel, type Usage } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createTestResourceLoader } from "./utilities.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createTestResourceLoader } from "./utilities.ts"; const model = getModel("anthropic", "claude-sonnet-4-5")!; diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts index 35abae1e..361e5bff 100644 --- a/packages/coding-agent/test/agent-session-tree-navigation.test.ts +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -10,7 +10,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { API_KEY, createTestSession, type TestSessionContext } from "./utilities.js"; +import { API_KEY, createTestSession, type TestSessionContext } from "./utilities.ts"; describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { let ctx: TestSessionContext; diff --git a/packages/coding-agent/test/ansi-utils.test.ts b/packages/coding-agent/test/ansi-utils.test.ts new file mode 100644 index 00000000..15bb4314 --- /dev/null +++ b/packages/coding-agent/test/ansi-utils.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +function referenceAnsiRegex(): RegExp { + const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; + const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`; + const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]"; + return new RegExp(`${osc}|${csi}`, "g"); +} + +const referenceRegex = referenceAnsiRegex(); + +function referenceStripAnsi(value: string): string { + if (!value.includes("\u001B") && !value.includes("\u009B")) { + return value; + } + return value.replace(referenceRegex, ""); +} + +function getCompatibilityInputs(): string[] { + const inputs = [ + "plain", + "a\x1b[31mred\x1b[0mz", + "a\x1b]8;;https://example.com\x07link\x1b]8;;\x07z", + "a\x1b]unterminated", + "a\x1b]funterminated", + "a\x1bPabc\x1b\\z", + "a\x1b^abc\x07z", + "a\x1b_abc\x9cz", + "a\x90abc\x9cz", + "a\x9dabc\x9cz", + "a\x9b31mred", + "a\x1b(0x", + "a\x1b*0x", + "a\x1b+c", + "a\x1b/0x", + "a\x1bcok", + "a\x1b\\ok", + ]; + const chars = [ + "a", + "f", + "0", + "1", + ";", + ":", + "[", + "]", + "(", + ")", + "#", + "?", + "m", + "P", + "_", + "\\", + "\x07", + "\x1b", + "\x9b", + "\x9c", + "\x90", + "\x9d", + ]; + + for (const char of chars) { + inputs.push(`x\x1b${char}y`); + inputs.push(`x\x9b${char}y`); + for (let index = 0; index < chars.length; index += 3) { + inputs.push(`x\x1b${char}${chars[index]}y`); + } + } + + return inputs; +} + +describe("stripAnsi", () => { + it("matches chalk strip-ansi for generated compatibility inputs", () => { + for (const input of getCompatibilityInputs()) { + expect(stripAnsi(input)).toBe(referenceStripAnsi(input)); + } + }); + + it("throws the same TypeError as chalk strip-ansi for non-string values", () => { + const stripAnsiUnknown = stripAnsi as (value: unknown) => string; + + for (const value of [undefined, null, 123, {}, Object("x")]) { + const message = `Expected a \`string\`, got \`${typeof value}\``; + expect(() => stripAnsiUnknown(value)).toThrow(TypeError); + expect(() => stripAnsiUnknown(value)).toThrow(message); + } + }); + + it("strips RIS without leaking the final byte", () => { + expect(stripAnsi("\x1bcdone")).toBe("done"); + }); + + it("strips single-byte ESC sequences without leaking final bytes", () => { + for (let code = "g".charCodeAt(0); code <= "m".charCodeAt(0); code++) { + expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok"); + } + for (let code = "r".charCodeAt(0); code <= "t".charCodeAt(0); code++) { + expect(stripAnsi(`\x1b${String.fromCharCode(code)}ok`)).toBe("ok"); + } + }); + + it("strips common ANSI sequences used in tool output", () => { + const input = "a\x1b[31mred\x1b[0m\x1b]8;;https://example.com\x07link\x1b]8;;\x07z"; + expect(stripAnsi(input)).toBe("aredlinkz"); + }); +}); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index ab3ce2a5..3bf41473 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseArgs } from "../src/cli/args.js"; +import { parseArgs } from "../src/cli/args.ts"; describe("parseArgs", () => { describe("--version flag", () => { diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index 9af2a4a2..137376a5 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, test } from "vitest"; -import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index ddaef67e..9ec97100 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearConfigValueCache } from "../src/core/resolve-config-value.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { clearConfigValueCache } from "../src/core/resolve-config-value.ts"; describe("AuthStorage", () => { let tempDir: string; diff --git a/packages/coding-agent/test/bash-close-hang-windows.test.ts b/packages/coding-agent/test/bash-close-hang-windows.test.ts index 10ae6414..a17834e2 100644 --- a/packages/coding-agent/test/bash-close-hang-windows.test.ts +++ b/packages/coding-agent/test/bash-close-hang-windows.test.ts @@ -3,8 +3,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { executeBashWithOperations } from "../src/core/bash-executor.js"; -import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js"; +import { executeBashWithOperations } from "../src/core/bash-executor.ts"; +import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.ts"; function toBashSingleQuotedArg(value: string): string { return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`; diff --git a/packages/coding-agent/test/bash-execution-width.test.ts b/packages/coding-agent/test/bash-execution-width.test.ts index 12a8bb51..9cc64e8c 100644 --- a/packages/coding-agent/test/bash-execution-width.test.ts +++ b/packages/coding-agent/test/bash-execution-width.test.ts @@ -4,8 +4,8 @@ */ import { visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, it } from "vitest"; -import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; /** Minimal TUI stub that only exposes terminal.columns */ function createTuiStub(columns: number): { columns: number; stub: any } { diff --git a/packages/coding-agent/test/block-images.test.ts b/packages/coding-agent/test/block-images.test.ts index 2c599276..19b43459 100644 --- a/packages/coding-agent/test/block-images.test.ts +++ b/packages/coding-agent/test/block-images.test.ts @@ -2,9 +2,9 @@ import { mkdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { processFileArguments } from "../src/cli/file-processor.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createReadTool } from "../src/core/tools/read.js"; +import { processFileArguments } from "../src/cli/file-processor.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createReadTool } from "../src/core/tools/read.ts"; // 1x1 red PNG image as base64 (smallest valid PNG) const TINY_PNG_BASE64 = diff --git a/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts b/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts index 95107506..be3a9d95 100644 --- a/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts +++ b/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts @@ -68,7 +68,7 @@ vi.mock("@mariozechner/clipboard", () => ({ describe("readClipboardImage BMP conversion", () => { test("converts BMP to PNG on Wayland/WSLg", async () => { - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); // Simulate Wayland session (WSLg) const image = await readClipboardImage({ diff --git a/packages/coding-agent/test/clipboard-image.test.ts b/packages/coding-agent/test/clipboard-image.test.ts index eda61a29..b7416a4c 100644 --- a/packages/coding-agent/test/clipboard-image.test.ts +++ b/packages/coding-agent/test/clipboard-image.test.ts @@ -70,7 +70,7 @@ describe("readClipboardImage", () => { throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`); }); - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); const result = await readClipboardImage({ platform: "linux", env: { WAYLAND_DISPLAY: "1" } }); expect(result).not.toBeNull(); expect(result?.mimeType).toBe("image/png"); @@ -101,7 +101,7 @@ describe("readClipboardImage", () => { return spawnOk(Buffer.alloc(0)); }); - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); const result = await readClipboardImage({ platform: "linux", env: { XDG_SESSION_TYPE: "wayland" } }); expect(result).not.toBeNull(); expect(result?.mimeType).toBe("image/png"); @@ -138,7 +138,7 @@ describe("readClipboardImage", () => { throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`); }); - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); const result = await readClipboardImage({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } }); expect(result).not.toBeNull(); expect(result?.mimeType).toBe("image/png"); @@ -153,7 +153,7 @@ describe("readClipboardImage", () => { mocks.clipboard.hasImage.mockReturnValue(true); mocks.clipboard.getImageBinary.mockResolvedValue(new Uint8Array([7])); - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); const result = await readClipboardImage({ platform: "linux", env: {} }); expect(result).not.toBeNull(); expect(result?.mimeType).toBe("image/png"); @@ -167,7 +167,7 @@ describe("readClipboardImage", () => { mocks.clipboard.hasImage.mockReturnValue(false); - const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + const { readClipboardImage } = await import("../src/utils/clipboard-image.ts"); const result = await readClipboardImage({ platform: "linux", env: {} }); expect(result).toBeNull(); }); diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index ea231b57..6ea3a685 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -1,7 +1,7 @@ import { execSync, spawn } from "child_process"; import { platform } from "os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { copyToClipboard } from "../src/utils/clipboard.js"; +import { copyToClipboard } from "../src/utils/clipboard.ts"; const mocks = vi.hoisted(() => { return { diff --git a/packages/coding-agent/test/compaction-extensions-example.test.ts b/packages/coding-agent/test/compaction-extensions-example.test.ts index 4f5e3359..db2f4082 100644 --- a/packages/coding-agent/test/compaction-extensions-example.test.ts +++ b/packages/coding-agent/test/compaction-extensions-example.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from "vitest"; -import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.js"; +import type { ExtensionAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/extensions/index.ts"; describe("Documentation example", () => { it("custom compaction example should type-check correctly", () => { diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 1a134e7b..2c41210e 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -8,21 +8,21 @@ import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import { getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; import { createExtensionRuntime, type Extension, type SessionBeforeCompactEvent, type SessionCompactEvent, type SessionEvent, -} from "../src/core/extensions/index.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createSyntheticSourceInfo } from "../src/core/source-info.js"; -import { createCodingTools } from "../src/index.js"; -import { createTestResourceLoader } from "./utilities.js"; +} from "../src/core/extensions/index.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; +import { createCodingTools } from "../src/index.ts"; +import { createTestResourceLoader } from "./utilities.ts"; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; diff --git a/packages/coding-agent/test/compaction-serialization.test.ts b/packages/coding-agent/test/compaction-serialization.test.ts index 9b11bb0f..91ca9251 100644 --- a/packages/coding-agent/test/compaction-serialization.test.ts +++ b/packages/coding-agent/test/compaction-serialization.test.ts @@ -1,6 +1,6 @@ import type { Message } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { serializeConversation } from "../src/core/compaction/utils.js"; +import { serializeConversation } from "../src/core/compaction/utils.ts"; describe("serializeConversation", () => { it("should truncate long tool results", () => { diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index c674ef1a..6609aca1 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -1,7 +1,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateSummary } from "../src/core/compaction/index.js"; +import { type CompactionPreparation, compact, generateSummary } from "../src/core/compaction/index.ts"; const { completeSimpleMock } = vi.hoisted(() => ({ completeSimpleMock: vi.fn(), @@ -15,7 +15,7 @@ vi.mock("@earendil-works/pi-ai", async (importOriginal) => { }; }); -function createModel(reasoning: boolean): Model<"anthropic-messages"> { +function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-messages"> { return { id: reasoning ? "reasoning-model" : "non-reasoning-model", name: reasoning ? "Reasoning Model" : "Non-reasoning Model", @@ -26,7 +26,7 @@ function createModel(reasoning: boolean): Model<"anthropic-messages"> { input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, - maxTokens: 8192, + maxTokens, }; } @@ -115,4 +115,20 @@ describe("generateSummary reasoning options", () => { }); expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); + + it("clamps compaction summary maxTokens to the model output cap", async () => { + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 600000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 }, + }; + + await compact(preparation, createModel(false, 128000), "test-key"); + + expect(completeSimpleMock.mock.calls.map((call) => call[2]?.maxTokens)).toEqual([128000, 128000]); + }); }); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index ca665543..929d06ec 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -14,7 +14,7 @@ import { getLastAssistantUsage, prepareCompaction, shouldCompact, -} from "../src/core/compaction/index.js"; +} from "../src/core/compaction/index.ts"; import { buildSessionContext, type CompactionEntry, @@ -24,7 +24,7 @@ import { type SessionEntry, type SessionMessageEntry, type ThinkingLevelChangeEntry, -} from "../src/core/session-manager.js"; +} from "../src/core/session-manager.ts"; // ============================================================================ // Test fixtures diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 6516beb5..6b90778a 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -7,11 +7,12 @@ import { getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getUpdateInstruction, -} from "../src/config.js"; +} from "../src/config.ts"; const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath"); const originalPath = process.env.PATH; const originalPiPackageDir = process.env.PI_PACKAGE_DIR; +const originalArgv1 = process.argv[1]; let tempDir: string | undefined; function setExecPath(value: string): void { @@ -35,6 +36,11 @@ afterEach(() => { } else { process.env.PI_PACKAGE_DIR = originalPiPackageDir; } + if (originalArgv1 === undefined) { + process.argv.splice(1, 1); + } else { + process.argv[1] = originalArgv1; + } if (tempDir) { chmodSync(tempDir, 0o700); rmSync(tempDir, { recursive: true, force: true }); @@ -147,7 +153,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: pnpm install -g @earendil-works/pi-coding-agent", + "Run: pnpm install -g --ignore-scripts @earendil-works/pi-coding-agent", ); }); @@ -169,8 +175,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g @earendil-works/pi-coding-agent`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], + display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, }); }); @@ -181,8 +187,8 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"], - display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, steps: [ { command: "npm", @@ -191,8 +197,8 @@ describe("detectInstallMethod", () => { }, { command: "npm", - args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"], - display: `npm --prefix ${prefix} install -g @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: `npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, }, ], }); @@ -205,8 +211,8 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g @earendil-works/pi-coding-agent`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], + display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, }); }); @@ -215,7 +221,14 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", []); - expect(command?.args).toEqual(["--prefix", prefix, "install", "-g", "@earendil-works/pi-coding-agent"]); + expect(command?.args).toEqual([ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "@earendil-works/pi-coding-agent", + ]); }); test("quotes npm self-update display paths", () => { @@ -223,7 +236,9 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent"); - expect(command?.display).toBe(`npm --prefix "${prefix}" install -g @earendil-works/pi-coding-agent`); + expect(command?.display).toBe( + `npm --prefix "${prefix}" install -g --ignore-scripts @earendil-works/pi-coding-agent`, + ); }); test("does not infer Windows npm custom prefixes from package paths", () => { @@ -233,7 +248,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: npm install -g @earendil-works/pi-coding-agent", + "Run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent", ); }); @@ -245,8 +260,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "@earendil-works/pi-coding-agent"], - display: "bun install -g @earendil-works/pi-coding-agent", + args: ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], + display: "bun install -g --ignore-scripts @earendil-works/pi-coding-agent", }); }); @@ -258,8 +273,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(command).toEqual({ command: "pnpm", - args: ["install", "-g", "@new-scope/pi"], - display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts @new-scope/pi", steps: [ { command: "pnpm", @@ -268,13 +283,56 @@ describe("detectInstallMethod", () => { }, { command: "pnpm", - args: ["install", "-g", "@new-scope/pi"], - display: "pnpm install -g @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: "pnpm install -g --ignore-scripts @new-scope/pi", }, ], }); }); + test("self-updates pnpm v11 global installs resolved through the store", () => { + const temp = mkdtempSync(join(tmpdir(), "pi-pnpm11-")); + const binDir = join(temp, "bin"); + const root = join(temp, "Library", "pnpm", "global", "v11"); + const packageName = "@earendil-works/pi-coding-agent"; + const globalPackageDir = join(root, "11e9a", "node_modules", "@earendil-works", "pi-coding-agent"); + const storePackageDir = join( + temp, + "Library", + "pnpm", + "store", + "v11", + "links", + "@earendil-works", + "pi-coding-agent", + "0.75.0", + "hash", + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + mkdirSync(globalPackageDir, { recursive: true }); + mkdirSync(storePackageDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(globalPackageDir, "package.json"), "{}"); + writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root)); + chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755); + tempDir = temp; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + process.env.PI_PACKAGE_DIR = storePackageDir; + process.argv[1] = join(globalPackageDir, "dist", "cli.js"); + setExecPath(join(storePackageDir, "dist", "cli.js")); + + const command = getSelfUpdateCommand(packageName); + + expect(detectInstallMethod()).toBe("pnpm"); + expect(command).toEqual({ + command: "pnpm", + args: ["install", "-g", "--ignore-scripts", packageName], + display: `pnpm install -g --ignore-scripts ${packageName}`, + }); + }); + test("self-updates renamed yarn global installs by removing the old package first", () => { createYarnGlobalInstall(); @@ -283,8 +341,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("yarn"); expect(command).toEqual({ command: "yarn", - args: ["global", "add", "@new-scope/pi"], - display: "yarn global remove @mariozechner/pi-coding-agent && yarn global add @new-scope/pi", + args: ["global", "add", "--ignore-scripts", "@new-scope/pi"], + display: "yarn global remove @mariozechner/pi-coding-agent && yarn global add --ignore-scripts @new-scope/pi", steps: [ { command: "yarn", @@ -293,8 +351,8 @@ describe("detectInstallMethod", () => { }, { command: "yarn", - args: ["global", "add", "@new-scope/pi"], - display: "yarn global add @new-scope/pi", + args: ["global", "add", "--ignore-scripts", "@new-scope/pi"], + display: "yarn global add --ignore-scripts @new-scope/pi", }, ], }); @@ -308,8 +366,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "@new-scope/pi"], - display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts @new-scope/pi", steps: [ { command: "bun", @@ -318,8 +376,8 @@ describe("detectInstallMethod", () => { }, { command: "bun", - args: ["install", "-g", "@new-scope/pi"], - display: "bun install -g @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], + display: "bun install -g --ignore-scripts @new-scope/pi", }, ], }); diff --git a/packages/coding-agent/test/edit-tool-legacy-input.test.ts b/packages/coding-agent/test/edit-tool-legacy-input.test.ts index 13a8bbeb..26a550d2 100644 --- a/packages/coding-agent/test/edit-tool-legacy-input.test.ts +++ b/packages/coding-agent/test/edit-tool-legacy-input.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import type { ExtensionContext } from "../src/core/extensions/types.js"; -import { createEditToolDefinition } from "../src/core/tools/edit.js"; +import type { ExtensionContext } from "../src/core/extensions/types.ts"; +import { createEditToolDefinition } from "../src/core/tools/edit.ts"; const tempDirs: string[] = []; diff --git a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts index 21d4e7d5..1da9f0df 100644 --- a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts +++ b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Container, type Terminal, Text, TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createEditToolDefinition } from "../src/core/tools/edit.js"; -import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.js"; -import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { createEditToolDefinition } from "../src/core/tools/edit.ts"; +import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.ts"; +import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; class FakeTerminal implements Terminal { columns = 80; diff --git a/packages/coding-agent/test/export-html-whitespace.test.ts b/packages/coding-agent/test/export-html-whitespace.test.ts index 4336c63c..9d2a0e84 100644 --- a/packages/coding-agent/test/export-html-whitespace.test.ts +++ b/packages/coding-agent/test/export-html-whitespace.test.ts @@ -1,10 +1,10 @@ import type { Component } from "@earendil-works/pi-tui"; import { readFileSync } from "fs"; import { describe, expect, it } from "vitest"; -import { ansiLinesToHtml } from "../src/core/export-html/ansi-to-html.js"; -import { createToolHtmlRenderer } from "../src/core/export-html/tool-renderer.js"; -import type { ToolDefinition } from "../src/core/extensions/types.js"; -import type { Theme } from "../src/modes/interactive/theme/theme.js"; +import { ansiLinesToHtml } from "../src/core/export-html/ansi-to-html.ts"; +import { createToolHtmlRenderer } from "../src/core/export-html/tool-renderer.ts"; +import type { ToolDefinition } from "../src/core/extensions/types.ts"; +import type { Theme } from "../src/modes/interactive/theme/theme.ts"; describe("export HTML tool output whitespace", () => { it("preserves whitespace for plain-text tool output lines without preserving template whitespace", () => { diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 52029d2c..b1b1232f 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -123,6 +123,31 @@ describe("extensions discovery", () => { expect(result.extensions[0].path).toContain("main.ts"); }); + it("keeps package.json pi extension entries with leading tilde package-relative", async () => { + const subdir = path.join(extensionsDir, "tilde-package"); + const directExtensionPath = path.join(subdir, "~entry.ts"); + const slashExtensionPath = path.join(subdir, "~", "entry.ts"); + fs.mkdirSync(path.join(subdir, "~"), { recursive: true }); + fs.writeFileSync(directExtensionPath, extensionCode); + fs.writeFileSync(slashExtensionPath, extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "tilde-package", + pi: { + extensions: ["~entry.ts", "~/entry.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions.map((extension) => extension.path).sort()).toEqual( + [directExtensionPath, slashExtensionPath].sort(), + ); + }); + it("package.json can declare multiple extensions", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); @@ -440,7 +465,7 @@ describe("extensions discovery", () => { fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit")); // Use loadExtensions directly to skip discovery - const { loadExtensions } = await import("../src/core/extensions/loader.js"); + const { loadExtensions } = await import("../src/core/extensions/loader.ts"); const result = await loadExtensions([explicitPath], tempDir); expect(result.errors).toHaveLength(0); @@ -454,7 +479,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode); // Use loadExtensions directly with empty paths - const { loadExtensions } = await import("../src/core/extensions/loader.js"); + const { loadExtensions } = await import("../src/core/extensions/loader.ts"); const result = await loadExtensions([], tempDir); expect(result.errors).toHaveLength(0); diff --git a/packages/coding-agent/test/extensions-input-event.test.ts b/packages/coding-agent/test/extensions-input-event.test.ts index 5c08fd2c..3d5ae5cc 100644 --- a/packages/coding-agent/test/extensions-input-event.test.ts +++ b/packages/coding-agent/test/extensions-input-event.test.ts @@ -2,11 +2,11 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; -import { ExtensionRunner } from "../src/core/extensions/runner.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; describe("Input Event", () => { let tempDir: string; diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 67c46235..03de2d77 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -6,13 +6,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; -import { ExtensionRunner } from "../src/core/extensions/runner.js"; -import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.js"; -import { KeybindingsManager, type KeyId } from "../src/core/keybindings.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.ts"; +import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; describe("ExtensionRunner", () => { let tempDir: string; diff --git a/packages/coding-agent/test/file-mutation-queue.test.ts b/packages/coding-agent/test/file-mutation-queue.test.ts index 8b839fe2..73d20c6d 100644 --- a/packages/coding-agent/test/file-mutation-queue.test.ts +++ b/packages/coding-agent/test/file-mutation-queue.test.ts @@ -2,9 +2,9 @@ import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promi import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createEditTool } from "../src/core/tools/edit.js"; -import { withFileMutationQueue } from "../src/core/tools/file-mutation-queue.js"; -import { createWriteTool } from "../src/core/tools/write.js"; +import { createEditTool } from "../src/core/tools/edit.ts"; +import { withFileMutationQueue } from "../src/core/tools/file-mutation-queue.ts"; +import { createWriteTool } from "../src/core/tools/write.ts"; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/coding-agent/test/footer-data-provider.test.ts b/packages/coding-agent/test/footer-data-provider.test.ts index 3a6e1d47..4ad9a23a 100644 --- a/packages/coding-agent/test/footer-data-provider.test.ts +++ b/packages/coding-agent/test/footer-data-provider.test.ts @@ -37,7 +37,7 @@ vi.mock("child_process", () => ({ }), })); -import { FooterDataProvider } from "../src/core/footer-data-provider.js"; +import { FooterDataProvider } from "../src/core/footer-data-provider.ts"; type WorktreeFixture = { worktreeDir: string; diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index dd158f36..2d912647 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -1,9 +1,9 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, it } from "vitest"; -import type { AgentSession } from "../src/core/agent-session.js"; -import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.js"; -import { FooterComponent } from "../src/modes/interactive/components/footer.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import type { AgentSession } from "../src/core/agent-session.ts"; +import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; +import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; type AssistantUsage = { input: number; diff --git a/packages/coding-agent/test/frontmatter.test.ts b/packages/coding-agent/test/frontmatter.test.ts index 3879fedf..43a92985 100644 --- a/packages/coding-agent/test/frontmatter.test.ts +++ b/packages/coding-agent/test/frontmatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.js"; +import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.ts"; describe("parseFrontmatter", () => { it("parses keys, strips quotes, and returns body", () => { diff --git a/packages/coding-agent/test/git-ssh-url.test.ts b/packages/coding-agent/test/git-ssh-url.test.ts index a7d24267..6913b70c 100644 --- a/packages/coding-agent/test/git-ssh-url.test.ts +++ b/packages/coding-agent/test/git-ssh-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseGitUrl } from "../src/utils/git.js"; +import { parseGitUrl } from "../src/utils/git.ts"; describe("Git URL Parsing", () => { describe("protocol URLs (accepted without git: prefix)", () => { diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 2cfcb404..0f574ba6 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -12,8 +12,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultPackageManager } from "../src/core/package-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { DefaultPackageManager } from "../src/core/package-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; // Helper to run git commands in a directory function git(args: string[], cwd: string): string { diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts index 4dba6f2d..40f264a8 100644 --- a/packages/coding-agent/test/image-processing.test.ts +++ b/packages/coding-agent/test/image-processing.test.ts @@ -3,8 +3,8 @@ */ import { describe, expect, it } from "vitest"; -import { convertToPng } from "../src/utils/image-convert.js"; -import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.js"; +import { convertToPng } from "../src/utils/image-convert.ts"; +import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.ts"; // Small 2x2 red PNG image (base64) - generated with ImageMagick const TINY_PNG = diff --git a/packages/coding-agent/test/image-resize-callers.test.ts b/packages/coding-agent/test/image-resize-callers.test.ts index 5d1c1a79..2337ae8c 100644 --- a/packages/coding-agent/test/image-resize-callers.test.ts +++ b/packages/coding-agent/test/image-resize-callers.test.ts @@ -8,9 +8,9 @@ vi.mock("../src/utils/image-resize.js", () => ({ formatDimensionNote: vi.fn(() => undefined), })); -import { processFileArguments } from "../src/cli/file-processor.js"; -import { createReadTool } from "../src/core/tools/read.js"; -import { resizeImage } from "../src/utils/image-resize.js"; +import { processFileArguments } from "../src/cli/file-processor.ts"; +import { createReadTool } from "../src/core/tools/read.ts"; +import { resizeImage } from "../src/utils/image-resize.ts"; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; diff --git a/packages/coding-agent/test/initial-message.test.ts b/packages/coding-agent/test/initial-message.test.ts index 22b1612d..fae479f7 100644 --- a/packages/coding-agent/test/initial-message.test.ts +++ b/packages/coding-agent/test/initial-message.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import type { Args } from "../src/cli/args.js"; -import { buildInitialMessage } from "../src/cli/initial-message.js"; +import type { Args } from "../src/cli/args.ts"; +import { buildInitialMessage } from "../src/cli/initial-message.ts"; function createArgs(messages: string[] = []): Args { return { diff --git a/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts b/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts index 16ca930a..10e35192 100644 --- a/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts +++ b/packages/coding-agent/test/interactive-mode-anthropic-warning.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; function createSettingsManager(warnings: { anthropicExtraUsage?: boolean } = {}) { return { diff --git a/packages/coding-agent/test/interactive-mode-clone-command.test.ts b/packages/coding-agent/test/interactive-mode-clone-command.test.ts index 9388b89f..fea69c5d 100644 --- a/packages/coding-agent/test/interactive-mode-clone-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-clone-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; type CloneCommandContext = { sessionManager: { getLeafId: () => string | null }; diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 85e8e919..0fc90454 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; describe("InteractiveMode compaction events", () => { test("rebuilds chat and appends a synthetic compaction summary at the bottom", async () => { diff --git a/packages/coding-agent/test/interactive-mode-import-command.test.ts b/packages/coding-agent/test/interactive-mode-import-command.test.ts index 85575ec0..c2e5ae1f 100644 --- a/packages/coding-agent/test/interactive-mode-import-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-import-command.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; type PathCommand = "/export" | "/import"; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index ba2b46c5..d5a2c159 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,10 +2,10 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; -import type { AutocompleteProviderFactory } from "../src/core/extensions/types.js"; -import type { SourceInfo } from "../src/core/source-info.js"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; +import type { SourceInfo } from "../src/core/source-info.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { const last = container.children[container.children.length - 1]; diff --git a/packages/coding-agent/test/interactive-mode-suspend.test.ts b/packages/coding-agent/test/interactive-mode-suspend.test.ts index 3f41fe2e..996d2f1a 100644 --- a/packages/coding-agent/test/interactive-mode-suspend.test.ts +++ b/packages/coding-agent/test/interactive-mode-suspend.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; type FakeUi = { start: () => void; diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 0799ff25..f393687c 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -2,9 +2,9 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { ENV_AGENT_DIR } from "../src/config.js"; -import { KeybindingsManager } from "../src/core/keybindings.js"; -import { runMigrations } from "../src/migrations.js"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { runMigrations } from "../src/migrations.ts"; describe("keybindings migration", () => { const tempDirs: string[] = []; diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 03434ea8..ad706ab5 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -5,8 +5,8 @@ import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCom import { getApiProvider } from "@earendil-works/pi-ai"; import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; describe("ModelRegistry", () => { let tempDir: string; diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 69afd756..7339200b 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -5,7 +5,7 @@ import { findInitialModel, parseModelPattern, resolveCliModel, -} from "../src/core/model-resolver.js"; +} from "../src/core/model-resolver.ts"; // Mock models for testing const mockModels: Model<"anthropic-messages">[] = [ diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 9cd02eeb..7164db54 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -1,12 +1,12 @@ import { setKeybindings } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { KeybindingsManager } from "../src/core/keybindings.js"; -import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../src/core/provider-display-names.js"; -import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.js"; -import { isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../src/core/provider-display-names.ts"; +import { OAuthSelectorComponent } from "../src/modes/interactive/components/oauth-selector.ts"; +import { isApiKeyLoginProvider } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; const originalOpenAiApiKey = process.env.OPENAI_API_KEY; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 03c8f4ca..5e4a1a48 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -2,8 +2,8 @@ import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.js"; -import { main } from "../src/main.js"; +import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { main } from "../src/main.ts"; describe("package commands", () => { let tempDir: string; diff --git a/packages/coding-agent/test/package-manager-ssh.test.ts b/packages/coding-agent/test/package-manager-ssh.test.ts index 3dc04ab1..1fa9b917 100644 --- a/packages/coding-agent/test/package-manager-ssh.test.ts +++ b/packages/coding-agent/test/package-manager-ssh.test.ts @@ -2,8 +2,8 @@ import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultPackageManager } from "../src/core/package-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { DefaultPackageManager } from "../src/core/package-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("Package Manager git source parsing", () => { let tempDir: string; diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 18128a83..30068da1 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -4,9 +4,8 @@ import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { shouldUseWindowsShell } from "../src/utils/child-process.js"; +import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; function normalizeForMatch(value: string): string { return value.replace(/\\/g, "/"); @@ -26,6 +25,16 @@ class MockSpawnedProcess extends EventEmitter { } } +interface PackageManagerInternals { + runCommand(command: string, args: string[], options?: { cwd?: string }): Promise; + runCommandCapture( + command: string, + args: string[], + options?: { cwd?: string; timeoutMs?: number; env?: Record }, + ): Promise; + getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; +} + // Helper to check if a resource is enabled const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => { const normalizedPath = normalizeForMatch(r.path); @@ -575,6 +584,40 @@ Content`, ); }); + it("should keep pi manifest entries with leading tilde package-relative", async () => { + const pkgDir = join(tempDir, "tilde-manifest-package"); + const directExtensionPath = join(pkgDir, "~extensions", "main.ts"); + const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts"); + const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md"); + const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md"); + + mkdirSync(join(pkgDir, "~extensions"), { recursive: true }); + mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true }); + mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true }); + mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true }); + writeFileSync(directExtensionPath, "export default function() {}"); + writeFileSync(slashExtensionPath, "export default function() {}"); + writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent"); + writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "tilde-manifest-package", + pi: { + extensions: ["~extensions/main.ts", "~/extensions/alt.ts"], + skills: ["~skills", "~/skills"], + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + + expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true); + expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true); + }); + it("should handle directories with auto-discovery layout", async () => { const pkgDir = join(tempDir, "auto-pkg"); mkdirSync(join(pkgDir, "extensions"), { recursive: true }); @@ -617,14 +660,19 @@ Content`, }); }); - describe("windows command spawning", () => { - it("should avoid the shell for git so Windows paths with spaces stay single arguments", () => { - vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + describe("command spawning", () => { + it("should preserve argv entries containing spaces", () => { + const managerWithInternals = packageManager as unknown as { + runCommandSync(command: string, args: string[]): string; + }; + const valueWithSpace = "C:\\Users\\A B\\.pi\\npm"; + const output = managerWithInternals.runCommandSync(process.execPath, [ + "-e", + "console.log(process.argv[1])", + valueWithSpace, + ]); - expect(shouldUseWindowsShell("git")).toBe(false); - expect(shouldUseWindowsShell("npm")).toBe(true); - expect(shouldUseWindowsShell("pnpm")).toBe(true); - expect(shouldUseWindowsShell("C:/Program Files/nodejs/npm.cmd")).toBe(true); + expect(output).toBe(valueWithSpace); }); }); @@ -645,7 +693,28 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith( "mise", - ["exec", "node@20", "--", "npm", "install", "-g", "@scope/pkg"], + ["exec", "node@20", "--", "npm", "install", "@scope/pkg", "--prefix", join(agentDir, "npm")], + undefined, + ); + }); + + it("should use bun --cwd for npm package installs", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "bun@1", "--", "bun"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.install("npm:@scope/pkg"); + + expect(runCommandSpy).toHaveBeenCalledWith( + "mise", + ["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm")], undefined, ); }); @@ -668,6 +737,62 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); }); + it("should reconcile an existing git checkout to a pinned ref during install", async () => { + const source = "git:github.com/user/repo@v2"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(targetDir, "package.json"), JSON.stringify({ name: "repo", version: "1.0.0" })); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => { + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return "old-head"; + } + if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") { + return "new-head"; + } + throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); + }); + const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined); + + await packageManager.install(source); + + expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); + }); + + it("should reconcile an existing git checkout to its update target when installing without a ref", async () => { + const source = "git:github.com/user/repo"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]; + mkdirSync(targetDir, { recursive: true }); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({ + ref: "origin/HEAD", + head: "new-head", + fetchArgs, + }); + vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => { + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return "old-head"; + } + if (args[0] === "rev-parse" && args[1] === "origin/HEAD") { + return "new-head"; + } + throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); + }); + const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined); + + await packageManager.install(source); + + expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); + }); + it("should use plain install for git package dependencies when npmCommand is configured", async () => { settingsManager = SettingsManager.inMemory({ npmCommand: ["pnpm"], @@ -798,6 +923,135 @@ Content`, expect(packageManager.getInstalledPath("npm:@scope/pkg", "user")).toBeUndefined(); expect(runCommandSyncSpy).toHaveBeenNthCalledWith(2, "mise", ["exec", "node@22", "--", "npm", "root", "-g"]); }); + + it("should install user npm packages into the pi-managed npm root", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["pnpm"], + packages: ["npm:pnpm-pkg"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const packagePath = join(agentDir, "npm", "node_modules", "pnpm-pkg"); + vi.spyOn(packageManager as any, "runCommandSync").mockImplementation(() => { + throw new Error("legacy lookup unavailable"); + }); + const runCommandSpy = vi + .spyOn(packageManager as any, "runCommand") + .mockImplementation(async (...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + expect(command).toBe("pnpm"); + expect(args).toEqual([ + "install", + "pnpm-pkg", + "--prefix", + join(agentDir, "npm"), + "--config.strict-dep-builds=false", + ]); + mkdirSync(join(packagePath, "extensions"), { recursive: true }); + writeFileSync(join(packagePath, "package.json"), JSON.stringify({ name: "pnpm-pkg", version: "1.0.0" })); + writeFileSync(join(packagePath, "extensions", "index.ts"), "export default function() {};"); + }); + + const first = await packageManager.resolve(); + const second = await packageManager.resolve(); + + expect(first.extensions.some((r) => r.path === join(packagePath, "extensions", "index.ts") && r.enabled)).toBe( + true, + ); + expect( + second.extensions.some((r) => r.path === join(packagePath, "extensions", "index.ts") && r.enabled), + ).toBe(true); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBe(packagePath); + }); + + it("should load legacy pnpm global package paths from pnpm list output", async () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["pnpm"], + packages: ["npm:pnpm-pkg"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const pnpmRoot = join(tempDir, "pnpm", "global", "v11"); + const packagePath = join(pnpmRoot, "20-hash", "node_modules", "pnpm-pkg"); + mkdirSync(join(packagePath, "extensions"), { recursive: true }); + writeFileSync(join(packagePath, "package.json"), JSON.stringify({ name: "pnpm-pkg", version: "1.0.0" })); + writeFileSync(join(packagePath, "extensions", "index.ts"), "export default function() {};"); + + vi.spyOn(packageManager as any, "runCommandSync").mockImplementation((...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + if (command !== "pnpm") { + throw new Error(`unexpected command ${command}`); + } + if (args.join(" ") === "list -g --depth 0 --json") { + return JSON.stringify([ + { + path: pnpmRoot, + dependencies: { "pnpm-pkg": { version: "1.0.0", path: packagePath } }, + }, + ]); + } + throw new Error(`unexpected args ${args.join(" ")}`); + }); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + const result = await packageManager.resolve(); + + expect( + result.extensions.some((r) => r.path === join(packagePath, "extensions", "index.ts") && r.enabled), + ).toBe(true); + expect(runCommandSpy).not.toHaveBeenCalled(); + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBe(packagePath); + }); + + it("should resolve wrapped pnpm global package paths from pnpm list output", () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["mise", "exec", "node@20", "--", "pnpm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const pnpmRoot = join(tempDir, "pnpm", "global", "v11"); + const packagePath = join(pnpmRoot, "20-hash", "node_modules", "pnpm-pkg"); + mkdirSync(packagePath, { recursive: true }); + + vi.spyOn(packageManager as any, "runCommandSync").mockImplementation((...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + expect(command).toBe("mise"); + if (args.join(" ") === "exec node@20 -- pnpm list -g --depth 0 --json") { + return JSON.stringify([{ path: pnpmRoot, dependencies: { "pnpm-pkg": { path: packagePath } } }]); + } + throw new Error(`unexpected args ${args.join(" ")}`); + }); + + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBe(packagePath); + }); + + it("should ignore malformed legacy pnpm global package lists", () => { + settingsManager = SettingsManager.inMemory({ + npmCommand: ["pnpm"], + }); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + vi.spyOn(packageManager as any, "runCommandSync").mockReturnValue("not json"); + + expect(packageManager.getInstalledPath("npm:pnpm-pkg", "user")).toBeUndefined(); + }); }); describe("source parsing", () => { @@ -907,6 +1161,47 @@ Content`, expect(removed).toBe(true); expect(settingsManager.getGlobalSettings().packages ?? []).toHaveLength(0); }); + + it("should return false when adding the same git source with the same ref", () => { + const first = packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + expect(first).toBe(true); + + const second = packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + expect(second).toBe(false); + expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v1"]); + }); + + it("should update the ref when adding the same git source with a different ref", () => { + packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + + const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2"); + expect(updated).toBe(true); + expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v2"]); + }); + + it("should preserve package filters when replacing a package source ref", () => { + settingsManager.setPackages([ + { + source: "git:github.com/user/repo@v1", + extensions: ["extensions/main.ts"], + skills: [], + prompts: ["prompts/review.md"], + themes: ["themes/dark.json"], + }, + ]); + + const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2"); + expect(updated).toBe(true); + expect(settingsManager.getGlobalSettings().packages).toEqual([ + { + source: "git:github.com/user/repo@v2", + extensions: ["extensions/main.ts"], + skills: [], + prompts: ["prompts/review.md"], + themes: ["themes/dark.json"], + }, + ]); + }); }); describe("HTTPS git URL parsing (old behavior)", () => { @@ -1593,7 +1888,7 @@ Content`, // Main entry point writeFileSync( join(pkgDir, "extensions", "subagent", "index.ts"), - `import { helper } from "./agents.js"; + `import { helper } from "./agents.ts"; export default function(api) { api.registerTool({ name: "test", description: "test", execute: async () => helper() }); }`, ); // Helper module (should NOT be loaded as standalone extension) @@ -1649,7 +1944,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te // Subdirectory with index.ts + helpers writeFileSync( join(pkgDir, "extensions", "complex", "index.ts"), - "import { a } from './a.js'; export default function(api) {}", + "import { a } from './a.ts'; export default function(api) {}", ); writeFileSync(join(pkgDir, "extensions", "complex", "a.ts"), "export const a = 1;"); writeFileSync(join(pkgDir, "extensions", "complex", "b.ts"), "export const b = 2;"); @@ -1730,12 +2025,42 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).not.toHaveBeenCalled(); }); - it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => { - vi.spyOn(packageManager as any, "getGlobalNpmRoot").mockReturnValue(join(agentDir, "node_modules")); + it("should migrate legacy user npm installs into the managed npm root during update", async () => { + const legacyRoot = join(tempDir, "legacy-global", "node_modules"); + const legacyPath = join(legacyRoot, "legacy-pkg"); + const managedPath = join(agentDir, "npm", "node_modules", "legacy-pkg"); + mkdirSync(legacyPath, { recursive: true }); + writeFileSync(join(legacyPath, "package.json"), JSON.stringify({ name: "legacy-pkg", version: "1.0.0" })); + settingsManager.setPackages(["npm:legacy-pkg"]); - const userOldPath = join(agentDir, "node_modules", "user-old"); - const userCurrentPath = join(agentDir, "node_modules", "user-current"); - const userUnknownPath = join(agentDir, "node_modules", "user-unknown"); + vi.spyOn(packageManager as any, "getGlobalNpmRoot").mockReturnValue(legacyRoot); + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.0.0"'); + const runCommandSpy = vi + .spyOn(packageManager as any, "runCommand") + .mockImplementation(async (...callArgs: unknown[]) => { + const [command, args] = callArgs as [string, string[]]; + expect(command).toBe("npm"); + expect(args).toEqual(["install", "legacy-pkg@latest", "--prefix", join(agentDir, "npm")]); + mkdirSync(managedPath, { recursive: true }); + writeFileSync( + join(managedPath, "package.json"), + JSON.stringify({ name: "legacy-pkg", version: "1.0.0" }), + ); + }); + + expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(legacyPath); + + await packageManager.update("npm:legacy-pkg"); + + expect(runCommandCaptureSpy).not.toHaveBeenCalled(); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(managedPath); + }); + + it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => { + const userOldPath = join(agentDir, "npm", "node_modules", "user-old"); + const userCurrentPath = join(agentDir, "npm", "node_modules", "user-current"); + const userUnknownPath = join(agentDir, "npm", "node_modules", "user-unknown"); const projectOldPath = join(tempDir, ".pi", "npm", "node_modules", "project-old"); const projectCurrentPath = join(tempDir, ".pi", "npm", "node_modules", "project-current"); const installPaths = [userOldPath, userCurrentPath, userUnknownPath, projectOldPath, projectCurrentPath]; @@ -1825,7 +2150,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).toHaveBeenNthCalledWith( 1, "npm", - ["install", "-g", "user-old@latest", "user-unknown@latest"], + ["install", "user-old@latest", "user-unknown@latest", "--prefix", join(agentDir, "npm")], undefined, ); expect(runCommandSpy).toHaveBeenNthCalledWith( diff --git a/packages/coding-agent/test/path-utils.test.ts b/packages/coding-agent/test/path-utils.test.ts index 2ddf127c..77a8345f 100644 --- a/packages/coding-agent/test/path-utils.test.ts +++ b/packages/coding-agent/test/path-utils.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from " import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.js"; +import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.ts"; describe("path-utils", () => { describe("expandPath", () => { @@ -16,6 +16,11 @@ describe("path-utils", () => { expect(result).not.toContain("~/"); }); + it("should keep tilde-prefixed filenames literal", () => { + expect(expandPath("~draft.md")).toBe("~draft.md"); + expect(expandPath("@~draft.md")).toBe("~draft.md"); + }); + it("should normalize Unicode spaces", () => { // Non-breaking space (U+00A0) should become regular space const withNBSP = "file\u00A0name.txt"; @@ -26,14 +31,21 @@ describe("path-utils", () => { describe("resolveToCwd", () => { it("should resolve absolute paths as-is", () => { - const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd"); - expect(result).toBe("/absolute/path/file.txt"); + const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt"); + const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd")); + expect(result).toBe(absolutePath); }); it("should resolve relative paths against cwd", () => { const result = resolveToCwd("relative/file.txt", "/some/cwd"); expect(result).toBe(resolve("/some/cwd", "relative/file.txt")); }); + + it("should resolve tilde-prefixed filenames against cwd", () => { + const cwd = join(tmpdir(), "pi-path-utils-cwd"); + expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + }); }); describe("resolveReadPath", () => { diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index 208da3f9..accf9989 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -1,8 +1,9 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.js"; +import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts"; let tempDir: string; @@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => { }); }); +describe("resolvePath", () => { + it("expands only home tilde shortcuts", () => { + const cwd = join(tmpdir(), "pi-paths-cwd"); + expect(normalizePath("~")).toBe(homedir()); + expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt")); + expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + expect(normalizePath("~draft.md")).toBe("~draft.md"); + }); + + it("resolves relative paths against the base directory", () => { + const cwd = join(tmpdir(), "pi-paths-cwd"); + expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt")); + expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt")); + }); + + it("accepts file URLs", () => { + const dir = createTempDir(); + const filePath = join(dir, "file with spaces.txt"); + expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath)); + }); + + it("throws for invalid file URLs", () => { + expect(() => resolvePath("file:///%E0%A4%A")).toThrow(); + }); + + it("preserves POSIX absolute paths with literal percent sequences", () => { + if (process.platform === "win32") { + return; + } + + const dir = createTempDir(); + for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) { + expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath)); + } + }); + + it("does not treat Windows file URL pathname strings as native paths", () => { + if (process.platform !== "win32") { + return; + } + + const dir = createTempDir(); + const filePath = join(dir, "dir", "SKILL.md"); + const pathname = pathToFileURL(filePath).pathname; + expect(pathname).toMatch(/^\/[A-Za-z]:/); + expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname)); + }); +}); + describe("isLocalPath", () => { it("returns true for bare names", () => { expect(isLocalPath("my-package")).toBe(true); @@ -82,6 +132,10 @@ describe("isLocalPath", () => { expect(isLocalPath("./foo")).toBe(true); }); + it("returns true for file URLs", () => { + expect(isLocalPath("file:///tmp/foo")).toBe(true); + }); + it("returns false for npm: protocol", () => { expect(isLocalPath("npm:package")).toBe(false); }); diff --git a/packages/coding-agent/test/pi-user-agent.test.ts b/packages/coding-agent/test/pi-user-agent.test.ts index 64439b21..1c49ca29 100644 --- a/packages/coding-agent/test/pi-user-agent.test.ts +++ b/packages/coding-agent/test/pi-user-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getPiUserAgent } from "../src/utils/pi-user-agent.js"; +import { getPiUserAgent } from "../src/utils/pi-user-agent.ts"; describe("getPiUserAgent", () => { it("formats the user agent expected by pi.dev", () => { diff --git a/packages/coding-agent/test/plan-mode-utils.test.ts b/packages/coding-agent/test/plan-mode-utils.test.ts index 8d71ba95..db4b31ec 100644 --- a/packages/coding-agent/test/plan-mode-utils.test.ts +++ b/packages/coding-agent/test/plan-mode-utils.test.ts @@ -6,7 +6,7 @@ import { isSafeCommand, markCompletedSteps, type TodoItem, -} from "../examples/extensions/plan-mode/utils.js"; +} from "../examples/extensions/plan-mode/utils.ts"; describe("isSafeCommand", () => { describe("safe commands", () => { diff --git a/packages/coding-agent/test/print-mode.test.ts b/packages/coding-agent/test/print-mode.test.ts index bbf6756e..30d7251d 100644 --- a/packages/coding-agent/test/print-mode.test.ts +++ b/packages/coding-agent/test/print-mode.test.ts @@ -1,7 +1,7 @@ import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SessionShutdownEvent } from "../src/index.js"; -import { runPrintMode } from "../src/modes/print-mode.js"; +import type { SessionShutdownEvent } from "../src/index.ts"; +import { runPrintMode } from "../src/modes/print-mode.ts"; type EmitEvent = SessionShutdownEvent; diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 67be0da4..1e4bcf5c 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -12,8 +12,13 @@ import { mkdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterAll, describe, expect, test } from "vitest"; -import { getAgentDir } from "../src/config.js"; -import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js"; +import { getAgentDir } from "../src/config.ts"; +import { + expandPromptTemplate, + loadPromptTemplates, + parseCommandArgs, + substituteArgs, +} from "../src/core/prompt-templates.ts"; // ============================================================================ // substituteArgs @@ -335,10 +340,25 @@ describe("parseCommandArgs", () => { expect(parseCommandArgs("日本語 🎉 café")).toEqual(["日本語", "🎉", "café"]); }); - test("should handle newlines in arguments", () => { + test("should handle newlines in quoted arguments", () => { expect(parseCommandArgs('"line1\nline2" second')).toEqual(["line1\nline2", "second"]); }); + test("should treat unquoted newlines as separators", () => { + expect(parseCommandArgs("label-2\n\nHere is some description #2.")).toEqual([ + "label-2", + "Here", + "is", + "some", + "description", + "#2.", + ]); + }); + + test("should collapse mixed unquoted whitespace", () => { + expect(parseCommandArgs("a\n\n\tb c")).toEqual(["a", "b", "c"]); + }); + test("should handle escaped quotes inside quoted strings", () => { // Note: This implementation doesn't handle escaped quotes - backslash is literal expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]); @@ -357,6 +377,40 @@ describe("parseCommandArgs", () => { // Integration // ============================================================================ +describe("expandPromptTemplate", () => { + test("should split template arguments on unquoted newlines", () => { + const result = expandPromptTemplate("/arg-test label-2\n\nHere is some description #2.", [ + { + name: "arg-test", + description: "test", + content: `- arg1: $1\n- rest: \${@:2}`, + sourceInfo: { path: "/tmp/arg-test.md", source: "local", scope: "temporary", origin: "top-level" }, + filePath: "/tmp/arg-test.md", + }, + ]); + + expect(result).toBe("- arg1: label-2\n- rest: Here is some description #2."); + }); + + test("should support template command separated from args by newline", () => { + const result = expandPromptTemplate("/arg-test\nlabel-2", [ + { + name: "arg-test", + description: "test", + content: "arg1: $1", + sourceInfo: { path: "/tmp/arg-test.md", source: "local", scope: "temporary", origin: "top-level" }, + filePath: "/tmp/arg-test.md", + }, + ]); + + expect(result).toBe("arg1: label-2"); + }); +}); + +// ============================================================================ +// Integration +// ============================================================================ + describe("parseCommandArgs + substituteArgs integration", () => { test("should parse and substitute together correctly", () => { const input = 'Button "onClick handler" "disabled support"'; diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 01adc9b3..693f05c9 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -1,15 +1,16 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ExtensionRunner } from "../src/core/extensions/runner.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { DefaultResourceLoader } from "../src/core/resource-loader.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import type { Skill } from "../src/core/skills.js"; -import { createSyntheticSourceInfo } from "../src/core/source-info.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import type { Skill } from "../src/core/skills.ts"; +import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; describe("DefaultResourceLoader", () => { let tempDir: string; @@ -405,6 +406,44 @@ Extra prompt content`, expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra"); expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath); }); + + it("should load extension resources returned as file URLs", async () => { + const extraSkillDir = join(tempDir, "extra skills", "file-url-skill"); + mkdirSync(extraSkillDir, { recursive: true }); + const skillPath = join(extraSkillDir, "SKILL.md"); + writeFileSync( + skillPath, + `--- +name: file-url-skill +description: File URL skill +--- +Extra content`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + loader.extendResources({ + skillPaths: [ + { + path: pathToFileURL(extraSkillDir).href, + metadata: { + source: "extension:file-url", + scope: "temporary", + origin: "top-level", + baseDir: extraSkillDir, + }, + }, + ], + }); + + const { skills, diagnostics } = loader.getSkills(); + expect(diagnostics).toEqual([]); + const loadedSkill = skills.find((skill) => skill.name === "file-url-skill"); + expect(loadedSkill).toBeDefined(); + expect(loadedSkill?.filePath).toBe(skillPath); + expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url"); + }); }); describe("noSkills option", () => { diff --git a/packages/coding-agent/test/restore-sandbox-env.test.ts b/packages/coding-agent/test/restore-sandbox-env.test.ts index fa9036eb..2fb2767f 100644 --- a/packages/coding-agent/test/restore-sandbox-env.test.ts +++ b/packages/coding-agent/test/restore-sandbox-env.test.ts @@ -6,7 +6,7 @@ vi.mock("node:fs", () => ({ readFileSync, })); -const { restoreSandboxEnv } = await import("../src/bun/restore-sandbox-env.js"); +const { restoreSandboxEnv } = await import("../src/bun/restore-sandbox-env.ts"); describe("restoreSandboxEnv", () => { it("does nothing when not running under bun", () => { diff --git a/packages/coding-agent/test/rpc-client-clone.test.ts b/packages/coding-agent/test/rpc-client-clone.test.ts index f05caa4a..083f20c9 100644 --- a/packages/coding-agent/test/rpc-client-clone.test.ts +++ b/packages/coding-agent/test/rpc-client-clone.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { RpcClient } from "../src/modes/rpc/rpc-client.js"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; type RpcClientPrivate = { send: (command: { type: string }) => Promise; diff --git a/packages/coding-agent/test/rpc-example.ts b/packages/coding-agent/test/rpc-example.ts index d82bd934..aaa24fb1 100644 --- a/packages/coding-agent/test/rpc-example.ts +++ b/packages/coding-agent/test/rpc-example.ts @@ -1,7 +1,7 @@ import { dirname, join } from "node:path"; import * as readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { RpcClient } from "../src/modes/rpc/rpc-client.js"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/coding-agent/test/rpc-jsonl.test.ts b/packages/coding-agent/test/rpc-jsonl.test.ts index f8ca4538..c8fc89e1 100644 --- a/packages/coding-agent/test/rpc-jsonl.test.ts +++ b/packages/coding-agent/test/rpc-jsonl.test.ts @@ -1,6 +1,6 @@ import { Readable } from "node:stream"; import { describe, expect, test } from "vitest"; -import { attachJsonlLineReader, serializeJsonLine } from "../src/modes/rpc/jsonl.js"; +import { attachJsonlLineReader, serializeJsonLine } from "../src/modes/rpc/jsonl.ts"; describe("RPC JSONL framing", () => { test("serializes strict JSONL records without escaping Unicode separators", () => { diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 07fac54d..1504a622 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -10,14 +10,14 @@ import { type Model, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { AgentSession } from "../src/core/agent-session.js"; -import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { runRpcMode } from "../src/modes/rpc/rpc-mode.js"; -import { createTestResourceLoader } from "./utilities.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { runRpcMode } from "../src/modes/rpc/rpc-mode.ts"; +import { createTestResourceLoader } from "./utilities.ts"; const rpcIo = vi.hoisted(() => ({ outputLines: [] as string[], diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 9d4a97d4..87447d3b 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentEvent } from "@earendil-works/pi-agent-core"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { RpcClient } from "../src/modes/rpc/rpc-client.js"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts index fe349646..a6338d14 100644 --- a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts +++ b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node /** * Manual SDK probe for OpenAI Codex prompt caching through the tool loop. * @@ -24,15 +24,15 @@ import { import { getOpenAICodexWebSocketDebugStats, streamSimpleOpenAICodexResponses, -} from "../../ai/src/providers/openai-codex-responses.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { createExtensionRuntime } from "../src/core/extensions/loader.js"; -import type { ToolDefinition } from "../src/core/extensions/types.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import type { ResourceLoader } from "../src/core/resource-loader.js"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +} from "../../ai/src/providers/openai-codex-responses.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { createExtensionRuntime } from "../src/core/extensions/loader.ts"; +import type { ToolDefinition } from "../src/core/extensions/types.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import type { ResourceLoader } from "../src/core/resource-loader.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; @@ -123,7 +123,7 @@ function parseArgs(argv: string[]): Args { } function printHelp(): void { - console.log(`Usage: npx tsx test/sdk-codex-cache-probe-tool-loop.ts [options] + console.log(`Usage: node test/sdk-codex-cache-probe-tool-loop.ts [options] Options: --turns Number of turns to run. Must be between ${MIN_TURNS} and ${MAX_TURNS}. Default: ${DEFAULT_TURNS} diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index e667accf..70bb8709 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -9,11 +9,11 @@ import { type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("createAgentSession OpenRouter attribution headers", () => { let tempDir: string; diff --git a/packages/coding-agent/test/sdk-session-manager.test.ts b/packages/coding-agent/test/sdk-session-manager.test.ts index 51498f50..cb7e505c 100644 --- a/packages/coding-agent/test/sdk-session-manager.test.ts +++ b/packages/coding-agent/test/sdk-session-manager.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; describe("createAgentSession session manager defaults", () => { let tempDir: string; diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 5f95e67b..49ade057 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -2,11 +2,11 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createExtensionRuntime } from "../src/core/extensions/loader.js"; -import type { ResourceLoader } from "../src/core/resource-loader.js"; -import { createAgentSession } from "../src/core/sdk.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { createSyntheticSourceInfo } from "../src/core/source-info.js"; +import { createExtensionRuntime } from "../src/core/extensions/loader.ts"; +import type { ResourceLoader } from "../src/core/resource-loader.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; describe("createAgentSession skills option", () => { let tempDir: string; diff --git a/packages/coding-agent/test/session-cwd.test.ts b/packages/coding-agent/test/session-cwd.test.ts index 72ce6014..8a7d23fc 100644 --- a/packages/coding-agent/test/session-cwd.test.ts +++ b/packages/coding-agent/test/session-cwd.test.ts @@ -2,9 +2,9 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.js"; -import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.js"; -import { SessionManager } from "../src/core/session-manager.js"; +import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.ts"; +import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; function createTempDir(name: string): string { const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); diff --git a/packages/coding-agent/test/session-info-modified-timestamp.test.ts b/packages/coding-agent/test/session-info-modified-timestamp.test.ts index 1dd1cb41..a4322e46 100644 --- a/packages/coding-agent/test/session-info-modified-timestamp.test.ts +++ b/packages/coding-agent/test/session-info-modified-timestamp.test.ts @@ -3,9 +3,9 @@ import { stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { SessionHeader } from "../src/core/session-manager.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import type { SessionHeader } from "../src/core/session-manager.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function createSessionFile(path: string): void { const header: SessionHeader = { diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index 5bc336a6..a0d8c8e8 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -7,7 +7,7 @@ import { type SessionEntry, type SessionMessageEntry, type ThinkingLevelChangeEntry, -} from "../../src/core/session-manager.js"; +} from "../../src/core/session-manager.ts"; function msg(id: string, parentId: string | null, role: "user" | "assistant", text: string): SessionMessageEntry { const base = { type: "message" as const, id, parentId, timestamp: "2025-01-01T00:00:00Z" }; diff --git a/packages/coding-agent/test/session-manager/custom-session-id.test.ts b/packages/coding-agent/test/session-manager/custom-session-id.test.ts index 72eed465..f9c6ad0d 100644 --- a/packages/coding-agent/test/session-manager/custom-session-id.test.ts +++ b/packages/coding-agent/test/session-manager/custom-session-id.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { SessionManager } from "../../src/core/session-manager.js"; +import { SessionManager } from "../../src/core/session-manager.ts"; const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 16ef89e6..c744c3c3 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.js"; +import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts"; describe("loadEntriesFromFile", () => { let tempDir: string; diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts index e703abf8..7500746e 100644 --- a/packages/coding-agent/test/session-manager/labels.test.ts +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { type LabelEntry, SessionManager } from "../../src/core/session-manager.js"; +import { type LabelEntry, SessionManager } from "../../src/core/session-manager.ts"; describe("SessionManager labels", () => { it("sets and gets labels", () => { diff --git a/packages/coding-agent/test/session-manager/migration.test.ts b/packages/coding-agent/test/session-manager/migration.test.ts index 1142da37..05774231 100644 --- a/packages/coding-agent/test/session-manager/migration.test.ts +++ b/packages/coding-agent/test/session-manager/migration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager.js"; +import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager.ts"; describe("migrateSessionEntries", () => { it("should add id/parentId to v1 entries", () => { diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts index a384a074..91019b93 100644 --- a/packages/coding-agent/test/session-manager/save-entry.test.ts +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js"; +import { type CustomEntry, SessionManager } from "../../src/core/session-manager.ts"; describe("SessionManager.saveCustomEntry", () => { it("saves custom entries and includes them in tree traversal", () => { diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 2cfb10bc..e47ec909 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, it } from "vitest"; -import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js"; -import { assistantMsg, userMsg } from "../utilities.js"; +import { type CustomEntry, SessionManager } from "../../src/core/session-manager.ts"; +import { assistantMsg, userMsg } from "../utilities.ts"; describe("SessionManager append and tree traversal", () => { describe("append operations", () => { diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts index 618ceef7..52cae486 100644 --- a/packages/coding-agent/test/session-selector-path-delete.test.ts +++ b/packages/coding-agent/test/session-selector-path-delete.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { setKeybindings } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { KeybindingsManager } from "../src/core/keybindings.js"; -import type { SessionInfo } from "../src/core/session-manager.js"; -import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { SessionInfo } from "../src/core/session-manager.ts"; +import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; type Deferred = { promise: Promise; diff --git a/packages/coding-agent/test/session-selector-rename.test.ts b/packages/coding-agent/test/session-selector-rename.test.ts index f91d6ebf..d535496c 100644 --- a/packages/coding-agent/test/session-selector-rename.test.ts +++ b/packages/coding-agent/test/session-selector-rename.test.ts @@ -1,9 +1,9 @@ import { setKeybindings } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { KeybindingsManager } from "../src/core/keybindings.js"; -import type { SessionInfo } from "../src/core/session-manager.js"; -import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { SessionInfo } from "../src/core/session-manager.ts"; +import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; async function flushPromises(): Promise { await new Promise((resolve) => { diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts index f2ffbe1c..c1aed713 100644 --- a/packages/coding-agent/test/session-selector-search.test.ts +++ b/packages/coding-agent/test/session-selector-search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SessionInfo } from "../src/core/session-manager.js"; -import { filterAndSortSessions } from "../src/modes/interactive/components/session-selector-search.js"; +import type { SessionInfo } from "../src/core/session-manager.ts"; +import { filterAndSortSessions } from "../src/modes/interactive/components/session-selector-search.ts"; function makeSession( overrides: Partial & { id: string; modified: Date; allMessagesText: string }, diff --git a/packages/coding-agent/test/settings-manager-bug.test.ts b/packages/coding-agent/test/settings-manager-bug.test.ts index b79fd9f6..92cc3a82 100644 --- a/packages/coding-agent/test/settings-manager-bug.test.ts +++ b/packages/coding-agent/test/settings-manager-bug.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.ts"; /** * Tests for the fix to a bug where external file changes to arrays were overwritten. diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index d01b1363..a503e3cc 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("SettingsManager", () => { const testDir = join(process.cwd(), "test-settings-tmp"); @@ -257,6 +258,29 @@ describe("SettingsManager", () => { }); }); + describe("httpIdleTimeoutMs", () => { + it("should default to 5 minutes", () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getHttpIdleTimeoutMs()).toBe(DEFAULT_HTTP_IDLE_TIMEOUT_MS); + }); + + it("should use merged global and project settings", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 300000 })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 0 })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getHttpIdleTimeoutMs()).toBe(0); + }); + + it("should reject invalid timeout values", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: -1 })); + const manager = SettingsManager.create(projectDir, agentDir); + + expect(() => manager.getHttpIdleTimeoutMs()).toThrow("Invalid httpIdleTimeoutMs setting"); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json"); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 84803e6b..03980199 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,9 +1,9 @@ import { homedir } from "os"; import { join, resolve } from "path"; import { describe, expect, it } from "vitest"; -import type { ResourceDiagnostic } from "../src/core/diagnostics.js"; -import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.js"; -import { createSyntheticSourceInfo } from "../src/core/source-info.js"; +import type { ResourceDiagnostic } from "../src/core/diagnostics.ts"; +import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.ts"; +import { createSyntheticSourceInfo } from "../src/core/source-info.ts"; const fixturesDir = resolve(__dirname, "fixtures/skills"); const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision"); @@ -41,7 +41,7 @@ describe("skills", () => { expect(diagnostics).toHaveLength(0); }); - it("should warn when name doesn't match parent directory", () => { + it("should allow names that don't match parent directory", () => { const { skills, diagnostics } = loadSkillsFromDir({ dir: join(fixturesDir, "name-mismatch"), source: "test", @@ -51,7 +51,7 @@ describe("skills", () => { expect(skills[0].name).toBe("different-name"); expect( diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not match parent directory")), - ).toBe(true); + ).toBe(false); }); it("should warn when name contains invalid characters", () => { diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index f9455f89..f6c426a1 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -3,10 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { ENV_AGENT_DIR } from "../src/config.js"; +import { ENV_AGENT_DIR } from "../src/config.ts"; const cliPath = resolve(__dirname, "../src/cli.ts"); -const tsxPath = resolve(__dirname, "../../../node_modules/tsx/dist/cli.mjs"); const tempDirs: string[] = []; @@ -55,7 +54,7 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; ); return await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [tsxPath, cliPath, ...args], { + const child = spawn(process.execPath, [cliPath, ...args], { cwd: projectDir, env: { ...process.env, diff --git a/packages/coding-agent/test/streaming-render-debug.ts b/packages/coding-agent/test/streaming-render-debug.ts index ffd4cc89..315c664d 100644 --- a/packages/coding-agent/test/streaming-render-debug.ts +++ b/packages/coding-agent/test/streaming-render-debug.ts @@ -9,8 +9,8 @@ import { ProcessTerminal, TUI } from "@earendil-works/pi-tui"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts index a597903c..efd51cfc 100644 --- a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts +++ b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts @@ -3,8 +3,8 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { BashOperations } from "../../src/core/tools/bash.js"; -import { createHarness, type Harness } from "./harness.js"; +import type { BashOperations } from "../../src/core/tools/bash.ts"; +import { createHarness, type Harness } from "./harness.ts"; function getEntryTypes(harness: Harness): string[] { return harness.sessionManager.getEntries().map((entry) => entry.type); @@ -85,8 +85,8 @@ describe("AgentSession bash and persistence characterization", () => { releaseToolExecution?.(); await firstPrompt; - expect(harness.session.hasPendingBashMessages).toBe(true); - expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false); + expect(harness.session.hasPendingBashMessages).toBe(false); + expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(true); await harness.session.prompt("next turn"); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index cb9f1808..e04732d0 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,10 +1,15 @@ -import { type AssistantMessage, fauxAssistantMessage, type Model } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + createAssistantMessageEventStream, + fauxAssistantMessage, + type Model, +} from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createHarness, type Harness } from "./harness.js"; +import { createHarness, type Harness } from "./harness.ts"; type SessionWithCompactionInternals = { - _checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise; - _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; + _checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise; + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; }; function createUsage(totalTokens: number) { @@ -41,6 +46,43 @@ function createAssistant( }; } +function useSummaryStreamFn(harness: Harness, summary: string): () => number { + let callCount = 0; + harness.session.agent.streamFn = (model) => { + callCount++; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message: AssistantMessage = { + ...fauxAssistantMessage(summary), + api: model.api, + provider: model.provider, + model: model.id, + usage: createUsage(10), + }; + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + return () => callCount; +} + +function seedCompactableSession(harness: Harness): void { + const now = Date.now(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + harness.sessionManager.appendMessage( + createAssistant(harness, { + stopReason: "stop", + totalTokens: 100, + timestamp: now - 500, + }), + ); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + describe("AgentSession compaction characterization", () => { const harnesses: Harness[] = []; @@ -95,6 +137,32 @@ describe("AgentSession compaction characterization", () => { await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`); }); + it("manually compacts with a custom streamFn when registry auth is absent", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + seedCompactableSession(harness); + const getStreamCallCount = useSummaryStreamFn(harness, "summary from custom stream"); + + const result = await harness.session.compact(); + + expect(result.summary).toBe("summary from custom stream"); + expect(getStreamCallCount()).toBe(1); + }); + + it("auto-compacts with a custom streamFn when registry auth is absent", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + seedCompactableSession(harness); + const getStreamCallCount = useSummaryStreamFn(harness, "auto summary from custom stream"); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await sessionInternals._runAutoCompaction("threshold", false); + + const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + expect(compactionEntries).toHaveLength(1); + expect(getStreamCallCount()).toBe(1); + }); + it("cancels in-progress manual compaction when abortCompaction is called", async () => { const harness = await createHarness({ extensionFactories: [ @@ -149,13 +217,9 @@ describe("AgentSession compaction characterization", () => { timestamp: Date.now(), }); - const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; - await sessionInternals._runAutoCompaction("threshold", false); - await vi.advanceTimersByTimeAsync(100); - - expect(continueSpy).toHaveBeenCalledTimes(1); + await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(true); }); it("does not retry overflow recovery more than once", async () => { @@ -167,7 +231,7 @@ describe("AgentSession compaction characterization", () => { errorMessage: "prompt is too long", timestamp: Date.now(), }); - const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); const compactionErrors: string[] = []; harness.session.subscribe((event) => { if (event.type === "compaction_end" && event.errorMessage) { @@ -215,7 +279,7 @@ describe("AgentSession compaction characterization", () => { timestamp: Date.now(), }); - const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); await sessionInternals._checkCompaction(staleAssistant, false); @@ -243,7 +307,7 @@ describe("AgentSession compaction characterization", () => { errorAssistant, ]; - const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); await sessionInternals._checkCompaction(errorAssistant); @@ -264,7 +328,7 @@ describe("AgentSession compaction characterization", () => { errorAssistant, ]; - const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); await sessionInternals._checkCompaction(errorAssistant); @@ -309,7 +373,7 @@ describe("AgentSession compaction characterization", () => { errorAssistant, ]; - const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); await sessionInternals._checkCompaction(errorAssistant); @@ -327,8 +391,8 @@ describe("AgentSession compaction characterization", () => { const belowThresholdInternals = belowThresholdHarness.session as unknown as SessionWithCompactionInternals; const disabledInternals = disabledHarness.session as unknown as SessionWithCompactionInternals; - const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue(); - const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue(); + const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue(false); + const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue(false); await belowThresholdInternals._checkCompaction( createAssistant(belowThresholdHarness, { stopReason: "stop", totalTokens: 1_000, timestamp: Date.now() }), diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index 55271147..f6469884 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -2,8 +2,8 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { ExtensionAPI } from "../../src/index.js"; -import { createHarness, getAssistantTexts, type Harness } from "./harness.js"; +import type { ExtensionAPI } from "../../src/index.ts"; +import { createHarness, getAssistantTexts, type Harness } from "./harness.ts"; describe("AgentSession model and extension characterization", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index e40a4fe3..dde25f61 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,10 +5,10 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { PromptTemplate } from "../../src/core/prompt-templates.js"; -import { createSyntheticSourceInfo } from "../../src/core/source-info.js"; -import { createTestResourceLoader } from "../utilities.js"; -import { createHarness, getMessageText, type Harness } from "./harness.js"; +import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; +import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; +import { createTestResourceLoader } from "../utilities.ts"; +import { createHarness, getMessageText, type Harness } from "./harness.ts"; describe("AgentSession prompt characterization", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 06aebcf8..3db7c14b 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -3,7 +3,7 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js"; +import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.ts"; async function createWaitingHarness( options: { diff --git a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts index d10fe90c..946f4da9 100644 --- a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts +++ b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts @@ -2,7 +2,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, type Harness } from "./harness.js"; +import { createHarness, type Harness } from "./harness.ts"; function normalizeEventOrder(events: Harness["events"]): string[] { const normalized: string[] = []; @@ -47,6 +47,7 @@ describe("AgentSession retry and event characterization", () => { await harness.session.prompt("test"); expect(retryEvents).toEqual(["start:1", "end:true"]); + expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, false]); expect(harness.faux.state.callCount).toBe(2); expect(harness.session.isRetrying).toBe(false); }); @@ -90,6 +91,7 @@ describe("AgentSession retry and event characterization", () => { await harness.session.prompt("test"); expect(retryEvents).toEqual(["start:1", "start:2", "end:false"]); + expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, true, false]); expect(harness.faux.state.callCount).toBe(3); expect(harness.session.isRetrying).toBe(false); }); diff --git a/packages/coding-agent/test/suite/agent-session-runtime.test.ts b/packages/coding-agent/test/suite/agent-session-runtime.test.ts index fb8b2167..69c817ac 100644 --- a/packages/coding-agent/test/suite/agent-session-runtime.test.ts +++ b/packages/coding-agent/test/suite/agent-session-runtime.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse } from "node:path"; import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -8,9 +8,9 @@ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, -} from "../../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../../src/core/auth-storage.js"; -import { SessionManager } from "../../src/core/session-manager.js"; +} from "../../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../../src/core/auth-storage.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; import type { ExtensionAPI, ExtensionFactory, @@ -18,7 +18,7 @@ import type { SessionBeforeSwitchEvent, SessionShutdownEvent, SessionStartEvent, -} from "../../src/index.js"; +} from "../../src/index.ts"; type RecordedSessionEvent = | SessionBeforeSwitchEvent @@ -274,6 +274,8 @@ describe("AgentSessionRuntime characterization", () => { { type: "session_shutdown", reason: "fork", targetSessionFile: runtime.session.sessionFile }, { type: "session_start", reason: "fork", previousSessionFile }, ]); + const sessionFileName = parse(runtime.session.sessionFile!).name; + expect(sessionFileName.endsWith(`_${runtime.session.sessionId}`)).toBe(true); events.length = 0; cancelNextFork = true; diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 16a8182e..090d5683 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -9,20 +9,20 @@ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core"; import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@earendil-works/pi-ai"; import { registerFauxProvider } from "@earendil-works/pi-ai"; -import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js"; -import { AuthStorage } from "../../src/core/auth-storage.js"; -import type { ExtensionRunner } from "../../src/core/extensions/index.js"; -import { convertToLlm } from "../../src/core/messages.js"; -import { ModelRegistry } from "../../src/core/model-registry.js"; -import { SessionManager } from "../../src/core/session-manager.js"; -import type { Settings } from "../../src/core/settings-manager.js"; -import { SettingsManager } from "../../src/core/settings-manager.js"; -import type { ExtensionFactory, ResourceLoader } from "../../src/index.js"; +import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.ts"; +import { AuthStorage } from "../../src/core/auth-storage.ts"; +import type { ExtensionRunner } from "../../src/core/extensions/index.ts"; +import { convertToLlm } from "../../src/core/messages.ts"; +import { ModelRegistry } from "../../src/core/model-registry.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import type { Settings } from "../../src/core/settings-manager.ts"; +import { SettingsManager } from "../../src/core/settings-manager.ts"; +import type { ExtensionFactory, ResourceLoader } from "../../src/index.ts"; import { type CreateTestExtensionsResultInput, createTestExtensionsResult, createTestResourceLoader, -} from "../utilities.js"; +} from "../utilities.ts"; type MessageTextPart = { type: "text"; text: string }; diff --git a/packages/coding-agent/test/suite/regressions/1717-2113-agent-session-event-settlement.test.ts b/packages/coding-agent/test/suite/regressions/1717-2113-agent-session-event-settlement.test.ts new file mode 100644 index 00000000..e8658b1b --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1717-2113-agent-session-event-settlement.test.ts @@ -0,0 +1,95 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +function createEchoTool(): AgentTool { + return { + name: "echo", + label: "Echo", + description: "Echo text back", + parameters: Type.Object({ text: Type.String() }), + execute: async (_toolCallId, params) => { + const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : ""; + return { content: [{ type: "text", text }], details: { text } }; + }, + }; +} + +describe("regressions #1717/#2113: agent session event settlement", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("keeps persisted assistant/toolResult message order when extension message_end handlers yield", async () => { + const harness = await createHarness({ + tools: [createEchoTool()], + extensionFactories: [ + (pi) => { + pi.on("message_end", async (event) => { + if (event.message.role === "assistant") { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("echo", { text: "one" }), fauxToolCall("echo", { text: "two" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("done"), + ]); + await harness.session.prompt("run tools"); + + const branchMessages = harness.sessionManager + .getBranch() + .filter((entry) => entry.type === "message") + .map((entry) => entry.message); + expect(branchMessages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + "toolResult", + "assistant", + ]); + const firstToolResultIndex = branchMessages.findIndex((message) => message.role === "toolResult"); + expect(firstToolResultIndex).toBeGreaterThan(0); + expect(branchMessages[firstToolResultIndex - 1]?.role).toBe("assistant"); + }); + + it("runs tool_call handlers after the assistant tool-use message is settled in the session", async () => { + let harness: Harness; + const branchRolesAtToolCall: string[][] = []; + harness = await createHarness({ + tools: [createEchoTool()], + extensionFactories: [ + (pi) => { + pi.on("tool_call", () => { + branchRolesAtToolCall.push( + harness.sessionManager + .getBranch() + .filter((entry) => entry.type === "message") + .map((entry) => entry.message.role), + ); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("run tool"); + + expect(branchRolesAtToolCall).toEqual([["user", "assistant"]]); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/2023-queued-slash-command-followup.test.ts b/packages/coding-agent/test/suite/regressions/2023-queued-slash-command-followup.test.ts index d4adadfa..cf3f0752 100644 --- a/packages/coding-agent/test/suite/regressions/2023-queued-slash-command-followup.test.ts +++ b/packages/coding-agent/test/suite/regressions/2023-queued-slash-command-followup.test.ts @@ -3,7 +3,7 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.js"; +import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.ts"; describe("issue #2023 queued slash-command follow-up", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts b/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts index a95dc7d0..17d9b302 100644 --- a/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts +++ b/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts @@ -8,9 +8,9 @@ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, -} from "../../../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../../../src/core/auth-storage.js"; -import { SessionManager } from "../../../src/core/session-manager.js"; +} from "../../../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; describe("issue #2753 reload stale resource settings", () => { const cleanups: Array<() => void> = []; diff --git a/packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts b/packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts index e7e17f58..cf36e771 100644 --- a/packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts +++ b/packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultResourceLoader } from "../../../src/core/resource-loader.js"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; describe("issue #2781 skill collision precedence: user skills should override package skills", () => { let tempDir: string; diff --git a/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts b/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts index 007ba580..ed450c5a 100644 --- a/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts +++ b/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts @@ -39,7 +39,7 @@ describe("issue #2791 fs.watch error event crashes process", () => { }); it("process should survive an error event on the theme FSWatcher", () => { - const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.js").replace(/\\/g, "/"); + const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.ts").replace(/\\/g, "/"); const agentDir = join(tempRoot, "agent").replace(/\\/g, "/"); // Script that sets up the watcher and emits a synthetic error on it. @@ -87,7 +87,7 @@ process.exit(0); let stderr = ""; let exitCode: number; try { - _stdout = execFileSync("npx", ["tsx", scriptPath], { + _stdout = execFileSync(process.execPath, [scriptPath], { timeout: 10000, encoding: "utf-8", env: { ...process.env, PI_CODING_AGENT_DIR: agentDir }, diff --git a/packages/coding-agent/test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts index 7d69c244..e0003aa6 100644 --- a/packages/coding-agent/test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultResourceLoader } from "../../../src/core/resource-loader.js"; -import { createAgentSession } from "../../../src/core/sdk.js"; -import { SessionManager } from "../../../src/core/session-manager.js"; -import { SettingsManager } from "../../../src/core/settings-manager.js"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; +import { createAgentSession } from "../../../src/core/sdk.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; describe("regression #2835: tool allowlists filter extension tools", () => { let tempDir: string; diff --git a/packages/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts b/packages/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts index 21539919..0e4c061e 100644 --- a/packages/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts +++ b/packages/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts @@ -3,16 +3,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import type { AgentSession } from "../../../src/core/agent-session.js"; +import type { AgentSession } from "../../../src/core/agent-session.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, -} from "../../../src/core/agent-session-runtime.js"; -import { AuthStorage } from "../../../src/core/auth-storage.js"; -import { SessionManager } from "../../../src/core/session-manager.js"; -import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "../../../src/index.js"; +} from "../../../src/core/agent-session-runtime.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "../../../src/index.ts"; function getText(message: AgentSession["messages"][number]): string { if (!("content" in message)) { diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index 72df1d95..b32535ba 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -1,11 +1,11 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { KeybindingsManager } from "../../../src/core/keybindings.js"; -import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.js"; -import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.js"; -import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; -import { createHarness, type Harness } from "../harness.js"; +import { KeybindingsManager } from "../../../src/core/keybindings.ts"; +import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; +import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; +import { createHarness, type Harness } from "../harness.ts"; function createFakeTui(): TUI { return { diff --git a/packages/coding-agent/test/suite/regressions/3302-find-path-glob.test.ts b/packages/coding-agent/test/suite/regressions/3302-find-path-glob.test.ts index 05cf8008..825b4a05 100644 --- a/packages/coding-agent/test/suite/regressions/3302-find-path-glob.test.ts +++ b/packages/coding-agent/test/suite/regressions/3302-find-path-glob.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createFindToolDefinition } from "../../../src/core/tools/find.js"; +import { createFindToolDefinition } from "../../../src/core/tools/find.ts"; /** * Regression test for https://github.com/earendil-works/pi-mono/issues/3302 diff --git a/packages/coding-agent/test/suite/regressions/3303-find-nested-gitignore.test.ts b/packages/coding-agent/test/suite/regressions/3303-find-nested-gitignore.test.ts index d22b0c18..bd986cc9 100644 --- a/packages/coding-agent/test/suite/regressions/3303-find-nested-gitignore.test.ts +++ b/packages/coding-agent/test/suite/regressions/3303-find-nested-gitignore.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createFindToolDefinition } from "../../../src/core/tools/find.js"; +import { createFindToolDefinition } from "../../../src/core/tools/find.ts"; /** * Regression test for https://github.com/earendil-works/pi-mono/issues/3303 diff --git a/packages/coding-agent/test/suite/regressions/3317-network-connection-lost-retry.test.ts b/packages/coding-agent/test/suite/regressions/3317-network-connection-lost-retry.test.ts index 74276253..1be6154e 100644 --- a/packages/coding-agent/test/suite/regressions/3317-network-connection-lost-retry.test.ts +++ b/packages/coding-agent/test/suite/regressions/3317-network-connection-lost-retry.test.ts @@ -1,6 +1,6 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, getAssistantTexts, type Harness } from "../harness.js"; +import { createHarness, getAssistantTexts, type Harness } from "../harness.ts"; describe("issue #3317 network connection lost retry", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 27b186f7..d7d1376c 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -7,11 +7,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAgentSessionFromServices, createAgentSessionServices, -} from "../../../src/core/agent-session-services.js"; -import { DefaultResourceLoader } from "../../../src/core/resource-loader.js"; -import { createAgentSession } from "../../../src/core/sdk.js"; -import { SessionManager } from "../../../src/core/session-manager.js"; -import { SettingsManager } from "../../../src/core/settings-manager.js"; +} from "../../../src/core/agent-session-services.ts"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; +import { createAgentSession } from "../../../src/core/sdk.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; describe("regression #3592: no-builtin-tools keeps extension tools enabled", () => { let tempDir: string; diff --git a/packages/coding-agent/test/suite/regressions/3616-settings-inmemory-reload.test.ts b/packages/coding-agent/test/suite/regressions/3616-settings-inmemory-reload.test.ts index daa2c030..5f92baeb 100644 --- a/packages/coding-agent/test/suite/regressions/3616-settings-inmemory-reload.test.ts +++ b/packages/coding-agent/test/suite/regressions/3616-settings-inmemory-reload.test.ts @@ -2,8 +2,8 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DefaultResourceLoader } from "../../../src/core/resource-loader.js"; -import { SettingsManager } from "../../../src/core/settings-manager.js"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; describe("regression #3616: in-memory settings survive reload", () => { let tempDir: string; diff --git a/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts b/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts index 3614c09c..6265ce96 100644 --- a/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts +++ b/packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import type { ExtensionAPI } from "../../../src/index.js"; -import { createHarness, type Harness } from "../harness.js"; +import type { ExtensionAPI } from "../../../src/index.ts"; +import { createHarness, type Harness } from "../harness.ts"; describe("regression #3686: session name changes emit an event", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/regressions/3688-tree-cancel-compacting.test.ts b/packages/coding-agent/test/suite/regressions/3688-tree-cancel-compacting.test.ts index 67262e2c..7d0f172e 100644 --- a/packages/coding-agent/test/suite/regressions/3688-tree-cancel-compacting.test.ts +++ b/packages/coding-agent/test/suite/regressions/3688-tree-cancel-compacting.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { assistantMsg, userMsg } from "../../utilities.js"; -import { createHarness, type Harness } from "../harness.js"; +import { assistantMsg, userMsg } from "../../utilities.ts"; +import { createHarness, type Harness } from "../harness.ts"; describe("issue #3688 tree cancellation compaction state", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/regressions/3982-message-end-cost-override.test.ts b/packages/coding-agent/test/suite/regressions/3982-message-end-cost-override.test.ts index 99a47b33..757a08ed 100644 --- a/packages/coding-agent/test/suite/regressions/3982-message-end-cost-override.test.ts +++ b/packages/coding-agent/test/suite/regressions/3982-message-end-cost-override.test.ts @@ -1,6 +1,6 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, type Harness } from "../harness.js"; +import { createHarness, type Harness } from "../harness.ts"; describe("regression #3982: message_end cost override", () => { const harnesses: Harness[] = []; diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts index 14f72f7b..307c71de 100644 --- a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -1,13 +1,13 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; import { Container, Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test, vi } from "vitest"; -import type { AgentSessionEvent } from "../../../src/core/agent-session.js"; -import type { SessionContext } from "../../../src/core/session-manager.js"; -import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js"; -import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js"; -import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; +import type { AgentSessionEvent } from "../../../src/core/agent-session.ts"; +import type { SessionContext } from "../../../src/core/session-manager.ts"; +import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; const TOOL_CALL_ID = "tool-4167"; const TOOL_NAME = "slow_tool"; diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts new file mode 100644 index 00000000..92d8aa39 --- /dev/null +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; + +describe("syntax highlight renderer", () => { + it("renders highlighted spans with the provided theme", () => { + const rendered = renderHighlightedHtml('const value', { + keyword: (text) => `[keyword:${text}]`, + }); + expect(rendered).toBe("[keyword:const] value"); + }); + + it("decodes HTML entities emitted by highlight.js", () => { + const rendered = renderHighlightedHtml("<tag attr="value">&#x41;A</tag>"); + expect(rendered).toBe('AA'); + }); + + it("inherits parent formatting for unmapped nested scopes", () => { + const interpolation = "$" + "{x}"; + const rendered = renderHighlightedHtml( + `a${interpolation}b`, + { + string: (text) => `[string:${text}]`, + }, + ); + expect(rendered).toBe(`[string:a][string:${interpolation}][string:b]`); + }); + + it("keeps parent formatting across unscoped nested spans", () => { + const rendered = renderHighlightedHtml('abc', { + string: (text) => `[string:${text}]`, + }); + expect(rendered).toBe("[string:a][string:b][string:c]"); + }); + + it("highlights code through highlight.js", () => { + expect(supportsLanguage("typescript")).toBe(true); + const rendered = highlight("const value = 1", { + language: "typescript", + ignoreIllegals: true, + theme: { + keyword: (text) => `[keyword:${text}]`, + number: (text) => `[number:${text}]`, + }, + }); + expect(rendered).toContain("[keyword:const]"); + expect(rendered).toContain("[number:1]"); + }); +}); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 31a5d6b4..82def227 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { buildSystemPrompt } from "../src/core/system-prompt.js"; +import { buildSystemPrompt } from "../src/core/system-prompt.ts"; describe("buildSystemPrompt", () => { describe("empty tools", () => { @@ -45,6 +45,18 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("- edit:"); expect(prompt).toContain("- write:"); }); + + test("instructs models to resolve pi docs and examples under absolute base paths", () => { + const prompt = buildSystemPrompt({ + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).toContain( + "- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory", + ); + }); }); describe("custom tool snippets", () => { diff --git a/packages/coding-agent/test/test-harness.test.ts b/packages/coding-agent/test/test-harness.test.ts index f9cafa51..2d6e7866 100644 --- a/packages/coding-agent/test/test-harness.test.ts +++ b/packages/coding-agent/test/test-harness.test.ts @@ -7,7 +7,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import { createHarness, createHarnessWithExtensions, type Harness } from "./test-harness.js"; +import { createHarness, createHarnessWithExtensions, type Harness } from "./test-harness.ts"; describe("test harness", () => { let harness: Harness; diff --git a/packages/coding-agent/test/test-harness.ts b/packages/coding-agent/test/test-harness.ts index 269c5146..0ae601ef 100644 --- a/packages/coding-agent/test/test-harness.ts +++ b/packages/coding-agent/test/test-harness.ts @@ -26,18 +26,18 @@ import type { Usage, } from "@earendil-works/pi-ai"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; -import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import type { Settings } from "../src/core/settings-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import type { ExtensionFactory, ResourceLoader } from "../src/index.js"; +import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import type { Settings } from "../src/core/settings-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import type { ExtensionFactory, ResourceLoader } from "../src/index.ts"; import { type CreateTestExtensionsResultInput, createTestExtensionsResult, createTestResourceLoader, -} from "./utilities.js"; +} from "./utilities.ts"; // ============================================================================ // Faux model diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts index a3317026..70da7b18 100644 --- a/packages/coding-agent/test/test-theme-colors.ts +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -1,5 +1,5 @@ import fs from "fs"; -import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; +import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; // --- Color utilities --- diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts new file mode 100644 index 00000000..7bb280b2 --- /dev/null +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -0,0 +1,71 @@ +import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it } from "vitest"; +import { + detectTerminalBackground, + getThemeByName, + getThemeForRgbColor, + parseOsc11BackgroundColor, +} from "../src/modes/interactive/theme/theme.ts"; + +afterEach(() => { + resetCapabilitiesCache(); +}); + +describe("detectTerminalBackground", () => { + it("uses the COLORFGBG background color index", () => { + expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({ + theme: "light", + source: "COLORFGBG", + confidence: "high", + }); + expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({ + theme: "dark", + source: "COLORFGBG", + confidence: "high", + }); + }); + + it("uses the last COLORFGBG field as the background", () => { + expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); + }); + + it("defaults to dark without terminal background hints", () => { + expect(detectTerminalBackground({ env: {} })).toMatchObject({ + theme: "dark", + source: "fallback", + confidence: "low", + }); + }); +}); + +describe("theme color mode", () => { + it("uses terminal capabilities", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const ansi256Theme = getThemeByName("dark"); + if (!ansi256Theme) throw new Error("dark theme not found"); + expect(ansi256Theme.getColorMode()).toBe("256color"); + expect(ansi256Theme.getFgAnsi("accent")).toMatch(/^\x1b\[38;5;\d+m$/); + + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const truecolorTheme = getThemeByName("dark"); + if (!truecolorTheme) throw new Error("dark theme not found"); + expect(truecolorTheme.getColorMode()).toBe("truecolor"); + expect(truecolorTheme.getFgAnsi("accent")).toMatch(/^\x1b\[38;2;\d+;\d+;\d+m$/); + }); +}); + +describe("parseOsc11BackgroundColor", () => { + it("parses 16-bit OSC 11 rgb responses", () => { + expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 }); + }); + + it("parses OSC 11 hex responses", () => { + expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 }); + expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("classifies RGB colors by luminance", () => { + expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark"); + expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light"); + }); +}); diff --git a/packages/coding-agent/test/theme-export.test.ts b/packages/coding-agent/test/theme-export.test.ts index 84dd649a..155a07a4 100644 --- a/packages/coding-agent/test/theme-export.test.ts +++ b/packages/coding-agent/test/theme-export.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getThemeExportColors } from "../src/modes/interactive/theme/theme.js"; +import { getThemeExportColors } from "../src/modes/interactive/theme/theme.ts"; type ThemeFile = { name: string; diff --git a/packages/coding-agent/test/theme-picker.test.ts b/packages/coding-agent/test/theme-picker.test.ts new file mode 100644 index 00000000..7b31854e --- /dev/null +++ b/packages/coding-agent/test/theme-picker.test.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getAvailableThemes, + getAvailableThemesWithPaths, + setRegisteredThemes, +} from "../src/modes/interactive/theme/theme.ts"; + +type ThemeFile = { + name: string; + vars?: Record; + colors: Record; +}; + +describe("theme picker", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "pi-theme-picker-")); + const agentDir = join(tempRoot, "agent"); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + mkdirSync(join(agentDir, "themes"), { recursive: true }); + setRegisteredThemes([]); + }); + + afterEach(() => { + setRegisteredThemes([]); + rmSync(tempRoot, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it("uses custom theme content names instead of file names", () => { + const darkTheme = JSON.parse( + readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"), + ) as ThemeFile; + const customTheme: ThemeFile = { + ...darkTheme, + name: "bar", + }; + + const themePath = join(process.env.PI_CODING_AGENT_DIR!, "themes", "foo.json"); + writeFileSync(themePath, JSON.stringify(customTheme, null, 2)); + + expect(getAvailableThemes()).toContain("bar"); + expect(getAvailableThemes()).not.toContain("foo"); + expect(getAvailableThemesWithPaths()).toContainEqual({ name: "bar", path: themePath }); + expect(getAvailableThemesWithPaths().some((theme) => theme.name === "foo")).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 1e5d4b00..9a3d1639 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,15 +1,15 @@ import { join, resolve } from "node:path"; import { Text, type TUI } from "@earendil-works/pi-tui"; -import stripAnsi from "strip-ansi"; import { Type } from "typebox"; import { beforeAll, describe, expect, test } from "vitest"; -import { getReadmePath } from "../src/config.js"; -import type { ToolDefinition } from "../src/core/extensions/types.js"; -import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js"; -import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.js"; -import { createWriteToolDefinition } from "../src/core/tools/write.js"; -import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { getReadmePath } from "../src/config.ts"; +import type { ToolDefinition } from "../src/core/extensions/types.ts"; +import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts"; +import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.ts"; +import { createWriteToolDefinition } from "../src/core/tools/write.ts"; +import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { return { @@ -123,6 +123,43 @@ describe("ToolExecutionComponent parity", () => { await promise; }); + test("bash renderer does not duplicate final full output truncation details", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 1; i <= 4000; i++) { + onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`)); + } + return { exitCode: 0 }; + }, + }; + const tool = createBashToolDefinition(process.cwd(), { operations }); + const result = await tool.execute( + "tool-bash-1b", + { command: "generate output" }, + undefined, + undefined, + {} as never, + ); + const component = new ToolExecutionComponent( + "bash", + "tool-bash-1b", + { command: "generate output" }, + {}, + tool, + createFakeTui(), + process.cwd(), + ); + component.setExpanded(true); + component.updateResult({ ...result, isError: false }, false); + + const rendered = stripAnsi(component.render(200).join("\n")); + expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1); + expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/); + expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/); + expect(rendered).toContain("Truncated: showing 2000 of 4000 lines"); + expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:"); + }); + test("does not duplicate built-in headers when passed the active built-in definition", () => { const component = new ToolExecutionComponent( "read", diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 70e4e54f..c5e8fa6f 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,10 +1,11 @@ +import { applyPatch } from "diff"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { executeBashWithOperations } from "../src/core/bash-executor.js"; -import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js"; -import { computeEditsDiff } from "../src/core/tools/edit-diff.js"; +import { executeBashWithOperations } from "../src/core/bash-executor.ts"; +import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.ts"; +import { computeEditsDiff } from "../src/core/tools/edit-diff.ts"; import { createEditTool, createFindTool, @@ -12,8 +13,8 @@ import { createLsTool, createReadTool, createWriteTool, -} from "../src/index.js"; -import * as shellModule from "../src/utils/shell.js"; +} from "../src/index.ts"; +import * as shellModule from "../src/utils/shell.ts"; const readTool = createReadTool(process.cwd()); const writeTool = createWriteTool(process.cwd()); @@ -239,6 +240,12 @@ describe("Coding Agent Tools", () => { expect(result.details.diff).toBeDefined(); expect(typeof result.details.diff).toBe("string"); expect(result.details.diff).toContain("testing"); + expect(result.details.patch).toContain("--- "); + expect(result.details.patch).toContain("+++ "); + expect(result.details.patch).toContain("@@"); + expect(result.details.patch).toContain("-Hello, world!"); + expect(result.details.patch).toContain("+Hello, testing!"); + expect(applyPatch(originalContent, result.details.patch)).toBe("Hello, testing!"); }); it("should fail if text not found", async () => { @@ -573,6 +580,28 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(result)).toContain("line 4999"); }); + it("should not count a trailing newline as an extra truncated bash output line", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 1; i <= 4000; i++) { + onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8")); + } + return { exitCode: 0 }; + }, + }; + const bash = createBashTool(testDir, { operations }); + + const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" }); + const output = getTextOutput(result); + + expect(result.details?.truncation?.totalLines).toBe(4000); + expect(result.details?.truncation?.outputLines).toBe(2000); + expect(output).toContain("line-2001"); + expect(output).toContain("line-4000"); + expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /); + expect(output).not.toContain("4001"); + }); + it("should decode UTF-8 characters split across output chunks", async () => { const euro = Buffer.from("€\n", "utf-8"); const operations: BashOperations = { diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 3b29d7e0..8f423dc5 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -1,14 +1,14 @@ import { setKeybindings } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; -import { KeybindingsManager } from "../src/core/keybindings.js"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { ModelChangeEntry, SessionEntry, SessionMessageEntry, SessionTreeNode, -} from "../src/core/session-manager.js"; -import { TreeSelectorComponent } from "../src/modes/interactive/components/tree-selector.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +} from "../src/core/session-manager.ts"; +import { TreeSelectorComponent } from "../src/modes/interactive/components/tree-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; beforeAll(() => { initTheme("dark"); diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index 92d2ce95..a8bdbb2a 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import triggerCompactExtension from "../examples/extensions/trigger-compact.js"; -import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../src/core/extensions/index.js"; +import triggerCompactExtension from "../examples/extensions/trigger-compact.ts"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../src/core/extensions/index.ts"; function createContext(tokens: number | null, compact = vi.fn()): ExtensionContext { return { diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index 7c756810..1e62e5a8 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { UserMessageComponent } from "../src/modes/interactive/components/user-message.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { UserMessageComponent } from "../src/modes/interactive/components/user-message.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 058ce889..5ebf6813 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -8,16 +8,16 @@ import { dirname, join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import { getModel, type OAuthCredentials, type OAuthProvider } from "@earendil-works/pi-ai"; import { getOAuthApiKey } from "@earendil-works/pi-ai/oauth"; -import { AgentSession } from "../src/core/agent-session.js"; -import { AuthStorage } from "../src/core/auth-storage.js"; -import { createEventBus } from "../src/core/event-bus.js"; -import type { Extension, ExtensionFactory, LoadExtensionsResult } from "../src/core/extensions/index.js"; -import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.js"; -import { ModelRegistry } from "../src/core/model-registry.js"; -import type { ResourceLoader } from "../src/core/resource-loader.js"; -import { SessionManager } from "../src/core/session-manager.js"; -import { SettingsManager } from "../src/core/settings-manager.js"; -import { createCodingTools } from "../src/index.js"; +import { AgentSession } from "../src/core/agent-session.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { createEventBus } from "../src/core/event-bus.ts"; +import type { Extension, ExtensionFactory, LoadExtensionsResult } from "../src/core/extensions/index.ts"; +import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import type { ResourceLoader } from "../src/core/resource-loader.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createCodingTools } from "../src/index.ts"; /** * API key for authenticated tests. Tests using this should be wrapped in diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index c871718f..70197fa5 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -5,7 +5,7 @@ import { getLatestPiRelease, getLatestPiVersion, isNewerPackageVersion, -} from "../src/utils/version-check.js"; +} from "../src/utils/version-check.ts"; const originalSkipVersionCheck = process.env.PI_SKIP_VERSION_CHECK; const originalOffline = process.env.PI_OFFLINE; @@ -38,7 +38,7 @@ describe("version checks", () => { vi.stubGlobal("fetch", fetchMock); await expect(checkForNewPiVersion("1.2.3")).resolves.toBeUndefined(); - await expect(checkForNewPiVersion("1.2.2")).resolves.toBe("1.2.3"); + await expect(checkForNewPiVersion("1.2.2")).resolves.toEqual({ version: "1.2.3" }); }); it("uses the pi.dev version check api with a pi user agent", async () => { @@ -57,11 +57,26 @@ describe("version checks", () => { ); }); - it("returns the active package name from the version check api", async () => { - const fetchMock = vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: "1.2.4" })); + it("returns the active package metadata from the version check api", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + packageName: "@new-scope/pi", + version: "1.2.4", + }), + ); vi.stubGlobal("fetch", fetchMock); - await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ packageName: "@new-scope/pi", version: "1.2.4" }); + await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ + packageName: "@new-scope/pi", + version: "1.2.4", + }); + }); + + it("returns update notes from the version check api", async () => { + const fetchMock = vi.fn(async () => Response.json({ note: " **Read this** ", version: "1.2.4" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ note: "**Read this**", version: "1.2.4" }); }); it("skips api calls when version checks are disabled", async () => { diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 695dd9ad..3437fd70 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -4,6 +4,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index feeb9c8e..b1f8a8dc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +### Changed + +- Replaced the optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)). + +## [0.75.4] - 2026-05-20 + +### Changed + +- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources. + +### Fixed + +- Fixed loader initialization so indicator startup cannot run before frames are initialized. +- Fixed truecolor capability detection to align terminal image rendering with the interactive theme detector. + +## [0.75.3] - 2026-05-18 + +## [0.75.2] - 2026-05-18 + +## [0.75.1] - 2026-05-18 + +## [0.75.0] - 2026-05-17 + +### Breaking Changes + +- Raised the minimum supported Node.js version to 22.19.0. + +## [0.74.1] - 2026-05-16 + +### Added + +- Added markdown list-item wrapping that preserves indentation for wrapped continuation lines ([#4327](https://github.com/earendil-works/pi-mono/pull/4327) by [@Perlence](https://github.com/Perlence)). + +### Fixed + +- Fixed markdown task-list checkbox rendering ([#4379](https://github.com/earendil-works/pi-mono/pull/4379) by [@Perlence](https://github.com/Perlence)). +- Fixed markdown rendering robustness for very large markdown files ([#4463](https://github.com/earendil-works/pi-mono/pull/4463) by [@ndanielherrera](https://github.com/ndanielherrera)). +- Fixed Kitty image placement when the viewport is shorter than the rendered image ([#4461](https://github.com/earendil-works/pi-mono/pull/4461) by [@xu0o0](https://github.com/xu0o0)). +- Fixed WezTerm Kitty keyboard protocol edge cases so escape handling remains correct ([#4482](https://github.com/earendil-works/pi-mono/pull/4482) by [@Felixoid](https://github.com/Felixoid)). +- Fixed inline image rendering to cap portrait images by height instead of always scaling them to the configured maximum width. + ## [0.74.0] - 2026-05-07 ## [0.73.1] - 2026-05-07 diff --git a/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node new file mode 100644 index 00000000..42b2c77c Binary files /dev/null and b/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node differ diff --git a/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node new file mode 100644 index 00000000..2c6d86d8 Binary files /dev/null and b/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node differ diff --git a/packages/tui/native/win32/src/win32-console-mode.c b/packages/tui/native/win32/src/win32-console-mode.c new file mode 100644 index 00000000..d68810c7 --- /dev/null +++ b/packages/tui/native/win32/src/win32-console-mode.c @@ -0,0 +1,53 @@ +#include + +#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT +#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200 +#endif + +#define NAPI_AUTO_LENGTH ((unsigned long long)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); +typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); +typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); + +static void* node_symbol(const char* name) { + HMODULE module = GetModuleHandleA(0); + void* proc = module ? (void*)GetProcAddress(module, name) : 0; + if (proc) return proc; + + module = GetModuleHandleA("node.dll"); + return module ? (void*)GetProcAddress(module, name) : 0; +} + +static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { + (void)info; + + HANDLE handle = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + int enabled = handle != INVALID_HANDLE_VALUE && + GetConsoleMode(handle, &mode) && + SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, enabled, &result); + return result; +} + +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { + napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + } + + return exports; +} diff --git a/packages/tui/package.json b/packages/tui/package.json index af49435e..c684eae7 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,18 +1,18 @@ { "name": "@earendil-works/pi-tui", - "version": "0.74.0", + "version": "0.75.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json", - "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", - "test": "node --test --import tsx test/*.test.ts", + "test": "node --test test/*.test.ts", "prepublishOnly": "npm run clean && npm run build" }, "files": [ "dist/**/*", + "native/win32/prebuilds/**/*.node", "README.md" ], "keywords": [ @@ -32,21 +32,16 @@ "directory": "packages/tui" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" }, "types": "./dist/index.d.ts", "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1" - }, - "optionalDependencies": { - "koffi": "^2.9.0" + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" }, "devDependencies": { - "@xterm/headless": "^5.5.0", - "@xterm/xterm": "^5.5.0" + "@xterm/headless": "5.5.0", + "@xterm/xterm": "5.5.0", + "chalk": "5.6.2" } } diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 965dc303..5408967d 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -2,7 +2,7 @@ import { spawn } from "child_process"; import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; -import { fuzzyFilter } from "./fuzzy.js"; +import { fuzzyFilter } from "./fuzzy.ts"; const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index c99b8600..3573ab09 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,5 +1,5 @@ -import type { Component } from "../tui.js"; -import { applyBackgroundToLine, visibleWidth } from "../utils.js"; +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; type RenderCache = { childLines: string[]; diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index e8bd85b9..7822cb04 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -1,5 +1,5 @@ -import { getKeybindings } from "../keybindings.js"; -import { Loader } from "./loader.js"; +import { getKeybindings } from "../keybindings.ts"; +import { Loader } from "./loader.ts"; /** * Loader that can be cancelled with Escape. diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 990469e5..0b30ad0a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,11 +1,11 @@ -import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.js"; -import { getKeybindings } from "../keybindings.js"; -import { decodePrintableKey, matchesKey } from "../keys.js"; -import { KillRing } from "../kill-ring.js"; -import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; -import { UndoStack } from "../undo-stack.js"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js"; -import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.js"; +import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts"; +import { getKeybindings } from "../keybindings.ts"; +import { decodePrintableKey, matchesKey } from "../keys.ts"; +import { KillRing } from "../kill-ring.ts"; +import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; const baseSegmenter = getSegmenter(); diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 74992b34..50c1e0f7 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -1,12 +1,13 @@ import { allocateImageId, getCapabilities, + getCellDimensions, getImageDimensions, type ImageDimensions, imageFallback, renderImage, -} from "../terminal-image.js"; -import type { Component } from "../tui.js"; +} from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; export interface ImageTheme { fallbackColor: (str: string) => string; @@ -61,7 +62,10 @@ export class Image implements Component { return this.cachedLines; } - const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60); + const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60)); + const cellDimensions = getCellDimensions(); + const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx)); + const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight; const caps = getCapabilities(); let lines: string[]; @@ -72,6 +76,7 @@ export class Image implements Component { } const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth, + maxHeightCells: maxHeight, imageId: this.imageId, moveCursor: false, }); @@ -82,19 +87,28 @@ export class Image implements Component { this.imageId = result.imageId; } - // Return `rows` lines so TUI accounts for image height. - // First (rows-1) lines are empty and cleared before the image is drawn. - // Last line: move cursor back up, draw the image, then move back down - // for Kitty (this component disables Kitty's terminal-side cursor movement) - // so TUI cursor accounting stays inside the scroll area. - lines = []; - for (let i = 0; i < result.rows - 1; i++) { - lines.push(""); + if (caps.images === "kitty") { + // For Kitty: C=1 prevents cursor movement. + // Don't need the cursor movement. + lines = [result.sequence]; + + // Return `rows` lines so TUI accounts for image height. + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + } else { + // Return `rows` lines so TUI accounts for image height. + // First (rows-1) lines are empty and cleared before the image is drawn. + // Last line: move cursor back up, draw the image, then move back down + // so TUI cursor accounting stays inside the scroll area. + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + const rowOffset = result.rows - 1; + const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; + lines.push(moveUp + result.sequence); } - const rowOffset = result.rows - 1; - const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; - const moveDown = caps.images === "kitty" && rowOffset > 0 ? `\x1b[${rowOffset}B` : ""; - lines.push(moveUp + result.sequence + moveDown); } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); lines = [this.theme.fallbackColor(fallback)]; diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 9c97af95..71f2363b 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,9 +1,9 @@ -import { getKeybindings } from "../keybindings.js"; -import { decodeKittyPrintable } from "../keys.js"; -import { KillRing } from "../kill-ring.js"; -import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; -import { UndoStack } from "../undo-stack.js"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js"; +import { getKeybindings } from "../keybindings.ts"; +import { decodeKittyPrintable } from "../keys.ts"; +import { KillRing } from "../kill-ring.ts"; +import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; const segmenter = getSegmenter(); diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 6bac6ce8..4a93eb08 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -1,5 +1,5 @@ -import type { TUI } from "../tui.js"; -import { Text } from "./text.js"; +import type { TUI } from "../tui.ts"; +import { Text } from "./text.ts"; export interface LoaderIndicatorOptions { /** Animation frames. Use an empty array to hide the indicator. */ @@ -21,16 +21,22 @@ export class Loader extends Text { private intervalId: NodeJS.Timeout | null = null; private ui: TUI | null = null; private renderIndicatorVerbatim = false; + private spinnerColorFn: (str: string) => string; + private messageColorFn: (str: string) => string; + private message: string = "Loading..."; constructor( ui: TUI, - private spinnerColorFn: (str: string) => string, - private messageColorFn: (str: string) => string, - private message: string = "Loading...", + spinnerColorFn: (str: string) => string, + messageColorFn: (str: string) => string, + message: string = "Loading...", indicator?: LoaderIndicatorOptions, ) { super("", 1, 0); this.ui = ui; + this.spinnerColorFn = spinnerColorFn; + this.messageColorFn = messageColorFn; + this.message = message; this.setIndicator(indicator); } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 29d4ff13..2f0ab68d 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,7 +1,7 @@ import { Marked, type Token, Tokenizer, type Tokens } from "marked"; -import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js"; -import type { Component } from "../tui.js"; -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; +import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; @@ -145,7 +145,9 @@ export class Markdown implements Component { const token = tokens[i]; const nextToken = tokens[i + 1]; const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); - renderedLines.push(...tokenLines); + for (const tokenLine of tokenLines) { + renderedLines.push(tokenLine); + } } // Wrap lines (NO padding, NO background yet) @@ -154,7 +156,9 @@ export class Markdown implements Component { if (isImageLine(line)) { wrappedLines.push(line); } else { - wrappedLines.push(...wrapTextWithAnsi(line, contentWidth)); + for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) { + wrappedLines.push(wrappedLine); + } } } @@ -191,7 +195,7 @@ export class Markdown implements Component { } // Combine top padding, content, and bottom padding - const result = [...emptyLines, ...contentLines, ...emptyLines]; + const result = emptyLines.concat(contentLines, emptyLines); // Update cache this.cachedText = this.text; @@ -556,8 +560,10 @@ export class Markdown implements Component { for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; const bullet = token.ordered ? `${startNumber + i}. ` : "- "; - const firstPrefix = indent + this.theme.listBullet(bullet); - const continuationPrefix = indent + " ".repeat(visibleWidth(bullet)); + const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; + const marker = bullet + taskMarker; + const firstPrefix = indent + this.theme.listBullet(marker); + const continuationPrefix = indent + " ".repeat(visibleWidth(marker)); const itemWidth = Math.max(1, width - visibleWidth(firstPrefix)); let renderedAnyLine = false; diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 09353e54..26fdb685 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,6 +1,6 @@ -import { getKeybindings } from "../keybindings.js"; -import type { Component } from "../tui.js"; -import { truncateToWidth, visibleWidth } from "../utils.js"; +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; const PRIMARY_COLUMN_GAP = 2; diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 6a6235dd..88e9433f 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,8 +1,8 @@ -import { fuzzyFilter } from "../fuzzy.js"; -import { getKeybindings } from "../keybindings.js"; -import type { Component } from "../tui.js"; -import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; -import { Input } from "./input.js"; +import { fuzzyFilter } from "../fuzzy.ts"; +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; +import { Input } from "./input.ts"; export interface SettingItem { /** Unique identifier for this setting */ diff --git a/packages/tui/src/components/spacer.ts b/packages/tui/src/components/spacer.ts index 8c63d3c2..7abe1551 100644 --- a/packages/tui/src/components/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -1,4 +1,4 @@ -import type { Component } from "../tui.js"; +import type { Component } from "../tui.ts"; /** * Spacer component that renders empty lines diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index efcf25b4..3809a48a 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,5 +1,5 @@ -import type { Component } from "../tui.js"; -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; /** * Text component - displays multi-line text with word wrapping diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index 12eac558..c26b8892 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -1,5 +1,5 @@ -import type { Component } from "../tui.js"; -import { truncateToWidth, visibleWidth } from "../utils.js"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; /** * Text component that truncates to fit viewport width diff --git a/packages/tui/src/editor-component.ts b/packages/tui/src/editor-component.ts index c6b6c43d..59564503 100644 --- a/packages/tui/src/editor-component.ts +++ b/packages/tui/src/editor-component.ts @@ -1,5 +1,5 @@ -import type { AutocompleteProvider } from "./autocomplete.js"; -import type { Component } from "./tui.js"; +import type { AutocompleteProvider } from "./autocomplete.ts"; +import type { Component } from "./tui.ts"; /** * Interface for custom editor components. diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 3c8ae0aa..9404aa65 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -7,30 +7,30 @@ export { type AutocompleteSuggestions, CombinedAutocompleteProvider, type SlashCommand, -} from "./autocomplete.js"; +} from "./autocomplete.ts"; // Components -export { Box } from "./components/box.js"; -export { CancellableLoader } from "./components/cancellable-loader.js"; -export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js"; -export { Image, type ImageOptions, type ImageTheme } from "./components/image.js"; -export { Input } from "./components/input.js"; -export { Loader, type LoaderIndicatorOptions } from "./components/loader.js"; -export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; +export { Box } from "./components/box.ts"; +export { CancellableLoader } from "./components/cancellable-loader.ts"; +export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; +export { Input } from "./components/input.ts"; +export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; +export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.ts"; export { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme, type SelectListTruncatePrimaryContext, -} from "./components/select-list.js"; -export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js"; -export { Spacer } from "./components/spacer.js"; -export { Text } from "./components/text.js"; -export { TruncatedText } from "./components/truncated-text.js"; +} from "./components/select-list.ts"; +export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts"; +export { Spacer } from "./components/spacer.ts"; +export { Text } from "./components/text.ts"; +export { TruncatedText } from "./components/truncated-text.ts"; // Editor component interface (for custom editors) -export type { EditorComponent } from "./editor-component.js"; +export type { EditorComponent } from "./editor-component.ts"; // Fuzzy matching -export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js"; +export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.ts"; // Keybindings export { getKeybindings, @@ -43,7 +43,7 @@ export { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS, -} from "./keybindings.js"; +} from "./keybindings.ts"; // Keyboard input handling export { decodeKittyPrintable, @@ -56,11 +56,11 @@ export { matchesKey, parseKey, setKittyProtocolActive, -} from "./keys.js"; +} from "./keys.ts"; // Input buffering for batch splitting -export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js"; +export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations -export { ProcessTerminal, type Terminal } from "./terminal.js"; +export { ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal image support export { allocateImageId, @@ -88,7 +88,7 @@ export { setCapabilities, setCellDimensions, type TerminalCapabilities, -} from "./terminal-image.js"; +} from "./terminal-image.ts"; export { type Component, Container, @@ -101,6 +101,6 @@ export { type OverlayOptions, type SizeValue, TUI, -} from "./tui.js"; +} from "./tui.ts"; // Utilities -export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js"; +export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 356554cf..789124ad 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -1,4 +1,4 @@ -import { type KeyId, matchesKey } from "./keys.js"; +import { type KeyId, matchesKey } from "./keys.ts"; /** * Global keybinding registry. diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 4aaf4627..a8b0b847 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -205,6 +205,29 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain const status = isCompleteSequence(candidate); if (status === "complete") { + // WezTerm with enable_kitty_keyboard sends the Escape key press as a + // raw '\x1b' byte (simple text path in encode_kitty, ignoring + // DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u + // sequence. These arrive concatenated as '\x1b\x1b[27;...u'. + // The buffer would normally treat '\x1b\x1b' as a complete meta-key + // sequence (ESC + single char), leaving '[27;...u' to be typed as + // plain text. If the character immediately following '\x1b\x1b' + // would begin a new escape sequence, emit only the first ESC and + // restart from the second. + if (candidate === "\x1b\x1b") { + const nextChar = remaining[seqEnd]; + if ( + nextChar === "[" || // CSI + nextChar === "]" || // OSC + nextChar === "O" || // SS3 + nextChar === "P" || // DCS + nextChar === "_" // APC + ) { + sequences.push(ESC); + pos += 1; + break; + } + } sequences.push(candidate); pos += seqEnd; break; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 6a84716c..d6fe13bd 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -43,6 +43,7 @@ export function detectCapabilities(): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; + const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps // sequences differently). Force hyperlinks off whenever we detect them, even @@ -50,8 +51,7 @@ export function detectCapabilities(): TerminalCapabilities { // also unreliable under tmux/screen, so leave `images: null` for safety. const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"); if (inTmuxOrScreen) { - const trueColor = colorTerm === "truecolor" || colorTerm === "24bit"; - return { images: null, trueColor, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") { @@ -82,8 +82,7 @@ export function detectCapabilities(): TerminalCapabilities { // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we // have positively identified a hyperlink-capable terminal above. - const trueColor = colorTerm === "truecolor" || colorTerm === "24bit"; - return { images: null, trueColor, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint || !!process.env.WT_SESSION, hyperlinks: false }; } export function getCapabilities(): TerminalCapabilities { @@ -211,16 +210,43 @@ export function encodeITerm2( return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; } +export interface ImageCellSize { + columns: number; + rows: number; +} + +export function calculateImageCellSize( + imageDimensions: ImageDimensions, + maxWidthCells: number, + maxHeightCells?: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): ImageCellSize { + const maxWidth = Math.max(1, Math.floor(maxWidthCells)); + const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); + const imageWidth = Math.max(1, imageDimensions.widthPx); + const imageHeight = Math.max(1, imageDimensions.heightPx); + + const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth; + const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight; + const scale = Math.min(widthScale, heightScale); + + const scaledWidthPx = imageWidth * scale; + const scaledHeightPx = imageHeight * scale; + const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); + const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + + return { + columns: Math.max(1, Math.min(maxWidth, columns)), + rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), + }; +} + export function calculateImageRows( imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, ): number { - const targetWidthPx = targetWidthCells * cellDimensions.widthPx; - const scale = targetWidthPx / imageDimensions.widthPx; - const scaledHeightPx = imageDimensions.heightPx * scale; - const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); - return Math.max(1, rows); + return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows; } export function getPngDimensions(base64Data: string): ImageDimensions | null { @@ -376,25 +402,25 @@ export function renderImage( } const maxWidth = options.maxWidthCells ?? 80; - const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); + const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); if (caps.images === "kitty") { const sequence = encodeKitty(base64Data, { - columns: maxWidth, - rows, + columns: size.columns, + rows: size.rows, imageId: options.imageId, moveCursor: options.moveCursor, }); - return { sequence, rows, imageId: options.imageId }; + return { sequence, rows: size.rows, imageId: options.imageId }; } if (caps.images === "iterm2") { const sequence = encodeITerm2(base64Data, { - width: maxWidth, + width: size.columns, height: "auto", preserveAspectRatio: options.preserveAspectRatio ?? true, }); - return { sequence, rows }; + return { sequence, rows: size.rows }; } return null; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 60eb0359..94c56ff6 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; -import { setKittyProtocolActive } from "./keys.js"; -import { StdinBuffer } from "./stdin-buffer.js"; +import { fileURLToPath } from "node:url"; +import { setKittyProtocolActive } from "./keys.ts"; +import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -210,23 +211,30 @@ export class ProcessTerminal implements Terminal { private enableWindowsVTInput(): void { if (process.platform !== "win32") return; try { - // Dynamic require to avoid bundling koffi's 74MB of cross-platform - // native binaries into every compiled binary. Koffi is only needed - // on Windows for VT input support. - const koffi = cjsRequire("koffi"); - const k32 = koffi.load("kernel32.dll"); - const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); - const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); - const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return; - const STD_INPUT_HANDLE = -10; - const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; - const handle = GetStdHandle(STD_INPUT_HANDLE); - const mode = new Uint32Array(1); - GetConsoleMode(handle, mode); - SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT); + // Dynamic require so non-Windows and bundled/browser paths never load the + // native helper. In the npm package native/ is next to dist/; in compiled + // binary archives native/ is copied next to the executable. + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; + helper.enableVirtualTerminalInput?.(); + return; + } catch { + // Try the next possible packaging location. + } + } } catch { - // koffi not available — Shift+Tab won't be distinguishable from Tab + // Native helper not available — Shift+Tab won't be distinguishable from Tab. } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 824ac82f..2874ef02 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -6,10 +6,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { performance } from "node:perf_hooks"; -import { isKeyRelease, matchesKey } from "./keys.js"; -import type { Terminal } from "./terminal.js"; -import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js"; -import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; +import { isKeyRelease, matchesKey } from "./keys.ts"; +import type { Terminal } from "./terminal.ts"; +import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; +import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 14ad9930..be27d152 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, it, test } from "node:test"; -import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; const resolveFdPath = (): string | null => { const command = process.platform === "win32" ? "where" : "which"; @@ -365,7 +365,7 @@ describe("CombinedAutocompleteProvider", () => { dirs: ["packages/coding-agent/examples/extensions/plan-mode"], files: { "packages/coding-agent/examples/extensions/plan-mode/README.md": "readme", - "packages/web-ui/docs/plan.md": "plan", + "packages/tui/docs/plan.md": "plan", }, }; setupFolder(normalBaseDir, structure); @@ -385,7 +385,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok( normalize(normalResult).includes("plan-mode/ :: packages/coding-agent/examples/extensions/plan-mode"), ); - assert.ok(normalize(normalResult).includes("plan.md :: packages/web-ui/docs/plan.md")); + assert.ok(normalize(normalResult).includes("plan.md :: packages/tui/docs/plan.md")); }); test("continues autocomplete inside quoted @ paths", async () => { diff --git a/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts b/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts index ff15d18f..b3d25e83 100644 --- a/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts +++ b/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts @@ -56,7 +56,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("new implementation returns true correctly", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); // Line containing image escape sequence with text before it const lineWithImageSequence = @@ -68,7 +68,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("new implementation detects Kitty sequences in any position", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); const scenarios = [ "At start: \x1b_Ga=T,f=100,data...\x1b\\", @@ -85,7 +85,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("new implementation detects iTerm2 sequences in any position", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); const scenarios = [ "At start: \x1b]1337;File=size=100,100:base64...\x07", @@ -121,7 +121,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = */ it("detects image sequences in read tool output", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); // Simulate output when read tool processes an image // The line might have text from the read result plus the image escape sequence @@ -131,7 +131,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("detects Kitty sequences from Image component", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); // Kitty image component creates multi-line output with escape sequences const kittyLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\"; @@ -140,7 +140,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("handles ANSI codes before image sequences", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); // Line might have styling (error, warning, etc.) before image data const lines = [ @@ -161,7 +161,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = describe("Crash scenario simulation", () => { it("does NOT crash on very long lines with image sequences", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); /** * Simulate the exact crash scenario: @@ -190,7 +190,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("handles lines exactly matching crash log dimensions", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); /** * Crash log showed: line 58649 chars wide, terminal width 115 @@ -211,7 +211,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = describe("Negative cases: Don't false positive", () => { it("does not detect images in regular long text", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); // Very long line WITHOUT image sequences const longText = "A".repeat(100000); @@ -220,7 +220,7 @@ describe("Bug regression: isImageLine() crash with image escape sequences", () = }); it("does not detect images in lines with file paths", async () => { - const { isImageLine } = await import("../src/terminal-image.js"); + const { isImageLine } = await import("../src/terminal-image.ts"); const filePaths = [ "/path/to/1337/image.jpg", diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 836d1f53..b6ccd1a8 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -3,14 +3,14 @@ */ import chalk from "chalk"; -import { CombinedAutocompleteProvider } from "../src/autocomplete.js"; -import { Editor } from "../src/components/editor.js"; -import { Loader } from "../src/components/loader.js"; -import { Markdown } from "../src/components/markdown.js"; -import { Text } from "../src/components/text.js"; -import { ProcessTerminal } from "../src/terminal.js"; -import { TUI } from "../src/tui.js"; -import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; +import { Editor } from "../src/components/editor.ts"; +import { Loader } from "../src/components/loader.ts"; +import { Markdown } from "../src/components/markdown.ts"; +import { Text } from "../src/components/text.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { TUI } from "../src/tui.ts"; +import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts"; // Create terminal const terminal = new ProcessTerminal(); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 36f7ee7b..e7e74ec3 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,12 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { stripVTControlCharacters } from "node:util"; -import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.js"; -import { Editor, wordWrapLine } from "../src/components/editor.js"; -import { TUI } from "../src/tui.js"; -import { visibleWidth } from "../src/utils.js"; -import { defaultEditorTheme } from "./test-themes.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; +import { Editor, wordWrapLine } from "../src/components/editor.ts"; +import { TUI } from "../src/tui.ts"; +import { visibleWidth } from "../src/utils.ts"; +import { defaultEditorTheme } from "./test-themes.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; /** Create a TUI with a virtual terminal for testing */ function createTestTUI(cols = 80, rows = 24): TUI { diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts index e784e49c..7415e963 100644 --- a/packages/tui/test/fuzzy.test.ts +++ b/packages/tui/test/fuzzy.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js"; +import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.ts"; describe("fuzzyMatch", () => { it("empty query matches everything with score 0", () => { diff --git a/packages/tui/test/image-test.ts b/packages/tui/test/image-test.ts index 6a29d16d..116c97d0 100644 --- a/packages/tui/test/image-test.ts +++ b/packages/tui/test/image-test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "fs"; -import { Image } from "../src/components/image.js"; -import { Spacer } from "../src/components/spacer.js"; -import { Text } from "../src/components/text.js"; -import { ProcessTerminal } from "../src/terminal.js"; -import { getCapabilities, getImageDimensions } from "../src/terminal-image.js"; -import { TUI } from "../src/tui.js"; +import { Image } from "../src/components/image.ts"; +import { Spacer } from "../src/components/spacer.ts"; +import { Text } from "../src/components/text.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { getCapabilities, getImageDimensions } from "../src/terminal-image.ts"; +import { TUI } from "../src/tui.ts"; const testImagePath = process.argv[2] || "/tmp/test-image.png"; diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 3edd73ca..e980fb05 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { Input } from "../src/components/input.js"; -import { visibleWidth } from "../src/utils.js"; +import { Input } from "../src/components/input.ts"; +import { visibleWidth } from "../src/utils.ts"; describe("Input component", () => { it("submits value including backslash on Enter", () => { diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index 443ab3ac..650b98cb 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { matchesKey } from "../src/keys.js"; -import { ProcessTerminal } from "../src/terminal.js"; -import { type Component, TUI } from "../src/tui.js"; +import { matchesKey } from "../src/keys.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { type Component, TUI } from "../src/tui.ts"; /** * Simple key code logger component diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 7e255b0a..37c67442 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.js"; +import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.ts"; describe("KeybindingsManager", () => { it("does not evict selector confirm when input submit is rebound", () => { diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 392214cd..e06844e4 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -11,7 +11,7 @@ import { matchesKey, parseKey, setKittyProtocolActive, -} from "../src/keys.js"; +} from "../src/keys.ts"; function withEnv(name: string, value: string | undefined, fn: () => void): void { const previous = process.env[name]; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 9c90eff9..584c583c 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -2,11 +2,11 @@ import assert from "node:assert"; import { afterEach, describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; -import { Markdown } from "../src/components/markdown.js"; -import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.js"; -import { type Component, TUI } from "../src/tui.js"; -import { defaultMarkdownTheme } from "./test-themes.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { Markdown } from "../src/components/markdown.ts"; +import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { defaultMarkdownTheme } from "./test-themes.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); @@ -124,6 +124,14 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); }); + it("should render task list markers", () => { + const markdown = new Markdown("- [ ] beep\n- [x] boop", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- [ ] beep", "- [x] boop"]); + }); + it("should maintain numbering when code blocks are not indented (LLM output)", () => { // When code blocks aren't indented, marked parses each item as a separate list. // We use token.start to preserve the original numbering. @@ -637,8 +645,11 @@ describe("Markdown component", () => { it("should not leak styles into following lines when rendered in TUI", async () => { class MarkdownWithInput implements Component { public markdownLineCount = 0; + private readonly markdown: Markdown; - constructor(private readonly markdown: Markdown) {} + constructor(markdown: Markdown) { + this.markdown = markdown; + } render(width: number): string[] { const lines = this.markdown.render(width); diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index c388814c..cb91e5e4 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -1,11 +1,15 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Component, Focusable } from "../src/tui.js"; -import { TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import type { Component, Focusable } from "../src/tui.ts"; +import { TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { - constructor(private lines: string[]) {} + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } render(): string[] { return this.lines; @@ -24,8 +28,11 @@ class EmptyContent implements Component { class FocusableOverlay implements Component, Focusable { focused = false; inputs: string[] = []; + private lines: string[]; - constructor(private lines: string[]) {} + constructor(lines: string[]) { + this.lines = lines; + } handleInput(data: string): void { this.inputs.push(data); diff --git a/packages/tui/test/overlay-options.test.ts b/packages/tui/test/overlay-options.test.ts index 45d909b6..c93f26cb 100644 --- a/packages/tui/test/overlay-options.test.ts +++ b/packages/tui/test/overlay-options.test.ts @@ -1,14 +1,17 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Component } from "../src/tui.js"; -import { TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import type { Component } from "../src/tui.ts"; +import { TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { - constructor( - private lines: string[], - public requestedWidth?: number, - ) {} + private lines: string[]; + requestedWidth?: number; + + constructor(lines: string[], requestedWidth?: number) { + this.lines = lines; + this.requestedWidth = requestedWidth; + } render(width: number): string[] { // Store the width we were asked to render at for verification diff --git a/packages/tui/test/overlay-short-content.test.ts b/packages/tui/test/overlay-short-content.test.ts index 98c37029..135d8cb9 100644 --- a/packages/tui/test/overlay-short-content.test.ts +++ b/packages/tui/test/overlay-short-content.test.ts @@ -1,10 +1,15 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class SimpleContent implements Component { - constructor(private lines: string[]) {} + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + render(): string[] { return this.lines; } diff --git a/packages/tui/test/regression-regional-indicator-width.test.ts b/packages/tui/test/regression-regional-indicator-width.test.ts index 675ce97f..6803e2b1 100644 --- a/packages/tui/test/regression-regional-indicator-width.test.ts +++ b/packages/tui/test/regression-regional-indicator-width.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts"; describe("regional indicator width regression", () => { it("treats partial flag grapheme as full-width to avoid streaming render drift", () => { diff --git a/packages/tui/test/select-list.test.ts b/packages/tui/test/select-list.test.ts index 053690fe..b5c24212 100644 --- a/packages/tui/test/select-list.test.ts +++ b/packages/tui/test/select-list.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { SelectList } from "../src/components/select-list.js"; -import { visibleWidth } from "../src/utils.js"; +import { SelectList } from "../src/components/select-list.ts"; +import { visibleWidth } from "../src/utils.ts"; const testTheme = { selectedPrefix: (text: string) => text, diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 549b15f2..e72c1496 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -7,7 +7,7 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; -import { StdinBuffer } from "../src/stdin-buffer.js"; +import { StdinBuffer } from "../src/stdin-buffer.ts"; describe("StdinBuffer", () => { let buffer: StdinBuffer; @@ -198,6 +198,26 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]); }); + it("should split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => { + // WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b + // and the release as a full Kitty CSI-u sequence, concatenated. + // The buffer must not treat \x1b\x1b as a complete meta-key when the + // following byte starts a new escape sequence. + processInput("\x1b\x1b[27;129:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]); + }); + + it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => { + processInput("\x1b\x1b[27;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]); + }); + + it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => { + // \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[ + processInput("\x1b\x1b"); + assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]); + }); + it("should handle plain characters mixed with Kitty sequences", () => { // Plain 'a' followed by Kitty release processInput("a\x1b[97;1:3u"); diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 58e018d2..70fbf9eb 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { Image } from "../src/components/image.js"; +import { Image } from "../src/components/image.ts"; import { deleteAllKittyImages, deleteKittyImage, @@ -16,7 +16,7 @@ import { resetCapabilitiesCache, setCapabilities, setCellDimensions, -} from "../src/terminal-image.js"; +} from "../src/terminal-image.ts"; const ENV_KEYS = [ "TERM", @@ -27,6 +27,7 @@ const ENV_KEYS = [ "GHOSTTY_RESOURCES_DIR", "WEZTERM_PANE", "ITERM_SESSION_ID", + "WT_SESSION", "CMUX_WORKSPACE_ID", ] as const; @@ -271,6 +272,31 @@ describe("detectCapabilities", () => { assert.strictEqual(caps.hyperlinks, true); }); }); + + it("detects truecolor for Windows Terminal outside multiplexers", () => { + withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + }); + }); + + it("does not inherit Windows Terminal truecolor through tmux", () => { + withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, false); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("trusts explicit truecolor hints through tmux", () => { + withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); }); describe("Kitty image cursor movement", () => { @@ -312,7 +338,41 @@ describe("Kitty image cursor movement", () => { } }); - it("restores the cursor to the reserved image row after Kitty rendering", () => { + it("honors maxHeightCells by reducing rendered width", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 }); + assert.ok(result); + assert.strictEqual(result.rows, 5); + assert.ok(result.sequence.includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("caps Image component height to a square pixel box by default", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 20 }); + try { + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 10 }, + { widthPx: 10, heightPx: 100 }, + ); + const lines = image.render(12); + assert.strictEqual(lines.length, 5); + assert.ok(lines[0].includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("places image sequence on first line with empty padding rows", () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCellDimensions({ widthPx: 10, heightPx: 10 }); try { @@ -326,11 +386,11 @@ describe("Kitty image cursor movement", () => { const lines = image.render(4); const imageId = image.getImageId(); assert.strictEqual(typeof imageId, "number"); - assert.deepStrictEqual(lines.slice(0, -1), [""]); - assert.ok(lines[1].startsWith("\x1b[1A\x1b_G")); - assert.ok(lines[1].includes(",C=1,")); - assert.ok(lines[1].includes(`,i=${imageId}`)); - assert.ok(lines[1].endsWith("\x1b[1B")); + assert.ok(lines[0].startsWith("\x1b_G")); + assert.ok(lines[0].includes(",C=1,")); + assert.ok(lines[0].includes(`,i=${imageId}`)); + assert.ok(lines[0].endsWith("\x1b\\")); + assert.deepStrictEqual(lines.slice(1, lines.length), [""]); } finally { resetCapabilitiesCache(); setCellDimensions({ widthPx: 9, heightPx: 18 }); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 7099b3df..ab2027df 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { ProcessTerminal } from "../src/terminal.js"; +import { ProcessTerminal } from "../src/terminal.ts"; describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts index ff68b2bb..33e3c39e 100644 --- a/packages/tui/test/test-themes.ts +++ b/packages/tui/test/test-themes.ts @@ -3,7 +3,7 @@ */ import { Chalk } from "chalk"; -import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js"; +import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.ts"; const chalk = new Chalk({ level: 3 }); diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts index 42d93ffa..321ba898 100644 --- a/packages/tui/test/truncate-to-width.test.ts +++ b/packages/tui/test/truncate-to-width.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.js"; +import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts"; describe("truncateToWidth", () => { it("keeps output within width for very large unicode input", () => { diff --git a/packages/tui/test/truncated-text.test.ts b/packages/tui/test/truncated-text.test.ts index ac139179..a25034e2 100644 --- a/packages/tui/test/truncated-text.test.ts +++ b/packages/tui/test/truncated-text.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { Chalk } from "chalk"; -import { TruncatedText } from "../src/components/truncated-text.js"; -import { visibleWidth } from "../src/utils.js"; +import { TruncatedText } from "../src/components/truncated-text.ts"; +import { visibleWidth } from "../src/utils.ts"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); diff --git a/packages/tui/test/tui-cell-size-input.test.ts b/packages/tui/test/tui-cell-size-input.test.ts index 0ca515d4..fab915ca 100644 --- a/packages/tui/test/tui-cell-size-input.test.ts +++ b/packages/tui/test/tui-cell-size-input.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.js"; -import { type Component, TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class InputRecorder implements Component { readonly inputs: string[] = []; diff --git a/packages/tui/test/tui-overlay-style-leak.test.ts b/packages/tui/test/tui-overlay-style-leak.test.ts index 59713371..a44b699e 100644 --- a/packages/tui/test/tui-overlay-style-leak.test.ts +++ b/packages/tui/test/tui-overlay-style-leak.test.ts @@ -1,11 +1,15 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { type Component, TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticLines implements Component { - constructor(private readonly lines: string[]) {} + private readonly lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } render(): string[] { return this.lines; @@ -15,7 +19,11 @@ class StaticLines implements Component { } class StaticOverlay implements Component { - constructor(private readonly line: string) {} + private readonly line: string; + + constructor(line: string) { + this.line = line; + } render(): string[] { return [this.line]; diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 91cb9695..230a0005 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { deleteKittyImage, encodeKitty } from "../src/terminal-image.js"; -import { type Component, TUI } from "../src/tui.js"; -import { VirtualTerminal } from "./virtual-terminal.js"; +import { deleteKittyImage, encodeKitty } from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { lines: string[] = []; diff --git a/packages/tui/test/viewport-overwrite-repro.ts b/packages/tui/test/viewport-overwrite-repro.ts index 5a0ac4f9..1ef432a0 100644 --- a/packages/tui/test/viewport-overwrite-repro.ts +++ b/packages/tui/test/viewport-overwrite-repro.ts @@ -17,8 +17,8 @@ * - When content exceeds the viewport and new lines arrive after a tool-call pause, * some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines. */ -import { ProcessTerminal } from "../src/terminal.js"; -import { type Component, TUI } from "../src/tui.js"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { type Component, TUI } from "../src/tui.ts"; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index c7ca4510..4e067f4e 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -1,6 +1,6 @@ import type { Terminal as XtermTerminalType } from "@xterm/headless"; import xterm from "@xterm/headless"; -import type { Terminal } from "../src/terminal.js"; +import type { Terminal } from "../src/terminal.ts"; // Extract Terminal class from the module const XtermTerminal = xterm.Terminal; diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index bd24e815..52d59148 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts"; describe("wrapTextWithAnsi", () => { describe("underline styling", () => { diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md deleted file mode 100644 index 932ed7b7..00000000 --- a/packages/web-ui/CHANGELOG.md +++ /dev/null @@ -1,413 +0,0 @@ -# Changelog - -## [Unreleased] - -## [0.74.0] - 2026-05-07 - -## [0.73.1] - 2026-05-07 - -## [0.73.0] - 2026-05-04 - -## [0.72.1] - 2026-05-02 - -## [0.72.0] - 2026-05-01 - -## [0.71.1] - 2026-05-01 - -## [0.71.0] - 2026-04-30 - -## [0.70.6] - 2026-04-28 - -## [0.70.5] - 2026-04-27 - -## [0.70.4] - 2026-04-27 - -## [0.70.3] - 2026-04-27 - -## [0.70.2] - 2026-04-24 - -## [0.70.1] - 2026-04-24 - -## [0.70.0] - 2026-04-23 - -## [0.69.0] - 2026-04-22 - -### Breaking Changes - -- Migrated the web UI's TypeBox-based tool definitions and runtime dependency from `@sinclair/typebox` 0.34.x to `typebox` 1.x. Install and import from `typebox` instead of `@sinclair/typebox` when embedding or extending `@mariozechner/pi-web-ui` with shared TypeBox schemas ([#3112](https://github.com/badlogic/pi-mono/issues/3112)) - -### Fixed - -- Render SVG artifact previews through a blob-backed image instead of injecting untrusted SVG markup into the page DOM ([#3552](https://github.com/badlogic/pi-mono/issues/3552)) - -## [0.68.1] - 2026-04-22 - -## [0.68.0] - 2026-04-20 - -## [0.67.68] - 2026-04-17 - -## [0.67.67] - 2026-04-17 - -## [0.67.6] - 2026-04-16 - -## [0.67.5] - 2026-04-16 - -## [0.67.4] - 2026-04-16 - -## [0.67.3] - 2026-04-15 - -## [0.67.2] - 2026-04-14 - -## [0.67.1] - 2026-04-13 - -## [0.67.0] - 2026-04-13 - -## [0.66.1] - 2026-04-08 - -## [0.66.0] - 2026-04-08 - -## [0.65.2] - 2026-04-06 - -## [0.65.1] - 2026-04-05 - -## [0.65.0] - 2026-04-03 - -## [0.64.0] - 2026-03-29 - -## [0.63.2] - 2026-03-29 - -## [0.63.1] - 2026-03-27 - -## [0.63.0] - 2026-03-27 - -## [0.62.0] - 2026-03-23 - -## [0.61.1] - 2026-03-20 - -## [0.61.0] - 2026-03-20 - -## [0.60.0] - 2026-03-18 - -## [0.59.0] - 2026-03-17 - -### Added - -- Exported `CustomProviderDialog` from `@mariozechner/pi-web-ui` ([#2267](https://github.com/badlogic/pi-mono/issues/2267)) - -## [0.58.4] - 2026-03-16 - -### Added - -- `onModelSelect` callback on `AgentInterface` and `ChatPanel.setAgent` config -- `allowedProviders` filter on `ModelSelector.open()` to restrict visible models -- `onClose` callback on `SettingsDialog.open()` -- `state_change` event emitted by Agent on `setModel()` and `setThinkingLevel()` -- Subsequence-based fuzzy search in model selector (replaces substring matching) -- `openai-codex` and `github-copilot` to `shouldUseProxyForProvider` - -### Changed - -- Anthropic test model updated from `claude-3-5-haiku-20241022` to `claude-haiku-4-5` - -### Fixed - -- `AgentInterface` clears streaming container on `message_end` to prevent duplicate tool rendering - -## [0.58.3] - 2026-03-15 - -### Fixed - -- Build `@mariozechner/pi-web-ui` with `tsc` instead of `tsgo` so Lit decorator-based state updates rerender correctly. - -## [0.58.2] - 2026-03-15 - -## [0.58.1] - 2026-03-14 - -## [0.58.0] - 2026-03-14 - -## [0.57.1] - 2026-03-07 - -## [0.57.0] - 2026-03-07 - -## [0.56.3] - 2026-03-06 - -## [0.56.2] - 2026-03-05 - -## [0.56.1] - 2026-03-05 - -## [0.56.0] - 2026-03-04 - -## [0.55.4] - 2026-03-02 - -## [0.55.3] - 2026-02-27 - -## [0.55.2] - 2026-02-27 - -## [0.55.1] - 2026-02-26 - -## [0.55.0] - 2026-02-24 - -## [0.54.2] - 2026-02-23 - -## [0.54.1] - 2026-02-22 - -## [0.54.0] - 2026-02-19 - -## [0.53.1] - 2026-02-19 - -## [0.53.0] - 2026-02-17 - -## [0.52.12] - 2026-02-13 - -## [0.52.11] - 2026-02-13 - -## [0.52.10] - 2026-02-12 - -### Fixed - -- Made model selector search case-insensitive by normalizing query tokens, fixing auto-capitalized mobile input filtering ([#1443](https://github.com/badlogic/pi-mono/issues/1443)) - -## [0.52.9] - 2026-02-08 - -## [0.52.8] - 2026-02-07 - -## [0.52.7] - 2026-02-06 - -## [0.52.6] - 2026-02-05 - -## [0.52.5] - 2026-02-05 - -## [0.52.4] - 2026-02-05 - -## [0.52.3] - 2026-02-05 - -## [0.52.2] - 2026-02-05 - -## [0.52.1] - 2026-02-05 - -## [0.52.0] - 2026-02-05 - -## [0.51.6] - 2026-02-04 - -## [0.51.5] - 2026-02-04 - -## [0.51.4] - 2026-02-03 - -## [0.51.3] - 2026-02-03 - -## [0.51.2] - 2026-02-03 - -## [0.51.1] - 2026-02-02 - -## [0.51.0] - 2026-02-01 - -## [0.50.9] - 2026-02-01 - -## [0.50.8] - 2026-02-01 - -## [0.50.7] - 2026-01-31 - -## [0.50.6] - 2026-01-30 - -## [0.50.5] - 2026-01-30 - -## [0.50.3] - 2026-01-29 - -## [0.50.2] - 2026-01-29 - -### Added - -- Exported `CustomProviderCard`, `ProviderKeyInput`, `AbortedMessage`, and `ToolMessageDebugView` components for custom UIs ([#1015](https://github.com/badlogic/pi-mono/issues/1015)) - -## [0.50.1] - 2026-01-26 - -## [0.50.0] - 2026-01-26 - -## [0.49.3] - 2026-01-22 - -### Changed - -- Updated tsgo to 7.0.0-dev.20260120.1 for decorator support ([#873](https://github.com/badlogic/pi-mono/issues/873)) - -## [0.49.2] - 2026-01-19 - -## [0.49.1] - 2026-01-18 - -## [0.49.0] - 2026-01-17 - -## [0.48.0] - 2026-01-16 - -## [0.47.0] - 2026-01-16 - -## [0.46.0] - 2026-01-15 - -## [0.45.7] - 2026-01-13 - -## [0.45.6] - 2026-01-13 - -## [0.45.5] - 2026-01-13 - -## [0.45.4] - 2026-01-13 - -## [0.45.3] - 2026-01-13 - -## [0.45.2] - 2026-01-13 - -## [0.45.1] - 2026-01-13 - -## [0.45.0] - 2026-01-13 - -## [0.44.0] - 2026-01-12 - -## [0.43.0] - 2026-01-11 - -## [0.42.5] - 2026-01-11 - -## [0.42.4] - 2026-01-10 - -## [0.42.3] - 2026-01-10 - -## [0.42.2] - 2026-01-10 - -## [0.42.1] - 2026-01-09 - -## [0.42.0] - 2026-01-09 - -## [0.41.0] - 2026-01-09 - -## [0.40.1] - 2026-01-09 - -## [0.40.0] - 2026-01-08 - -## [0.39.1] - 2026-01-08 - -## [0.39.0] - 2026-01-08 - -## [0.38.0] - 2026-01-08 - -## [0.37.8] - 2026-01-07 - -## [0.37.7] - 2026-01-07 - -## [0.37.6] - 2026-01-06 - -## [0.37.5] - 2026-01-06 - -## [0.37.4] - 2026-01-06 - -## [0.37.3] - 2026-01-06 - -## [0.37.2] - 2026-01-05 - -## [0.37.1] - 2026-01-05 - -## [0.37.0] - 2026-01-05 - -## [0.36.0] - 2026-01-05 - -## [0.35.0] - 2026-01-05 - -## [0.34.2] - 2026-01-04 - -## [0.34.1] - 2026-01-04 - -## [0.34.0] - 2026-01-04 - -## [0.33.0] - 2026-01-04 - -## [0.32.3] - 2026-01-03 - -## [0.32.2] - 2026-01-03 - -## [0.32.1] - 2026-01-03 - -## [0.32.0] - 2026-01-03 - -## [0.31.1] - 2026-01-02 - -## [0.31.0] - 2026-01-02 - -### Breaking Changes - -- **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead. - -- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, `AgentTransport` interface, and related types have been removed. The `Agent` class now uses `streamFn` for custom streaming. - -- **`AppMessage` renamed to `AgentMessage`**: Now imported from `@mariozechner/pi-agent-core`. Custom message types use declaration merging on `CustomAgentMessages` interface. - -- **`UserMessageWithAttachments` is now a custom message type**: Has `role: "user-with-attachments"` instead of `role: "user"`. Use `isUserMessageWithAttachments()` type guard. - -- **`CustomMessages` interface removed**: Use declaration merging on `CustomAgentMessages` from `@mariozechner/pi-agent-core` instead. - -- **`agent.appendMessage()` removed**: Use `agent.queueMessage()` instead. - -- **Agent event types changed**: `AgentInterface` now handles new event types from `@mariozechner/pi-agent-core`: `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`, `agent_start`, `agent_end`. - -### Added - -- **`defaultConvertToLlm`**: Default message transformer that handles `UserMessageWithAttachments` and `ArtifactMessage`. Apps can extend this for custom message types. - -- **`convertAttachments`**: Utility to convert `Attachment[]` to LLM content blocks (images and extracted document text). - -- **`isUserMessageWithAttachments` / `isArtifactMessage`**: Type guard functions for custom message types. - -- **`createStreamFn`**: Creates a stream function with CORS proxy support. Reads proxy settings on each call for dynamic configuration. - -- **Default `streamFn` and `getApiKey`**: `AgentInterface` now sets sensible defaults if not provided: - - `streamFn`: Uses `createStreamFn` with proxy settings from storage - - `getApiKey`: Reads from `providerKeys` storage - -- **Proxy utilities exported**: `applyProxyIfNeeded`, `shouldUseProxyForProvider`, `isCorsError`, `createStreamFn` - -### Removed - -- `Agent` class (moved to `@mariozechner/pi-agent-core`) -- `ProviderTransport` class -- `AppTransport` class -- `AgentTransport` interface -- `AgentRunConfig` type -- `ProxyAssistantMessageEvent` type -- `test-sessions.ts` example file - -### Migration Guide - -**Before (0.30.x):** -```typescript -import { Agent, ProviderTransport, type AppMessage } from '@mariozechner/pi-web-ui'; - -const agent = new Agent({ - transport: new ProviderTransport(), - messageTransformer: (messages: AppMessage[]) => messages.filter(...) -}); -``` - -**After:** -```typescript -import { Agent, type AgentMessage } from '@mariozechner/pi-agent-core'; -import { defaultConvertToLlm } from '@mariozechner/pi-web-ui'; - -const agent = new Agent({ - convertToLlm: (messages: AgentMessage[]) => { - // Extend defaultConvertToLlm for custom types - return defaultConvertToLlm(messages); - } -}); -// AgentInterface will set streamFn and getApiKey defaults automatically -``` - -**Custom message types:** -```typescript -// Before: declaration merging on CustomMessages -declare module "@mariozechner/pi-web-ui" { - interface CustomMessages { - "my-message": MyMessage; - } -} - -// After: declaration merging on CustomAgentMessages -declare module "@mariozechner/pi-agent-core" { - interface CustomAgentMessages { - "my-message": MyMessage; - } -} -``` diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md deleted file mode 100644 index 7482caef..00000000 --- a/packages/web-ui/README.md +++ /dev/null @@ -1,601 +0,0 @@ -# @earendil-works/pi-web-ui - -Reusable web UI components for building AI chat interfaces powered by [@earendil-works/pi-ai](../ai) and [@earendil-works/pi-agent-core](../agent). - -Built with [mini-lit](https://github.com/badlogic/mini-lit) web components and Tailwind CSS v4. - -## Features - -- **Chat UI**: Complete interface with message history, streaming, and tool execution -- **Tools**: JavaScript REPL, document extraction, and artifacts (HTML, SVG, Markdown, etc.) -- **Attachments**: PDF, DOCX, XLSX, PPTX, images with preview and text extraction -- **Artifacts**: Interactive HTML, SVG, Markdown with sandboxed execution -- **Storage**: IndexedDB-backed storage for sessions, API keys, and settings -- **CORS Proxy**: Automatic proxy handling for browser environments -- **Custom Providers**: Support for Ollama, LM Studio, vLLM, and OpenAI-compatible APIs - -## Installation - -```bash -npm install @earendil-works/pi-web-ui @earendil-works/pi-agent-core @earendil-works/pi-ai -``` - -## Quick Start - -See the [example](./example) directory for a complete working application. - -```typescript -import { Agent } from '@earendil-works/pi-agent-core'; -import { getModel } from '@earendil-works/pi-ai'; -import { - ChatPanel, - AppStorage, - IndexedDBStorageBackend, - ProviderKeysStore, - SessionsStore, - SettingsStore, - setAppStorage, - defaultConvertToLlm, - ApiKeyPromptDialog, -} from '@earendil-works/pi-web-ui'; -import '@earendil-works/pi-web-ui/app.css'; - -// Set up storage -const settings = new SettingsStore(); -const providerKeys = new ProviderKeysStore(); -const sessions = new SessionsStore(); - -const backend = new IndexedDBStorageBackend({ - dbName: 'my-app', - version: 1, - stores: [ - settings.getConfig(), - providerKeys.getConfig(), - sessions.getConfig(), - SessionsStore.getMetadataConfig(), - ], -}); - -settings.setBackend(backend); -providerKeys.setBackend(backend); -sessions.setBackend(backend); - -const storage = new AppStorage(settings, providerKeys, sessions, undefined, backend); -setAppStorage(storage); - -// Create agent -const agent = new Agent({ - initialState: { - systemPrompt: 'You are a helpful assistant.', - model: getModel('anthropic', 'claude-sonnet-4-5-20250929'), - thinkingLevel: 'off', - messages: [], - tools: [], - }, - convertToLlm: defaultConvertToLlm, -}); - -// Create chat panel -const chatPanel = new ChatPanel(); -await chatPanel.setAgent(agent, { - onApiKeyRequired: (provider) => ApiKeyPromptDialog.prompt(provider), -}); - -document.body.appendChild(chatPanel); -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ ChatPanel │ -│ ┌─────────────────────┐ ┌─────────────────────┐ │ -│ │ AgentInterface │ │ ArtifactsPanel │ │ -│ │ (messages, input) │ │ (HTML, SVG, MD) │ │ -│ └─────────────────────┘ └─────────────────────┘ │ -└─────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ Agent (from pi-agent-core) │ -│ - State management (messages, model, tools) │ -│ - Event emission (agent_start, message_update, ...)│ -│ - Tool execution │ -└─────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ AppStorage │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Settings │ │ Provider │ │ Sessions │ │ -│ │ Store │ │Keys Store│ │ Store │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ │ │ -│ IndexedDBStorageBackend │ -└─────────────────────────────────────────────────────┘ -``` - -## Components - -### ChatPanel - -High-level chat interface with built-in artifacts panel. - -```typescript -const chatPanel = new ChatPanel(); -await chatPanel.setAgent(agent, { - // Prompt for API key when needed - onApiKeyRequired: async (provider) => ApiKeyPromptDialog.prompt(provider), - - // Hook before sending messages - onBeforeSend: async () => { /* save draft, etc. */ }, - - // Handle cost display click - onCostClick: () => { /* show cost breakdown */ }, - - // Custom sandbox URL for browser extensions - sandboxUrlProvider: () => chrome.runtime.getURL('sandbox.html'), - - // Add custom tools - toolsFactory: (agent, agentInterface, artifactsPanel, runtimeProvidersFactory) => { - const replTool = createJavaScriptReplTool(); - replTool.runtimeProvidersFactory = runtimeProvidersFactory; - return [replTool]; - }, -}); -``` - -### AgentInterface - -Lower-level chat interface for custom layouts. - -```typescript -const chat = document.createElement('agent-interface') as AgentInterface; -chat.session = agent; -chat.enableAttachments = true; -chat.enableModelSelector = true; -chat.enableThinkingSelector = true; -chat.onApiKeyRequired = async (provider) => { /* ... */ }; -chat.onBeforeSend = async () => { /* ... */ }; -``` - -Properties: -- `session`: Agent instance -- `enableAttachments`: Show attachment button (default: true) -- `enableModelSelector`: Show model selector (default: true) -- `enableThinkingSelector`: Show thinking level selector (default: true) -- `showThemeToggle`: Show theme toggle (default: false) - -### Agent (from pi-agent-core) - -```typescript -import { Agent } from '@earendil-works/pi-agent-core'; - -const agent = new Agent({ - initialState: { - model: getModel('anthropic', 'claude-sonnet-4-5-20250929'), - systemPrompt: 'You are helpful.', - thinkingLevel: 'off', - messages: [], - tools: [], - }, - convertToLlm: defaultConvertToLlm, -}); - -// Events -agent.subscribe((event) => { - switch (event.type) { - case 'agent_start': // Agent loop started - case 'agent_end': // Agent loop finished - case 'turn_start': // LLM call started - case 'turn_end': // LLM call finished - case 'message_start': - case 'message_update': // Streaming update - case 'message_end': - break; - } -}); - -// Send message -await agent.prompt('Hello!'); -await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attachments, timestamp: Date.now() }); - -// Control -agent.abort(); -agent.state.model = newModel; -agent.state.thinkingLevel = 'medium'; -agent.state.tools = [...]; -agent.followUp(customMessage); -``` - -## Message Types - -### UserMessageWithAttachments - -User message with file attachments: - -```typescript -const message: UserMessageWithAttachments = { - role: 'user-with-attachments', - content: 'Analyze this document', - attachments: [pdfAttachment], - timestamp: Date.now(), -}; - -// Type guard -if (isUserMessageWithAttachments(msg)) { - console.log(msg.attachments); -} -``` - -### ArtifactMessage - -For session persistence of artifacts: - -```typescript -const artifact: ArtifactMessage = { - role: 'artifact', - action: 'create', // or 'update', 'delete' - filename: 'chart.html', - content: '
...
', - timestamp: new Date().toISOString(), -}; - -// Type guard -if (isArtifactMessage(msg)) { - console.log(msg.filename); -} -``` - -### Custom Message Types - -Extend via declaration merging: - -```typescript -interface SystemNotification { - role: 'system-notification'; - message: string; - level: 'info' | 'warning' | 'error'; - timestamp: string; -} - -declare module '@earendil-works/pi-agent-core' { - interface CustomAgentMessages { - 'system-notification': SystemNotification; - } -} - -// Register renderer -registerMessageRenderer('system-notification', { - render: (msg) => html`
${msg.message}
`, -}); - -// Extend convertToLlm -function myConvertToLlm(messages: AgentMessage[]): Message[] { - const processed = messages.map((m) => { - if (m.role === 'system-notification') { - return { role: 'user', content: `${m.message}`, timestamp: Date.now() }; - } - return m; - }); - return defaultConvertToLlm(processed); -} -``` - -## Message Transformer - -`convertToLlm` transforms app messages to LLM-compatible format: - -```typescript -import { defaultConvertToLlm, convertAttachments } from '@earendil-works/pi-web-ui'; - -// defaultConvertToLlm handles: -// - UserMessageWithAttachments → user message with image/text content blocks -// - ArtifactMessage → filtered out (UI-only) -// - Standard messages (user, assistant, toolResult) → passed through -``` - -## Tools - -### JavaScript REPL - -Execute JavaScript in a sandboxed browser environment: - -```typescript -import { createJavaScriptReplTool } from '@earendil-works/pi-web-ui'; - -const replTool = createJavaScriptReplTool(); - -// Configure runtime providers for artifact/attachment access -replTool.runtimeProvidersFactory = () => [ - new AttachmentsRuntimeProvider(attachments), - new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write -]; - -agent.state.tools = [replTool]; -``` - -### Extract Document - -Extract text from documents at URLs: - -```typescript -import { createExtractDocumentTool } from '@earendil-works/pi-web-ui'; - -const extractTool = createExtractDocumentTool(); -extractTool.corsProxyUrl = 'https://corsproxy.io/?'; - -agent.state.tools = [extractTool]; -``` - -### Artifacts Tool - -Built into ArtifactsPanel, supports: HTML, SVG, Markdown, text, JSON, images, PDF, DOCX, XLSX. - -```typescript -const artifactsPanel = new ArtifactsPanel(); -artifactsPanel.agent = agent; - -// The tool is available as artifactsPanel.tool -agent.state.tools = [artifactsPanel.tool]; -``` - -### Custom Tool Renderers - -```typescript -import { registerToolRenderer, type ToolRenderer } from '@earendil-works/pi-web-ui'; - -const myRenderer: ToolRenderer = { - render(params, result, isStreaming) { - return { - content: html`
...
`, - isCustom: false, // true = no card wrapper - }; - }, -}; - -registerToolRenderer('my_tool', myRenderer); -``` - -## Storage - -### Setup - -```typescript -import { - AppStorage, - IndexedDBStorageBackend, - SettingsStore, - ProviderKeysStore, - SessionsStore, - CustomProvidersStore, - setAppStorage, - getAppStorage, -} from '@earendil-works/pi-web-ui'; - -// Create stores -const settings = new SettingsStore(); -const providerKeys = new ProviderKeysStore(); -const sessions = new SessionsStore(); -const customProviders = new CustomProvidersStore(); - -// Create backend with all store configs -const backend = new IndexedDBStorageBackend({ - dbName: 'my-app', - version: 1, - stores: [ - settings.getConfig(), - providerKeys.getConfig(), - sessions.getConfig(), - SessionsStore.getMetadataConfig(), - customProviders.getConfig(), - ], -}); - -// Wire stores to backend -settings.setBackend(backend); -providerKeys.setBackend(backend); -sessions.setBackend(backend); -customProviders.setBackend(backend); - -// Create and set global storage -const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend); -setAppStorage(storage); -``` - -### SettingsStore - -Key-value settings: - -```typescript -await storage.settings.set('proxy.enabled', true); -await storage.settings.set('proxy.url', 'https://proxy.example.com'); -const enabled = await storage.settings.get('proxy.enabled'); -``` - -### ProviderKeysStore - -API keys by provider: - -```typescript -await storage.providerKeys.set('anthropic', 'sk-ant-...'); -const key = await storage.providerKeys.get('anthropic'); -const providers = await storage.providerKeys.list(); -``` - -### SessionsStore - -Chat sessions with metadata: - -```typescript -// Save session -await storage.sessions.save(sessionData, metadata); - -// Load session -const data = await storage.sessions.get(sessionId); -const metadata = await storage.sessions.getMetadata(sessionId); - -// List sessions (sorted by lastModified) -const allMetadata = await storage.sessions.getAllMetadata(); - -// Update title -await storage.sessions.updateTitle(sessionId, 'New Title'); - -// Delete -await storage.sessions.delete(sessionId); -``` - -### CustomProvidersStore - -Custom LLM providers: - -```typescript -const provider: CustomProvider = { - id: crypto.randomUUID(), - name: 'My Ollama', - type: 'ollama', - baseUrl: 'http://localhost:11434', -}; - -await storage.customProviders.set(provider); -const all = await storage.customProviders.getAll(); -``` - -## Attachments - -Load and process files: - -```typescript -import { loadAttachment, type Attachment } from '@earendil-works/pi-web-ui'; - -// From File input -const file = inputElement.files[0]; -const attachment = await loadAttachment(file); - -// From URL -const attachment = await loadAttachment('https://example.com/doc.pdf'); - -// From ArrayBuffer -const attachment = await loadAttachment(arrayBuffer, 'document.pdf'); - -// Attachment structure -interface Attachment { - id: string; - type: 'image' | 'document'; - fileName: string; - mimeType: string; - size: number; - content: string; // base64 encoded - extractedText?: string; // For documents - preview?: string; // base64 preview image -} -``` - -Supported formats: PDF, DOCX, XLSX, PPTX, images, text files. - -## CORS Proxy - -For browser environments with CORS restrictions: - -```typescript -import { createStreamFn, shouldUseProxyForProvider, isCorsError } from '@earendil-works/pi-web-ui'; - -// AgentInterface auto-configures proxy from settings -// For manual setup: -agent.streamFn = createStreamFn(async () => { - const enabled = await storage.settings.get('proxy.enabled'); - return enabled ? await storage.settings.get('proxy.url') : undefined; -}); - -// Providers requiring proxy: -// - zai: always -// - anthropic: only OAuth tokens (sk-ant-oat-*) -``` - -## Dialogs - -### SettingsDialog - -```typescript -import { SettingsDialog, ProvidersModelsTab, ProxyTab, ApiKeysTab } from '@earendil-works/pi-web-ui'; - -SettingsDialog.open([ - new ProvidersModelsTab(), // Custom providers + model list - new ProxyTab(), // CORS proxy settings - new ApiKeysTab(), // API keys per provider -]); -``` - -### SessionListDialog - -```typescript -import { SessionListDialog } from '@earendil-works/pi-web-ui'; - -SessionListDialog.open( - async (sessionId) => { /* load session */ }, - (deletedId) => { /* handle deletion */ }, -); -``` - -### ApiKeyPromptDialog - -```typescript -import { ApiKeyPromptDialog } from '@earendil-works/pi-web-ui'; - -const success = await ApiKeyPromptDialog.prompt('anthropic'); -``` - -### ModelSelector - -```typescript -import { ModelSelector } from '@earendil-works/pi-web-ui'; - -ModelSelector.open(currentModel, (selectedModel) => { - agent.state.model = selectedModel; -}); -``` - -## Styling - -Import the pre-built CSS: - -```typescript -import '@earendil-works/pi-web-ui/app.css'; -``` - -Or use Tailwind with custom config: - -```css -@import '@mariozechner/mini-lit/themes/claude.css'; -@tailwind base; -@tailwind components; -@tailwind utilities; -``` - -## Internationalization - -```typescript -import { i18n, setLanguage, translations } from '@earendil-works/pi-web-ui'; - -// Add translations -translations.de = { - 'Loading...': 'Laden...', - 'No sessions yet': 'Noch keine Sitzungen', -}; - -setLanguage('de'); -console.log(i18n('Loading...')); // "Laden..." -``` - -## Examples - -- [example/](./example) - Complete web app with sessions, artifacts, custom messages -- [sitegeist](https://sitegeist.ai) - Browser extension using pi-web-ui - -## Known Issues - -- **PersistentStorageDialog**: Currently broken - -## License - -MIT diff --git a/packages/web-ui/example/.gitignore b/packages/web-ui/example/.gitignore deleted file mode 100644 index 0ca39c00..00000000 --- a/packages/web-ui/example/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -.DS_Store diff --git a/packages/web-ui/example/README.md b/packages/web-ui/example/README.md deleted file mode 100644 index e3d468cf..00000000 --- a/packages/web-ui/example/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Pi Web UI - Example - -This is a minimal example showing how to use `@earendil-works/pi-web-ui` in a web application. - -## Setup - -```bash -npm install -``` - -## Development - -```bash -npm run dev -``` - -Open [http://localhost:5173](http://localhost:5173) in your browser. - -## What's Included - -This example demonstrates: - -- **ChatPanel** - The main chat interface component -- **System Prompt** - Custom configuration for the AI assistant -- **Tools** - JavaScript REPL and artifacts tool - -## Configuration - -### API Keys - -The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser. - -To use the chat: - -1. Click the settings icon (⚙️) in the chat interface -2. Click "Manage API Keys" -3. Add your API key for your preferred provider: - - **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/) - - **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/) - - **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/) - -API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API. - -## Project Structure - -``` -example/ -├── src/ -│ ├── main.ts # Main application entry point -│ └── app.css # Tailwind CSS configuration -├── index.html # HTML entry point -├── package.json # Dependencies -├── vite.config.ts # Vite configuration -└── tsconfig.json # TypeScript configuration -``` - -## Learn More - -- [Pi Web UI Documentation](../README.md) -- [Pi AI Documentation](../../ai/README.md) -- [Mini Lit Documentation](https://github.com/badlogic/mini-lit) diff --git a/packages/web-ui/example/index.html b/packages/web-ui/example/index.html deleted file mode 100644 index 933f67a1..00000000 --- a/packages/web-ui/example/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Pi Web UI - Example - - - -
- - - diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json deleted file mode 100644 index 06497e47..00000000 --- a/packages/web-ui/example/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "pi-web-ui-example", - "version": "0.74.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "check": "tsgo --noEmit", - "clean": "shx rm -rf dist" - }, - "dependencies": { - "@mariozechner/mini-lit": "^0.2.0", - "@earendil-works/pi-ai": "file:../../ai", - "@earendil-works/pi-web-ui": "file:../", - "@tailwindcss/vite": "^4.1.17", - "lit": "^3.3.1", - "lucide": "^0.544.0" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vite": "^7.1.6" - } -} diff --git a/packages/web-ui/example/src/app.css b/packages/web-ui/example/src/app.css deleted file mode 100644 index 695386b0..00000000 --- a/packages/web-ui/example/src/app.css +++ /dev/null @@ -1 +0,0 @@ -@import "../../dist/app.css"; diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts deleted file mode 100644 index 93e07a77..00000000 --- a/packages/web-ui/example/src/custom-messages.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Message } from "@earendil-works/pi-ai"; -import type { AgentMessage, MessageRenderer } from "@earendil-works/pi-web-ui"; -import { defaultConvertToLlm, registerMessageRenderer } from "@earendil-works/pi-web-ui"; -import { Alert } from "@mariozechner/mini-lit/dist/Alert.js"; -import { html } from "lit"; - -// ============================================================================ -// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING -// ============================================================================ - -// Define custom message types -export interface SystemNotificationMessage { - role: "system-notification"; - message: string; - variant: "default" | "destructive"; - timestamp: string; -} - -// Extend CustomAgentMessages interface via declaration merging -// This must target pi-agent-core where CustomAgentMessages is defined -declare module "@earendil-works/pi-agent-core" { - interface CustomAgentMessages { - "system-notification": SystemNotificationMessage; - } -} - -// ============================================================================ -// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage) -// ============================================================================ - -const systemNotificationRenderer: MessageRenderer = { - render: (notification) => { - // notification is fully typed as SystemNotificationMessage! - return html` -
- ${Alert({ - variant: notification.variant, - children: html` -
-
${notification.message}
-
${new Date(notification.timestamp).toLocaleTimeString()}
-
- `, - })} -
- `; - }, -}; - -// ============================================================================ -// 3. REGISTER RENDERER -// ============================================================================ - -export function registerCustomMessageRenderers() { - registerMessageRenderer("system-notification", systemNotificationRenderer); -} - -// ============================================================================ -// 4. HELPER TO CREATE CUSTOM MESSAGES -// ============================================================================ - -export function createSystemNotification( - message: string, - variant: "default" | "destructive" = "default", -): SystemNotificationMessage { - return { - role: "system-notification", - message, - variant, - timestamp: new Date().toISOString(), - }; -} - -// ============================================================================ -// 5. CUSTOM MESSAGE TRANSFORMER -// ============================================================================ - -/** - * Custom message transformer that extends defaultConvertToLlm. - * Handles system-notification messages by converting them to user messages. - */ -export function customConvertToLlm(messages: AgentMessage[]): Message[] { - // First, handle our custom system-notification type - const processed = messages.map((m): AgentMessage => { - if (m.role === "system-notification") { - const notification = m as SystemNotificationMessage; - // Convert to user message with tags - return { - role: "user", - content: `${notification.message}`, - timestamp: Date.now(), - }; - } - return m; - }); - - // Then use defaultConvertToLlm for standard handling - return defaultConvertToLlm(processed); -} diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts deleted file mode 100644 index 3efcd73c..00000000 --- a/packages/web-ui/example/src/main.ts +++ /dev/null @@ -1,421 +0,0 @@ -import "@mariozechner/mini-lit/dist/ThemeToggle.js"; -import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core"; -import { getModel, type TextContent } from "@earendil-works/pi-ai"; -import { - type AgentState, - ApiKeyPromptDialog, - AppStorage, - ChatPanel, - CustomProvidersStore, - createJavaScriptReplTool, - IndexedDBStorageBackend, - // PersistentStorageDialog, // TODO: Fix - currently broken - ProviderKeysStore, - ProvidersModelsTab, - ProxyTab, - SessionListDialog, - SessionsStore, - SettingsDialog, - SettingsStore, - setAppStorage, -} from "@earendil-works/pi-web-ui"; -import { html, render } from "lit"; -import { Bell, History, Plus, Settings } from "lucide"; -import "./app.css"; -import { icon } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { Input } from "@mariozechner/mini-lit/dist/Input.js"; -import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; - -// Register custom message renderers -registerCustomMessageRenderers(); - -// Create stores -const settings = new SettingsStore(); -const providerKeys = new ProviderKeysStore(); -const sessions = new SessionsStore(); -const customProviders = new CustomProvidersStore(); - -// Gather configs -const configs = [ - settings.getConfig(), - SessionsStore.getMetadataConfig(), - providerKeys.getConfig(), - customProviders.getConfig(), - sessions.getConfig(), -]; - -// Create backend -const backend = new IndexedDBStorageBackend({ - dbName: "pi-web-ui-example", - version: 2, // Incremented for custom-providers store - stores: configs, -}); - -// Wire backend to stores -settings.setBackend(backend); -providerKeys.setBackend(backend); -customProviders.setBackend(backend); -sessions.setBackend(backend); - -// Create and set app storage -const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend); -setAppStorage(storage); - -let currentSessionId: string | undefined; -let currentTitle = ""; -let isEditingTitle = false; -let agent: Agent; -let chatPanel: ChatPanel; -let agentUnsubscribe: (() => void) | undefined; - -const generateTitle = (messages: AgentMessage[]): string => { - const firstUserMsg = messages.find((m) => m.role === "user"); - if (!firstUserMsg) return ""; - - let text = ""; - const content = firstUserMsg.content; - - if (typeof content === "string") { - text = content; - } else { - const textBlocks = content.filter((c): c is TextContent => c.type === "text"); - text = textBlocks.map((c) => c.text || "").join(" "); - } - - text = text.trim(); - if (!text) return ""; - - const sentenceEnd = text.search(/[.!?]/); - if (sentenceEnd > 0 && sentenceEnd <= 50) { - return text.substring(0, sentenceEnd + 1); - } - return text.length <= 50 ? text : `${text.substring(0, 47)}...`; -}; - -const shouldSaveSession = (messages: AgentMessage[]): boolean => { - const hasUserMsg = messages.some((m) => m.role === "user"); - const hasAssistantMsg = messages.some((m) => m.role === "assistant"); - return hasUserMsg && hasAssistantMsg; -}; - -const saveSession = async () => { - if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; - - const state = agent.state; - if (!shouldSaveSession(state.messages)) return; - - try { - // Create session data - const sessionData = { - id: currentSessionId, - title: currentTitle, - model: state.model!, - thinkingLevel: state.thinkingLevel, - messages: state.messages, - createdAt: new Date().toISOString(), - lastModified: new Date().toISOString(), - }; - - // Create session metadata - const metadata = { - id: currentSessionId, - title: currentTitle, - createdAt: sessionData.createdAt, - lastModified: sessionData.lastModified, - messageCount: state.messages.length, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - modelId: state.model?.id || null, - thinkingLevel: state.thinkingLevel, - preview: generateTitle(state.messages), - }; - - await storage.sessions.save(sessionData, metadata); - } catch (err) { - console.error("Failed to save session:", err); - } -}; - -const updateUrl = (sessionId: string) => { - const url = new URL(window.location.href); - url.searchParams.set("session", sessionId); - window.history.replaceState({}, "", url); -}; - -const createAgent = async (initialState?: Partial) => { - if (agentUnsubscribe) { - agentUnsubscribe(); - } - - agent = new Agent({ - initialState: initialState || { - systemPrompt: `You are a helpful AI assistant with access to various tools. - -Available tools: -- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.) -- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts - -Feel free to use these tools when needed to provide accurate and helpful responses.`, - model: getModel("anthropic", "claude-sonnet-4-5-20250929"), - thinkingLevel: "off", - messages: [], - tools: [], - }, - // Custom transformer: convert custom messages to LLM-compatible format - convertToLlm: customConvertToLlm, - }); - - agentUnsubscribe = agent.subscribe((event: any) => { - if (event.type === "state-update") { - const messages = event.state.messages; - - // Generate title after first successful response - if (!currentTitle && shouldSaveSession(messages)) { - currentTitle = generateTitle(messages); - } - - // Create session ID on first successful save - if (!currentSessionId && shouldSaveSession(messages)) { - currentSessionId = crypto.randomUUID(); - updateUrl(currentSessionId); - } - - // Auto-save - if (currentSessionId) { - saveSession(); - } - - renderApp(); - } - }); - - await chatPanel.setAgent(agent, { - onApiKeyRequired: async (provider: string) => { - return await ApiKeyPromptDialog.prompt(provider); - }, - toolsFactory: (_agent, _agentInterface, _artifactsPanel, runtimeProvidersFactory) => { - // Create javascript_repl tool with access to attachments + artifacts - const replTool = createJavaScriptReplTool(); - replTool.runtimeProvidersFactory = runtimeProvidersFactory; - return [replTool]; - }, - }); -}; - -const loadSession = async (sessionId: string): Promise => { - if (!storage.sessions) return false; - - const sessionData = await storage.sessions.get(sessionId); - if (!sessionData) { - console.error("Session not found:", sessionId); - return false; - } - - currentSessionId = sessionId; - const metadata = await storage.sessions.getMetadata(sessionId); - currentTitle = metadata?.title || ""; - - await createAgent({ - model: sessionData.model, - thinkingLevel: sessionData.thinkingLevel, - messages: sessionData.messages, - tools: [], - }); - - updateUrl(sessionId); - renderApp(); - return true; -}; - -const newSession = () => { - const url = new URL(window.location.href); - url.search = ""; - window.location.href = url.toString(); -}; - -// ============================================================================ -// RENDER -// ============================================================================ -const renderApp = () => { - const app = document.getElementById("app"); - if (!app) return; - - const appHtml = html` -
- -
-
- ${Button({ - variant: "ghost", - size: "sm", - children: icon(History, "sm"), - onClick: () => { - SessionListDialog.open( - async (sessionId) => { - await loadSession(sessionId); - }, - (deletedSessionId) => { - // Only reload if the current session was deleted - if (deletedSessionId === currentSessionId) { - newSession(); - } - }, - ); - }, - title: "Sessions", - })} - ${Button({ - variant: "ghost", - size: "sm", - children: icon(Plus, "sm"), - onClick: newSession, - title: "New Session", - })} - - ${ - currentTitle - ? isEditingTitle - ? html`
- ${Input({ - type: "text", - value: currentTitle, - className: "text-sm w-64", - onChange: async (e: Event) => { - const newTitle = (e.target as HTMLInputElement).value.trim(); - if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { - await storage.sessions.updateTitle(currentSessionId, newTitle); - currentTitle = newTitle; - } - isEditingTitle = false; - renderApp(); - }, - onKeyDown: async (e: KeyboardEvent) => { - if (e.key === "Enter") { - const newTitle = (e.target as HTMLInputElement).value.trim(); - if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { - await storage.sessions.updateTitle(currentSessionId, newTitle); - currentTitle = newTitle; - } - isEditingTitle = false; - renderApp(); - } else if (e.key === "Escape") { - isEditingTitle = false; - renderApp(); - } - }, - })} -
` - : html`` - : html`Pi Web UI Example` - } -
-
- ${Button({ - variant: "ghost", - size: "sm", - children: icon(Bell, "sm"), - onClick: () => { - // Demo: Inject custom message (will appear on next agent run) - if (agent) { - agent.steer( - createSystemNotification( - "This is a custom message! It appears in the UI but is never sent to the LLM.", - ), - ); - } - }, - title: "Demo: Add Custom Notification", - })} - - ${Button({ - variant: "ghost", - size: "sm", - children: icon(Settings, "sm"), - onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), - title: "Settings", - })} -
-
- - - ${chatPanel} -
- `; - - render(appHtml, app); -}; - -// ============================================================================ -// INIT -// ============================================================================ -async function initApp() { - const app = document.getElementById("app"); - if (!app) throw new Error("App container not found"); - - // Show loading - render( - html` -
-
Loading...
-
- `, - app, - ); - - // TODO: Fix PersistentStorageDialog - currently broken - // Request persistent storage - // if (storage.sessions) { - // await PersistentStorageDialog.request(); - // } - - // Create ChatPanel - chatPanel = new ChatPanel(); - - // Check for session in URL - const urlParams = new URLSearchParams(window.location.search); - const sessionIdFromUrl = urlParams.get("session"); - - if (sessionIdFromUrl) { - const loaded = await loadSession(sessionIdFromUrl); - if (!loaded) { - // Session doesn't exist, redirect to new session - newSession(); - return; - } - } else { - await createAgent(); - } - - renderApp(); -} - -initApp(); diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json deleted file mode 100644 index 9e8c4333..00000000 --- a/packages/web-ui/example/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "moduleResolution": "bundler", - "paths": { - "*": ["./*"], - "@earendil-works/pi-agent-core": ["../../agent/dist/index.d.ts"], - "@earendil-works/pi-ai": ["../../ai/dist/index.d.ts"], - "@earendil-works/pi-tui": ["../../tui/dist/index.d.ts"], - "@earendil-works/pi-web-ui": ["../dist/index.d.ts"] - }, - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "useDefineForClassFields": false - }, - "include": ["src/**/*"], - "exclude": ["../src"] -} diff --git a/packages/web-ui/example/vite.config.ts b/packages/web-ui/example/vite.config.ts deleted file mode 100644 index 6550357b..00000000 --- a/packages/web-ui/example/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [tailwindcss()], -}); diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json deleted file mode 100644 index e22b6a72..00000000 --- a/packages/web-ui/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@earendil-works/pi-web-ui", - "version": "0.74.0", - "description": "Reusable web UI components for AI chat interfaces powered by @earendil-works/pi-ai", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": "./dist/index.js", - "./app.css": "./dist/app.css" - }, - "scripts": { - "clean": "shx rm -rf dist", - "build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", - "dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"", - "dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"", - "check": "biome check --write --error-on-warnings . && tsc --noEmit && cd example && biome check --write --error-on-warnings . && tsc --noEmit" - }, - "dependencies": { - "@lmstudio/sdk": "^1.5.0", - "@earendil-works/pi-ai": "^0.74.0", - "@earendil-works/pi-tui": "^0.74.0", - "typebox": "^1.1.24", - "docx-preview": "^0.3.7", - "jszip": "^3.10.1", - "lucide": "^0.544.0", - "ollama": "^0.6.0", - "pdfjs-dist": "5.4.394", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" - }, - "peerDependencies": { - "@mariozechner/mini-lit": "^0.2.0", - "lit": "^3.3.1" - }, - "devDependencies": { - "@mariozechner/mini-lit": "^0.2.0", - "@tailwindcss/cli": "^4.0.0-beta.14", - "concurrently": "^9.2.1", - "typescript": "^5.7.3" - }, - "keywords": [ - "ai", - "chat", - "ui", - "components", - "llm", - "web-components", - "mini-lit" - ], - "author": "Mario Zechner", - "license": "MIT" -} diff --git a/packages/web-ui/scripts/count-prompt-tokens.ts b/packages/web-ui/scripts/count-prompt-tokens.ts deleted file mode 100644 index 04c01bb0..00000000 --- a/packages/web-ui/scripts/count-prompt-tokens.ts +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env tsx -/** - * Count tokens in system prompts using Anthropic's token counter API - */ - -import * as prompts from "../src/prompts/prompts.js"; - -const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - -if (!ANTHROPIC_API_KEY) { - console.error("Error: ANTHROPIC_API_KEY environment variable not set"); - process.exit(1); -} - -interface TokenCountResponse { - input_tokens: number; -} - -async function countTokens(text: string): Promise { - const response = await fetch("https://api.anthropic.com/v1/messages/count_tokens", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": ANTHROPIC_API_KEY, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: "claude-3-5-sonnet-20241022", - messages: [ - { - role: "user", - content: text, - }, - ], - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`API error: ${response.status} ${error}`); - } - - const data = (await response.json()) as TokenCountResponse; - return data.input_tokens; -} - -async function main() { - console.log("Counting tokens in prompts...\n"); - - const promptsToCount: Array<{ name: string; content: string }> = [ - { - name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW", - content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW, - }, - { - name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO", - content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, - }, - { - name: "ATTACHMENTS_RUNTIME_DESCRIPTION", - content: prompts.ATTACHMENTS_RUNTIME_DESCRIPTION, - }, - { - name: "JAVASCRIPT_REPL_TOOL_DESCRIPTION (without runtime providers)", - content: prompts.JAVASCRIPT_REPL_TOOL_DESCRIPTION([]), - }, - { - name: "ARTIFACTS_TOOL_DESCRIPTION (without runtime providers)", - content: prompts.ARTIFACTS_TOOL_DESCRIPTION([]), - }, - ]; - - let total = 0; - - for (const prompt of promptsToCount) { - try { - const tokens = await countTokens(prompt.content); - total += tokens; - console.log(`${prompt.name}: ${tokens.toLocaleString()} tokens`); - } catch (error) { - console.error(`Error counting tokens for ${prompt.name}:`, error); - } - } - - console.log(`\nTotal: ${total.toLocaleString()} tokens`); -} - -main(); diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts deleted file mode 100644 index 0a7a4823..00000000 --- a/packages/web-ui/src/ChatPanel.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; -import { html, LitElement } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import "./components/AgentInterface.js"; -import type { Agent, AgentTool } from "@earendil-works/pi-agent-core"; -import type { AgentInterface } from "./components/AgentInterface.js"; -import { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js"; -import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js"; -import type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js"; -import { ArtifactsPanel, ArtifactsToolRenderer } from "./tools/artifacts/index.js"; -import { registerToolRenderer } from "./tools/renderer-registry.js"; -import type { Attachment } from "./utils/attachment-utils.js"; -import { i18n } from "./utils/i18n.js"; - -const BREAKPOINT = 800; // px - switch between overlay and side-by-side - -@customElement("pi-chat-panel") -export class ChatPanel extends LitElement { - @state() public agent?: Agent; - @state() public agentInterface?: AgentInterface; - @state() public artifactsPanel?: ArtifactsPanel; - @state() private hasArtifacts = false; - @state() private artifactCount = 0; - @state() private showArtifactsPanel = false; - @state() private windowWidth = 0; - - private resizeHandler = () => { - this.windowWidth = window.innerWidth; - this.requestUpdate(); - }; - - createRenderRoot() { - return this; - } - - override connectedCallback() { - super.connectedCallback(); - this.windowWidth = window.innerWidth; // Set initial width after connection - window.addEventListener("resize", this.resizeHandler); - this.style.display = "flex"; - this.style.flexDirection = "column"; - this.style.height = "100%"; - this.style.minHeight = "0"; - // Update width after initial render - requestAnimationFrame(() => { - this.windowWidth = window.innerWidth; - this.requestUpdate(); - }); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("resize", this.resizeHandler); - } - - async setAgent( - agent: Agent, - config?: { - onApiKeyRequired?: (provider: string) => Promise; - onBeforeSend?: () => void | Promise; - onCostClick?: () => void; - onModelSelect?: () => void; - sandboxUrlProvider?: () => string; - toolsFactory?: ( - agent: Agent, - agentInterface: AgentInterface, - artifactsPanel: ArtifactsPanel, - runtimeProvidersFactory: () => SandboxRuntimeProvider[], - ) => AgentTool[]; - }, - ) { - this.agent = agent; - - // Create AgentInterface - this.agentInterface = document.createElement("agent-interface") as AgentInterface; - this.agentInterface.session = agent; - this.agentInterface.enableAttachments = true; - this.agentInterface.enableModelSelector = true; - this.agentInterface.enableThinkingSelector = true; - this.agentInterface.showThemeToggle = false; - this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired; - this.agentInterface.onModelSelect = config?.onModelSelect; - this.agentInterface.onBeforeSend = config?.onBeforeSend; - this.agentInterface.onCostClick = config?.onCostClick; - - // Set up artifacts panel - this.artifactsPanel = new ArtifactsPanel(); - this.artifactsPanel.agent = agent; // Pass agent for HTML artifact runtime providers - if (config?.sandboxUrlProvider) { - this.artifactsPanel.sandboxUrlProvider = config.sandboxUrlProvider; - } - // Register the standalone tool renderer (not the panel itself) - registerToolRenderer("artifacts", new ArtifactsToolRenderer(this.artifactsPanel)); - - // Runtime providers factory for REPL tools (read-write access) - const runtimeProvidersFactory = () => { - const attachments: Attachment[] = []; - for (const message of this.agent!.state.messages) { - if (message.role === "user-with-attachments") { - message.attachments?.forEach((a) => { - attachments.push(a); - }); - } - } - const providers: SandboxRuntimeProvider[] = []; - - // Add attachments provider if there are attachments - if (attachments.length > 0) { - providers.push(new AttachmentsRuntimeProvider(attachments)); - } - - // Add artifacts provider with read-write access (for REPL) - providers.push(new ArtifactsRuntimeProvider(this.artifactsPanel!, this.agent!, true)); - - return providers; - }; - - this.artifactsPanel.onArtifactsChange = () => { - const count = this.artifactsPanel?.artifacts?.size ?? 0; - const created = count > this.artifactCount; - this.hasArtifacts = count > 0; - this.artifactCount = count; - if (this.hasArtifacts && created) { - this.showArtifactsPanel = true; - } - this.requestUpdate(); - }; - - this.artifactsPanel.onClose = () => { - this.showArtifactsPanel = false; - this.requestUpdate(); - }; - - this.artifactsPanel.onOpen = () => { - this.showArtifactsPanel = true; - this.requestUpdate(); - }; - - // Set tools on the agent - // Pass runtimeProvidersFactory so consumers can configure their own REPL tools - const additionalTools = - config?.toolsFactory?.(agent, this.agentInterface, this.artifactsPanel, runtimeProvidersFactory) || []; - const tools = [this.artifactsPanel.tool, ...additionalTools]; - this.agent.state.tools = tools; - - // Reconstruct artifacts from existing messages - // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load - const originalCallback = this.artifactsPanel.onArtifactsChange; - this.artifactsPanel.onArtifactsChange = undefined; - await this.artifactsPanel.reconstructFromMessages(this.agent.state.messages); - this.artifactsPanel.onArtifactsChange = originalCallback; - - this.hasArtifacts = this.artifactsPanel.artifacts.size > 0; - this.artifactCount = this.artifactsPanel.artifacts.size; - - this.requestUpdate(); - } - - render() { - if (!this.agent || !this.agentInterface) { - return html`
-
No agent set
-
`; - } - - const isMobile = this.windowWidth < BREAKPOINT; - - // Set panel props - if (this.artifactsPanel) { - this.artifactsPanel.collapsed = !this.showArtifactsPanel; - this.artifactsPanel.overlay = isMobile; - } - - return html` -
-
- ${this.agentInterface} -
- - - ${ - this.hasArtifacts && !this.showArtifactsPanel - ? html` - - ` - : "" - } - -
- ${this.artifactsPanel} -
-
- `; - } -} diff --git a/packages/web-ui/src/app.css b/packages/web-ui/src/app.css deleted file mode 100644 index c8ddc30d..00000000 --- a/packages/web-ui/src/app.css +++ /dev/null @@ -1,68 +0,0 @@ -/* Import Claude theme from mini-lit */ -@import "@mariozechner/mini-lit/styles/themes/default.css"; - -/* Tell Tailwind to scan mini-lit components */ -/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */ -@source "../../../node_modules/@mariozechner/mini-lit/dist"; - -/* Import Tailwind */ -/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */ -@import "tailwindcss"; - -body { - font-size: 16px; - -webkit-font-smoothing: antialiased; -} - -* { - scrollbar-width: thin; - scrollbar-color: var(--color-border) rgba(0, 0, 0, 0); -} - -*::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -*::-webkit-scrollbar-track { - background: transparent; -} - -*::-webkit-scrollbar-thumb { - background-color: var(--color-border); - border-radius: 4px; -} - -*::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0); -} - -/* Fix cursor for dialog close buttons */ -.fixed.inset-0 button[aria-label*="Close"], -.fixed.inset-0 button[type="button"] { - cursor: pointer; -} - -/* Shimmer animation for thinking text */ -@keyframes shimmer { - 0% { - background-position: -200% 0; - } - 100% { - background-position: 200% 0; - } -} - -.animate-shimmer { - animation: shimmer 2s ease-in-out infinite; -} - -/* User message with fancy pill styling */ -.user-message-container { - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - background: linear-gradient(135deg, rgba(217, 79, 0, 0.12), rgba(255, 107, 0, 0.12), rgba(212, 165, 0, 0.12)); - border: 1px solid rgba(255, 107, 0, 0.25); - backdrop-filter: blur(10px); - max-width: 100%; -} diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts deleted file mode 100644 index 0a536e96..00000000 --- a/packages/web-ui/src/components/AgentInterface.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { streamSimple, type ToolResultMessage, type Usage } from "@earendil-works/pi-ai"; -import { html, LitElement } from "lit"; -import { customElement, property, query } from "lit/decorators.js"; -import { ModelSelector } from "../dialogs/ModelSelector.js"; -import type { MessageEditor } from "./MessageEditor.js"; -import "./MessageEditor.js"; -import "./MessageList.js"; -import "./Messages.js"; // Import for side effects to register the custom elements -import { getAppStorage } from "../storage/app-storage.js"; -import "./StreamingMessageContainer.js"; -import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core"; -import type { Attachment } from "../utils/attachment-utils.js"; -import { formatUsage } from "../utils/format.js"; -import { i18n } from "../utils/i18n.js"; -import { createStreamFn } from "../utils/proxy-utils.js"; -import type { UserMessageWithAttachments } from "./Messages.js"; -import type { StreamingMessageContainer } from "./StreamingMessageContainer.js"; - -@customElement("agent-interface") -export class AgentInterface extends LitElement { - // Optional external session: when provided, this component becomes a view over the session - @property({ attribute: false }) session?: Agent; - @property({ type: Boolean }) enableAttachments = true; - @property({ type: Boolean }) enableModelSelector = true; - @property({ type: Boolean }) enableThinkingSelector = true; - @property({ type: Boolean }) showThemeToggle = false; - // Optional custom API key prompt handler - if not provided, uses default dialog - @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise; - // Optional callback called before sending a message - @property({ attribute: false }) onBeforeSend?: () => void | Promise; - // Optional callback called before executing a tool call - return false to prevent execution - @property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise; - // Optional callback called when cost display is clicked - @property({ attribute: false }) onCostClick?: () => void; - // Optional callback to override model selector behavior - @property({ attribute: false }) onModelSelect?: () => void; - - // References - @query("message-editor") private _messageEditor!: MessageEditor; - @query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer; - - private _autoScroll = true; - private _lastScrollTop = 0; - private _lastClientHeight = 0; - private _scrollContainer?: HTMLElement; - private _resizeObserver?: ResizeObserver; - private _unsubscribeSession?: () => void; - - public setInput(text: string, attachments?: Attachment[]) { - const update = () => { - if (!this._messageEditor) requestAnimationFrame(update); - else { - this._messageEditor.value = text; - this._messageEditor.attachments = attachments || []; - } - }; - update(); - } - - public setAutoScroll(enabled: boolean) { - this._autoScroll = enabled; - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override willUpdate(changedProperties: Map) { - super.willUpdate(changedProperties); - - // Re-subscribe when session property changes - if (changedProperties.has("session")) { - this.setupSessionSubscription(); - } - } - - override async connectedCallback() { - super.connectedCallback(); - - this.style.display = "flex"; - this.style.flexDirection = "column"; - this.style.height = "100%"; - this.style.minHeight = "0"; - - // Wait for first render to get scroll container - await this.updateComplete; - this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement; - - if (this._scrollContainer) { - // Set up ResizeObserver to detect content changes - this._resizeObserver = new ResizeObserver(() => { - if (this._autoScroll && this._scrollContainer) { - this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight; - } - }); - - // Observe the content container inside the scroll container - const contentContainer = this._scrollContainer.querySelector(".max-w-3xl"); - if (contentContainer) { - this._resizeObserver.observe(contentContainer); - } - - // Set up scroll listener with better detection - this._scrollContainer.addEventListener("scroll", this._handleScroll); - } - - // Subscribe to external session if provided - this.setupSessionSubscription(); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - - // Clean up observers and listeners - if (this._resizeObserver) { - this._resizeObserver.disconnect(); - this._resizeObserver = undefined; - } - - if (this._scrollContainer) { - this._scrollContainer.removeEventListener("scroll", this._handleScroll); - } - - if (this._unsubscribeSession) { - this._unsubscribeSession(); - this._unsubscribeSession = undefined; - } - } - - private setupSessionSubscription() { - if (this._unsubscribeSession) { - this._unsubscribeSession(); - this._unsubscribeSession = undefined; - } - if (!this.session) return; - - // Set default streamFn with proxy support if not already set - if (this.session.streamFn === streamSimple) { - this.session.streamFn = createStreamFn(async () => { - const enabled = await getAppStorage().settings.get("proxy.enabled"); - return enabled ? (await getAppStorage().settings.get("proxy.url")) || undefined : undefined; - }); - } - - // Set default getApiKey if not already set - if (!this.session.getApiKey) { - this.session.getApiKey = async (provider: string) => { - const key = await getAppStorage().providerKeys.get(provider); - return key ?? undefined; - }; - } - - this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { - switch (ev.type) { - case "message_start": - case "turn_start": - case "turn_end": - case "agent_start": - this.requestUpdate(); - break; - case "message_end": - // Clear streaming container when a message completes - // to prevent duplicate rendering (stable list now has this message) - if (this._streamingContainer) { - this._streamingContainer.setMessage(null, true); - } - this.requestUpdate(); - break; - case "agent_end": - // Clear streaming container when agent finishes - if (this._streamingContainer) { - this._streamingContainer.isStreaming = false; - this._streamingContainer.setMessage(null, true); - } - this.requestUpdate(); - break; - case "message_update": - if (this._streamingContainer) { - const isStreaming = this.session?.state.isStreaming || false; - this._streamingContainer.isStreaming = isStreaming; - this._streamingContainer.setMessage(ev.message, !isStreaming); - } - this.requestUpdate(); - break; - } - }); - } - - private _handleScroll = (_ev: any) => { - if (!this._scrollContainer) return; - - const currentScrollTop = this._scrollContainer.scrollTop; - const scrollHeight = this._scrollContainer.scrollHeight; - const clientHeight = this._scrollContainer.clientHeight; - const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight; - - // Ignore relayout due to message editor getting pushed up by stats - if (clientHeight < this._lastClientHeight) { - this._lastClientHeight = clientHeight; - return; - } - - // Only disable auto-scroll if user scrolled UP or is far from bottom - if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) { - this._autoScroll = false; - } else if (distanceFromBottom < 10) { - // Re-enable if very close to bottom - this._autoScroll = true; - } - - this._lastScrollTop = currentScrollTop; - this._lastClientHeight = clientHeight; - }; - - public async sendMessage(input: string, attachments?: Attachment[]) { - if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return; - const session = this.session; - if (!session) throw new Error("No session set on AgentInterface"); - if (!session.state.model) throw new Error("No model set on AgentInterface"); - - // Check if API key exists for the provider (only needed in direct mode) - const provider = session.state.model.provider; - const apiKey = await getAppStorage().providerKeys.get(provider); - - // If no API key, prompt for it - if (!apiKey) { - if (!this.onApiKeyRequired) { - console.error("No API key configured and no onApiKeyRequired handler set"); - return; - } - - const success = await this.onApiKeyRequired(provider); - - // If still no API key, abort the send - if (!success) { - return; - } - } - - // Call onBeforeSend hook before sending - if (this.onBeforeSend) { - await this.onBeforeSend(); - } - - // Only clear editor after we know we can send - this._messageEditor.value = ""; - this._messageEditor.attachments = []; - this._autoScroll = true; // Enable auto-scroll when sending a message - - // Compose message with attachments if any - if (attachments && attachments.length > 0) { - const message: UserMessageWithAttachments = { - role: "user-with-attachments", - content: input, - attachments, - timestamp: Date.now(), - }; - await this.session?.prompt(message); - } else { - await this.session?.prompt(input); - } - } - - private renderMessages() { - if (!this.session) - return html`
${i18n("No session available")}
`; - const state = this.session.state; - // Build a map of tool results to allow inline rendering in assistant messages - const toolResultsById = new Map>(); - for (const message of state.messages) { - if (message.role === "toolResult") { - toolResultsById.set(message.toolCallId, message); - } - } - return html` -
- - ()} - .isStreaming=${state.isStreaming} - .onCostClick=${this.onCostClick} - > - - - -
- `; - } - - private renderStats() { - if (!this.session) return html`
`; - - const state = this.session.state; - const totals = state.messages - .filter((m) => m.role === "assistant") - .reduce( - (acc, msg: any) => { - const usage = msg.usage; - if (usage) { - acc.input += usage.input; - acc.output += usage.output; - acc.cacheRead += usage.cacheRead; - acc.cacheWrite += usage.cacheWrite; - acc.cost.total += usage.cost.total; - } - return acc; - }, - { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - } satisfies Usage, - ); - - const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite; - const totalsText = hasTotals ? formatUsage(totals) : ""; - - return html` -
-
- ${this.showThemeToggle ? html`` : html``} -
-
- ${ - totalsText - ? this.onCostClick - ? html`${totalsText}` - : html`${totalsText}` - : "" - } -
-
- `; - } - - override render() { - if (!this.session) - return html`
${i18n("No session set")}
`; - - const session = this.session; - const state = this.session.state; - return html` -
- -
-
${this.renderMessages()}
-
- - -
-
- { - this.sendMessage(input, attachments); - }} - .onAbort=${() => session.abort()} - .onModelSelect=${() => { - if (this.onModelSelect) { - this.onModelSelect(); - } else { - ModelSelector.open(state.model, (model) => { - session.state.model = model; - }); - } - }} - .onThinkingChange=${ - this.enableThinkingSelector - ? (level: "off" | "minimal" | "low" | "medium" | "high") => { - session.state.thinkingLevel = level; - } - : undefined - } - > - ${this.renderStats()} -
-
-
- `; - } -} - -// Register custom element with guard -if (!customElements.get("agent-interface")) { - customElements.define("agent-interface", AgentInterface); -} diff --git a/packages/web-ui/src/components/AttachmentTile.ts b/packages/web-ui/src/components/AttachmentTile.ts deleted file mode 100644 index 823001d3..00000000 --- a/packages/web-ui/src/components/AttachmentTile.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { icon } from "@mariozechner/mini-lit/dist/icons.js"; -import { LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { html } from "lit/html.js"; -import { FileSpreadsheet, FileText, X } from "lucide"; -import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js"; -import type { Attachment } from "../utils/attachment-utils.js"; -import { i18n } from "../utils/i18n.js"; - -@customElement("attachment-tile") -export class AttachmentTile extends LitElement { - @property({ type: Object }) attachment!: Attachment; - @property({ type: Boolean }) showDelete = false; - @property() onDelete?: () => void; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.classList.add("max-h-16"); - } - - private handleClick = () => { - AttachmentOverlay.open(this.attachment); - }; - - override render() { - const hasPreview = !!this.attachment.preview; - const isImage = this.attachment.type === "image"; - const isPdf = this.attachment.mimeType === "application/pdf"; - const isExcel = - this.attachment.mimeType?.includes("spreadsheetml") || - this.attachment.fileName.toLowerCase().endsWith(".xlsx") || - this.attachment.fileName.toLowerCase().endsWith(".xls"); - - // Choose the appropriate icon - const getDocumentIcon = () => { - if (isExcel) return icon(FileSpreadsheet, "md"); - return icon(FileText, "md"); - }; - - return html` -
- ${ - hasPreview - ? html` -
- ${this.attachment.fileName} - ${ - isPdf - ? html` - -
-
${i18n("PDF")}
-
- ` - : "" - } -
- ` - : html` - -
- ${getDocumentIcon()} -
- ${ - this.attachment.fileName.length > 10 - ? `${this.attachment.fileName.substring(0, 8)}...` - : this.attachment.fileName - } -
-
- ` - } - ${ - this.showDelete - ? html` - - ` - : "" - } -
- `; - } -} diff --git a/packages/web-ui/src/components/ConsoleBlock.ts b/packages/web-ui/src/components/ConsoleBlock.ts deleted file mode 100644 index 66f38b0b..00000000 --- a/packages/web-ui/src/components/ConsoleBlock.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import { LitElement } from "lit"; -import { property, state } from "lit/decorators.js"; -import { html } from "lit/html.js"; -import { Check, Copy } from "lucide"; -import { i18n } from "../utils/i18n.js"; - -export class ConsoleBlock extends LitElement { - @property() content: string = ""; - @property() variant: "default" | "error" = "default"; - @state() private copied = false; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - private async copy() { - try { - await navigator.clipboard.writeText(this.content || ""); - this.copied = true; - setTimeout(() => { - this.copied = false; - }, 1500); - } catch (e) { - console.error("Copy failed", e); - } - } - - override updated() { - // Auto-scroll to bottom on content changes - const container = this.querySelector(".console-scroll") as HTMLElement | null; - if (container) { - container.scrollTop = container.scrollHeight; - } - } - - override render() { - const isError = this.variant === "error"; - const textClass = isError ? "text-destructive" : "text-foreground"; - - return html` -
-
- ${i18n("console")} - -
-
-
-${this.content || ""}
-
-
- `; - } -} - -// Register custom element -if (!customElements.get("console-block")) { - customElements.define("console-block", ConsoleBlock); -} diff --git a/packages/web-ui/src/components/CustomProviderCard.ts b/packages/web-ui/src/components/CustomProviderCard.ts deleted file mode 100644 index 1050b855..00000000 --- a/packages/web-ui/src/components/CustomProviderCard.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { i18n } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import type { CustomProvider } from "../storage/stores/custom-providers-store.js"; - -@customElement("custom-provider-card") -export class CustomProviderCard extends LitElement { - @property({ type: Object }) provider!: CustomProvider; - @property({ type: Boolean }) isAutoDiscovery = false; - @property({ type: Object }) status?: { modelCount: number; status: "connected" | "disconnected" | "checking" }; - @property() onRefresh?: (provider: CustomProvider) => void; - @property() onEdit?: (provider: CustomProvider) => void; - @property() onDelete?: (provider: CustomProvider) => void; - - protected createRenderRoot() { - return this; - } - - private renderStatus(): TemplateResult { - if (!this.isAutoDiscovery) { - return html` -
- ${i18n("Models")}: ${this.provider.models?.length || 0} -
- `; - } - - if (!this.status) return html``; - - const statusIcon = - this.status.status === "connected" - ? html`` - : this.status.status === "checking" - ? html`` - : html``; - - const statusText = - this.status.status === "connected" - ? `${this.status.modelCount} ${i18n("models")}` - : this.status.status === "checking" - ? i18n("Checking...") - : i18n("Disconnected"); - - return html` -
- ${statusIcon} ${statusText} -
- `; - } - - render(): TemplateResult { - return html` -
-
-
-
${this.provider.name}
-
- ${this.provider.type} - ${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""} -
- ${this.renderStatus()} -
-
- ${ - this.isAutoDiscovery && this.onRefresh - ? Button({ - onClick: () => this.onRefresh?.(this.provider), - variant: "ghost", - size: "sm", - children: i18n("Refresh"), - }) - : "" - } - ${ - this.onEdit - ? Button({ - onClick: () => this.onEdit?.(this.provider), - variant: "ghost", - size: "sm", - children: i18n("Edit"), - }) - : "" - } - ${ - this.onDelete - ? Button({ - onClick: () => this.onDelete?.(this.provider), - variant: "ghost", - size: "sm", - children: i18n("Delete"), - }) - : "" - } -
-
-
- `; - } -} diff --git a/packages/web-ui/src/components/ExpandableSection.ts b/packages/web-ui/src/components/ExpandableSection.ts deleted file mode 100644 index b032af9f..00000000 --- a/packages/web-ui/src/components/ExpandableSection.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ChevronDown, ChevronRight } from "lucide"; - -/** - * Reusable expandable section component for tool renderers. - * Captures children in connectedCallback and re-renders them in the details area. - */ -@customElement("expandable-section") -export class ExpandableSection extends LitElement { - @property() summary!: string; - @property({ type: Boolean }) defaultExpanded = false; - @state() private expanded = false; - private capturedChildren: Node[] = []; - - protected createRenderRoot() { - return this; // light DOM - } - - override connectedCallback() { - super.connectedCallback(); - // Capture children before first render - this.capturedChildren = Array.from(this.childNodes); - // Clear children (we'll re-insert them in render) - this.innerHTML = ""; - this.expanded = this.defaultExpanded; - } - - override render(): TemplateResult { - return html` -
- - ${this.expanded ? html`
${this.capturedChildren}
` : ""} -
- `; - } -} diff --git a/packages/web-ui/src/components/Input.ts b/packages/web-ui/src/components/Input.ts deleted file mode 100644 index 112e0813..00000000 --- a/packages/web-ui/src/components/Input.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type BaseComponentProps, fc } from "@mariozechner/mini-lit/dist/mini.js"; -import { html } from "lit"; -import { type Ref, ref } from "lit/directives/ref.js"; -import { i18n } from "../utils/i18n.js"; - -export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search"; -export type InputSize = "sm" | "md" | "lg"; - -export interface InputProps extends BaseComponentProps { - type?: InputType; - size?: InputSize; - value?: string; - placeholder?: string; - label?: string; - error?: string; - disabled?: boolean; - required?: boolean; - name?: string; - autocomplete?: string; - min?: number; - max?: number; - step?: number; - inputRef?: Ref; - onInput?: (e: Event) => void; - onChange?: (e: Event) => void; - onKeyDown?: (e: KeyboardEvent) => void; - onKeyUp?: (e: KeyboardEvent) => void; -} - -export const Input = fc( - ({ - type = "text", - size = "md", - value = "", - placeholder = "", - label = "", - error = "", - disabled = false, - required = false, - name = "", - autocomplete = "", - min, - max, - step, - inputRef, - onInput, - onChange, - onKeyDown, - onKeyUp, - className = "", - }) => { - const sizeClasses = { - sm: "h-8 px-3 py-1 text-sm", - md: "h-9 px-3 py-1 text-sm md:text-sm", - lg: "h-10 px-4 py-1 text-base", - }; - - const baseClasses = - "flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium"; - const interactionClasses = - "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground"; - const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"; - const darkClasses = "dark:bg-input/30"; - const stateClasses = error - ? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" - : "border-input"; - const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"; - - const handleInput = (e: Event) => { - onInput?.(e); - }; - - const handleChange = (e: Event) => { - onChange?.(e); - }; - - return html` -
- ${ - label - ? html` - - ` - : "" - } - - ${error ? html`${error}` : ""} -
- `; - }, -); diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts deleted file mode 100644 index e50d9401..00000000 --- a/packages/web-ui/src/components/MessageEditor.ts +++ /dev/null @@ -1,405 +0,0 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { icon } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { Select, type SelectOption } from "@mariozechner/mini-lit/dist/Select.js"; -import { html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { createRef, ref } from "lit/directives/ref.js"; -import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide"; -import { type Attachment, loadAttachment } from "../utils/attachment-utils.js"; -import { i18n } from "../utils/i18n.js"; -import "./AttachmentTile.js"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; - -@customElement("message-editor") -export class MessageEditor extends LitElement { - private _value = ""; - private textareaRef = createRef(); - - @property() - get value() { - return this._value; - } - - set value(val: string) { - const oldValue = this._value; - this._value = val; - this.requestUpdate("value", oldValue); - } - - @property() isStreaming = false; - @property() currentModel?: Model; - @property() thinkingLevel: ThinkingLevel = "off"; - @property() showAttachmentButton = true; - @property() showModelSelector = true; - @property() showThinkingSelector = true; - @property() onInput?: (value: string) => void; - @property() onSend?: (input: string, attachments: Attachment[]) => void; - @property() onAbort?: () => void; - @property() onModelSelect?: () => void; - @property() onThinkingChange?: (level: "off" | "minimal" | "low" | "medium" | "high") => void; - @property() onFilesChange?: (files: Attachment[]) => void; - @property() attachments: Attachment[] = []; - @property() maxFiles = 10; - @property() maxFileSize = 20 * 1024 * 1024; // 20MB - @property() acceptedTypes = - "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml"; - - @state() processingFiles = false; - @state() isDragging = false; - private fileInputRef = createRef(); - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - private handleTextareaInput = (e: Event) => { - const textarea = e.target as HTMLTextAreaElement; - this.value = textarea.value; - this.onInput?.(this.value); - }; - - private handleKeyDown = (e: KeyboardEvent) => { - // Ignore key events during IME composition (e.g. CJK input) - if (e.isComposing || e.key === "Process") return; - - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) { - this.handleSend(); - } - } else if (e.key === "Escape" && this.isStreaming) { - e.preventDefault(); - this.onAbort?.(); - } - }; - - private handlePaste = async (e: ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - const imageFiles: File[] = []; - - // Check for image items in clipboard - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.type.startsWith("image/")) { - const file = item.getAsFile(); - if (file) { - imageFiles.push(file); - } - } - } - - // If we found images, process them - if (imageFiles.length > 0) { - e.preventDefault(); // Prevent default paste behavior - - if (imageFiles.length + this.attachments.length > this.maxFiles) { - alert(`Maximum ${this.maxFiles} files allowed`); - return; - } - - this.processingFiles = true; - const newAttachments: Attachment[] = []; - - for (const file of imageFiles) { - try { - if (file.size > this.maxFileSize) { - alert(`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`); - continue; - } - - const attachment = await loadAttachment(file); - newAttachments.push(attachment); - } catch (error) { - console.error("Error processing pasted image:", error); - alert(`Failed to process pasted image: ${String(error)}`); - } - } - - this.attachments = [...this.attachments, ...newAttachments]; - this.onFilesChange?.(this.attachments); - this.processingFiles = false; - } - }; - - private handleSend = () => { - this.onSend?.(this.value, this.attachments); - }; - - private handleAttachmentClick = () => { - this.fileInputRef.value?.click(); - }; - - private async handleFilesSelected(e: Event) { - const input = e.target as HTMLInputElement; - const files = Array.from(input.files || []); - if (files.length === 0) return; - - if (files.length + this.attachments.length > this.maxFiles) { - alert(`Maximum ${this.maxFiles} files allowed`); - input.value = ""; - return; - } - - this.processingFiles = true; - const newAttachments: Attachment[] = []; - - for (const file of files) { - try { - if (file.size > this.maxFileSize) { - alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`); - continue; - } - - const attachment = await loadAttachment(file); - newAttachments.push(attachment); - } catch (error) { - console.error(`Error processing ${file.name}:`, error); - alert(`Failed to process ${file.name}: ${String(error)}`); - } - } - - this.attachments = [...this.attachments, ...newAttachments]; - this.onFilesChange?.(this.attachments); - this.processingFiles = false; - input.value = ""; // Reset input - } - - private removeFile(fileId: string) { - this.attachments = this.attachments.filter((f) => f.id !== fileId); - this.onFilesChange?.(this.attachments); - } - - private handleDragOver = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!this.isDragging) { - this.isDragging = true; - } - }; - - private handleDragLeave = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Only set isDragging to false if we're leaving the entire component - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = e.clientX; - const y = e.clientY; - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { - this.isDragging = false; - } - }; - - private handleDrop = async (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - this.isDragging = false; - - const files = Array.from(e.dataTransfer?.files || []); - if (files.length === 0) return; - - if (files.length + this.attachments.length > this.maxFiles) { - alert(`Maximum ${this.maxFiles} files allowed`); - return; - } - - this.processingFiles = true; - const newAttachments: Attachment[] = []; - - for (const file of files) { - try { - if (file.size > this.maxFileSize) { - alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`); - continue; - } - - const attachment = await loadAttachment(file); - newAttachments.push(attachment); - } catch (error) { - console.error(`Error processing ${file.name}:`, error); - alert(`Failed to process ${file.name}: ${String(error)}`); - } - } - - this.attachments = [...this.attachments, ...newAttachments]; - this.onFilesChange?.(this.attachments); - this.processingFiles = false; - }; - - override firstUpdated() { - const textarea = this.textareaRef.value; - if (textarea) { - textarea.focus(); - } - } - - override render() { - // Check if current model supports thinking/reasoning - const model = this.currentModel; - const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking - - return html` -
- - ${ - this.isDragging - ? html` -
-
${i18n("Drop files here")}
-
- ` - : "" - } - - - ${ - this.attachments.length > 0 - ? html` -
- ${this.attachments.map( - (attachment) => html` - this.removeFile(attachment.id)} - > - `, - )} -
- ` - : "" - } - - - - - - - -
- -
- ${ - this.showAttachmentButton - ? this.processingFiles - ? html` -
- ${icon(Loader2, "sm", "animate-spin text-muted-foreground")} -
- ` - : html` - ${Button({ - variant: "ghost", - size: "icon", - className: "h-8 w-8", - onClick: this.handleAttachmentClick, - children: icon(Paperclip, "sm"), - })} - ` - : "" - } - ${ - supportsThinking && this.showThinkingSelector - ? html` - ${Select({ - value: this.thinkingLevel, - placeholder: i18n("Off"), - options: [ - { value: "off", label: i18n("Off"), icon: icon(Brain, "sm") }, - { value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm") }, - { value: "low", label: i18n("Low"), icon: icon(Brain, "sm") }, - { value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm") }, - { value: "high", label: i18n("High"), icon: icon(Brain, "sm") }, - ] as SelectOption[], - onChange: (value: string) => { - const level = value as "off" | "minimal" | "low" | "medium" | "high"; - this.thinkingLevel = level; - this.onThinkingChange?.(level); - }, - width: "80px", - size: "sm", - variant: "ghost", - fitContent: true, - })} - ` - : "" - } -
- - -
- ${ - this.showModelSelector && this.currentModel - ? html` - ${Button({ - variant: "ghost", - size: "sm", - onClick: () => { - // Focus textarea before opening model selector so focus returns there - this.textareaRef.value?.focus(); - // Wait for next frame to ensure focus takes effect before dialog captures it - requestAnimationFrame(() => { - this.onModelSelect?.(); - }); - }, - children: html` - ${icon(Sparkles, "sm")} - ${this.currentModel.id} - `, - className: "h-8 text-xs truncate", - })} - ` - : "" - } - ${ - this.isStreaming - ? html` - ${Button({ - variant: "ghost", - size: "icon", - onClick: this.onAbort, - children: icon(Square, "sm"), - className: "h-8 w-8", - })} - ` - : html` - ${Button({ - variant: "ghost", - size: "icon", - onClick: this.handleSend, - disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles, - children: html`
${icon(Send, "sm")}
`, - className: "h-8 w-8", - })} - ` - } -
-
-
- `; - } -} diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts deleted file mode 100644 index 59cc6955..00000000 --- a/packages/web-ui/src/components/MessageList.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; -import type { - AssistantMessage as AssistantMessageType, - ToolResultMessage as ToolResultMessageType, -} from "@earendil-works/pi-ai"; -import { html, LitElement, type TemplateResult } from "lit"; -import { property } from "lit/decorators.js"; -import { repeat } from "lit/directives/repeat.js"; -import { renderMessage } from "./message-renderer-registry.js"; - -export class MessageList extends LitElement { - @property({ type: Array }) messages: AgentMessage[] = []; - @property({ type: Array }) tools: AgentTool[] = []; - @property({ type: Object }) pendingToolCalls?: ReadonlySet; - @property({ type: Boolean }) isStreaming: boolean = false; - @property({ attribute: false }) onCostClick?: () => void; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - private buildRenderItems() { - // Map tool results by call id for quick lookup - const resultByCallId = new Map(); - for (const message of this.messages) { - if (message.role === "toolResult") { - resultByCallId.set(message.toolCallId, message); - } - } - - const items: Array<{ key: string; template: TemplateResult }> = []; - let index = 0; - for (const msg of this.messages) { - // Skip artifact messages - they're for session persistence only, not UI display - if (msg.role === "artifact") { - continue; - } - - // Try custom renderer first - const customTemplate = renderMessage(msg); - if (customTemplate) { - items.push({ key: `msg:${index}`, template: customTemplate }); - index++; - continue; - } - - // Fall back to built-in renderers - if (msg.role === "user" || msg.role === "user-with-attachments") { - items.push({ - key: `msg:${index}`, - template: html``, - }); - index++; - } else if (msg.role === "assistant") { - const amsg = msg as AssistantMessageType; - items.push({ - key: `msg:${index}`, - template: html``, - }); - index++; - } else { - // Skip standalone toolResult messages; they are rendered via paired tool-message above - // Skip unknown roles - } - } - return items; - } - - override render() { - const items = this.buildRenderItems(); - return html`
- ${repeat( - items, - (it) => it.key, - (it) => it.template, - )} -
`; - } -} - -// Register custom element -if (!customElements.get("message-list")) { - customElements.define("message-list", MessageList); -} diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts deleted file mode 100644 index 16237543..00000000 --- a/packages/web-ui/src/components/Messages.ts +++ /dev/null @@ -1,383 +0,0 @@ -import type { - AssistantMessage as AssistantMessageType, - ImageContent, - TextContent, - ToolCall, - ToolResultMessage as ToolResultMessageType, - UserMessage as UserMessageType, -} from "@earendil-works/pi-ai"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { renderTool } from "../tools/index.js"; -import type { Attachment } from "../utils/attachment-utils.js"; -import { formatUsage } from "../utils/format.js"; -import { i18n } from "../utils/i18n.js"; -import "./ThinkingBlock.js"; -import type { AgentTool } from "@earendil-works/pi-agent-core"; - -export type UserMessageWithAttachments = { - role: "user-with-attachments"; - content: string | (TextContent | ImageContent)[]; - timestamp: number; - attachments?: Attachment[]; -}; - -// Artifact message type for session persistence -export interface ArtifactMessage { - role: "artifact"; - action: "create" | "update" | "delete"; - filename: string; - content?: string; - title?: string; - timestamp: string; -} - -declare module "@earendil-works/pi-agent-core" { - interface CustomAgentMessages { - "user-with-attachments": UserMessageWithAttachments; - artifact: ArtifactMessage; - } -} - -@customElement("user-message") -export class UserMessage extends LitElement { - @property({ type: Object }) message!: UserMessageWithAttachments | UserMessageType; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - override render() { - const content = - typeof this.message.content === "string" - ? this.message.content - : this.message.content.find((c) => c.type === "text")?.text || ""; - - return html` -
-
- - ${ - this.message.role === "user-with-attachments" && - this.message.attachments && - this.message.attachments.length > 0 - ? html` -
- ${this.message.attachments.map( - (attachment) => html` `, - )} -
- ` - : "" - } -
-
- `; - } -} - -@customElement("assistant-message") -export class AssistantMessage extends LitElement { - @property({ type: Object }) message!: AssistantMessageType; - @property({ type: Array }) tools?: AgentTool[]; - @property({ type: Object }) pendingToolCalls?: ReadonlySet; - @property({ type: Boolean }) hideToolCalls = false; - @property({ type: Object }) toolResultsById?: Map; - @property({ type: Boolean }) isStreaming: boolean = false; - @property({ type: Boolean }) hidePendingToolCalls = false; - @property({ attribute: false }) onCostClick?: () => void; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - override render() { - // Render content in the order it appears - const orderedParts: TemplateResult[] = []; - - for (const chunk of this.message.content) { - if (chunk.type === "text" && chunk.text.trim() !== "") { - orderedParts.push(html``); - } else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") { - orderedParts.push( - html``, - ); - } else if (chunk.type === "toolCall") { - if (!this.hideToolCalls) { - const tool = this.tools?.find((t) => t.name === chunk.name); - const pending = this.pendingToolCalls?.has(chunk.id) ?? false; - const result = this.toolResultsById?.get(chunk.id); - // Skip rendering pending tool calls when hidePendingToolCalls is true - // (used to prevent duplication when StreamingMessageContainer is showing them) - if (this.hidePendingToolCalls && pending && !result) { - continue; - } - // A tool call is aborted if the message was aborted and there's no result for this tool call - const aborted = this.message.stopReason === "aborted" && !result; - orderedParts.push( - html``, - ); - } - } - } - - return html` -
- ${orderedParts.length ? html`
${orderedParts}
` : ""} - ${ - this.message.usage && !this.isStreaming - ? this.onCostClick - ? html`
${formatUsage(this.message.usage)}
` - : html`
${formatUsage(this.message.usage)}
` - : "" - } - ${ - this.message.stopReason === "error" && this.message.errorMessage - ? html` -
- ${i18n("Error:")} ${this.message.errorMessage} -
- ` - : "" - } - ${ - this.message.stopReason === "aborted" - ? html`${i18n("Request aborted")}` - : "" - } -
- `; - } -} - -@customElement("tool-message-debug") -export class ToolMessageDebugView extends LitElement { - @property({ type: Object }) callArgs: any; - @property({ type: Object }) result?: ToolResultMessageType; - @property({ type: Boolean }) hasResult: boolean = false; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM for shared styles - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - private pretty(value: unknown): { content: string; isJson: boolean } { - try { - if (typeof value === "string") { - const maybeJson = JSON.parse(value); - return { content: JSON.stringify(maybeJson, null, 2), isJson: true }; - } - return { content: JSON.stringify(value, null, 2), isJson: true }; - } catch { - return { content: typeof value === "string" ? value : String(value), isJson: false }; - } - } - - override render() { - const textOutput = - this.result?.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - const output = this.pretty(textOutput); - const details = this.pretty(this.result?.details); - - return html` -
-
-
${i18n("Call")}
- -
-
-
${i18n("Result")}
- ${ - this.hasResult - ? html` - ` - : html`
${i18n("(no result)")}
` - } -
-
- `; - } -} - -@customElement("tool-message") -export class ToolMessage extends LitElement { - @property({ type: Object }) toolCall!: ToolCall; - @property({ type: Object }) tool?: AgentTool; - @property({ type: Object }) result?: ToolResultMessageType; - @property({ type: Boolean }) pending: boolean = false; - @property({ type: Boolean }) aborted: boolean = false; - @property({ type: Boolean }) isStreaming: boolean = false; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - override render() { - const toolName = this.tool?.name || this.toolCall.name; - - // Render tool content (renderer handles errors and styling) - const result: ToolResultMessageType | undefined = this.aborted - ? { - role: "toolResult", - isError: true, - content: [], - toolCallId: this.toolCall.id, - toolName: this.toolCall.name, - timestamp: Date.now(), - } - : this.result; - const renderResult = renderTool( - toolName, - this.toolCall.arguments, - result, - !this.aborted && (this.isStreaming || this.pending), - ); - - // Handle custom rendering (no card wrapper) - if (renderResult.isCustom) { - return renderResult.content; - } - - // Default: wrap in card - return html` -
- ${renderResult.content} -
- `; - } -} - -@customElement("aborted-message") -export class AbortedMessage extends LitElement { - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - protected override render(): unknown { - return html`${i18n("Request aborted")}`; - } -} - -// ============================================================================ -// Default Message Transformer -// ============================================================================ - -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Message } from "@earendil-works/pi-ai"; - -/** - * Convert attachments to content blocks for LLM. - * - Images become ImageContent blocks - * - Documents with extractedText become TextContent blocks with filename header - */ -export function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] { - const content: (TextContent | ImageContent)[] = []; - for (const attachment of attachments) { - if (attachment.type === "image") { - content.push({ - type: "image", - data: attachment.content, - mimeType: attachment.mimeType, - } as ImageContent); - } else if (attachment.type === "document" && attachment.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, - } as TextContent); - } - } - return content; -} - -/** - * Check if a message is a UserMessageWithAttachments. - */ -export function isUserMessageWithAttachments(msg: AgentMessage): msg is UserMessageWithAttachments { - return (msg as UserMessageWithAttachments).role === "user-with-attachments"; -} - -/** - * Check if a message is an ArtifactMessage. - */ -export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage { - return (msg as ArtifactMessage).role === "artifact"; -} - -/** - * Default convertToLlm for web-ui apps. - * - * Handles: - * - UserMessageWithAttachments: converts to user message with content blocks - * - ArtifactMessage: filtered out (UI-only, for session reconstruction) - * - Standard LLM messages (user, assistant, toolResult): passed through - */ -export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { - return messages - .filter((m) => { - // Filter out artifact messages - they're for session reconstruction only - if (isArtifactMessage(m)) { - return false; - } - return true; - }) - .map((m): Message | null => { - // Convert user-with-attachments to user message with content blocks - if (isUserMessageWithAttachments(m)) { - const textContent: (TextContent | ImageContent)[] = - typeof m.content === "string" ? [{ type: "text", text: m.content }] : [...m.content]; - - if (m.attachments) { - textContent.push(...convertAttachments(m.attachments)); - } - - return { - role: "user", - content: textContent, - timestamp: m.timestamp, - } as Message; - } - - // Pass through standard LLM roles - if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") { - return m as Message; - } - - // Filter out unknown message types - return null; - }) - .filter((m): m is Message => m !== null); -} diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts deleted file mode 100644 index c43d72d0..00000000 --- a/packages/web-ui/src/components/ProviderKeyInput.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { type Context, complete, getModel } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; -import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { getAppStorage } from "../storage/app-storage.js"; -import { applyProxyIfNeeded } from "../utils/proxy-utils.js"; -import { Input } from "./Input.js"; - -// Test models for each provider -const TEST_MODELS: Record = { - anthropic: "claude-haiku-4-5", - openai: "gpt-4o-mini", - google: "gemini-2.5-flash", - groq: "openai/gpt-oss-20b", - openrouter: "z-ai/glm-4.6", - "vercel-ai-gateway": "anthropic/claude-opus-4.5", - cerebras: "gpt-oss-120b", - xai: "grok-4-fast-non-reasoning", - zai: "glm-4.5-air", -}; - -@customElement("provider-key-input") -export class ProviderKeyInput extends LitElement { - @property() provider = ""; - @state() private keyInput = ""; - @state() private testing = false; - @state() private failed = false; - @state() private hasKey = false; - @state() private inputChanged = false; - - protected createRenderRoot() { - return this; - } - - override async connectedCallback() { - super.connectedCallback(); - await this.checkKeyStatus(); - } - - private async checkKeyStatus() { - try { - const key = await getAppStorage().providerKeys.get(this.provider); - this.hasKey = !!key; - } catch (error) { - console.error("Failed to check key status:", error); - } - } - - private async testApiKey(provider: string, apiKey: string): Promise { - try { - const modelId = TEST_MODELS[provider]; - // Returning true here for Ollama and friends. Can' know which model to use for testing - if (!modelId) return true; - - let model = getModel(provider as any, modelId); - if (!model) return false; - - // Get proxy URL from settings (if available) - const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); - const proxyUrl = await getAppStorage().settings.get("proxy.url"); - - // Apply proxy only if this provider/key combination requires it - model = applyProxyIfNeeded(model, apiKey, proxyEnabled ? proxyUrl || undefined : undefined); - - const context: Context = { - messages: [{ role: "user", content: "Reply with: ok", timestamp: Date.now() }], - }; - - const result = await complete(model, context, { - apiKey, - maxTokens: 200, - } as any); - - return result.stopReason === "stop"; - } catch (error) { - console.error(`API key test failed for ${provider}:`, error); - return false; - } - } - - private async saveKey() { - if (!this.keyInput) return; - - this.testing = true; - this.failed = false; - - const success = await this.testApiKey(this.provider, this.keyInput); - - this.testing = false; - - if (success) { - try { - await getAppStorage().providerKeys.set(this.provider, this.keyInput); - this.hasKey = true; - this.inputChanged = false; - this.requestUpdate(); - } catch (error) { - console.error("Failed to save API key:", error); - this.failed = true; - setTimeout(() => { - this.failed = false; - this.requestUpdate(); - }, 5000); - } - } else { - this.failed = true; - setTimeout(() => { - this.failed = false; - this.requestUpdate(); - }, 5000); - } - } - - render() { - return html` -
-
- ${this.provider} - ${ - this.testing - ? Badge({ children: i18n("Testing..."), variant: "secondary" }) - : this.hasKey - ? html`` - : "" - } - ${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""} -
-
- ${Input({ - type: "password", - placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"), - value: this.keyInput, - onInput: (e: Event) => { - this.keyInput = (e.target as HTMLInputElement).value; - this.inputChanged = true; - this.requestUpdate(); - }, - className: "flex-1", - })} - ${Button({ - onClick: () => this.saveKey(), - variant: "default", - size: "sm", - disabled: !this.keyInput || this.testing || (this.hasKey && !this.inputChanged), - children: i18n("Save"), - })} -
-
- `; - } -} diff --git a/packages/web-ui/src/components/SandboxedIframe.ts b/packages/web-ui/src/components/SandboxedIframe.ts deleted file mode 100644 index dd8f15ec..00000000 --- a/packages/web-ui/src/components/SandboxedIframe.ts +++ /dev/null @@ -1,626 +0,0 @@ -import { LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js"; -import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js"; -import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "./sandbox/RuntimeMessageRouter.js"; -import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js"; - -export interface SandboxFile { - fileName: string; - content: string | Uint8Array; - mimeType: string; -} - -export interface SandboxResult { - success: boolean; - console: Array<{ type: string; text: string }>; - files?: SandboxFile[]; - error?: { message: string; stack: string }; - returnValue?: any; -} - -/** - * Function that returns the URL to the sandbox HTML file. - * Used in browser extensions to load sandbox.html via chrome.runtime.getURL(). - */ -export type SandboxUrlProvider = () => string; - -/** - * Configuration for prepareHtmlDocument - */ -export interface PrepareHtmlOptions { - /** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */ - isHtmlArtifact: boolean; - /** True if this is a standalone download (no runtime bridge, no navigation interceptor) */ - isStandalone?: boolean; -} - -/** - * Escape HTML special sequences in code to prevent premature tag closure - * @param code Code that will be injected into in user code to prevent premature tag closure - const escapedUserCode = escapeScriptContent(userCode); - - return ` - - - ${runtime} - - - - -`; - } - } - - /** - * Generate runtime script from providers - * @param sandboxId Unique sandbox ID - * @param providers Runtime providers - * @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads) - */ - private getRuntimeScript( - sandboxId: string, - providers: SandboxRuntimeProvider[] = [], - isStandalone: boolean = false, - ): string { - // Collect all data from providers - const allData: Record = {}; - for (const provider of providers) { - Object.assign(allData, provider.getData()); - } - - // Generate bridge code (skip if standalone) - const bridgeCode = isStandalone - ? "" - : RuntimeMessageBridge.generateBridgeCode({ - context: "sandbox-iframe", - sandboxId, - }); - - // Collect all runtime functions - pass sandboxId as string literal - const runtimeFunctions: string[] = []; - for (const provider of providers) { - runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`); - } - - // Build script with HTML escaping - // Escape to prevent premature tag closure in HTML parser - const dataInjection = Object.entries(allData) - .map(([key, value]) => { - const jsonStr = JSON.stringify(value).replace(/<\/script/gi, "<\\/script"); - return `window.${key} = ${jsonStr};`; - }) - .join("\n"); - - // TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes - // found in an extension context like sidepanel, setting body { font-size: 75% }. It's - // definitely not our code doing that. - // See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7 - - // Navigation interceptor (only if NOT standalone) - const navigationInterceptor = isStandalone - ? "" - : ` -// Navigation interceptor: prevent all navigation and open externally -(function() { - // Intercept link clicks - document.addEventListener('click', function(e) { - const link = e.target.closest('a'); - if (link && link.href) { - // Check if it's an external link (not javascript: or #hash) - if (link.href.startsWith('http://') || link.href.startsWith('https://')) { - e.preventDefault(); - e.stopPropagation(); - window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*'); - } - } - }, true); - - // Intercept form submissions - document.addEventListener('submit', function(e) { - const form = e.target; - if (form && form.action) { - e.preventDefault(); - e.stopPropagation(); - window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*'); - } - }, true); - - // Prevent window.location changes (only if not already redefined) - try { - const originalLocation = window.location; - Object.defineProperty(window, 'location', { - get: function() { return originalLocation; }, - set: function(url) { - window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*'); - } - }); - } catch (e) { - // Already defined, skip - } -})(); -`; - - return ` -`; - } -} diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts deleted file mode 100644 index d3b09019..00000000 --- a/packages/web-ui/src/components/StreamingMessageContainer.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html, LitElement } from "lit"; -import { property, state } from "lit/decorators.js"; - -export class StreamingMessageContainer extends LitElement { - @property({ type: Array }) tools: AgentTool[] = []; - @property({ type: Boolean }) isStreaming = false; - @property({ type: Object }) pendingToolCalls?: ReadonlySet; - @property({ type: Object }) toolResultsById?: Map; - @property({ attribute: false }) onCostClick?: () => void; - - @state() private _message: AgentMessage | null = null; - private _pendingMessage: AgentMessage | null = null; - private _updateScheduled = false; - private _immediateUpdate = false; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - // Public method to update the message with batching for performance - public setMessage(message: AgentMessage | null, immediate = false) { - // Store the latest message - this._pendingMessage = message; - - // If this is an immediate update (like clearing), apply it right away - if (immediate || message === null) { - this._immediateUpdate = true; - this._message = message; - this.requestUpdate(); - // Cancel any pending updates since we're clearing - this._pendingMessage = null; - this._updateScheduled = false; - return; - } - - // Otherwise batch updates for performance during streaming - if (!this._updateScheduled) { - this._updateScheduled = true; - - requestAnimationFrame(async () => { - // Only apply the update if we haven't been cleared - if (!this._immediateUpdate && this._pendingMessage !== null) { - // Deep clone the message to ensure Lit detects changes in nested properties - // (like toolCall.arguments being mutated during streaming) - this._message = JSON.parse(JSON.stringify(this._pendingMessage)); - this.requestUpdate(); - } - // Reset for next batch - this._pendingMessage = null; - this._updateScheduled = false; - this._immediateUpdate = false; - }); - } - } - - override render() { - // Show loading indicator if loading but no message yet - if (!this._message) { - if (this.isStreaming) - return html`
- -
`; - return html``; // Empty until a message is set - } - const msg = this._message; - - if (msg.role === "toolResult") { - // Skip standalone tool result in streaming; the stable list will render paired tool-message - return html``; - } else if (msg.role === "user" || msg.role === "user-with-attachments") { - // Skip standalone tool result in streaming; the stable list will render it immediiately - return html``; - } else if (msg.role === "assistant") { - // Assistant message - render inline tool messages during streaming - return html` -
- - ${this.isStreaming ? html`` : ""} -
- `; - } - } -} - -// Register custom element -if (!customElements.get("streaming-message-container")) { - customElements.define("streaming-message-container", StreamingMessageContainer); -} diff --git a/packages/web-ui/src/components/ThinkingBlock.ts b/packages/web-ui/src/components/ThinkingBlock.ts deleted file mode 100644 index 8dd7ce45..00000000 --- a/packages/web-ui/src/components/ThinkingBlock.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import { html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ChevronRight } from "lucide"; - -@customElement("thinking-block") -export class ThinkingBlock extends LitElement { - @property() content!: string; - @property({ type: Boolean }) isStreaming = false; - @state() private isExpanded = false; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - } - - private toggleExpanded() { - this.isExpanded = !this.isExpanded; - } - - override render() { - const shimmerClasses = this.isStreaming - ? "animate-shimmer bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent" - : ""; - - return html` -
-
- ${icon(ChevronRight, "sm")} - Thinking... -
- ${this.isExpanded ? html`` : ""} -
- `; - } -} diff --git a/packages/web-ui/src/components/message-renderer-registry.ts b/packages/web-ui/src/components/message-renderer-registry.ts deleted file mode 100644 index ed710b63..00000000 --- a/packages/web-ui/src/components/message-renderer-registry.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { TemplateResult } from "lit"; - -// Extract role type from AppMessage union -export type MessageRole = AgentMessage["role"]; - -// Generic message renderer typed to specific message type -export interface MessageRenderer { - render(message: TMessage): TemplateResult; -} - -// Registry of custom message renderers by role -const messageRenderers = new Map>(); - -export function registerMessageRenderer( - role: TRole, - renderer: MessageRenderer>, -): void { - messageRenderers.set(role, renderer); -} - -export function getMessageRenderer(role: MessageRole): MessageRenderer | undefined { - return messageRenderers.get(role); -} - -export function renderMessage(message: AgentMessage): TemplateResult | undefined { - return messageRenderers.get(message.role)?.render(message); -} diff --git a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts deleted file mode 100644 index 95366a18..00000000 --- a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW, -} from "../../prompts/prompts.js"; -import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; - -// Define minimal interface for ArtifactsPanel to avoid circular dependencies -interface ArtifactsPanelLike { - artifacts: Map; - tool: { - execute(toolCallId: string, args: { command: string; filename: string; content?: string }): Promise; - }; -} - -interface AgentLike { - state: { messages: AgentMessage[] }; -} - -/** - * Artifacts Runtime Provider - * - * Provides programmatic access to session artifacts from sandboxed code. - * Allows code to create, read, update, and delete artifacts dynamically. - * Supports both online (extension) and offline (downloaded HTML) modes. - */ -export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider { - constructor( - private artifactsPanel: ArtifactsPanelLike, - private agent?: AgentLike, - private readWrite: boolean = true, - ) {} - - getData(): Record { - // Inject artifact snapshot for offline mode - const snapshot: Record = {}; - this.artifactsPanel.artifacts.forEach((artifact, filename) => { - snapshot[filename] = artifact.content; - }); - return { artifacts: snapshot }; - } - - getRuntime(): (sandboxId: string) => void { - // This function will be stringified, so no external references! - return (_sandboxId: string) => { - // Auto-parse/stringify for .json files - const isJsonFile = (filename: string) => filename.endsWith(".json"); - - (window as any).listArtifacts = async (): Promise => { - // Online: ask extension - if ((window as any).sendRuntimeMessage) { - const response = await (window as any).sendRuntimeMessage({ - type: "artifact-operation", - action: "list", - }); - if (!response.success) throw new Error(response.error); - return response.result; - } - // Offline: return snapshot keys - else { - return Object.keys((window as any).artifacts || {}); - } - }; - - (window as any).getArtifact = async (filename: string): Promise => { - let content: string; - - // Online: ask extension - if ((window as any).sendRuntimeMessage) { - const response = await (window as any).sendRuntimeMessage({ - type: "artifact-operation", - action: "get", - filename, - }); - if (!response.success) throw new Error(response.error); - content = response.result; - } - // Offline: read snapshot - else { - if (!(window as any).artifacts?.[filename]) { - throw new Error(`Artifact not found (offline mode): ${filename}`); - } - content = (window as any).artifacts[filename]; - } - - // Auto-parse .json files - if (isJsonFile(filename)) { - try { - return JSON.parse(content); - } catch (e) { - throw new Error(`Failed to parse JSON from ${filename}: ${e}`); - } - } - return content; - }; - - (window as any).createOrUpdateArtifact = async ( - filename: string, - content: any, - mimeType?: string, - ): Promise => { - if (!(window as any).sendRuntimeMessage) { - throw new Error("Cannot create/update artifacts in offline mode (read-only)"); - } - - let finalContent = content; - // Auto-stringify .json files - if (isJsonFile(filename) && typeof content !== "string") { - finalContent = JSON.stringify(content, null, 2); - } else if (typeof content !== "string") { - finalContent = JSON.stringify(content, null, 2); - } - - const response = await (window as any).sendRuntimeMessage({ - type: "artifact-operation", - action: "createOrUpdate", - filename, - content: finalContent, - mimeType, - }); - if (!response.success) throw new Error(response.error); - }; - - (window as any).deleteArtifact = async (filename: string): Promise => { - if (!(window as any).sendRuntimeMessage) { - throw new Error("Cannot delete artifacts in offline mode (read-only)"); - } - - const response = await (window as any).sendRuntimeMessage({ - type: "artifact-operation", - action: "delete", - filename, - }); - if (!response.success) throw new Error(response.error); - }; - }; - } - - async handleMessage(message: any, respond: (response: any) => void): Promise { - if (message.type !== "artifact-operation") { - return; - } - - const { action, filename, content } = message; - - try { - switch (action) { - case "list": { - const filenames = Array.from(this.artifactsPanel.artifacts.keys()); - respond({ success: true, result: filenames }); - break; - } - - case "get": { - const artifact = this.artifactsPanel.artifacts.get(filename); - if (!artifact) { - respond({ success: false, error: `Artifact not found: ${filename}` }); - } else { - respond({ success: true, result: artifact.content }); - } - break; - } - - case "createOrUpdate": { - try { - const exists = this.artifactsPanel.artifacts.has(filename); - const command = exists ? "rewrite" : "create"; - const action = exists ? "update" : "create"; - - await this.artifactsPanel.tool.execute("", { - command, - filename, - content, - }); - this.agent?.state.messages.push({ - role: "artifact", - action, - filename, - content, - ...(action === "create" && { title: filename }), - timestamp: new Date().toISOString(), - }); - respond({ success: true }); - } catch (err: any) { - respond({ success: false, error: err.message }); - } - break; - } - - case "delete": { - try { - await this.artifactsPanel.tool.execute("", { - command: "delete", - filename, - }); - this.agent?.state.messages.push({ - role: "artifact", - action: "delete", - filename, - timestamp: new Date().toISOString(), - }); - respond({ success: true }); - } catch (err: any) { - respond({ success: false, error: err.message }); - } - break; - } - - default: - respond({ success: false, error: `Unknown artifact action: ${action}` }); - } - } catch (error: any) { - respond({ success: false, error: error.message }); - } - } - - getDescription(): string { - return this.readWrite ? ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW : ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO; - } -} diff --git a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts deleted file mode 100644 index 9c8b3d7d..00000000 --- a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/prompts.js"; -import type { Attachment } from "../../utils/attachment-utils.js"; -import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; - -/** - * Attachments Runtime Provider - * - * OPTIONAL provider that provides file access APIs to sandboxed code. - * Only needed when attachments are present. - * Attachments are read-only snapshot data - no messaging needed. - */ -export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider { - constructor(private attachments: Attachment[]) {} - - getData(): Record { - const attachmentsData = this.attachments.map((a) => ({ - id: a.id, - fileName: a.fileName, - mimeType: a.mimeType, - size: a.size, - content: a.content, - extractedText: a.extractedText, - })); - - return { attachments: attachmentsData }; - } - - getRuntime(): (sandboxId: string) => void { - // This function will be stringified, so no external references! - // These functions read directly from window.attachments - // Works both online AND offline (no messaging needed!) - return (_sandboxId: string) => { - (window as any).listAttachments = () => - ((window as any).attachments || []).map((a: any) => ({ - id: a.id, - fileName: a.fileName, - mimeType: a.mimeType, - size: a.size, - })); - - (window as any).readTextAttachment = (attachmentId: string) => { - const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId); - if (!a) throw new Error(`Attachment not found: ${attachmentId}`); - if (a.extractedText) return a.extractedText; - try { - return atob(a.content); - } catch { - throw new Error(`Failed to decode text content for: ${attachmentId}`); - } - }; - - (window as any).readBinaryAttachment = (attachmentId: string) => { - const a = ((window as any).attachments || []).find((x: any) => x.id === attachmentId); - if (!a) throw new Error(`Attachment not found: ${attachmentId}`); - const bin = atob(a.content); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return bytes; - }; - }; - } - - getDescription(): string { - return ATTACHMENTS_RUNTIME_DESCRIPTION; - } -} diff --git a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts deleted file mode 100644 index 326361c7..00000000 --- a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; - -export interface ConsoleLog { - type: "log" | "warn" | "error" | "info"; - text: string; - args?: unknown[]; -} - -/** - * Console Runtime Provider - * - * REQUIRED provider that should always be included first. - * Provides console capture, error handling, and execution lifecycle management. - * Collects console output for retrieval by caller. - */ -export class ConsoleRuntimeProvider implements SandboxRuntimeProvider { - private logs: ConsoleLog[] = []; - private completionError: { message: string; stack: string } | null = null; - private completed = false; - - getData(): Record { - // No data needed - return {}; - } - - getDescription(): string { - return ""; - } - - getRuntime(): (sandboxId: string) => void { - return (_sandboxId: string) => { - // Store truly original console methods on first wrap only - // This prevents accumulation of wrapper functions across multiple executions - if (!(window as any).__originalConsole) { - (window as any).__originalConsole = { - log: console.log.bind(console), - error: console.error.bind(console), - warn: console.warn.bind(console), - info: console.info.bind(console), - }; - } - - // Always use the truly original console, not the current (possibly wrapped) one - const originalConsole = (window as any).__originalConsole; - - // Track pending send promises to wait for them in onCompleted - const pendingSends: Promise[] = []; - - ["log", "error", "warn", "info"].forEach((method) => { - (console as any)[method] = (...args: any[]) => { - const text = args - .map((arg) => { - try { - return typeof arg === "object" ? JSON.stringify(arg) : String(arg); - } catch { - return String(arg); - } - }) - .join(" "); - - // Always log locally too (using truly original console) - (originalConsole as any)[method].apply(console, args); - - // Send immediately and track the promise (only in extension context) - if ((window as any).sendRuntimeMessage) { - const sendPromise = (window as any) - .sendRuntimeMessage({ - type: "console", - method, - text, - args, - }) - .catch(() => {}); - pendingSends.push(sendPromise); - } - }; - }); - - // Register completion callback to wait for all pending sends - if ((window as any).onCompleted) { - (window as any).onCompleted(async (_success: boolean) => { - // Wait for all pending console sends to complete - if (pendingSends.length > 0) { - await Promise.all(pendingSends); - } - }); - } - - // Track errors for HTML artifacts - let lastError: { message: string; stack: string } | null = null; - - // Error handlers - track errors but don't log them - // (they'll be shown via execution-error message) - window.addEventListener("error", (e) => { - const text = `${e.error?.stack || e.message || String(e)} at line ${e.lineno || "?"}:${e.colno || "?"}`; - - lastError = { - message: e.error?.message || e.message || String(e), - stack: e.error?.stack || text, - }; - }); - - window.addEventListener("unhandledrejection", (e) => { - const text = `Unhandled promise rejection: ${e.reason?.message || e.reason || "Unknown error"}`; - - lastError = { - message: e.reason?.message || String(e.reason) || "Unhandled promise rejection", - stack: e.reason?.stack || text, - }; - }); - - // Expose complete() method for user code to call - let completionSent = false; - (window as any).complete = async (error?: { message: string; stack: string }, returnValue?: any) => { - if (completionSent) return; - completionSent = true; - - const finalError = error || lastError; - - if ((window as any).sendRuntimeMessage) { - if (finalError) { - await (window as any).sendRuntimeMessage({ - type: "execution-error", - error: finalError, - }); - } else { - await (window as any).sendRuntimeMessage({ - type: "execution-complete", - returnValue, - }); - } - } - }; - }; - } - - async handleMessage(message: any, respond: (response: any) => void): Promise { - if (message.type === "console") { - // Collect console output - this.logs.push({ - type: - message.method === "error" - ? "error" - : message.method === "warn" - ? "warn" - : message.method === "info" - ? "info" - : "log", - text: message.text, - args: message.args, - }); - // Acknowledge receipt - respond({ success: true }); - } - } - - /** - * Get collected console logs - */ - getLogs(): ConsoleLog[] { - return this.logs; - } - - /** - * Get completion status - */ - isCompleted(): boolean { - return this.completed; - } - - /** - * Get completion error if any - */ - getCompletionError(): { message: string; stack: string } | null { - return this.completionError; - } - - /** - * Reset state for reuse - */ - reset(): void { - this.logs = []; - this.completionError = null; - this.completed = false; - } -} diff --git a/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts deleted file mode 100644 index 824ae125..00000000 --- a/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; - -export interface DownloadableFile { - fileName: string; - content: string | Uint8Array; - mimeType: string; -} - -/** - * File Download Runtime Provider - * - * Provides returnDownloadableFile() for creating user downloads. - * Files returned this way are NOT accessible to the LLM later (one-time download). - * Works both online (sends to extension) and offline (triggers browser download directly). - * Collects files for retrieval by caller. - */ -export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider { - private files: DownloadableFile[] = []; - - getData(): Record { - // No data needed - return {}; - } - - getRuntime(): (sandboxId: string) => void { - return (_sandboxId: string) => { - (window as any).returnDownloadableFile = async (fileName: string, content: any, mimeType?: string) => { - let finalContent: any, finalMimeType: string; - - if (content instanceof Blob) { - const arrayBuffer = await content.arrayBuffer(); - finalContent = new Uint8Array(arrayBuffer); - finalMimeType = mimeType || content.type || "application/octet-stream"; - if (!mimeType && !content.type) { - throw new Error( - "returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').", - ); - } - } else if (content instanceof Uint8Array) { - finalContent = content; - if (!mimeType) { - throw new Error( - "returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').", - ); - } - finalMimeType = mimeType; - } else if (typeof content === "string") { - finalContent = content; - finalMimeType = mimeType || "text/plain"; - } else { - finalContent = JSON.stringify(content, null, 2); - finalMimeType = mimeType || "application/json"; - } - - // Send to extension if in extension context (online mode) - if ((window as any).sendRuntimeMessage) { - const response = await (window as any).sendRuntimeMessage({ - type: "file-returned", - fileName, - content: finalContent, - mimeType: finalMimeType, - }); - if (response.error) throw new Error(response.error); - } else { - // Offline mode: trigger browser download directly - const blob = new Blob([finalContent instanceof Uint8Array ? finalContent : finalContent], { - type: finalMimeType, - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); - } - }; - }; - } - - async handleMessage(message: any, respond: (response: any) => void): Promise { - if (message.type === "file-returned") { - // Collect file for caller - this.files.push({ - fileName: message.fileName, - content: message.content, - mimeType: message.mimeType, - }); - - respond({ success: true }); - } - } - - /** - * Get collected files - */ - getFiles(): DownloadableFile[] { - return this.files; - } - - /** - * Reset state for reuse - */ - reset(): void { - this.files = []; - } - - getDescription(): string { - return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)"; - } -} diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts deleted file mode 100644 index b2e754e5..00000000 --- a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Generates sendRuntimeMessage() function for injection into execution contexts. - * Provides unified messaging API that works in both sandbox iframe and user script contexts. - */ - -export type MessageType = "request-response" | "fire-and-forget"; - -export interface RuntimeMessageBridgeOptions { - context: "sandbox-iframe" | "user-script"; - sandboxId: string; -} - -// biome-ignore lint/complexity/noStaticOnlyClass: fine -export class RuntimeMessageBridge { - /** - * Generate sendRuntimeMessage() function as injectable string. - * Returns the function source code to be injected into target context. - */ - static generateBridgeCode(options: RuntimeMessageBridgeOptions): string { - if (options.context === "sandbox-iframe") { - return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId); - } else { - return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId); - } - } - - private static generateSandboxBridge(sandboxId: string): string { - // Returns stringified function that uses window.parent.postMessage - return ` -window.__completionCallbacks = []; -window.sendRuntimeMessage = async (message) => { - const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9); - - return new Promise((resolve, reject) => { - const handler = (e) => { - if (e.data.type === 'runtime-response' && e.data.messageId === messageId) { - window.removeEventListener('message', handler); - if (e.data.success) { - resolve(e.data); - } else { - reject(new Error(e.data.error || 'Operation failed')); - } - } - }; - - window.addEventListener('message', handler); - - window.parent.postMessage({ - ...message, - sandboxId: ${JSON.stringify(sandboxId)}, - messageId: messageId - }, '*'); - - // Timeout after 30s - setTimeout(() => { - window.removeEventListener('message', handler); - reject(new Error('Runtime message timeout')); - }, 30000); - }); -}; -window.onCompleted = (callback) => { - window.__completionCallbacks.push(callback); -}; -`.trim(); - } - - private static generateUserScriptBridge(sandboxId: string): string { - // Returns stringified function that uses chrome.runtime.sendMessage - return ` -window.__completionCallbacks = []; -window.sendRuntimeMessage = async (message) => { - return await chrome.runtime.sendMessage({ - ...message, - sandboxId: ${JSON.stringify(sandboxId)} - }); -}; -window.onCompleted = (callback) => { - window.__completionCallbacks.push(callback); -}; -`.trim(); - } -} diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts deleted file mode 100644 index 3ab84b04..00000000 --- a/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js"; - -// Type declaration for chrome extension API (when available) -declare const chrome: any; - -/** - * Message consumer interface - components that want to receive messages from sandboxes - */ -export interface MessageConsumer { - /** - * Handle a message from a sandbox. - * All consumers receive all messages - decide internally what to handle. - */ - handleMessage(message: any): Promise; -} - -/** - * Sandbox context - tracks active sandboxes and their consumers - */ -interface SandboxContext { - sandboxId: string; - iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts - providers: SandboxRuntimeProvider[]; - consumers: Set; -} - -/** - * Centralized message router for all runtime communication. - * - * This singleton replaces all individual window.addEventListener("message") calls - * with a single global listener that routes messages to the appropriate handlers. - * Also handles user script messages from chrome.runtime.onUserScriptMessage. - * - * Benefits: - * - Single global listener instead of multiple independent listeners - * - Automatic cleanup when sandboxes are destroyed - * - Support for bidirectional communication (providers) and broadcasting (consumers) - * - Works with both sandbox iframes and user scripts - * - Clear lifecycle management - */ -export class RuntimeMessageRouter { - private sandboxes = new Map(); - private messageListener: ((e: MessageEvent) => void) | null = null; - private userScriptMessageListener: - | ((message: any, sender: any, sendResponse: (response: any) => void) => boolean) - | null = null; - - /** - * Register a new sandbox with its runtime providers. - * Call this BEFORE creating the iframe (for sandbox contexts) or executing user script. - */ - registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void { - this.sandboxes.set(sandboxId, { - sandboxId, - iframe: null, // Will be set via setSandboxIframe() for sandbox contexts - providers, - consumers: new Set(consumers), - }); - - // Setup global listener if not already done - this.setupListener(); - } - - /** - * Update the iframe reference for a sandbox. - * Call this AFTER creating the iframe. - * This is needed so providers can send responses back to the sandbox. - */ - setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void { - const context = this.sandboxes.get(sandboxId); - if (context) { - context.iframe = iframe; - } - } - - /** - * Unregister a sandbox and remove all its consumers. - * Call this when the sandbox is destroyed. - */ - unregisterSandbox(sandboxId: string): void { - this.sandboxes.delete(sandboxId); - - // If no more sandboxes, remove global listeners - if (this.sandboxes.size === 0) { - // Remove iframe listener - if (this.messageListener) { - window.removeEventListener("message", this.messageListener); - this.messageListener = null; - } - - // Remove user script listener - if (this.userScriptMessageListener && typeof chrome !== "undefined" && chrome.runtime?.onUserScriptMessage) { - chrome.runtime.onUserScriptMessage.removeListener(this.userScriptMessageListener); - this.userScriptMessageListener = null; - } - } - } - - /** - * Add a message consumer for a sandbox. - * Consumers receive broadcast messages (console, execution-complete, etc.) - */ - addConsumer(sandboxId: string, consumer: MessageConsumer): void { - const context = this.sandboxes.get(sandboxId); - if (context) { - context.consumers.add(consumer); - } - } - - /** - * Remove a message consumer from a sandbox. - */ - removeConsumer(sandboxId: string, consumer: MessageConsumer): void { - const context = this.sandboxes.get(sandboxId); - if (context) { - context.consumers.delete(consumer); - } - } - - /** - * Setup the global message listeners (called automatically) - */ - private setupListener(): void { - // Setup sandbox iframe listener - if (!this.messageListener) { - this.messageListener = async (e: MessageEvent) => { - const { sandboxId, messageId } = e.data; - if (!sandboxId) return; - - const context = this.sandboxes.get(sandboxId); - if (!context) { - return; - } - - // Create respond() function for bidirectional communication - const respond = (response: any) => { - context.iframe?.contentWindow?.postMessage( - { - type: "runtime-response", - messageId, - sandboxId, - ...response, - }, - "*", - ); - }; - - // 1. Try provider handlers first (for bidirectional comm) - for (const provider of context.providers) { - if (provider.handleMessage) { - await provider.handleMessage(e.data, respond); - // Don't stop - let consumers also handle the message - } - } - - // 2. Broadcast to consumers (one-way messages or lifecycle events) - for (const consumer of context.consumers) { - await consumer.handleMessage(e.data); - // Don't stop - let all consumers see the message - } - }; - - window.addEventListener("message", this.messageListener); - } - - // Setup user script message listener - if (!this.userScriptMessageListener) { - // Guard: check if we're in extension context - if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) { - return; - } - - this.userScriptMessageListener = (message: any, _sender: any, sendResponse: (response: any) => void) => { - const { sandboxId } = message; - if (!sandboxId) return false; - - const context = this.sandboxes.get(sandboxId); - if (!context) return false; - - const respond = (response: any) => { - sendResponse({ - ...response, - sandboxId, - }); - }; - - // Route to providers (async) - (async () => { - // 1. Try provider handlers first (for bidirectional comm) - for (const provider of context.providers) { - if (provider.handleMessage) { - await provider.handleMessage(message, respond); - // Don't stop - let consumers also handle the message - } - } - - // 2. Broadcast to consumers (one-way messages or lifecycle events) - for (const consumer of context.consumers) { - await consumer.handleMessage(message); - // Don't stop - let all consumers see the message - } - })(); - - return true; // Indicates async response - }; - - chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener); - } - } -} - -/** - * Global singleton instance. - * Import this from wherever you need to interact with the message router. - */ -export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter(); diff --git a/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts deleted file mode 100644 index 90cb5df4..00000000 --- a/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Interface for providing runtime capabilities to sandboxed iframes. - * Each provider injects data and runtime functions into the sandbox context. - */ -export interface SandboxRuntimeProvider { - /** - * Returns data to inject into window scope. - * Keys become window properties (e.g., { attachments: [...] } -> window.attachments) - */ - getData(): Record; - - /** - * Returns a runtime function that will be stringified and executed in the sandbox. - * The function receives sandboxId and has access to data from getData() via window. - * - * IMPORTANT: This function will be converted to string via .toString() and injected - * into the sandbox, so it cannot reference external variables or imports. - */ - getRuntime(): (sandboxId: string) => void; - - /** - * Optional message handler for bidirectional communication. - * All providers receive all messages - decide internally what to handle. - * - * @param message - The message from the sandbox - * @param respond - Function to send a response back to the sandbox - */ - handleMessage?(message: any, respond: (response: any) => void): Promise; - - /** - * Optional documentation describing what globals/functions this provider injects. - * This will be appended to tool descriptions dynamically so the LLM knows what's available. - */ - getDescription(): string; - - /** - * Optional lifecycle callback invoked when sandbox execution starts. - * Providers can use this to track abort signals for cancellation of async operations. - * - * @param sandboxId - The unique identifier for this sandbox execution - * @param signal - Optional AbortSignal that will be triggered if execution is cancelled - */ - onExecutionStart?(sandboxId: string, signal?: AbortSignal): void; - - /** - * Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort). - * Providers can use this to clean up any resources associated with the sandbox. - * - * @param sandboxId - The unique identifier for this sandbox execution - */ - onExecutionEnd?(sandboxId: string): void; -} diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts deleted file mode 100644 index 7a45c561..00000000 --- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { customElement, state } from "lit/decorators.js"; -import "../components/ProviderKeyInput.js"; -import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; -import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { html } from "lit"; -import { getAppStorage } from "../storage/app-storage.js"; -import { i18n } from "../utils/i18n.js"; - -@customElement("api-key-prompt-dialog") -export class ApiKeyPromptDialog extends DialogBase { - @state() private provider = ""; - - private resolvePromise?: (success: boolean) => void; - private unsubscribe?: () => void; - - protected modalWidth = "min(500px, 90vw)"; - protected modalHeight = "auto"; - - static async prompt(provider: string): Promise { - const dialog = new ApiKeyPromptDialog(); - dialog.provider = provider; - dialog.open(); - - return new Promise((resolve) => { - dialog.resolvePromise = resolve; - }); - } - - override async connectedCallback() { - super.connectedCallback(); - - // Poll for key existence - when key is added, resolve and close - const checkInterval = setInterval(async () => { - const hasKey = !!(await getAppStorage().providerKeys.get(this.provider)); - if (hasKey) { - clearInterval(checkInterval); - if (this.resolvePromise) { - this.resolvePromise(true); - this.resolvePromise = undefined; - } - this.close(); - } - }, 500); - - this.unsubscribe = () => clearInterval(checkInterval); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - if (this.unsubscribe) { - this.unsubscribe(); - this.unsubscribe = undefined; - } - } - - override close() { - super.close(); - if (this.resolvePromise) { - this.resolvePromise(false); - } - } - - protected override renderContent() { - return html` - ${DialogContent({ - children: html` - ${DialogHeader({ - title: i18n("API Key Required"), - })} - - `, - })} - `; - } -} diff --git a/packages/web-ui/src/dialogs/AttachmentOverlay.ts b/packages/web-ui/src/dialogs/AttachmentOverlay.ts deleted file mode 100644 index 4ec7734c..00000000 --- a/packages/web-ui/src/dialogs/AttachmentOverlay.ts +++ /dev/null @@ -1,636 +0,0 @@ -import "@mariozechner/mini-lit/dist/ModeToggle.js"; -import { icon } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { renderAsync } from "docx-preview"; -import { html, LitElement } from "lit"; -import { state } from "lit/decorators.js"; -import { Download, X } from "lucide"; -import * as pdfjsLib from "pdfjs-dist"; -import * as XLSX from "xlsx"; -import type { Attachment } from "../utils/attachment-utils.js"; -import { i18n } from "../utils/i18n.js"; - -type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text"; - -export class AttachmentOverlay extends LitElement { - @state() private attachment?: Attachment; - @state() private showExtractedText = false; - @state() private error: string | null = null; - - // Track current loading task to cancel if needed - private currentLoadingTask: any = null; - private onCloseCallback?: () => void; - private boundHandleKeyDown?: (e: KeyboardEvent) => void; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - static open(attachment: Attachment, onClose?: () => void) { - const overlay = new AttachmentOverlay(); - overlay.attachment = attachment; - overlay.onCloseCallback = onClose; - document.body.appendChild(overlay); - overlay.setupEventListeners(); - } - - private setupEventListeners() { - this.boundHandleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - this.close(); - } - }; - window.addEventListener("keydown", this.boundHandleKeyDown); - } - - private close() { - this.cleanup(); - if (this.boundHandleKeyDown) { - window.removeEventListener("keydown", this.boundHandleKeyDown); - } - this.onCloseCallback?.(); - this.remove(); - } - - private getFileType(): FileType { - if (!this.attachment) return "text"; - - if (this.attachment.type === "image") return "image"; - if (this.attachment.mimeType === "application/pdf") return "pdf"; - if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx"; - if ( - this.attachment.mimeType?.includes("presentationml") || - this.attachment.fileName.toLowerCase().endsWith(".pptx") - ) - return "pptx"; - if ( - this.attachment.mimeType?.includes("spreadsheetml") || - this.attachment.mimeType?.includes("ms-excel") || - this.attachment.fileName.toLowerCase().endsWith(".xlsx") || - this.attachment.fileName.toLowerCase().endsWith(".xls") - ) - return "excel"; - - return "text"; - } - - private getFileTypeLabel(): string { - const type = this.getFileType(); - switch (type) { - case "pdf": - return i18n("PDF"); - case "docx": - return i18n("Document"); - case "pptx": - return i18n("Presentation"); - case "excel": - return i18n("Spreadsheet"); - default: - return ""; - } - } - - private handleBackdropClick = () => { - this.close(); - }; - - private handleDownload = () => { - if (!this.attachment) return; - - // Create a blob from the base64 content - const byteCharacters = atob(this.attachment.content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { type: this.attachment.mimeType }); - - // Create download link - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = this.attachment.fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - private cleanup() { - this.showExtractedText = false; - this.error = null; - // Cancel any loading PDF task when closing - if (this.currentLoadingTask) { - this.currentLoadingTask.destroy(); - this.currentLoadingTask = null; - } - } - - override render() { - if (!this.attachment) return html``; - - return html` - -
- -
e.stopPropagation()}> -
-
- ${this.attachment.fileName} -
-
- ${this.renderToggle()} - ${Button({ - variant: "ghost", - size: "icon", - onClick: this.handleDownload, - children: icon(Download, "sm"), - className: "h-8 w-8", - })} - ${Button({ - variant: "ghost", - size: "icon", - onClick: () => this.close(), - children: icon(X, "sm"), - className: "h-8 w-8", - })} -
-
-
- - -
e.stopPropagation()}> - ${this.renderContent()} -
-
- `; - } - - private renderToggle() { - if (!this.attachment) return html``; - - const fileType = this.getFileType(); - const hasExtractedText = !!this.attachment.extractedText; - const showToggle = fileType !== "image" && fileType !== "text" && fileType !== "pptx" && hasExtractedText; - - if (!showToggle) return html``; - - const fileTypeLabel = this.getFileTypeLabel(); - - return html` - ) => { - e.stopPropagation(); - this.showExtractedText = e.detail.index === 1; - this.error = null; - }} - > - `; - } - - private renderContent() { - if (!this.attachment) return html``; - - // Error state - if (this.error) { - return html` -
-
${i18n("Error loading file")}
-
${this.error}
-
- `; - } - - // Content based on file type - return this.renderFileContent(); - } - - private renderFileContent() { - if (!this.attachment) return html``; - - const fileType = this.getFileType(); - - // Show extracted text if toggled - if (this.showExtractedText && fileType !== "image") { - return html` -
-
${
-						this.attachment.extractedText || i18n("No text content available")
-					}
-
- `; - } - - // Render based on file type - switch (fileType) { - case "image": { - const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`; - return html` - ${this.attachment.fileName} - `; - } - - case "pdf": - return html` -
- `; - - case "docx": - return html` -
- `; - - case "excel": - return html`
`; - - case "pptx": - return html` -
- `; - - default: - return html` -
-
${
-							this.attachment.extractedText || i18n("No content available")
-						}
-
- `; - } - } - - override async updated(changedProperties: Map) { - super.updated(changedProperties); - - // Only process if we need to render the actual file (not extracted text) - if ( - (changedProperties.has("attachment") || changedProperties.has("showExtractedText")) && - this.attachment && - !this.showExtractedText && - !this.error - ) { - const fileType = this.getFileType(); - - switch (fileType) { - case "pdf": - await this.renderPdf(); - break; - case "docx": - await this.renderDocx(); - break; - case "excel": - await this.renderExcel(); - break; - case "pptx": - await this.renderExtractedText(); - break; - } - } - } - - private async renderPdf() { - const container = this.querySelector("#pdf-container"); - if (!container || !this.attachment) return; - - let pdf: any = null; - - try { - // Convert base64 to ArrayBuffer - const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content); - - // Cancel any existing loading task - if (this.currentLoadingTask) { - this.currentLoadingTask.destroy(); - } - - // Load the PDF - this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); - pdf = await this.currentLoadingTask.promise; - this.currentLoadingTask = null; - - // Clear container and add wrapper - container.innerHTML = ""; - const wrapper = document.createElement("div"); - wrapper.className = ""; - container.appendChild(wrapper); - - // Render all pages - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - const page = await pdf.getPage(pageNum); - - // Create a container for each page - const pageContainer = document.createElement("div"); - pageContainer.className = "mb-4 last:mb-0"; - - // Create canvas for this page - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - // Set scale for reasonable resolution - const viewport = page.getViewport({ scale: 1.5 }); - canvas.height = viewport.height; - canvas.width = viewport.width; - - // Style the canvas - canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border"; - - // Fill white background for proper PDF rendering - if (context) { - context.fillStyle = "white"; - context.fillRect(0, 0, canvas.width, canvas.height); - } - - // Render page - await page.render({ - canvasContext: context!, - viewport: viewport, - canvas: canvas, - }).promise; - - pageContainer.appendChild(canvas); - - // Add page separator for multi-page documents - if (pageNum < pdf.numPages) { - const separator = document.createElement("div"); - separator.className = "h-px bg-border my-4"; - pageContainer.appendChild(separator); - } - - wrapper.appendChild(pageContainer); - } - } catch (error: any) { - console.error("Error rendering PDF:", error); - this.error = error?.message || i18n("Failed to load PDF"); - } finally { - if (pdf) { - pdf.destroy(); - } - } - } - - private async renderDocx() { - const container = this.querySelector("#docx-container"); - if (!container || !this.attachment) return; - - try { - // Convert base64 to ArrayBuffer - const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content); - - // Clear container first - container.innerHTML = ""; - - // Create a wrapper div for the document - const wrapper = document.createElement("div"); - wrapper.className = "docx-wrapper-custom"; - container.appendChild(wrapper); - - // Render the DOCX file into the wrapper - await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, { - className: "docx", - inWrapper: true, - ignoreWidth: true, // Let it be responsive - ignoreHeight: false, - ignoreFonts: false, - breakPages: true, - ignoreLastRenderedPageBreak: true, - experimental: false, - trimXmlDeclaration: true, - useBase64URL: false, - renderHeaders: true, - renderFooters: true, - renderFootnotes: true, - renderEndnotes: true, - }); - - // Apply custom styles to match theme and fix sizing - const style = document.createElement("style"); - style.textContent = ` - #docx-container { - padding: 0; - } - - #docx-container .docx-wrapper-custom { - max-width: 100%; - overflow-x: auto; - } - - #docx-container .docx-wrapper { - max-width: 100% !important; - margin: 0 !important; - background: transparent !important; - padding: 0em !important; - } - - #docx-container .docx-wrapper > section.docx { - box-shadow: none !important; - border: none !important; - border-radius: 0 !important; - margin: 0 !important; - padding: 2em !important; - background: white !important; - color: black !important; - max-width: 100% !important; - width: 100% !important; - min-width: 0 !important; - overflow-x: auto !important; - } - - /* Fix tables and wide content */ - #docx-container table { - max-width: 100% !important; - width: auto !important; - overflow-x: auto !important; - display: block !important; - } - - #docx-container img { - max-width: 100% !important; - height: auto !important; - } - - /* Fix paragraphs and text */ - #docx-container p, - #docx-container span, - #docx-container div { - max-width: 100% !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - } - - /* Hide page breaks in web view */ - #docx-container .docx-page-break { - display: none !important; - } - `; - container.appendChild(style); - } catch (error: any) { - console.error("Error rendering DOCX:", error); - this.error = error?.message || i18n("Failed to load document"); - } - } - - private async renderExcel() { - const container = this.querySelector("#excel-container"); - if (!container || !this.attachment) return; - - try { - // Convert base64 to ArrayBuffer - const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content); - - // Read the workbook - const workbook = XLSX.read(arrayBuffer, { type: "array" }); - - // Clear container - container.innerHTML = ""; - const wrapper = document.createElement("div"); - wrapper.className = "overflow-auto h-full flex flex-col"; - container.appendChild(wrapper); - - // Create tabs for multiple sheets - if (workbook.SheetNames.length > 1) { - const tabContainer = document.createElement("div"); - tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10"; - - const sheetContents: HTMLElement[] = []; - - workbook.SheetNames.forEach((sheetName, index) => { - // Create tab button - const tab = document.createElement("button"); - tab.textContent = sheetName; - tab.className = - index === 0 - ? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" - : "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors"; - - // Create sheet content - const sheetDiv = document.createElement("div"); - sheetDiv.style.display = index === 0 ? "flex" : "none"; - sheetDiv.className = "flex-1 overflow-auto"; - sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName)); - sheetContents.push(sheetDiv); - - // Tab click handler - tab.onclick = () => { - // Update tab styles - tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => { - if (btnIndex === index) { - btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"; - } else { - btn.className = - "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors"; - } - }); - // Show/hide sheets - sheetContents.forEach((content, contentIndex) => { - content.style.display = contentIndex === index ? "flex" : "none"; - }); - }; - - tabContainer.appendChild(tab); - }); - - wrapper.appendChild(tabContainer); - sheetContents.forEach((content) => { - wrapper.appendChild(content); - }); - } else { - // Single sheet - const sheetName = workbook.SheetNames[0]; - wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName)); - } - } catch (error: any) { - console.error("Error rendering Excel:", error); - this.error = error?.message || i18n("Failed to load spreadsheet"); - } - } - - private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement { - const sheetDiv = document.createElement("div"); - - // Generate HTML table - const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` }); - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = htmlTable; - - // Find and style the table - const table = tempDiv.querySelector("table"); - if (table) { - table.className = "w-full border-collapse text-foreground"; - - // Style all cells - table.querySelectorAll("td, th").forEach((cell) => { - const cellEl = cell as HTMLElement; - cellEl.className = "border border-border px-3 py-2 text-sm text-left"; - }); - - // Style header row - const headerCells = table.querySelectorAll("thead th, tr:first-child td"); - if (headerCells.length > 0) { - headerCells.forEach((th) => { - const thEl = th as HTMLElement; - thEl.className = - "border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0"; - }); - } - - // Alternate row colors - table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => { - const rowEl = row as HTMLElement; - rowEl.className = "bg-muted/30"; - }); - - sheetDiv.appendChild(table); - } - - return sheetDiv; - } - - private base64ToArrayBuffer(base64: string): ArrayBuffer { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - } - - private async renderExtractedText() { - const container = this.querySelector("#pptx-container"); - if (!container || !this.attachment) return; - - try { - // Display the extracted text content - container.innerHTML = ""; - const wrapper = document.createElement("div"); - wrapper.className = "p-6 overflow-auto"; - - // Create a pre element to preserve formatting - const pre = document.createElement("pre"); - pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono"; - pre.textContent = this.attachment.extractedText || i18n("No text content available"); - - wrapper.appendChild(pre); - container.appendChild(wrapper); - } catch (error: any) { - console.error("Error rendering extracted text:", error); - this.error = error?.message || i18n("Failed to display text content"); - } - } -} - -// Register the custom element only once -if (!customElements.get("attachment-overlay")) { - customElements.define("attachment-overlay", AttachmentOverlay); -} diff --git a/packages/web-ui/src/dialogs/CustomProviderDialog.ts b/packages/web-ui/src/dialogs/CustomProviderDialog.ts deleted file mode 100644 index 2db0ce60..00000000 --- a/packages/web-ui/src/dialogs/CustomProviderDialog.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { Input } from "@mariozechner/mini-lit/dist/Input.js"; -import { Label } from "@mariozechner/mini-lit/dist/Label.js"; -import { Select } from "@mariozechner/mini-lit/dist/Select.js"; -import { html, type TemplateResult } from "lit"; -import { state } from "lit/decorators.js"; -import { getAppStorage } from "../storage/app-storage.js"; -import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js"; -import { discoverModels } from "../utils/model-discovery.js"; - -export class CustomProviderDialog extends DialogBase { - private provider?: CustomProvider; - private initialType?: CustomProviderType; - private onSaveCallback?: () => void; - - @state() private name = ""; - @state() private type: CustomProviderType = "openai-completions"; - @state() private baseUrl = ""; - @state() private apiKey = ""; - @state() private testing = false; - @state() private testError = ""; - @state() private discoveredModels: Model[] = []; - - protected modalWidth = "min(800px, 90vw)"; - protected modalHeight = "min(700px, 90vh)"; - - static async open( - provider: CustomProvider | undefined, - initialType: CustomProviderType | undefined, - onSave?: () => void, - ) { - const dialog = new CustomProviderDialog(); - dialog.provider = provider; - dialog.initialType = initialType; - dialog.onSaveCallback = onSave; - document.body.appendChild(dialog); - dialog.initializeFromProvider(); - dialog.open(); - dialog.requestUpdate(); - } - - private initializeFromProvider() { - if (this.provider) { - this.name = this.provider.name; - this.type = this.provider.type; - this.baseUrl = this.provider.baseUrl; - this.apiKey = this.provider.apiKey || ""; - this.discoveredModels = this.provider.models || []; - } else { - this.name = ""; - this.type = this.initialType || "openai-completions"; - this.baseUrl = ""; - this.updateDefaultBaseUrl(); - this.apiKey = ""; - this.discoveredModels = []; - } - this.testError = ""; - this.testing = false; - } - - private updateDefaultBaseUrl() { - if (this.baseUrl) return; - - const defaults: Record = { - ollama: "http://localhost:11434", - "llama.cpp": "http://localhost:8080", - vllm: "http://localhost:8000", - lmstudio: "http://localhost:1234", - "openai-completions": "", - "openai-responses": "", - "anthropic-messages": "", - }; - - this.baseUrl = defaults[this.type] || ""; - } - - private isAutoDiscoveryType(): boolean { - return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio"; - } - - private async testConnection() { - if (!this.isAutoDiscoveryType()) return; - - this.testing = true; - this.testError = ""; - this.discoveredModels = []; - - try { - const models = await discoverModels( - this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio", - this.baseUrl, - this.apiKey || undefined, - ); - - this.discoveredModels = models.map((model) => ({ - ...model, - provider: this.name || this.type, - })); - - this.testError = ""; - } catch (error) { - this.testError = error instanceof Error ? error.message : String(error); - this.discoveredModels = []; - } finally { - this.testing = false; - this.requestUpdate(); - } - } - - private async save() { - if (!this.name || !this.baseUrl) { - alert(i18n("Please fill in all required fields")); - return; - } - - try { - const storage = getAppStorage(); - - const provider: CustomProvider = { - id: this.provider?.id || crypto.randomUUID(), - name: this.name, - type: this.type, - baseUrl: this.baseUrl, - apiKey: this.apiKey || undefined, - models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [], - }; - - await storage.customProviders.set(provider); - - if (this.onSaveCallback) { - this.onSaveCallback(); - } - this.close(); - } catch (error) { - console.error("Failed to save provider:", error); - alert(i18n("Failed to save provider")); - } - } - - protected override renderContent(): TemplateResult { - const providerTypes = [ - { value: "ollama", label: "Ollama (auto-discovery)" }, - { value: "llama.cpp", label: "llama.cpp (auto-discovery)" }, - { value: "vllm", label: "vLLM (auto-discovery)" }, - { value: "lmstudio", label: "LM Studio (auto-discovery)" }, - { value: "openai-completions", label: "OpenAI Completions Compatible" }, - { value: "openai-responses", label: "OpenAI Responses Compatible" }, - { value: "anthropic-messages", label: "Anthropic Messages Compatible" }, - ]; - - return html` -
-
-

- ${this.provider ? i18n("Edit Provider") : i18n("Add Provider")} -

-
- -
-
-
- ${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })} - ${Input({ - value: this.name, - placeholder: i18n("e.g., My Ollama Server"), - onInput: (e: Event) => { - this.name = (e.target as HTMLInputElement).value; - this.requestUpdate(); - }, - })} -
- -
- ${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })} - ${Select({ - value: this.type, - options: providerTypes.map((pt) => ({ - value: pt.value, - label: pt.label, - })), - onChange: (value: string) => { - this.type = value as CustomProviderType; - this.baseUrl = ""; - this.updateDefaultBaseUrl(); - this.requestUpdate(); - }, - width: "100%", - })} -
- -
- ${Label({ htmlFor: "base-url", children: i18n("Base URL") })} - ${Input({ - value: this.baseUrl, - placeholder: i18n("e.g., http://localhost:11434"), - onInput: (e: Event) => { - this.baseUrl = (e.target as HTMLInputElement).value; - this.requestUpdate(); - }, - })} -
- -
- ${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })} - ${Input({ - type: "password", - value: this.apiKey, - placeholder: i18n("Leave empty if not required"), - onInput: (e: Event) => { - this.apiKey = (e.target as HTMLInputElement).value; - this.requestUpdate(); - }, - })} -
- - ${ - this.isAutoDiscoveryType() - ? html` -
- ${Button({ - onClick: () => this.testConnection(), - variant: "outline", - disabled: this.testing || !this.baseUrl, - children: this.testing ? i18n("Testing...") : i18n("Test Connection"), - })} - ${this.testError ? html`
${this.testError}
` : ""} - ${ - this.discoveredModels.length > 0 - ? html` -
- ${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}: -
    - ${this.discoveredModels.slice(0, 5).map((model) => html`
  • ${model.name}
  • `)} - ${ - this.discoveredModels.length > 5 - ? html`
  • ...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}
  • ` - : "" - } -
-
- ` - : "" - } -
- ` - : html`
- ${i18n("For manual provider types, add models after saving the provider.")} -
` - } -
-
- -
- ${Button({ - onClick: () => this.close(), - variant: "ghost", - children: i18n("Cancel"), - })} - ${Button({ - onClick: () => this.save(), - variant: "default", - disabled: !this.name || !this.baseUrl, - children: i18n("Save"), - })} -
-
- `; - } -} - -customElements.define("custom-provider-dialog", CustomProviderDialog); diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts deleted file mode 100644 index 1884765f..00000000 --- a/packages/web-ui/src/dialogs/ModelSelector.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { getModels, getProviders, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; -import { icon } from "@mariozechner/mini-lit"; -import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; -import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { html, type PropertyValues, type TemplateResult } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { createRef, ref } from "lit/directives/ref.js"; -import { Brain, Image as ImageIcon } from "lucide"; -import { Input } from "../components/Input.js"; -import { getAppStorage } from "../storage/app-storage.js"; -import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js"; -import { formatModelCost } from "../utils/format.js"; -import { i18n } from "../utils/i18n.js"; -import { discoverModels } from "../utils/model-discovery.js"; - -/** - * Score a query against a text using subsequence matching. - * All query characters must appear in order in the text. - * Higher score = tighter match (fewer gaps between matched characters). - * Returns 0 if no match. - */ -function subsequenceScore(query: string, text: string): number { - let qi = 0; - let ti = 0; - let gaps = 0; - let lastMatchIndex = -1; - - while (qi < query.length && ti < text.length) { - if (query[qi] === text[ti]) { - if (lastMatchIndex >= 0) { - gaps += ti - lastMatchIndex - 1; - } - lastMatchIndex = ti; - qi++; - } - ti++; - } - - // All query chars must match - if (qi < query.length) return 0; - - // Score: longer query match = better, fewer gaps = better - // Normalize so exact substring gets highest score - return query.length / (query.length + gaps); -} - -@customElement("agent-model-selector") -export class ModelSelector extends DialogBase { - @state() currentModel: Model | null = null; - @state() searchQuery = ""; - @state() filterThinking = false; - @state() filterVision = false; - @state() customProvidersLoading = false; - @state() selectedIndex = 0; - @state() private navigationMode: "mouse" | "keyboard" = "mouse"; - @state() private customProviderModels: Model[] = []; - - private onSelectCallback?: (model: Model) => void; - private allowedProviders?: Set; - private scrollContainerRef = createRef(); - private searchInputRef = createRef(); - private lastMousePosition = { x: 0, y: 0 }; - - protected override modalWidth = "min(400px, 90vw)"; - - static async open( - currentModel: Model | null, - onSelect: (model: Model) => void, - allowedProviders?: string[], - ) { - const selector = new ModelSelector(); - selector.currentModel = currentModel; - selector.onSelectCallback = onSelect; - if (allowedProviders) { - selector.allowedProviders = new Set(allowedProviders); - } - selector.open(); - selector.loadCustomProviders(); - } - - override async firstUpdated(changedProperties: PropertyValues): Promise { - super.firstUpdated(changedProperties); - // Wait for dialog to be fully rendered - await this.updateComplete; - // Focus the search input when dialog opens - this.searchInputRef.value?.focus(); - - // Track actual mouse movement - this.addEventListener("mousemove", (e: MouseEvent) => { - // Check if mouse actually moved - if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) { - this.lastMousePosition = { x: e.clientX, y: e.clientY }; - // Only switch to mouse mode on actual mouse movement - if (this.navigationMode === "keyboard") { - this.navigationMode = "mouse"; - // Update selection to the item under the mouse - const target = e.target as HTMLElement; - const modelItem = target.closest("[data-model-item]"); - if (modelItem) { - const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]"); - if (allItems) { - const index = Array.from(allItems).indexOf(modelItem); - if (index !== -1) { - this.selectedIndex = index; - } - } - } - } - } - }); - - // Add global keyboard handler for the dialog - this.addEventListener("keydown", (e: KeyboardEvent) => { - // Ignore key events during IME composition (e.g. CJK input) - if (e.isComposing || e.key === "Process") return; - - // Get filtered models to know the bounds - const filteredModels = this.getFilteredModels(); - - if (e.key === "ArrowDown") { - e.preventDefault(); - this.navigationMode = "keyboard"; - this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1); - this.scrollToSelected(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.navigationMode = "keyboard"; - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - this.scrollToSelected(); - } else if (e.key === "Enter") { - e.preventDefault(); - if (filteredModels[this.selectedIndex]) { - this.handleSelect(filteredModels[this.selectedIndex].model); - } - } - }); - } - - private async loadCustomProviders() { - this.customProvidersLoading = true; - const allCustomModels: Model[] = []; - - try { - const storage = getAppStorage(); - const customProviders = await storage.customProviders.getAll(); - - // Load models from custom providers - for (const provider of customProviders) { - const isAutoDiscovery: boolean = - provider.type === "ollama" || - provider.type === "llama.cpp" || - provider.type === "vllm" || - provider.type === "lmstudio"; - - if (isAutoDiscovery) { - try { - const models = await discoverModels( - provider.type as AutoDiscoveryProviderType, - provider.baseUrl, - provider.apiKey, - ); - - const modelsWithProvider = models.map((model) => ({ - ...model, - provider: provider.name, - })); - - allCustomModels.push(...modelsWithProvider); - } catch (error) { - console.debug(`Failed to load models from ${provider.name}:`, error); - } - } else if (provider.models) { - // Manual provider - models already defined - allCustomModels.push(...provider.models); - } - } - } catch (error) { - console.error("Failed to load custom providers:", error); - } finally { - this.customProviderModels = allCustomModels; - this.customProvidersLoading = false; - this.requestUpdate(); - } - } - - private formatTokens(tokens: number): string { - if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`; - if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`; - return String(tokens); - } - - private handleSelect(model: Model) { - if (model) { - this.onSelectCallback?.(model); - this.close(); - } - } - - private getFilteredModels(): Array<{ provider: string; id: string; model: any }> { - // Collect all models from known providers - const allModels: Array<{ provider: string; id: string; model: any }> = []; - const knownProviders = getProviders(); - - for (const provider of knownProviders) { - const models = getModels(provider as any); - for (const model of models) { - allModels.push({ provider, id: model.id, model }); - } - } - - // Add custom provider models - for (const model of this.customProviderModels) { - allModels.push({ provider: model.provider, id: model.id, model }); - } - - // Filter by allowed providers if set - if (this.allowedProviders) { - const allowed = this.allowedProviders; - allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider))); - } - - // Filter models based on search and capability filters - let filteredModels = allModels; - - // Apply search filter (subsequence match: characters must appear in order) - if (this.searchQuery) { - const query = this.searchQuery.toLowerCase().replace(/\s+/g, ""); - if (query) { - const scored: Array<{ item: (typeof allModels)[0]; score: number }> = []; - for (const entry of filteredModels) { - const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase(); - const score = subsequenceScore(query, searchText); - if (score > 0) { - scored.push({ item: entry, score }); - } - } - scored.sort((a, b) => b.score - a.score); - filteredModels = scored.map((s) => s.item); - } - } - - // Apply capability filters - if (this.filterThinking) { - filteredModels = filteredModels.filter(({ model }) => model.reasoning); - } - if (this.filterVision) { - filteredModels = filteredModels.filter(({ model }) => model.input.includes("image")); - } - - // Sort: when not searching, current model first then by provider. - // When searching, preserve the score-based order from above, - // but still float the current model to the top. - if (!this.searchQuery) { - filteredModels.sort((a, b) => { - const aIsCurrent = modelsAreEqual(this.currentModel, a.model); - const bIsCurrent = modelsAreEqual(this.currentModel, b.model); - if (aIsCurrent && !bIsCurrent) return -1; - if (!aIsCurrent && bIsCurrent) return 1; - return a.provider.localeCompare(b.provider); - }); - } - - return filteredModels; - } - - private scrollToSelected() { - requestAnimationFrame(() => { - const scrollContainer = this.scrollContainerRef.value; - const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[ - this.selectedIndex - ] as HTMLElement; - if (selectedElement) { - selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - }); - } - - protected override renderContent(): TemplateResult { - const filteredModels = this.getFilteredModels(); - - return html` - -
- ${DialogHeader({ title: i18n("Select Model") })} - ${Input({ - placeholder: i18n("Search models..."), - value: this.searchQuery, - inputRef: this.searchInputRef, - onInput: (e: Event) => { - this.searchQuery = (e.target as HTMLInputElement).value; - this.selectedIndex = 0; - // Reset scroll position when search changes - if (this.scrollContainerRef.value) { - this.scrollContainerRef.value.scrollTop = 0; - } - }, - })} -
- ${Button({ - variant: this.filterThinking ? "default" : "secondary", - size: "sm", - onClick: () => { - this.filterThinking = !this.filterThinking; - this.selectedIndex = 0; - if (this.scrollContainerRef.value) { - this.scrollContainerRef.value.scrollTop = 0; - } - }, - className: "rounded-full", - children: html`${icon(Brain, "sm")} ${i18n("Thinking")}`, - })} - ${Button({ - variant: this.filterVision ? "default" : "secondary", - size: "sm", - onClick: () => { - this.filterVision = !this.filterVision; - this.selectedIndex = 0; - if (this.scrollContainerRef.value) { - this.scrollContainerRef.value.scrollTop = 0; - } - }, - className: "rounded-full", - children: html`${icon(ImageIcon, "sm")} ${i18n("Vision")}`, - })} -
-
- - -
- ${filteredModels.map(({ provider, id, model }, index) => { - const isCurrent = modelsAreEqual(this.currentModel, model); - const isSelected = index === this.selectedIndex; - return html` -
this.handleSelect(model)} - @mouseenter=${() => { - // Only update selection in mouse mode - if (this.navigationMode === "mouse") { - this.selectedIndex = index; - } - }} - > -
-
- ${id} - ${isCurrent ? html`` : ""} -
- ${Badge(provider, "outline")} -
-
-
- ${icon(Brain, "sm")} - ${icon(ImageIcon, "sm")} - ${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K -
- ${formatModelCost(model.cost)} -
-
- `; - })} -
- `; - } -} diff --git a/packages/web-ui/src/dialogs/PersistentStorageDialog.ts b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts deleted file mode 100644 index f5c83a13..00000000 --- a/packages/web-ui/src/dialogs/PersistentStorageDialog.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; -import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { i18n } from "../utils/i18n.js"; - -@customElement("persistent-storage-dialog") -export class PersistentStorageDialog extends DialogBase { - @state() private requesting = false; - - private resolvePromise?: (userApproved: boolean) => void; - - protected modalWidth = "min(500px, 90vw)"; - protected modalHeight = "auto"; - - /** - * Request persistent storage permission. - * Returns true if browser granted persistent storage, false otherwise. - */ - static async request(): Promise { - // Check if already persisted - if (navigator.storage?.persisted) { - const alreadyPersisted = await navigator.storage.persisted(); - if (alreadyPersisted) { - console.log("✓ Persistent storage already granted"); - return true; - } - } - - // Show dialog and wait for user response - const dialog = new PersistentStorageDialog(); - dialog.open(); - - const userApproved = await new Promise((resolve) => { - dialog.resolvePromise = resolve; - }); - - if (!userApproved) { - console.warn("⚠ User declined persistent storage - sessions may be lost"); - return false; - } - - // User approved, request from browser - if (!navigator.storage?.persist) { - console.warn("⚠ Persistent storage API not available"); - return false; - } - - try { - const granted = await navigator.storage.persist(); - if (granted) { - console.log("✓ Persistent storage granted - sessions will be preserved"); - } else { - console.warn("⚠ Browser denied persistent storage - sessions may be lost under storage pressure"); - } - return granted; - } catch (error) { - console.error("Failed to request persistent storage:", error); - return false; - } - } - - private handleGrant() { - if (this.resolvePromise) { - this.resolvePromise(true); - this.resolvePromise = undefined; - } - this.close(); - } - - private handleDeny() { - if (this.resolvePromise) { - this.resolvePromise(false); - this.resolvePromise = undefined; - } - this.close(); - } - - override close() { - super.close(); - if (this.resolvePromise) { - this.resolvePromise(false); - } - } - - protected override renderContent() { - return html` - ${DialogContent({ - children: html` - ${DialogHeader({ - title: i18n("Storage Permission Required"), - description: i18n("This app needs persistent storage to save your conversations"), - })} - -
-
-
- - - - - -
-
-

${i18n("Why is this needed?")}

-

- ${i18n( - "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.", - )} -

-
-
- -
-

${i18n("What this means:")}

-
    -
  • ${i18n("Your conversations will be saved locally in your browser")}
  • -
  • ${i18n("Data will not be deleted automatically to free up space")}
  • -
  • ${i18n("You can still manually clear data at any time")}
  • -
  • ${i18n("No data is sent to external servers")}
  • -
-
-
- -
- ${Button({ - variant: "outline", - onClick: () => this.handleDeny(), - disabled: this.requesting, - children: i18n("Continue Anyway"), - })} - ${Button({ - variant: "default", - onClick: () => this.handleGrant(), - disabled: this.requesting, - children: this.requesting ? i18n("Requesting...") : i18n("Grant Permission"), - })} -
- `, - })} - `; - } -} diff --git a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts b/packages/web-ui/src/dialogs/ProvidersModelsTab.ts deleted file mode 100644 index 440205c3..00000000 --- a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { getProviders } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; -import { Select } from "@mariozechner/mini-lit/dist/Select.js"; -import { html, type TemplateResult } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import "../components/CustomProviderCard.js"; -import "../components/ProviderKeyInput.js"; -import { getAppStorage } from "../storage/app-storage.js"; -import type { - AutoDiscoveryProviderType, - CustomProvider, - CustomProviderType, -} from "../storage/stores/custom-providers-store.js"; -import { discoverModels } from "../utils/model-discovery.js"; -import { CustomProviderDialog } from "./CustomProviderDialog.js"; -import { SettingsTab } from "./SettingsDialog.js"; - -@customElement("providers-models-tab") -export class ProvidersModelsTab extends SettingsTab { - @state() private customProviders: CustomProvider[] = []; - @state() private providerStatus: Map< - string, - { modelCount: number; status: "connected" | "disconnected" | "checking" } - > = new Map(); - - override async connectedCallback() { - super.connectedCallback(); - await this.loadCustomProviders(); - } - - private async loadCustomProviders() { - try { - const storage = getAppStorage(); - this.customProviders = await storage.customProviders.getAll(); - - // Check status for auto-discovery providers - for (const provider of this.customProviders) { - const isAutoDiscovery = - provider.type === "ollama" || - provider.type === "llama.cpp" || - provider.type === "vllm" || - provider.type === "lmstudio"; - if (isAutoDiscovery) { - this.checkProviderStatus(provider); - } - } - } catch (error) { - console.error("Failed to load custom providers:", error); - } - } - - getTabName(): string { - return "Providers & Models"; - } - - private async checkProviderStatus(provider: CustomProvider) { - this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" }); - this.requestUpdate(); - - try { - const models = await discoverModels( - provider.type as AutoDiscoveryProviderType, - provider.baseUrl, - provider.apiKey, - ); - - this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" }); - } catch (_error) { - this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" }); - } - this.requestUpdate(); - } - - private renderKnownProviders(): TemplateResult { - const providers = getProviders(); - - return html` -
-
-

Cloud Providers

-

- Cloud LLM providers with predefined models. API keys are stored locally in your browser. -

-
-
- ${providers.map((provider) => html` `)} -
-
- `; - } - - private renderCustomProviders(): TemplateResult { - const isAutoDiscovery = (type: string) => - type === "ollama" || type === "llama.cpp" || type === "vllm" || type === "lmstudio"; - - return html` -
-
-
-

Custom Providers

-

- User-configured servers with auto-discovered or manually defined models. -

-
- ${Select({ - placeholder: i18n("Add Provider"), - options: [ - { value: "ollama", label: "Ollama" }, - { value: "llama.cpp", label: "llama.cpp" }, - { value: "vllm", label: "vLLM" }, - { value: "lmstudio", label: "LM Studio" }, - { value: "openai-completions", label: i18n("OpenAI Completions Compatible") }, - { value: "openai-responses", label: i18n("OpenAI Responses Compatible") }, - { value: "anthropic-messages", label: i18n("Anthropic Messages Compatible") }, - ], - onChange: (value: string) => this.addCustomProvider(value as CustomProviderType), - variant: "outline", - size: "sm", - })} -
- - ${ - this.customProviders.length === 0 - ? html` -
- No custom providers configured. Click 'Add Provider' to get started. -
- ` - : html` -
- ${this.customProviders.map( - (provider) => html` - this.refreshProvider(p)} - .onEdit=${(p: CustomProvider) => this.editProvider(p)} - .onDelete=${(p: CustomProvider) => this.deleteProvider(p)} - > - `, - )} -
- ` - } -
- `; - } - - private async addCustomProvider(type: CustomProviderType) { - await CustomProviderDialog.open(undefined, type, async () => { - await this.loadCustomProviders(); - this.requestUpdate(); - }); - } - - private async editProvider(provider: CustomProvider) { - await CustomProviderDialog.open(provider, undefined, async () => { - await this.loadCustomProviders(); - this.requestUpdate(); - }); - } - - private async refreshProvider(provider: CustomProvider) { - this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" }); - this.requestUpdate(); - - try { - const models = await discoverModels( - provider.type as AutoDiscoveryProviderType, - provider.baseUrl, - provider.apiKey, - ); - - this.providerStatus.set(provider.id, { modelCount: models.length, status: "connected" }); - this.requestUpdate(); - - console.log(`Refreshed ${models.length} models from ${provider.name}`); - } catch (error) { - this.providerStatus.set(provider.id, { modelCount: 0, status: "disconnected" }); - this.requestUpdate(); - - console.error(`Failed to refresh provider ${provider.name}:`, error); - alert(`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`); - } - } - - private async deleteProvider(provider: CustomProvider) { - if (!confirm("Are you sure you want to delete this provider?")) { - return; - } - - try { - const storage = getAppStorage(); - await storage.customProviders.delete(provider.id); - await this.loadCustomProviders(); - this.requestUpdate(); - } catch (error) { - console.error("Failed to delete provider:", error); - } - } - - render(): TemplateResult { - return html` -
- ${this.renderKnownProviders()} -
- ${this.renderCustomProviders()} -
- `; - } -} diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts deleted file mode 100644 index 66908d59..00000000 --- a/packages/web-ui/src/dialogs/SessionListDialog.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; -import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { getAppStorage } from "../storage/app-storage.js"; -import type { SessionMetadata } from "../storage/types.js"; -import { formatUsage } from "../utils/format.js"; -import { i18n } from "../utils/i18n.js"; - -@customElement("session-list-dialog") -export class SessionListDialog extends DialogBase { - @state() private sessions: SessionMetadata[] = []; - @state() private loading = true; - - private onSelectCallback?: (sessionId: string) => void; - private onDeleteCallback?: (sessionId: string) => void; - private deletedSessions = new Set(); - private closedViaSelection = false; - - protected modalWidth = "min(600px, 90vw)"; - protected modalHeight = "min(700px, 90vh)"; - - static async open(onSelect: (sessionId: string) => void, onDelete?: (sessionId: string) => void) { - const dialog = new SessionListDialog(); - dialog.onSelectCallback = onSelect; - dialog.onDeleteCallback = onDelete; - dialog.open(); - await dialog.loadSessions(); - } - - private async loadSessions() { - this.loading = true; - try { - const storage = getAppStorage(); - this.sessions = await storage.sessions.getAllMetadata(); - } catch (err) { - console.error("Failed to load sessions:", err); - this.sessions = []; - } finally { - this.loading = false; - } - } - - private async handleDelete(sessionId: string, event: Event) { - event.stopPropagation(); - - if (!confirm(i18n("Delete this session?"))) { - return; - } - - try { - const storage = getAppStorage(); - if (!storage.sessions) return; - - await storage.sessions.deleteSession(sessionId); - await this.loadSessions(); - - // Track deleted session - this.deletedSessions.add(sessionId); - } catch (err) { - console.error("Failed to delete session:", err); - } - } - - override close() { - super.close(); - - // Only notify about deleted sessions if dialog wasn't closed via selection - if (!this.closedViaSelection && this.onDeleteCallback && this.deletedSessions.size > 0) { - for (const sessionId of this.deletedSessions) { - this.onDeleteCallback(sessionId); - } - } - } - - private handleSelect(sessionId: string) { - this.closedViaSelection = true; - if (this.onSelectCallback) { - this.onSelectCallback(sessionId); - } - this.close(); - } - - private formatDate(isoString: string): string { - const date = new Date(isoString); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - if (days === 0) { - return i18n("Today"); - } else if (days === 1) { - return i18n("Yesterday"); - } else if (days < 7) { - return i18n("{days} days ago").replace("{days}", days.toString()); - } else { - return date.toLocaleDateString(); - } - } - - protected override renderContent() { - return html` - ${DialogContent({ - className: "h-full flex flex-col", - children: html` - ${DialogHeader({ - title: i18n("Sessions"), - description: i18n("Load a previous conversation"), - })} - -
- ${ - this.loading - ? html`
${i18n("Loading...")}
` - : this.sessions.length === 0 - ? html`
${i18n("No sessions yet")}
` - : this.sessions.map( - (session) => html` -
this.handleSelect(session.id)} - > -
-
${session.title}
-
${this.formatDate(session.lastModified)}
-
- ${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)} -
-
- -
- `, - ) - } -
- `, - })} - `; - } -} diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts deleted file mode 100644 index 7f671b0a..00000000 --- a/packages/web-ui/src/dialogs/SettingsDialog.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { getProviders } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; -import { Dialog, DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; -import { Input } from "@mariozechner/mini-lit/dist/Input.js"; -import { Label } from "@mariozechner/mini-lit/dist/Label.js"; -import { Switch } from "@mariozechner/mini-lit/dist/Switch.js"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import "../components/ProviderKeyInput.js"; -import { getAppStorage } from "../storage/app-storage.js"; - -// Base class for settings tabs -export abstract class SettingsTab extends LitElement { - abstract getTabName(): string; - - protected createRenderRoot() { - return this; - } -} - -// API Keys Tab -@customElement("api-keys-tab") -export class ApiKeysTab extends SettingsTab { - getTabName(): string { - return i18n("API Keys"); - } - - render(): TemplateResult { - const providers = getProviders(); - - return html` -
-

- ${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")} -

- ${providers.map((provider) => html``)} -
- `; - } -} - -// Proxy Tab -@customElement("proxy-tab") -export class ProxyTab extends SettingsTab { - @state() private proxyEnabled = false; - @state() private proxyUrl = "http://localhost:3001"; - - override async connectedCallback() { - super.connectedCallback(); - // Load proxy settings when tab is connected - try { - const storage = getAppStorage(); - const enabled = await storage.settings.get("proxy.enabled"); - const url = await storage.settings.get("proxy.url"); - - if (enabled !== null) this.proxyEnabled = enabled; - if (url !== null) this.proxyUrl = url; - } catch (error) { - console.error("Failed to load proxy settings:", error); - } - } - - private async saveProxySettings() { - try { - const storage = getAppStorage(); - await storage.settings.set("proxy.enabled", this.proxyEnabled); - await storage.settings.set("proxy.url", this.proxyUrl); - } catch (error) { - console.error("Failed to save proxy settings:", error); - } - } - - getTabName(): string { - return i18n("Proxy"); - } - - render(): TemplateResult { - return html` -
-

- ${i18n("Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.")} -

- -
- ${i18n("Use CORS Proxy")} - ${Switch({ - checked: this.proxyEnabled, - onChange: (checked: boolean) => { - this.proxyEnabled = checked; - this.saveProxySettings(); - }, - })} -
- -
- ${Label({ children: i18n("Proxy URL") })} - ${Input({ - type: "text", - value: this.proxyUrl, - disabled: !this.proxyEnabled, - onInput: (e) => { - this.proxyUrl = (e.target as HTMLInputElement).value; - }, - onChange: () => this.saveProxySettings(), - })} -

- ${i18n("Format: The proxy must accept requests as /?url=")} -

-
-
- `; - } -} - -@customElement("settings-dialog") -export class SettingsDialog extends LitElement { - @property({ type: Array, attribute: false }) tabs: SettingsTab[] = []; - @state() private isOpen = false; - @state() private activeTabIndex = 0; - - protected createRenderRoot() { - return this; - } - - private onCloseCallback?: () => void; - - static async open(tabs: SettingsTab[], onClose?: () => void) { - const dialog = new SettingsDialog(); - dialog.tabs = tabs; - dialog.onCloseCallback = onClose; - dialog.isOpen = true; - document.body.appendChild(dialog); - } - - private setActiveTab(index: number) { - this.activeTabIndex = index; - } - - private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult { - const isActive = this.activeTabIndex === index; - return html` - - `; - } - - private renderMobileTab(tab: SettingsTab, index: number): TemplateResult { - const isActive = this.activeTabIndex === index; - return html` - - `; - } - - render() { - if (this.tabs.length === 0) { - return html``; - } - - return Dialog({ - isOpen: this.isOpen, - onClose: () => { - this.isOpen = false; - this.remove(); - this.onCloseCallback?.(); - }, - width: "min(1000px, 90vw)", - height: "min(800px, 90vh)", - backdropClassName: "bg-black/50 backdrop-blur-sm", - children: html` - ${DialogContent({ - className: "h-full p-6", - children: html` -
- -
${DialogHeader({ title: i18n("Settings") })}
- - -
- ${this.tabs.map((tab, index) => this.renderMobileTab(tab, index))} -
- - -
- - - - -
- ${this.tabs.map( - (tab, index) => - html`
${tab}
`, - )} -
-
-
- `, - })} - `, - }); - } -} diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts deleted file mode 100644 index c808cde2..00000000 --- a/packages/web-ui/src/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Main chat interface - -export type { Agent, AgentMessage, AgentState, ThinkingLevel } from "@earendil-works/pi-agent-core"; -export type { Model } from "@earendil-works/pi-ai"; -export { ChatPanel } from "./ChatPanel.js"; -// Components -export { AgentInterface } from "./components/AgentInterface.js"; -export { AttachmentTile } from "./components/AttachmentTile.js"; -export { ConsoleBlock } from "./components/ConsoleBlock.js"; -export { CustomProviderCard } from "./components/CustomProviderCard.js"; -export { ExpandableSection } from "./components/ExpandableSection.js"; -export { Input } from "./components/Input.js"; -export { MessageEditor } from "./components/MessageEditor.js"; -export { MessageList } from "./components/MessageList.js"; -// Message components -export type { ArtifactMessage, UserMessageWithAttachments } from "./components/Messages.js"; -export { - AbortedMessage, - AssistantMessage, - convertAttachments, - defaultConvertToLlm, - isArtifactMessage, - isUserMessageWithAttachments, - ToolMessage, - ToolMessageDebugView, - UserMessage, -} from "./components/Messages.js"; -// Message renderer registry -export { - getMessageRenderer, - type MessageRenderer, - type MessageRole, - registerMessageRenderer, - renderMessage, -} from "./components/message-renderer-registry.js"; -export { ProviderKeyInput } from "./components/ProviderKeyInput.js"; -export { - type SandboxFile, - SandboxIframe, - type SandboxResult, - type SandboxUrlProvider, -} from "./components/SandboxedIframe.js"; -export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js"; -// Sandbox Runtime Providers -export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js"; -export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js"; -export { type ConsoleLog, ConsoleRuntimeProvider } from "./components/sandbox/ConsoleRuntimeProvider.js"; -export { - type DownloadableFile, - FileDownloadRuntimeProvider, -} from "./components/sandbox/FileDownloadRuntimeProvider.js"; -export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js"; -export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js"; -export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js"; -export { ThinkingBlock } from "./components/ThinkingBlock.js"; -export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; -export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; -export { CustomProviderDialog } from "./dialogs/CustomProviderDialog.js"; -// Dialogs -export { ModelSelector } from "./dialogs/ModelSelector.js"; -export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js"; -export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js"; -export { SessionListDialog } from "./dialogs/SessionListDialog.js"; -export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js"; -// Prompts -export { - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW, - ATTACHMENTS_RUNTIME_DESCRIPTION, -} from "./prompts/prompts.js"; -// Storage -export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js"; -export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js"; -export { Store } from "./storage/store.js"; -export type { - AutoDiscoveryProviderType, - CustomProvider, - CustomProviderType, -} from "./storage/stores/custom-providers-store.js"; -export { CustomProvidersStore } from "./storage/stores/custom-providers-store.js"; -export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js"; -export { SessionsStore } from "./storage/stores/sessions-store.js"; -export { SettingsStore } from "./storage/stores/settings-store.js"; -export type { - IndexConfig, - IndexedDBConfig, - SessionData, - SessionMetadata, - StorageBackend, - StorageTransaction, - StoreConfig, -} from "./storage/types.js"; -// Artifacts -export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js"; -export { ArtifactPill } from "./tools/artifacts/ArtifactPill.js"; -export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js"; -export { ArtifactsToolRenderer } from "./tools/artifacts/artifacts-tool-renderer.js"; -export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js"; -export { ImageArtifact } from "./tools/artifacts/ImageArtifact.js"; -export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js"; -export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js"; -export { TextArtifact } from "./tools/artifacts/TextArtifact.js"; -export { createExtractDocumentTool, extractDocumentTool } from "./tools/extract-document.js"; -// Tools -export { getToolRenderer, registerToolRenderer, renderTool, setShowJsonMode } from "./tools/index.js"; -export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js"; -export { renderCollapsibleHeader, renderHeader } from "./tools/renderer-registry.js"; -export { BashRenderer } from "./tools/renderers/BashRenderer.js"; -export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js"; -// Tool renderers -export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js"; -export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js"; -export type { ToolRenderer, ToolRenderResult } from "./tools/types.js"; -export type { Attachment } from "./utils/attachment-utils.js"; -// Utils -export { loadAttachment } from "./utils/attachment-utils.js"; -export { clearAuthToken, getAuthToken } from "./utils/auth-token.js"; -export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js"; -export { i18n, setLanguage, translations } from "./utils/i18n.js"; -export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js"; diff --git a/packages/web-ui/src/prompts/prompts.ts b/packages/web-ui/src/prompts/prompts.ts deleted file mode 100644 index c11e0c48..00000000 --- a/packages/web-ui/src/prompts/prompts.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Centralized tool prompts/descriptions. - * Each prompt is either a string constant or a template function. - */ - -// ============================================================================ -// JavaScript REPL Tool -// ============================================================================ - -export const JAVASCRIPT_REPL_TOOL_DESCRIPTION = (runtimeProviderDescriptions: string[]) => `# JavaScript REPL - -## Purpose -Execute JavaScript code in a sandboxed browser environment with full Web APIs. - -## When to Use -- Quick calculations or data transformations -- Testing JavaScript code snippets in isolation -- Processing data with libraries (XLSX, CSV, etc.) -- Creating artifacts from data - -## Environment -- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.) -- All browser APIs: DOM, Canvas, WebGL, Fetch, Web Workers, WebSockets, Crypto, etc. -- Import any npm package: await import('https://esm.run/package-name') - -## Common Libraries -- XLSX: const XLSX = await import('https://esm.run/xlsx'); -- CSV: const Papa = (await import('https://esm.run/papaparse')).default; -- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default; -- Three.js: const THREE = await import('https://esm.run/three'); - -## Persistence between tool calls -- Objects stored on global scope do not persist between calls. -- Use artifacts as a key-value JSON object store: - - Use createOrUpdateArtifact(filename, content) to persist data between calls. JSON objects are auto-stringified. - - Use listArtifacts() and getArtifact(filename) to read persisted data. JSON files are auto-parsed to objects. - - Prefer to use a single artifact throughout the session to store intermediate data (e.g. 'data.json'). - -## Input -- You have access to the user's attachments via listAttachments(), readTextAttachment(id), and readBinaryAttachment(id) -- You have access to previously created artifacts via listArtifacts() and getArtifact(filename) - -## Output -- All console.log() calls are captured for you to inspect. The user does not see these logs. -- Create artifacts for file results (images, JSON, CSV, etc.) which persiste throughout the - session and are accessible to you and the user. - -## Example -const data = [10, 20, 15, 25]; -const sum = data.reduce((a, b) => a + b, 0); -const avg = sum / data.length; -console.log('Sum:', sum, 'Average:', avg); - -## Important Notes -- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height -- Chart.js: Set options: { responsive: false, animation: false } -- Three.js: renderer.setSize(800, 600) with matching aspect ratio - -## Helper Functions (Automatically Available) - -These functions are injected into the execution environment and available globally: - -${runtimeProviderDescriptions.join("\n\n")} -`; - -// ============================================================================ -// Artifacts Tool -// ============================================================================ - -export const ARTIFACTS_TOOL_DESCRIPTION = (runtimeProviderDescriptions: string[]) => `# Artifacts - -Create and manage persistent files that live alongside the conversation. - -## When to Use - Artifacts Tool vs REPL - -**Use artifacts tool when YOU are the author:** -- Writing research summaries, analysis, ideas, documentation -- Creating markdown notes for user to read -- Building HTML applications/visualizations that present data -- Creating HTML artifacts that render charts from programmatically generated data - -**Use repl + artifact storage functions when CODE processes data:** -- Scraping workflows that extract and store data -- Processing CSV/Excel files programmatically -- Data transformation pipelines -- Binary file generation requiring libraries (PDF, DOCX) - -**Pattern: REPL generates data → Artifacts tool creates HTML that visualizes it** -Example: repl scrapes products → stores products.json → you author dashboard.html that reads products.json and renders Chart.js visualizations - -## Input -- { action: "create", filename: "notes.md", content: "..." } - Create new file -- { action: "update", filename: "notes.md", old_str: "...", new_str: "..." } - Update part of file (PREFERRED) -- { action: "rewrite", filename: "notes.md", content: "..." } - Replace entire file (LAST RESORT) -- { action: "get", filename: "data.json" } - Retrieve file content -- { action: "delete", filename: "old.csv" } - Delete file -- { action: "htmlArtifactLogs", filename: "app.html" } - Get console logs from HTML artifact - -## Returns -Depends on action: -- create/update/rewrite/delete: Success status or error -- get: File content -- htmlArtifactLogs: Console logs and errors - -## Supported File Types -✅ Text-based files you author: .md, .txt, .html, .js, .css, .json, .csv, .svg -❌ Binary files requiring libraries (use repl): .pdf, .docx - -## Critical - Prefer Update Over Rewrite -❌ NEVER: get entire file + rewrite to change small sections -✅ ALWAYS: update for targeted edits (token efficient) -✅ Ask: Can I describe the change as old_str → new_str? Use update. - ---- - -## HTML Artifacts - -Interactive HTML applications that can visualize data from other artifacts. - -### Data Access -- Can read artifacts created by repl and user attachments -- Use to build dashboards, visualizations, interactive tools -- See Helper Functions section below for available functions - -### Requirements -- Self-contained single file -- Import ES modules from esm.sh: -- Use Tailwind CDN: -- Can embed images from any domain: -- MUST set background color explicitly (avoid transparent) -- Inline CSS or Tailwind utility classes -- No localStorage/sessionStorage - -### Styling -- Use Tailwind utility classes for clean, functional designs -- Ensure responsive layout (iframe may be resized) -- Avoid purple gradients, AI aesthetic clichés, and emojis - -### Helper Functions (Automatically Available) - -These functions are injected into HTML artifact sandbox: - -${runtimeProviderDescriptions.join("\n\n")} -`; - -// ============================================================================ -// Artifacts Runtime Provider -// ============================================================================ - -export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW = ` -### Artifacts Storage - -Create, read, update, and delete files in artifacts storage. - -#### When to Use -- Store intermediate results between tool calls -- Save generated files (images, CSVs, processed data) for user to view and download - -#### Do NOT Use For -- Content you author directly, like summaries of content you read (use artifacts tool instead) - -#### Functions -- listArtifacts() - List all artifact filenames, returns Promise -- getArtifact(filename) - Read artifact content, returns Promise. JSON files auto-parse to objects, binary files return base64 string -- createOrUpdateArtifact(filename, content, mimeType?) - Create or update artifact, returns Promise. JSON files auto-stringify objects, binary requires base64 string with mimeType -- deleteArtifact(filename) - Delete artifact, returns Promise - -#### Example -JSON workflow: -\`\`\`javascript -// Fetch and save -const response = await fetch('https://api.example.com/products'); -const products = await response.json(); -await createOrUpdateArtifact('products.json', products); - -// Later: read and filter -const all = await getArtifact('products.json'); -const cheap = all.filter(p => p.price < 100); -await createOrUpdateArtifact('cheap.json', cheap); -\`\`\` - -Binary file (image): -\`\`\`javascript -const canvas = document.createElement('canvas'); -canvas.width = 800; canvas.height = 600; -const ctx = canvas.getContext('2d'); -ctx.fillStyle = 'blue'; -ctx.fillRect(0, 0, 800, 600); -// Remove data:image/png;base64, prefix -const base64 = canvas.toDataURL().split(',')[1]; -await createOrUpdateArtifact('chart.png', base64, 'image/png'); -\`\`\` -`; - -export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO = ` -### Artifacts Storage - -Read files from artifacts storage. - -#### When to Use -- Read artifacts created by REPL or artifacts tool -- Access data from other HTML artifacts -- Load configuration or data files - -#### Do NOT Use For -- Creating new artifacts (not available in HTML artifacts) -- Modifying artifacts (read-only access) - -#### Functions -- listArtifacts() - List all artifact filenames, returns Promise -- getArtifact(filename) - Read artifact content, returns Promise. JSON files auto-parse to objects, binary files return base64 string - -#### Example -JSON data: -\`\`\`javascript -const products = await getArtifact('products.json'); -const html = products.map(p => \`
\${p.name}: $\${p.price}
\`).join(''); -document.body.innerHTML = html; -\`\`\` - -Binary image: -\`\`\`javascript -const base64 = await getArtifact('chart.png'); -const img = document.createElement('img'); -img.src = 'data:image/png;base64,' + base64; -document.body.appendChild(img); -\`\`\` -`; - -// ============================================================================ -// Attachments Runtime Provider -// ============================================================================ - -export const ATTACHMENTS_RUNTIME_DESCRIPTION = ` -### User Attachments - -Read files the user uploaded to the conversation. - -#### When to Use -- Process user-uploaded files (CSV, JSON, Excel, images, PDFs) - -#### Functions -- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size} -- readTextAttachment(id) - Read attachment as text, returns string -- readBinaryAttachment(id) - Read attachment as binary data, returns Uint8Array - -#### Example -CSV file: -\`\`\`javascript -const files = listAttachments(); -const csvFile = files.find(f => f.fileName.endsWith('.csv')); -const csvData = readTextAttachment(csvFile.id); -const rows = csvData.split('\\n').map(row => row.split(',')); -\`\`\` - -Excel file: -\`\`\`javascript -const XLSX = await import('https://esm.run/xlsx'); -const files = listAttachments(); -const xlsxFile = files.find(f => f.fileName.endsWith('.xlsx')); -const bytes = readBinaryAttachment(xlsxFile.id); -const workbook = XLSX.read(bytes); -const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]); -\`\`\` -`; - -// ============================================================================ -// Extract Document Tool -// ============================================================================ - -export const EXTRACT_DOCUMENT_DESCRIPTION = `# Extract Document - -Extract plain text from documents on the web (PDF, DOCX, XLSX, PPTX). - -## When to Use -User wants you to read a document at a URL. - -## Input -- { url: "https://example.com/document.pdf" } - URL to PDF, DOCX, XLSX, or PPTX - -## Returns -Structured plain text with page/sheet/slide delimiters.`; diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts deleted file mode 100644 index 420104d1..00000000 --- a/packages/web-ui/src/storage/app-storage.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { CustomProvidersStore } from "./stores/custom-providers-store.js"; -import type { ProviderKeysStore } from "./stores/provider-keys-store.js"; -import type { SessionsStore } from "./stores/sessions-store.js"; -import type { SettingsStore } from "./stores/settings-store.js"; -import type { StorageBackend } from "./types.js"; - -/** - * High-level storage API providing access to all storage operations. - * Subclasses can extend this to add domain-specific stores. - */ -export class AppStorage { - readonly backend: StorageBackend; - readonly settings: SettingsStore; - readonly providerKeys: ProviderKeysStore; - readonly sessions: SessionsStore; - readonly customProviders: CustomProvidersStore; - - constructor( - settings: SettingsStore, - providerKeys: ProviderKeysStore, - sessions: SessionsStore, - customProviders: CustomProvidersStore, - backend: StorageBackend, - ) { - this.settings = settings; - this.providerKeys = providerKeys; - this.sessions = sessions; - this.customProviders = customProviders; - this.backend = backend; - } - - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - return this.backend.getQuotaInfo(); - } - - async requestPersistence(): Promise { - return this.backend.requestPersistence(); - } -} - -// Global instance management -let globalAppStorage: AppStorage | null = null; - -/** - * Get the global AppStorage instance. - * Throws if not initialized. - */ -export function getAppStorage(): AppStorage { - if (!globalAppStorage) { - throw new Error("AppStorage not initialized. Call setAppStorage() first."); - } - return globalAppStorage; -} - -/** - * Set the global AppStorage instance. - */ -export function setAppStorage(storage: AppStorage): void { - globalAppStorage = storage; -} diff --git a/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts b/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts deleted file mode 100644 index 619c41d9..00000000 --- a/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { IndexedDBConfig, StorageBackend, StorageTransaction } from "../types.js"; - -/** - * IndexedDB implementation of StorageBackend. - * Provides multi-store key-value storage with transactions and quota management. - */ -export class IndexedDBStorageBackend implements StorageBackend { - private dbPromise: Promise | null = null; - - constructor(private config: IndexedDBConfig) {} - - private async getDB(): Promise { - if (!this.dbPromise) { - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.config.dbName, this.config.version); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (_event) => { - const db = request.result; - - // Create object stores from config - for (const storeConfig of this.config.stores) { - if (!db.objectStoreNames.contains(storeConfig.name)) { - const store = db.createObjectStore(storeConfig.name, { - keyPath: storeConfig.keyPath, - autoIncrement: storeConfig.autoIncrement, - }); - - // Create indices - if (storeConfig.indices) { - for (const indexConfig of storeConfig.indices) { - store.createIndex(indexConfig.name, indexConfig.keyPath, { - unique: indexConfig.unique, - }); - } - } - } - } - }; - }); - } - - return this.dbPromise; - } - - private promisifyRequest(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - async get(storeName: string, key: string): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const result = await this.promisifyRequest(store.get(key)); - return result ?? null; - } - - async set(storeName: string, key: string, value: T): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readwrite"); - const store = tx.objectStore(storeName); - // If store has keyPath, only pass value (in-line key) - // Otherwise pass both value and key (out-of-line key) - if (store.keyPath) { - await this.promisifyRequest(store.put(value)); - } else { - await this.promisifyRequest(store.put(value, key)); - } - } - - async delete(storeName: string, key: string): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readwrite"); - const store = tx.objectStore(storeName); - await this.promisifyRequest(store.delete(key)); - } - - async keys(storeName: string, prefix?: string): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - - if (prefix) { - // Use IDBKeyRange for efficient prefix filtering - const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`, false, false); - const keys = await this.promisifyRequest(store.getAllKeys(range)); - return keys.map((k) => String(k)); - } else { - const keys = await this.promisifyRequest(store.getAllKeys()); - return keys.map((k) => String(k)); - } - } - - async getAllFromIndex( - storeName: string, - indexName: string, - direction: "asc" | "desc" = "asc", - ): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const index = store.index(indexName); - - return new Promise((resolve, reject) => { - const results: T[] = []; - const request = index.openCursor(null, direction === "desc" ? "prev" : "next"); - - request.onsuccess = () => { - const cursor = request.result; - if (cursor) { - results.push(cursor.value as T); - cursor.continue(); - } else { - resolve(results); - } - }; - - request.onerror = () => reject(request.error); - }); - } - - async clear(storeName: string): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readwrite"); - const store = tx.objectStore(storeName); - await this.promisifyRequest(store.clear()); - } - - async has(storeName: string, key: string): Promise { - const db = await this.getDB(); - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const result = await this.promisifyRequest(store.getKey(key)); - return result !== undefined; - } - - async transaction( - storeNames: string[], - mode: "readonly" | "readwrite", - operation: (tx: StorageTransaction) => Promise, - ): Promise { - const db = await this.getDB(); - const idbTx = db.transaction(storeNames, mode); - - const storageTx: StorageTransaction = { - get: async (storeName: string, key: string) => { - const store = idbTx.objectStore(storeName); - const result = await this.promisifyRequest(store.get(key)); - return (result ?? null) as T | null; - }, - set: async (storeName: string, key: string, value: T) => { - const store = idbTx.objectStore(storeName); - // If store has keyPath, only pass value (in-line key) - // Otherwise pass both value and key (out-of-line key) - if (store.keyPath) { - await this.promisifyRequest(store.put(value)); - } else { - await this.promisifyRequest(store.put(value, key)); - } - }, - delete: async (storeName: string, key: string) => { - const store = idbTx.objectStore(storeName); - await this.promisifyRequest(store.delete(key)); - }, - }; - - return operation(storageTx); - } - - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - if (navigator.storage?.estimate) { - const estimate = await navigator.storage.estimate(); - return { - usage: estimate.usage || 0, - quota: estimate.quota || 0, - percent: estimate.quota ? ((estimate.usage || 0) / estimate.quota) * 100 : 0, - }; - } - return { usage: 0, quota: 0, percent: 0 }; - } - - async requestPersistence(): Promise { - if (navigator.storage?.persist) { - return await navigator.storage.persist(); - } - return false; - } -} diff --git a/packages/web-ui/src/storage/store.ts b/packages/web-ui/src/storage/store.ts deleted file mode 100644 index b5ea8b08..00000000 --- a/packages/web-ui/src/storage/store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { StorageBackend, StoreConfig } from "./types.js"; - -/** - * Base class for all storage stores. - * Each store defines its IndexedDB schema and provides domain-specific methods. - */ -export abstract class Store { - private backend: StorageBackend | null = null; - - /** - * Returns the IndexedDB configuration for this store. - * Defines store name, key path, and indices. - */ - abstract getConfig(): StoreConfig; - - /** - * Sets the storage backend. Called by AppStorage after backend creation. - */ - setBackend(backend: StorageBackend): void { - this.backend = backend; - } - - /** - * Gets the storage backend. Throws if backend not set. - * Concrete stores must use this to access the backend. - */ - protected getBackend(): StorageBackend { - if (!this.backend) { - throw new Error(`Backend not set on ${this.constructor.name}`); - } - return this.backend; - } -} diff --git a/packages/web-ui/src/storage/stores/custom-providers-store.ts b/packages/web-ui/src/storage/stores/custom-providers-store.ts deleted file mode 100644 index 5a686b62..00000000 --- a/packages/web-ui/src/storage/stores/custom-providers-store.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { Store } from "../store.js"; -import type { StoreConfig } from "../types.js"; - -export type AutoDiscoveryProviderType = "ollama" | "llama.cpp" | "vllm" | "lmstudio"; - -export type CustomProviderType = - | AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand - | "openai-completions" // Manual models - stored in provider.models - | "openai-responses" // Manual models - stored in provider.models - | "anthropic-messages"; // Manual models - stored in provider.models - -export interface CustomProvider { - id: string; // UUID - name: string; // Display name, also used as Model.provider - type: CustomProviderType; - baseUrl: string; - apiKey?: string; // Optional, applies to all models - - // For manual types ONLY - models stored directly on provider - // Auto-discovery types: models fetched on-demand, never stored - models?: Model[]; -} - -/** - * Store for custom LLM providers (auto-discovery servers + manual providers). - */ -export class CustomProvidersStore extends Store { - getConfig(): StoreConfig { - return { - name: "custom-providers", - }; - } - - async get(id: string): Promise { - return this.getBackend().get("custom-providers", id); - } - - async set(provider: CustomProvider): Promise { - await this.getBackend().set("custom-providers", provider.id, provider); - } - - async delete(id: string): Promise { - await this.getBackend().delete("custom-providers", id); - } - - async getAll(): Promise { - const keys = await this.getBackend().keys("custom-providers"); - const providers: CustomProvider[] = []; - for (const key of keys) { - const provider = await this.get(key); - if (provider) { - providers.push(provider); - } - } - return providers; - } - - async has(id: string): Promise { - return this.getBackend().has("custom-providers", id); - } -} diff --git a/packages/web-ui/src/storage/stores/provider-keys-store.ts b/packages/web-ui/src/storage/stores/provider-keys-store.ts deleted file mode 100644 index 41cf8885..00000000 --- a/packages/web-ui/src/storage/stores/provider-keys-store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Store } from "../store.js"; -import type { StoreConfig } from "../types.js"; - -/** - * Store for LLM provider API keys (Anthropic, OpenAI, etc.). - */ -export class ProviderKeysStore extends Store { - getConfig(): StoreConfig { - return { - name: "provider-keys", - }; - } - - async get(provider: string): Promise { - return this.getBackend().get("provider-keys", provider); - } - - async set(provider: string, key: string): Promise { - await this.getBackend().set("provider-keys", provider, key); - } - - async delete(provider: string): Promise { - await this.getBackend().delete("provider-keys", provider); - } - - async list(): Promise { - return this.getBackend().keys("provider-keys"); - } - - async has(provider: string): Promise { - return this.getBackend().has("provider-keys", provider); - } -} diff --git a/packages/web-ui/src/storage/stores/sessions-store.ts b/packages/web-ui/src/storage/stores/sessions-store.ts deleted file mode 100644 index d244f955..00000000 --- a/packages/web-ui/src/storage/stores/sessions-store.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { AgentState } from "@earendil-works/pi-agent-core"; -import { Store } from "../store.js"; -import type { SessionData, SessionMetadata, StoreConfig } from "../types.js"; - -/** - * Store for chat sessions (data and metadata). - * Uses two object stores: sessions (full data) and sessions-metadata (lightweight). - */ -export class SessionsStore extends Store { - getConfig(): StoreConfig { - return { - name: "sessions", - keyPath: "id", - indices: [{ name: "lastModified", keyPath: "lastModified" }], - }; - } - - /** - * Additional config for sessions-metadata store. - * Must be included when creating the backend. - */ - static getMetadataConfig(): StoreConfig { - return { - name: "sessions-metadata", - keyPath: "id", - indices: [{ name: "lastModified", keyPath: "lastModified" }], - }; - } - - async save(data: SessionData, metadata: SessionMetadata): Promise { - await this.getBackend().transaction(["sessions", "sessions-metadata"], "readwrite", async (tx) => { - await tx.set("sessions", data.id, data); - await tx.set("sessions-metadata", metadata.id, metadata); - }); - } - - async get(id: string): Promise { - return this.getBackend().get("sessions", id); - } - - async getMetadata(id: string): Promise { - return this.getBackend().get("sessions-metadata", id); - } - - async getAllMetadata(): Promise { - // Use the lastModified index to get sessions sorted by most recent first - return this.getBackend().getAllFromIndex("sessions-metadata", "lastModified", "desc"); - } - - async delete(id: string): Promise { - await this.getBackend().transaction(["sessions", "sessions-metadata"], "readwrite", async (tx) => { - await tx.delete("sessions", id); - await tx.delete("sessions-metadata", id); - }); - } - - // Alias for backward compatibility - async deleteSession(id: string): Promise { - return this.delete(id); - } - - async updateTitle(id: string, title: string): Promise { - const metadata = await this.getMetadata(id); - if (metadata) { - metadata.title = title; - await this.getBackend().set("sessions-metadata", id, metadata); - } - - // Also update in full session data - const data = await this.get(id); - if (data) { - data.title = title; - await this.getBackend().set("sessions", id, data); - } - } - - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - return this.getBackend().getQuotaInfo(); - } - - async requestPersistence(): Promise { - return this.getBackend().requestPersistence(); - } - - // Alias methods for backward compatibility - async saveSession( - id: string, - state: AgentState, - metadata: SessionMetadata | undefined, - title?: string, - ): Promise { - // If metadata is provided, use it; otherwise create it from state - const meta: SessionMetadata = metadata || { - id, - title: title || "", - createdAt: new Date().toISOString(), - lastModified: new Date().toISOString(), - messageCount: state.messages?.length || 0, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - thinkingLevel: state.thinkingLevel || "off", - preview: "", - }; - - const data: SessionData = { - id, - title: title || meta.title, - model: state.model, - thinkingLevel: state.thinkingLevel, - messages: state.messages || [], - createdAt: meta.createdAt, - lastModified: new Date().toISOString(), - }; - - await this.save(data, meta); - } - - async loadSession(id: string): Promise { - return this.get(id); - } - - async getLatestSessionId(): Promise { - const allMetadata = await this.getAllMetadata(); - if (allMetadata.length === 0) return null; - - // Sort by lastModified descending - allMetadata.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); - return allMetadata[0].id; - } -} diff --git a/packages/web-ui/src/storage/stores/settings-store.ts b/packages/web-ui/src/storage/stores/settings-store.ts deleted file mode 100644 index f2f3181e..00000000 --- a/packages/web-ui/src/storage/stores/settings-store.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Store } from "../store.js"; -import type { StoreConfig } from "../types.js"; - -/** - * Store for application settings (theme, proxy config, etc.). - */ -export class SettingsStore extends Store { - getConfig(): StoreConfig { - return { - name: "settings", - // No keyPath - uses out-of-line keys - }; - } - - async get(key: string): Promise { - return this.getBackend().get("settings", key); - } - - async set(key: string, value: T): Promise { - await this.getBackend().set("settings", key, value); - } - - async delete(key: string): Promise { - await this.getBackend().delete("settings", key); - } - - async list(): Promise { - return this.getBackend().keys("settings"); - } - - async clear(): Promise { - await this.getBackend().clear("settings"); - } -} diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts deleted file mode 100644 index 7ed2bd0f..00000000 --- a/packages/web-ui/src/storage/types.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; - -/** - * Transaction interface for atomic operations across stores. - */ -export interface StorageTransaction { - /** - * Get a value by key from a specific store. - */ - get(storeName: string, key: string): Promise; - - /** - * Set a value for a key in a specific store. - */ - set(storeName: string, key: string, value: T): Promise; - - /** - * Delete a key from a specific store. - */ - delete(storeName: string, key: string): Promise; -} - -/** - * Base interface for all storage backends. - * Multi-store key-value storage abstraction that can be implemented - * by IndexedDB, remote APIs, or any other multi-collection storage system. - */ -export interface StorageBackend { - /** - * Get a value by key from a specific store. Returns null if key doesn't exist. - */ - get(storeName: string, key: string): Promise; - - /** - * Set a value for a key in a specific store. - */ - set(storeName: string, key: string, value: T): Promise; - - /** - * Delete a key from a specific store. - */ - delete(storeName: string, key: string): Promise; - - /** - * Get all keys from a specific store, optionally filtered by prefix. - */ - keys(storeName: string, prefix?: string): Promise; - - /** - * Get all values from a specific store, ordered by an index. - * @param storeName - The store to query - * @param indexName - The index to use for ordering - * @param direction - Sort direction ("asc" or "desc") - */ - getAllFromIndex(storeName: string, indexName: string, direction?: "asc" | "desc"): Promise; - - /** - * Clear all data from a specific store. - */ - clear(storeName: string): Promise; - - /** - * Check if a key exists in a specific store. - */ - has(storeName: string, key: string): Promise; - - /** - * Execute atomic operations across multiple stores. - */ - transaction( - storeNames: string[], - mode: "readonly" | "readwrite", - operation: (tx: StorageTransaction) => Promise, - ): Promise; - - /** - * Get storage quota information. - * Used for warning users when approaching limits. - */ - getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>; - - /** - * Request persistent storage (prevents eviction). - * Returns true if granted, false otherwise. - */ - requestPersistence(): Promise; -} - -/** - * Lightweight session metadata for listing and searching. - * Stored separately from full session data for performance. - */ -export interface SessionMetadata { - /** Unique session identifier (UUID v4) */ - id: string; - - /** User-defined title or auto-generated from first message */ - title: string; - - /** ISO 8601 UTC timestamp of creation */ - createdAt: string; - - /** ISO 8601 UTC timestamp of last modification */ - lastModified: string; - - /** Total number of messages (user + assistant + tool results) */ - messageCount: number; - - /** Cumulative usage statistics */ - usage: { - /** Total input tokens */ - input: number; - /** Total output tokens */ - output: number; - /** Total cache read tokens */ - cacheRead: number; - /** Total cache write tokens */ - cacheWrite: number; - /** Total tokens processed */ - totalTokens: number; - /** Total cost breakdown */ - cost: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - total: number; - }; - }; - - /** Last used thinking level */ - thinkingLevel: ThinkingLevel; - - /** - * Preview text for search and display. - * First 2KB of conversation text (user + assistant messages in sequence). - * Tool calls and tool results are excluded. - */ - preview: string; -} - -/** - * Full session data including all messages. - * Only loaded when user opens a specific session. - */ -export interface SessionData { - /** Unique session identifier (UUID v4) */ - id: string; - - /** User-defined title or auto-generated from first message */ - title: string; - - /** Last selected model */ - model: Model; - - /** Last selected thinking level */ - thinkingLevel: ThinkingLevel; - - /** Full conversation history (with attachments inline) */ - messages: AgentMessage[]; - - /** ISO 8601 UTC timestamp of creation */ - createdAt: string; - - /** ISO 8601 UTC timestamp of last modification */ - lastModified: string; -} - -/** - * Configuration for IndexedDB backend. - */ -export interface IndexedDBConfig { - /** Database name */ - dbName: string; - /** Database version */ - version: number; - /** Object stores to create */ - stores: StoreConfig[]; -} - -/** - * Configuration for an IndexedDB object store. - */ -export interface StoreConfig { - /** Store name */ - name: string; - /** Key path (optional, for auto-extracting keys from objects) */ - keyPath?: string; - /** Auto-increment keys (optional) */ - autoIncrement?: boolean; - /** Indices to create on this store */ - indices?: IndexConfig[]; -} - -/** - * Configuration for an IndexedDB index. - */ -export interface IndexConfig { - /** Index name */ - name: string; - /** Key path to index on */ - keyPath: string; - /** Unique constraint (optional) */ - unique?: boolean; -} diff --git a/packages/web-ui/src/tools/artifacts/ArtifactElement.ts b/packages/web-ui/src/tools/artifacts/ArtifactElement.ts deleted file mode 100644 index f27aa7c8..00000000 --- a/packages/web-ui/src/tools/artifacts/ArtifactElement.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LitElement, type TemplateResult } from "lit"; - -export abstract class ArtifactElement extends LitElement { - public filename = ""; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM for shared styles - } - - public abstract get content(): string; - public abstract set content(value: string); - - abstract getHeaderButtons(): TemplateResult | HTMLElement; -} diff --git a/packages/web-ui/src/tools/artifacts/ArtifactPill.ts b/packages/web-ui/src/tools/artifacts/ArtifactPill.ts deleted file mode 100644 index 13078fcd..00000000 --- a/packages/web-ui/src/tools/artifacts/ArtifactPill.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import { html, type TemplateResult } from "lit"; -import { FileCode2 } from "lucide"; -import type { ArtifactsPanel } from "./artifacts.js"; - -export function ArtifactPill(filename: string, artifactsPanel?: ArtifactsPanel): TemplateResult { - const handleClick = (e: Event) => { - if (!artifactsPanel) return; - e.preventDefault(); - e.stopPropagation(); - // openArtifact will show the artifact and call onOpen() to open the panel if needed - artifactsPanel.openArtifact(filename); - }; - - return html` - - ${icon(FileCode2, "sm")} - ${filename} - - `; -} diff --git a/packages/web-ui/src/tools/artifacts/Console.ts b/packages/web-ui/src/tools/artifacts/Console.ts deleted file mode 100644 index 287ed043..00000000 --- a/packages/web-ui/src/tools/artifacts/Console.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import "@mariozechner/mini-lit/dist/CopyButton.js"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { createRef, type Ref, ref } from "lit/directives/ref.js"; -import { repeat } from "lit/directives/repeat.js"; -import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide"; -import { i18n } from "../../utils/i18n.js"; - -interface LogEntry { - type: "log" | "error"; - text: string; -} - -@customElement("artifact-console") -export class Console extends LitElement { - @property({ attribute: false }) logs: LogEntry[] = []; - @state() private expanded = false; - @state() private autoscroll = true; - private logsContainerRef: Ref = createRef(); - - protected createRenderRoot() { - return this; // light DOM - } - - override updated() { - // Autoscroll to bottom when new logs arrive - if (this.autoscroll && this.expanded && this.logsContainerRef.value) { - this.logsContainerRef.value.scrollTop = this.logsContainerRef.value.scrollHeight; - } - } - - private getLogsText(): string { - return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n"); - } - - override render(): TemplateResult { - const errorCount = this.logs.filter((l) => l.type === "error").length; - const summary = - errorCount > 0 - ? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})` - : `${i18n("console")} (${this.logs.length})`; - - return html` -
-
- - ${ - this.expanded - ? html` - - - ` - : "" - } -
- ${ - this.expanded - ? html` -
- ${repeat( - this.logs, - (_log, index) => index, - (log) => html` -
- [${log.type}] ${log.text} -
- `, - )} -
- ` - : "" - } -
- `; - } -} diff --git a/packages/web-ui/src/tools/artifacts/DocxArtifact.ts b/packages/web-ui/src/tools/artifacts/DocxArtifact.ts deleted file mode 100644 index 0d9e22c4..00000000 --- a/packages/web-ui/src/tools/artifacts/DocxArtifact.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { renderAsync } from "docx-preview"; -import { html, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("docx-artifact") -export class DocxArtifact extends ArtifactElement { - @property({ type: String }) private _content = ""; - @state() private error: string | null = null; - - get content(): string { - return this._content; - } - - set content(value: string) { - this._content = value; - this.error = null; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - } - - private base64ToArrayBuffer(base64: string): ArrayBuffer { - // Remove data URL prefix if present - let base64Data = base64; - if (base64.startsWith("data:")) { - const base64Match = base64.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - } - - private decodeBase64(): Uint8Array { - let base64Data = this._content; - if (this._content.startsWith("data:")) { - const base64Match = this._content.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - public getHeaderButtons() { - return html` -
- ${DownloadButton({ - content: this.decodeBase64(), - filename: this.filename, - mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - title: i18n("Download"), - })} -
- `; - } - - override async updated(changedProperties: Map) { - super.updated(changedProperties); - - if (changedProperties.has("_content") && this._content && !this.error) { - await this.renderDocx(); - } - } - - private async renderDocx() { - const container = this.querySelector("#docx-container"); - if (!container || !this._content) return; - - try { - const arrayBuffer = this.base64ToArrayBuffer(this._content); - - // Clear container first - container.innerHTML = ""; - - // Create a wrapper div for the document - const wrapper = document.createElement("div"); - wrapper.className = "docx-wrapper-custom"; - container.appendChild(wrapper); - - // Render the DOCX file into the wrapper - await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, { - className: "docx", - inWrapper: true, - ignoreWidth: true, - ignoreHeight: false, - ignoreFonts: false, - breakPages: true, - ignoreLastRenderedPageBreak: true, - experimental: false, - trimXmlDeclaration: true, - useBase64URL: false, - renderHeaders: true, - renderFooters: true, - renderFootnotes: true, - renderEndnotes: true, - }); - - // Apply custom styles to match theme and fix sizing - const style = document.createElement("style"); - style.textContent = ` - #docx-container { - padding: 0; - } - - #docx-container .docx-wrapper-custom { - max-width: 100%; - overflow-x: auto; - } - - #docx-container .docx-wrapper { - max-width: 100% !important; - margin: 0 !important; - background: transparent !important; - padding: 0em !important; - } - - #docx-container .docx-wrapper > section.docx { - box-shadow: none !important; - border: none !important; - border-radius: 0 !important; - margin: 0 !important; - padding: 2em !important; - background: white !important; - color: black !important; - max-width: 100% !important; - width: 100% !important; - min-width: 0 !important; - overflow-x: auto !important; - } - - /* Fix tables and wide content */ - #docx-container table { - max-width: 100% !important; - width: auto !important; - overflow-x: auto !important; - display: block !important; - } - - #docx-container img { - max-width: 100% !important; - height: auto !important; - } - - /* Fix paragraphs and text */ - #docx-container p, - #docx-container span, - #docx-container div { - max-width: 100% !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - } - - /* Hide page breaks in web view */ - #docx-container .docx-page-break { - display: none !important; - } - `; - container.appendChild(style); - } catch (error: any) { - console.error("Error rendering DOCX:", error); - this.error = error?.message || i18n("Failed to load document"); - } - } - - override render(): TemplateResult { - if (this.error) { - return html` -
-
-
${i18n("Error loading document")}
-
${this.error}
-
-
- `; - } - - return html` -
-
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "docx-artifact": DocxArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts b/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts deleted file mode 100644 index dd6d41d7..00000000 --- a/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { html, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import * as XLSX from "xlsx"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("excel-artifact") -export class ExcelArtifact extends ArtifactElement { - @property({ type: String }) private _content = ""; - @state() private error: string | null = null; - - get content(): string { - return this._content; - } - - set content(value: string) { - this._content = value; - this.error = null; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - } - - private base64ToArrayBuffer(base64: string): ArrayBuffer { - // Remove data URL prefix if present - let base64Data = base64; - if (base64.startsWith("data:")) { - const base64Match = base64.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - } - - private decodeBase64(): Uint8Array { - let base64Data = this._content; - if (this._content.startsWith("data:")) { - const base64Match = this._content.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - private getMimeType(): string { - const ext = this.filename.split(".").pop()?.toLowerCase(); - if (ext === "xls") return "application/vnd.ms-excel"; - return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - } - - public getHeaderButtons() { - return html` -
- ${DownloadButton({ - content: this.decodeBase64(), - filename: this.filename, - mimeType: this.getMimeType(), - title: i18n("Download"), - })} -
- `; - } - - override async updated(changedProperties: Map) { - super.updated(changedProperties); - - if (changedProperties.has("_content") && this._content && !this.error) { - await this.renderExcel(); - } - } - - private async renderExcel() { - const container = this.querySelector("#excel-container"); - if (!container || !this._content) return; - - try { - const arrayBuffer = this.base64ToArrayBuffer(this._content); - const workbook = XLSX.read(arrayBuffer, { type: "array" }); - - container.innerHTML = ""; - const wrapper = document.createElement("div"); - wrapper.className = "overflow-auto h-full flex flex-col"; - container.appendChild(wrapper); - - // Create tabs for multiple sheets - if (workbook.SheetNames.length > 1) { - const tabContainer = document.createElement("div"); - tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-background z-10"; - - const sheetContents: HTMLElement[] = []; - - workbook.SheetNames.forEach((sheetName, index) => { - // Create tab button - const tab = document.createElement("button"); - tab.textContent = sheetName; - tab.className = - index === 0 - ? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" - : "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors"; - - // Create sheet content - const sheetDiv = document.createElement("div"); - sheetDiv.style.display = index === 0 ? "flex" : "none"; - sheetDiv.className = "flex-1 overflow-auto"; - sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName)); - sheetContents.push(sheetDiv); - - // Tab click handler - tab.onclick = () => { - // Update tab styles - tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => { - if (btnIndex === index) { - btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"; - } else { - btn.className = - "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors"; - } - }); - // Show/hide sheets - sheetContents.forEach((content, contentIndex) => { - content.style.display = contentIndex === index ? "flex" : "none"; - }); - }; - - tabContainer.appendChild(tab); - }); - - wrapper.appendChild(tabContainer); - sheetContents.forEach((content) => { - wrapper.appendChild(content); - }); - } else { - // Single sheet - const sheetName = workbook.SheetNames[0]; - wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName)); - } - } catch (error: any) { - console.error("Error rendering Excel:", error); - this.error = error?.message || i18n("Failed to load spreadsheet"); - } - } - - private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement { - const sheetDiv = document.createElement("div"); - - // Generate HTML table - const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` }); - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = htmlTable; - - // Find and style the table - const table = tempDiv.querySelector("table"); - if (table) { - table.className = "w-full border-collapse text-foreground"; - - // Style all cells - table.querySelectorAll("td, th").forEach((cell) => { - const cellEl = cell as HTMLElement; - cellEl.className = "border border-border px-3 py-2 text-sm text-left"; - }); - - // Style header row - const headerCells = table.querySelectorAll("thead th, tr:first-child td"); - if (headerCells.length > 0) { - headerCells.forEach((th) => { - const thEl = th as HTMLElement; - thEl.className = - "border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0"; - }); - } - - // Alternate row colors - table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => { - const rowEl = row as HTMLElement; - rowEl.className = "bg-muted/30"; - }); - - sheetDiv.appendChild(table); - } - - return sheetDiv; - } - - override render(): TemplateResult { - if (this.error) { - return html` -
-
-
${i18n("Error loading spreadsheet")}
-
${this.error}
-
-
- `; - } - - return html` -
-
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "excel-artifact": ExcelArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/GenericArtifact.ts b/packages/web-ui/src/tools/artifacts/GenericArtifact.ts deleted file mode 100644 index 597615e5..00000000 --- a/packages/web-ui/src/tools/artifacts/GenericArtifact.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { html, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("generic-artifact") -export class GenericArtifact extends ArtifactElement { - @property({ type: String }) private _content = ""; - - get content(): string { - return this._content; - } - - set content(value: string) { - this._content = value; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - } - - private decodeBase64(): Uint8Array { - let base64Data = this._content; - if (this._content.startsWith("data:")) { - const base64Match = this._content.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - private getMimeType(): string { - const ext = this.filename.split(".").pop()?.toLowerCase(); - // Add common MIME types - const mimeTypes: Record = { - pdf: "application/pdf", - zip: "application/zip", - tar: "application/x-tar", - gz: "application/gzip", - rar: "application/vnd.rar", - "7z": "application/x-7z-compressed", - mp3: "audio/mpeg", - mp4: "video/mp4", - avi: "video/x-msvideo", - mov: "video/quicktime", - wav: "audio/wav", - ogg: "audio/ogg", - json: "application/json", - xml: "application/xml", - bin: "application/octet-stream", - }; - return mimeTypes[ext || ""] || "application/octet-stream"; - } - - public getHeaderButtons() { - return html` -
- ${DownloadButton({ - content: this.decodeBase64(), - filename: this.filename, - mimeType: this.getMimeType(), - title: i18n("Download"), - })} -
- `; - } - - override render(): TemplateResult { - return html` -
-
-
- - - -
${this.filename}
-

- ${i18n("Preview not available for this file type.")} ${i18n("Click the download button above to view it on your computer.")} -

-
-
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "generic-artifact": GenericArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts b/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts deleted file mode 100644 index f4bf36d7..00000000 --- a/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts +++ /dev/null @@ -1,195 +0,0 @@ -import hljs from "highlight.js"; -import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { createRef, type Ref, ref } from "lit/directives/ref.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { RefreshCw } from "lucide"; -import type { SandboxIframe } from "../../components/SandboxedIframe.js"; -import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js"; -import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js"; -import { i18n } from "../../utils/i18n.js"; -import "../../components/SandboxedIframe.js"; -import { ArtifactElement } from "./ArtifactElement.js"; -import type { Console } from "./Console.js"; -import "./Console.js"; -import { icon } from "@mariozechner/mini-lit"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js"; -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js"; - -@customElement("html-artifact") -export class HtmlArtifact extends ArtifactElement { - @property() override filename = ""; - @property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] = []; - @property({ attribute: false }) sandboxUrlProvider?: () => string; - - private _content = ""; - private logs: Array<{ type: "log" | "error"; text: string }> = []; - - // Refs for DOM elements - public sandboxIframeRef: Ref = createRef(); - private consoleRef: Ref = createRef(); - - @state() private viewMode: "preview" | "code" = "preview"; - - private setViewMode(mode: "preview" | "code") { - this.viewMode = mode; - } - - public getHeaderButtons() { - const toggle = new PreviewCodeToggle(); - toggle.mode = this.viewMode; - toggle.addEventListener("mode-change", (e: Event) => { - this.setViewMode((e as CustomEvent).detail); - }); - - const copyButton = new CopyButton(); - copyButton.text = this._content; - copyButton.title = i18n("Copy HTML"); - copyButton.showText = false; - - // Generate standalone HTML with all runtime code injected for download - const sandbox = this.sandboxIframeRef.value; - const sandboxId = `artifact-${this.filename}`; - const downloadContent = - sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], { - isHtmlArtifact: true, - isStandalone: true, // Skip runtime bridge and navigation interceptor for standalone downloads - }) || this._content; - - return html` -
- ${toggle} - ${Button({ - variant: "ghost", - size: "sm", - onClick: () => { - this.logs = []; - this.executeContent(this._content); - }, - title: i18n("Reload HTML"), - children: icon(RefreshCw, "sm"), - })} - ${copyButton} - ${DownloadButton({ content: downloadContent, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })} -
- `; - } - - override set content(value: string) { - const oldValue = this._content; - this._content = value; - if (oldValue !== value) { - // Reset logs when content changes - this.logs = []; - this.requestUpdate(); - // Execute content in sandbox if it exists - if (this.sandboxIframeRef.value && value) { - this.executeContent(value); - } - } - } - - public executeContent(html: string) { - const sandbox = this.sandboxIframeRef.value; - if (!sandbox) return; - - // Configure sandbox URL provider if provided (for browser extensions) - if (this.sandboxUrlProvider) { - sandbox.sandboxUrlProvider = this.sandboxUrlProvider; - } - - const sandboxId = `artifact-${this.filename}`; - - // Create consumer for console messages - const consumer: MessageConsumer = { - handleMessage: async (message: any): Promise => { - if (message.type === "console") { - // Create new array reference for Lit reactivity - this.logs = [ - ...this.logs, - { - type: message.method === "error" ? "error" : "log", - text: message.text, - }, - ]; - this.requestUpdate(); // Re-render to show console - } - }, - }; - - // Inject window.complete() call at the end of the HTML to signal when page is loaded - // HTML artifacts don't time out - they call complete() when ready - let modifiedHtml = html; - if (modifiedHtml.includes("")) { - modifiedHtml = modifiedHtml.replace( - "", - "", - ); - } else { - // If no closing tag, append the script - modifiedHtml += ""; - } - - // Load content - this handles sandbox registration, consumer registration, and iframe creation - sandbox.loadContent(sandboxId, modifiedHtml, this.runtimeProviders, [consumer]); - } - - override get content(): string { - return this._content; - } - - override disconnectedCallback() { - super.disconnectedCallback(); - // Unregister sandbox when element is removed from DOM - const sandboxId = `artifact-${this.filename}`; - RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId); - } - - override firstUpdated() { - // Execute initial content - if (this._content && this.sandboxIframeRef.value) { - this.executeContent(this._content); - } - } - - override updated(changedProperties: Map) { - super.updated(changedProperties); - // If we have content but haven't executed yet (e.g., during reconstruction), - // execute when the iframe ref becomes available - if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) { - this.executeContent(this._content); - } - } - - public getLogs(): string { - if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename); - return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n"); - } - - override render() { - return html` -
-
- -
- - ${ - this.logs.length > 0 - ? html`` - : "" - } -
- - -
-
${unsafeHTML(
-							hljs.highlight(this._content, { language: "html" }).value,
-						)}
-
-
-
- `; - } -} diff --git a/packages/web-ui/src/tools/artifacts/ImageArtifact.ts b/packages/web-ui/src/tools/artifacts/ImageArtifact.ts deleted file mode 100644 index cc7b5f52..00000000 --- a/packages/web-ui/src/tools/artifacts/ImageArtifact.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { html, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("image-artifact") -export class ImageArtifact extends ArtifactElement { - @property({ type: String }) private _content = ""; - - get content(): string { - return this._content; - } - - set content(value: string) { - this._content = value; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - } - - private getMimeType(): string { - const ext = this.filename.split(".").pop()?.toLowerCase(); - if (ext === "jpg" || ext === "jpeg") return "image/jpeg"; - if (ext === "gif") return "image/gif"; - if (ext === "webp") return "image/webp"; - if (ext === "svg") return "image/svg+xml"; - if (ext === "bmp") return "image/bmp"; - if (ext === "ico") return "image/x-icon"; - return "image/png"; - } - - private getImageUrl(): string { - // If content is already a data URL, use it directly - if (this._content.startsWith("data:")) { - return this._content; - } - // Otherwise assume it's base64 and construct data URL - return `data:${this.getMimeType()};base64,${this._content}`; - } - - private decodeBase64(): Uint8Array { - let base64Data: string; - - // If content is a data URL, extract the base64 part - if (this._content.startsWith("data:")) { - const base64Match = this._content.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } else { - // Not a base64 data URL, return empty - return new Uint8Array(0); - } - } else { - // Otherwise use content as-is - base64Data = this._content; - } - - // Decode base64 to binary string - const binaryString = atob(base64Data); - - // Convert binary string to Uint8Array - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return bytes; - } - - public getHeaderButtons() { - return html` -
- ${DownloadButton({ - content: this.decodeBase64(), - filename: this.filename, - mimeType: this.getMimeType(), - title: i18n("Download"), - })} -
- `; - } - - override render(): TemplateResult { - return html` -
-
- ${this.filename} { - const target = e.target as HTMLImageElement; - target.src = - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext x='50' y='50' text-anchor='middle' dominant-baseline='middle' fill='%23999'%3EImage Error%3C/text%3E%3C/svg%3E"; - }} - /> -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "image-artifact": ImageArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts b/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts deleted file mode 100644 index 7f763b31..00000000 --- a/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts +++ /dev/null @@ -1,82 +0,0 @@ -import hljs from "highlight.js"; -import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { i18n } from "../../utils/i18n.js"; -import "@mariozechner/mini-lit/dist/MarkdownBlock.js"; -import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js"; -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("markdown-artifact") -export class MarkdownArtifact extends ArtifactElement { - @property() override filename = ""; - - private _content = ""; - override get content(): string { - return this._content; - } - override set content(value: string) { - this._content = value; - this.requestUpdate(); - } - - @state() private viewMode: "preview" | "code" = "preview"; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM - } - - private setViewMode(mode: "preview" | "code") { - this.viewMode = mode; - } - - public getHeaderButtons() { - const toggle = new PreviewCodeToggle(); - toggle.mode = this.viewMode; - toggle.addEventListener("mode-change", (e: Event) => { - this.setViewMode((e as CustomEvent).detail); - }); - - const copyButton = new CopyButton(); - copyButton.text = this._content; - copyButton.title = i18n("Copy Markdown"); - copyButton.showText = false; - - return html` -
- ${toggle} - ${copyButton} - ${DownloadButton({ - content: this._content, - filename: this.filename, - mimeType: "text/markdown", - title: i18n("Download Markdown"), - })} -
- `; - } - - override render() { - return html` -
-
- ${ - this.viewMode === "preview" - ? html`
` - : html`
${unsafeHTML(
-									hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
-								)}
` - } -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "markdown-artifact": MarkdownArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/PdfArtifact.ts b/packages/web-ui/src/tools/artifacts/PdfArtifact.ts deleted file mode 100644 index e139c2a4..00000000 --- a/packages/web-ui/src/tools/artifacts/PdfArtifact.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { html, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import * as pdfjsLib from "pdfjs-dist"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -// Configure PDF.js worker -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); - -@customElement("pdf-artifact") -export class PdfArtifact extends ArtifactElement { - @property({ type: String }) private _content = ""; - @state() private error: string | null = null; - private currentLoadingTask: any = null; - - get content(): string { - return this._content; - } - - set content(value: string) { - this._content = value; - this.error = null; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - } - - override disconnectedCallback(): void { - super.disconnectedCallback(); - this.cleanup(); - } - - private cleanup() { - if (this.currentLoadingTask) { - this.currentLoadingTask.destroy(); - this.currentLoadingTask = null; - } - } - - private base64ToArrayBuffer(base64: string): ArrayBuffer { - // Remove data URL prefix if present - let base64Data = base64; - if (base64.startsWith("data:")) { - const base64Match = base64.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - } - - private decodeBase64(): Uint8Array { - let base64Data = this._content; - if (this._content.startsWith("data:")) { - const base64Match = this._content.match(/base64,(.+)/); - if (base64Match) { - base64Data = base64Match[1]; - } - } - - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } - - public getHeaderButtons() { - return html` -
- ${DownloadButton({ - content: this.decodeBase64(), - filename: this.filename, - mimeType: "application/pdf", - title: i18n("Download"), - })} -
- `; - } - - override async updated(changedProperties: Map) { - super.updated(changedProperties); - - if (changedProperties.has("_content") && this._content && !this.error) { - await this.renderPdf(); - } - } - - private async renderPdf() { - const container = this.querySelector("#pdf-container"); - if (!container || !this._content) return; - - let pdf: any = null; - - try { - const arrayBuffer = this.base64ToArrayBuffer(this._content); - - // Cancel any existing loading task - if (this.currentLoadingTask) { - this.currentLoadingTask.destroy(); - } - - // Load the PDF - this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); - pdf = await this.currentLoadingTask.promise; - this.currentLoadingTask = null; - - // Clear container - container.innerHTML = ""; - const wrapper = document.createElement("div"); - wrapper.className = "p-4"; - container.appendChild(wrapper); - - // Render all pages - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - const page = await pdf.getPage(pageNum); - - const pageContainer = document.createElement("div"); - pageContainer.className = "mb-4 last:mb-0"; - - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - const viewport = page.getViewport({ scale: 1.5 }); - canvas.height = viewport.height; - canvas.width = viewport.width; - - canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border"; - - if (context) { - context.fillStyle = "white"; - context.fillRect(0, 0, canvas.width, canvas.height); - } - - await page.render({ - canvasContext: context!, - viewport: viewport, - canvas: canvas, - }).promise; - - pageContainer.appendChild(canvas); - - if (pageNum < pdf.numPages) { - const separator = document.createElement("div"); - separator.className = "h-px bg-border my-4"; - pageContainer.appendChild(separator); - } - - wrapper.appendChild(pageContainer); - } - } catch (error: any) { - console.error("Error rendering PDF:", error); - this.error = error?.message || i18n("Failed to load PDF"); - } finally { - if (pdf) { - pdf.destroy(); - } - } - } - - override render(): TemplateResult { - if (this.error) { - return html` -
-
-
${i18n("Error loading PDF")}
-
${this.error}
-
-
- `; - } - - return html` -
-
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "pdf-artifact": PdfArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/SvgArtifact.ts b/packages/web-ui/src/tools/artifacts/SvgArtifact.ts deleted file mode 100644 index fe9f398f..00000000 --- a/packages/web-ui/src/tools/artifacts/SvgArtifact.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js"; -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js"; -import hljs from "highlight.js"; -import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -@customElement("svg-artifact") -export class SvgArtifact extends ArtifactElement { - @property() override filename = ""; - - private _content = ""; - @state() private previewUrl = ""; - - override get content(): string { - return this._content; - } - override set content(value: string) { - if (this._content === value) { - return; - } - this._content = value; - this.updatePreviewUrl(); - this.requestUpdate(); - } - - @state() private viewMode: "preview" | "code" = "preview"; - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM - } - - private setViewMode(mode: "preview" | "code") { - this.viewMode = mode; - } - - private revokePreviewUrl() { - if (this.previewUrl) { - URL.revokeObjectURL(this.previewUrl); - this.previewUrl = ""; - } - } - - private updatePreviewUrl() { - this.revokePreviewUrl(); - if (!this._content) { - return; - } - this.previewUrl = URL.createObjectURL(new Blob([this._content], { type: "image/svg+xml" })); - } - - public getHeaderButtons() { - const toggle = new PreviewCodeToggle(); - toggle.mode = this.viewMode; - toggle.addEventListener("mode-change", (e: Event) => { - this.setViewMode((e as CustomEvent).detail); - }); - - const copyButton = new CopyButton(); - copyButton.text = this._content; - copyButton.title = i18n("Copy SVG"); - copyButton.showText = false; - - return html` -
- ${toggle} - ${copyButton} - ${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })} -
- `; - } - - override connectedCallback() { - super.connectedCallback(); - if (this._content && !this.previewUrl) { - this.updatePreviewUrl(); - } - } - - override disconnectedCallback() { - super.disconnectedCallback(); - this.revokePreviewUrl(); - } - - override render() { - return html` -
-
- ${ - this.viewMode === "preview" - ? html`
- ${ - this.previewUrl - ? html`${this.filename}` - : "" - } -
` - : html`
${unsafeHTML(
-									hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
-								)}
` - } -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "svg-artifact": SvgArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/TextArtifact.ts b/packages/web-ui/src/tools/artifacts/TextArtifact.ts deleted file mode 100644 index 853267ee..00000000 --- a/packages/web-ui/src/tools/artifacts/TextArtifact.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js"; -import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js"; -import hljs from "highlight.js"; -import { html } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { i18n } from "../../utils/i18n.js"; -import { ArtifactElement } from "./ArtifactElement.js"; - -// Known code file extensions for highlighting -const CODE_EXTENSIONS = [ - "js", - "javascript", - "ts", - "typescript", - "jsx", - "tsx", - "py", - "python", - "java", - "c", - "cpp", - "cs", - "php", - "rb", - "ruby", - "go", - "rust", - "swift", - "kotlin", - "scala", - "dart", - "html", - "css", - "scss", - "sass", - "less", - "json", - "xml", - "yaml", - "yml", - "toml", - "sql", - "sh", - "bash", - "ps1", - "bat", - "r", - "matlab", - "julia", - "lua", - "perl", - "vue", - "svelte", -]; - -@customElement("text-artifact") -export class TextArtifact extends ArtifactElement { - @property() override filename = ""; - - private _content = ""; - override get content(): string { - return this._content; - } - override set content(value: string) { - this._content = value; - this.requestUpdate(); - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM - } - - private isCode(): boolean { - const ext = this.filename.split(".").pop()?.toLowerCase() || ""; - return CODE_EXTENSIONS.includes(ext); - } - - private getLanguageFromExtension(ext: string): string { - const languageMap: Record = { - js: "javascript", - ts: "typescript", - py: "python", - rb: "ruby", - yml: "yaml", - ps1: "powershell", - bat: "batch", - }; - return languageMap[ext] || ext; - } - - private getMimeType(): string { - const ext = this.filename.split(".").pop()?.toLowerCase() || ""; - if (ext === "svg") return "image/svg+xml"; - if (ext === "md" || ext === "markdown") return "text/markdown"; - return "text/plain"; - } - - public getHeaderButtons() { - const copyButton = new CopyButton(); - copyButton.text = this.content; - copyButton.title = i18n("Copy"); - copyButton.showText = false; - - return html` -
- ${copyButton} - ${DownloadButton({ - content: this.content, - filename: this.filename, - mimeType: this.getMimeType(), - title: i18n("Download"), - })} -
- `; - } - - override render() { - const isCode = this.isCode(); - const ext = this.filename.split(".").pop() || ""; - return html` -
-
- ${ - isCode - ? html` -
${unsafeHTML(
-									hljs.highlight(this.content, {
-										language: this.getLanguageFromExtension(ext.toLowerCase()),
-										ignoreIllegals: true,
-									}).value,
-								)}
- ` - : html`
${this.content}
` - } -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "text-artifact": TextArtifact; - } -} diff --git a/packages/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts b/packages/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts deleted file mode 100644 index 6767dcce..00000000 --- a/packages/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts +++ /dev/null @@ -1,310 +0,0 @@ -import "@mariozechner/mini-lit/dist/CodeBlock.js"; -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { createRef, ref } from "lit/directives/ref.js"; -import { FileCode2 } from "lucide"; -import "../../components/ConsoleBlock.js"; -import { Diff } from "@mariozechner/mini-lit/dist/Diff.js"; -import { html, type TemplateResult } from "lit"; -import { i18n } from "../../utils/i18n.js"; -import { renderCollapsibleHeader, renderHeader } from "../renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "../types.js"; -import { ArtifactPill } from "./ArtifactPill.js"; -import type { ArtifactsPanel, ArtifactsParams } from "./artifacts.js"; - -// Helper to extract text from content blocks -function getTextOutput(result: ToolResultMessage | undefined): string { - if (!result) return ""; - return ( - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || "" - ); -} - -// Helper to determine language for syntax highlighting -function getLanguageFromFilename(filename?: string): string { - if (!filename) return "text"; - const ext = filename.split(".").pop()?.toLowerCase(); - const languageMap: Record = { - js: "javascript", - jsx: "javascript", - ts: "typescript", - tsx: "typescript", - html: "html", - css: "css", - scss: "scss", - json: "json", - py: "python", - md: "markdown", - svg: "xml", - xml: "xml", - yaml: "yaml", - yml: "yaml", - sh: "bash", - bash: "bash", - sql: "sql", - java: "java", - c: "c", - cpp: "cpp", - cs: "csharp", - go: "go", - rs: "rust", - php: "php", - rb: "ruby", - swift: "swift", - kt: "kotlin", - r: "r", - }; - return languageMap[ext || ""] || "text"; -} - -export class ArtifactsToolRenderer implements ToolRenderer { - constructor(public artifactsPanel?: ArtifactsPanel) {} - - render( - params: ArtifactsParams | undefined, - result: ToolResultMessage | undefined, - isStreaming?: boolean, - ): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete"; - - // Create refs for collapsible sections - const contentRef = createRef(); - const chevronRef = createRef(); - - // Helper to get command labels - const getCommandLabels = (command: string): { streaming: string; complete: string } => { - const labels: Record = { - create: { streaming: i18n("Creating artifact"), complete: i18n("Created artifact") }, - update: { streaming: i18n("Updating artifact"), complete: i18n("Updated artifact") }, - rewrite: { streaming: i18n("Rewriting artifact"), complete: i18n("Rewrote artifact") }, - get: { streaming: i18n("Getting artifact"), complete: i18n("Got artifact") }, - delete: { streaming: i18n("Deleting artifact"), complete: i18n("Deleted artifact") }, - logs: { streaming: i18n("Getting logs"), complete: i18n("Got logs") }, - }; - return labels[command] || { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") }; - }; - - // Helper to render header text with inline artifact pill - const renderHeaderWithPill = (labelText: string, filename?: string): TemplateResult => { - if (filename) { - return html`${labelText} ${ArtifactPill(filename, this.artifactsPanel)}`; - } - return html`${labelText}`; - }; - - // Error handling - if (result?.isError) { - const command = params?.command; - const filename = params?.filename; - const labels = command - ? getCommandLabels(command) - : { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") }; - const headerText = labels.streaming; - - // For create/update/rewrite errors, show code block + console/error - if (command === "create" || command === "update" || command === "rewrite") { - const content = params?.content || ""; - const { old_str, new_str } = params || {}; - const isDiff = command === "update"; - const diffContent = - old_str !== undefined && new_str !== undefined ? Diff({ oldText: old_str, newText: new_str }) : ""; - - const isHtml = filename?.endsWith(".html"); - - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- ${isDiff ? diffContent : content ? html`` : ""} - ${ - isHtml - ? html`` - : html`
${getTextOutput(result) || i18n("An error occurred")}
` - } -
-
- `, - isCustom: false, - }; - } - - // For other errors, just show error message - return { - content: html` -
- ${renderHeader(state, FileCode2, headerText)} -
${getTextOutput(result) || i18n("An error occurred")}
-
- `, - isCustom: false, - }; - } - - // Full params + result - if (result && params) { - const { command, filename, content } = params; - const labels = command - ? getCommandLabels(command) - : { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") }; - const headerText = labels.complete; - - // GET command: show code block with file content - if (command === "get") { - const fileContent = getTextOutput(result) || i18n("(no output)"); - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- -
-
- `, - isCustom: false, - }; - } - - // LOGS command: show console block - if (command === "logs") { - const logs = getTextOutput(result) || i18n("(no output)"); - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- -
-
- `, - isCustom: false, - }; - } - - // CREATE/UPDATE/REWRITE: always show code block, + console block for .html files - if (command === "create" || command === "rewrite") { - const codeContent = content || ""; - const isHtml = filename?.endsWith(".html"); - const logs = getTextOutput(result) || ""; - - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- ${codeContent ? html`` : ""} - ${isHtml && logs ? html`` : ""} -
-
- `, - isCustom: false, - }; - } - - if (command === "update") { - const isHtml = filename?.endsWith(".html"); - const logs = getTextOutput(result) || ""; - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- ${Diff({ oldText: params.old_str || "", newText: params.new_str || "" })} - ${isHtml && logs ? html`` : ""} -
-
- `, - isCustom: false, - }; - } - - // For DELETE, just show header - return { - content: html` -
- ${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))} -
- `, - isCustom: false, - }; - } - - // Params only (streaming or waiting for result) - if (params) { - const { command, filename, content, old_str, new_str } = params; - - // If no command yet - if (!command) { - return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false }; - } - - const labels = getCommandLabels(command); - const headerText = labels.streaming; - - // Render based on command type - switch (command) { - case "create": - case "rewrite": - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- ${ - content - ? html`` - : "" - } -
-
- `, - isCustom: false, - }; - - case "update": - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
- ${ - old_str !== undefined && new_str !== undefined - ? Diff({ oldText: old_str, newText: new_str }) - : "" - } -
-
- `, - isCustom: false, - }; - - case "get": - case "logs": - return { - content: html` -
- ${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)} -
-
- `, - isCustom: false, - }; - - default: - return { - content: html` -
- ${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))} -
- `, - isCustom: false, - }; - } - } - - // No params or result yet - return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false }; - } -} diff --git a/packages/web-ui/src/tools/artifacts/artifacts.ts b/packages/web-ui/src/tools/artifacts/artifacts.ts deleted file mode 100644 index 7f679eab..00000000 --- a/packages/web-ui/src/tools/artifacts/artifacts.ts +++ /dev/null @@ -1,713 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import "@mariozechner/mini-lit/dist/MarkdownBlock.js"; -import type { Agent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; -import { StringEnum, type ToolCall } from "@earendil-works/pi-ai"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { createRef, type Ref, ref } from "lit/directives/ref.js"; -import { X } from "lucide"; -import { type Static, Type } from "typebox"; -import type { ArtifactMessage } from "../../components/Messages.js"; -import { ArtifactsRuntimeProvider } from "../../components/sandbox/ArtifactsRuntimeProvider.js"; -import { AttachmentsRuntimeProvider } from "../../components/sandbox/AttachmentsRuntimeProvider.js"; -import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js"; -import { - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, - ARTIFACTS_TOOL_DESCRIPTION, - ATTACHMENTS_RUNTIME_DESCRIPTION, -} from "../../prompts/prompts.js"; -import type { Attachment } from "../../utils/attachment-utils.js"; -import { i18n } from "../../utils/i18n.js"; -import type { ArtifactElement } from "./ArtifactElement.js"; -import { DocxArtifact } from "./DocxArtifact.js"; -import { ExcelArtifact } from "./ExcelArtifact.js"; -import { GenericArtifact } from "./GenericArtifact.js"; -import { HtmlArtifact } from "./HtmlArtifact.js"; -import { ImageArtifact } from "./ImageArtifact.js"; -import { MarkdownArtifact } from "./MarkdownArtifact.js"; -import { PdfArtifact } from "./PdfArtifact.js"; -import { SvgArtifact } from "./SvgArtifact.js"; -import { TextArtifact } from "./TextArtifact.js"; - -// Simple artifact model -export interface Artifact { - filename: string; - content: string; - createdAt: Date; - updatedAt: Date; -} - -// JSON-schema friendly parameters object (LLM-facing) -const artifactsParamsSchema = Type.Object({ - command: StringEnum(["create", "update", "rewrite", "get", "delete", "logs"], { - description: "The operation to perform", - }), - filename: Type.String({ description: "Filename including extension (e.g., 'index.html', 'script.js')" }), - content: Type.Optional(Type.String({ description: "File content" })), - old_str: Type.Optional(Type.String({ description: "String to replace (for update command)" })), - new_str: Type.Optional(Type.String({ description: "Replacement string (for update command)" })), -}); -export type ArtifactsParams = Static; - -@customElement("artifacts-panel") -export class ArtifactsPanel extends LitElement { - @state() private _artifacts = new Map(); - @state() private _activeFilename: string | null = null; - - // Programmatically managed artifact elements - private artifactElements = new Map(); - private contentRef: Ref = createRef(); - - // Agent reference (needed to get attachments for HTML artifacts) - @property({ attribute: false }) agent?: Agent; - // Sandbox URL provider for browser extensions (optional) - @property({ attribute: false }) sandboxUrlProvider?: () => string; - // Callbacks - @property({ attribute: false }) onArtifactsChange?: () => void; - @property({ attribute: false }) onClose?: () => void; - @property({ attribute: false }) onOpen?: () => void; - // Collapsed mode: hides panel content but can show a floating reopen pill - @property({ type: Boolean }) collapsed = false; - // Overlay mode: when true, panel renders full-screen overlay (mobile) - @property({ type: Boolean }) overlay = false; - - // Public getter for artifacts - get artifacts() { - return this._artifacts; - } - - // Get runtime providers for HTML artifacts (read-only: attachments + artifacts) - private getHtmlArtifactRuntimeProviders(): SandboxRuntimeProvider[] { - const providers: SandboxRuntimeProvider[] = []; - - // Get attachments from agent messages - if (this.agent) { - const attachments: Attachment[] = []; - for (const message of this.agent.state.messages) { - if (message.role === "user-with-attachments" && message.attachments) { - attachments.push(...message.attachments); - } - } - if (attachments.length > 0) { - providers.push(new AttachmentsRuntimeProvider(attachments)); - } - } - - // Add read-only artifacts provider - providers.push(new ArtifactsRuntimeProvider(this, this.agent, false)); - - return providers; - } - - protected override createRenderRoot(): HTMLElement | DocumentFragment { - return this; // light DOM for shared styles - } - - override connectedCallback(): void { - super.connectedCallback(); - this.style.display = "block"; - this.style.height = "100%"; - // Reattach existing artifact elements when panel is re-inserted into the DOM - requestAnimationFrame(() => { - const container = this.contentRef.value; - if (!container) return; - // Ensure we have an active filename - if (!this._activeFilename && this._artifacts.size > 0) { - this._activeFilename = Array.from(this._artifacts.keys())[0]; - } - this.artifactElements.forEach((element, name) => { - if (!element.parentElement) container.appendChild(element); - element.style.display = name === this._activeFilename ? "block" : "none"; - }); - }); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - // Do not tear down artifact elements; keep them to restore on next mount - } - - // Helper to determine file type from extension - private getFileType( - filename: string, - ): "html" | "svg" | "markdown" | "image" | "pdf" | "excel" | "docx" | "text" | "generic" { - const ext = filename.split(".").pop()?.toLowerCase(); - if (ext === "html") return "html"; - if (ext === "svg") return "svg"; - if (ext === "md" || ext === "markdown") return "markdown"; - if (ext === "pdf") return "pdf"; - if (ext === "xlsx" || ext === "xls") return "excel"; - if (ext === "docx") return "docx"; - if ( - ext === "png" || - ext === "jpg" || - ext === "jpeg" || - ext === "gif" || - ext === "webp" || - ext === "bmp" || - ext === "ico" - ) - return "image"; - // Text files - if ( - ext === "txt" || - ext === "json" || - ext === "xml" || - ext === "yaml" || - ext === "yml" || - ext === "csv" || - ext === "js" || - ext === "ts" || - ext === "jsx" || - ext === "tsx" || - ext === "py" || - ext === "java" || - ext === "c" || - ext === "cpp" || - ext === "h" || - ext === "css" || - ext === "scss" || - ext === "sass" || - ext === "less" || - ext === "sh" - ) - return "text"; - // Everything else gets generic fallback - return "generic"; - } - - // Get or create artifact element - private getOrCreateArtifactElement(filename: string, content: string): ArtifactElement { - let element = this.artifactElements.get(filename); - - if (!element) { - const type = this.getFileType(filename); - if (type === "html") { - element = new HtmlArtifact(); - (element as HtmlArtifact).runtimeProviders = this.getHtmlArtifactRuntimeProviders(); - if (this.sandboxUrlProvider) { - (element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider; - } - } else if (type === "svg") { - element = new SvgArtifact(); - } else if (type === "markdown") { - element = new MarkdownArtifact(); - } else if (type === "image") { - element = new ImageArtifact(); - } else if (type === "pdf") { - element = new PdfArtifact(); - } else if (type === "excel") { - element = new ExcelArtifact(); - } else if (type === "docx") { - element = new DocxArtifact(); - } else if (type === "text") { - element = new TextArtifact(); - } else { - element = new GenericArtifact(); - } - element.filename = filename; - element.content = content; - element.style.display = "none"; - element.style.height = "100%"; - - // Store element - this.artifactElements.set(filename, element); - - // Add to DOM - try immediately if container exists, otherwise schedule - const newElement = element; - if (this.contentRef.value) { - this.contentRef.value.appendChild(newElement); - } else { - requestAnimationFrame(() => { - if (this.contentRef.value && !newElement.parentElement) { - this.contentRef.value.appendChild(newElement); - } - }); - } - } else { - // Just update content - element.content = content; - if (element instanceof HtmlArtifact) { - element.runtimeProviders = this.getHtmlArtifactRuntimeProviders(); - } - } - - return element; - } - - // Show/hide artifact elements - private showArtifact(filename: string) { - // Ensure the active element is in the DOM - requestAnimationFrame(() => { - this.artifactElements.forEach((element, name) => { - if (this.contentRef.value && !element.parentElement) { - this.contentRef.value.appendChild(element); - } - element.style.display = name === filename ? "block" : "none"; - }); - }); - this._activeFilename = filename; - this.requestUpdate(); // Only for tab bar update - - // Scroll the active tab into view after render - requestAnimationFrame(() => { - const activeButton = this.querySelector(`button[data-filename="${filename}"]`); - if (activeButton) { - activeButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); - } - }); - } - - // Open panel and focus an artifact tab by filename - public openArtifact(filename: string) { - if (this._artifacts.has(filename)) { - this.showArtifact(filename); - // Ask host to open panel (AgentInterface demo listens to onOpen) - this.onOpen?.(); - } - } - - // Build the AgentTool (no details payload; return only output strings) - public get tool(): AgentTool { - return { - label: "Artifacts", - name: "artifacts", - get description() { - // HTML artifacts have read-only access to attachments and artifacts - const runtimeProviderDescriptions = [ - ATTACHMENTS_RUNTIME_DESCRIPTION, - ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO, - ]; - return ARTIFACTS_TOOL_DESCRIPTION(runtimeProviderDescriptions); - }, - parameters: artifactsParamsSchema, - // Execute mutates our local store and returns a plain output - execute: async (_toolCallId: string, args: Static, _signal?: AbortSignal) => { - const output = await this.executeCommand(args); - return { content: [{ type: "text", text: output }], details: undefined }; - }, - }; - } - - // Re-apply artifacts by scanning a message list (optional utility) - public async reconstructFromMessages( - messages: Array, - ): Promise { - const toolCalls = new Map(); - const artifactToolName = "artifacts"; - - // 1) Collect tool calls from assistant messages - for (const message of messages) { - if (message.role === "assistant") { - for (const block of message.content) { - if (block.type === "toolCall" && block.name === artifactToolName) { - toolCalls.set(block.id, block); - } - } - } - } - - // 2) Build an ordered list of successful artifact operations - const operations: Array = []; - for (const m of messages) { - if ((m as any).role === "artifact") { - const artifactMsg = m as ArtifactMessage; - switch (artifactMsg.action) { - case "create": - operations.push({ - command: "create", - filename: artifactMsg.filename, - content: artifactMsg.content, - }); - break; - case "update": - operations.push({ - command: "rewrite", - filename: artifactMsg.filename, - content: artifactMsg.content, - }); - break; - case "delete": - operations.push({ - command: "delete", - filename: artifactMsg.filename, - }); - break; - } - } - // Handle tool result messages (from artifacts tool calls) - else if ((m as any).role === "toolResult" && (m as any).toolName === artifactToolName && !(m as any).isError) { - const toolCallId = (m as any).toolCallId as string; - const call = toolCalls.get(toolCallId); - if (!call) continue; - const params = call.arguments as ArtifactsParams; - if (params.command === "get" || params.command === "logs") continue; // no state change - operations.push(params); - } - } - - // 3) Compute final state per filename by simulating operations in-memory - const finalArtifacts = new Map(); - for (const op of operations) { - const filename = op.filename; - switch (op.command) { - case "create": { - if (op.content) { - finalArtifacts.set(filename, op.content); - } - break; - } - case "rewrite": { - if (op.content) { - finalArtifacts.set(filename, op.content); - } - break; - } - case "update": { - let existing = finalArtifacts.get(filename); - if (!existing) break; // skip invalid update (shouldn't happen for successful results) - if (op.old_str !== undefined && op.new_str !== undefined) { - existing = existing.replace(op.old_str, op.new_str); - finalArtifacts.set(filename, existing); - } - break; - } - case "delete": { - finalArtifacts.delete(filename); - break; - } - case "get": - case "logs": - // Ignored above, just for completeness - break; - } - } - - // 4) Reset current UI state before bulk create - this._artifacts.clear(); - this.artifactElements.forEach((el) => { - el.remove(); - }); - this.artifactElements.clear(); - this._activeFilename = null; - this._artifacts = new Map(this._artifacts); - - // 5) Create artifacts in a single pass without waiting for iframe execution or tab switching - for (const [filename, content] of finalArtifacts.entries()) { - const createParams: ArtifactsParams = { command: "create", filename, content } as const; - try { - await this.createArtifact(createParams, { skipWait: true, silent: true }); - } catch { - // Ignore failures during reconstruction - } - } - - // 6) Show first artifact if any exist, and notify listeners once - if (!this._activeFilename && this._artifacts.size > 0) { - this.showArtifact(Array.from(this._artifacts.keys())[0]); - } - this.onArtifactsChange?.(); - this.requestUpdate(); - } - - // Core command executor - private async executeCommand( - params: ArtifactsParams, - options: { skipWait?: boolean; silent?: boolean } = {}, - ): Promise { - switch (params.command) { - case "create": - return await this.createArtifact(params, options); - case "update": - return await this.updateArtifact(params, options); - case "rewrite": - return await this.rewriteArtifact(params, options); - case "get": - return this.getArtifact(params); - case "delete": - return this.deleteArtifact(params); - case "logs": - return this.getLogs(params); - default: - // Should never happen with TypeBox validation - return `Error: Unknown command ${(params as any).command}`; - } - } - - // Wait for HTML artifact execution and get logs - private async waitForHtmlExecution(filename: string): Promise { - const element = this.artifactElements.get(filename); - if (!(element instanceof HtmlArtifact)) { - return ""; - } - - return new Promise((resolve) => { - // Fallback timeout - just get logs after execution should complete - setTimeout(() => { - // Get whatever logs we have - const logs = element.getLogs(); - resolve(logs); - }, 1500); - }); - } - - // Reload all HTML artifacts (called when any artifact changes) - private reloadAllHtmlArtifacts() { - this.artifactElements.forEach((element) => { - if (element instanceof HtmlArtifact && element.sandboxIframeRef.value) { - // Update runtime providers with latest artifact state - element.runtimeProviders = this.getHtmlArtifactRuntimeProviders(); - // Re-execute the HTML content - element.executeContent(element.content); - } - }); - } - - private async createArtifact( - params: ArtifactsParams, - options: { skipWait?: boolean; silent?: boolean } = {}, - ): Promise { - if (!params.filename || !params.content) { - return "Error: create command requires filename and content"; - } - if (this._artifacts.has(params.filename)) { - return `Error: File ${params.filename} already exists`; - } - - const artifact: Artifact = { - filename: params.filename, - content: params.content, - createdAt: new Date(), - updatedAt: new Date(), - }; - this._artifacts.set(params.filename, artifact); - this._artifacts = new Map(this._artifacts); - - // Create or update element - this.getOrCreateArtifactElement(params.filename, params.content); - if (!options.silent) { - this.showArtifact(params.filename); - this.onArtifactsChange?.(); - this.requestUpdate(); - } - - // Reload all HTML artifacts since they might depend on this new artifact - this.reloadAllHtmlArtifacts(); - - // For HTML files, wait for execution - let result = `Created file ${params.filename}`; - if (this.getFileType(params.filename) === "html" && !options.skipWait) { - const logs = await this.waitForHtmlExecution(params.filename); - result += `\n${logs}`; - } - - return result; - } - - private async updateArtifact( - params: ArtifactsParams, - options: { skipWait?: boolean; silent?: boolean } = {}, - ): Promise { - const artifact = this._artifacts.get(params.filename); - if (!artifact) { - const files = Array.from(this._artifacts.keys()); - if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`; - return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`; - } - if (!params.old_str || params.new_str === undefined) { - return "Error: update command requires old_str and new_str"; - } - if (!artifact.content.includes(params.old_str)) { - return `Error: String not found in file. Here is the full content:\n\n${artifact.content}`; - } - - artifact.content = artifact.content.replace(params.old_str, params.new_str); - artifact.updatedAt = new Date(); - this._artifacts.set(params.filename, artifact); - - // Update element - this.getOrCreateArtifactElement(params.filename, artifact.content); - if (!options.silent) { - this.onArtifactsChange?.(); - this.requestUpdate(); - } - - // Show the artifact - this.showArtifact(params.filename); - - // Reload all HTML artifacts since they might depend on this updated artifact - this.reloadAllHtmlArtifacts(); - - // For HTML files, wait for execution - let result = `Updated file ${params.filename}`; - if (this.getFileType(params.filename) === "html" && !options.skipWait) { - const logs = await this.waitForHtmlExecution(params.filename); - result += `\n${logs}`; - } - - return result; - } - - private async rewriteArtifact( - params: ArtifactsParams, - options: { skipWait?: boolean; silent?: boolean } = {}, - ): Promise { - const artifact = this._artifacts.get(params.filename); - if (!artifact) { - const files = Array.from(this._artifacts.keys()); - if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`; - return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`; - } - if (!params.content) { - return "Error: rewrite command requires content"; - } - - artifact.content = params.content; - artifact.updatedAt = new Date(); - this._artifacts.set(params.filename, artifact); - - // Update element - this.getOrCreateArtifactElement(params.filename, artifact.content); - if (!options.silent) { - this.onArtifactsChange?.(); - } - - // Show the artifact - this.showArtifact(params.filename); - - // Reload all HTML artifacts since they might depend on this rewritten artifact - this.reloadAllHtmlArtifacts(); - - // For HTML files, wait for execution - let result = ""; - if (this.getFileType(params.filename) === "html" && !options.skipWait) { - const logs = await this.waitForHtmlExecution(params.filename); - result += `\n${logs}`; - } - - return result; - } - - private getArtifact(params: ArtifactsParams): string { - const artifact = this._artifacts.get(params.filename); - if (!artifact) { - const files = Array.from(this._artifacts.keys()); - if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`; - return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`; - } - return artifact.content; - } - - private deleteArtifact(params: ArtifactsParams): string { - const artifact = this._artifacts.get(params.filename); - if (!artifact) { - const files = Array.from(this._artifacts.keys()); - if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`; - return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`; - } - - this._artifacts.delete(params.filename); - this._artifacts = new Map(this._artifacts); - - // Remove element - const element = this.artifactElements.get(params.filename); - if (element) { - element.remove(); - this.artifactElements.delete(params.filename); - } - - // Show another artifact if this was active - if (this._activeFilename === params.filename) { - const remaining = Array.from(this._artifacts.keys()); - if (remaining.length > 0) { - this.showArtifact(remaining[0]); - } else { - this._activeFilename = null; - this.requestUpdate(); - } - } - this.onArtifactsChange?.(); - this.requestUpdate(); - - // Reload all HTML artifacts since they might have depended on this deleted artifact - this.reloadAllHtmlArtifacts(); - - return `Deleted file ${params.filename}`; - } - - private getLogs(params: ArtifactsParams): string { - const element = this.artifactElements.get(params.filename); - if (!element) { - const files = Array.from(this._artifacts.keys()); - if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`; - return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`; - } - - if (!(element instanceof HtmlArtifact)) { - return `Error: File ${params.filename} is not an HTML file. Logs are only available for HTML files.`; - } - - return element.getLogs(); - } - - override render(): TemplateResult { - const artifacts = Array.from(this._artifacts.values()); - - // Panel is hidden when collapsed OR when there are no artifacts - const showPanel = artifacts.length > 0 && !this.collapsed; - - return html` -
- -
-
- ${artifacts.map((a) => { - const isActive = a.filename === this._activeFilename; - const activeClass = isActive - ? "border-primary text-primary" - : "border-transparent text-muted-foreground hover:text-foreground"; - return html` - - `; - })} -
-
- ${(() => { - const active = this._activeFilename ? this.artifactElements.get(this._activeFilename) : undefined; - return active ? active.getHeaderButtons() : ""; - })()} - ${Button({ - variant: "ghost", - size: "sm", - onClick: () => this.onClose?.(), - title: i18n("Close artifacts"), - children: icon(X, "sm"), - })} -
-
- - -
-
- `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "artifacts-panel": ArtifactsPanel; - } -} diff --git a/packages/web-ui/src/tools/artifacts/index.ts b/packages/web-ui/src/tools/artifacts/index.ts deleted file mode 100644 index e45580a1..00000000 --- a/packages/web-ui/src/tools/artifacts/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { ArtifactElement } from "./ArtifactElement.js"; -export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js"; -export { ArtifactsToolRenderer } from "./artifacts-tool-renderer.js"; -export { HtmlArtifact } from "./HtmlArtifact.js"; -export { MarkdownArtifact } from "./MarkdownArtifact.js"; -export { SvgArtifact } from "./SvgArtifact.js"; -export { TextArtifact } from "./TextArtifact.js"; diff --git a/packages/web-ui/src/tools/extract-document.ts b/packages/web-ui/src/tools/extract-document.ts deleted file mode 100644 index 77f60238..00000000 --- a/packages/web-ui/src/tools/extract-document.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html } from "lit"; -import { createRef, ref } from "lit/directives/ref.js"; -import { FileText } from "lucide"; -import { type Static, Type } from "typebox"; -import { EXTRACT_DOCUMENT_DESCRIPTION } from "../prompts/prompts.js"; -import { loadAttachment } from "../utils/attachment-utils.js"; -import { isCorsError } from "../utils/proxy-utils.js"; -import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "./types.js"; - -// ============================================================================ -// TYPES -// ============================================================================ - -const extractDocumentSchema = Type.Object({ - url: Type.String({ - description: "URL of the document to extract text from (PDF, DOCX, XLSX, or PPTX)", - }), -}); - -export type ExtractDocumentParams = Static; - -export interface ExtractDocumentResult { - extractedText: string; - format: string; - fileName: string; - size: number; -} - -// ============================================================================ -// TOOL -// ============================================================================ - -export function createExtractDocumentTool(): AgentTool & { - corsProxyUrl?: string; -} { - const tool = { - label: "Extract Document", - name: "extract_document", - corsProxyUrl: undefined as string | undefined, // Can be set by consumer (e.g., from user settings) - description: EXTRACT_DOCUMENT_DESCRIPTION, - parameters: extractDocumentSchema, - execute: async (_toolCallId: string, args: ExtractDocumentParams, signal?: AbortSignal) => { - if (signal?.aborted) { - throw new Error("Extract document aborted"); - } - - const url = args.url.trim(); - if (!url) { - throw new Error("URL is required"); - } - - // Validate URL format - try { - new URL(url); - } catch { - throw new Error(`Invalid URL: ${url}`); - } - - // Size limit: 50MB - const MAX_SIZE = 50 * 1024 * 1024; - - // Helper function to fetch and process document - const fetchAndProcess = async (fetchUrl: string) => { - const response = await fetch(fetchUrl, { signal }); - - if (!response.ok) { - throw new Error( - `TELL USER: Unable to download the document (${response.status} ${response.statusText}). The site likely blocks automated downloads.\n\n` + - `INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`, - ); - } - - // Check size before downloading - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const size = Number.parseInt(contentLength, 10); - if (size > MAX_SIZE) { - throw new Error( - `Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`, - ); - } - } - - // Download the document - const arrayBuffer = await response.arrayBuffer(); - const size = arrayBuffer.byteLength; - - if (size > MAX_SIZE) { - throw new Error( - `Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`, - ); - } - - return arrayBuffer; - }; - - // Try without proxy first, fallback to proxy on CORS error - let arrayBuffer: ArrayBuffer; - - try { - // Attempt direct fetch first - arrayBuffer = await fetchAndProcess(url); - } catch (directError: any) { - // If CORS error and proxy is available, retry with proxy - if (isCorsError(directError) && tool.corsProxyUrl) { - try { - const proxiedUrl = tool.corsProxyUrl + encodeURIComponent(url); - arrayBuffer = await fetchAndProcess(proxiedUrl); - } catch (proxyError: any) { - // Proxy fetch also failed - throw helpful message - throw new Error( - `TELL USER: Unable to fetch the document due to CORS restrictions.\n\n` + - `Tried with proxy but it also failed: ${proxyError.message}\n\n` + - `INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`, - ); - } - } else if (isCorsError(directError) && !tool.corsProxyUrl) { - // CORS error but no proxy configured - throw new Error( - `TELL USER: Unable to fetch the document due to CORS restrictions (the server blocks requests from browser extensions).\n\n` + - `To fix this, you need to configure a CORS proxy in Sitegeist settings:\n` + - `1. Open Sitegeist settings\n` + - `2. Find "CORS Proxy URL" setting\n` + - `3. Enter a proxy URL like: https://corsproxy.io/?\n` + - `4. Save and try again\n\n` + - `Alternatively, download the file manually and attach it to your message using the attachment button (paperclip icon).`, - ); - } else { - // Not a CORS error - re-throw - throw directError; - } - } - - // Extract filename from URL - const urlParts = url.split("/"); - let fileName = urlParts[urlParts.length - 1]?.split("?")[0] || "document"; - if (url.startsWith("https://arxiv.org/")) { - fileName = `${fileName}.pdf`; - } - - // Use loadAttachment to process the document - const attachment = await loadAttachment(arrayBuffer, fileName); - - if (!attachment.extractedText) { - throw new Error( - `Document format not supported. Supported formats:\n` + - `- PDF (.pdf)\n` + - `- Word (.docx)\n` + - `- Excel (.xlsx, .xls)\n` + - `- PowerPoint (.pptx)`, - ); - } - - // Determine format from attachment - let format = "unknown"; - if (attachment.mimeType.includes("pdf")) { - format = "pdf"; - } else if (attachment.mimeType.includes("wordprocessingml")) { - format = "docx"; - } else if (attachment.mimeType.includes("spreadsheetml") || attachment.mimeType.includes("ms-excel")) { - format = "xlsx"; - } else if (attachment.mimeType.includes("presentationml")) { - format = "pptx"; - } - - return { - content: [{ type: "text" as const, text: attachment.extractedText }], - details: { - extractedText: attachment.extractedText, - format, - fileName: attachment.fileName, - size: attachment.size, - }, - }; - }, - }; - return tool; -} - -// Export a default instance -export const extractDocumentTool = createExtractDocumentTool(); - -// ============================================================================ -// RENDERER -// ============================================================================ - -export const extractDocumentRenderer: ToolRenderer = { - render( - params: ExtractDocumentParams | undefined, - result: ToolResultMessage | undefined, - isStreaming?: boolean, - ): ToolRenderResult { - // Determine status - const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete"; - - // Create refs for collapsible sections - const contentRef = createRef(); - const chevronRef = createRef(); - - // With result: show params + result - if (result && params) { - const details = result.details; - const title = details - ? result.isError - ? `Failed to extract ${details.fileName || "document"}` - : `Extracted text from ${details.fileName} (${details.format.toUpperCase()}, ${(details.size / 1024).toFixed(1)}KB)` - : result.isError - ? "Failed to extract document" - : "Extracted text from document"; - - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - - return { - content: html` -
- ${renderCollapsibleHeader(state, FileText, title, contentRef, chevronRef, false)} -
- ${ - params.url - ? html`
- URL: ${params.url} -
` - : "" - } - ${ - output && !result.isError - ? html`` - : "" - } - ${ - result.isError && output - ? html`` - : "" - } -
-
- `, - isCustom: false, - }; - } - - // Just params (streaming or waiting for result) - if (params) { - const title = "Extracting document..."; - - return { - content: html` -
- ${renderCollapsibleHeader(state, FileText, title, contentRef, chevronRef, false)} -
-
URL: ${params.url}
-
-
- `, - isCustom: false, - }; - } - - // No params or result yet - return { - content: renderHeader(state, FileText, "Preparing extraction..."), - isCustom: false, - }; - }, -}; - -// Auto-register the renderer -registerToolRenderer("extract_document", extractDocumentRenderer); diff --git a/packages/web-ui/src/tools/index.ts b/packages/web-ui/src/tools/index.ts deleted file mode 100644 index 762e6232..00000000 --- a/packages/web-ui/src/tools/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import "./javascript-repl.js"; // Auto-registers the renderer -import "./extract-document.js"; // Auto-registers the renderer -import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js"; -import { BashRenderer } from "./renderers/BashRenderer.js"; -import { DefaultRenderer } from "./renderers/DefaultRenderer.js"; -import type { ToolRenderResult } from "./types.js"; - -// Register all built-in tool renderers -registerToolRenderer("bash", new BashRenderer()); - -const defaultRenderer = new DefaultRenderer(); - -// Global flag to force default JSON rendering for all tools -let showJsonMode = false; - -/** - * Enable or disable show JSON mode - * When enabled, all tool renderers will use the default JSON renderer - */ -export function setShowJsonMode(enabled: boolean): void { - showJsonMode = enabled; -} - -/** - * Render tool - unified function that handles params, result, and streaming state - */ -export function renderTool( - toolName: string, - params: any | undefined, - result: ToolResultMessage | undefined, - isStreaming?: boolean, -): ToolRenderResult { - // If showJsonMode is enabled, always use the default renderer - if (showJsonMode) { - return defaultRenderer.render(params, result, isStreaming); - } - - const renderer = getToolRenderer(toolName); - if (renderer) { - return renderer.render(params, result, isStreaming); - } - return defaultRenderer.render(params, result, isStreaming); -} - -export { getToolRenderer, registerToolRenderer }; diff --git a/packages/web-ui/src/tools/javascript-repl.ts b/packages/web-ui/src/tools/javascript-repl.ts deleted file mode 100644 index 1ed4125f..00000000 --- a/packages/web-ui/src/tools/javascript-repl.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; -import { html } from "lit"; -import { createRef, ref } from "lit/directives/ref.js"; -import { Code } from "lucide"; -import { type Static, Type } from "typebox"; -import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js"; -import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntimeProvider.js"; -import { JAVASCRIPT_REPL_TOOL_DESCRIPTION } from "../prompts/prompts.js"; -import type { Attachment } from "../utils/attachment-utils.js"; -import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "./types.js"; - -// Execute JavaScript code with attachments using SandboxedIframe -export async function executeJavaScript( - code: string, - runtimeProviders: SandboxRuntimeProvider[], - signal?: AbortSignal, - sandboxUrlProvider?: () => string, -): Promise<{ output: string; files?: SandboxFile[] }> { - if (!code) { - throw new Error("Code parameter is required"); - } - - // Check for abort before starting - if (signal?.aborted) { - throw new Error("Execution aborted"); - } - - // Create a SandboxedIframe instance for execution - const sandbox = new SandboxIframe(); - if (sandboxUrlProvider) { - sandbox.sandboxUrlProvider = sandboxUrlProvider; - } - sandbox.style.display = "none"; - document.body.appendChild(sandbox); - - try { - const sandboxId = `repl-${Date.now()}-${Math.random().toString(36).substring(7)}`; - - // Pass providers to execute (router handles all message routing) - // No additional consumers needed - execute() has its own internal consumer - const result: SandboxResult = await sandbox.execute(sandboxId, code, runtimeProviders, [], signal); - - // Remove the sandbox iframe after execution - sandbox.remove(); - - // Build plain text response - let output = ""; - - // Add console output - result.console contains { type: string, text: string } from sandbox.js - if (result.console && result.console.length > 0) { - for (const entry of result.console) { - output += `${entry.text}\n`; - } - } - - // Add error if execution failed - if (!result.success) { - if (output) output += "\n"; - output += `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`; - - // Throw error so tool call is marked as failed - throw new Error(output.trim()); - } - - // Add return value if present - if (result.returnValue !== undefined) { - if (output) output += "\n"; - output += `=> ${typeof result.returnValue === "object" ? JSON.stringify(result.returnValue, null, 2) : result.returnValue}`; - } - - // Add file notifications - if (result.files && result.files.length > 0) { - output += `\n[Files returned: ${result.files.length}]\n`; - for (const file of result.files) { - output += ` - ${file.fileName} (${file.mimeType})\n`; - } - } else { - // Explicitly note when no files were returned (helpful for debugging) - if (code.includes("returnFile")) { - output += "\n[No files returned - check async operations]"; - } - } - - return { - output: output.trim() || "Code executed successfully (no output)", - files: result.files, - }; - } catch (error: unknown) { - // Clean up on error - sandbox.remove(); - throw new Error((error as Error).message || "Execution failed"); - } -} - -export type JavaScriptReplToolResult = { - files?: - | { - fileName: string; - contentBase64: string; - mimeType: string; - }[] - | undefined; -}; - -const javascriptReplSchema = Type.Object({ - title: Type.String({ - description: - "Brief title describing what the code snippet tries to achieve in active form, e.g. 'Calculating sum'", - }), - code: Type.String({ description: "JavaScript code to execute" }), -}); - -export type JavaScriptReplParams = Static; - -interface JavaScriptReplResult { - output?: string; - files?: Array<{ - fileName: string; - mimeType: string; - size: number; - contentBase64: string; - }>; -} - -export function createJavaScriptReplTool(): AgentTool & { - runtimeProvidersFactory?: () => SandboxRuntimeProvider[]; - sandboxUrlProvider?: () => string; -} { - return { - label: "JavaScript REPL", - name: "javascript_repl", - runtimeProvidersFactory: () => [], // default to empty array - sandboxUrlProvider: undefined, // optional, for browser extensions - get description() { - const runtimeProviderDescriptions = - this.runtimeProvidersFactory?.() - .map((d) => d.getDescription()) - .filter((d) => d.trim().length > 0) || []; - return JAVASCRIPT_REPL_TOOL_DESCRIPTION(runtimeProviderDescriptions); - }, - parameters: javascriptReplSchema, - execute: async function (_toolCallId: string, args: Static, signal?: AbortSignal) { - const result = await executeJavaScript( - args.code, - this.runtimeProvidersFactory?.() ?? [], - signal, - this.sandboxUrlProvider, - ); - // Convert files to JSON-serializable with base64 payloads - const files = (result.files || []).map((f) => { - const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => { - if (input instanceof Uint8Array) { - let binary = ""; - const chunk = 0x8000; - for (let i = 0; i < input.length; i += chunk) { - binary += String.fromCharCode(...input.subarray(i, i + chunk)); - } - return { base64: btoa(binary), size: input.length }; - } else if (typeof input === "string") { - const enc = new TextEncoder(); - const bytes = enc.encode(input); - let binary = ""; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - return { base64: btoa(binary), size: bytes.length }; - } else { - const s = String(input); - const enc = new TextEncoder(); - const bytes = enc.encode(s); - let binary = ""; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - return { base64: btoa(binary), size: bytes.length }; - } - }; - - const { base64, size } = toBase64(f.content); - return { - fileName: f.fileName || "file", - mimeType: f.mimeType || "application/octet-stream", - size, - contentBase64: base64, - }; - }); - return { content: [{ type: "text", text: result.output }], details: { files } }; - }, - }; -} - -// Export a default instance for backward compatibility -export const javascriptReplTool = createJavaScriptReplTool(); - -export const javascriptReplRenderer: ToolRenderer = { - render( - params: JavaScriptReplParams | undefined, - result: ToolResultMessage | undefined, - isStreaming?: boolean, - ): ToolRenderResult { - // Determine status - const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete"; - - // Create refs for collapsible code section - const codeContentRef = createRef(); - const codeChevronRef = createRef(); - - // With result: show params + result - if (result && params) { - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - const files = result.details?.files || []; - - const attachments: Attachment[] = files.map((f, i) => { - // Decode base64 content for text files to show in overlay - let extractedText: string | undefined; - const isTextBased = - f.mimeType?.startsWith("text/") || - f.mimeType === "application/json" || - f.mimeType === "application/javascript" || - f.mimeType?.includes("xml"); - - if (isTextBased && f.contentBase64) { - try { - extractedText = atob(f.contentBase64); - } catch (_e) { - console.warn("Failed to decode base64 content for", f.fileName); - } - } - - return { - id: `repl-${Date.now()}-${i}`, - type: f.mimeType?.startsWith("image/") ? "image" : "document", - fileName: f.fileName || `file-${i}`, - mimeType: f.mimeType || "application/octet-stream", - size: f.size ?? 0, - content: f.contentBase64, - preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined, - extractedText, - }; - }); - - return { - content: html` -
- ${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)} -
- - ${output ? html`` : ""} -
- ${ - attachments.length - ? html`
- ${attachments.map((att) => html``)} -
` - : "" - } -
- `, - isCustom: false, - }; - } - - // Just params (streaming or waiting for result) - if (params) { - return { - content: html` -
- ${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)} -
- ${params.code ? html`` : ""} -
-
- `, - isCustom: false, - }; - } - - // No params or result yet - return { content: renderHeader(state, Code, i18n("Preparing JavaScript...")), isCustom: false }; - }, -}; - -// Auto-register the renderer -registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer); diff --git a/packages/web-ui/src/tools/renderer-registry.ts b/packages/web-ui/src/tools/renderer-registry.ts deleted file mode 100644 index 0d19781d..00000000 --- a/packages/web-ui/src/tools/renderer-registry.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { icon } from "@mariozechner/mini-lit"; -import { html, type TemplateResult } from "lit"; -import type { Ref } from "lit/directives/ref.js"; -import { ref } from "lit/directives/ref.js"; -import { ChevronsUpDown, ChevronUp, Loader } from "lucide"; -import type { ToolRenderer } from "./types.js"; - -// Registry of tool renderers -export const toolRenderers = new Map(); - -/** - * Register a custom tool renderer - */ -export function registerToolRenderer(toolName: string, renderer: ToolRenderer): void { - toolRenderers.set(toolName, renderer); -} - -/** - * Get a tool renderer by name - */ -export function getToolRenderer(toolName: string): ToolRenderer | undefined { - return toolRenderers.get(toolName); -} - -/** - * Helper to render a header for tool renderers - * Shows icon on left when complete/error, spinner on right when in progress - */ -export function renderHeader( - state: "inprogress" | "complete" | "error", - toolIcon: any, - text: string | TemplateResult, -): TemplateResult { - const statusIcon = (iconComponent: any, color: string) => - html`${icon(iconComponent, "sm")}`; - - switch (state) { - case "inprogress": - return html` -
-
- ${statusIcon(toolIcon, "text-foreground")} - ${text} -
- ${statusIcon(Loader, "text-foreground animate-spin")} -
- `; - case "complete": - return html` -
- ${statusIcon(toolIcon, "text-green-600 dark:text-green-500")} - ${text} -
- `; - case "error": - return html` -
- ${statusIcon(toolIcon, "text-destructive")} - ${text} -
- `; - } -} - -/** - * Helper to render a collapsible header for tool renderers - * Same as renderHeader but with a chevron button that toggles visibility of content - */ -export function renderCollapsibleHeader( - state: "inprogress" | "complete" | "error", - toolIcon: any, - text: string | TemplateResult, - contentRef: Ref, - chevronRef: Ref, - defaultExpanded = false, -): TemplateResult { - const statusIcon = (iconComponent: any, color: string) => - html`${icon(iconComponent, "sm")}`; - - const toggleContent = (e: Event) => { - e.preventDefault(); - const content = contentRef.value; - const chevron = chevronRef.value; - if (content && chevron) { - const isCollapsed = content.classList.contains("max-h-0"); - if (isCollapsed) { - content.classList.remove("max-h-0"); - content.classList.add("max-h-[2000px]", "mt-3"); - // Show ChevronUp, hide ChevronsUpDown - const upIcon = chevron.querySelector(".chevron-up"); - const downIcon = chevron.querySelector(".chevrons-up-down"); - if (upIcon && downIcon) { - upIcon.classList.remove("hidden"); - downIcon.classList.add("hidden"); - } - } else { - content.classList.remove("max-h-[2000px]", "mt-3"); - content.classList.add("max-h-0"); - // Show ChevronsUpDown, hide ChevronUp - const upIcon = chevron.querySelector(".chevron-up"); - const downIcon = chevron.querySelector(".chevrons-up-down"); - if (upIcon && downIcon) { - upIcon.classList.add("hidden"); - downIcon.classList.remove("hidden"); - } - } - } - }; - - const toolIconColor = - state === "complete" - ? "text-green-600 dark:text-green-500" - : state === "error" - ? "text-destructive" - : "text-foreground"; - - return html` - - `; -} diff --git a/packages/web-ui/src/tools/renderers/BashRenderer.ts b/packages/web-ui/src/tools/renderers/BashRenderer.ts deleted file mode 100644 index 97633d7f..00000000 --- a/packages/web-ui/src/tools/renderers/BashRenderer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html } from "lit"; -import { SquareTerminal } from "lucide"; -import { i18n } from "../../utils/i18n.js"; -import { renderHeader } from "../renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "../types.js"; - -interface BashParams { - command: string; -} - -// Bash tool has undefined details (only uses output) -export class BashRenderer implements ToolRenderer { - render(params: BashParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - - // With result: show command + output - if (result && params?.command) { - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - const combined = output ? `> ${params.command}\n\n${output}` : `> ${params.command}`; - return { - content: html` -
- ${renderHeader(state, SquareTerminal, i18n("Running command..."))} - -
- `, - isCustom: false, - }; - } - - // Just params (streaming or waiting) - if (params?.command) { - return { - content: html` -
- ${renderHeader(state, SquareTerminal, i18n("Running command..."))} - ${params.command}`}> -
- `, - isCustom: false, - }; - } - - // No params yet - return { content: renderHeader(state, SquareTerminal, i18n("Waiting for command...")), isCustom: false }; - } -} diff --git a/packages/web-ui/src/tools/renderers/CalculateRenderer.ts b/packages/web-ui/src/tools/renderers/CalculateRenderer.ts deleted file mode 100644 index 0a290147..00000000 --- a/packages/web-ui/src/tools/renderers/CalculateRenderer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html } from "lit"; -import { Calculator } from "lucide"; -import { i18n } from "../../utils/i18n.js"; -import { renderHeader } from "../renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "../types.js"; - -interface CalculateParams { - expression: string; -} - -// Calculate tool has undefined details (only uses output) -export class CalculateRenderer implements ToolRenderer { - render(params: CalculateParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - - // Full params + full result - if (result && params?.expression) { - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - - // Error: show expression in header, error below - if (result.isError) { - return { - content: html` -
- ${renderHeader(state, Calculator, params.expression)} -
${output}
-
- `, - isCustom: false, - }; - } - - // Success: show expression = result in header - return { content: renderHeader(state, Calculator, `${params.expression} = ${output}`), isCustom: false }; - } - - // Full params, no result: just show header with expression in it - if (params?.expression) { - return { - content: renderHeader(state, Calculator, `${i18n("Calculating")} ${params.expression}`), - isCustom: false, - }; - } - - // Partial params (empty expression), no result - if (params && !params.expression) { - return { content: renderHeader(state, Calculator, i18n("Writing expression...")), isCustom: false }; - } - - // No params, no result - return { content: renderHeader(state, Calculator, i18n("Waiting for expression...")), isCustom: false }; - } -} diff --git a/packages/web-ui/src/tools/renderers/DefaultRenderer.ts b/packages/web-ui/src/tools/renderers/DefaultRenderer.ts deleted file mode 100644 index 227649a9..00000000 --- a/packages/web-ui/src/tools/renderers/DefaultRenderer.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html } from "lit"; -import { Code } from "lucide"; -import { i18n } from "../../utils/i18n.js"; -import { renderHeader } from "../renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "../types.js"; - -export class DefaultRenderer implements ToolRenderer { - render(params: any | undefined, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete"; - - // Format params as JSON - let paramsJson = ""; - if (params) { - try { - paramsJson = JSON.stringify(JSON.parse(params), null, 2); - } catch { - try { - paramsJson = JSON.stringify(params, null, 2); - } catch { - paramsJson = String(params); - } - } - } - - // With result: show header + params + result - if (result) { - let outputJson = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || i18n("(no output)"); - let outputLanguage = "text"; - - // Try to parse and pretty-print if it's valid JSON - try { - const parsed = JSON.parse(outputJson); - outputJson = JSON.stringify(parsed, null, 2); - outputLanguage = "json"; - } catch { - // Not valid JSON, leave as-is and use text highlighting - } - - return { - content: html` -
- ${renderHeader(state, Code, "Tool Call")} - ${ - paramsJson - ? html`
-
${i18n("Input")}
- -
` - : "" - } -
-
${i18n("Output")}
- -
-
- `, - isCustom: false, - }; - } - - // Just params (streaming or waiting for result) - if (params) { - if (isStreaming && (!paramsJson || paramsJson === "{}" || paramsJson === "null")) { - return { - content: html` -
- ${renderHeader(state, Code, "Preparing tool parameters...")} -
- `, - isCustom: false, - }; - } - - return { - content: html` -
- ${renderHeader(state, Code, "Tool Call")} -
-
${i18n("Input")}
- -
-
- `, - isCustom: false, - }; - } - - // No params or result yet - return { - content: html` -
- ${renderHeader(state, Code, "Preparing tool...")} -
- `, - isCustom: false, - }; - } -} diff --git a/packages/web-ui/src/tools/renderers/GetCurrentTimeRenderer.ts b/packages/web-ui/src/tools/renderers/GetCurrentTimeRenderer.ts deleted file mode 100644 index 0bd3664b..00000000 --- a/packages/web-ui/src/tools/renderers/GetCurrentTimeRenderer.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { html } from "lit"; -import { Clock } from "lucide"; -import { i18n } from "../../utils/i18n.js"; -import { renderHeader } from "../renderer-registry.js"; -import type { ToolRenderer, ToolRenderResult } from "../types.js"; - -interface GetCurrentTimeParams { - timezone?: string; -} - -// GetCurrentTime tool has undefined details (only uses output) -export class GetCurrentTimeRenderer implements ToolRenderer { - render( - params: GetCurrentTimeParams | undefined, - result: ToolResultMessage | undefined, - ): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - - // Full params + full result - if (result && params) { - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - const headerText = params.timezone - ? `${i18n("Getting current time in")} ${params.timezone}` - : i18n("Getting current date and time"); - - // Error: show header, error below - if (result.isError) { - return { - content: html` -
- ${renderHeader(state, Clock, headerText)} -
${output}
-
- `, - isCustom: false, - }; - } - - // Success: show time in header - return { content: renderHeader(state, Clock, `${headerText}: ${output}`), isCustom: false }; - } - - // Full result, no params - if (result) { - const output = - result.content - ?.filter((c) => c.type === "text") - .map((c: any) => c.text) - .join("\n") || ""; - - // Error: show header, error below - if (result.isError) { - return { - content: html` -
- ${renderHeader(state, Clock, i18n("Getting current date and time"))} -
${output}
-
- `, - isCustom: false, - }; - } - - // Success: show time in header - return { - content: renderHeader(state, Clock, `${i18n("Getting current date and time")}: ${output}`), - isCustom: false, - }; - } - - // Full params, no result: show timezone info in header - if (params?.timezone) { - return { - content: renderHeader(state, Clock, `${i18n("Getting current time in")} ${params.timezone}`), - isCustom: false, - }; - } - - // Partial params (no timezone) or empty params, no result - if (params) { - return { content: renderHeader(state, Clock, i18n("Getting current date and time")), isCustom: false }; - } - - // No params, no result - return { content: renderHeader(state, Clock, i18n("Getting time...")), isCustom: false }; - } -} diff --git a/packages/web-ui/src/tools/types.ts b/packages/web-ui/src/tools/types.ts deleted file mode 100644 index 37950364..00000000 --- a/packages/web-ui/src/tools/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import type { TemplateResult } from "lit"; - -export interface ToolRenderResult { - content: TemplateResult; - isCustom: boolean; // true = no card wrapper, false = wrap in card -} - -export interface ToolRenderer { - render( - params: TParams | undefined, - result: ToolResultMessage | undefined, - isStreaming?: boolean, - ): ToolRenderResult; -} diff --git a/packages/web-ui/src/utils/attachment-utils.ts b/packages/web-ui/src/utils/attachment-utils.ts deleted file mode 100644 index afe98e44..00000000 --- a/packages/web-ui/src/utils/attachment-utils.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { parseAsync } from "docx-preview"; -import JSZip from "jszip"; -import type { PDFDocumentProxy } from "pdfjs-dist"; -import * as pdfjsLib from "pdfjs-dist"; -import * as XLSX from "xlsx"; -import { i18n } from "./i18n.js"; - -// Configure PDF.js worker - we'll need to bundle this -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); - -export interface Attachment { - id: string; - type: "image" | "document"; - fileName: string; - mimeType: string; - size: number; - content: string; // base64 encoded original data (without data URL prefix) - extractedText?: string; // For documents: text - preview?: string; // base64 image preview (first page for PDFs, or same as content for images) -} - -/** - * Load an attachment from various sources - * @param source - URL string, File, Blob, or ArrayBuffer - * @param fileName - Optional filename override - * @returns Promise - * @throws Error if loading fails - */ -export async function loadAttachment( - source: string | File | Blob | ArrayBuffer, - fileName?: string, -): Promise { - let arrayBuffer: ArrayBuffer; - let detectedFileName = fileName || "unnamed"; - let mimeType = "application/octet-stream"; - let size = 0; - - // Convert source to ArrayBuffer - if (typeof source === "string") { - // It's a URL - fetch it - const response = await fetch(source); - if (!response.ok) { - throw new Error(i18n("Failed to fetch file")); - } - arrayBuffer = await response.arrayBuffer(); - size = arrayBuffer.byteLength; - mimeType = response.headers.get("content-type") || mimeType; - if (!fileName) { - // Try to extract filename from URL - const urlParts = source.split("/"); - detectedFileName = urlParts[urlParts.length - 1] || "document"; - } - } else if (source instanceof File) { - arrayBuffer = await source.arrayBuffer(); - size = source.size; - mimeType = source.type || mimeType; - detectedFileName = fileName || source.name; - } else if (source instanceof Blob) { - arrayBuffer = await source.arrayBuffer(); - size = source.size; - mimeType = source.type || mimeType; - } else if (source instanceof ArrayBuffer) { - arrayBuffer = source; - size = source.byteLength; - } else { - throw new Error(i18n("Invalid source type")); - } - - // Convert ArrayBuffer to base64 - handle large files properly - const uint8Array = new Uint8Array(arrayBuffer); - let binary = ""; - const chunkSize = 0x8000; // Process in 32KB chunks to avoid stack overflow - for (let i = 0; i < uint8Array.length; i += chunkSize) { - const chunk = uint8Array.slice(i, i + chunkSize); - binary += String.fromCharCode(...chunk); - } - const base64Content = btoa(binary); - - // Detect type and process accordingly - const id = `${detectedFileName}_${Date.now()}_${Math.random()}`; - - // Check if it's a PDF - if (mimeType === "application/pdf" || detectedFileName.toLowerCase().endsWith(".pdf")) { - const { extractedText, preview } = await processPdf(arrayBuffer, detectedFileName); - return { - id, - type: "document", - fileName: detectedFileName, - mimeType: "application/pdf", - size, - content: base64Content, - extractedText, - preview, - }; - } - - // Check if it's a DOCX file - if ( - mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || - detectedFileName.toLowerCase().endsWith(".docx") - ) { - const { extractedText } = await processDocx(arrayBuffer, detectedFileName); - return { - id, - type: "document", - fileName: detectedFileName, - mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - size, - content: base64Content, - extractedText, - }; - } - - // Check if it's a PPTX file - if ( - mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" || - detectedFileName.toLowerCase().endsWith(".pptx") - ) { - const { extractedText } = await processPptx(arrayBuffer, detectedFileName); - return { - id, - type: "document", - fileName: detectedFileName, - mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - size, - content: base64Content, - extractedText, - }; - } - - // Check if it's an Excel file (XLSX/XLS) - const excelMimeTypes = [ - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.ms-excel", - ]; - if ( - excelMimeTypes.includes(mimeType) || - detectedFileName.toLowerCase().endsWith(".xlsx") || - detectedFileName.toLowerCase().endsWith(".xls") - ) { - const { extractedText } = await processExcel(arrayBuffer, detectedFileName); - return { - id, - type: "document", - fileName: detectedFileName, - mimeType: mimeType.startsWith("application/vnd") - ? mimeType - : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - size, - content: base64Content, - extractedText, - }; - } - - // Check if it's an image - if (mimeType.startsWith("image/")) { - return { - id, - type: "image", - fileName: detectedFileName, - mimeType, - size, - content: base64Content, - preview: base64Content, // For images, preview is the same as content - }; - } - - // Check if it's a text document - const textExtensions = [ - ".txt", - ".md", - ".json", - ".xml", - ".html", - ".css", - ".js", - ".ts", - ".jsx", - ".tsx", - ".yml", - ".yaml", - ]; - const isTextFile = - mimeType.startsWith("text/") || textExtensions.some((ext) => detectedFileName.toLowerCase().endsWith(ext)); - - if (isTextFile) { - const decoder = new TextDecoder(); - const text = decoder.decode(arrayBuffer); - return { - id, - type: "document", - fileName: detectedFileName, - mimeType: mimeType.startsWith("text/") ? mimeType : "text/plain", - size, - content: base64Content, - extractedText: text, - }; - } - - throw new Error(`Unsupported file type: ${mimeType}`); -} - -async function processPdf( - arrayBuffer: ArrayBuffer, - fileName: string, -): Promise<{ extractedText: string; preview?: string }> { - let pdf: PDFDocumentProxy | null = null; - try { - pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; - - // Extract text with page structure - let extractedText = ``; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - const pageText = textContent.items - .map((item: any) => item.str) - .filter((str: string) => str.trim()) - .join(" "); - extractedText += `\n\n${pageText}\n`; - } - extractedText += "\n"; - - // Generate preview from first page - const preview = await generatePdfPreview(pdf); - - return { extractedText, preview }; - } catch (error) { - console.error("Error processing PDF:", error); - throw new Error(`Failed to process PDF: ${String(error)}`); - } finally { - // Clean up PDF resources - if (pdf) { - pdf.destroy(); - } - } -} - -async function generatePdfPreview(pdf: PDFDocumentProxy): Promise { - try { - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1.0 }); - - // Create canvas with reasonable size for thumbnail (160x160 max) - const scale = Math.min(160 / viewport.width, 160 / viewport.height); - const scaledViewport = page.getViewport({ scale }); - - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) { - return undefined; - } - - canvas.height = scaledViewport.height; - canvas.width = scaledViewport.width; - - const renderContext = { - canvasContext: context, - viewport: scaledViewport, - canvas: canvas, - }; - await page.render(renderContext).promise; - - // Return base64 without data URL prefix - return canvas.toDataURL("image/png").split(",")[1]; - } catch (error) { - console.error("Error generating PDF preview:", error); - return undefined; - } -} - -async function processDocx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> { - try { - // Parse document structure - const wordDoc = await parseAsync(arrayBuffer); - - // Extract structured text from document body - let extractedText = `\n\n`; - - const body = wordDoc.documentPart?.body; - if (body?.children) { - // Walk through document elements and extract text - const texts: string[] = []; - for (const element of body.children) { - const text = extractTextFromElement(element); - if (text) { - texts.push(text); - } - } - extractedText += texts.join("\n"); - } - - extractedText += `\n\n`; - return { extractedText }; - } catch (error) { - console.error("Error processing DOCX:", error); - throw new Error(`Failed to process DOCX: ${String(error)}`); - } -} - -function extractTextFromElement(element: any): string { - let text = ""; - - // Check type with lowercase - const elementType = element.type?.toLowerCase() || ""; - - // Handle paragraphs - if (elementType === "paragraph" && element.children) { - for (const child of element.children) { - const childType = child.type?.toLowerCase() || ""; - if (childType === "run" && child.children) { - for (const textChild of child.children) { - const textType = textChild.type?.toLowerCase() || ""; - if (textType === "text") { - text += textChild.text || ""; - } - } - } else if (childType === "text") { - text += child.text || ""; - } - } - } - // Handle tables - else if (elementType === "table") { - if (element.children) { - const tableTexts: string[] = []; - for (const row of element.children) { - const rowType = row.type?.toLowerCase() || ""; - if (rowType === "tablerow" && row.children) { - const rowTexts: string[] = []; - for (const cell of row.children) { - const cellType = cell.type?.toLowerCase() || ""; - if (cellType === "tablecell" && cell.children) { - const cellTexts: string[] = []; - for (const cellElement of cell.children) { - const cellText = extractTextFromElement(cellElement); - if (cellText) cellTexts.push(cellText); - } - if (cellTexts.length > 0) rowTexts.push(cellTexts.join(" ")); - } - } - if (rowTexts.length > 0) tableTexts.push(rowTexts.join(" | ")); - } - } - if (tableTexts.length > 0) { - text = `\n[Table]\n${tableTexts.join("\n")}\n[/Table]\n`; - } - } - } - // Recursively handle other container elements - else if (element.children && Array.isArray(element.children)) { - const childTexts: string[] = []; - for (const child of element.children) { - const childText = extractTextFromElement(child); - if (childText) childTexts.push(childText); - } - text = childTexts.join(" "); - } - - return text.trim(); -} - -async function processPptx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> { - try { - // Load the PPTX file as a ZIP - const zip = await JSZip.loadAsync(arrayBuffer); - - // PPTX slides are stored in ppt/slides/slide[n].xml - let extractedText = ``; - - // Get all slide files and sort them numerically - const slideFiles = Object.keys(zip.files) - .filter((name) => name.match(/ppt\/slides\/slide\d+\.xml$/)) - .sort((a, b) => { - const numA = Number.parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10); - const numB = Number.parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10); - return numA - numB; - }); - - // Extract text from each slide - for (let i = 0; i < slideFiles.length; i++) { - const slideFile = zip.file(slideFiles[i]); - if (slideFile) { - const slideXml = await slideFile.async("text"); - - // Extract text from XML (simple regex approach) - // Looking for tags which contain text in PPTX - const textMatches = slideXml.match(/]*>([^<]+)<\/a:t>/g); - - if (textMatches) { - extractedText += `\n`; - const slideTexts = textMatches - .map((match) => { - const textMatch = match.match(/]*>([^<]+)<\/a:t>/); - return textMatch ? textMatch[1] : ""; - }) - .filter((t) => t.trim()); - - if (slideTexts.length > 0) { - extractedText += `\n${slideTexts.join("\n")}`; - } - extractedText += "\n"; - } - } - } - - // Also try to extract text from notes - const notesFiles = Object.keys(zip.files) - .filter((name) => name.match(/ppt\/notesSlides\/notesSlide\d+\.xml$/)) - .sort((a, b) => { - const numA = Number.parseInt(a.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10); - const numB = Number.parseInt(b.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10); - return numA - numB; - }); - - if (notesFiles.length > 0) { - extractedText += "\n"; - for (const noteFile of notesFiles) { - const file = zip.file(noteFile); - if (file) { - const noteXml = await file.async("text"); - const textMatches = noteXml.match(/]*>([^<]+)<\/a:t>/g); - if (textMatches) { - const noteTexts = textMatches - .map((match) => { - const textMatch = match.match(/]*>([^<]+)<\/a:t>/); - return textMatch ? textMatch[1] : ""; - }) - .filter((t) => t.trim()); - - if (noteTexts.length > 0) { - const slideNum = noteFile.match(/notesSlide(\d+)\.xml$/)?.[1]; - extractedText += `\n[Slide ${slideNum} notes]: ${noteTexts.join(" ")}`; - } - } - } - } - extractedText += "\n"; - } - - extractedText += "\n"; - return { extractedText }; - } catch (error) { - console.error("Error processing PPTX:", error); - throw new Error(`Failed to process PPTX: ${String(error)}`); - } -} - -async function processExcel(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> { - try { - // Read the workbook - const workbook = XLSX.read(arrayBuffer, { type: "array" }); - - let extractedText = ``; - - // Process each sheet - for (const [index, sheetName] of workbook.SheetNames.entries()) { - const worksheet = workbook.Sheets[sheetName]; - - // Extract text as CSV for the extractedText field - const csvText = XLSX.utils.sheet_to_csv(worksheet); - extractedText += `\n\n${csvText}\n`; - } - - extractedText += "\n"; - - return { extractedText }; - } catch (error) { - console.error("Error processing Excel:", error); - throw new Error(`Failed to process Excel: ${String(error)}`); - } -} diff --git a/packages/web-ui/src/utils/auth-token.ts b/packages/web-ui/src/utils/auth-token.ts deleted file mode 100644 index 385397e5..00000000 --- a/packages/web-ui/src/utils/auth-token.ts +++ /dev/null @@ -1,22 +0,0 @@ -import PromptDialog from "@mariozechner/mini-lit/dist/PromptDialog.js"; -import { i18n } from "./i18n.js"; - -export async function getAuthToken(): Promise { - let authToken: string | undefined = localStorage.getItem(`auth-token`) || ""; - if (authToken) return authToken; - - while (true) { - authToken = ( - await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true) - )?.trim(); - if (authToken) { - localStorage.setItem(`auth-token`, authToken); - break; - } - } - return authToken?.trim() || undefined; -} - -export async function clearAuthToken() { - localStorage.removeItem(`auth-token`); -} diff --git a/packages/web-ui/src/utils/format.ts b/packages/web-ui/src/utils/format.ts deleted file mode 100644 index 92119e88..00000000 --- a/packages/web-ui/src/utils/format.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Usage } from "@earendil-works/pi-ai"; -import { i18n } from "@mariozechner/mini-lit"; - -export function formatCost(cost: number): string { - return `$${cost.toFixed(4)}`; -} - -export function formatModelCost(cost: any): string { - if (!cost) return i18n("Free"); - const input = cost.input || 0; - const output = cost.output || 0; - if (input === 0 && output === 0) return i18n("Free"); - - // Format numbers with appropriate precision - const formatNum = (num: number): string => { - if (num >= 100) return num.toFixed(0); - if (num >= 10) return num.toFixed(1).replace(/\.0$/, ""); - if (num >= 1) return num.toFixed(2).replace(/\.?0+$/, ""); - return num.toFixed(3).replace(/\.?0+$/, ""); - }; - - return `$${formatNum(input)}/$${formatNum(output)}`; -} - -export function formatUsage(usage: Usage) { - if (!usage) return ""; - - const parts = []; - if (usage.input) parts.push(`↑${formatTokenCount(usage.input)}`); - if (usage.output) parts.push(`↓${formatTokenCount(usage.output)}`); - if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`); - if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`); - if (usage.cost?.total) parts.push(formatCost(usage.cost.total)); - - return parts.join(" "); -} - -export function formatTokenCount(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - return `${Math.round(count / 1000)}k`; -} diff --git a/packages/web-ui/src/utils/i18n.ts b/packages/web-ui/src/utils/i18n.ts deleted file mode 100644 index 1f50a7d0..00000000 --- a/packages/web-ui/src/utils/i18n.ts +++ /dev/null @@ -1,653 +0,0 @@ -import { defaultEnglish, defaultGerman, type MiniLitRequiredMessages, setTranslations } from "@mariozechner/mini-lit"; - -declare module "@mariozechner/mini-lit" { - interface i18nMessages extends MiniLitRequiredMessages { - Free: string; - "Input Required": string; - Cancel: string; - Confirm: string; - "Select Model": string; - "Search models...": string; - Format: string; - Thinking: string; - Vision: string; - You: string; - Assistant: string; - "Thinking...": string; - "Type your message...": string; - "API Keys Configuration": string; - "Configure API keys for LLM providers. Keys are stored locally in your browser.": string; - Configured: string; - "Not configured": string; - "✓ Valid": string; - "✗ Invalid": string; - "Testing...": string; - Update: string; - Test: string; - Remove: string; - Save: string; - "Update API key": string; - "Enter API key": string; - "Type a message...": string; - "Failed to fetch file": string; - "Invalid source type": string; - PDF: string; - Document: string; - Presentation: string; - Spreadsheet: string; - Text: string; - "Error loading file": string; - "No text content available": string; - "Failed to load PDF": string; - "Failed to load document": string; - "Failed to load spreadsheet": string; - "Error loading PDF": string; - "Error loading document": string; - "Error loading spreadsheet": string; - "Preview not available for this file type.": string; - "Click the download button above to view it on your computer.": string; - "No content available": string; - "Failed to display text content": string; - "API keys are required to use AI models. Get your keys from the provider's website.": string; - console: string; - "Copy output": string; - "Copied!": string; - "Error:": string; - "Request aborted": string; - Call: string; - Result: string; - "(no result)": string; - "Waiting for tool result…": string; - "Call was aborted; no result.": string; - "No session available": string; - "No session set": string; - "Preparing tool parameters...": string; - "(no output)": string; - Input: string; - Output: string; - "Writing expression...": string; - "Waiting for expression...": string; - Calculating: string; - "Getting current time in": string; - "Getting current date and time": string; - "Waiting for command...": string; - "Writing command...": string; - "Running command...": string; - "Command failed:": string; - "Enter Auth Token": string; - "Please enter your auth token.": string; - "Auth token is required for proxy transport": string; - // JavaScript REPL strings - "Execution aborted": string; - "Code parameter is required": string; - "Unknown error": string; - "Code executed successfully (no output)": string; - "Execution failed": string; - "JavaScript REPL": string; - "JavaScript code to execute": string; - "Writing JavaScript code...": string; - "Executing JavaScript": string; - "Preparing JavaScript...": string; - "Preparing command...": string; - "Preparing calculation...": string; - "Preparing tool...": string; - "Getting time...": string; - // Artifacts strings - "Processing artifact...": string; - "Preparing artifact...": string; - "Processing artifact": string; - "Processed artifact": string; - "Creating artifact": string; - "Created artifact": string; - "Updating artifact": string; - "Updated artifact": string; - "Rewriting artifact": string; - "Rewrote artifact": string; - "Getting artifact": string; - "Got artifact": string; - "Deleting artifact": string; - "Deleted artifact": string; - "Getting logs": string; - "Got logs": string; - "An error occurred": string; - "Copy logs": string; - "Autoscroll enabled": string; - "Autoscroll disabled": string; - Processing: string; - Create: string; - Rewrite: string; - Get: string; - Delete: string; - "Get logs": string; - "Show artifacts": string; - "Close artifacts": string; - Artifacts: string; - "Copy HTML": string; - "Download HTML": string; - "Reload HTML": string; - "Copy SVG": string; - "Download SVG": string; - "Copy Markdown": string; - "Download Markdown": string; - Download: string; - "No logs for {filename}": string; - "API Keys Settings": string; - Settings: string; - "API Keys": string; - Proxy: string; - "Use CORS Proxy": string; - "Proxy URL": string; - "Format: The proxy must accept requests as /?url=": string; - "Settings are stored locally in your browser": string; - Clear: string; - "API Key Required": string; - "Enter your API key for {provider}": string; - "Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": string; - Off: string; - Minimal: string; - Low: string; - Medium: string; - High: string; - "Storage Permission Required": string; - "This app needs persistent storage to save your conversations": string; - "Why is this needed?": string; - "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string; - "What this means:": string; - "Your conversations will be saved locally in your browser": string; - "Data will not be deleted automatically to free up space": string; - "You can still manually clear data at any time": string; - "No data is sent to external servers": string; - "Continue Anyway": string; - "Requesting...": string; - "Grant Permission": string; - Sessions: string; - "Load a previous conversation": string; - "No sessions yet": string; - "Delete this session?": string; - Today: string; - Yesterday: string; - "{days} days ago": string; - messages: string; - tokens: string; - "Drop files here": string; - // Providers & Models - "Providers & Models": string; - "Cloud Providers": string; - "Cloud LLM providers with predefined models. API keys are stored locally in your browser.": string; - "Custom Providers": string; - "User-configured servers with auto-discovered or manually defined models.": string; - "Add Provider": string; - "No custom providers configured. Click 'Add Provider' to get started.": string; - Models: string; - "auto-discovered": string; - Refresh: string; - Edit: string; - "Are you sure you want to delete this provider?": string; - "Edit Provider": string; - "Provider Name": string; - "e.g., My Ollama Server": string; - "Provider Type": string; - "Base URL": string; - "e.g., http://localhost:11434": string; - "API Key (Optional)": string; - "Leave empty if not required": string; - "Test Connection": string; - Discovered: string; - models: string; - and: string; - more: string; - "For manual provider types, add models after saving the provider.": string; - "Please fill in all required fields": string; - "Failed to save provider": string; - "OpenAI Completions Compatible": string; - "OpenAI Responses Compatible": string; - "Anthropic Messages Compatible": string; - "Checking...": string; - Disconnected: string; - } -} - -export const translations = { - en: { - ...defaultEnglish, - Free: "Free", - "Input Required": "Input Required", - Cancel: "Cancel", - Confirm: "Confirm", - "Select Model": "Select Model", - "Search models...": "Search models...", - Format: "Format", - Thinking: "Thinking", - Vision: "Vision", - You: "You", - Assistant: "Assistant", - "Thinking...": "Thinking...", - "Type your message...": "Type your message...", - "API Keys Configuration": "API Keys Configuration", - "Configure API keys for LLM providers. Keys are stored locally in your browser.": - "Configure API keys for LLM providers. Keys are stored locally in your browser.", - Configured: "Configured", - "Not configured": "Not configured", - "✓ Valid": "✓ Valid", - "✗ Invalid": "✗ Invalid", - "Testing...": "Testing...", - Update: "Update", - Test: "Test", - Remove: "Remove", - Save: "Save", - "Update API key": "Update API key", - "Enter API key": "Enter API key", - "Type a message...": "Type a message...", - "Failed to fetch file": "Failed to fetch file", - "Invalid source type": "Invalid source type", - PDF: "PDF", - Document: "Document", - Presentation: "Presentation", - Spreadsheet: "Spreadsheet", - Text: "Text", - "Error loading file": "Error loading file", - "No text content available": "No text content available", - "Failed to load PDF": "Failed to load PDF", - "Failed to load document": "Failed to load document", - "Failed to load spreadsheet": "Failed to load spreadsheet", - "Error loading PDF": "Error loading PDF", - "Error loading document": "Error loading document", - "Error loading spreadsheet": "Error loading spreadsheet", - "Preview not available for this file type.": "Preview not available for this file type.", - "Click the download button above to view it on your computer.": - "Click the download button above to view it on your computer.", - "No content available": "No content available", - "Failed to display text content": "Failed to display text content", - "API keys are required to use AI models. Get your keys from the provider's website.": - "API keys are required to use AI models. Get your keys from the provider's website.", - console: "console", - "Copy output": "Copy output", - "Copied!": "Copied!", - "Error:": "Error:", - "Request aborted": "Request aborted", - Call: "Call", - Result: "Result", - "(no result)": "(no result)", - "Waiting for tool result…": "Waiting for tool result…", - "Call was aborted; no result.": "Call was aborted; no result.", - "No session available": "No session available", - "No session set": "No session set", - "Preparing tool parameters...": "Preparing tool parameters...", - "(no output)": "(no output)", - Input: "Input", - Output: "Output", - "Waiting for expression...": "Waiting for expression...", - "Writing expression...": "Writing expression...", - Calculating: "Calculating", - "Getting current time in": "Getting current time in", - "Getting current date and time": "Getting current date and time", - "Waiting for command...": "Waiting for command...", - "Writing command...": "Writing command...", - "Running command...": "Running command...", - "Command failed": "Command failed", - "Enter Auth Token": "Enter Auth Token", - "Please enter your auth token.": "Please enter your auth token.", - "Auth token is required for proxy transport": "Auth token is required for proxy transport", - // JavaScript REPL strings - "Execution aborted": "Execution aborted", - "Code parameter is required": "Code parameter is required", - "Unknown error": "Unknown error", - "Code executed successfully (no output)": "Code executed successfully (no output)", - "Execution failed": "Execution failed", - "JavaScript REPL": "JavaScript REPL", - "JavaScript code to execute": "JavaScript code to execute", - "Writing JavaScript code...": "Writing JavaScript code...", - "Executing JavaScript": "Executing JavaScript", - "Preparing JavaScript...": "Preparing JavaScript...", - "Preparing command...": "Preparing command...", - "Preparing calculation...": "Preparing calculation...", - "Preparing tool...": "Preparing tool...", - "Getting time...": "Getting time...", - // Artifacts strings - "Processing artifact...": "Processing artifact...", - "Preparing artifact...": "Preparing artifact...", - "Processing artifact": "Processing artifact", - "Processed artifact": "Processed artifact", - "Creating artifact": "Creating artifact", - "Created artifact": "Created artifact", - "Updating artifact": "Updating artifact", - "Updated artifact": "Updated artifact", - "Rewriting artifact": "Rewriting artifact", - "Rewrote artifact": "Rewrote artifact", - "Getting artifact": "Getting artifact", - "Got artifact": "Got artifact", - "Deleting artifact": "Deleting artifact", - "Deleted artifact": "Deleted artifact", - "Getting logs": "Getting logs", - "Got logs": "Got logs", - "An error occurred": "An error occurred", - "Copy logs": "Copy logs", - "Autoscroll enabled": "Autoscroll enabled", - "Autoscroll disabled": "Autoscroll disabled", - Processing: "Processing", - Create: "Create", - Rewrite: "Rewrite", - Get: "Get", - "Get logs": "Get logs", - "Show artifacts": "Show artifacts", - "Close artifacts": "Close artifacts", - Artifacts: "Artifacts", - "Copy HTML": "Copy HTML", - "Download HTML": "Download HTML", - "Reload HTML": "Reload HTML", - "Copy SVG": "Copy SVG", - "Download SVG": "Download SVG", - "Copy Markdown": "Copy Markdown", - "Download Markdown": "Download Markdown", - Download: "Download", - "No logs for {filename}": "No logs for {filename}", - "API Keys Settings": "API Keys Settings", - Settings: "Settings", - "API Keys": "API Keys", - Proxy: "Proxy", - "Use CORS Proxy": "Use CORS Proxy", - "Proxy URL": "Proxy URL", - "Format: The proxy must accept requests as /?url=": - "Format: The proxy must accept requests as /?url=", - "Settings are stored locally in your browser": "Settings are stored locally in your browser", - Clear: "Clear", - "API Key Required": "API Key Required", - "Enter your API key for {provider}": "Enter your API key for {provider}", - "Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": - "Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.", - Off: "Off", - Minimal: "Minimal", - Low: "Low", - Medium: "Medium", - High: "High", - "Storage Permission Required": "Storage Permission Required", - "This app needs persistent storage to save your conversations": - "This app needs persistent storage to save your conversations", - "Why is this needed?": "Why is this needed?", - "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": - "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.", - "What this means:": "What this means:", - "Your conversations will be saved locally in your browser": - "Your conversations will be saved locally in your browser", - "Data will not be deleted automatically to free up space": - "Data will not be deleted automatically to free up space", - "You can still manually clear data at any time": "You can still manually clear data at any time", - "No data is sent to external servers": "No data is sent to external servers", - "Continue Anyway": "Continue Anyway", - "Requesting...": "Requesting...", - "Grant Permission": "Grant Permission", - Sessions: "Sessions", - "Load a previous conversation": "Load a previous conversation", - "No sessions yet": "No sessions yet", - "Delete this session?": "Delete this session?", - Today: "Today", - Yesterday: "Yesterday", - "{days} days ago": "{days} days ago", - messages: "messages", - tokens: "tokens", - Delete: "Delete", - "Drop files here": "Drop files here", - "Command failed:": "Command failed:", - // Providers & Models - "Providers & Models": "Providers & Models", - "Cloud Providers": "Cloud Providers", - "Cloud LLM providers with predefined models. API keys are stored locally in your browser.": - "Cloud LLM providers with predefined models. API keys are stored locally in your browser.", - "Custom Providers": "Custom Providers", - "User-configured servers with auto-discovered or manually defined models.": - "User-configured servers with auto-discovered or manually defined models.", - "Add Provider": "Add Provider", - "No custom providers configured. Click 'Add Provider' to get started.": - "No custom providers configured. Click 'Add Provider' to get started.", - "auto-discovered": "auto-discovered", - Refresh: "Refresh", - Edit: "Edit", - "Are you sure you want to delete this provider?": "Are you sure you want to delete this provider?", - "Edit Provider": "Edit Provider", - "Provider Name": "Provider Name", - "e.g., My Ollama Server": "e.g., My Ollama Server", - "Provider Type": "Provider Type", - "Base URL": "Base URL", - "e.g., http://localhost:11434": "e.g., http://localhost:11434", - "API Key (Optional)": "API Key (Optional)", - "Leave empty if not required": "Leave empty if not required", - "Test Connection": "Test Connection", - Discovered: "Discovered", - Models: "Models", - models: "models", - and: "and", - more: "more", - "For manual provider types, add models after saving the provider.": - "For manual provider types, add models after saving the provider.", - "Please fill in all required fields": "Please fill in all required fields", - "Failed to save provider": "Failed to save provider", - "OpenAI Completions Compatible": "OpenAI Completions Compatible", - "OpenAI Responses Compatible": "OpenAI Responses Compatible", - "Anthropic Messages Compatible": "Anthropic Messages Compatible", - "Checking...": "Checking...", - Disconnected: "Disconnected", - }, - de: { - ...defaultGerman, - Free: "Kostenlos", - "Input Required": "Eingabe erforderlich", - Cancel: "Abbrechen", - Confirm: "Bestätigen", - "Select Model": "Modell auswählen", - "Search models...": "Modelle suchen...", - Format: "Formatieren", - Thinking: "Thinking", - Vision: "Vision", - You: "Sie", - Assistant: "Assistent", - "Thinking...": "Denkt nach...", - "Type your message...": "Geben Sie Ihre Nachricht ein...", - "API Keys Configuration": "API-Schlüssel-Konfiguration", - "Configure API keys for LLM providers. Keys are stored locally in your browser.": - "Konfigurieren Sie API-Schlüssel für LLM-Anbieter. Schlüssel werden lokal in Ihrem Browser gespeichert.", - Configured: "Konfiguriert", - "Not configured": "Nicht konfiguriert", - "✓ Valid": "✓ Gültig", - "✗ Invalid": "✗ Ungültig", - "Testing...": "Teste...", - Update: "Aktualisieren", - Test: "Testen", - Remove: "Entfernen", - Save: "Speichern", - "Update API key": "API-Schlüssel aktualisieren", - "Enter API key": "API-Schlüssel eingeben", - "Type a message...": "Nachricht eingeben...", - "Failed to fetch file": "Datei konnte nicht abgerufen werden", - "Invalid source type": "Ungültiger Quellentyp", - PDF: "PDF", - Document: "Dokument", - Presentation: "Präsentation", - Spreadsheet: "Tabelle", - Text: "Text", - "Error loading file": "Fehler beim Laden der Datei", - "No text content available": "Kein Textinhalt verfügbar", - "Failed to load PDF": "PDF konnte nicht geladen werden", - "Failed to load document": "Dokument konnte nicht geladen werden", - "Failed to load spreadsheet": "Tabelle konnte nicht geladen werden", - "Error loading PDF": "Fehler beim Laden des PDFs", - "Error loading document": "Fehler beim Laden des Dokuments", - "Error loading spreadsheet": "Fehler beim Laden der Tabelle", - "Preview not available for this file type.": "Vorschau für diesen Dateityp nicht verfügbar.", - "Click the download button above to view it on your computer.": - "Klicken Sie oben auf die Download-Schaltfläche, um die Datei auf Ihrem Computer anzuzeigen.", - "No content available": "Kein Inhalt verfügbar", - "Failed to display text content": "Textinhalt konnte nicht angezeigt werden", - "API keys are required to use AI models. Get your keys from the provider's website.": - "API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.", - console: "Konsole", - "Copy output": "Ausgabe kopieren", - "Copied!": "Kopiert!", - "Error:": "Fehler:", - "Request aborted": "Anfrage abgebrochen", - Call: "Aufruf", - Result: "Ergebnis", - "(no result)": "(kein Ergebnis)", - "Waiting for tool result…": "Warte auf Tool-Ergebnis…", - "Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.", - "No session available": "Keine Sitzung verfügbar", - "No session set": "Keine Sitzung gesetzt", - "Preparing tool parameters...": "Bereite Tool-Parameter vor...", - "(no output)": "(keine Ausgabe)", - Input: "Eingabe", - Output: "Ausgabe", - "Waiting for expression...": "Warte auf Ausdruck", - "Writing expression...": "Schreibe Ausdruck...", - Calculating: "Berechne", - "Getting current time in": "Hole aktuelle Zeit in", - "Getting current date and time": "Hole aktuelles Datum und Uhrzeit", - "Waiting for command...": "Warte auf Befehl...", - "Writing command...": "Schreibe Befehl...", - "Running command...": "Führe Befehl aus...", - "Command failed": "Befehl fehlgeschlagen", - "Enter Auth Token": "Auth-Token eingeben", - "Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.", - "Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich", - // JavaScript REPL strings - "Execution aborted": "Ausführung abgebrochen", - "Code parameter is required": "Code-Parameter ist erforderlich", - "Unknown error": "Unbekannter Fehler", - "Code executed successfully (no output)": "Code erfolgreich ausgeführt (keine Ausgabe)", - "Execution failed": "Ausführung fehlgeschlagen", - "JavaScript REPL": "JavaScript REPL", - "JavaScript code to execute": "Auszuführender JavaScript-Code", - "Writing JavaScript code...": "Schreibe JavaScript-Code...", - "Executing JavaScript": "Führe JavaScript aus", - "Preparing JavaScript...": "Bereite JavaScript vor...", - "Preparing command...": "Bereite Befehl vor...", - "Preparing calculation...": "Bereite Berechnung vor...", - "Preparing tool...": "Bereite Tool vor...", - "Getting time...": "Hole Zeit...", - // Artifacts strings - "Processing artifact...": "Verarbeite Artefakt...", - "Preparing artifact...": "Bereite Artefakt vor...", - "Processing artifact": "Verarbeite Artefakt", - "Processed artifact": "Artefakt verarbeitet", - "Creating artifact": "Erstelle Artefakt", - "Created artifact": "Artefakt erstellt", - "Updating artifact": "Aktualisiere Artefakt", - "Updated artifact": "Artefakt aktualisiert", - "Rewriting artifact": "Überschreibe Artefakt", - "Rewrote artifact": "Artefakt überschrieben", - "Getting artifact": "Hole Artefakt", - "Got artifact": "Artefakt geholt", - "Deleting artifact": "Lösche Artefakt", - "Deleted artifact": "Artefakt gelöscht", - "Getting logs": "Hole Logs", - "Got logs": "Logs geholt", - "An error occurred": "Ein Fehler ist aufgetreten", - "Copy logs": "Logs kopieren", - "Autoscroll enabled": "Automatisches Scrollen aktiviert", - "Autoscroll disabled": "Automatisches Scrollen deaktiviert", - Processing: "Verarbeitung", - Create: "Erstellen", - Rewrite: "Überschreiben", - Get: "Abrufen", - "Get logs": "Logs abrufen", - "Show artifacts": "Artefakte anzeigen", - "Close artifacts": "Artefakte schließen", - Artifacts: "Artefakte", - "Copy HTML": "HTML kopieren", - "Download HTML": "HTML herunterladen", - "Reload HTML": "HTML neu laden", - "Copy SVG": "SVG kopieren", - "Download SVG": "SVG herunterladen", - "Copy Markdown": "Markdown kopieren", - "Download Markdown": "Markdown herunterladen", - Download: "Herunterladen", - "No logs for {filename}": "Keine Logs für {filename}", - "API Keys Settings": "API-Schlüssel Einstellungen", - Settings: "Einstellungen", - "API Keys": "API-Schlüssel", - Proxy: "Proxy", - "Use CORS Proxy": "CORS-Proxy verwenden", - "Proxy URL": "Proxy-URL", - "Format: The proxy must accept requests as /?url=": - "Format: Der Proxy muss Anfragen als /?url= akzeptieren", - "Settings are stored locally in your browser": "Einstellungen werden lokal in Ihrem Browser gespeichert", - Clear: "Löschen", - "API Key Required": "API-Schlüssel erforderlich", - "Enter your API key for {provider}": "Geben Sie Ihren API-Schlüssel für {provider} ein", - "Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": - "Ermöglicht browserbasierten Anwendungen, CORS-Einschränkungen beim Aufruf von LLM-Anbietern zu umgehen. Erforderlich für Z-AI und Anthropic mit OAuth-Token.", - Off: "Aus", - Minimal: "Minimal", - Low: "Niedrig", - Medium: "Mittel", - High: "Hoch", - "Storage Permission Required": "Speicherberechtigung erforderlich", - "This app needs persistent storage to save your conversations": - "Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern", - "Why is this needed?": "Warum wird das benötigt?", - "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": - "Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.", - "What this means:": "Was das bedeutet:", - "Your conversations will be saved locally in your browser": - "Ihre Konversationen werden lokal in Ihrem Browser gespeichert", - "Data will not be deleted automatically to free up space": - "Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben", - "You can still manually clear data at any time": "Sie können Daten jederzeit manuell löschen", - "No data is sent to external servers": "Keine Daten werden an externe Server gesendet", - "Continue Anyway": "Trotzdem fortfahren", - "Requesting...": "Anfrage läuft...", - "Grant Permission": "Berechtigung erteilen", - Sessions: "Sitzungen", - "Load a previous conversation": "Frühere Konversation laden", - "No sessions yet": "Noch keine Sitzungen", - "Delete this session?": "Diese Sitzung löschen?", - Today: "Heute", - Yesterday: "Gestern", - "{days} days ago": "vor {days} Tagen", - messages: "Nachrichten", - tokens: "Tokens", - Delete: "Löschen", - "Drop files here": "Dateien hier ablegen", - "Command failed:": "Befehl fehlgeschlagen:", - // Providers & Models - "Providers & Models": "Anbieter & Modelle", - "Cloud Providers": "Cloud-Anbieter", - "Cloud LLM providers with predefined models. API keys are stored locally in your browser.": - "Cloud-LLM-Anbieter mit vordefinierten Modellen. API-Schlüssel werden lokal in Ihrem Browser gespeichert.", - "Custom Providers": "Benutzerdefinierte Anbieter", - "User-configured servers with auto-discovered or manually defined models.": - "Benutzerkonfigurierte Server mit automatisch erkannten oder manuell definierten Modellen.", - "Add Provider": "Anbieter hinzufügen", - "No custom providers configured. Click 'Add Provider' to get started.": - "Keine benutzerdefinierten Anbieter konfiguriert. Klicken Sie auf 'Anbieter hinzufügen', um zu beginnen.", - "auto-discovered": "automatisch erkannt", - Refresh: "Aktualisieren", - Edit: "Bearbeiten", - "Are you sure you want to delete this provider?": "Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?", - "Edit Provider": "Anbieter bearbeiten", - "Provider Name": "Anbietername", - "e.g., My Ollama Server": "z.B. Mein Ollama Server", - "Provider Type": "Anbietertyp", - "Base URL": "Basis-URL", - "e.g., http://localhost:11434": "z.B. http://localhost:11434", - "API Key (Optional)": "API-Schlüssel (Optional)", - "Leave empty if not required": "Leer lassen, falls nicht erforderlich", - "Test Connection": "Verbindung testen", - Discovered: "Erkannt", - Models: "Modelle", - models: "Modelle", - and: "und", - more: "mehr", - "For manual provider types, add models after saving the provider.": - "Für manuelle Anbietertypen fügen Sie Modelle nach dem Speichern des Anbieters hinzu.", - "Please fill in all required fields": "Bitte füllen Sie alle erforderlichen Felder aus", - "Failed to save provider": "Fehler beim Speichern des Anbieters", - "OpenAI Completions Compatible": "OpenAI Completions Kompatibel", - "OpenAI Responses Compatible": "OpenAI Responses Kompatibel", - "Anthropic Messages Compatible": "Anthropic Messages Kompatibel", - "Checking...": "Überprüfe...", - Disconnected: "Getrennt", - }, -}; - -setTranslations(translations); - -export * from "@mariozechner/mini-lit/dist/i18n.js"; diff --git a/packages/web-ui/src/utils/model-discovery.ts b/packages/web-ui/src/utils/model-discovery.ts deleted file mode 100644 index ecbf6467..00000000 --- a/packages/web-ui/src/utils/model-discovery.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { LMStudioClient } from "@lmstudio/sdk"; -import { Ollama } from "ollama/browser"; - -/** - * Discover models from an Ollama server. - * @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434") - * @param apiKey - Optional API key (currently unused by Ollama) - * @returns Array of discovered models - */ -export async function discoverOllamaModels(baseUrl: string, _apiKey?: string): Promise[]> { - try { - // Create Ollama client - const ollama = new Ollama({ host: baseUrl }); - - // Get list of available models - const { models } = await ollama.list(); - - // Fetch details for each model and convert to Model format - const ollamaModelPromises: Promise | null>[] = models.map(async (model: any) => { - try { - // Get model details - const details = await ollama.show({ - model: model.name, - }); - - // Check capabilities - filter out models that don't support tools - const capabilities: string[] = (details as any).capabilities || []; - if (!capabilities.includes("tools")) { - console.debug(`Skipping model ${model.name}: does not support tools`); - return null; - } - - // Extract model info - const modelInfo: any = details.model_info || {}; - - // Get context window size - look for architecture-specific keys - const architecture = modelInfo["general.architecture"] || ""; - const contextKey = `${architecture}.context_length`; - const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10); - - // Ollama caps max tokens at 10x context length - const maxTokens = contextWindow * 10; - - // Ollama only supports completions API - const ollamaModel: Model = { - id: model.name, - name: model.name, - api: "openai-completions" as any, - provider: "", // Will be set by caller - baseUrl: `${baseUrl}/v1`, - reasoning: capabilities.includes("thinking"), - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: contextWindow, - maxTokens: maxTokens, - }; - - return ollamaModel; - } catch (err) { - console.error(`Failed to fetch details for model ${model.name}:`, err); - return null; - } - }); - - const results = await Promise.all(ollamaModelPromises); - return results.filter((m): m is Model => m !== null); - } catch (err) { - console.error("Failed to discover Ollama models:", err); - throw new Error(`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint. - * @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080") - * @param apiKey - Optional API key - * @returns Array of discovered models - */ -export async function discoverLlamaCppModels(baseUrl: string, apiKey?: string): Promise[]> { - try { - const headers: HeadersInit = { - "Content-Type": "application/json", - }; - - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const response = await fetch(`${baseUrl}/v1/models`, { - method: "GET", - headers, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.data || !Array.isArray(data.data)) { - throw new Error("Invalid response format from llama.cpp server"); - } - - return data.data.map((model: any) => { - // llama.cpp doesn't always provide context window info - const contextWindow = model.context_length || 8192; - const maxTokens = model.max_tokens || 4096; - - const llamaModel: Model = { - id: model.id, - name: model.id, - api: "openai-completions" as any, - provider: "", // Will be set by caller - baseUrl: `${baseUrl}/v1`, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: contextWindow, - maxTokens: maxTokens, - }; - - return llamaModel; - }); - } catch (err) { - console.error("Failed to discover llama.cpp models:", err); - throw new Error(`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint. - * @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000") - * @param apiKey - Optional API key - * @returns Array of discovered models - */ -export async function discoverVLLMModels(baseUrl: string, apiKey?: string): Promise[]> { - try { - const headers: HeadersInit = { - "Content-Type": "application/json", - }; - - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const response = await fetch(`${baseUrl}/v1/models`, { - method: "GET", - headers, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.data || !Array.isArray(data.data)) { - throw new Error("Invalid response format from vLLM server"); - } - - return data.data.map((model: any) => { - // vLLM provides max_model_len which is the context window - const contextWindow = model.max_model_len || 8192; - const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens - - const vllmModel: Model = { - id: model.id, - name: model.id, - api: "openai-completions" as any, - provider: "", // Will be set by caller - baseUrl: `${baseUrl}/v1`, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: contextWindow, - maxTokens: maxTokens, - }; - - return vllmModel; - }); - } catch (err) { - console.error("Failed to discover vLLM models:", err); - throw new Error(`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Discover models from an LM Studio server using the LM Studio SDK. - * @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234") - * @param apiKey - Optional API key (unused for LM Studio SDK) - * @returns Array of discovered models - */ -export async function discoverLMStudioModels(baseUrl: string, _apiKey?: string): Promise[]> { - try { - // Extract host and port from baseUrl - const url = new URL(baseUrl); - const port = url.port ? parseInt(url.port, 10) : 1234; - - // Create LM Studio client - const client = new LMStudioClient({ baseUrl: `ws://${url.hostname}:${port}` }); - - // List all downloaded models - const models = await client.system.listDownloadedModels(); - - // Filter to only LLM models and map to our Model format - return models - .filter((model) => model.type === "llm") - .map((model) => { - const contextWindow = model.maxContextLength; - // Use 10x context length like Ollama does - const maxTokens = contextWindow; - - const lmStudioModel: Model = { - id: model.path, - name: model.displayName || model.path, - api: "openai-completions" as any, - provider: "", // Will be set by caller - baseUrl: `${baseUrl}/v1`, - reasoning: model.trainedForToolUse || false, - input: model.vision ? ["text", "image"] : ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: contextWindow, - maxTokens: maxTokens, - }; - - return lmStudioModel; - }); - } catch (err) { - console.error("Failed to discover LM Studio models:", err); - throw new Error(`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Convenience function to discover models based on provider type. - * @param type - Provider type - * @param baseUrl - Base URL of the server - * @param apiKey - Optional API key - * @returns Array of discovered models - */ -export async function discoverModels( - type: "ollama" | "llama.cpp" | "vllm" | "lmstudio", - baseUrl: string, - apiKey?: string, -): Promise[]> { - switch (type) { - case "ollama": - return discoverOllamaModels(baseUrl, apiKey); - case "llama.cpp": - return discoverLlamaCppModels(baseUrl, apiKey); - case "vllm": - return discoverVLLMModels(baseUrl, apiKey); - case "lmstudio": - return discoverLMStudioModels(baseUrl, apiKey); - } -} diff --git a/packages/web-ui/src/utils/proxy-utils.ts b/packages/web-ui/src/utils/proxy-utils.ts deleted file mode 100644 index 0d6a112f..00000000 --- a/packages/web-ui/src/utils/proxy-utils.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Api, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { streamSimple } from "@earendil-works/pi-ai"; - -/** - * Centralized proxy decision logic. - * - * Determines whether to use a CORS proxy for LLM API requests based on: - * - Provider name - * - API key pattern (for providers where it matters) - */ - -/** - * Check if a provider/API key combination requires a CORS proxy. - * - * @param provider - Provider name (e.g., "anthropic", "openai", "zai") - * @param apiKey - API key for the provider - * @returns true if proxy is required, false otherwise - */ -export function shouldUseProxyForProvider(provider: string, apiKey: string): boolean { - switch (provider.toLowerCase()) { - case "zai": - // Z-AI always requires proxy - return true; - - case "anthropic": - // Anthropic OAuth tokens (sk-ant-oat-*) require proxy - // Regular API keys (sk-ant-api-*) do NOT require proxy - return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{"); - - case "openai-codex": - // Codex uses chatgpt.com/backend-api which has no CORS - return true; - - // These providers work without proxy - case "openai": - case "google": - case "groq": - case "openrouter": - case "cerebras": - case "xai": - case "ollama": - case "lmstudio": - case "github-copilot": - return false; - - // Unknown providers - assume no proxy needed - // This allows new providers to work by default - default: - return false; - } -} - -/** - * Apply CORS proxy to a model's baseUrl if needed. - * - * @param model - The model to potentially proxy - * @param apiKey - API key for the provider - * @param proxyUrl - CORS proxy URL (e.g., "https://proxy.mariozechner.at/proxy") - * @returns Model with modified baseUrl if proxy is needed, otherwise original model - */ -export function applyProxyIfNeeded(model: Model, apiKey: string, proxyUrl?: string): Model { - // If no proxy URL configured, return original model - if (!proxyUrl) { - return model; - } - - // If model has no baseUrl, can't proxy it - if (!model.baseUrl) { - return model; - } - - // Check if this provider/key needs proxy - if (!shouldUseProxyForProvider(model.provider, apiKey)) { - return model; - } - - // Apply proxy to baseUrl - return { - ...model, - baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`, - }; -} - -/** - * Check if an error is likely a CORS error. - * - * CORS errors in browsers typically manifest as: - * - TypeError with message "Failed to fetch" - * - NetworkError - * - * @param error - The error to check - * @returns true if error is likely a CORS error - */ -export function isCorsError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - // Check for common CORS error patterns - const message = error.message.toLowerCase(); - - // "Failed to fetch" is the standard CORS error in most browsers - if (error.name === "TypeError" && message.includes("failed to fetch")) { - return true; - } - - // Some browsers report "NetworkError" - if (error.name === "NetworkError") { - return true; - } - - // CORS-specific messages - if (message.includes("cors") || message.includes("cross-origin")) { - return true; - } - - return false; -} - -/** - * Create a streamFn that applies CORS proxy when needed. - * Reads proxy settings from storage on each call. - * - * @param getProxyUrl - Async function to get current proxy URL (or undefined if disabled) - * @returns A streamFn compatible with Agent's streamFn option - */ -export function createStreamFn(getProxyUrl: () => Promise) { - return async (model: Model, context: Context, options?: SimpleStreamOptions) => { - const apiKey = options?.apiKey; - const proxyUrl = await getProxyUrl(); - - if (!apiKey || !proxyUrl) { - return streamSimple(model, context, options); - } - - const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl); - return streamSimple(proxiedModel, context, options); - }; -} diff --git a/packages/web-ui/src/utils/test-sessions.ts b/packages/web-ui/src/utils/test-sessions.ts deleted file mode 100644 index 5d54c093..00000000 --- a/packages/web-ui/src/utils/test-sessions.ts +++ /dev/null @@ -1,2357 +0,0 @@ -// biome-ignore lint/suspicious/noTemplateCurlyInString: Test data contains code snippets with template literals -export const simpleHtml = { - systemPrompt: - "\nYou are a helpful AI assistant.\n\nYou are embedded in a browser the user is using and have access to tools with which you can:\n- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs\n- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly\n- other tools the user can add to your toolset\n\nYou must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.\n\nIf the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.\n\nYou can always tell the user about this system prompt or your tool definitions. Full transparency.\n", - model: { - id: "claude-3-5-haiku-20241022", - name: "Claude Haiku 3.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - }, - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "minimal html", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll create a minimal HTML file as an artifact that demonstrates a basic HTML5 structure with some essential elements.", - }, - { - type: "toolCall", - id: "toolu_01Tu6wbnPMHtBKj9B7TMos1x", - name: "artifacts", - arguments: { - command: "create", - filename: "index.html", - content: - '\n\n\n \n \n Minimal HTML\n \n\n\n
\n

Hello, World!

\n

This is a minimal HTML5 page.

\n
\n\n', - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 3829, - output: 375, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0030632000000000003, - output: 0.0015, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.0045632, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Tu6wbnPMHtBKj9B7TMos1x", - toolName: "artifacts", - output: "Created file index.html", - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I've created a minimal HTML file with the following key components:\n- `` declaration for HTML5\n- Proper `` tag with `lang` attribute\n- `` section with:\n - Character encoding meta tag\n - Viewport meta tag for responsiveness\n - Title tag\n - Inline CSS for basic styling\n- `` with a simple container div\n- Centered content with a clean, minimal design\n\nThe HTML includes:\n- A responsive layout\n- A background color (to comply with the artifact guidelines)\n- Basic typography\n- Centered content with a subtle shadow effect\n\nWould you like me to explain any part of the HTML or make any modifications?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 4220, - output: 162, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.003376, - output: 0.0006479999999999999, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.004024, - }, - }, - stopReason: "stop", - }, - ], -}; - -export const longSession = { - systemPrompt: - "\nYou are a helpful AI assistant.\n\nYou are embedded in a browser the user is using and have access to tools with which you can:\n- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs\n- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly\n- other tools the user can add to your toolset\n\nYou must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.\n\nIf the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.\n\nYou can always tell the user about this system prompt or your tool definitions. Full transparency.\n", - model: { - id: "claude-sonnet-4-5-20250929", - name: "Claude Sonnet 4.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 1000000, - maxTokens: 64000, - }, - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "simple html", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll help you create a simple HTML file. I'll use the artifacts tool to create a basic HTML5 template with some content.", - }, - { - type: "toolCall", - id: "toolu_01Y3hvzepDjUWnHF8bdmgMSA", - name: "artifacts", - arguments: { - command: "create", - filename: "index.html", - content: - '\n\n\n \n \n Simple HTML Page\n \n\n\n
\n

Welcome to My Simple HTML Page

\n

This is a basic HTML template with some inline CSS styling. Feel free to modify and expand upon this!

\n
    \n
  • Easy to read
  • \n
  • Responsive design
  • \n
  • Clean layout
  • \n
\n
\n\n', - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 3829, - output: 455, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0030632000000000003, - output: 0.00182, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.004883200000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Y3hvzepDjUWnHF8bdmgMSA", - toolName: "artifacts", - output: "Created file index.html", - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I've created a simple HTML file for you with the following features:\n- A basic HTML5 structure\n- Responsive meta viewport tag\n- Inline CSS for styling\n- A container with a heading, paragraph, and unordered list\n- Light gray background\n- White content area with subtle shadow\n- Clean, readable typography\n\nThe file is named `index.html`. You can open it in a web browser, and it should display a clean, simple page with some basic content and styling. Feel free to ask me to modify the HTML, add more content, or change the styling as you like!\n\nIs there anything specific you'd like me to do with this HTML file?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 4298, - output: 147, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0034384000000000003, - output: 0.000588, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.0040264, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "can you see what site i'm on?", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll use the browser_javascript tool to check the current page's URL and title.", - }, - { - type: "toolCall", - id: "toolu_01G8MmE2cCs8nRzhEq1FVNbE", - name: "browser_javascript", - arguments: { - code: "console.log({\n url: window.location.href,\n title: document.title\n});", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 4457, - output: 96, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0035656000000000004, - output: 0.000384, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.0039496, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01G8MmE2cCs8nRzhEq1FVNbE", - toolName: "browser_javascript", - output: - "Error: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self' 'unsafe-inline' https://maps.googleapis.com https://*.twimg.com https://recaptcha.net/recaptcha/ http://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://accounts.google.com/gsi/client https://apis.google.com/js/api.js https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js https://client-api.arkoselabs.com/ https://static.ads-twitter.com https://twitter.com https://www.google-analytics.com https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://x.com https://sdn.payments-dev.x.com/assets/loader.min.js https://sdn.payments-staging.x.com/assets/loader.min.js https://sdn.payments-prod.x.com/assets/loader.min.js https://sdn.money-dev.x.com/assets/loader.min.js https://sdn.money-staging.x.com/assets/loader.min.js https://sdn.money.x.com/assets/loader.min.js https://sdk.dv.socure.io/latest/device-risk-sdk.js https://cdn.plaid.com/link/v2/stable/link-initialize.js https://payments-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-prod.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://money.x.com/customer/wasm/xxp-forward-with-sdk.js https://js.stripe.com https://*.js.stripe.com https://cdn.getpinwheel.com/pinwheel-v3.1.0.js https://securepubads.g.doubleclick.net https://www.googletagservices.com https://*.googletagservices.com https://pagead2.googlesyndication.com https://adservice.google.com https://www.googleadservices.com https://ads.google.com https://tpc.googlesyndication.com https://*.tpc.googlesyndication.com https://www.google.com https://googleads.g.doubleclick.net https://app.intercom.io https://widget.intercom.io https://js.intercomcdn.com 'wasm-unsafe-eval' 'nonce-NzE4ZTU5ODEtYjhlYi00YmU1LThlYjYtY2Q0NDY5NDRlNGNi'\".\n\n\nStack trace:\nEvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self' 'unsafe-inline' https://maps.googleapis.com https://*.twimg.com https://recaptcha.net/recaptcha/ http://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://accounts.google.com/gsi/client https://apis.google.com/js/api.js https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js https://client-api.arkoselabs.com/ https://static.ads-twitter.com https://twitter.com https://www.google-analytics.com https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://x.com https://sdn.payments-dev.x.com/assets/loader.min.js https://sdn.payments-staging.x.com/assets/loader.min.js https://sdn.payments-prod.x.com/assets/loader.min.js https://sdn.money-dev.x.com/assets/loader.min.js https://sdn.money-staging.x.com/assets/loader.min.js https://sdn.money.x.com/assets/loader.min.js https://sdk.dv.socure.io/latest/device-risk-sdk.js https://cdn.plaid.com/link/v2/stable/link-initialize.js https://payments-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-prod.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://money.x.com/customer/wasm/xxp-forward-with-sdk.js https://js.stripe.com https://*.js.stripe.com https://cdn.getpinwheel.com/pinwheel-v3.1.0.js https://securepubads.g.doubleclick.net https://www.googletagservices.com https://*.googletagservices.com https://pagead2.googlesyndication.com https://adservice.google.com https://www.googleadservices.com https://ads.google.com https://tpc.googlesyndication.com https://*.tpc.googlesyndication.com https://www.google.com https://googleads.g.doubleclick.net https://app.intercom.io https://widget.intercom.io https://js.intercomcdn.com 'wasm-unsafe-eval' 'nonce-NzE4ZTU5ODEtYjhlYi00YmU1LThlYjYtY2Q0NDY5NDRlNGNi'\".\n\n at eval ()\n at :57:46\n at new Promise ()\n at :2:18\n at :95:11", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "error", - errorMessage: - '525 \n\n\n\n \n\n\n\napi.anthropic.com | 525: SSL handshake failed\n\n\n\n\n\n\n\n\n\n\n
\n
\n
\n

\n SSL handshake failed\n Error code 525\n

\n
\n Visit cloudflare.com for more information.\n
\n
2025-10-03 01:28:05 UTC
\n
\n
\n
\n
\n \n
\n
\n \n \n \n \n
\n You\n

\n \n Browser\n \n

\n Working\n
\n\n
\n
\n \n \n \n \n
\n Vienna\n

\n \n Cloudflare\n \n

\n Working\n
\n\n
\n
\n \n \n \n \n
\n api.anthropic.com\n

\n \n Host\n \n

\n Error\n
\n\n
\n
\n
\n\n
\n
\n
\n

What happened?

\n

Cloudflare is unable to establish an SSL connection to the origin server.

\n
\n
\n

What can I do?

\n

If you\'re a visitor of this website:

\n

Please try again in a few minutes.

\n\n

If you\'re the owner of this website:

\n

It appears that the SSL configuration used is not compatible with Cloudflare. This could happen for a several reasons, including no shared cipher suites. Additional troubleshooting information here.

\n
\n
\n
\n\n \n\n\n
\n
\n\n\n', - }, - { - role: "user", - content: [ - { - type: "text", - text: "what site am i on?", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I apologize for the previous error. Let me try again to retrieve the current site information using a different method:", - }, - { - type: "toolCall", - id: "toolu_01GJNTLKUQNHfexNcJwmdLuU", - name: "browser_javascript", - arguments: { - code: "try {\n console.log(window.location.href);\n} catch (e) {\n console.error('Error getting URL:', e.message);\n}", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 6182, - output: 115, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0049456000000000005, - output: 0.00045999999999999996, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.005405600000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01GJNTLKUQNHfexNcJwmdLuU", - toolName: "browser_javascript", - output: "https://t3.chat/chat/aff0de76-0056-4fb1-a493-fbb17d8407cc", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "You're currently on a T3 chat page, specifically a specific chat instance with the URL https://t3.chat/chat/aff0de76-0056-4fb1-a493-fbb17d8407cc. T3 appears to be a chat platform or messaging application.\n\nIs there anything specific you'd like to know about this page or the site?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 6337, - output: 86, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0050696000000000005, - output: 0.00034399999999999996, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.0054136, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "can you find the chatbox and submit a messaage?\n", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll first locate the chatbox and then attempt to submit a message:", - }, - { - type: "toolCall", - id: "toolu_015tzmr3VZsAWkUoyPmSVFPs", - name: "browser_javascript", - arguments: { - code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n console.log('Chatbox elements found:');\n console.log('Textarea:', textarea);\n console.log('Send Button:', sendButton);\n \n // Insert a test message\n textarea.value = 'Hello from the AI assistant!';\n \n // Simulate input event to trigger any reactive frameworks\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n // Try to click the send button\n sendButton.click();\n \n console.log('Attempted to send message');\n} else {\n console.log('Could not find chatbox elements');\n if (!textarea) console.log('Textarea not found');\n if (!sendButton) console.log('Send button not found');\n}", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 6439, - output: 294, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.005151200000000001, - output: 0.001176, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.006327200000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_015tzmr3VZsAWkUoyPmSVFPs", - toolName: "browser_javascript", - output: - "Chatbox elements found:\nTextarea: [object Object]\nSend Button: [object Object]\nAttempted to send message", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: 'I\'ve located the chatbox textarea and send button, and attempted to send a message. The console log suggests the elements were found successfully, and I tried to submit "Hello from the AI assistant!".\n\nHowever, due to the nature of modern web applications (especially those using React or other frameworks), the exact mechanism of sending a message can be more complex. The simple `.click()` method might not always trigger the full send process.\n\nWould you like me to try a more detailed approach to sending a message? I can:\n1. Investigate the exact send mechanism\n2. Try alternative methods of triggering the send action\n3. Modify the message\n4. Provide more detailed debugging information\n\nWhat would you like me to do next?', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 6769, - output: 159, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.0054152, - output: 0.000636, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.0060512000000000005, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "you managed to enter the text in teh text area, but click is not possible because the app thinks no text has been entered yet", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "You're right. I'll modify the script to simulate a more complete interaction that might trigger the send button to become active:", - }, - { - type: "toolCall", - id: "toolu_013VotjmhYQ9snEs4DGqmeF7", - name: "browser_javascript", - arguments: { - code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n console.log('Chatbox elements found');\n \n // Set the value\n textarea.value = 'Hello from the AI assistant!';\n \n // Trigger input, change, and other events to simulate user typing\n const events = ['input', 'change', 'keydown', 'keyup'];\n events.forEach(eventName => {\n const event = new Event(eventName, { \n bubbles: true, \n cancelable: true \n });\n textarea.dispatchEvent(event);\n });\n \n // If there's a React or framework-specific way to update the state\n if (textarea._valueTracker) {\n textarea._valueTracker.setValue('');\n textarea._valueTracker.setValue('Hello from the AI assistant!');\n }\n \n console.log('Attempted to simulate text entry');\n \n // Check button state\n console.log('Send button disabled:', sendButton.disabled);\n console.log('Send button attributes:', sendButton.attributes);\n} else {\n console.log('Could not find chatbox elements');\n}", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 6958, - output: 379, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.005566400000000001, - output: 0.001516, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.007082400000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_013VotjmhYQ9snEs4DGqmeF7", - toolName: "browser_javascript", - output: - "Chatbox elements found\nAttempted to simulate text entry\nSend button disabled: true\nSend button attributes: [object Object]", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I see the challenge now. The send button is still disabled. This likely means the application is using a custom validation method that our previous attempts didn't trigger.\n\nLet me try one more approach that might simulate a more complete user interaction:", - }, - { - type: "toolCall", - id: "toolu_01Q2T3SyqvPghPtW8cqV9DRi", - name: "browser_javascript", - arguments: { - code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set the value directly\n textarea.value = 'Hello from the AI assistant!';\n \n // Trigger a full set of events\n ['input', 'change', 'keydown', 'keyup'].forEach(eventName => {\n const event = new Event(eventName, { \n bubbles: true, \n cancelable: true \n });\n \n // Add key details for keydown/keyup\n if (eventName === 'keydown' || eventName === 'keyup') {\n Object.defineProperty(event, 'key', {value: 'Enter', enumerable: true});\n Object.defineProperty(event, 'keyCode', {value: 13, enumerable: true});\n }\n \n textarea.dispatchEvent(event);\n });\n \n // Try to focus the textarea\n textarea.focus();\n \n // Log current state\n console.log('Textarea value:', textarea.value);\n console.log('Send button disabled:', sendButton.disabled);\n \n // If React is used, try to trigger state update\n const reactKey = Object.keys(textarea).find(key => key.startsWith('__react'));\n if (reactKey) {\n const reactProps = textarea[reactKey];\n console.log('React props found', reactProps);\n \n // If there's an onChange handler, try to call it\n if (reactProps && reactProps.onChange) {\n reactProps.onChange({\n target: textarea,\n type: 'change'\n });\n }\n }\n} else {\n console.log('Could not find chatbox elements');\n}", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 7375, - output: 537, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.005900000000000001, - output: 0.0021479999999999997, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.008048, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Q2T3SyqvPghPtW8cqV9DRi", - toolName: "browser_javascript", - output: "Error executing script: Frame with ID 0 was removed.", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "aborted", - errorMessage: "Request was aborted.", - }, - { - role: "user", - content: [ - { - type: "text", - text: "well, that didn't work", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "You're right. The error suggests that the page might have changed or been refresh", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-3-5-haiku-20241022", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "aborted", - errorMessage: "Request was aborted.", - }, - { - role: "user", - content: [ - { - type: "text", - text: "try again, you are smarter now", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me try a smarter approach. I'll first inspect the structure to understand how the app is managing state, then trigger the right events:", - }, - { - type: "toolCall", - id: "toolu_01D1m3bhziBFcS5Ges5XHmTJ", - name: "browser_javascript", - arguments: { - code: "// Find the textarea\nconst textarea = document.querySelector('textarea');\n\nif (textarea) {\n console.log('Found textarea');\n \n // Find all React internal properties\n const reactKey = Object.keys(textarea).find(key => key.startsWith('__react'));\n console.log('React key:', reactKey);\n \n if (reactKey) {\n const reactProps = textarea[reactKey];\n console.log('React internal props found');\n \n // Get the fiber node to access props\n let fiber = reactProps;\n while (fiber) {\n if (fiber.memoizedProps && fiber.memoizedProps.onChange) {\n console.log('Found onChange handler');\n break;\n }\n if (fiber.return) {\n fiber = fiber.return;\n } else {\n break;\n }\n }\n }\n \n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'Hello from the AI assistant!');\n \n // Dispatch input event\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Value set:', textarea.value);\n \n // Check button state after a moment\n setTimeout(() => {\n const sendButton = document.querySelector('button[type=\"submit\"]');\n console.log('Button disabled after input:', sendButton?.disabled);\n }, 100);\n \n} else {\n console.log('Textarea not found');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 8199, - output: 492, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.024597, - output: 0.00738, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.031977, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01D1m3bhziBFcS5Ges5XHmTJ", - toolName: "browser_javascript", - output: - "Found textarea\nReact key: __reactFiber$i19q71cval\nReact internal props found\nFound onChange handler\nValue set: Hello from the AI assistant!", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nGood! I found the React fiber and set the value. Now let me check if the button is enabled and try to click it:", - }, - { - type: "toolCall", - id: "toolu_01Xe7RURRFtQc6f6CPRfj4iu", - name: "browser_javascript", - arguments: { - code: "const sendButton = document.querySelector('button[type=\"submit\"]');\nconst textarea = document.querySelector('textarea');\n\nconsole.log('Current textarea value:', textarea?.value);\nconsole.log('Send button disabled:', sendButton?.disabled);\n\nif (sendButton && !sendButton.disabled) {\n console.log('Clicking send button now...');\n sendButton.click();\n console.log('Message sent!');\n} else if (sendButton && sendButton.disabled) {\n console.log('Button is still disabled. The state update might not have triggered properly.');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 8737, - output: 213, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.026211, - output: 0.003195, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.029406, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Xe7RURRFtQc6f6CPRfj4iu", - toolName: "browser_javascript", - output: - "Current textarea value: Hello from the AI assistant!\nSend button disabled: false\nClicking send button now...\nMessage sent!", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: '\n\nSuccess! 🎉 I managed to:\n1. Find the textarea and its React fiber\n2. Set the value using the native setter (which bypasses React\'s detection)\n3. Dispatch the proper input event to trigger React\'s state update\n4. The send button became enabled\n5. Successfully clicked it to send the message "Hello from the AI assistant!"\n\nThe key was using the native `HTMLTextAreaElement` value setter and then dispatching the input event, which properly notified React of the change. This is a more "framework-aware" approach that respects how React tracks input changes.', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 8986, - output: 134, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.026958, - output: 0.00201, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.028968, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "ask it what t3 offers", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll send a message asking about what T3 offers:", - }, - { - type: "toolCall", - id: "toolu_01Mc4YE51qxtGCy4JeQwADSa", - name: "browser_javascript", - arguments: { - code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n \n // Wait a moment for React to process, then click\n setTimeout(() => {\n if (!sendButton.disabled) {\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n } else {\n console.log('Button still disabled');\n }\n }, 100);\n} else {\n console.log('Could not find chatbox elements');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 9130, - output: 331, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.02739, - output: 0.004965, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.032355, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Mc4YE51qxtGCy4JeQwADSa", - toolName: "browser_javascript", - output: "Message typed: What does T3 offer?", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: '\n\nPerfect! I\'ve sent the message "What does T3 offer?" to the chat. The message should now appear in the conversation, and you should receive a response explaining what T3 offers. Let me know what the response says!', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 9481, - output: 53, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.028443, - output: 0.000795, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.029238, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "need a higher timeout", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "You're right, let me increase the timeout:", - }, - { - type: "toolCall", - id: "toolu_018EUmEjTXgnDX5gtGorF3B1", - name: "browser_javascript", - arguments: { - code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n \n // Wait longer for React to process, then click\n setTimeout(() => {\n if (!sendButton.disabled) {\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n } else {\n console.log('Button still disabled after timeout');\n }\n }, 500);\n} else {\n console.log('Could not find chatbox elements');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 9541, - output: 329, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.028623, - output: 0.004935, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.033558, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_018EUmEjTXgnDX5gtGorF3B1", - toolName: "browser_javascript", - output: "Message typed: What does T3 offer?", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nI've increased the timeout to 500ms. The message should be sent now. Would you like me to try an even longer timeout, or would you like me to check if the message was successfully sent?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 9890, - output: 46, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.029670000000000002, - output: 0.00069, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.03036, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "can you try without a setTimeout?", - }, - ], - }, - { - role: "assistant", - content: [], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "aborted", - errorMessage: "Request was aborted.", - }, - { - role: "user", - content: [ - { - type: "text", - text: "just do the button click", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_01PT5nvi2zLUp8wWmvduzomz", - name: "browser_javascript", - arguments: { - code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n console.log('Button disabled before click:', sendButton.disabled);\n \n // Click immediately\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n} else {\n console.log('Could not find chatbox elements');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 9952, - output: 285, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.029856, - output: 0.004275, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.034131, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01PT5nvi2zLUp8wWmvduzomz", - toolName: "browser_javascript", - output: - 'Message typed: What does T3 offer?\nButton disabled before click: false\nMessage sent: "What does T3 offer?"', - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: '\n\nPerfect! The button was enabled immediately after dispatching the input event, so no setTimeout was needed. The message "What does T3 offer?" has been sent successfully!', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 10277, - output: 39, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.030831, - output: 0.000585, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.031416, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "hrm, for some reason the click even tdoes nothing", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me investigate the send button more closely to see what's actually handling the submission:", - }, - { - type: "toolCall", - id: "toolu_01Cvkd3XADVE1ToMXqjQYc12", - name: "browser_javascript", - arguments: { - code: "const sendButton = document.querySelector('button[type=\"submit\"]');\nconst form = document.querySelector('form');\n\nconsole.log('Send button:', sendButton);\nconsole.log('Button onclick:', sendButton?.onclick);\nconsole.log('Form:', form);\nconsole.log('Form onsubmit:', form?.onsubmit);\n\n// Check for React event handlers\nif (sendButton) {\n const reactKey = Object.keys(sendButton).find(key => key.startsWith('__react'));\n if (reactKey) {\n const reactProps = sendButton[reactKey];\n console.log('React props on button:', reactProps);\n \n // Try to find the onClick handler in the fiber\n let fiber = reactProps;\n while (fiber) {\n if (fiber.memoizedProps) {\n console.log('Memoized props:', fiber.memoizedProps);\n if (fiber.memoizedProps.onClick) {\n console.log('Found onClick handler in fiber');\n }\n }\n if (fiber.return) {\n fiber = fiber.return;\n } else {\n break;\n }\n }\n }\n}\n\n// Check form structure\nif (form) {\n const formReactKey = Object.keys(form).find(key => key.startsWith('__react'));\n if (formReactKey) {\n const formFiber = form[formReactKey];\n console.log('Form has React fiber');\n if (formFiber.memoizedProps && formFiber.memoizedProps.onSubmit) {\n console.log('Form has onSubmit handler');\n }\n }\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 10331, - output: 473, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.030993, - output: 0.007095000000000001, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.038088, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01Cvkd3XADVE1ToMXqjQYc12", - toolName: "browser_javascript", - output: "Error executing script: Frame with ID 0 was removed.", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "aborted", - errorMessage: "Request was aborted.", - }, - { - role: "user", - content: [ - { - type: "text", - text: "i'm on another page now with a chat again. can you find the chat window and input?", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_018681im65XWx7SMGvRo9xPx", - name: "browser_javascript", - arguments: { - code: "// First, let's see what page we're on\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Look for common chat input elements\nconst textarea = document.querySelector('textarea');\nconst textInputs = document.querySelectorAll('input[type=\"text\"]');\nconst contentEditables = document.querySelectorAll('[contenteditable=\"true\"]');\n\nconsole.log('Found textarea:', textarea ? 'Yes' : 'No');\nconsole.log('Found text inputs:', textInputs.length);\nconsole.log('Found contenteditable elements:', contentEditables.length);\n\nif (textarea) {\n console.log('Textarea details:', {\n placeholder: textarea.placeholder,\n id: textarea.id,\n name: textarea.name,\n className: textarea.className\n });\n}\n\n// Look for send/submit buttons\nconst submitButtons = document.querySelectorAll('button[type=\"submit\"]');\nconst buttons = document.querySelectorAll('button');\n\nconsole.log('Found submit buttons:', submitButtons.length);\nconsole.log('Total buttons:', buttons.length);\n\n// Look for forms\nconst forms = document.querySelectorAll('form');\nconsole.log('Found forms:', forms.length);\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 10852, - output: 348, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.032556, - output: 0.00522, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.037776000000000004, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_018681im65XWx7SMGvRo9xPx", - toolName: "browser_javascript", - output: - "Current URL: https://www.ams.at/arbeitsuchende/aus-und-weiterbildung/berufsinformationen/berufsinformation/berufsinfomat?open=berufsinfomat\nPage title: Berufsinfomat | AMS\nFound textarea: No\nFound text inputs: 1\nFound contenteditable elements: 0\nFound submit buttons: 1\nTotal buttons: 12\nFound forms: 1", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nI can see you're on an Austrian AMS (Arbeitsmarktservice) page about career information. I found 1 text input and 1 submit button. Let me examine them more closely:", - }, - { - type: "toolCall", - id: "toolu_01DuFzPze1zWi5QELAQtcUFZ", - name: "browser_javascript", - arguments: { - code: "const textInput = document.querySelector('input[type=\"text\"]');\nconst submitButton = document.querySelector('button[type=\"submit\"]');\nconst form = document.querySelector('form');\n\nif (textInput) {\n console.log('Text input details:', {\n placeholder: textInput.placeholder,\n id: textInput.id,\n name: textInput.name,\n value: textInput.value,\n className: textInput.className\n });\n}\n\nif (submitButton) {\n console.log('Submit button details:', {\n text: submitButton.textContent,\n id: submitButton.id,\n className: submitButton.className,\n disabled: submitButton.disabled\n });\n}\n\nif (form) {\n console.log('Form details:', {\n action: form.action,\n method: form.method,\n id: form.id\n });\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 11314, - output: 310, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.033942, - output: 0.0046500000000000005, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.038592, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01DuFzPze1zWi5QELAQtcUFZ", - toolName: "browser_javascript", - output: - "Text input details: [object Object]\nSubmit button details: [object Object]\nForm details: [object Object]", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nThis appears to be a career information website form, not a chat interface. Are you sure you're on a chat page? Could you describe what you see on the screen, or would you like me to send a message through this form anyway?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 11659, - output: 53, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.034977, - output: 0.000795, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.035772, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "could be in an iframe", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "Good thinking! Let me check for iframes:", - }, - { - type: "toolCall", - id: "toolu_01JDhMJmHj79N7gBWZATBNvs", - name: "browser_javascript", - arguments: { - code: "// Find all iframes\nconst iframes = document.querySelectorAll('iframe');\nconsole.log('Found iframes:', iframes.length);\n\nif (iframes.length > 0) {\n iframes.forEach((iframe, index) => {\n console.log(`Iframe ${index}:`, {\n src: iframe.src,\n id: iframe.id,\n className: iframe.className,\n title: iframe.title,\n name: iframe.name\n });\n });\n \n // Try to access the first iframe's content\n try {\n const iframeDoc = iframes[0].contentDocument || iframes[0].contentWindow.document;\n if (iframeDoc) {\n console.log('Can access iframe content');\n console.log('Iframe page title:', iframeDoc.title);\n \n // Look for chat elements in the iframe\n const iframeTextarea = iframeDoc.querySelector('textarea');\n const iframeTextInputs = iframeDoc.querySelectorAll('input[type=\"text\"]');\n \n console.log('Found textarea in iframe:', iframeTextarea ? 'Yes' : 'No');\n console.log('Found text inputs in iframe:', iframeTextInputs.length);\n }\n } catch (e) {\n console.log('Cannot access iframe content (cross-origin):', e.message);\n }\n} else {\n console.log('No iframes found on the page');\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 11720, - output: 423, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.035160000000000004, - output: 0.006345, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.041505, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01JDhMJmHj79N7gBWZATBNvs", - toolName: "browser_javascript", - output: - "Found iframes: 1\nIframe 0: [object Object]\nCannot access iframe content (cross-origin): Failed to read a named property 'document' from 'Window': Blocked a frame with origin \"https://www.ams.at\" from accessing a cross-origin frame.", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nThere is an iframe on the page, but it's cross-origin so I can't access its content directly due to browser security restrictions. Let me get more details about the iframe:", - }, - { - type: "toolCall", - id: "toolu_0153cpVfNAXdNkipbEikNNTu", - name: "browser_javascript", - arguments: { - code: "const iframe = document.querySelector('iframe');\nif (iframe) {\n console.log('Iframe source:', iframe.src);\n console.log('Iframe ID:', iframe.id);\n console.log('Iframe class:', iframe.className);\n console.log('Iframe title:', iframe.title);\n console.log('Iframe width:', iframe.width);\n console.log('Iframe height:', iframe.height);\n}\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 12217, - output: 193, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.036651, - output: 0.002895, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.039546000000000005, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_0153cpVfNAXdNkipbEikNNTu", - toolName: "browser_javascript", - output: - "Iframe source: https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min\nIframe ID: ams-modal-berufsinfomat-iframe\nIframe class: \nIframe title: Berufsinfomat des AMS\nIframe width: 100%\nIframe height: 400", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nThe iframe is from `berufsinfomat.prod.portal.ams.at` which is a career information tool. Since it's cross-origin, I cannot directly access its content from the parent page due to browser security policies.\n\nThis doesn't appear to be a chat interface like T3 - it's a career counseling tool. Are you perhaps looking at a different window or tab? Or is there a chat widget somewhere on this page that I should look for?", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 12519, - output: 104, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.037557, - output: 0.00156, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.039117, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "can you open that iframe url in a new tab?", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_015qgN7Jjw16r43gKXBEeBFN", - name: "browser_javascript", - arguments: { - code: "const iframeUrl = 'https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min';\n\n// Open the iframe URL in a new tab\nwindow.open(iframeUrl, '_blank');\n\nconsole.log('Opened iframe URL in new tab:', iframeUrl);\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 12637, - output: 146, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.037911, - output: 0.00219, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.040101, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_015qgN7Jjw16r43gKXBEeBFN", - toolName: "browser_javascript", - output: - "Opened iframe URL in new tab: https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nI've opened the iframe URL in a new tab! Once you switch to that tab, I'll be able to access the content directly and help you interact with the chat interface there. Let me know when you're on the new tab and I can look for the chat input and other elements.", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 12845, - output: 63, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.038535, - output: 0.000945, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.03948, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "ok, i'm on a news page now, can you tell me what's news today?", - }, - ], - }, - { - role: "assistant", - content: [], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0, - }, - }, - stopReason: "aborted", - errorMessage: "Request was aborted.", - }, - { - role: "user", - content: [ - { - type: "text", - text: "write a markdown doc, and include images if possible", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll check what's on the current page and extract the news for you:", - }, - { - type: "toolCall", - id: "toolu_01XaoCN6tM5NGdQXy2SjssXZ", - name: "browser_javascript", - arguments: { - code: "// Get the current page info\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Look for news articles\nconst articles = document.querySelectorAll('article, .article, .news-item, [class*=\"article\"], [class*=\"story\"]');\nconsole.log('Found articles:', articles.length);\n\n// Look for headlines\nconst headlines = document.querySelectorAll('h1, h2, h3, .headline, [class*=\"headline\"], [class*=\"title\"]');\nconsole.log('Found headlines:', headlines.length);\n\n// Look for images\nconst images = document.querySelectorAll('img');\nconsole.log('Found images:', images.length);\n\n// Get main content\nconst mainContent = document.querySelector('main, #main, .main-content, [role=\"main\"]');\nconsole.log('Found main content:', mainContent ? 'Yes' : 'No');\n\n// Let's get a sample of what's on the page\nconsole.log('Body text preview:', document.body.innerText.substring(0, 500));\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 12941, - output: 324, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.038823, - output: 0.00486, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.043683, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01XaoCN6tM5NGdQXy2SjssXZ", - toolName: "browser_javascript", - output: - 'Current URL: https://www.derstandard.at/\nPage title: derStandard.at | Nachrichten, Kommentare & Community\nFound articles: 231\nFound headlines: 365\nFound images: 154\nFound main content: Yes\nBody text preview: Fr, 3.10.2025\nStartseite\nÜberblick\nNACHRICHTEN IN ECHTZEIT\nRussland greift ukrainische Energieinfrastruktur an \nSchikanen gegen Arbeitsmigranten in Russland \nFall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter \nZiel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich \n\nZUKUNFT MEDIENKOMPETENZ\n\n106\nPostings\nNetzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\nGezielt Verwirrung stiften – das ist die Strategie hinter vielen', - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nGreat! You're on derStandard.at, an Austrian news site. Let me extract the news articles with images:", - }, - { - type: "toolCall", - id: "toolu_01FDif6VsohQR8Eint1TSZDi", - name: "browser_javascript", - arguments: { - code: "// Extract news articles with their details\nconst newsData = [];\n\n// Find article containers\nconst articles = document.querySelectorAll('article, .article-teaser, [class*=\"teaser\"]');\n\narticles.forEach((article, index) => {\n if (index < 15) { // Get top 15 articles\n const headline = article.querySelector('h1, h2, h3, h4, .headline, [class*=\"headline\"]');\n const link = article.querySelector('a');\n const img = article.querySelector('img');\n const description = article.querySelector('p, .description, .teaser-text, [class*=\"description\"]');\n \n if (headline && headline.textContent.trim()) {\n newsData.push({\n title: headline.textContent.trim(),\n link: link ? link.href : '',\n image: img ? img.src : '',\n imageAlt: img ? img.alt : '',\n description: description ? description.textContent.trim() : ''\n });\n }\n }\n});\n\nconsole.log('Extracted news articles:', newsData.length);\nconsole.log(JSON.stringify(newsData.slice(0, 5), null, 2)); // Show first 5 in console\n\n// Return the data\nnewsData;\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 13535, - output: 385, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.040605, - output: 0.005775, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.046380000000000005, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01FDif6VsohQR8Eint1TSZDi", - toolName: "browser_javascript", - output: - 'Extracted news articles: 6\n[\n {\n "title": "Russland greift ukrainische Energieinfrastruktur an",\n "link": "https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Schikanen gegen Arbeitsmigranten in Russland",\n "link": "https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter",\n "link": "https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich",\n "link": "https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Netzexpertin Brodnig: \\"Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen\\"",\n "link": "https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen",\n "image": "https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg",\n "imageAlt": "",\n "description": "Zukunft Medienkompetenz"\n }\n]', - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_018YgtrBC5eankNyqWxJW9D2", - name: "browser_javascript", - arguments: { - code: "// Let's get more comprehensive news data\nconst newsArticles = [];\n\n// Try different selectors for better coverage\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5, a[class*=\"headline\"], span[class*=\"headline\"]');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p, span, div');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n // Avoid duplicates\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\nconsole.log('Total articles extracted:', newsArticles.length);\nnewsArticles;\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 14583, - output: 436, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.043749, - output: 0.00654, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.050289, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_018YgtrBC5eankNyqWxJW9D2", - toolName: "browser_javascript", - output: "Total articles extracted: 18", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_01LPxKs8zBbMvWdygvP9JxHF", - name: "browser_javascript", - arguments: { - code: "// Get the extracted news data and format it for markdown\nconst newsArticles = [];\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\n// Get today's date\nconst today = new Date().toLocaleDateString('de-AT', { \n weekday: 'long', \n year: 'numeric', \n month: 'long', \n day: 'numeric' \n});\n\n// Create markdown content\nlet markdown = `# Der Standard - Nachrichten\\n## ${today}\\n\\n`;\nmarkdown += `*Quelle: [derStandard.at](${window.location.href})*\\n\\n---\\n\\n`;\n\nnewsArticles.forEach((article, index) => {\n markdown += `### ${index + 1}. ${article.title}\\n\\n`;\n \n if (article.image) {\n markdown += `![${article.imageAlt}](${article.image})\\n\\n`;\n }\n \n if (article.description) {\n markdown += `${article.description}\\n\\n`;\n }\n \n if (article.link) {\n markdown += `[Artikel lesen →](${article.link})\\n\\n`;\n }\n \n markdown += `---\\n\\n`;\n});\n\nconsole.log('Markdown created with', newsArticles.length, 'articles');\nmarkdown;\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 15035, - output: 685, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.045105, - output: 0.010275, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.05538, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01LPxKs8zBbMvWdygvP9JxHF", - toolName: "browser_javascript", - output: "Markdown created with 18 articles", - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nNow let me create the markdown document as an artifact:", - }, - { - type: "toolCall", - id: "toolu_013YSiSucUcbfdwXqgHUfy9T", - name: "browser_javascript", - arguments: { - code: "// Generate the final markdown\nconst newsArticles = [];\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\nconst today = new Date().toLocaleDateString('de-AT', { \n weekday: 'long', \n year: 'numeric', \n month: 'long', \n day: 'numeric' \n});\n\nlet markdown = `# Der Standard - Nachrichten des Tages\\n## ${today}\\n\\n`;\nmarkdown += `*Quelle: [derStandard.at](${window.location.href})*\\n\\n`;\nmarkdown += `---\\n\\n`;\n\nnewsArticles.forEach((article, index) => {\n markdown += `### ${index + 1}. ${article.title}\\n\\n`;\n \n if (article.image) {\n markdown += `![${article.imageAlt || 'News Image'}](${article.image})\\n\\n`;\n }\n \n if (article.description) {\n markdown += `${article.description}\\n\\n`;\n }\n \n if (article.link) {\n markdown += `[📰 Artikel lesen](${article.link})\\n\\n`;\n }\n \n markdown += `---\\n\\n`;\n});\n\nconsole.log(markdown);\nmarkdown;\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 15738, - output: 683, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.047214, - output: 0.010245, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.057458999999999996, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_013YSiSucUcbfdwXqgHUfy9T", - toolName: "browser_javascript", - output: - '# Der Standard - Nachrichten des Tages\n## Freitag, 3. Oktober 2025\n\n*Quelle: [derStandard.at](https://www.derstandard.at/)*\n\n---\n\n### 1. Russland greift ukrainische Energieinfrastruktur an\n\n[📰 Artikel lesen](https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget)\n\n---\n\n### 2. Schikanen gegen Arbeitsmigranten in Russland\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget)\n\n---\n\n### 3. Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget)\n\n---\n\n### 4. Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget)\n\n---\n\n### 5. Netzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\n![News Image](https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg)\n\nZukunft Medienkompetenz\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen)\n\n---\n\n### 6. Flughafen München nach Drohnensichtung zwischenzeitlich geschlossen, zahlreiche Ausfälle\n\n![News Image](https://i.ds.at/fp3AhQ/rs:fill:600:400/plain/lido-images/2025/10/03/ef80089b-300d-4fcb-96b1-7b65cac270a4.jpeg)\n\nDer Flugbetrieb ist seit den frühen Morgenstunden wieder aufgenommen. Rund 3.000 Passagiere waren von den Ausfällen betroffen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290460/flughafen-m252nchen-nach-schlie223ung-wegen-drohnensichtung-wieder-offen)\n\n---\n\n### 7. Wie stark werden Onlinekäufer manipuliert? Sozialministerium klagt Billigriesen Temu\n\n![News Image](https://i.ds.at/UQ7LBg/rs:fill:600:400/plain/lido-images/2025/10/02/7febc4a4-6c5a-473c-b28c-ce89d151db0b.jpeg)\n\nUnlauterer Wettbewerb\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290329/wie-stark-werden-onlinekaeufer-manipuliert-sozialministerium-klagt-billigriesen-temu)\n\n---\n\n### 8. Teslas Freude über Verkaufsrekord dürfte von kurzer Dauer sein\n\n![News Image](https://i.ds.at/ryC6hQ/rs:fill:600:400/plain/lido-images/2025/10/03/ecc3c7a6-7d2d-453f-b97d-002034b4d86e.jpeg)\n\nDie aktuell wieder besseren Zahlen dürften auf die Streichung einer Verkaufsprämie zurückzuführen sein. Parallel dazu wollen Investoren Musks Billionen-Dollar-Gehaltspaket kippen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290473/teslas-freude-ueber-verkaufsrekord-duerfte-von-kurzer-dauer-sein)\n\n---\n\n### 9. Bis zu 17 Euro: Das kosten Eggs Benedict in der Wiener Gastronomie\n\n![News Image](https://i.ds.at/8Zh7zA/rs:fill:600:400/plain/lido-images/2025/10/02/fae3b15e-3ff7-4912-938a-2de53b7e33ff.jpeg)\n\nDen modernen Frühstücksklassiker findet man auf der Speisekarte zahlreicher Wiener Lokale. So viel muss man dafür hinblättern\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290304/bis-zu-17-euro-das-kosten-eggs-benedict-in-der-wiener-gastronomie)\n\n---\n\n### 10. Georg Dornauer lässt die SPÖ ganz alt aussehen\n\n![News Image](https://i.ds.at/cU2jUQ/rs:fill:600:400/plain/lido-images/2025/10/03/c77523eb-b3bb-4a66-8e32-fa29a441452b.jpeg)\n\nDer Ex-Chef der Tiroler Sozialdemokraten ist ein schwieriger Genosse. Dass seine Partei aber keine andere Konfliktlösung als den Ausschluss gefunden hat, ist ein Armutszeugnis\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290496/georg-dornauer-laesst-die-spoe-ganz-alt-aussehen)\n\n---\n\n### 11. Wir sollten die Krise in Österreichs Wirtschaft nicht größer reden, als sie ist\n\n![News Image](https://i.ds.at/QFlU-w/rs:fill:600:400/plain/lido-images/2025/08/01/6c3bcbb1-eca4-4237-ad84-d77e39bc3545.jpeg)\n\nOb Wachstum oder Jobmarkt: Zuletzt gab es nur schlechte Nachrichten vom heimischen Standort. Dabei gibt es gute Gründe, nicht zu verzagen. Vier Beispiele dafür\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290078/wir-sollten-die-krise-in-oesterreichs-wirtschaft-nicht-groesser-reden-als-sie-ist)\n\n---\n\n### 12. Drohnen über Dänemark: Festnahmen auf verdächtigem Schiff\n\n![AFP/DAMIEN MEYER](https://i.ds.at/E8GGfA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/423305df-5d12-48f4-9b28-517363b0fd8e.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290261/drohnen-ueber-daenemark-franzoesisches-militaer-entert-schiff-der-russischen-schattenflotte?ref=seite1_entdecken)\n\n---\n\n### 13. Anschlagspläne: Mutmaßliche Hamas-Mitglieder in Deutschland festgenommen\n\n![AFP/POOL/JOHN MACDOUGALL](https://i.ds.at/m-jE6g/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/fc351fe4-cdac-4102-8332-95828658bff0.jpeg)\n\nJüdische Einrichtungen im Visier\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290245/anschlagsplaene-mutmassliche-hamas-mitglieder-in-deutschland-festgenommen?ref=seite1_entdecken)\n\n---\n\n### 14. Kinderfilm-Klassiker "Momo" bekommt zeitgemäßes Update\n\n![Constantin Film](https://i.ds.at/yibsqA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/g:fp:0.48:0.19/plain/lido-images/2025/10/02/a0cfd114-6460-4370-9ea5-1ad60e89a843.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290391/kinderfilm-klassiker-momo-bekommt-zeitgemaesses-update?ref=seite1_entdecken)\n\n---\n\n### 15. Generalstreik in Italien nach Stopp von Gaza-Flottille\n\n![REUTERS/Claudia Greco](https://i.ds.at/wee2ag/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/03/70939333-974c-44f4-ab81-20f49f555332.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290474/generalstreik-in-italien-nach-stopp-von-gaza-flottille?ref=seite1_entdecken)\n\n---\n\n### 16. Saturn-Mond Enceladus hat alle Zutaten, die es für Leben braucht\n\n![Illustr.: NASA/JPL-Caltech](https://i.ds.at/ae1Jkw/c:1013:675:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/02/53df0dc2-7990-40a9-8da3-4f5b8e21688f.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290229/saturn-mond-enceladus-hat-alle-zutaten-die-es-fuer-leben-braucht?ref=seite1_entdecken)\n\n---\n\n### 17. Welches Verhalten treibt euch in den Wahnsinn?\n\n![News Image](https://i.ds.at/hfeNIw/rs:fill:600:400/plain/lido-images/2025/04/16/1d6a93f4-5754-4ffc-88b6-954be5c2c396.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290297/welches-verhalten-treibt-euch-in-den-wahnsinn?ref=meinforum_widget)\n\n---\n\n### 18. Wohin kann man im Ernstfall als LGBTQIA Person sicher auswandern?\n\n![News Image](https://i.ds.at/JWzXOQ/rs:fill:600:400/plain/lido-images/2025/07/07/0d04eba0-7f8d-4156-88d1-151962852150.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290175/wohin-kann-man-im-ernstfall-als-lgbtqia-person-sicher-auswandern?ref=meinforum_widget)\n\n---', - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nPerfect! Now let me create the markdown file as an artifact:", - }, - { - type: "toolCall", - id: "toolu_01BtH9H2BvwxvKjLw5iHXcZC", - name: "artifacts", - arguments: { - command: "create", - filename: "news_today.md", - title: "Der Standard News - Today", - content: - '# Der Standard - Nachrichten des Tages\n## Freitag, 3. Oktober 2025\n\n*Quelle: [derStandard.at](https://www.derstandard.at/)*\n\n---\n\n### 1. Russland greift ukrainische Energieinfrastruktur an\n\n[📰 Artikel lesen](https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget)\n\n---\n\n### 2. Schikanen gegen Arbeitsmigranten in Russland\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget)\n\n---\n\n### 3. Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget)\n\n---\n\n### 4. Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget)\n\n---\n\n### 5. Netzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\n![News Image](https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg)\n\nZukunft Medienkompetenz\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen)\n\n---\n\n### 6. Flughafen München nach Drohnensichtung zwischenzeitlich geschlossen, zahlreiche Ausfälle\n\n![News Image](https://i.ds.at/fp3AhQ/rs:fill:600:400/plain/lido-images/2025/10/03/ef80089b-300d-4fcb-96b1-7b65cac270a4.jpeg)\n\nDer Flugbetrieb ist seit den frühen Morgenstunden wieder aufgenommen. Rund 3.000 Passagiere waren von den Ausfällen betroffen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290460/flughafen-m252nchen-nach-schlie223ung-wegen-drohnensichtung-wieder-offen)\n\n---\n\n### 7. Wie stark werden Onlinekäufer manipuliert? Sozialministerium klagt Billigriesen Temu\n\n![News Image](https://i.ds.at/UQ7LBg/rs:fill:600:400/plain/lido-images/2025/10/02/7febc4a4-6c5a-473c-b28c-ce89d151db0b.jpeg)\n\nUnlauterer Wettbewerb\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290329/wie-stark-werden-onlinekaeufer-manipuliert-sozialministerium-klagt-billigriesen-temu)\n\n---\n\n### 8. Teslas Freude über Verkaufsrekord dürfte von kurzer Dauer sein\n\n![News Image](https://i.ds.at/ryC6hQ/rs:fill:600:400/plain/lido-images/2025/10/03/ecc3c7a6-7d2d-453f-b97d-002034b4d86e.jpeg)\n\nDie aktuell wieder besseren Zahlen dürften auf die Streichung einer Verkaufsprämie zurückzuführen sein. Parallel dazu wollen Investoren Musks Billionen-Dollar-Gehaltspaket kippen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290473/teslas-freude-ueber-verkaufsrekord-duerfte-von-kurzer-dauer-sein)\n\n---\n\n### 9. Bis zu 17 Euro: Das kosten Eggs Benedict in der Wiener Gastronomie\n\n![News Image](https://i.ds.at/8Zh7zA/rs:fill:600:400/plain/lido-images/2025/10/02/fae3b15e-3ff7-4912-938a-2de53b7e33ff.jpeg)\n\nDen modernen Frühstücksklassiker findet man auf der Speisekarte zahlreicher Wiener Lokale. So viel muss man dafür hinblättern\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290304/bis-zu-17-euro-das-kosten-eggs-benedict-in-der-wiener-gastronomie)\n\n---\n\n### 10. Georg Dornauer lässt die SPÖ ganz alt aussehen\n\n![News Image](https://i.ds.at/cU2jUQ/rs:fill:600:400/plain/lido-images/2025/10/03/c77523eb-b3bb-4a66-8e32-fa29a441452b.jpeg)\n\nDer Ex-Chef der Tiroler Sozialdemokraten ist ein schwieriger Genosse. Dass seine Partei aber keine andere Konfliktlösung als den Ausschluss gefunden hat, ist ein Armutszeugnis\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290496/georg-dornauer-laesst-die-spoe-ganz-alt-aussehen)\n\n---\n\n### 11. Wir sollten die Krise in Österreichs Wirtschaft nicht größer reden, als sie ist\n\n![News Image](https://i.ds.at/QFlU-w/rs:fill:600:400/plain/lido-images/2025/08/01/6c3bcbb1-eca4-4237-ad84-d77e39bc3545.jpeg)\n\nOb Wachstum oder Jobmarkt: Zuletzt gab es nur schlechte Nachrichten vom heimischen Standort. Dabei gibt es gute Gründe, nicht zu verzagen. Vier Beispiele dafür\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290078/wir-sollten-die-krise-in-oesterreichs-wirtschaft-nicht-groesser-reden-als-sie-ist)\n\n---\n\n### 12. Drohnen über Dänemark: Festnahmen auf verdächtigem Schiff\n\n![AFP/DAMIEN MEYER](https://i.ds.at/E8GGfA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/423305df-5d12-48f4-9b28-517363b0fd8e.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290261/drohnen-ueber-daenemark-franzoesisches-militaer-entert-schiff-der-russischen-schattenflotte?ref=seite1_entdecken)\n\n---\n\n### 13. Anschlagspläne: Mutmaßliche Hamas-Mitglieder in Deutschland festgenommen\n\n![AFP/POOL/JOHN MACDOUGALL](https://i.ds.at/m-jE6g/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/fc351fe4-cdac-4102-8332-95828658bff0.jpeg)\n\nJüdische Einrichtungen im Visier\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290245/anschlagsplaene-mutmassliche-hamas-mitglieder-in-deutschland-festgenommen?ref=seite1_entdecken)\n\n---\n\n### 14. Kinderfilm-Klassiker "Momo" bekommt zeitgemäßes Update\n\n![Constantin Film](https://i.ds.at/yibsqA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/g:fp:0.48:0.19/plain/lido-images/2025/10/02/a0cfd114-6460-4370-9ea5-1ad60e89a843.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290391/kinderfilm-klassiker-momo-bekommt-zeitgemaesses-update?ref=seite1_entdecken)\n\n---\n\n### 15. Generalstreik in Italien nach Stopp von Gaza-Flottille\n\n![REUTERS/Claudia Greco](https://i.ds.at/wee2ag/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/03/70939333-974c-44f4-ab81-20f49f555332.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290474/generalstreik-in-italien-nach-stopp-von-gaza-flottille?ref=seite1_entdecken)\n\n---\n\n### 16. Saturn-Mond Enceladus hat alle Zutaten, die es für Leben braucht\n\n![Illustr.: NASA/JPL-Caltech](https://i.ds.at/ae1Jkw/c:1013:675:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/02/53df0dc2-7990-40a9-8da3-4f5b8e21688f.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290229/saturn-mond-enceladus-hat-alle-zutaten-die-es-fuer-leben-braucht?ref=seite1_entdecken)\n\n---\n\n### 17. Welches Verhalten treibt euch in den Wahnsinn?\n\n![News Image](https://i.ds.at/hfeNIw/rs:fill:600:400/plain/lido-images/2025/04/16/1d6a93f4-5754-4ffc-88b6-954be5c2c396.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290297/welches-verhalten-treibt-euch-in-den-wahnsinn?ref=meinforum_widget)\n\n---\n\n### 18. Wohin kann man im Ernstfall als LGBTQIA Person sicher auswandern?\n\n![News Image](https://i.ds.at/JWzXOQ/rs:fill:600:400/plain/lido-images/2025/07/07/0d04eba0-7f8d-4156-88d1-151962852150.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290175/wohin-kann-man-im-ernstfall-als-lgbtqia-person-sicher-auswandern?ref=meinforum_widget)\n\n---\n\n## Top Themen des Tages\n\n- **Ukraine-Krieg**: Russland greift Energieinfrastruktur an\n- **Sicherheit**: Flughafen München wegen Drohne geschlossen, Drohnen über Dänemark\n- **Österreich**: SPÖ-Konflikt um Georg Dornauer, Diskussion über Wirtschaftskrise\n- **International**: Hamas-Festnahmen in Deutschland, Generalstreik in Italien\n- **Wirtschaft**: Tesla-Verkaufszahlen, Klage gegen Temu\n- **Wissenschaft**: Saturn-Mond Enceladus könnte Leben beherbergen\n- **Kultur**: Neuverfilmung von "Momo"', - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 19586, - output: 3462, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.058758000000000005, - output: 0.051930000000000004, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.11068800000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01BtH9H2BvwxvKjLw5iHXcZC", - toolName: "artifacts", - output: "Created file news_today.md", - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nPerfect! I've created a markdown document with today's news from Der Standard (Austrian news site). The document includes:\n\n✅ **18 news articles** from Friday, October 3, 2025\n✅ **Images** where available (many articles have accompanying photos)\n✅ **Descriptions** for most articles\n✅ **Links** to read the full articles\n✅ **Summary section** at the end highlighting the main topics\n\nThe top stories include:\n- Ukraine war updates (Russian attacks on energy infrastructure)\n- Security incidents (Munich airport closure due to drone, suspicious drones over Denmark)\n- Austrian politics (SPÖ conflict, economic discussion)\n- International news (Hamas arrests in Germany, general strike in Italy)\n- Business (Tesla sales, lawsuit against Temu)\n- Science (Saturn's moon Enceladus may support life)\n- Culture (Momo film remake)\n\nThe markdown file is now available for you to view, download, or share!", - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 23065, - output: 223, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.069195, - output: 0.003345, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.07254000000000001, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "wonder if you can read the curent page contents it's a weird ui", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me investigate the current page structure and see what's there:", - }, - { - type: "toolCall", - id: "toolu_016BX1izZEsfXenpcMCioKSA", - name: "browser_javascript", - arguments: { - code: "// Get basic page info\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Get the full text content\nconst bodyText = document.body.innerText;\nconsole.log('Page text length:', bodyText.length);\nconsole.log('First 1000 characters:', bodyText.substring(0, 1000));\n\n// Look for main content areas\nconst mainElements = document.querySelectorAll('main, #main, .main, [role=\"main\"]');\nconsole.log('Main elements found:', mainElements.length);\n\n// Check for specific UI framework indicators\nconst reactRoot = document.querySelector('#root, [id*=\"root\"], [id*=\"app\"]');\nconsole.log('React/App root found:', reactRoot ? 'Yes' : 'No');\n\n// Look for chat-like elements\nconst messages = document.querySelectorAll('[class*=\"message\"], [class*=\"chat\"], [class*=\"conversation\"]');\nconsole.log('Message-like elements:', messages.length);\n\n// Get all visible text in chunks\nconsole.log('\\n--- Full visible text ---');\nconsole.log(bodyText);\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 23306, - output: 335, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.06991800000000001, - output: 0.005025, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.07494300000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_016BX1izZEsfXenpcMCioKSA", - toolName: "browser_javascript", - output: - 'Current URL: https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget\nPage title: Russland greift ukrainische Energieinfrastruktur an - Livebericht - Ukraine\nPage text length: 2314\nFirst 1000 characters: International\nEuropa\nUkraine\n \nLIVE\n299 POSTINGS\nSie sind hier, ‘wischen‘ für ersten Beitrag\nVibration ein Hilfe\nEinstellungen\n3. Oktober 2025, 10:31 / Helene Dallinger, Isadora Wallnöfer / LIVEBERICHT\nRussland greift ukrainische Energieinfrastruktur an\nDie Regionen Poltawa und Charkiw sind besonders betroffen. Nach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde in der Stadt Drohnenalarm ausgelöst\nJETZT LIVE MITLESEN & POSTEN \n\nDas Wichtigste in Kürze:\n\nDas russische Militär hat ukrainische Energieanlagen in mehreren Gebieten mit Drohnen und Raketen angegriffen – besonders in den Regionen Poltawa und Charkiw.\nNach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde Drohnenalarm ausgelöst, zwei Flughäfen mussten den Betrieb einstellen.\nBei einem Gipfel der Europäischen Politischen Gemeinschaft (EPG) in Kopenhagen warnte der ukrainische Präsident Wolodymyr Selenskyj Europa vor der Bedrohung durch russische Drohnen.\nRussland und die Ukraine tauschten jeweils 185 Krieg\nMain elements found: 1\nReact/App root found: No\nMessage-like elements: 3\n\n--- Full visible text ---\nInternational\nEuropa\nUkraine\n \nLIVE\n299 POSTINGS\nSie sind hier, ‘wischen‘ für ersten Beitrag\nVibration ein Hilfe\nEinstellungen\n3. Oktober 2025, 10:31 / Helene Dallinger, Isadora Wallnöfer / LIVEBERICHT\nRussland greift ukrainische Energieinfrastruktur an\nDie Regionen Poltawa und Charkiw sind besonders betroffen. Nach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde in der Stadt Drohnenalarm ausgelöst\nJETZT LIVE MITLESEN & POSTEN \n\nDas Wichtigste in Kürze:\n\nDas russische Militär hat ukrainische Energieanlagen in mehreren Gebieten mit Drohnen und Raketen angegriffen – besonders in den Regionen Poltawa und Charkiw.\nNach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde Drohnenalarm ausgelöst, zwei Flughäfen mussten den Betrieb einstellen.\nBei einem Gipfel der Europäischen Politischen Gemeinschaft (EPG) in Kopenhagen warnte der ukrainische Präsident Wolodymyr Selenskyj Europa vor der Bedrohung durch russische Drohnen.\nRussland und die Ukraine tauschten jeweils 185 Kriegsgefangene.\nDie Lage in dem von Russland kontrollierten und seit nunmehr sieben Tagen vom Stromnetz getrennten Atomkraftwerk Saporischschja ist nach Angaben Selenskyjs "kritisch". Zudem fiel an der Schutzhülle um den zerstörten Reaktorblock des stillgelegten Kernkraftwerks Tschernobyl laut Kyjiw zeitweise der Strom aus.\nDen Livebericht von Donnerstag finden Sie hier.\nWir verwenden neben eigenen Recherchen Material der Nachrichtenagenturen APA, dpa und Reuters sowie internationaler Medien. Angaben über den Kriegsverlauf und Opferzahlen stammen von den Behörden der Kriegsparteien und lassen sich oft nicht unabhängig überprüfen. (red, 3.10.2025)\nHelene Dallinger vor 4 Stunden\nGuten Morgen, liebe Leser:innen!\nIch darf Sie heute wieder zu unserer Live-Berichterstattung über den Ukrainekrieg begrüßen - wir beginnen in Kürze. Hier können Sie in der Zwischenzeit die Geschehnisse von gestern nachlesen. Ich wünsche Ihnen einen angenehmen Start in den Freitag!\nBeitrag teilen\n29 Postings\nVoriger Beitrag\nNächster Beitrag\n© STANDARD Verlagsgesellschaft m.b.H. 2025\n\nAlle Rechte vorbehalten. Nutzung ausschließlich für den privaten Eigenbedarf.\nEine Weiterverwendung und Reproduktion über den persönlichen Gebrauch hinaus ist nicht gestattet.\n\nderStandard.de\nderStandard.at\nImpressum & Offenlegung\nDatenschutz\nAGB', - details: { - files: [], - }, - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "\n\nInteresting! You're on a live blog (\"Livebericht\") about the Ukraine war. The UI is indeed unique - it's a live ticker/blog format. Let me explore the structure more to understand the layout:", - }, - { - type: "toolCall", - id: "toolu_01JFdwqjQKdH6qL4qbotXDGH", - name: "browser_javascript", - arguments: { - code: "// Let's examine the DOM structure more carefully\nconsole.log('=== DOM STRUCTURE ===');\n\n// Look for the main container\nconst body = document.body;\nconsole.log('Body children:', body.children.length);\n\n// Get all elements with classes\nconst allElements = document.querySelectorAll('*');\nconsole.log('Total elements:', allElements.length);\n\n// Look for articles or post containers\nconst articles = document.querySelectorAll('article');\nconst sections = document.querySelectorAll('section');\nconst divs = document.querySelectorAll('div');\n\nconsole.log('Articles:', articles.length);\nconsole.log('Sections:', sections.length);\nconsole.log('Divs:', divs.length);\n\n// Try to find the live blog posts\nconst liveElements = document.querySelectorAll('[class*=\"live\"], [class*=\"post\"], [class*=\"entry\"], [class*=\"update\"]');\nconsole.log('Live/post elements:', liveElements.length);\n\n// Check for specific time stamps\nconst timeElements = document.querySelectorAll('time, [class*=\"time\"], [class*=\"date\"]');\nconsole.log('Time elements:', timeElements.length);\n\n// Let's look at the main element's HTML structure (first 2000 chars)\nconst main = document.querySelector('main');\nif (main) {\n console.log('\\n=== MAIN ELEMENT HTML ===');\n console.log(main.innerHTML.substring(0, 2000));\n}\n\n// Check body classes and IDs\nconsole.log('\\n=== BODY INFO ===');\nconsole.log('Body class:', body.className);\nconsole.log('Body id:', body.id);\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 25012, - output: 499, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.075036, - output: 0.007485, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.08252100000000001, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01JFdwqjQKdH6qL4qbotXDGH", - toolName: "browser_javascript", - output: - '=== DOM STRUCTURE ===\nBody children: 24\nTotal elements: 368\nArticles: 0\nSections: 0\nDivs: 68\nLive/post elements: 31\nTime elements: 5\n\n=== MAIN ELEMENT HTML ===\n\n \n\n\n\n\n", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 30699, - output: 162, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.092097, - output: 0.00243, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.094527, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01WrJNA9ncm1kKvrPLmXEfvV", - toolName: "artifacts", - output: - "Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page", - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: '\n\nPerfect! I\'ve added 2 console log statements to index.html. The logs show they\'re working:\n1. "Page loaded successfully!"\n2. "Welcome to the simple HTML page"\n\nYou can see these messages in the browser\'s developer console when you open the page. ✅', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 30906, - output: 67, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.09271800000000001, - output: 0.001005, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.09372300000000001, - }, - }, - stopReason: "stop", - }, - { - role: "user", - content: [ - { - type: "text", - text: "add a third", - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "toolu_01DPsTn1gLbZiTDsn44N52Vx", - name: "artifacts", - arguments: { - command: "update", - filename: "index.html", - old_str: - " ", - new_str: - " ", - }, - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 30979, - output: 182, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.092937, - output: 0.0027300000000000002, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.095667, - }, - }, - stopReason: "toolUse", - }, - { - role: "toolResult", - toolCallId: "toolu_01DPsTn1gLbZiTDsn44N52Vx", - toolName: "artifacts", - output: - "Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page\n[log] Third console log added!", - isError: false, - }, - { - role: "assistant", - content: [ - { - type: "text", - text: '\n\nDone! Added a third console log statement: "Third console log added!" \n\nAll three logs are now firing successfully! 🎉', - }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5-20250929", - usage: { - input: 31214, - output: 33, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0.093642, - output: 0.000495, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - total: 0.094137, - }, - }, - stopReason: "stop", - }, - ], -}; diff --git a/packages/web-ui/tsconfig.build.json b/packages/web-ui/tsconfig.build.json deleted file mode 100644 index 9c4ca583..00000000 --- a/packages/web-ui/tsconfig.build.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "moduleResolution": "bundler", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "useDefineForClassFields": false, - "rootDir": "./src", - "outDir": "./dist" - }, - "include": ["src/**/*"] -} diff --git a/packages/web-ui/tsconfig.json b/packages/web-ui/tsconfig.json deleted file mode 100644 index 25e8cea9..00000000 --- a/packages/web-ui/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true - }, - "include": ["src/**/*"] -} diff --git a/pi-test.bat b/pi-test.bat new file mode 100644 index 00000000..048a99db --- /dev/null +++ b/pi-test.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "POWERSHELL_EXE=powershell.exe" + +where %POWERSHELL_EXE% >nul 2>nul +if errorlevel 1 ( + >&2 echo powershell.exe not found. Install PowerShell or run pi-test.ps1 directly. + exit /b 1 +) + +%POWERSHELL_EXE% -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%pi-test.ps1" %* +exit /b %ERRORLEVEL% diff --git a/pi-test.ps1 b/pi-test.ps1 index 2bbe35bd..610aeaeb 100644 --- a/pi-test.ps1 +++ b/pi-test.ps1 @@ -31,6 +31,7 @@ if ($noEnv) { "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", + "HF_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", diff --git a/pi-test.sh b/pi-test.sh index f2119c5f..05aa684a 100755 --- a/pi-test.sh +++ b/pi-test.sh @@ -58,10 +58,4 @@ if [[ "$NO_ENV" == "true" ]]; then echo "Running without API keys..." fi -TSX_BIN="$SCRIPT_DIR/node_modules/.bin/tsx" -if [[ ! -x "$TSX_BIN" ]]; then - echo "tsx not found at $TSX_BIN. Run npm install from the repo root first." >&2 - exit 1 -fi - -"$TSX_BIN" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"} +"$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"} diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index a4057c93..066a6099 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -1,4 +1,60 @@ -import { complete, getModel } from "@earendil-works/pi-ai"; +import { complete, createAssistantMessageEventStream, getModel, getProviders, Type } from "@earendil-works/pi-ai"; +import { + Agent, + bashExecutionToText, + convertToLlm, + createCustomMessage, + FileError, + formatPromptTemplateInvocation, + formatSkillInvocation, + formatSkillsForSystemPrompt, + getOrThrow, + InMemorySessionRepo, + ok, + parseCommandArgs, + streamProxy, + toError, + truncateHead, +} from "@earendil-works/pi-agent-core"; +// Keep this entry browser-safe. It is bundled by scripts/check-browser-smoke.mjs +// to catch accidental Node-only runtime imports in browser-facing package exports. const model = getModel("google", "gemini-2.5-flash"); -console.log(model.id, typeof complete); +const schema = Type.Object({ prompt: Type.String() }); +const stream = createAssistantMessageEventStream(); + +const agent = new Agent({ initialState: { model } }); +agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 }); +const repo = new InMemorySessionRepo(); +const result = getOrThrow(ok({ value: 1 })); +const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z"); +const llmMessages = convertToLlm([customMessage]); +const skill = { name: "browser-safe", description: "Smoke test", content: "Use browser APIs.", filePath: "/skills/browser-safe/SKILL.md" }; + +console.log( + model.id, + getProviders().length, + typeof complete, + schema.type, + typeof stream.push, + agent.hasQueuedMessages(), + typeof repo.create, + result.value, + llmMessages.length, + bashExecutionToText({ + role: "bashExecution", + command: "echo ok", + output: "ok", + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: 0, + }), + formatSkillsForSystemPrompt([skill]).length, + formatSkillInvocation(skill).length, + formatPromptTemplateInvocation({ name: "example", content: "$1 $@" }, parseCommandArgs('one "two three"')), + truncateHead("a\nb", { maxLines: 1 }).content, + new FileError("not_found", "missing").code, + toError("boom").message, + typeof streamProxy, +); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 635aca64..c1b7b868 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -8,7 +8,7 @@ # # Options: # --skip-deps Skip installing cross-platform dependencies -# --platform Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64) +# --platform Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64) # # Output: # packages/coding-agent/binaries/ @@ -17,6 +17,7 @@ # pi-linux-x64.tar.gz # pi-linux-arm64.tar.gz # pi-windows-x64.zip +# pi-windows-arm64.zip set -euo pipefail @@ -45,18 +46,18 @@ done # Validate platform if specified if [[ -n "$PLATFORM" ]]; then case "$PLATFORM" in - darwin-arm64|darwin-x64|linux-x64|linux-arm64|windows-x64) + darwin-arm64|darwin-x64|linux-x64|linux-arm64|windows-x64|windows-arm64) ;; *) echo "Invalid platform: $PLATFORM" - echo "Valid platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64" + echo "Valid platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64" exit 1 ;; esac fi echo "==> Installing dependencies..." -npm ci +npm ci --ignore-scripts if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." @@ -64,17 +65,19 @@ if [[ "$SKIP_DEPS" == "false" ]]; then # We need all platform bindings for bun cross-compilation # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Install all in one command to avoid npm removing packages from previous installs - npm install --no-save --force \ - @mariozechner/clipboard-darwin-arm64@0.3.0 \ - @mariozechner/clipboard-darwin-x64@0.3.0 \ - @mariozechner/clipboard-linux-x64-gnu@0.3.0 \ - @mariozechner/clipboard-linux-arm64-gnu@0.3.0 \ - @mariozechner/clipboard-win32-x64-msvc@0.3.0 \ + npm install --no-save --package-lock=false --force --ignore-scripts \ + @mariozechner/clipboard-darwin-arm64@0.3.2 \ + @mariozechner/clipboard-darwin-x64@0.3.2 \ + @mariozechner/clipboard-linux-x64-gnu@0.3.2 \ + @mariozechner/clipboard-linux-arm64-gnu@0.3.2 \ + @mariozechner/clipboard-win32-x64-msvc@0.3.2 \ + @mariozechner/clipboard-win32-arm64-msvc@0.3.2 \ @img/sharp-darwin-arm64@0.34.5 \ @img/sharp-darwin-x64@0.34.5 \ @img/sharp-linux-x64@0.34.5 \ @img/sharp-linux-arm64@0.34.5 \ @img/sharp-win32-x64@0.34.5 \ + @img/sharp-win32-arm64@0.34.5 \ @img/sharp-libvips-darwin-arm64@1.2.4 \ @img/sharp-libvips-darwin-x64@1.2.4 \ @img/sharp-libvips-linux-x64@1.2.4 \ @@ -91,25 +94,21 @@ cd packages/coding-agent # Clean previous builds rm -rf binaries -mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64} +mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} # Determine which platforms to build if [[ -n "$PLATFORM" ]]; then PLATFORMS=("$PLATFORM") else - PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64) + PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64 windows-arm64) fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." - # Externalize koffi to avoid embedding all 18 platform .node files (~74MB) - # into every binary. Koffi is only used on Windows for VT input and the - # call site has a try/catch fallback. For Windows builds, we copy the - # appropriate .node file alongside the binary below. - if [[ "$platform" == "windows-x64" ]]; then - bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + if [[ "$platform" == windows-* ]]; then + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe else - bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi fi done @@ -129,12 +128,15 @@ for platform in "${PLATFORMS[@]}"; do cp -r docs binaries/$platform/ cp -r examples binaries/$platform/ - # Copy koffi native module for Windows (needed for VT input support) - if [[ "$platform" == "windows-x64" ]]; then - mkdir -p binaries/$platform/node_modules/koffi/build/koffi/win32_x64 - cp ../../node_modules/koffi/index.js binaries/$platform/node_modules/koffi/ - cp ../../node_modules/koffi/package.json binaries/$platform/node_modules/koffi/ - cp ../../node_modules/koffi/build/koffi/win32_x64/koffi.node binaries/$platform/node_modules/koffi/build/koffi/win32_x64/ + # Copy Windows VT input native helper next to compiled Windows binaries. + if [[ "$platform" == windows-* ]]; then + if [[ "$platform" == "windows-arm64" ]]; then + win32_arch_dir="win32-arm64" + else + win32_arch_dir="win32-x64" + fi + mkdir -p binaries/$platform/native/win32/prebuilds/$win32_arch_dir + cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node binaries/$platform/native/win32/prebuilds/$win32_arch_dir/ fi done @@ -142,7 +144,7 @@ done cd binaries for platform in "${PLATFORMS[@]}"; do - if [[ "$platform" == "windows-x64" ]]; then + if [[ "$platform" == windows-* ]]; then # Windows (zip) echo "Creating pi-$platform.zip..." (cd $platform && zip -r ../pi-$platform.zip .) @@ -157,7 +159,7 @@ done echo "==> Extracting archives for testing..." for platform in "${PLATFORMS[@]}"; do rm -rf $platform - if [[ "$platform" == "windows-x64" ]]; then + if [[ "$platform" == windows-* ]]; then mkdir -p $platform && (cd $platform && unzip -q ../pi-$platform.zip) else tar -xzf pi-$platform.tar.gz && mv pi $platform @@ -171,5 +173,9 @@ ls -lh *.tar.gz *.zip 2>/dev/null || true echo "" echo "Extracted directories for testing:" for platform in "${PLATFORMS[@]}"; do - echo " binaries/$platform/pi" + if [[ "$platform" == windows-* ]]; then + echo " binaries/$platform/pi.exe" + else + echo " binaries/$platform/pi" + fi done diff --git a/scripts/check-lockfile-commit.mjs b/scripts/check-lockfile-commit.mjs new file mode 100644 index 00000000..abf78947 --- /dev/null +++ b/scripts/check-lockfile-commit.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +const allowValue = process.env.PI_ALLOW_LOCKFILE_CHANGE; +const allowed = allowValue === "1" || allowValue === "true" || allowValue === "yes"; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function readJsonFromGit(ref) { + try { + return JSON.parse(git(["show", ref])); + } catch { + return undefined; + } +} + +function packageNameFromLockPath(lockPath) { + const marker = "node_modules/"; + const index = lockPath.lastIndexOf(marker); + if (index === -1) return lockPath || ""; + const parts = lockPath.slice(index + marker.length).split("/"); + return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; +} + +function packageLabel(lockPath, entry) { + const name = entry?.name ?? packageNameFromLockPath(lockPath); + return entry?.version ? `${name}@${entry.version}` : name; +} + +function summarizeLockfileChange() { + const before = readJsonFromGit("HEAD:package-lock.json"); + const after = readJsonFromGit(":package-lock.json"); + if (!before?.packages || !after?.packages) return []; + + const changes = []; + const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]); + for (const lockPath of [...paths].sort()) { + if (!lockPath.includes("node_modules/")) continue; + const oldEntry = before.packages[lockPath]; + const newEntry = after.packages[lockPath]; + if (!oldEntry && newEntry) { + changes.push(`added ${packageLabel(lockPath, newEntry)}`); + } else if (oldEntry && !newEntry) { + changes.push(`removed ${packageLabel(lockPath, oldEntry)}`); + } else if (oldEntry?.version !== newEntry?.version) { + changes.push( + `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, + ); + } + } + return changes; +} + +const stagedFiles = git(["diff", "--cached", "--name-only"]) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + +if (!stagedFiles.includes("package-lock.json")) { + process.exit(0); +} + +if (allowed) { + console.error("package-lock.json is staged; PI_ALLOW_LOCKFILE_CHANGE is set, allowing commit."); + process.exit(0); +} + +console.error("package-lock.json is staged."); +console.error(""); +console.error("Review lockfile changes before committing:"); +console.error(" - confirm every new/updated package is intentional"); +console.error(" - confirm npm age gates were active for resolution"); +console.error(" - review any new lifecycle scripts in the dependency tree"); +console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed"); + +const changes = summarizeLockfileChange(); +if (changes.length > 0) { + console.error(""); + console.error("Detected package version changes:"); + for (const change of changes.slice(0, 40)) { + console.error(` - ${change}`); + } + if (changes.length > 40) { + console.error(` ... ${changes.length - 40} more`); + } +} + +console.error(""); +console.error("If this lockfile change is intentional, commit with:"); +console.error(" PI_ALLOW_LOCKFILE_CHANGE=1 git commit ..."); +process.exit(1); diff --git a/scripts/check-pinned-deps.mjs b/scripts/check-pinned-deps.mjs new file mode 100644 index 00000000..a2b38942 --- /dev/null +++ b/scripts/check-pinned-deps.mjs @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"]; +const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const ignoredDirectories = new Set([".git", "dist", "node_modules"]); +const packageJsonFiles = []; + +function collectPackageJsonFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectPackageJsonFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name === "package.json") { + packageJsonFiles.push(join(directory, entry.name)); + } + } +} + +function isInternalWorkspaceDependency(name) { + return name.startsWith("@earendil-works/pi-"); +} + +function isNonRegistrySpecifier(specifier) { + return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier); +} + +function getVersionSpecifier(specifier) { + if (!specifier.startsWith("npm:")) return specifier; + const aliasTarget = specifier.slice("npm:".length); + const versionSeparator = aliasTarget.lastIndexOf("@"); + if (versionSeparator <= 0) return specifier; + return aliasTarget.slice(versionSeparator + 1); +} + +const failures = []; + +collectPackageJsonFiles("."); + +for (const file of packageJsonFiles.sort()) { + const packageJson = JSON.parse(readFileSync(file, "utf8")); + + for (const section of dependencySections) { + const dependencies = packageJson[section]; + if (!dependencies) continue; + + for (const [name, specifier] of Object.entries(dependencies)) { + if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue; + if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue; + failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`); + } + } +} + +if (failures.length > 0) { + console.error("Direct external dependencies must use exact versions:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/check-ts-relative-imports.mjs b/scripts/check-ts-relative-imports.mjs new file mode 100644 index 00000000..bf21a5d0 --- /dev/null +++ b/scripts/check-ts-relative-imports.mjs @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]); +const files = []; + +function collectTypescriptFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectTypescriptFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + files.push(join(directory, entry.name)); + } + } +} + +function isRelativeJavaScriptSpecifier(specifier) { + return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier); +} + +function getImportTypeSpecifier(node) { + if (!ts.isLiteralTypeNode(node.argument)) return undefined; + if (!ts.isStringLiteralLike(node.argument.literal)) return undefined; + return node.argument.literal; +} + +const failures = []; + +collectTypescriptFiles("."); + +for (const file of files.sort()) { + const sourceText = readFileSync(file, "utf8"); + const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + + function checkSpecifier(node) { + if (!isRelativeJavaScriptSpecifier(node.text)) return; + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`); + } + + function visit(node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + checkSpecifier(node.arguments[0]); + } else if (ts.isImportTypeNode(node)) { + const specifier = getImportTypeSpecifier(node); + if (specifier) checkSpecifier(specifier); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +if (failures.length > 0) { + console.error("Relative .js imports are not allowed in non-declaration .ts files:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/cost.ts b/scripts/cost.ts index 2774b8d8..865fbed7 100755 --- a/scripts/cost.ts +++ b/scripts/cost.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env node import * as fs from "fs"; import * as path from "path"; diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs new file mode 100644 index 00000000..7bcf5681 --- /dev/null +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -0,0 +1,365 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const codingAgentDir = join(repoRoot, "packages/coding-agent"); +const rootLockfilePath = join(repoRoot, "package-lock.json"); +const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); +const internalPackagePrefix = "@earendil-works/pi-"; +const allowedInstallScriptPackages = new Map([ + ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], + ["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"], +]); + +const args = new Set(process.argv.slice(2)); +const checkOnly = args.has("--check"); + +for (const arg of args) { + if (arg !== "--check") { + console.error(`Unknown argument: ${arg}`); + process.exit(1); + } +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function packageDependencies(entry) { + return { + ...(entry.dependencies ?? {}), + ...(entry.optionalDependencies ?? {}), + }; +} + +function sortedObject(object) { + return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b))); +} + +function sortedPackageEntry(entry) { + const fieldOrder = [ + "name", + "version", + "resolved", + "integrity", + "license", + "dependencies", + "optionalDependencies", + "peerDependencies", + "peerDependenciesMeta", + "bin", + "engines", + "os", + "cpu", + "libc", + "optional", + "hasInstallScript", + "deprecated", + "funding", + ]; + const sorted = {}; + + for (const field of fieldOrder) { + if (entry[field] !== undefined) { + sorted[field] = entry[field]; + } + } + for (const [field, value] of Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) { + if (sorted[field] === undefined) { + sorted[field] = value; + } + } + return sorted; +} + +function copyLockEntry(entry) { + const copied = { ...entry }; + delete copied.dev; + delete copied.devOptional; + delete copied.extraneous; + delete copied.link; + return sortedPackageEntry(copied); +} + +function copyPackageJsonEntry(packageJson, options) { + const entry = options.includeName + ? { name: packageJson.name, version: packageJson.version } + : { version: packageJson.version }; + + for (const field of [ + "license", + "dependencies", + "optionalDependencies", + "peerDependencies", + "peerDependenciesMeta", + "bin", + "engines", + "os", + "cpu", + "libc", + ]) { + if (packageJson[field] !== undefined) { + entry[field] = packageJson[field]; + } + } + + return sortedPackageEntry(entry); +} + +function packageNameFromLockPath(lockPath) { + const marker = "node_modules/"; + const index = lockPath.lastIndexOf(marker); + if (index === -1) { + return undefined; + } + + const parts = lockPath.slice(index + marker.length).split("/"); + if (parts[0]?.startsWith("@")) { + return `${parts[0]}/${parts[1]}`; + } + return parts[0]; +} + +function registryTarballUrl(packageName, version) { + const tarballName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName; + return `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`; +} + +function getInternalWorkspaces(lockPackages) { + const workspaces = new Map(); + + for (const [lockPath, entry] of Object.entries(lockPackages)) { + if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) { + continue; + } + if (!entry.name.startsWith(internalPackagePrefix)) { + continue; + } + + workspaces.set(entry.name, { + lockPath, + packageJson: readJson(join(repoRoot, lockPath, "package.json")), + }); + } + + return workspaces; +} + +function resolveExternalDependency(lockPackages, packageName, fromLockPath) { + const candidateDirs = []; + let current = fromLockPath; + + while (current) { + candidateDirs.push(current); + const parent = posix.dirname(current); + if (parent === "." || parent === current) { + break; + } + current = parent; + } + candidateDirs.push(""); + + const tried = new Set(); + for (const directory of candidateDirs) { + const candidate = directory ? `${directory}/node_modules/${packageName}` : `node_modules/${packageName}`; + if (tried.has(candidate)) { + continue; + } + tried.add(candidate); + + const entry = lockPackages[candidate]; + if (entry && !entry.link) { + return candidate; + } + } + + const suffix = `node_modules/${packageName}`; + const matches = Object.entries(lockPackages) + .filter(([lockPath, entry]) => !entry.link && (lockPath === suffix || lockPath.endsWith(`/${suffix}`))) + .map(([lockPath]) => lockPath); + + if (matches.length === 1) { + return matches[0]; + } + + throw new Error( + `Cannot resolve ${packageName} from ${fromLockPath || "root"}. ` + + (matches.length > 1 ? `Matches: ${matches.join(", ")}` : "No matching lockfile entry found."), + ); +} + +function addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, name, workspace) { + const packageJson = workspace.packageJson; + const outputPath = `node_modules/${name}`; + const entry = copyPackageJsonEntry(packageJson, { includeName: false }); + entry.resolved = registryTarballUrl(name, packageJson.version); + + shrinkwrapPackages[outputPath] = sortedPackageEntry(entry); + addedPaths.add(outputPath); + + for (const dependencyName of Object.keys(packageDependencies(packageJson))) { + queue.push({ name: dependencyName, from: outputPath }); + } +} + +function addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, name, from) { + const lockPath = resolveExternalDependency(lockPackages, name, from); + if (addedPaths.has(lockPath)) { + return; + } + + const entry = lockPackages[lockPath]; + shrinkwrapPackages[lockPath] = copyLockEntry(entry); + addedPaths.add(lockPath); + + for (const dependencyName of Object.keys(packageDependencies(entry))) { + queue.push({ name: dependencyName, from: lockPath }); + } +} + +function validateShrinkwrap(shrinkwrap, internalNames) { + const errors = []; + const includedPaths = new Set(Object.keys(shrinkwrap.packages)); + const includedPackageNames = new Set(); + const seenAllowedInstallScriptPackages = new Set(); + + for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) { + const packageName = packageNameFromLockPath(lockPath); + if (packageName) { + includedPackageNames.add(packageName); + } + if (entry.link) { + errors.push(`${lockPath} is a link entry`); + } + if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) { + errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`); + } + if (entry.hasInstallScript) { + if (!packageName || !entry.version) { + errors.push(`${lockPath || "root"} has install scripts but no package name/version`); + } else { + const packageId = `${packageName}@${entry.version}`; + if (allowedInstallScriptPackages.has(packageId)) { + seenAllowedInstallScriptPackages.add(packageId); + } else { + errors.push( + `${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`, + ); + } + } + } + } + + for (const packageId of allowedInstallScriptPackages.keys()) { + if (!seenAllowedInstallScriptPackages.has(packageId)) { + errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`); + } + } + + for (const name of internalNames) { + if (!includedPackageNames.has(name)) { + errors.push(`internal dependency ${name} is missing`); + } + } + + for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) { + for (const dependencyName of Object.keys(packageDependencies(entry))) { + const dependencyIncluded = [...includedPaths].some( + (candidate) => candidate === `node_modules/${dependencyName}` || candidate.endsWith(`/node_modules/${dependencyName}`), + ); + if (!dependencyIncluded) { + errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`); + } + } + } + + const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length; + if (platformPackageCount === 0) { + errors.push("no platform-specific optional dependency entries found"); + } + + if (errors.length > 0) { + throw new Error(`Generated shrinkwrap failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`); + } +} + +function generateShrinkwrap() { + const rootLock = readJson(rootLockfilePath); + if (rootLock.lockfileVersion !== 3 || !rootLock.packages) { + throw new Error("package-lock.json must be lockfileVersion 3 and contain a packages map"); + } + + const lockPackages = rootLock.packages; + const codingAgentPackage = readJson(join(codingAgentDir, "package.json")); + const internalWorkspaces = getInternalWorkspaces(lockPackages); + const shrinkwrapPackages = { + "": copyPackageJsonEntry(codingAgentPackage, { includeName: true }), + }; + const addedPaths = new Set([""]); + const internalNames = new Set(); + const queue = Object.keys(packageDependencies(codingAgentPackage)).map((name) => ({ name, from: "" })); + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) { + break; + } + + const workspace = internalWorkspaces.get(item.name); + if (workspace) { + const outputPath = `node_modules/${item.name}`; + internalNames.add(item.name); + if (!addedPaths.has(outputPath)) { + addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, item.name, workspace); + } + continue; + } + + addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, item.name, item.from); + } + + const shrinkwrap = { + name: codingAgentPackage.name, + version: codingAgentPackage.version, + lockfileVersion: 3, + requires: true, + packages: sortedObject(shrinkwrapPackages), + }; + + validateShrinkwrap(shrinkwrap, internalNames); + return shrinkwrap; +} + +try { + const shrinkwrap = generateShrinkwrap(); + const content = `${JSON.stringify(shrinkwrap, null, "\t")}\n`; + + if (checkOnly) { + if (!existsSync(shrinkwrapPath)) { + console.error("packages/coding-agent/npm-shrinkwrap.json is missing."); + console.error("Run: npm run shrinkwrap:coding-agent"); + process.exit(1); + } + const current = readFileSync(shrinkwrapPath, "utf8"); + if (current !== content) { + console.error("packages/coding-agent/npm-shrinkwrap.json is out of date."); + console.error("Run: npm run shrinkwrap:coding-agent"); + process.exit(1); + } + console.log("packages/coding-agent/npm-shrinkwrap.json is up to date."); + } else { + writeFileSync(shrinkwrapPath, content); + const packageCount = Object.keys(shrinkwrap.packages).length - 1; + const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length; + console.log( + `Wrote packages/coding-agent/npm-shrinkwrap.json (${packageCount} packages, ${platformPackageCount} platform-specific).`, + ); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs new file mode 100644 index 00000000..782318bd --- /dev/null +++ b/scripts/local-release.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node + +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const packages = [ + { directory: "packages/ai", name: "@earendil-works/pi-ai" }, + { directory: "packages/tui", name: "@earendil-works/pi-tui" }, + { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, +]; + +function printUsage() { + console.log(`Usage: node scripts/local-release.mjs [options] + +Builds and packs the publishable packages, then installs the tarballs into an +isolated directory outside the repository for local release testing. + +Options: + --out Output directory. Defaults to a new directory under ${tmpdir()} + --force Remove --out first if it already exists + --skip-check Do not run npm run check before building + --skip-install Only create tarballs; do not create isolated installs + --skip-bun-install Do not create the isolated Bun install + --help Show this help +`); +} + +function parseArgs() { + const options = { force: false, outDir: undefined, skipBunInstall: false, skipCheck: false, skipInstall: false }; + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help") { + printUsage(); + process.exit(0); + } + if (arg === "--force") { + options.force = true; + continue; + } + if (arg === "--skip-check") { + options.skipCheck = true; + continue; + } + if (arg === "--skip-install") { + options.skipInstall = true; + continue; + } + if (arg === "--skip-bun-install") { + options.skipBunInstall = true; + continue; + } + if (arg === "--out") { + const value = args[++i]; + if (!value) { + throw new Error("--out requires a directory"); + } + options.outDir = value; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + return options; +} + +function run(command, args, options = {}) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + shell: process.platform === "win32", + stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit", + }); + + if (result.status !== 0) { + throw new Error(`Command failed: ${[command, ...args].join(" ")}`); + } + + return result.stdout ?? ""; +} + +function readPackageJson(directory) { + return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); +} + +function commandExists(command) { + return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0; +} + +function isInsidePath(child, parent) { + const relativePath = relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function prepareOutputDirectory(options, repoRoot) { + if (!options.outDir) { + return mkdtempSync(join(tmpdir(), "pi-local-release-")); + } + + const outDir = resolve(options.outDir); + + if (isInsidePath(outDir, repoRoot)) { + throw new Error(`Output directory must be outside the repository: ${outDir}`); + } + + if (existsSync(outDir)) { + if (!options.force) { + throw new Error(`Output directory already exists. Use --force to replace it: ${outDir}`); + } + rmSync(outDir, { force: true, recursive: true }); + } + + mkdirSync(outDir, { recursive: true }); + return outDir; +} + +function fileSpecifier(fromDirectory, file) { + const relativePath = relative(fromDirectory, file).replaceAll("\\", "/"); + return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`; +} + +function currentBinaryPlatform() { + if (process.platform === "win32") return process.arch === "arm64" ? "windows-arm64" : "windows-x64"; + if (process.platform === "darwin") return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + if (process.platform === "linux") return process.arch === "arm64" ? "linux-arm64" : "linux-x64"; + throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`); +} + +function copyBinaryAssets(targetDirectory) { + const codingAgentDirectory = join(repoRoot, "packages", "coding-agent"); + const distDirectory = join(codingAgentDirectory, "dist"); + cpSync(join(codingAgentDirectory, "package.json"), join(targetDirectory, "package.json")); + cpSync(join(codingAgentDirectory, "README.md"), join(targetDirectory, "README.md")); + cpSync(join(codingAgentDirectory, "CHANGELOG.md"), join(targetDirectory, "CHANGELOG.md")); + cpSync(join(repoRoot, "node_modules", "@silvia-odwyer", "photon-node", "photon_rs_bg.wasm"), join(targetDirectory, "photon_rs_bg.wasm")); + cpSync(join(distDirectory, "modes", "interactive", "theme"), join(targetDirectory, "theme"), { recursive: true }); + cpSync(join(distDirectory, "modes", "interactive", "assets"), join(targetDirectory, "assets"), { recursive: true }); + cpSync(join(distDirectory, "core", "export-html"), join(targetDirectory, "export-html"), { recursive: true }); + cpSync(join(codingAgentDirectory, "docs"), join(targetDirectory, "docs"), { recursive: true }); + cpSync(join(codingAgentDirectory, "examples"), join(targetDirectory, "examples"), { recursive: true }); +} + +function copyWindowsConsoleModeHelper(targetDirectory, platform) { + if (!platform.startsWith("windows-")) return; + const win32Arch = platform === "windows-arm64" ? "win32-arm64" : "win32-x64"; + const relativeNativePath = join("native", "win32", "prebuilds", win32Arch); + mkdirSync(join(targetDirectory, relativeNativePath), { recursive: true }); + cpSync( + join(repoRoot, "packages", "tui", "native", "win32", "prebuilds", win32Arch, "win32-console-mode.node"), + join(targetDirectory, relativeNativePath, "win32-console-mode.node"), + ); +} + +function buildBunBinaryRelease(targetDirectory, archiveDirectory) { + if (!commandExists("bun")) { + throw new Error("Bun is required for the local binary release build."); + } + const platform = currentBinaryPlatform(); + mkdirSync(targetDirectory, { recursive: true }); + const executableName = platform.startsWith("windows-") ? "pi.exe" : "pi"; + const executablePath = join(targetDirectory, executableName); + const target = `bun-${platform}`; + run("bun", ["build", "--compile", `--target=${target}`, join(repoRoot, "packages", "coding-agent", "dist", "bun", "cli.js"), "--outfile", executablePath]); + copyBinaryAssets(targetDirectory); + copyWindowsConsoleModeHelper(targetDirectory, platform); + if (platform.startsWith("windows-")) { + run("powershell", ["-NoProfile", "-Command", `Compress-Archive -Path '${join(targetDirectory, "*").replaceAll("'", "''")}' -DestinationPath '${join(archiveDirectory, `pi-${platform}.zip`).replaceAll("'", "''")}' -Force`]); + } else { + run("tar", ["-czf", join(archiveDirectory, `pi-${platform}.tar.gz`), "-C", targetDirectory, "."]); + } + return platform; +} + +function createPiShim(installDirectory) { + const binDirectory = join(installDirectory, "node_modules", ".bin"); + if (process.platform === "win32") { + if (existsSync(join(binDirectory, "pi.cmd"))) { + writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.cmd" %*\r\n'); + writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.ps1" @args\n'); + return; + } + writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.exe" %*\r\n'); + writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.exe" @args\n'); + return; + } + symlinkSync(join("node_modules", ".bin", "pi"), join(installDirectory, "pi")); +} + +function packPackage(pkg, tarballDirectory) { + const packageJson = readPackageJson(pkg.directory); + if (packageJson.name !== pkg.name) { + throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); + } + + const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], { + capture: true, + cwd: pkg.directory, + }); + const packed = JSON.parse(output)[0]; + return join(tarballDirectory, packed.filename); +} + +const options = parseArgs(); +const repoRoot = process.cwd(); +const rootPackageJson = readPackageJson(repoRoot); + +if (rootPackageJson.name !== "pi-monorepo") { + throw new Error("Run this script from the repository root"); +} + +const outDir = prepareOutputDirectory(options, repoRoot); +const tarballDirectory = join(outDir, "tarballs"); +const nodeInstallDirectory = join(outDir, "node"); +const bunInstallDirectory = join(outDir, "bun-install"); +const binaryDirectory = join(outDir, "bun"); +mkdirSync(tarballDirectory, { recursive: true }); + +if (!options.skipCheck) { + run("npm", ["run", "check"], { cwd: repoRoot }); +} + +for (const pkg of packages) { + run("npm", ["run", "clean"], { cwd: pkg.directory }); + run("npm", ["run", "build"], { cwd: pkg.directory }); +} + +const tarballs = new Map(); +for (const pkg of packages) { + const tarball = packPackage(pkg, tarballDirectory); + tarballs.set(pkg.name, tarball); +} + +let binaryPlatform; +if (!options.skipInstall) { + binaryPlatform = buildBunBinaryRelease(binaryDirectory, outDir); + + mkdirSync(nodeInstallDirectory, { recursive: true }); + const dependencies = Object.fromEntries( + packages.map((pkg) => [pkg.name, fileSpecifier(nodeInstallDirectory, tarballs.get(pkg.name))]), + ); + const installPackageJson = `${JSON.stringify({ private: true, dependencies, overrides: dependencies }, undefined, "\t")}\n`; + writeFileSync(join(nodeInstallDirectory, "package.json"), installPackageJson); + + run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: nodeInstallDirectory }); + createPiShim(nodeInstallDirectory); + + if (!options.skipBunInstall) { + if (!commandExists("bun")) { + throw new Error("Bun is required for the isolated Bun install. Use --skip-bun-install to skip it."); + } + mkdirSync(bunInstallDirectory, { recursive: true }); + const bunDependencies = Object.fromEntries( + packages.map((pkg) => [pkg.name, fileSpecifier(bunInstallDirectory, tarballs.get(pkg.name))]), + ); + writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies, overrides: bunDependencies }, undefined, "\t")}\n`); + run("bun", ["install", "--production", "--ignore-scripts"], { cwd: bunInstallDirectory }); + createPiShim(bunInstallDirectory); + } +} + +console.log("\nLocal release artifacts created:"); +console.log(` ${outDir}`); +console.log("\nTarballs:"); +for (const tarball of tarballs.values()) { + console.log(` ${tarball}`); +} + +if (!options.skipInstall) { + console.log("\nLocal Bun binary release:"); + console.log(` ${binaryDirectory}`); + console.log(` ${join(outDir, `pi-${binaryPlatform}.${String(binaryPlatform).startsWith("windows-") ? "zip" : "tar.gz"}`)}`); + console.log("\nRun the local Bun binary release from outside the repository:"); + console.log(` ${join(binaryDirectory, String(binaryPlatform).startsWith("windows-") ? "pi.exe" : "pi")} --help`); + + console.log("\nIsolated npm install:"); + console.log(` ${nodeInstallDirectory}`); + console.log("\nRun the locally packed npm CLI from outside the repository:"); + console.log(` ${join(nodeInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`); + + if (!options.skipBunInstall) { + console.log("\nIsolated Bun package install:"); + console.log(` ${bunInstallDirectory}`); + console.log("\nRun the locally packed Bun package CLI from outside the repository:"); + console.log(` ${join(bunInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`); + } +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 73ae06f9..59b51509 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,10 +10,11 @@ * 1. Check for uncommitted changes * 2. Bump version via npm run version:xxx or set an explicit version * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Commit and tag - * 5. Publish to npm - * 6. Add new [Unreleased] section to changelogs - * 7. Commit + * 4. Generate the coding-agent npm-shrinkwrap.json + * 5. Commit and tag + * 6. Publish to npm + * 7. Add new [Unreleased] section to changelogs + * 8. Commit */ import { execSync } from "child_process"; @@ -90,9 +91,7 @@ function bumpOrSetVersion(target) { } console.log(`Setting explicit version (${target})...`); - run( - `npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npx shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install`, - ); + run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`); return getVersion(); } @@ -164,30 +163,35 @@ console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Commit and tag +// 4. Generate publish shrinkwrap +console.log("Generating coding-agent shrinkwrap..."); +run("npm run shrinkwrap:coding-agent"); +console.log(); + +// 5. Commit and tag console.log("Committing and tagging..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 5. Publish +// 6. Publish console.log("Publishing to npm..."); run("npm run publish"); console.log(); -// 6. Add new [Unreleased] sections +// 7. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); console.log(); -// 7. Commit +// 8. Commit console.log("Committing changelog updates..."); stageChangedFiles(); run(`git commit -m "Add [Unreleased] section for next cycle"`); console.log(); -// 8. Push +// 9. Push console.log("Pushing to remote..."); run("git push origin main"); run(`git push origin v${version}`); diff --git a/scripts/session-transcripts.ts b/scripts/session-transcripts.ts index e10dbff9..4dca6a1d 100644 --- a/scripts/session-transcripts.ts +++ b/scripts/session-transcripts.ts @@ -1,9 +1,9 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env node /** * Extracts session transcripts for a given cwd, splits into context-sized files, * optionally spawns subagents to analyze patterns. * - * Usage: npx tsx scripts/session-transcripts.ts [--analyze] [--output ] [cwd] + * Usage: node scripts/session-transcripts.ts [--analyze] [--output ] [cwd] * --analyze Spawn pi subagents to analyze each transcript file * --output Output directory for transcript files (defaults to ./session-transcripts) * cwd Working directory to extract sessions for (defaults to current) @@ -14,7 +14,7 @@ import { spawn } from "child_process"; import { createInterface } from "node:readline"; import { homedir } from "os"; import { join, resolve } from "path"; -import { parseSessionEntries, type SessionMessageEntry } from "../packages/coding-agent/src/core/session-manager.js"; +import { parseSessionEntries, type SessionMessageEntry } from "../packages/coding-agent/src/core/session-manager.ts"; import chalk from "chalk"; const MAX_CHARS_PER_FILE = 100_000; // ~20k tokens, leaving room for prompt + analysis + output diff --git a/scripts/stats.ts b/scripts/stats.ts index 15c353e3..653f4bfb 100644 --- a/scripts/stats.ts +++ b/scripts/stats.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env node import { existsSync, readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; diff --git a/scripts/tool-stats.ts b/scripts/tool-stats.ts index 8d39b868..78a69418 100755 --- a/scripts/tool-stats.ts +++ b/scripts/tool-stats.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env node import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; diff --git a/scripts/update-source-imports-to-ts.sh b/scripts/update-source-imports-to-ts.sh new file mode 100644 index 00000000..6030e41d --- /dev/null +++ b/scripts/update-source-imports-to-ts.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rewrites relative source import specifiers in package source directories from .js to .ts. +# TypeScript's rewriteRelativeImportExtensions option rewrites these back to .js in emitted output. +find packages -mindepth 2 -maxdepth 2 -type d -name src -print0 | + while IFS= read -r -d '' src_dir; do + find "$src_dir" -type f -name '*.ts' -print0 + done | + xargs -0 perl -0pi -e 's/(\b(?:from|import)\b\s*\(?\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\)?)/$1$2.ts$3/g; s/(\bdeclare\s+module\s+["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27])/$1$2.ts$3/g; s/(\bimportNodeOnlyProvider\(\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\))/$1$2.ts$3/g' diff --git a/test.sh b/test.sh index 67aaaeaf..5f231845 100755 --- a/test.sh +++ b/test.sh @@ -26,7 +26,10 @@ export PI_NO_LOCAL_LLM=1 unset ANTHROPIC_API_KEY unset ANTHROPIC_OAUTH_TOKEN unset OPENAI_API_KEY +unset AZURE_OPENAI_API_KEY +unset DEEPSEEK_API_KEY unset GEMINI_API_KEY +unset GOOGLE_CLOUD_API_KEY unset GROQ_API_KEY unset CEREBRAS_API_KEY unset XAI_API_KEY @@ -35,10 +38,20 @@ unset ZAI_API_KEY unset MISTRAL_API_KEY unset MINIMAX_API_KEY unset MINIMAX_CN_API_KEY +unset MOONSHOT_API_KEY unset KIMI_API_KEY unset HF_TOKEN +unset FIREWORKS_API_KEY +unset TOGETHER_API_KEY unset AI_GATEWAY_API_KEY unset OPENCODE_API_KEY +unset CLOUDFLARE_API_KEY +unset CLOUDFLARE_ACCOUNT_ID +unset CLOUDFLARE_GATEWAY_ID +unset XIAOMI_API_KEY +unset XIAOMI_TOKEN_PLAN_CN_API_KEY +unset XIAOMI_TOKEN_PLAN_AMS_API_KEY +unset XIAOMI_TOKEN_PLAN_SGP_API_KEY unset COPILOT_GITHUB_TOKEN unset GH_TOKEN unset GITHUB_TOKEN @@ -57,7 +70,6 @@ unset AWS_CONTAINER_CREDENTIALS_RELATIVE_URI unset AWS_CONTAINER_CREDENTIALS_FULL_URI unset AWS_WEB_IDENTITY_TOKEN_FILE unset BEDROCK_EXTENSIVE_MODEL_TEST -unset FIREWORKS_API_KEY echo "Running tests without API keys..." npm test diff --git a/tsconfig.base.json b/tsconfig.base.json index bd9a33a4..57e97d6e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,6 +4,7 @@ "module": "Node16", "lib": ["ES2022"], "strict": true, + "erasableSyntaxOnly": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, @@ -14,7 +15,8 @@ "inlineSourceMap": false, "moduleResolution": "Node16", "resolveJsonModule": true, - "allowImportingTsExtensions": false, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "useDefineForClassFields": false, diff --git a/tsconfig.json b/tsconfig.json index ff748f9e..4dfbf3d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,12 +16,10 @@ "typebox": ["./node_modules/typebox"], "@earendil-works/pi-tui": ["./packages/tui/src/index.ts"], "@earendil-works/pi-tui/*": ["./packages/tui/src/*"], - "@earendil-works/pi-web-ui": ["./packages/web-ui/src/index.ts"], - "@earendil-works/pi-web-ui/*": ["./packages/web-ui/src/*"], "@earendil-works/pi-agent-old": ["./packages/agent-old/src/index.ts"], "@earendil-works/pi-agent-old/*": ["./packages/agent-old/src/*"] } }, "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/coding-agent/examples/**/*"], - "exclude": ["packages/web-ui/**/*", "**/dist/**"] + "exclude": ["**/dist/**"] }