Compare commits
19
Commits
b7006d1ad8
...
v1.24.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2eda3bb4 | ||
|
|
e6718fa118 | ||
|
|
f98030f28a | ||
|
|
93e5464370 | ||
|
|
f5b04214cf | ||
|
|
008f737e1f | ||
|
|
2100e7ab54 | ||
|
|
c53fdfa7c9 | ||
|
|
5912a35bbb | ||
|
|
d86d6aeef2 | ||
|
|
30cc579773 | ||
|
|
a54f1644f5 | ||
|
|
23e68dc979 | ||
|
|
8c99c6b1ca | ||
|
|
9b28a829e5 | ||
|
|
b5ebd07fa8 | ||
|
|
7e6ed34f0d | ||
|
|
34322e492a | ||
|
|
3e260e6077 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
name: Build & Deploy (Nix)
|
name: Build & Deploy (Bun)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -25,26 +25,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Setup Bun
|
||||||
uses: DeterminateSystems/nix-installer-action@v22
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
determinate: false
|
bun-version: 1.3.14
|
||||||
extra-conf: |
|
|
||||||
sandbox = false
|
|
||||||
accept-flake-config = true
|
|
||||||
|
|
||||||
- name: Cache Nix
|
- name: Install deps
|
||||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build zesdex
|
- name: Type-check
|
||||||
id: build
|
run: bun run check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
run: |
|
run: |
|
||||||
nix build .#default --impure --option sandbox false --print-build-logs
|
bun run build
|
||||||
STORE_PATH=$(readlink result)
|
test -x dist/zesdex && echo "binary OK ($(stat -c%s dist/zesdex) bytes)"
|
||||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Build OK: $STORE_PATH"
|
|
||||||
|
|
||||||
- name: Setup SSH key
|
- name: Setup SSH key
|
||||||
|
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||||
env:
|
env:
|
||||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
@@ -56,6 +57,7 @@ jobs:
|
|||||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
- name: Deploy zesdex to VPS
|
- name: Deploy zesdex to VPS
|
||||||
|
if: env.VPS_HOST != '' && env.VPS_HOST != 'null'
|
||||||
run: |
|
run: |
|
||||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
scp dist/zesdex "$VPS_USER@$VPS_HOST:/usr/local/bin/zesdex"
|
||||||
ssh "$VPS_USER@$VPS_HOST" "nix copy --to file:///nix/store $STORE_PATH && nix-env --install --force $STORE_PATH --profile /nix/var/nix/profiles/zesdex && systemctl restart zesdex"
|
ssh "$VPS_USER@$VPS_HOST" "chmod +x /usr/local/bin/zesdex && systemctl restart zesdex"
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Publish to FlakeHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flakehub-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: DeterminateSystems/determinate-nix-action@main
|
|
||||||
- uses: DeterminateSystems/flakehub-push@main
|
|
||||||
with:
|
|
||||||
visibility: public
|
|
||||||
rolling: true
|
|
||||||
@@ -40,16 +40,18 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install semantic-release
|
- name: Install semantic-release (isolated prefix — do NOT touch repo package.json)
|
||||||
run: |
|
run: |
|
||||||
npm install -D \
|
mkdir -p /tmp/sr-tools
|
||||||
|
npm install --prefix /tmp/sr-tools \
|
||||||
semantic-release \
|
semantic-release \
|
||||||
@semantic-release/exec \
|
@semantic-release/exec \
|
||||||
@semantic-release/git \
|
@semantic-release/git \
|
||||||
@semantic-release/changelog \
|
@semantic-release/changelog \
|
||||||
@semantic-release/github
|
@semantic-release/github
|
||||||
|
echo "/tmp/sr-tools/node_modules/.bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Run semantic-release
|
- name: Run semantic-release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npx semantic-release
|
run: semantic-release
|
||||||
@@ -1,38 +1,55 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Kilo when working with code in the zesdex repository.
|
This file provides guidance to AI agents (Kilo / Hermes) working in the zesdex repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Zesdex is an **AI-native autonomous coding agent**, implemented in **TypeScript / Bun**.
|
||||||
|
It was rebuilt to be a **pure CLI/TUI tool** — there are **no network server interfaces**
|
||||||
|
(REST API / WebSocket / gRPC / web / daemon were all removed). The only two entry points:
|
||||||
|
|
||||||
|
- `zesdex` — interactive **OpenTUI** (primary interface, wired to the real agent).
|
||||||
|
- `zesdex --headless "<prompt>"` — one-shot non-interactive turn (headless mode).
|
||||||
|
|
||||||
## Best Practice Conventions
|
## Best Practice Conventions
|
||||||
|
|
||||||
Zesdex follows Kana Engineering Best Practices:
|
1. **Clean Architecture** — strict `domain` / `application` / `infrastructure` layering
|
||||||
|
under `src/`, with thin `interfaces/` for the TUI + CLI.
|
||||||
|
- `domain` has zero I/O and zero framework dependencies.
|
||||||
|
- `application` depends only on `domain` (ports + turn service).
|
||||||
|
- `infrastructure` implements domain ports (LLM client, tool executor, file repos).
|
||||||
|
- DTOs/value objects cross layer boundaries, not entities.
|
||||||
|
|
||||||
1. **Clean Architecture** — Strict domain/application/infrastructure/presentation layering.
|
2. **Clean Code** — functions under ~40 lines, one level of abstraction per function,
|
||||||
- Domain has ZERO framework dependencies.
|
descriptive names, no flag arguments, no commented-out dead code.
|
||||||
- Application depends only on domain.
|
|
||||||
- Infrastructure implements domain traits.
|
|
||||||
- DTOs cross layer boundaries, NOT entities.
|
|
||||||
|
|
||||||
2. **Clean Code** — Functions under ~40 lines, one level of abstraction per function, descriptive names, no flag arguments, no commented-out code.
|
3. **Documentation** — every exported `function`, `class`, `interface`, and `type`
|
||||||
|
needs a `/** */` doc comment explaining what, flow, why, and return value.
|
||||||
|
|
||||||
3. **Documentation** — Every pub fn, struct, enum, and trait needs a doc comment (///) explaining what, flow, why, and return value.
|
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia:
|
||||||
|
`feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
||||||
|
|
||||||
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia: `feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
5. **Error Handling** — throw `Error` with clear messages; the turn loop wraps LLM
|
||||||
|
errors and recovers. Log with `console.*` (redirected to `~/.local/share/zesdex/zesdex.log`).
|
||||||
|
|
||||||
5. **Error Handling** — `anyhow::Result` and `anyhow::bail!` throughout. Log with `tracing` (never stderr).
|
6. **Testing** — Bun tests (`bun test`) using `describe/test/expect`. Tests are
|
||||||
|
F.I.R.S.T. Logic-pure modules (TUI state/action/command/controller) are unit-tested.
|
||||||
|
|
||||||
6. **Testing** — `#[cfg(test)] mod tests` blocks inline in production files. Tests are F.I.R.S.T. (Fast, Independent, Repeatable, Self-validating, Timely).
|
7. **No Compiler Bypasses** — no `@ts-ignore` / `any` where a real type exists.
|
||||||
|
|
||||||
7. **No Compiler Bypasses** — Never use `#[allow(...)]`, `#[expect(...)]`, or `#[allow(dead_code)]`. Fix the underlying code.
|
8. **Boy Scout Rule** — leave every module cleaner than you found it.
|
||||||
|
|
||||||
8. **Boy Scout Rule** — Leave every module cleaner than you found it.
|
## Stack
|
||||||
|
|
||||||
## Available Agents
|
- Bun 1.3.14 (packageManager pinned in `package.json`).
|
||||||
|
- TypeScript strict mode, `tsconfig.json` uses `paths` aliases (`@zesdex/*`) →
|
||||||
|
`src/*`. `bun build ... --compile` bundles into a single `dist/zesdex` binary.
|
||||||
|
|
||||||
- `@rust-engineer` — Rust clean architecture specialist (subagent).
|
## Commands
|
||||||
- `@code-reviewer` — Code review specialist (subagent).
|
|
||||||
|
|
||||||
## Available Commands
|
- `bun run check` — `tsc --noEmit` typecheck.
|
||||||
|
- `bun run test` — run all Bun tests.
|
||||||
- `/check` — Run cargo check, clippy, and tests.
|
- `bun run build` — compile `dist/zesdex` binary.
|
||||||
- `/audit` — Code quality audit against clean-architecture best practices.
|
- `bun run tui` — run the interactive TUI.
|
||||||
- `/doc` — Generate or update doc comments.
|
- `bun run headless "<prompt>"` — one-shot agent turn.
|
||||||
|
- `bun run bootstrap` — seed default settings/app_config.
|
||||||
@@ -1,3 +1,58 @@
|
|||||||
|
# [1.24.0](https://github.com/asepharyana/zesdex/compare/v1.23.2...v1.24.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tui:** add new flow helpers for bash exit code handling and verification prompts ([f98030f](https://github.com/asepharyana/zesdex/commit/f98030f28a20e3d9ba807bb98ddbf142511a3964))
|
||||||
|
* **tui:** enhance autocomplete functionality and UI interactions ([008f737](https://github.com/asepharyana/zesdex/commit/008f737e1f5d0eef6a07ebe0fff2c9730bd44eb4))
|
||||||
|
* **tui:** enhance command execution on autocomplete and improve UI responsiveness ([f5b0421](https://github.com/asepharyana/zesdex/commit/f5b04214cf385ba2e4a02f6ce202436ffab23c35))
|
||||||
|
* **tui:** enhance model selection and improve UI components ([c53fdfa](https://github.com/asepharyana/zesdex/commit/c53fdfa7c99887e8f3dfe68e7f1b2e766de039b1))
|
||||||
|
* **tui:** implement interactive menu system for model and config selection ([2100e7a](https://github.com/asepharyana/zesdex/commit/2100e7ab54460f28b67c3bb07855eef8f04eb9ec))
|
||||||
|
* **tui:** implement live model fetching and enhance model selection menus ([93e5464](https://github.com/asepharyana/zesdex/commit/93e54643703ce7f18286bd6a988e0fab18cc9e66))
|
||||||
|
* **tui:** implement message compaction and summarization logic for efficient conversation handling ([e6718fa](https://github.com/asepharyana/zesdex/commit/e6718fa11825cccc74a9d100755226b7dfefc106))
|
||||||
|
|
||||||
|
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
|
||||||
|
|
||||||
|
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** repair layout overflow + streaming dup; polish UI/state ([a54f164](https://github.com/asepharyana/zesdex/commit/a54f1644f5943399568194b4f129185a9d841f1d))
|
||||||
|
|
||||||
|
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auth:** perbaiki base64url encode JWT — string di-base64 dulu sebelum replace chars ([1b130e6](https://github.com/asepharyana/zesdex/commit/1b130e69b36603f894bcd45469b9feef28b18562))
|
||||||
|
* **ci:** Release workflow install semantic-release tanpa merusak package.json ([8c99c6b](https://github.com/asepharyana/zesdex/commit/8c99c6b1ca94e1d2b80dd59bb96eb24b7b3b2dfa))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root ([49e7932](https://github.com/asepharyana/zesdex/commit/49e79322b4aa1824467e1c871cf68809c1a424ee))
|
||||||
|
* **auth:** port 3f auth infrastructure — Argon2 passwords + HS256 JWT tokens ([7e8a160](https://github.com/asepharyana/zesdex/commit/7e8a160b0e918d51dc559f55fa19395368a4d84e))
|
||||||
|
* **auth:** tambah OAuth loopback server untuk capture authorization-code redirect ([fd58b46](https://github.com/asepharyana/zesdex/commit/fd58b4661fd0928dd461c74a5cb7bc7870ca9692))
|
||||||
|
* **cli:** port Fase 4 CLI — parser arg, mode dispatch, single-process composition root + REPL loop, bootstrap seed ([c582dc1](https://github.com/asepharyana/zesdex/commit/c582dc1461024d5037b8351a0df6b9b11e967782))
|
||||||
|
* **cli:** wire mode dispatch ke server interfaces — --api/--ws/--grpc/--web start server; add tsconfig paths ([02a5523](https://github.com/asepharyana/zesdex/commit/02a55235cfadb3e5daf3774a04f6fc6abe5da78d))
|
||||||
|
* **daemon:** port Fase 5 daemon — IPC agent-driver loop (Submit/Close) + streaming tokens; wire CLI --daemon/--attach ([14d3556](https://github.com/asepharyana/zesdex/commit/14d3556179685587e62873f81a57125037ac5e5a))
|
||||||
|
* **infra:** port tool system ke TypeScript (37 tools + registry + executor) ([82b51f7](https://github.com/asepharyana/zesdex/commit/82b51f7921e33e912f1244152df509522e44cae3))
|
||||||
|
* **ipc:** port 3g IPC + bgbash — framed Unix-socket server/client + background job registry, wire bash tools ([4b4730c](https://github.com/asepharyana/zesdex/commit/4b4730cff7ee2a5c43f8906d17282d95de84cf7a))
|
||||||
|
* **rewrite:** bootstrap monorepo Bun + port domain layer ke TypeScript ([07e84e4](https://github.com/asepharyana/zesdex/commit/07e84e43b386388f75f8293879e49dff1db461ab))
|
||||||
|
* **rewrite:** tambah application layer — port traits & use cases TypeScript ([03538a4](https://github.com/asepharyana/zesdex/commit/03538a455a93ff554abb45a1e54809c9579763fd))
|
||||||
|
* **rewrite:** tambah infrastructure — LLM client + full file persistence ([a2c7e5d](https://github.com/asepharyana/zesdex/commit/a2c7e5d44c784508b4fe735ec3434df2407f76de))
|
||||||
|
* **subagent:** port 3c subagent engine — run_agent loop + spawn/delegate/parallel ([a14a227](https://github.com/asepharyana/zesdex/commit/a14a2275ff90059874166aec8c03bd44e6c7efee))
|
||||||
|
* **tui:** port Fase 4 TUI logic layer — InputState/Action dispatcher/key controller/slash commands + lightweight ANSI renderer + run loop; 20 test ([8098133](https://github.com/asepharyana/zesdex/commit/8098133ba50135c7a5f9a063221ffdb860e064cb))
|
||||||
|
* **web): port Fase 5 static file server w/ traversal protection; feat(grpc:** port health stub ([1a6268b](https://github.com/asepharyana/zesdex/commit/1a6268bde58c67b9d3cbabce08306d53f0b3791a))
|
||||||
|
* **workflow:** port 3d workflow + hive-mind engine — parse, execute, cycle, synthesis, docs ([edc0076](https://github.com/asepharyana/zesdex/commit/edc007619b63d0d91fdad79a6d534c112b8a3981))
|
||||||
|
* **ws:** port Fase 5 WS server — Bun native WebSocket, ZESDEX_WS_TOKEN guard, prompt turn proxy + streaming token/done/error ([d4d423a](https://github.com/asepharyana/zesdex/commit/d4d423a4cae62562da4ca4f0231f6a4f90481473))
|
||||||
|
|
||||||
# [1.22.0](https://github.com/asepharyana/zesdex/compare/v1.21.2...v1.22.0) (2026-08-28)
|
# [1.22.0](https://github.com/asepharyana/zesdex/compare/v1.21.2...v1.22.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
> Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo
|
||||||
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
|
> TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits.
|
||||||
>
|
>
|
||||||
> **Status:** Fase 0–6 **semua selesai**. Rust source dihapus, build CI/Docker/release pakai Bun. Sisa: `flake.nix`/deploy infra (Nix), `settings.json` wire-shape check. 74 test hijau.
|
> **Status:** Fase 0–6 **semua selesai**. Rust source dihapus, build CI/Docker/release pakai Bun, `flake.lock` Rust/Nix dibersihkan, wire-shape lama terbaca. Base: `bun run check` bersih, 70 test hijau.
|
||||||
> Base: `bun run check` bersih, 74 test hijau.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,10 +180,10 @@ apps/
|
|||||||
- [x] Update CI workflow: `.github/workflows/{ci,release}.yml` → `bun install`/`bun test`/`bun run check`
|
- [x] Update CI workflow: `.github/workflows/{ci,release}.yml` → `bun install`/`bun test`/`bun run check`
|
||||||
- [x] Update `Dockerfile` → Bun (multi-stage, `--compile` single binary; d build verified)
|
- [x] Update `Dockerfile` → Bun (multi-stage, `--compile` single binary; d build verified)
|
||||||
- [x] Ganti/luruskan tool release (semantic-release) ke pipeline Bun (`release.yml` test job → bun; prepareCmd → `bun run check`)
|
- [x] Ganti/luruskan tool release (semantic-release) ke pipeline Bun (`release.yml` test job → bun; prepareCmd → `bun run check`)
|
||||||
- [ ] `flake.nix`/`shell.nix`/`deploy.yml`/`flakehub-publish-rolling.yml` → Nix build masih Cargo (tinggalkan — deploy infra, tinggalkan ke user/ops; build lokal `bun build --compile` sudah diverifikasi)
|
- [x] `flake.lock` dead (Rust/Nix era) dihapus — deploy infra 100% Bun (`deploy.yml` bun build --compile + scp binary)
|
||||||
- [x] Berkas jadi: hapus folder Rust yang sudah kosong dari `apps/` + Cargo.toml/lock + .cargo + lefthook
|
- [x] Berkas jadi: hapus folder Rust yang sudah kosong dari `apps/` + Cargo.toml/lock + .cargo + lefthook
|
||||||
- [x] Verifikasi akhir: `tsc --noEmit` exit 0, `bun test` 74 hijau, `bun run cli --help` jalan
|
- [x] Verifikasi akhir: `tsc --noEmit` exit 0, `bun test` 74 hijau, `bun run cli --help` jalan
|
||||||
- [ ] Baca ulang file lama (`settings.json`, `app_config.json`, `conversation.json`, memory `.md`) — pastikan wire-shape terbaca
|
- [x] Wire-shape lama terbaca — `settings.json` (Rust-era shape: api_keys/provider/model/review_*) dimuat true di headless + TUI; repos direct JSON parser. Verified: `--headless "1+1"` → "2", TUI header tunjuk `claude-opus-5`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
"name": "zesdex",
|
"name": "zesdex",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.0",
|
"@node-rs/argon2": "^2.2.0",
|
||||||
|
"@opentui/core": "^0.5.10",
|
||||||
|
"@opentui/react": "^0.5.10",
|
||||||
|
"react": "^19.2.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -42,14 +46,70 @@
|
|||||||
|
|
||||||
"@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cfcNjVqLpgOU/9l2mLOFduuXTxvGH5qoV38AhcOz9oQaC7GDmsFbAgcuizVNuOtpSyQr5HoQez3f2X2jYgGMsg=="],
|
"@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cfcNjVqLpgOU/9l2mLOFduuXTxvGH5qoV38AhcOz9oQaC7GDmsFbAgcuizVNuOtpSyQr5HoQez3f2X2jYgGMsg=="],
|
||||||
|
|
||||||
|
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentuiLine truncated
|
||||||
|
|
||||||
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||||
|
|
||||||
|
"@opentui/react": ["@opentui/react@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-76UFqe/ML121Skjche2+mGX56GKTlwG3ftrOi3b7WGLvjGucPqgEDJ0+CmaC/Yk85O8ui/ZTZJ2tay/8foR4FQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
|
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
|
||||||
|
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
|
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||||
|
|
||||||
|
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
pkgs.rustPlatform.buildRustPackage {
|
|
||||||
pname = "zesdex";
|
|
||||||
version = "1.18.2";
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
cargoLock = {
|
|
||||||
lockFile = ./Cargo.lock;
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoBuildFlags = [ "-p" "zesdex-gateway" "--bin" "zesdex" ];
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config ];
|
|
||||||
buildInputs = with pkgs; [ openssl sqlite ];
|
|
||||||
|
|
||||||
doCheck = false;
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "AI-native coding agent with workflow orchestration";
|
|
||||||
homepage = "https://github.com/asepharyana/zesdex";
|
|
||||||
license = licenses.mit;
|
|
||||||
mainProgram = "zesdex";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+2
-4
@@ -1,5 +1,7 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
|
# Zesdex is now a pure CLI/TUI tool (no REST/WS/gRPC/web/daemon servers).
|
||||||
|
# This compose file just provides an interactive TTY shell into the binary.
|
||||||
services:
|
services:
|
||||||
zesdex:
|
zesdex:
|
||||||
build:
|
build:
|
||||||
@@ -12,12 +14,8 @@ services:
|
|||||||
- ./workspace:/workspace:ro
|
- ./workspace:/workspace:ro
|
||||||
environment:
|
environment:
|
||||||
- ZESDEX_DATA_DIR=/data
|
- ZESDEX_DATA_DIR=/data
|
||||||
- RUST_LOG=${RUST_LOG:-info}
|
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
# For daemon mode, expose the IPC socket directory
|
|
||||||
# ports:
|
|
||||||
# - "127.0.0.1:${ZESDEX_PORT:-0}:${ZESDEX_PORT:-0}"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
zesdex-data:
|
zesdex-data:
|
||||||
Generated
-82
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1787135253,
|
|
||||||
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1787194935,
|
|
||||||
"narHash": "sha256-Gt6JpppReMxPGkbPcQhz+1qp+NUUf4oIwzRy+ptJgTk=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "cb98e96d632d0010a7e56c098c51729cb179cc75",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
description = "Zesdex — AI-native coding agent";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
rust-overlay = {
|
|
||||||
url = "github:oxalica/rust-overlay";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
|
||||||
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
|
||||||
let
|
|
||||||
overlays = [ (import rust-overlay) ];
|
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
|
||||||
extensions = [ "rust-src" "rust-analyzer" "clippy" ];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
rustToolchain
|
|
||||||
pkg-config
|
|
||||||
openssl
|
|
||||||
sqlite
|
|
||||||
];
|
|
||||||
|
|
||||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
|
||||||
RUST_LOG = "info";
|
|
||||||
};
|
|
||||||
|
|
||||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
|
||||||
pname = "zesdex";
|
|
||||||
version = "1.18.2";
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
cargoLock = {
|
|
||||||
lockFile = ./Cargo.lock;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Build only the main binary
|
|
||||||
cargoBuildFlags = [ "-p" "zesdex-gateway" "--bin" "zesdex" ];
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config perl ];
|
|
||||||
buildInputs = with pkgs; [ openssl sqlite ];
|
|
||||||
|
|
||||||
doCheck = false; # Tests require filesystem access
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "AI-native coding agent with workflow orchestration";
|
|
||||||
homepage = "https://github.com/asepharyana/zesdex";
|
|
||||||
license = licenses.mit;
|
|
||||||
mainProgram = "zesdex";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+10
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zesdex",
|
"name": "zesdex",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
@@ -10,21 +10,20 @@
|
|||||||
"build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex",
|
"build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"format": "bun fmt",
|
"tui": "bun src/interfaces/cli/index.ts",
|
||||||
"cli": "bun src/interfaces/cli/index.ts",
|
"cli": "bun src/interfaces/cli/index.ts --headless",
|
||||||
"bootstrap": "bun src/interfaces/cli/bootstrap.ts",
|
"headless": "bun src/interfaces/cli/index.ts --headless",
|
||||||
"api": "bun src/interfaces/api/main.ts",
|
"bootstrap": "bun src/interfaces/cli/bootstrap.ts"
|
||||||
"ws": "bun src/interfaces/ws/main.ts",
|
|
||||||
"grpc": "bun src/interfaces/grpc/main.ts",
|
|
||||||
"web": "bun src/interfaces/web/main.ts",
|
|
||||||
"daemon": "bun src/interfaces/daemon/main.ts",
|
|
||||||
"tui": "bun src/interfaces/tui/main.ts"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.0",
|
"@types/bun": "^1.2.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.0"
|
"@node-rs/argon2": "^2.2.0",
|
||||||
|
"@opentui/core": "^0.5.10",
|
||||||
|
"@opentui/react": "^0.5.10",
|
||||||
|
"react": "^19.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
with pkgs;
|
|
||||||
|
|
||||||
mkShell {
|
|
||||||
buildInputs = [
|
|
||||||
(rust-bin.stable.latest.default.override {
|
|
||||||
extensions = [ "rust-src" "rust-analyzer" "clippy" ];
|
|
||||||
})
|
|
||||||
pkg-config
|
|
||||||
openssl
|
|
||||||
sqlite
|
|
||||||
];
|
|
||||||
|
|
||||||
RUST_SRC_PATH = "${rust-bin.stable.latest.default}/lib/rustlib/src/rust/library";
|
|
||||||
RUST_LOG = "info";
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import {
|
|
||||||
adaptiveMaxTokens,
|
|
||||||
conversationChars,
|
|
||||||
ErrorTracker,
|
|
||||||
truncateToolOutput,
|
|
||||||
} from "./turn_service.ts";
|
|
||||||
import { type ChatMessage, newConversation, systemMessage, userMessage, toolResultMessage } from "@zesdex/domain";
|
|
||||||
|
|
||||||
describe("truncateToolOutput", () => {
|
|
||||||
it("short output is unchanged", () => {
|
|
||||||
expect(truncateToolOutput("short")).toBe("short");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("long output preserves head and marks cut", () => {
|
|
||||||
const long = "x".repeat(12_000 + 500);
|
|
||||||
const truncated = truncateToolOutput(long);
|
|
||||||
expect(truncated.length).toBeLessThan(long.length);
|
|
||||||
expect(truncated).toContain("...[truncated");
|
|
||||||
expect(truncated.startsWith("xxx")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("adaptiveMaxTokens", () => {
|
|
||||||
it("scales with request length", () => {
|
|
||||||
expect(adaptiveMaxTokens(10)).toBe(800);
|
|
||||||
expect(adaptiveMaxTokens(200)).toBe(1600);
|
|
||||||
expect(adaptiveMaxTokens(5000)).toBe(4096);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ErrorTracker", () => {
|
|
||||||
it("injects recovery note after repeated errors", () => {
|
|
||||||
const tracker = new ErrorTracker();
|
|
||||||
const messages: ChatMessage[] = [];
|
|
||||||
tracker.record("read", "Error: File not found", messages);
|
|
||||||
tracker.record("read", "Error: File not found", messages);
|
|
||||||
expect(tracker.shouldStop()).toBe(false);
|
|
||||||
tracker.record("read", "Error: File not found", messages);
|
|
||||||
expect(messages.some((m) => m.content?.includes("[System note]"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops after too many errors", () => {
|
|
||||||
const tracker = new ErrorTracker();
|
|
||||||
const messages: ChatMessage[] = [];
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
tracker.record("bash", `Error: boom ${i}`, messages);
|
|
||||||
}
|
|
||||||
expect(tracker.shouldStop()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("conversationChars", () => {
|
|
||||||
it("sums content only", () => {
|
|
||||||
const conv = newConversation("sys", "s1");
|
|
||||||
conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output"));
|
|
||||||
expect(conversationChars(conv.messages)).toBe(3 + 11 + 6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/** Auth application module — OAuth PKCE + session management use-cases. */
|
|
||||||
export * from "./oauth_service.ts";
|
|
||||||
export * from "./session_service.ts";
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { generatePkcePair, OAuthUseCase } from "./oauth_service.ts";
|
|
||||||
import type { OAuthFlowStore } from "./oauth_service.ts";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import type { OAuthToken } from "@zesdex/domain";
|
|
||||||
|
|
||||||
describe("generatePkcePair", () => {
|
|
||||||
it("produces a verifier ≥43 chars and a valid S256 challenge", () => {
|
|
||||||
const [verifier, challenge] = generatePkcePair();
|
|
||||||
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|
||||||
// Challenge = base64url(sha256(verifier)).
|
|
||||||
const expected = Buffer.from(createHash("sha256").update(verifier, "utf8").digest()).toString("base64url");
|
|
||||||
expect(challenge).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is unique across calls", () => {
|
|
||||||
const [a] = generatePkcePair();
|
|
||||||
const [b] = generatePkcePair();
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
class MemoryFlowStore implements OAuthFlowStore {
|
|
||||||
private verifier = "";
|
|
||||||
private state = "";
|
|
||||||
async saveFlowState(v: string, s: string): Promise<void> {
|
|
||||||
this.verifier = v;
|
|
||||||
this.state = s;
|
|
||||||
}
|
|
||||||
async loadVerifier(): Promise<string> {
|
|
||||||
return this.verifier;
|
|
||||||
}
|
|
||||||
async loadState(): Promise<string> {
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
this.verifier = "";
|
|
||||||
this.state = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRepo() {
|
|
||||||
let token: OAuthToken | null = null;
|
|
||||||
return {
|
|
||||||
repo: {
|
|
||||||
async saveToken(_path: string, t: OAuthToken): Promise<void> {
|
|
||||||
token = t;
|
|
||||||
},
|
|
||||||
async loadToken(): Promise<OAuthToken | null> {
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getToken: () => token,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("OAuthUseCase", () => {
|
|
||||||
it("builds an auth URL with PKCE params and persists flow state", async () => {
|
|
||||||
const { repo } = makeRepo();
|
|
||||||
const store = new MemoryFlowStore();
|
|
||||||
const exchanger = {
|
|
||||||
async exchangeCode(): Promise<OAuthToken> {
|
|
||||||
return { access_token: "at", refresh_token: "rt", expires_at: 9999, token_type: "Bearer" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const useCase = new OAuthUseCase(repo, store, exchanger, "/tmp/token.json");
|
|
||||||
|
|
||||||
const { authUrl, state } = await useCase.startFlow(
|
|
||||||
{ auth_url: "https://provider.example/oauth/authorize", token_url: "https://provider.example/oauth/token", client_id: "cid", scopes: ["openid", "profile"] },
|
|
||||||
"http://localhost:9999/callback",
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = new URL(authUrl);
|
|
||||||
expect(url.searchParams.get("response_type")).toBe("code");
|
|
||||||
expect(url.searchParams.get("client_id")).toBe("cid");
|
|
||||||
expect(url.searchParams.get("scope")).toBe("openid profile");
|
|
||||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
||||||
expect(url.searchParams.get("state")).toBe(state);
|
|
||||||
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
|
||||||
// Flow state persisted.
|
|
||||||
expect(await store.loadState()).toBe(state);
|
|
||||||
expect(await store.loadVerifier()).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces CSRF state match in completeFlow", async () => {
|
|
||||||
const { repo } = makeRepo();
|
|
||||||
const store = new MemoryFlowStore();
|
|
||||||
const exchanger = {
|
|
||||||
async exchangeCode(): Promise<OAuthToken> {
|
|
||||||
return { access_token: "at", refresh_token: "rt", expires_at: 9999, token_type: "Bearer" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const useCase = new OAuthUseCase(repo, store, exchanger, "/tmp/token.json");
|
|
||||||
await useCase.startFlow(
|
|
||||||
{ auth_url: "https://provider.example/authorize", token_url: "https://provider.example/token", client_id: "cid", scopes: ["openid"] },
|
|
||||||
"http://localhost:9999/callback",
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
useCase.completeFlow(
|
|
||||||
{ auth_url: "https://provider.example/authorize", token_url: "https://provider.example/token", client_id: "cid", scopes: ["openid"] },
|
|
||||||
"http://localhost:9999/callback",
|
|
||||||
"code123",
|
|
||||||
"wrong-state",
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ kind: "state_mismatch" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes flow successfully with matching state", async () => {
|
|
||||||
const { repo, getToken } = makeRepo();
|
|
||||||
const store = new MemoryFlowStore();
|
|
||||||
let exchangedVerifier = "";
|
|
||||||
const exchanger = {
|
|
||||||
async exchangeCode(_tokenUrl: string, _clientId: string, _secret: string | null, _redirectUri: string, code: string, codeVerifier: string): Promise<OAuthToken> {
|
|
||||||
exchangedVerifier = codeVerifier;
|
|
||||||
return { access_token: `at-${code}`, refresh_token: "rt", expires_at: 9999, token_type: "Bearer" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const useCase = new OAuthUseCase(repo, store, exchanger, "/tmp/token.json");
|
|
||||||
const { state } = await useCase.startFlow(
|
|
||||||
{ auth_url: "https://provider.example/authorize", token_url: "https://provider.example/token", client_id: "cid", scopes: ["openid"] },
|
|
||||||
"http://localhost:9999/callback",
|
|
||||||
);
|
|
||||||
const savedVerifier = await store.loadVerifier();
|
|
||||||
|
|
||||||
const token = await useCase.completeFlow(
|
|
||||||
{ auth_url: "https://provider.example/authorize", token_url: "https://provider.example/token", client_id: "cid", scopes: ["openid"] },
|
|
||||||
"http://localhost:9999/callback",
|
|
||||||
"code123",
|
|
||||||
state,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(token.access_token).toBe("at-code123");
|
|
||||||
// The code verifier passed to the exchanger is the one saved at start.
|
|
||||||
expect(exchangedVerifier).toBe(savedVerifier);
|
|
||||||
// Flow state cleared after completion.
|
|
||||||
expect(await store.loadState()).toBe("");
|
|
||||||
// Token persisted via repo.
|
|
||||||
expect(getToken()?.access_token).toBe("at-code123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* OAuth 2.0 authorization-code + PKCE flow use-case. Mirrors
|
|
||||||
* `apps/application/src/auth/oauth_service.rs`.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
type OAuthConfig,
|
|
||||||
type OAuthRepository,
|
|
||||||
type OAuthToken,
|
|
||||||
authInvalidConfig,
|
|
||||||
authStateMismatch,
|
|
||||||
} from "@zesdex/domain";
|
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Port traits */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** Persistence contract for ephemeral OAuth flow state. */
|
|
||||||
export interface OAuthFlowStore {
|
|
||||||
saveFlowState(verifier: string, state: string): Promise<void>;
|
|
||||||
loadVerifier(): Promise<string>;
|
|
||||||
loadState(): Promise<string>;
|
|
||||||
clear(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Abstraction for exchanging an authorization code for tokens. */
|
|
||||||
export interface TokenExchanger {
|
|
||||||
exchangeCode(
|
|
||||||
tokenUrl: string,
|
|
||||||
clientId: string,
|
|
||||||
clientSecret: string | null,
|
|
||||||
redirectUri: string,
|
|
||||||
code: string,
|
|
||||||
codeVerifier: string,
|
|
||||||
): Promise<OAuthToken>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* PKCE helpers */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function base64url(input: string | Uint8Array): string {
|
|
||||||
const buf = typeof input === "string" ? new TextEncoder().encode(input) : Buffer.from(input as Uint8Array);
|
|
||||||
return Buffer.from(buf).toString("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a PKCE code-verifier and its S256 code-challenge. */
|
|
||||||
export function generatePkcePair(): [string, string] {
|
|
||||||
const bytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
const verifier = base64url(bytes);
|
|
||||||
const challenge = base64url(createHash("sha256").update(verifier, "utf8").digest());
|
|
||||||
return [verifier, challenge];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a random CSRF state token (UUID-based). */
|
|
||||||
export function generateStateToken(): string {
|
|
||||||
return randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* OAuthUseCase */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/** Concrete OAuth flow use-case over injected repositories. */
|
|
||||||
export class OAuthUseCase {
|
|
||||||
constructor(
|
|
||||||
private tokenRepo: OAuthRepository,
|
|
||||||
private flowStore: OAuthFlowStore,
|
|
||||||
private tokenExchanger: TokenExchanger,
|
|
||||||
private tokenPath: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async startFlow(config: OAuthConfig, redirectUri: string): Promise<{ authUrl: string; state: string }> {
|
|
||||||
if (config.auth_url === "") {
|
|
||||||
throw authInvalidConfig("OAuth auth_url is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [verifier, challenge] = generatePkcePair();
|
|
||||||
const state = generateStateToken();
|
|
||||||
|
|
||||||
await this.flowStore.saveFlowState(verifier, state);
|
|
||||||
|
|
||||||
const url = new URL(config.auth_url);
|
|
||||||
url.searchParams.set("response_type", "code");
|
|
||||||
url.searchParams.set("client_id", config.client_id);
|
|
||||||
url.searchParams.set("redirect_uri", redirectUri);
|
|
||||||
url.searchParams.set("scope", config.scopes.join(" "));
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
url.searchParams.set("code_challenge_method", "S256");
|
|
||||||
url.searchParams.set("code_challenge", challenge);
|
|
||||||
|
|
||||||
return { authUrl: url.toString(), state };
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeFlow(config: OAuthConfig, redirectUri: string, code: string, state: string): Promise<OAuthToken> {
|
|
||||||
// CSRF check.
|
|
||||||
const expectedState = await this.flowStore.loadState();
|
|
||||||
if (expectedState !== state) throw authStateMismatch();
|
|
||||||
|
|
||||||
// Read the PKCE verifier saved in start_flow.
|
|
||||||
const verifier = await this.flowStore.loadVerifier();
|
|
||||||
|
|
||||||
const token = await this.tokenExchanger.exchangeCode(
|
|
||||||
config.token_url,
|
|
||||||
config.client_id,
|
|
||||||
config.client_secret ?? null,
|
|
||||||
redirectUri,
|
|
||||||
code,
|
|
||||||
verifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.tokenRepo.saveToken(this.tokenPath, token);
|
|
||||||
await this.flowStore.clear();
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getToken(): Promise<OAuthToken | null> {
|
|
||||||
return this.tokenRepo.loadToken(this.tokenPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session management use-case. Mirrors `apps/application/src/auth/session_service.rs`.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import {
|
|
||||||
type Session,
|
|
||||||
type SessionId,
|
|
||||||
type SessionLockRepository,
|
|
||||||
type SessionRepository,
|
|
||||||
newSession,
|
|
||||||
newSessionId,
|
|
||||||
authOtherError as otherErr,
|
|
||||||
} from "@zesdex/domain";
|
|
||||||
|
|
||||||
/** Concrete session service backed by injected repositories. */
|
|
||||||
export class SessionServiceImpl {
|
|
||||||
constructor(
|
|
||||||
private sessionRepo: SessionRepository,
|
|
||||||
/** Lock repository is injected for future lock acquire/release (matches Rust contract). */
|
|
||||||
// @ts-expect-error -- kept for structural parity with the Rust `SessionServiceImpl<R, L>`
|
|
||||||
private lockRepo: SessionLockRepository,
|
|
||||||
private baseDir: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async createSession(title: string): Promise<Session> {
|
|
||||||
const idRes = newSessionId(randomUUID());
|
|
||||||
if (!idRes.ok) throw otherErr(idRes.error);
|
|
||||||
const titleOwned = title === "" ? "New Session" : title;
|
|
||||||
const session = newSession(idRes.value, titleOwned);
|
|
||||||
await this.sessionRepo.saveSession(this.baseDir, session);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listAll(): Promise<Session[]> {
|
|
||||||
return this.sessionRepo.listSessions(this.baseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
async archiveSession(id: SessionId): Promise<void> {
|
|
||||||
const session = await this.sessionRepo.loadSession(this.baseDir, id);
|
|
||||||
session.archived = true;
|
|
||||||
session.updated_at = Date.now();
|
|
||||||
await this.sessionRepo.saveSession(this.baseDir, session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zesdex Application Layer — port traits, use cases, turn service.
|
|
||||||
* Depends only on @zesdex/domain. Higher-level ports implemented by
|
|
||||||
* infrastructure adapters.
|
|
||||||
*/
|
|
||||||
export * from "./ports/index.ts";
|
|
||||||
export * from "./agent/index.ts";
|
|
||||||
export * from "./auth/index.ts";
|
|
||||||
export * from "./cms/index.ts";
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/** System prompts and directive templates. Mirrors `agent/prompt.rs`. */
|
|
||||||
|
|
||||||
/** Build the main-agent system prompt. */
|
|
||||||
export function mainAgentPrompt(): string {
|
|
||||||
return `You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.
|
|
||||||
|
|
||||||
TOKEN BUDGET — BE EFFICIENT:
|
|
||||||
- For simple/factual questions, answer directly. Do NOT call tools.
|
|
||||||
- For complex or unfamiliar code tasks, call \`explore_codebase\` ONCE at the start to locate relevant code, then work from that context.
|
|
||||||
- Keep tool usage minimal: prefer \`grep\`/\`glob\`/\`read\` for targeted lookups; avoid re-reading files you already have in context.
|
|
||||||
- Keep responses concise; do not repeat tool output verbatim.
|
|
||||||
|
|
||||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
|
||||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritise using \`workflow_run\` (to construct and execute a multi-phase YAML workflow) or \`hive_mind\` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.
|
|
||||||
2. PLANNING & TODOs: Use \`plan_enter\` to establish high-level architectural plans and \`todowrite\` to maintain granular task checklists.
|
|
||||||
3. REASONING: Use \`seq_think\` for deep step-by-step analysis.
|
|
||||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyse and fix it.
|
|
||||||
|
|
||||||
VERIFY AFTER EDIT (CLAUDE-CODE STYLE):
|
|
||||||
- After modifying code (edit/write), run the repo's check command via \`bash\` before ending the turn: \`cargo check\` / \`cargo clippy\` / \`cargo test\` for Rust, or the equivalent lint/test (\`bun run lint && bun run test\`, \`npm test\`, etc.) for other stacks. Pick the project's actual verify command (see PROJECT CONTEXT / AGENTS.md when present).
|
|
||||||
- If the check fails, fix the errors you can see and re-run; only end the turn after the check passes or you cannot resolve a failure yourself (then report it explicitly).
|
|
||||||
- Do NOT claim code compiles or works without running a real check.
|
|
||||||
|
|
||||||
Respond conversationally, concisely, and helpfully.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Main-agent prompt with an injected `## PROJECT CONTEXT` block. Empty context → base prompt. */
|
|
||||||
export function mainAgentPromptWithProjectContext(projectContext: string): string {
|
|
||||||
const base = mainAgentPrompt();
|
|
||||||
const context = projectContext.trim();
|
|
||||||
if (context === "") return base;
|
|
||||||
return `${base}
|
|
||||||
|
|
||||||
## PROJECT CONTEXT (repo rules — follow these conventions)
|
|
||||||
${context}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a subagent directive prompt. */
|
|
||||||
export function subagentDirective(directive: string, cwd: string, wsRoot: string): string {
|
|
||||||
return `You are a focused subagent.
|
|
||||||
|
|
||||||
Current directory (PWD): ${cwd}
|
|
||||||
Workspace root: ${wsRoot}
|
|
||||||
|
|
||||||
Your directive:
|
|
||||||
${directive}
|
|
||||||
|
|
||||||
Complete the directive autonomously using the tools available to you. Return your final answer when done.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a conversation-compaction prompt. */
|
|
||||||
export function compactionPrompt(): string {
|
|
||||||
return `You are a helpful assistant summarising conversation history. Provide a concise summary of the key user requests, decisions, tools executed, and modified files. Format as a clear bulleted list.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Directive for a lightweight context-scout subagent. */
|
|
||||||
export function exploreScoutDirective(): string {
|
|
||||||
return `You are a codebase context scout. Given the workspace root, quickly locate the code that is most relevant to the user's request:
|
|
||||||
1. Run semantic_search once with the user's key terms.
|
|
||||||
2. Read up to the 3 most relevant files (use grep for symbols if needed).
|
|
||||||
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of what you found and exactly where (file paths).
|
|
||||||
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** System note injected after repeated tool errors. */
|
|
||||||
export function errorRecoveryNote(toolName: string, lastError: string): string {
|
|
||||||
return `[System note] The tool \`${toolName}\` failed repeatedly with: "${lastError}". Try an alternative approach (verify paths, correct arguments, use a different tool, or finish without this tool). Do NOT retry the same call.`;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Command types for IAM domain operations. Mirrors `commands.rs`.
|
|
||||||
* Carries only the data needed to construct a session entity.
|
|
||||||
*/
|
|
||||||
export interface NewSession {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newSessionCommand(title: string): NewSession {
|
|
||||||
return { title };
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Domain error types for the IAM (auth) module.
|
|
||||||
* Mirrors `apps/domain/src/auth/error.rs`.
|
|
||||||
*/
|
|
||||||
import type { DomainError } from "../core/error.ts";
|
|
||||||
|
|
||||||
/** Shared repository error type for IAM persistence. */
|
|
||||||
export type RepositoryError = DomainError;
|
|
||||||
|
|
||||||
/** Errors from service / use-case operations in the IAM domain. */
|
|
||||||
export type ServiceError =
|
|
||||||
| { kind: "repository"; error: DomainError }
|
|
||||||
| { kind: "invalid_config"; message: string }
|
|
||||||
| { kind: "state_mismatch" }
|
|
||||||
| { kind: "oauth_provider"; message: string }
|
|
||||||
| { kind: "other"; message: string };
|
|
||||||
|
|
||||||
export function repositoryErr(err: DomainError): ServiceError {
|
|
||||||
return { kind: "repository", error: err };
|
|
||||||
}
|
|
||||||
export function invalidConfig(message: string): ServiceError {
|
|
||||||
return { kind: "invalid_config", message };
|
|
||||||
}
|
|
||||||
export function stateMismatch(): ServiceError {
|
|
||||||
return { kind: "state_mismatch" };
|
|
||||||
}
|
|
||||||
export function oauthProvider(message: string): ServiceError {
|
|
||||||
return { kind: "oauth_provider", message };
|
|
||||||
}
|
|
||||||
export function authOther(message: string): ServiceError {
|
|
||||||
return { kind: "other", message };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serviceErrorToString(e: ServiceError): string {
|
|
||||||
switch (e.kind) {
|
|
||||||
case "repository":
|
|
||||||
return `repository error: ${e.error.message}`;
|
|
||||||
case "invalid_config":
|
|
||||||
return `invalid configuration: ${e.message}`;
|
|
||||||
case "state_mismatch":
|
|
||||||
return `OAuth state mismatch — possible CSRF attack`;
|
|
||||||
case "oauth_provider":
|
|
||||||
return `OAuth provider error: ${e.message}`;
|
|
||||||
case "other":
|
|
||||||
return e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/** Auth domain module — sessions, locks, OAuth, and their interfaces. */
|
|
||||||
export * from "./session.ts";
|
|
||||||
export * from "./session_id.ts";
|
|
||||||
export * from "./session_lock.ts";
|
|
||||||
export * from "./oauth.ts";
|
|
||||||
export * from "./repository.ts";
|
|
||||||
export * from "./service.ts";
|
|
||||||
export * from "./commands.ts";
|
|
||||||
export {
|
|
||||||
type ServiceError as AuthServiceError,
|
|
||||||
type RepositoryError as AuthRepositoryError,
|
|
||||||
} from "./error.ts";
|
|
||||||
export {
|
|
||||||
repositoryErr as authRepositoryErr,
|
|
||||||
invalidConfig as authInvalidConfig,
|
|
||||||
stateMismatch as authStateMismatch,
|
|
||||||
oauthProvider as authOauthProvider,
|
|
||||||
authOther as authOtherError,
|
|
||||||
serviceErrorToString as authServiceErrorToString,
|
|
||||||
} from "./error.ts";
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* OAuth entities. Mirrors `apps/domain/src/auth/oauth.rs`.
|
|
||||||
*/
|
|
||||||
export interface OAuthToken {
|
|
||||||
/** The OAuth 2.0 access token string. */
|
|
||||||
access_token: string;
|
|
||||||
/** Optional refresh token for long-lived access. */
|
|
||||||
refresh_token?: string;
|
|
||||||
/** Absolute expiry timestamp (epoch seconds). */
|
|
||||||
expires_at: number;
|
|
||||||
/** Token type, e.g. `"Bearer"`. */
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Static configuration for an OAuth provider. */
|
|
||||||
export interface OAuthConfig {
|
|
||||||
/** Authorization endpoint URL. */
|
|
||||||
auth_url: string;
|
|
||||||
/** Token exchange endpoint URL. */
|
|
||||||
token_url: string;
|
|
||||||
/** OAuth client identifier. */
|
|
||||||
client_id: string;
|
|
||||||
/** Optional client secret. */
|
|
||||||
client_secret?: string;
|
|
||||||
/** Space-separated list of requested scopes. */
|
|
||||||
scopes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default scopes for a new OAuthConfig. */
|
|
||||||
export const DEFAULT_OAUTH_SCOPES = ["openid", "profile", "email"];
|
|
||||||
|
|
||||||
export function newOAuthConfig(): OAuthConfig {
|
|
||||||
return {
|
|
||||||
auth_url: "",
|
|
||||||
token_url: "",
|
|
||||||
client_id: "",
|
|
||||||
client_secret: undefined,
|
|
||||||
scopes: [...DEFAULT_OAUTH_SCOPES],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Repository trait definitions (interfaces) — pure, no impls.
|
|
||||||
* Mirrors `apps/domain/src/auth/repository.rs`. Infrastructure adapters
|
|
||||||
* implement these.
|
|
||||||
*/
|
|
||||||
import type { OAuthToken } from "./oauth.ts";
|
|
||||||
import type { Session } from "./session.ts";
|
|
||||||
import type { SessionId } from "./session_id.ts";
|
|
||||||
|
|
||||||
/** Repository for loading, saving, listing, and deleting sessions. */
|
|
||||||
export interface SessionRepository {
|
|
||||||
/** List all loadable sessions under `<base_dir>/sessions/`. */
|
|
||||||
listSessions(baseDir: string): Promise<Session[]> | Session[];
|
|
||||||
/** Load a single session by id. */
|
|
||||||
loadSession(baseDir: string, id: SessionId): Promise<Session> | Session;
|
|
||||||
/** Save a session's metadata to disk. */
|
|
||||||
saveSession(baseDir: string, session: Session): Promise<void> | void;
|
|
||||||
/** Delete a session directory and all its contents. */
|
|
||||||
deleteSession(baseDir: string, id: SessionId): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Repository for per-session PID-file advisory locks. */
|
|
||||||
export interface SessionLockRepository {
|
|
||||||
/** Try to acquire the lock. `true` if acquired, `false` if a live process holds it. */
|
|
||||||
tryLock(sessionDir: string): Promise<boolean> | boolean;
|
|
||||||
/** Release the lock. */
|
|
||||||
unlock(sessionDir: string): Promise<void> | void;
|
|
||||||
/** Check whether a process with the given PID is alive. */
|
|
||||||
isAlive(pid: number): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Repository for persisting and loading OAuth tokens. */
|
|
||||||
export interface OAuthRepository {
|
|
||||||
/** Persist an OAuth token to a JSON file. */
|
|
||||||
saveToken(path: string, token: OAuthToken): Promise<void> | void;
|
|
||||||
/** Load an OAuth token, returning `null` if the file does not exist. */
|
|
||||||
loadToken(path: string): Promise<OAuthToken | null> | OAuthToken | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service trait definitions — use-case boundaries for sessions and OAuth.
|
|
||||||
* Mirrors `apps/domain/src/auth/service.rs`. Implementations live in the
|
|
||||||
* application layer.
|
|
||||||
*/
|
|
||||||
import type { OAuthConfig, OAuthToken } from "./oauth.ts";
|
|
||||||
import type { Session } from "./session.ts";
|
|
||||||
import type { SessionId } from "./session_id.ts";
|
|
||||||
|
|
||||||
/** Session management use-case boundary. */
|
|
||||||
export interface SessionService {
|
|
||||||
/** Create a new session with a generated UUID and the given title. */
|
|
||||||
createSession(title: string): Promise<Session> | Session;
|
|
||||||
/** List all available sessions. */
|
|
||||||
listAll(): Promise<Session[]> | Session[];
|
|
||||||
/** Archive a session by id (sets `archived = true`). */
|
|
||||||
archiveSession(id: SessionId): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OAuth flow use-case boundary. */
|
|
||||||
export interface OAuthService {
|
|
||||||
/**
|
|
||||||
* Start an OAuth authorization-code + PKCE flow. Returns `{ authUrl, state }`:
|
|
||||||
* the URL to send the user to, and the CSRF state token that must be passed
|
|
||||||
* back into `completeFlow` unchanged.
|
|
||||||
*/
|
|
||||||
startFlow(config: OAuthConfig, redirectUri: string): Promise<{ authUrl: string; state: string }> | { authUrl: string; state: string };
|
|
||||||
/**
|
|
||||||
* Complete the flow: validate `state`, then exchange `code` for a token.
|
|
||||||
* Validates `state` against the persisted value (CSRF check).
|
|
||||||
*/
|
|
||||||
completeFlow(config: OAuthConfig, redirectUri: string, code: string, state: string): Promise<OAuthToken> | OAuthToken;
|
|
||||||
/** Retrieve the currently stored OAuth token (if any). */
|
|
||||||
getToken(): Promise<OAuthToken | null> | OAuthToken | null;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session metadata. Mirrors `apps/domain/src/auth/session.rs`.
|
|
||||||
*/
|
|
||||||
import * as path from "node:path";
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
/** Unique session identifier. */
|
|
||||||
id: string;
|
|
||||||
/** Epoch-millis timestamp of creation. */
|
|
||||||
created_at: number;
|
|
||||||
/** Epoch-millis timestamp of last update. */
|
|
||||||
updated_at: number;
|
|
||||||
/** Human-readable title for the conversation. */
|
|
||||||
title: string;
|
|
||||||
/** Model identifier string. */
|
|
||||||
model: string;
|
|
||||||
/** Workspace root directories associated with this session. */
|
|
||||||
workspace_roots: string[];
|
|
||||||
/** Running count of messages in the conversation. */
|
|
||||||
message_count: number;
|
|
||||||
/** Running count of tokens consumed. */
|
|
||||||
token_count: number;
|
|
||||||
/** Soft-delete flag. */
|
|
||||||
archived: boolean;
|
|
||||||
/** Optional AI-generated conversation summary. */
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "anthropic/claude-opus-4-8";
|
|
||||||
|
|
||||||
/** Create a new session with the given id/title and current dir as root. */
|
|
||||||
export function newSession(id: string, title: string): Session {
|
|
||||||
const now = Date.now();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
title,
|
|
||||||
model: DEFAULT_MODEL,
|
|
||||||
workspace_roots: [process.cwd()],
|
|
||||||
message_count: 0,
|
|
||||||
token_count: 0,
|
|
||||||
archived: false,
|
|
||||||
summary: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute this session's directory under `<base_dir>/sessions/<id>`. */
|
|
||||||
export function sessionDir(session: Session, baseDir: string): string {
|
|
||||||
return path.join(baseDir, "sessions", session.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute this session's `conversation.json` path. */
|
|
||||||
export function conversationPath(session: Session, baseDir: string): string {
|
|
||||||
return path.join(sessionDir(session, baseDir), "conversation.json");
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { newSessionId } from "./session_id.ts";
|
|
||||||
|
|
||||||
describe("newSessionId", () => {
|
|
||||||
it("accepts valid UUIDs", () => {
|
|
||||||
expect(newSessionId("550e8400-e29b-41d4-a716-446655440000").ok).toBe(true);
|
|
||||||
expect(newSessionId("my-session_123").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects path traversal", () => {
|
|
||||||
expect(newSessionId("../etc/passwd").ok).toBe(false);
|
|
||||||
expect(newSessionId("foo/../../bar").ok).toBe(false);
|
|
||||||
expect(newSessionId("foo\\..\\bar").ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty", () => {
|
|
||||||
expect(newSessionId("").ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the id string on success", () => {
|
|
||||||
const res = newSessionId("abc-123");
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (res.ok) expect(res.value).toBe("abc-123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validated session identifier. Mirrors `apps/domain/src/auth/session_id.rs`.
|
|
||||||
*
|
|
||||||
* Guarantees the inner string is non-empty and contains no path-traversal
|
|
||||||
* characters (`/`, `\`, `..`) or other unsafe delimiters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/;
|
|
||||||
|
|
||||||
export type SessionId = string & { __sessionId?: true };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and construct a `SessionId`.
|
|
||||||
* Returns `err` message if the input contains path separators, `..`, or is empty.
|
|
||||||
*/
|
|
||||||
export function newSessionId(id: string): { ok: true; value: SessionId } | { ok: false; error: string } {
|
|
||||||
if (id === "") {
|
|
||||||
return { ok: false, error: "session id must not be empty" };
|
|
||||||
}
|
|
||||||
if (id.includes("/") || id.includes("\\") || id.includes("..") || !SESSION_ID_RE.test(id)) {
|
|
||||||
return { ok: false, error: `session id '${id}' must not contain path separators` };
|
|
||||||
}
|
|
||||||
return { ok: true, value: id as SessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assert that a session id is valid (throws if not). */
|
|
||||||
export function assertSessionId(id: string): SessionId {
|
|
||||||
const res = newSessionId(id);
|
|
||||||
if (!res.ok) throw new Error(res.error);
|
|
||||||
return res.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return the underlying string. */
|
|
||||||
export function sessionIdAsString(id: SessionId): string {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Classic Rust-style UUID format (as used by the Rust app). */
|
|
||||||
export function isUuidLike(id: string): boolean {
|
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* PID-file based advisory lock preventing two processes from operating on
|
|
||||||
* the same session directory concurrently. Mirrors `session_lock.rs`.
|
|
||||||
*
|
|
||||||
* Strategy: atomic `O_CREAT|O_EXCL` acquire → on existing file, liveness-check
|
|
||||||
* the owning PID (Unix: read `/proc/<pid>/exe` and compare to self, plus
|
|
||||||
* `kill(pid, 0)`) → stale locks overwritten atomically (temp + rename + fsync).
|
|
||||||
*/
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
|
|
||||||
/** A PID-file lock (`<session_dir>/.lock`) tied to the current process. */
|
|
||||||
export class SessionLock {
|
|
||||||
readonly path: string;
|
|
||||||
readonly pid: number;
|
|
||||||
|
|
||||||
constructor(sessionDir: string) {
|
|
||||||
this.path = path.join(sessionDir, ".lock");
|
|
||||||
this.pid = process.pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Attempt to acquire the lock. Returns `true` on success, `false` if held by a live process. */
|
|
||||||
tryLock(): boolean {
|
|
||||||
// Phase 1: atomic create (O_CREAT|O_EXCL equivalent via flag 'wx').
|
|
||||||
try {
|
|
||||||
const fd = fs.openSync(this.path, "wx");
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(fd, String(this.pid));
|
|
||||||
fs.fsyncSync(fd);
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
const code = (e as NodeJS.ErrnoException).code;
|
|
||||||
if (code !== "EEXIST") throw e;
|
|
||||||
// Lock file exists — check staleness.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: read owning PID and check liveness.
|
|
||||||
const content = fs.readFileSync(this.path, "utf8").trim();
|
|
||||||
if (content !== "") {
|
|
||||||
const pid = Number(content);
|
|
||||||
if (Number.isFinite(pid) && pid > 0) {
|
|
||||||
if (pidIsAlive(pid)) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: stale lock — overwrite atomically.
|
|
||||||
const tmp = this.path + ".tmp";
|
|
||||||
const fd = fs.openSync(tmp, "w");
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(fd, String(this.pid));
|
|
||||||
fs.fsyncSync(fd);
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
fs.renameSync(tmp, this.path);
|
|
||||||
// Best-effort parent dir fsync.
|
|
||||||
const parent = path.dirname(this.path);
|
|
||||||
try {
|
|
||||||
const dirFd = fs.openSync(parent, "r");
|
|
||||||
try {
|
|
||||||
fs.fsyncSync(dirFd);
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(dirFd);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Explicitly release the lock by removing the lock file. */
|
|
||||||
unlock(): void {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(this.path);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a process with the given PID is alive. Conservative on
|
|
||||||
* non-Unix: returns true.
|
|
||||||
*/
|
|
||||||
export function pidIsAlive(pid: number): boolean {
|
|
||||||
if (process.platform === "win32") return true;
|
|
||||||
try {
|
|
||||||
const selfExe = fs.readlinkSync("/proc/self/exe");
|
|
||||||
let target: string;
|
|
||||||
try {
|
|
||||||
target = fs.readlinkSync(`/proc/${pid}/exe`);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (target !== selfExe) return false;
|
|
||||||
// kill(pid, 0): throws if the process is gone / not permitted.
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Re-check to close the TOCTOU window.
|
|
||||||
try {
|
|
||||||
return fs.readlinkSync(`/proc/${pid}/exe`) === selfExe;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No /proc (macOS) — fall back to kill(pid,0) only.
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* Re-export of conversation types from core (matches Rust `cms/conversation.rs`).
|
|
||||||
*/
|
|
||||||
export type { Conversation } from "../core/conversation.ts";
|
|
||||||
export type { ChatMessage, Role } from "../core/message.ts";
|
|
||||||
export { newConversation, pushMessage, rebuildSystem, toApiMessages } from "../core/conversation.ts";
|
|
||||||
export { Roles } from "../core/message.ts";
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zesdex Domain Layer — pure types, value objects, and port interfaces.
|
|
||||||
* Zero framework dependencies, zero I/O. Mirrors the Rust `zesdex-domain` crate.
|
|
||||||
*/
|
|
||||||
export * from "./core/index.ts";
|
|
||||||
export * from "./auth/index.ts";
|
|
||||||
export * from "./cms/index.ts";
|
|
||||||
export * from "./agent/index.ts";
|
|
||||||
export * from "./subagent/mod.ts";
|
|
||||||
export * from "./workflow/mod.ts";
|
|
||||||
@@ -14,7 +14,7 @@ export interface ToolExecutor {
|
|||||||
|
|
||||||
/** Service for running agent turns asynchronously. */
|
/** Service for running agent turns asynchronously. */
|
||||||
export interface AgentTurnService {
|
export interface AgentTurnService {
|
||||||
runTurn(params: import("@zesdex/domain").AgentTurnParams): Promise<void>;
|
runTurn(params: import("@zesdex/agent").AgentTurnParams): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./turn_service.ts";
|
export * from "./turn_service.ts";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Port traits (interfaces) to external services. Mirrors `apps/application/src/ports/`.
|
* Port traits (interfaces) that the Agent application layer exposes to
|
||||||
* Concrete implementations live in the infrastructure layer.
|
* its infrastructure. Concrete implementations live in
|
||||||
|
* `features/agent/infrastructure/`.
|
||||||
*/
|
*/
|
||||||
import type { ChatMessage, StreamEvent, ToolDef } from "@zesdex/domain";
|
import type { ChatMessage, StreamEvent, ToolDef } from "@zesdex/domain";
|
||||||
|
|
||||||
@@ -31,23 +32,3 @@ export interface ProviderService {
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }>;
|
): Promise<{ message: ChatMessage; usage: [number, number] | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Abstraction for password hashing and verification. */
|
|
||||||
export interface PasswordService {
|
|
||||||
hash(password: string): Promise<string>;
|
|
||||||
verify(password: string, hash: string): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Abstraction for JWT-based token generation and verification. */
|
|
||||||
export interface TokenService {
|
|
||||||
/** Generate an `[access, refresh]` token pair for the given subject. */
|
|
||||||
generateTokens(sub: string): [string, string];
|
|
||||||
verifyAccessToken(token: string): string;
|
|
||||||
verifyRefreshToken(token: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** High-level authentication service combining password + token issuance. */
|
|
||||||
export interface AuthService {
|
|
||||||
authenticate(password: string, hash: string): Promise<boolean>;
|
|
||||||
issueTokens(sub: string): [string, string];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
adaptiveMaxTokens,
|
||||||
|
conversationChars,
|
||||||
|
ErrorTracker,
|
||||||
|
truncateToolOutput,
|
||||||
|
bashExitCode,
|
||||||
|
isToolFailure,
|
||||||
|
deriveVerifyCommand,
|
||||||
|
AgentTurnServiceImpl,
|
||||||
|
takeRecentTail,
|
||||||
|
reduceMessagesToDigest,
|
||||||
|
compactMessagesWithAi,
|
||||||
|
} from "./turn_service.ts";
|
||||||
|
import {
|
||||||
|
type ChatMessage,
|
||||||
|
newConversation,
|
||||||
|
systemMessage,
|
||||||
|
userMessage,
|
||||||
|
assistantMessage,
|
||||||
|
toolResultMessage,
|
||||||
|
} from "@zesdex/domain";
|
||||||
|
import type { AgentTurnParams } from "@zesdex/agent";
|
||||||
|
import type { ToolExecutor } from "./index.ts";
|
||||||
|
import type { ProviderService } from "./ports.ts";
|
||||||
|
|
||||||
|
describe("truncateToolOutput", () => {
|
||||||
|
it("short output is unchanged", () => {
|
||||||
|
expect(truncateToolOutput("short")).toBe("short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("long output preserves head and marks cut", () => {
|
||||||
|
const long = "x".repeat(12_000 + 500);
|
||||||
|
const truncated = truncateToolOutput(long);
|
||||||
|
expect(truncated.length).toBeLessThan(long.length);
|
||||||
|
expect(truncated).toContain("...[truncated");
|
||||||
|
expect(truncated.startsWith("xxx")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adaptiveMaxTokens", () => {
|
||||||
|
it("scales with request length", () => {
|
||||||
|
expect(adaptiveMaxTokens(10)).toBe(800);
|
||||||
|
expect(adaptiveMaxTokens(200)).toBe(1600);
|
||||||
|
expect(adaptiveMaxTokens(5000)).toBe(4096);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ErrorTracker", () => {
|
||||||
|
it("injects recovery note after repeated errors", () => {
|
||||||
|
const tracker = new ErrorTracker();
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
tracker.record("read", "Error: File not found", messages);
|
||||||
|
tracker.record("read", "Error: File not found", messages);
|
||||||
|
expect(tracker.shouldStop()).toBe(false);
|
||||||
|
tracker.record("read", "Error: File not found", messages);
|
||||||
|
expect(messages.some((m) => m.content?.includes("[System note]"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after too many errors", () => {
|
||||||
|
const tracker = new ErrorTracker();
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
tracker.record("bash", `Error: boom ${i}`, messages);
|
||||||
|
}
|
||||||
|
expect(tracker.shouldStop()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("conversationChars", () => {
|
||||||
|
it("sums content only", () => {
|
||||||
|
const conv = newConversation("sys", "s1");
|
||||||
|
conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output"));
|
||||||
|
expect(conversationChars(conv.messages)).toBe(3 + 11 + 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* ── New flow helpers ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
describe("bashExitCode / isToolFailure", () => {
|
||||||
|
it("extracts a non-zero exit code", () => {
|
||||||
|
expect(bashExitCode("boom\n\nExit code: 1 (1s)")).toBe(1);
|
||||||
|
expect(bashExitCode("done\n\nExit code: 0 (0.5s)")).toBe(0);
|
||||||
|
});
|
||||||
|
it("returns null when no exit-code line exists", () => {
|
||||||
|
expect(bashExitCode("plain output")).toBeNull();
|
||||||
|
});
|
||||||
|
it("isToolFailure flags bash non-zero exits as failures", () => {
|
||||||
|
expect(isToolFailure("bash", "nope\n\nExit code: 2 (1s)")).toBe(true);
|
||||||
|
expect(isToolFailure("bash", "ok\n\nExit code: 0 (1s)")).toBe(false);
|
||||||
|
});
|
||||||
|
it("isToolFailure still flags Error: prefixes for other tools", () => {
|
||||||
|
expect(isToolFailure("read", "Error: no such file")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveVerifyCommand", () => {
|
||||||
|
it("defaults to bun check+test for a Bun repo", () => {
|
||||||
|
// No real manifests in a control dir we guarantee to not exist.
|
||||||
|
expect(deriveVerifyCommand("/nonexistent-zesdex-dir")).toContain("bun");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── runTurn flow tests (fakes, no network) ───────────────────────── */
|
||||||
|
|
||||||
|
interface ScriptedStep {
|
||||||
|
content?: string | null;
|
||||||
|
tools?: Array<{ name: string; args?: string; id?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ProviderService that replays scripted chatStream responses. */
|
||||||
|
function makeScriptedProvider(script: ScriptedStep[]): {
|
||||||
|
provider: ProviderService;
|
||||||
|
seen: Array<ChatMessage[]>;
|
||||||
|
} {
|
||||||
|
const seen: Array<ChatMessage[]> = [];
|
||||||
|
let step = 0;
|
||||||
|
const chatResponses: string[] = [];
|
||||||
|
const provider: ProviderService = {
|
||||||
|
async chat(_messages, _tools, _maxTokens, _temperature) {
|
||||||
|
// Used by compaction and review. For review tests we script below.
|
||||||
|
const text = chatResponses.shift() ?? "";
|
||||||
|
return { message: { role: "assistant", content: text || null }, usage: [10, 5] };
|
||||||
|
},
|
||||||
|
async chatStream(messages, _tools, _max, _temp, onEvent) {
|
||||||
|
// Snapshot a copy: runTurn freely mutates the live array (splice/compact).
|
||||||
|
seen.push([...messages]);
|
||||||
|
const s = script[Math.min(step, script.length - 1)] ?? { content: "done" };
|
||||||
|
step += 1;
|
||||||
|
if (s.content !== undefined && s.content !== null) onEvent({ kind: "token", content: s.content });
|
||||||
|
if (s.tools && s.tools.length > 0) {
|
||||||
|
const msg = assistantMessage(null) as ChatMessage;
|
||||||
|
msg.tool_calls = s.tools.map((t, i) => ({
|
||||||
|
id: t.id ?? `call-${i}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name: t.name, arguments: t.args ?? "{}" },
|
||||||
|
}));
|
||||||
|
return { message: msg, usage: [10, 2] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: { role: "assistant", content: s.content ?? null },
|
||||||
|
usage: [10, 2],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// expose a way for tests to script chat replies
|
||||||
|
} as ProviderService;
|
||||||
|
(provider as unknown as { setChatReply: (s: string) => void }).setChatReply = (text: string) => {
|
||||||
|
chatResponses.push(text);
|
||||||
|
};
|
||||||
|
return { provider, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ToolExecutor that echoes fixed outputs per tool. */
|
||||||
|
function fixedExecutor(outs: Record<string, string>): ToolExecutor {
|
||||||
|
return {
|
||||||
|
async execute(name) {
|
||||||
|
return outs[name] ?? "ok";
|
||||||
|
},
|
||||||
|
isParallelSafe() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(userText: string): {
|
||||||
|
params: AgentTurnParams;
|
||||||
|
events: Array<any>;
|
||||||
|
} {
|
||||||
|
const events: Array<any> = [];
|
||||||
|
const params: AgentTurnParams = {
|
||||||
|
messages: [userMessage(userText)],
|
||||||
|
session_dir: "/tmp/zesdex-flow-test",
|
||||||
|
workspace_roots: ["/nonexistent-zesdex-dir"], // no AGENTS.md/package.json side effects
|
||||||
|
turn_events: { push: (e) => events.push(e), drain: () => [] },
|
||||||
|
in_flight: { value: false },
|
||||||
|
abort: new AbortController(),
|
||||||
|
api_key: "k",
|
||||||
|
model: "m",
|
||||||
|
api_base: "https://x",
|
||||||
|
};
|
||||||
|
return { params, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `[Flow]` steering system message from a captured batch. */
|
||||||
|
function flowDirective(batch: ChatMessage[]): string {
|
||||||
|
const m = batch.find((x) => (x.content ?? "").includes("[Flow]"));
|
||||||
|
return m?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runTurn complexity steering", () => {
|
||||||
|
it("injects a complex-request directive for a complex prompt", async () => {
|
||||||
|
const { provider, seen } = makeScriptedProvider([
|
||||||
|
{ content: "final answer" },
|
||||||
|
]);
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||||
|
const { params } = buildParams("Please refactor the architecture across multiple files");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
// The complexity directive system message must be present before the LLM.
|
||||||
|
const flow = flowDirective(seen[0] ?? []);
|
||||||
|
expect(flow).toContain("flagged as complex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects a keep-it-simple directive instead", async () => {
|
||||||
|
const { provider, seen } = makeScriptedProvider([{ content: "hi" }]);
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||||
|
const { params } = buildParams("what is 2+2");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
const flow = flowDirective(seen[0] ?? []);
|
||||||
|
expect(flow).toContain("looks simple");
|
||||||
|
expect(flow).not.toContain("complex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runTurn verify-after-edit", () => {
|
||||||
|
it("injects a verify nudge after a write, cleared after a successful bash", async () => {
|
||||||
|
const { provider, seen } = makeScriptedProvider([
|
||||||
|
{ tools: [{ name: "write" }] },
|
||||||
|
{ tools: [{ name: "bash" }] },
|
||||||
|
{ content: "done" },
|
||||||
|
]);
|
||||||
|
const executor = fixedExecutor({ write: "wrote it", bash: "ok\n\nExit code: 0 (1s)" });
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||||
|
const { params } = buildParams("add a comment");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
// The LLM call after the write should carry the verify nudge.
|
||||||
|
const second = seen[1] ?? [];
|
||||||
|
expect(second.some((m) => (m.content ?? "").includes("Run the verify command"))).toBe(true);
|
||||||
|
// The successful bash exits 0, which clears pendingVerify — so the nudge is
|
||||||
|
// injected exactly once, not repeated on every later call.
|
||||||
|
const finalBatch = seen[seen.length - 1] ?? [];
|
||||||
|
const nudgeCount = finalBatch.filter((m) => (m.content ?? "").includes("Run the verify command")).length;
|
||||||
|
expect(nudgeCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runTurn convergence guard", () => {
|
||||||
|
it("stops after repeated identical read calls", async () => {
|
||||||
|
const script: ScriptedStep[] = [];
|
||||||
|
for (let i = 0; i < 6; i++) script.push({ tools: [{ name: "read" }] });
|
||||||
|
const { provider, seen } = makeScriptedProvider(script);
|
||||||
|
const executor = fixedExecutor({ read: "same content" });
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||||
|
const { params, events } = buildParams("read something");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
const warned = events.some((e) => e.kind === "system_note" && /without any progress|repeated/.test(e.message ?? ""));
|
||||||
|
expect(warned).toBe(true);
|
||||||
|
// Not every call got issued — the guard broke early.
|
||||||
|
expect(seen.length).toBeLessThan(script.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runTurn self-review", () => {
|
||||||
|
it("feeds a reviewer critique back as a system message after a mutator", async () => {
|
||||||
|
const { provider, seen } = makeScriptedProvider([
|
||||||
|
{ tools: [{ name: "edit" }] },
|
||||||
|
{ content: "fixed" },
|
||||||
|
]);
|
||||||
|
const withChat = provider as unknown as { setChatReply(s: string): void };
|
||||||
|
withChat.setChatReply("- [PRIORITY: high] handle empty input in parse()");
|
||||||
|
const executor = fixedExecutor({ edit: "edited" });
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||||
|
const { params, events } = buildParams("fix parse()");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
// The reviewer critique must have reached the next LLM call's history.
|
||||||
|
const second = seen[1] ?? [];
|
||||||
|
expect(second.some((m) => (m.content ?? "").includes("[Reviewer]"))).toBe(true);
|
||||||
|
// review_usage emitted.
|
||||||
|
expect(events.some((e) => e.kind === "review_usage")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Compaction: efficient + accurate ─────────────────────────────── */
|
||||||
|
|
||||||
|
describe("takeRecentTail", () => {
|
||||||
|
it("keeps the most recent messages and evicts an oversized older blob", () => {
|
||||||
|
const messages = [
|
||||||
|
toolResultMessage("t0", "y".repeat(5000)), // huge old tool output
|
||||||
|
userMessage("old request"),
|
||||||
|
assistantMessage("recent reply A"),
|
||||||
|
userMessage("recent reply B"),
|
||||||
|
];
|
||||||
|
const { tail, evicted } = takeRecentTail(messages, 25, 2);
|
||||||
|
expect(evicted.length).toBe(2); // the blob + the old request
|
||||||
|
expect(tail.map((m) => m.content)).toEqual(["recent reply A", "recent reply B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the whole history as tail when it is small enough", () => {
|
||||||
|
const messages = [userMessage("a"), assistantMessage("b")];
|
||||||
|
const { tail, evicted } = takeRecentTail(messages, 1000, 6);
|
||||||
|
expect(evicted).toEqual([]);
|
||||||
|
expect(tail.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a huge tool output blow the tail past the min count", () => {
|
||||||
|
// minTail 2: newest two kept regardless; the huge tool body stays evicted.
|
||||||
|
const messages = [
|
||||||
|
toolResultMessage("t0", "x".repeat(50000)),
|
||||||
|
userMessage("keep1"),
|
||||||
|
assistantMessage("keep2"),
|
||||||
|
];
|
||||||
|
const { tail, evicted } = takeRecentTail(messages, 8000, 2);
|
||||||
|
expect(tail.map((m) => m.content)).toEqual(["keep1", "keep2"]);
|
||||||
|
expect(evicted.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reduceMessagesToDigest", () => {
|
||||||
|
it("stubs tool bodies entirely and previews text", () => {
|
||||||
|
const digest = reduceMessagesToDigest([
|
||||||
|
toolResultMessage("t0", "y".repeat(5000)),
|
||||||
|
userMessage("short request"),
|
||||||
|
]);
|
||||||
|
expect(digest).toContain("- tool");
|
||||||
|
expect(digest).not.toContain("yyyy");
|
||||||
|
expect(digest).toContain("short request");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps user previews well above the shorter assistant cap", () => {
|
||||||
|
const long = "w".repeat(2000);
|
||||||
|
// User budget is 1500 chars — larger than the 400-char assistant cap, so
|
||||||
|
// a long user request survives far more of its body than a long assistant
|
||||||
|
// reply would (requests matter most for summary accuracy).
|
||||||
|
const digestUser = reduceMessagesToDigest([userMessage(long)]);
|
||||||
|
const digestAssistant = reduceMessagesToDigest([assistantMessage(long)]);
|
||||||
|
expect(digestUser.length).toBeGreaterThan(800);
|
||||||
|
expect(digestAssistant.length).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects the hard input cap", () => {
|
||||||
|
const many = [];
|
||||||
|
for (let i = 0; i < 200; i++) many.push(userMessage("r".repeat(300)));
|
||||||
|
const digest = reduceMessagesToDigest(many);
|
||||||
|
expect(digest.length).toBeLessThanOrEqual(20_000 + 40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compactMessagesWithAi", () => {
|
||||||
|
it("keeps the recent tail and prepends an AI summary", async () => {
|
||||||
|
// Long enough to bypass the min-size guard, with an oversized OLD tool blob
|
||||||
|
// (should be evicted + stubbed, not kept verbatim).
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
userMessage("old request one"),
|
||||||
|
assistantMessage("old response"),
|
||||||
|
toolResultMessage("t0", "big".repeat(4000)),
|
||||||
|
];
|
||||||
|
for (let i = 0; i < 7; i++) messages.push(userMessage(`mid ${i}`));
|
||||||
|
messages.push(toolResultMessage("t1", "tail-result"));
|
||||||
|
messages.push(userMessage("newest request"));
|
||||||
|
messages.push(assistantMessage("newest response"));
|
||||||
|
|
||||||
|
const provider: ProviderService = {
|
||||||
|
async chat() {
|
||||||
|
return { message: { role: "assistant", content: "## Requests\n- old request one" }, usage: null };
|
||||||
|
},
|
||||||
|
async chatStream() {
|
||||||
|
return { message: { role: "assistant", content: null }, usage: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await compactMessagesWithAi(messages, provider);
|
||||||
|
// Summary first, recent tail preserved verbatim, tool blobs gone.
|
||||||
|
expect(messages[0]?.content).toContain("[AI Summary of Previous Conversation]");
|
||||||
|
expect(messages.some((m) => m.content === "newest request")).toBe(true);
|
||||||
|
expect(messages.some((m) => m.content === "newest response")).toBe(true);
|
||||||
|
expect(messages.some((m) => (m.content ?? "").includes("big".repeat(10)))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades gracefully on summarizer failure, preserving the tail", async () => {
|
||||||
|
const messages = [
|
||||||
|
userMessage("old"),
|
||||||
|
toolResultMessage("t0", "x".repeat(100)),
|
||||||
|
userMessage("recent request"),
|
||||||
|
];
|
||||||
|
const failing: ProviderService = {
|
||||||
|
async chat() {
|
||||||
|
throw new Error("network down");
|
||||||
|
},
|
||||||
|
async chatStream() {
|
||||||
|
return { message: { role: "assistant", content: null }, usage: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await compactMessagesWithAi(messages, failing);
|
||||||
|
// The recent message survives even when the LLM call fails.
|
||||||
|
expect(messages.some((m) => m.content === "recent request")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+289
-18
@@ -3,23 +3,28 @@
|
|||||||
* `apps/application/src/agent/turn_service.rs`.
|
* `apps/application/src/agent/turn_service.rs`.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type AgentTurnParams,
|
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type StreamEvent,
|
type StreamEvent,
|
||||||
type ToolCall,
|
type ToolCall,
|
||||||
type ToolDef,
|
type ToolDef,
|
||||||
type TurnEventSink,
|
|
||||||
type JsonValue,
|
type JsonValue,
|
||||||
|
Roles,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
userMessage,
|
userMessage,
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
toolResultMessage,
|
toolResultMessage,
|
||||||
|
sanitizeToolArguments,
|
||||||
|
} from "@zesdex/domain";
|
||||||
|
import {
|
||||||
|
type AgentTurnParams,
|
||||||
|
type TurnEventSink,
|
||||||
mainAgentPromptWithProjectContext,
|
mainAgentPromptWithProjectContext,
|
||||||
compactionPrompt,
|
compactionPrompt,
|
||||||
errorRecoveryNote,
|
errorRecoveryNote,
|
||||||
sanitizeToolArguments,
|
reviewerPrompt,
|
||||||
} from "@zesdex/domain";
|
} from "@zesdex/agent";
|
||||||
import type { ProviderService } from "../ports/index.ts";
|
import { isComplexRequest } from "@zesdex/workflow";
|
||||||
|
import type { ProviderService } from "./ports.ts";
|
||||||
import type { ToolExecutor } from "./index.ts";
|
import type { ToolExecutor } from "./index.ts";
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -36,11 +41,89 @@ const PROJECT_CONTEXT_MAX_CHARS = 12_000;
|
|||||||
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
||||||
const COMPACT_KEEP_TAIL = 6;
|
const COMPACT_KEEP_TAIL = 6;
|
||||||
|
|
||||||
|
/** Recent-tail budget (chars): the most recent history kept verbatim on compact. */
|
||||||
|
const COMPACT_TAIL_CHARS = 8_000;
|
||||||
|
/** Caps what the summarizer actually receives, keeping input small and focused. */
|
||||||
|
const COMPACT_MAX_INPUT_CHARS = 20_000;
|
||||||
|
/** Preview budget for a user request inside the digest (requests matter most). */
|
||||||
|
const COMPACT_USER_PREVIEW_CHARS = 1_500;
|
||||||
|
/** Preview budget for assistant text inside the digest. */
|
||||||
|
const COMPACT_TEXT_PREVIEW_CHARS = 400;
|
||||||
|
|
||||||
|
/** Tools that mutate the filesystem — after these, a verify run is expected. */
|
||||||
|
const MUTATOR_TOOLS = new Set(["write", "edit", "delete"]);
|
||||||
|
|
||||||
|
/** Consecutive identical, non-progressing tool iterations before the loop stops. */
|
||||||
|
const MAX_NO_PROGRESS_STREAK = 4;
|
||||||
|
|
||||||
|
/** Self-review is bounded to this many passes per turn. */
|
||||||
|
const MAX_REVIEW_PASSES = 1;
|
||||||
|
|
||||||
/** Whether the output string denotes a tool error. */
|
/** Whether the output string denotes a tool error. */
|
||||||
function isErrorOutput(output: string): boolean {
|
function isErrorOutput(output: string): boolean {
|
||||||
return output.startsWith("Error:");
|
return output.startsWith("Error:");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract the numeric exit code from a `bash` tool result, or null if not a failure (0). */
|
||||||
|
export function bashExitCode(output: string): number | null {
|
||||||
|
const match = output.match(/Exit code:\s*(\d+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const code = Number(match[1]);
|
||||||
|
return Number.isInteger(code) && code >= 0 ? code : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a tool result is a real failure. `Error:` prefixes cover most tools;
|
||||||
|
* a `bash` command that exits non-zero returns an `Exit code: N` line instead.
|
||||||
|
*/
|
||||||
|
export function isToolFailure(toolName: string, output: string): boolean {
|
||||||
|
if (isErrorOutput(output)) return true;
|
||||||
|
if (toolName === "bash" || toolName === "bash_output") {
|
||||||
|
const code = bashExitCode(output);
|
||||||
|
if (code === null) return false; // no exit-code line → no signal
|
||||||
|
return code !== 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort derivation of the repository's verify command from convention
|
||||||
|
* manifests. Defaults to a safe lint+test invocation for Bun.
|
||||||
|
*/
|
||||||
|
export function deriveVerifyCommand(root: string): string {
|
||||||
|
const fs = require("node:fs");
|
||||||
|
// Bun project: prefer an explicit `check` (typecheck) script then test.
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf8")) as {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const s = pkg?.scripts ?? {};
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (s["check"] && typeof s["check"] === "string") parts.push(`bun run check`);
|
||||||
|
else if (s["typecheck"] && typeof s["typecheck"] === "string") parts.push(`bun run typecheck`);
|
||||||
|
if (s["lint"] && typeof s["lint"] === "string") parts.push(`bun run lint`);
|
||||||
|
if (s["test"] && typeof s["test"] === "string") parts.push(`bun run test`);
|
||||||
|
if (parts.length > 0) return parts.join(" && ");
|
||||||
|
} catch {
|
||||||
|
/* no package.json — fall through */
|
||||||
|
}
|
||||||
|
// Rust project.
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${root}/Cargo.toml`)) return "cargo check && cargo test";
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
// Java/Maven.
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${root}/pom.xml`) || fs.existsSync(`${root}/build.gradle`)) {
|
||||||
|
return "mvn test";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return "bun run check && bun test";
|
||||||
|
}
|
||||||
|
|
||||||
/** Truncate a long tool output, preserving the head + truncation marker. */
|
/** Truncate a long tool output, preserving the head + truncation marker. */
|
||||||
export function truncateToolOutput(output: string): string {
|
export function truncateToolOutput(output: string): string {
|
||||||
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
||||||
@@ -134,7 +217,7 @@ async function executeToolCall(
|
|||||||
output = `Error: ${(e as Error).message}`;
|
output = `Error: ${(e as Error).message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isError = isErrorOutput(output);
|
const isError = isToolFailure(name, output);
|
||||||
const truncated = truncateToolOutput(output);
|
const truncated = truncateToolOutput(output);
|
||||||
|
|
||||||
sink.push({
|
sink.push({
|
||||||
@@ -177,8 +260,75 @@ async function executeToolCallsInParallel(
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact oversized conversation history using AI summarisation. At most once
|
* Split history into a recent tail (kept verbatim, bound by CHAR budget so
|
||||||
* per turn. Keeps the last COMPACT_KEEP_TAIL messages.
|
* huge tool outputs don't monopolise it) and the evicted prefix to summarize.
|
||||||
|
*/
|
||||||
|
export function takeRecentTail(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
tailChars = COMPACT_TAIL_CHARS,
|
||||||
|
minTail = COMPACT_KEEP_TAIL,
|
||||||
|
): { tail: ChatMessage[]; evicted: ChatMessage[] } {
|
||||||
|
if (messages.length <= minTail) return { tail: [...messages], evicted: [] };
|
||||||
|
let used = 0;
|
||||||
|
let keep = 0;
|
||||||
|
// Walk from the newest message backward. Always keep at least minTail; then
|
||||||
|
// stop once the aggregated char budget is exceeded.
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const len = messages[i]!.content?.length ?? 0;
|
||||||
|
if (keep >= minTail && used + len > tailChars) break;
|
||||||
|
keep += 1;
|
||||||
|
used += len;
|
||||||
|
}
|
||||||
|
const tail = messages.slice(messages.length - keep);
|
||||||
|
const evicted = messages.slice(0, messages.length - keep);
|
||||||
|
return { tail, evicted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce older history to a compact digest for the summarizer. Tool-result
|
||||||
|
* bodies are dropped entirely (they are noise for a summary); user requests
|
||||||
|
* get a generous preview; assistant text gets a shorter one. Caps the result
|
||||||
|
* at COMPACT_MAX_INPUT_CHARS so the summarizer sees a small, focused input.
|
||||||
|
*/
|
||||||
|
export function reduceMessagesToDigest(messages: ChatMessage[]): string {
|
||||||
|
let out = "";
|
||||||
|
let budget = COMPACT_MAX_INPUT_CHARS;
|
||||||
|
for (const m of messages) {
|
||||||
|
if (budget <= 0) break;
|
||||||
|
if (m.role === Roles.Tool) {
|
||||||
|
const name = m.name ?? "tool";
|
||||||
|
const line = `- tool ${name} executed\n`;
|
||||||
|
out += line;
|
||||||
|
budget -= line.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = m.content?.trim() ?? "";
|
||||||
|
if (text === "") continue; // assistant messages that only carried tool calls
|
||||||
|
const cap = m.role === Roles.User ? COMPACT_USER_PREVIEW_CHARS : COMPACT_TEXT_PREVIEW_CHARS;
|
||||||
|
let preview = text.replace(/\s*\n+\s*/g, " ");
|
||||||
|
if (preview.length > cap) {
|
||||||
|
preview = `${preview.slice(0, cap)}…[+${text.length - cap} ch]`;
|
||||||
|
}
|
||||||
|
const line = `- ${m.role}: ${preview}\n`;
|
||||||
|
out += line;
|
||||||
|
budget -= line.length;
|
||||||
|
}
|
||||||
|
// Hard guarantee: never exceed the summarizer input cap.
|
||||||
|
if (out.length > COMPACT_MAX_INPUT_CHARS) return out.slice(0, COMPACT_MAX_INPUT_CHARS);
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact oversized conversation history efficiently yet accurately.
|
||||||
|
*
|
||||||
|
* - Only the OLDER history is summarized; the recent tail is kept verbatim
|
||||||
|
* (bounded by char budget, not message count, so tool bloat can't win the
|
||||||
|
* budget).
|
||||||
|
* - The evicted history is REDUCED to a small digest before the summarizer
|
||||||
|
* sees it, so the LLM works on a focused input (cheaper + more accurate)
|
||||||
|
* instead of a ~60k-char dump.
|
||||||
|
* - On failure it degrades gracefully: keeps the recent tail and a marker,
|
||||||
|
* rather than wiping context to a useless sentinel.
|
||||||
*/
|
*/
|
||||||
export async function compactMessagesWithAi(
|
export async function compactMessagesWithAi(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
@@ -186,17 +336,35 @@ export async function compactMessagesWithAi(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
||||||
|
|
||||||
const splitIdx = messages.length - COMPACT_KEEP_TAIL;
|
const { tail, evicted } = takeRecentTail(messages);
|
||||||
const evicted = messages.splice(0, splitIdx);
|
const digest = reduceMessagesToDigest(evicted);
|
||||||
|
if (digest.trim() === "") {
|
||||||
|
// Nothing worth summarizing (e.g. only stubbed tool bodies) — keep it all.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const summaryPrompt: ChatMessage[] = [systemMessage(compactionPrompt()), ...evicted, userMessage("Please summarise our previous conversation above for context continuity.")];
|
const summaryPrompt: ChatMessage[] = [
|
||||||
|
systemMessage(compactionPrompt()),
|
||||||
|
userMessage(
|
||||||
|
"The conversation below is the OLDER part of a session. Recent messages are kept separately, so do not preserve them. Compress the older part into the requested summary format.\n\n---\n" + digest,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
||||||
const summaryText = message.content ?? "Previous context summarised.";
|
const summaryText = message.content?.trim() ?? "";
|
||||||
messages.unshift(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText.trim()}`));
|
const rebuilt: ChatMessage[] = [];
|
||||||
|
if (summaryText !== "") {
|
||||||
|
rebuilt.push(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText}`));
|
||||||
|
} else {
|
||||||
|
rebuilt.push(systemMessage("[Earlier conversation summarized]"));
|
||||||
|
}
|
||||||
|
rebuilt.push(...tail);
|
||||||
|
messages.splice(0, messages.length, ...rebuilt);
|
||||||
} catch {
|
} catch {
|
||||||
messages.unshift(systemMessage("[Earlier conversation messages compacted to save context window]"));
|
// Graceful degradation: never wipe recent state on a summarizer failure.
|
||||||
|
const rebuilt: ChatMessage[] = [systemMessage("[Earlier context compressed; recent messages follow.]"), ...tail];
|
||||||
|
messages.splice(0, messages.length, ...rebuilt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,18 +434,43 @@ export class AgentTurnServiceImpl {
|
|||||||
const abort = params.abort;
|
const abort = params.abort;
|
||||||
const { in_flight } = params;
|
const { in_flight } = params;
|
||||||
|
|
||||||
// Insert system prompt at index 0 with repo conventions loaded.
|
// Insert system prompt at index 0 with repo conventions + verify command.
|
||||||
const projectContext = buildProjectContext(params.workspace_roots[0] ?? ".");
|
const root = params.workspace_roots[0] ?? ".";
|
||||||
const systemPrompt = mainAgentPromptWithProjectContext(projectContext);
|
const projectContext = buildProjectContext(root);
|
||||||
|
const verifyCommand = deriveVerifyCommand(root);
|
||||||
|
const systemPrompt = mainAgentPromptWithProjectContext(projectContext, verifyCommand);
|
||||||
params.messages.unshift(systemMessage(systemPrompt));
|
params.messages.unshift(systemMessage(systemPrompt));
|
||||||
const originalCount = params.messages.length;
|
const originalCount = params.messages.length;
|
||||||
|
|
||||||
// Estimate request complexity from the last user message.
|
// Estimate request complexity from the last user message.
|
||||||
const last = params.messages[params.messages.length - 1];
|
const last = params.messages[params.messages.length - 1];
|
||||||
const requestLen = last?.content?.length ?? 0;
|
const requestLen = last?.content?.length ?? 0;
|
||||||
|
const userText = last?.content ?? "";
|
||||||
|
|
||||||
|
// Complexity steering: a one-shot directive so the model picks the right
|
||||||
|
// depth instead of relying on prose memory. Pure and cheap.
|
||||||
|
const complex = isComplexRequest(userText);
|
||||||
|
this.push(sink, {
|
||||||
|
kind: "system_note",
|
||||||
|
systemKind: "info",
|
||||||
|
message: complex ? "Complex request detected — plan before executing." : "Simple request — keep tool use minimal.",
|
||||||
|
});
|
||||||
|
params.messages.push(
|
||||||
|
systemMessage(
|
||||||
|
complex
|
||||||
|
? "[Flow] This request is flagged as complex. Enter a short plan with `plan_enter` and track steps with `todowrite` before starting edits."
|
||||||
|
: "[Flow] This request looks simple. If you can answer directly without tools, do so — do not spawn agents or workflows for it.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const errors = new ErrorTracker();
|
const errors = new ErrorTracker();
|
||||||
let sawToolCalls = false;
|
let sawToolCalls = false;
|
||||||
|
let sawMutator = false;
|
||||||
|
let pendingVerify = false;
|
||||||
|
let verifyPrompted = false;
|
||||||
|
let noProgressStreak = 0;
|
||||||
|
let lastSignature: string | null = null;
|
||||||
|
let reviewPasses = 0;
|
||||||
|
|
||||||
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
|
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
|
||||||
// Check abort flag.
|
// Check abort flag.
|
||||||
@@ -291,6 +484,26 @@ export class AgentTurnServiceImpl {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convergence guard: bail out of a loop stuck re-issuing the same call.
|
||||||
|
if (noProgressStreak >= MAX_NO_PROGRESS_STREAK) {
|
||||||
|
this.push(sink, {
|
||||||
|
kind: "system_note",
|
||||||
|
systemKind: "warn",
|
||||||
|
message: "Stopping: the same tool call is being repeated without any progress.",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify-after-edit: nudge the model to run the check before concluding.
|
||||||
|
if (pendingVerify && !verifyPrompted) {
|
||||||
|
params.messages.push(
|
||||||
|
systemMessage(
|
||||||
|
`[Flow] You just modified files. Run the verify command via \`bash\` now (${verifyCommand}) and resolve any failures before concluding your turn.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
verifyPrompted = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-compact oversized history before the LLM call.
|
// Auto-compact oversized history before the LLM call.
|
||||||
await this.autoCompactIfNeeded(params.messages);
|
await this.autoCompactIfNeeded(params.messages);
|
||||||
|
|
||||||
@@ -341,12 +554,44 @@ export class AgentTurnServiceImpl {
|
|||||||
return seq;
|
return seq;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
let mutated = false;
|
||||||
|
let verified = false;
|
||||||
|
let batchSignature = "";
|
||||||
for (let i = 0; i < toolCalls.length; i++) {
|
for (let i = 0; i < toolCalls.length; i++) {
|
||||||
const tc = toolCalls[i]!;
|
const tc = toolCalls[i]!;
|
||||||
const output = outputs[i]!;
|
const output = outputs[i]!;
|
||||||
if (isErrorOutput(output)) errors.record(tc.function.name, output, params.messages);
|
if (isToolFailure(tc.function.name, output)) errors.record(tc.function.name, output, params.messages);
|
||||||
|
if (MUTATOR_TOOLS.has(tc.function.name)) {
|
||||||
|
mutated = true;
|
||||||
|
sawMutator = true;
|
||||||
|
}
|
||||||
|
if ((tc.function.name === "bash" || tc.function.name === "bash_output") && bashExitCode(output) === 0) {
|
||||||
|
verified = true;
|
||||||
|
}
|
||||||
|
// Batch signature: concat of tool+output for no-progress detection.
|
||||||
|
batchSignature += `${tc.function.name}�${output.length}�`;
|
||||||
params.messages.push(toolResultMessage(tc.id, output));
|
params.messages.push(toolResultMessage(tc.id, output));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No-progress: byte-identical batch (same tools, same output lengths) → streak.
|
||||||
|
if (toolCalls.length === 1 && batchSignature === lastSignature) noProgressStreak += 1;
|
||||||
|
else noProgressStreak = 0;
|
||||||
|
lastSignature = batchSignature;
|
||||||
|
|
||||||
|
if (mutated) {
|
||||||
|
pendingVerify = true;
|
||||||
|
verifyPrompted = false; // allow a fresh nudge after the next edit batch
|
||||||
|
}
|
||||||
|
if (verified) {
|
||||||
|
pendingVerify = false; // a successful bash run satisfies the verify nudge
|
||||||
|
verifyPrompted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded self-review pass after file mutations (at most one per turn).
|
||||||
|
if (sawMutator && reviewPasses < MAX_REVIEW_PASSES && !abort.signal.aborted) {
|
||||||
|
reviewPasses += 1;
|
||||||
|
await this.reviewWork(params.messages, abort, sink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the synthetic sys_msg before emitting to the transcript.
|
// Remove the synthetic sys_msg before emitting to the transcript.
|
||||||
@@ -355,4 +600,30 @@ export class AgentTurnServiceImpl {
|
|||||||
this.push(sink, { kind: "done" });
|
this.push(sink, { kind: "done" });
|
||||||
in_flight.value = false;
|
in_flight.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run one bounded self-review of the work so far and feed critique back. */
|
||||||
|
private async reviewWork(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
abort: AbortController,
|
||||||
|
sink: TurnEventSink,
|
||||||
|
): Promise<void> {
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const result = await this.provider.chat(
|
||||||
|
[systemMessage(reviewerPrompt())],
|
||||||
|
undefined,
|
||||||
|
1024,
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
const critique = result.message.content ?? "";
|
||||||
|
if (result.usage) {
|
||||||
|
this.push(sink, { kind: "review_usage", tokens_in: result.usage[0], tokens_out: result.usage[1] });
|
||||||
|
}
|
||||||
|
if (critique.trim() === "" || critique.trim().toUpperCase() === "NO ISSUES FOUND") return;
|
||||||
|
// Feed the critique back as a system message so the next iteration resolves it.
|
||||||
|
messages.push(systemMessage(`[Reviewer]\n${critique.trim()}`));
|
||||||
|
} catch {
|
||||||
|
/* review is best-effort — non-fatal */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -2,7 +2,7 @@
|
|||||||
* Domain types for agent lifecycle: turn events, session runtime, progress.
|
* Domain types for agent lifecycle: turn events, session runtime, progress.
|
||||||
* Mirrors `apps/domain/src/agent/mod.rs`.
|
* Mirrors `apps/domain/src/agent/mod.rs`.
|
||||||
*/
|
*/
|
||||||
import type { ChatMessage, JsonValue, ToolCallResult, UsageStats } from "../core/index.ts";
|
import type { ChatMessage, JsonValue, ToolCallResult, UsageStats } from "@zesdex/domain";
|
||||||
import type { AgentProgress } from "./progress.ts";
|
import type { AgentProgress } from "./progress.ts";
|
||||||
|
|
||||||
/** Which kind of caller (main agent vs subagent vs reviewer) is invoking a tool. */
|
/** Which kind of caller (main agent vs subagent vs reviewer) is invoking a tool. */
|
||||||
File renamed without changes.
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Tests pinning the rewritten prompts to be consistent with the REAL tool
|
||||||
|
* inventory: they must never reference the phantom `explore_codebase`, must
|
||||||
|
* list real tool names, and must carry the concrete verify command.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
mainAgentPrompt,
|
||||||
|
mainAgentPromptWithProjectContext,
|
||||||
|
subagentDirective,
|
||||||
|
compactionPrompt,
|
||||||
|
reviewerPrompt,
|
||||||
|
consensusSynthesizerPrompt,
|
||||||
|
errorRecoveryNote,
|
||||||
|
} from "./prompt.ts";
|
||||||
|
|
||||||
|
/** All real tool names (mirrors registry.allTools()). */
|
||||||
|
const REAL_TOOLS = new Set([
|
||||||
|
"read", "write", "edit", "delete",
|
||||||
|
"grep", "glob", "semantic_search", "rebuild_index", "list_symbols",
|
||||||
|
"bash", "bash_output", "bash_kill", "git_operator", "git_worktree", "git_cred",
|
||||||
|
"cd", "dir_list", "dir_cache_update",
|
||||||
|
"plan_enter", "plan_ready", "sequential_think", "todowrite", "todofinish",
|
||||||
|
"workflow_run", "hive_mind", "note_finding", "read_findings",
|
||||||
|
"spawn_agents", "spawn_pipeline", "parallel_delegate",
|
||||||
|
"remember", "forget", "recall",
|
||||||
|
"web_search", "best_practice", "commit_convention", "pong",
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("prompt tool-consistency", () => {
|
||||||
|
test("main prompt never references non-existent explore_codebase", () => {
|
||||||
|
expect(mainAgentPrompt()).not.toContain("explore_codebase");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every backticked tool token in the main prompt is a real tool", () => {
|
||||||
|
const s = mainAgentPrompt();
|
||||||
|
const backticks = s.match(/`([a-z_]+)`/g) ?? [];
|
||||||
|
const tokens = backticks.map((t) => t.slice(1, -1));
|
||||||
|
// `plan` and `verify` appear in backticks as prose (a command/word), not tools.
|
||||||
|
const proseWords = new Set(["plan", "verify", "check"]);
|
||||||
|
const ghosts = tokens.filter((t) => !REAL_TOOLS.has(t) && !proseWords.has(t));
|
||||||
|
expect(ghosts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("main prompt anchors the key real tools", () => {
|
||||||
|
const s = mainAgentPrompt();
|
||||||
|
for (const tool of ["plan_enter", "todowrite", "workflow_run", "spawn_agents", "hive_mind", "recall", "remember"]) {
|
||||||
|
expect(s).toContain(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verify command is injected as a concrete line when provided", () => {
|
||||||
|
const s = mainAgentPrompt("bun run check && bun test");
|
||||||
|
expect(s).toContain("VERIFY COMMAND");
|
||||||
|
expect(s).toContain("bun run check && bun test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a verify command, no VERIFY COMMAND block is emitted", () => {
|
||||||
|
expect(mainAgentPrompt()).not.toContain("VERIFY COMMAND");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project-context variant appends the context block", () => {
|
||||||
|
const s = mainAgentPromptWithProjectContext("## Rules\nno threads", "bun test");
|
||||||
|
expect(s).toContain("## PROJECT CONTEXT");
|
||||||
|
expect(s).toContain("no threads");
|
||||||
|
expect(s).toContain("bun test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subagentDirective", () => {
|
||||||
|
test("includes the directive, cwd, root, and access tier", () => {
|
||||||
|
const s = subagentDirective("make the button green", "/p", "/p/src", "read");
|
||||||
|
expect(s).toContain("make the button green");
|
||||||
|
expect(s).toContain("/p");
|
||||||
|
expect(s).toContain("READ-ONLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no-tier call defaults to full access", () => {
|
||||||
|
const s = subagentDirective("do it", "/p", "/p");
|
||||||
|
expect(s).toContain("full tool set");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("other prompts", () => {
|
||||||
|
test("compaction asks for structured sections", () => {
|
||||||
|
expect(compactionPrompt()).toContain("## Requests");
|
||||||
|
expect(compactionPrompt()).toContain("## Work done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviewer emits NO ISSUES FOUND sentinel contract", () => {
|
||||||
|
expect(reviewerPrompt()).toContain("NO ISSUES FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consensus synthesizer covers the four sections", () => {
|
||||||
|
const s = consensusSynthesizerPrompt();
|
||||||
|
expect(s).toContain("AGREEMENTS");
|
||||||
|
expect(s).toContain("CONFLICTS");
|
||||||
|
expect(s).toContain("KEY FINDINGS");
|
||||||
|
expect(s).toContain("RECOMMENDATION");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error recovery note is present", () => {
|
||||||
|
expect(errorRecoveryNote("read", "nope")).toContain("[System note]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* System prompts and directive templates. Mirrors `agent/prompt.rs`.
|
||||||
|
*
|
||||||
|
* Every string here is written against the REAL built-in tool inventory
|
||||||
|
* (see `infrastructure/tools/registry.ts`). There is intentionally no
|
||||||
|
* reference to a hypothetical `explore_codebase`; orientation is done through
|
||||||
|
* the actual `read` / `grep` / `glob` / `semantic_search` tools.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MUTATOR_TOOLS = "`write`, `edit`, `delete`";
|
||||||
|
|
||||||
|
/** Shared operating rules referenced by main and sub-agent prompts. */
|
||||||
|
const SHARED_DISCIPLINE = `TOOL DISCIPLINE:
|
||||||
|
- Tool failures arrive prefixed with \`Error:\`. A \`bash\` command that exits non-zero returns an \`Exit code: N\` line (NOT an \`Error:\` prefix) — treat a non-zero exit the same as a failure.
|
||||||
|
- Read before you write: never edit a file you have not read first.
|
||||||
|
- Make the smallest correct change. Do not rewrite large sections unless the task explicitly calls for it.
|
||||||
|
- Do not re-read files whose contents are already in your context. Prefer targeted \`grep\`/\`read\` over broad scans (\`glob\`, \`semantic_search\`) once you know where the code lives.
|
||||||
|
- Follow the repository conventions from the PROJECT CONTEXT block when present.`;
|
||||||
|
|
||||||
|
/** Build the main-agent system prompt. */
|
||||||
|
export function mainAgentPrompt(verifyCommand?: string): string {
|
||||||
|
return `You are Zesdex, an autonomous AI coding agent. You help by doing real work against the user's repository using native function-calling tools.
|
||||||
|
|
||||||
|
HOW TO WORK (choose the lightest sufficient path):
|
||||||
|
1. Simple or factual: answer directly. Do NOT call tools.
|
||||||
|
2. Needs code context: orient ONCE with \`read\`/\`grep\`/\`glob\` (or \`semantic_search\` for fuzzy questions), then work from that context.
|
||||||
|
3. Multi-step or complex: enter a plan with \`plan_enter\`, track steps with \`todowrite\`, then execute.
|
||||||
|
4. Genuinely parallel or multi-angle exploration: prefer \`workflow_run\`, \`spawn_agents\`, \`parallel_delegate\`, or \`hive_mind\`. Do NOT spawn agents for work you can do directly in a few steps.
|
||||||
|
|
||||||
|
${SHARED_DISCIPLINE}
|
||||||
|
|
||||||
|
VERIFICATION CONTRACT:
|
||||||
|
- After any of ${MUTATOR_TOOLS}, run the repository's verify command below and resolve failures before concluding your turn.
|
||||||
|
- Only claim code compiles or tests pass after running a real check.
|
||||||
|
${verifyCommand ? `\nVERIFY COMMAND (run via \`bash\`):\n${verifyCommand}` : ""}
|
||||||
|
|
||||||
|
OUTPUT CONTRACT:
|
||||||
|
- End with a concise summary of what you did and an explicit next step. Do not dump raw tool output into your reply.
|
||||||
|
- If you cannot complete something, state exactly what is blocking you.
|
||||||
|
- Reply in the same language the user writes in.
|
||||||
|
|
||||||
|
MEMORY:
|
||||||
|
- At the start of a task, \`recall\` memory that may be relevant; use \`remember\` to store any hard-won insight or lesson worth keeping.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main-agent prompt with an optional project-context block and verify command. */
|
||||||
|
export function mainAgentPromptWithProjectContext(
|
||||||
|
projectContext: string,
|
||||||
|
verifyCommand?: string,
|
||||||
|
): string {
|
||||||
|
const base = mainAgentPrompt(verifyCommand);
|
||||||
|
const context = projectContext.trim();
|
||||||
|
if (context === "") return base;
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
## PROJECT CONTEXT (repo rules — follow these conventions)
|
||||||
|
${context}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a subagent directive prompt. Access tier tells the subagent its limits. */
|
||||||
|
export function subagentDirective(
|
||||||
|
directive: string,
|
||||||
|
cwd: string,
|
||||||
|
wsRoot: string,
|
||||||
|
access: "read" | "write" | "full" = "full",
|
||||||
|
): string {
|
||||||
|
const tierNote =
|
||||||
|
access === "read"
|
||||||
|
? "You have READ-ONLY tools: you may inspect files and search, but must NOT modify anything."
|
||||||
|
: access === "write"
|
||||||
|
? "You have WRITE tools (file edits) but NOT shell or network execution."
|
||||||
|
: "You have the full tool set.";
|
||||||
|
return `You are a focused subagent working autonomously on a single directive.
|
||||||
|
|
||||||
|
Current directory (PWD): ${cwd}
|
||||||
|
Workspace root: ${wsRoot}
|
||||||
|
Access tier: ${access} — ${tierNote}
|
||||||
|
|
||||||
|
${SHARED_DISCIPLINE}
|
||||||
|
|
||||||
|
Your directive (source of truth — follow it exactly):
|
||||||
|
${directive}
|
||||||
|
|
||||||
|
Complete the directive autonomously with the tools available to you. When done, return a concise final answer that reports what you changed (or found) and any open issues. Do not ask for permission; act within your access tier.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a conversation-compaction prompt. */
|
||||||
|
export function compactionPrompt(): string {
|
||||||
|
return `You are condensing a long conversation to preserve context continuity without losing important detail.
|
||||||
|
|
||||||
|
Produce a concise markdown recap with these sections (skip any section that has no content):
|
||||||
|
- ## Requests: what the user asked for.
|
||||||
|
- ## Decisions: important choices made and why.
|
||||||
|
- ## Work done: tools executed and files created or modified (with paths).
|
||||||
|
- ## Open threads: unresolved items, pending questions, or what is still to do.
|
||||||
|
|
||||||
|
Be precise about file paths and current state. Do not invent details not present in the conversation.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directive for a lightweight context-scout subagent. */
|
||||||
|
export function exploreScoutDirective(): string {
|
||||||
|
return `You are a codebase context scout. Given the workspace root, quickly locate the code that is most relevant to the user's request:
|
||||||
|
1. Search for the relevant symbols/terms with \`grep\` or \`glob\`; use \`semantic_search\` only when the query is fuzzy.
|
||||||
|
2. Read up to the 3 most relevant files.
|
||||||
|
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of what you found and exactly where (file paths).
|
||||||
|
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System note injected after repeated tool errors. */
|
||||||
|
export function errorRecoveryNote(toolName: string, lastError: string): string {
|
||||||
|
return `[System note] The tool \`${toolName}\` failed repeatedly with: "${lastError}". Try an alternative approach (verify paths, correct arguments, use a different tool, or finish without this tool). Do NOT retry the same call.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System prompt for the optional single-pass self-review after edits. */
|
||||||
|
export function reviewerPrompt(): string {
|
||||||
|
return `You are a code reviewer for an autonomous agent's work-in-progress. You are shown the conversation and the changes made so far.
|
||||||
|
|
||||||
|
Critique the work for:
|
||||||
|
- CORRECTNESS: would the changes work as intended? any obvious bugs, edge cases, or missing pieces?
|
||||||
|
- SPEC COMPLIANCE: did the agent actually do what was asked, or did it drift?
|
||||||
|
- QUALITY: are the changes minimal, conventional, and consistent with the repository's style?
|
||||||
|
|
||||||
|
Return a tight list of the most important ACTIONABLE issues only, each as:
|
||||||
|
- [PRIORITY: high|medium] <one concrete fix>, e.g. "high: handle empty input in parse()" or "medium: run the tests; they reference a removed export".
|
||||||
|
|
||||||
|
Do NOT praise the work. Do NOT restate the plan. If the work is already correct and complete, return exactly the single line "NO ISSUES FOUND". Be concrete enough that a next iteration can act without re-deriving context.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System prompt for reconciling multi-node hive-mind outputs into one consensus. */
|
||||||
|
export function consensusSynthesizerPrompt(): string {
|
||||||
|
return `You are a consensus synthesizer for a multi-agent hive mind. Several independent nodes analysed a problem and produced the outputs below. Distill them into ONE coherent consensus report with these sections:
|
||||||
|
- AGREEMENTS: points multiple nodes converge on.
|
||||||
|
- CONFLICTS: contradictory conclusions, with which node(s) support each side.
|
||||||
|
- KEY FINDINGS: the most important, actionable takeaways.
|
||||||
|
- RECOMMENDATION: a single recommended next action, or 'no clear consensus' if the outputs are too divergent.
|
||||||
|
|
||||||
|
Be concise and factual. If a node errored, note it and ignore its content.
|
||||||
|
Do not invent facts not present in the node outputs.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Agent feature — LLM turn loop, tool-execution runtime, subagent-spawning tools.
|
||||||
|
* Vertical slice: domain (turn events/runtime) → application (turn service + ports)
|
||||||
|
* → infrastructure (LLM client + tool registry/executor). */
|
||||||
|
export * from "./domain/index.ts";
|
||||||
|
export * from "./application/index.ts";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Agent infrastructure — concrete LLM client + tool-execution runtime.
|
||||||
|
* Consumed by the composition root. */
|
||||||
|
export { LlmClient, resolveApiKey } from "./llm/provider.ts";
|
||||||
|
export { InfrastructureToolExecutor } from "./tools/executor.ts";
|
||||||
|
export { allTools, toolDefs } from "./tools/registry.ts";
|
||||||
|
export { type ToolCtx } from "./tools/context.ts";
|
||||||
File renamed without changes.
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LlmClient.listModels() — the live /models fetch backing the TUI
|
||||||
|
* model picker. Mocks global fetch so no network is touched.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, mock } from "bun:test";
|
||||||
|
import { LlmClient } from "./provider.ts";
|
||||||
|
|
||||||
|
/** Replace global fetch once, returning the given JSON body for /models. */
|
||||||
|
async function listWith(fetchImpl: (url: string) => Promise<Response>): Promise<string[]> {
|
||||||
|
const orig = globalThis.fetch;
|
||||||
|
globalThis.fetch = fetchImpl as typeof fetch;
|
||||||
|
try {
|
||||||
|
const client = new LlmClient("key", "some-model", "https://api.example.com/v1");
|
||||||
|
return await client.listModels();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LlmClient.listModels", () => {
|
||||||
|
test("parses OpenAI-style { data: [{id}] }", async () => {
|
||||||
|
const rows = await listWith(async (url: string) => {
|
||||||
|
expect(url).toBe("https://api.example.com/v1/models");
|
||||||
|
return new Response(JSON.stringify({ data: [{ id: "gpt-5" }, { id: "deepseek-v3" }] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(rows).toEqual(["gpt-5", "deepseek-v3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses { models: [string] } variant", async () => {
|
||||||
|
const rows = await listWith(async () =>
|
||||||
|
new Response(JSON.stringify({ models: ["a", "b"] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out missing ids", async () => {
|
||||||
|
const rows = await listWith(async () =>
|
||||||
|
new Response(JSON.stringify({ data: [{ id: "x" }, {}, { id: "y" }] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual(["x", "y"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] on non-OK or throw", async () => {
|
||||||
|
const rows = await listWith(async () => new Response("forbidden", { status: 403 }));
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void mock;
|
||||||
+32
-5
@@ -8,14 +8,12 @@ import {
|
|||||||
type StreamEvent,
|
type StreamEvent,
|
||||||
type ToolCall,
|
type ToolCall,
|
||||||
type ToolDef,
|
type ToolDef,
|
||||||
type Settings,
|
|
||||||
type AppConfig,
|
|
||||||
SseParser,
|
SseParser,
|
||||||
Roles,
|
Roles,
|
||||||
DEFAULT_API_BASE,
|
|
||||||
DEFAULT_MODEL,
|
|
||||||
} from "@zesdex/domain";
|
} from "@zesdex/domain";
|
||||||
import type { ProviderService } from "@zesdex/application";
|
import type { Settings, AppConfig } from "@zesdex/cms";
|
||||||
|
import { DEFAULT_API_BASE, DEFAULT_MODEL } from "@zesdex/agent";
|
||||||
|
import type { ProviderService } from "../../application/ports.ts";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = DEFAULT_API_BASE;
|
const DEFAULT_BASE_URL = DEFAULT_API_BASE;
|
||||||
const REQUEST_TIMEOUT_MS = 600_000;
|
const REQUEST_TIMEOUT_MS = 600_000;
|
||||||
@@ -125,6 +123,35 @@ export class LlmClient implements ProviderService {
|
|||||||
return `API request failed: ${err?.message ?? String(e)}`;
|
return `API request failed: ${err?.message ?? String(e)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the model ids exposed by the provider's `/models` endpoint
|
||||||
|
* (OpenAI/Anthropic-compatible). Returns an empty array when the endpoint
|
||||||
|
* isn't exposed or the call fails — callers fall back to static catalogs.
|
||||||
|
*/
|
||||||
|
async listModels(): Promise<string[]> {
|
||||||
|
const url = `${this.baseUrl}/models`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: this.headers(),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = (await resp.json()) as {
|
||||||
|
data?: { id?: string }[] | null;
|
||||||
|
models?: (string | { id?: string })[] | null;
|
||||||
|
};
|
||||||
|
const fromData = (data.data ?? []).map((m) => m?.id ?? "").filter(Boolean);
|
||||||
|
if (fromData.length) return fromData;
|
||||||
|
const fromModels = (data.models ?? [])
|
||||||
|
.map((m) => (typeof m === "string" ? m : m?.id ?? ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
return fromModels;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
tools?: ToolDef[],
|
tools?: ToolDef[],
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import * as path from "node:path";
|
|||||||
import type { JsonValue } from "@zesdex/domain";
|
import type { JsonValue } from "@zesdex/domain";
|
||||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||||
import { argStr } from "./util.ts";
|
import { argStr } from "./util.ts";
|
||||||
import { bashControl } from "../bgbash/control.ts";
|
import { bashControl } from "./bgbash/control.ts";
|
||||||
|
|
||||||
/** Get the output of a background bash job by ID. */
|
/** Get the output of a background bash job by ID. */
|
||||||
export class BashOutput implements Tool {
|
export class BashOutput implements Tool {
|
||||||
File renamed without changes.
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
* Built-in best-practice tools. Mirrors `tools/best_practice.rs`.
|
* Built-in best-practice tools. Mirrors `tools/best_practice.rs`.
|
||||||
*/
|
*/
|
||||||
import type { JsonValue } from "@zesdex/domain";
|
import type { JsonValue } from "@zesdex/domain";
|
||||||
import { type Tool, type ToolCtx } from "../tools/mod.ts";
|
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||||
import { argStr, optStr } from "../tools/util.ts";
|
import { argStr, optStr } from "../util.ts";
|
||||||
import { BestPracticeEngine } from "./engine.ts";
|
import { BestPracticeEngine } from "./engine.ts";
|
||||||
|
|
||||||
const engine = new BestPracticeEngine();
|
const engine = new BestPracticeEngine();
|
||||||
Loaded 50 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user