Compare commits

...
21 Commits
Author SHA1 Message Date
semantic-release-bot 29585cc630 chore(release): 1.25.0 [skip ci]
# [1.25.0](https://github.com/asepharyana/zesdex/compare/v1.24.0...v1.25.0) (2026-09-03)

### Features

* **tui:** enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing ([5c65f8f](https://github.com/asepharyana/zesdex/commit/5c65f8f6ab093dbfad682939a9fcae79a8a68661))
2026-09-03 09:39:11 +00:00
asepharyana 5c65f8f6ab feat(tui): enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing 2026-09-03 16:38:21 +07:00
semantic-release-bot 7e2eda3bb4 chore(release): 1.24.0 [skip ci]
# [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))
2026-09-03 09:14:28 +00:00
asepharyana e6718fa118 feat(tui): implement message compaction and summarization logic for efficient conversation handling 2026-09-03 16:13:29 +07:00
asepharyana f98030f28a feat(tui): add new flow helpers for bash exit code handling and verification prompts 2026-09-03 16:13:29 +07:00
asepharyana 93e5464370 feat(tui): implement live model fetching and enhance model selection menus 2026-09-03 16:13:29 +07:00
asepharyana f5b04214cf feat(tui): enhance command execution on autocomplete and improve UI responsiveness 2026-09-03 16:13:29 +07:00
asepharyana 008f737e1f feat(tui): enhance autocomplete functionality and UI interactions 2026-09-03 16:13:29 +07:00
asepharyana 2100e7ab54 feat(tui): implement interactive menu system for model and config selection 2026-09-03 16:13:29 +07:00
asepharyana c53fdfa7c9 feat(tui): enhance model selection and improve UI components
- Added tests for handling key events in model selector, including Enter and ArrowDown functionality.
- Updated TUI layout to include a new header, activity bar, and improved status bar with spinner and uptime.
- Refactored transcript rendering to support markdown and improved message display.
- Introduced overlays for model selection, help, and editor with appropriate rendering logic.
- Implemented smoke tests for various TUI states to ensure UI integrity and functionality.
2026-09-03 16:13:29 +07:00
semantic-release-bot 5912a35bbb chore(release): 1.23.2 [skip ci]
## [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))
2026-09-03 05:29:53 +00:00
zesdex d86d6aeef2 fix(tui): sliding-window stream reconciler + UI polish
- Replace naive reconcileStream with stateful sliding-window replay
  merger: tracks per-message replay cursors (_posContent/_posReasoning)
  to deduplicate the segment-replay waves 9router emits. Verified
  against 970 real captured chunks (100% clean output).
- TranscriptMessage: colored [TAG] headers, 4-space indented body,
  thin separator lines between messages, dimmed reasoning.
- InputView: divider above prompt, '❯' prompt, cursor block.
- 75 tests pass, typecheck clean, biome clean.
2026-09-03 12:28:56 +07:00
semantic-release-bot 30cc579773 chore(release): 1.23.1 [skip ci]
## [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))
2026-09-03 04:01:28 +00:00
asepharyana a54f1644f5 fix(tui): repair layout overflow + streaming dup; polish UI/state
Two real bugs squashed:
- Layout: giving the header box and scrollbox an explicit width in a
  column-flex root made OpenTUI overflow vertically, pushing the input
  row and status bar entirely off-screen (only header+one message showed).
  Root cause confirmed in a minimal probe; fix = drop the redundant
  width so column children size off the root box.
- Streaming: 9router's OpenAI-compatible layer emits FULL-TEXT chunks
  (the whole transcript so far) instead of incremental deltas, so naive
  content+=token duplicated text ("Hello! 👋 How canHello! 👋 How can...").
  Added reconcileStream() that accepts both incremental and full-text
  modes (shorter-replay → ignore, growing-authoritative → adopt, else
  append), unit-tested for both.

UI/state polish:
- Rich transcript: per-role colour + labelled blocks (YOU/AI/SYS/TOOL),
  word-wrapped to terminal width, reasoning dimmed, no misleading
  "(empty)" on tool-calling assistant messages.
- Header: app · real model · session, spread across full width.
- Status bar: ● running/idle, live token usage (in/out), autocomplete
  hint, active toast.
- Bordered autocomplete dropdown + full-width help/overlay panels.
- Turn runner streams all TurnEvents into state (tool markers, agent
  progress, workflow updates, toasts, usage) — ui.tsx is now display-only.
2026-09-03 11:00:29 +07:00
semantic-release-bot 23e68dc979 chore(release): 1.23.0 [skip ci]
# [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))
2026-09-03 01:34:43 +00:00
asepharyana 8c99c6b1ca fix(ci): Release workflow install semantic-release tanpa merusak package.json
npm install -D menulis 5 paket semantic-release + 333 dep ke devDependencies
package.json — padahal repo pakai bun.lock. prepareCmd lalu jalankan
'bun install --frozen-lockfile' yang melihat package.json 'terpolusi'
(1561 paket vs 30 di bun.lock) -> 'lockfile had changes, but lockfile is
frozen' -> Release selalu gagal.

Fix: install semantic-release ke prefix terisolasi /tmp/sr-tools + tambah ke
GITHUB_PATH, jalankan 'semantic-release' langsung (bukan npx yang pakai
proses -D). prepareCmd (--frozen-lockfile) dibiarkan benar: bun.lock
version-agnostic, verifikasi lokal lolos.
2026-09-03 08:33:44 +07:00
asepharyana 9b28a829e5 chore: bersihkan flake.lock Rust/Nix mati — deploy infra 100% Bun
Todo Fase 6 final: hapus flake.lock (artefak era Rust/Nix, tidak ada flake.nix
lagi, deploy.yml sudah bun build --compile + scp binary). Verifikasi wire-shape
lama selesai: settings.json (api_keys/provider/model/review_*) diload true di
headless + TUI real agent. Gate: tsc 0 error, bun test 70 pass, TUI render.
2026-09-03 08:30:23 +07:00
asepharyana b5ebd07fa8 refactor: rombak arsitektur file ke modular clean-arch (vertikal per-feature)
Organisasi ulang layer-first (domain/application/infrastructure) menjadi
vertikal per-feature, behavior tidak berubah:

- src/shared/domain/  -> fondasi bersama (ex-domain/core): message, provider,
  tool_call, tool_result, store, error, usage, conversation
- src/features/agent/ -> LLM turn loop: domain (TurnEvent, AgentTurnParams) +
  application (turn_service, ports) + infrastructure (llm LlmClient + tools)
- src/features/cms/   -> settings/conversation/memory persistence: domain +
  application (services) + infrastructure (persistence repos)
- src/features/subagent/ -> orchestration: domain (AccessTier) + infrastructure
  (engine runner, context, provider, delegate, spawn_tools)
- src/features/workflow/ -> multi-phase + hive-mind: domain + infrastructure
- src/interfaces/     -> cli + tui (outer ring) tetap

Subagent-runner (engine.ts runAgent, division.ts toolsFor) dipindah ke
features/subagent (bukan agent tools) utk memutus coupling agent<->subagent.

Alias tsconfig: @zesdex/domain -> shared/domain, + @zesdex/agent,
@zesdex/agent-infra, @zesdex/cms, @zesdex/subagent, @zesdex/workflow, @zesdex/shared.
Bersihkan dead: application/ports PasswordService/TokenService/AuthService.
compose.ts (composition root) & bootstrap repoint ke barrel feature.

Gates: tsc --noEmit 0 error, bun test 70 pass/0 fail, bun build 147 modules,
./dist/zesdex --headless -> real turn OK.
2026-09-03 02:30:12 +07:00
asepharyana 7e6ed34f0d refactor: rombak jadi full CLI/TUI only — hapus semua interface server (api/ws/grpc/web/daemon) + wire TUI ke agent beneran
- Hapus total interfaces: api (REST), ws (WebSocket), grpc, web, daemon (IPC)
- Hapus infra server-only yang jadi dead code: infrastructure/ipc + seluruh
  stack auth (domain/auth, application/auth, persistence/iam, infrastructure/auth jwt/password/oauth_loopback)
- TUI (OpenTUI) jadi interface utama: wire ke runSingleProcess() real runtime,
  ganti mock '(no LLM configured)' dengan turnService.runTurn asli + streaming
  stream_token/reasoning/tool_result/system_note/error ke transcript
- Tampilkan model asli di header (mis. claude-opus-5), abort turn via Ctrl+C
- CLI jadi mode headless tambahan: zesdex --headless '<prompt>' one-shot turn
- Bersihkan package.json (script tui/headless), tsconfig paths, docker-compose,
  AGENTS.md; hapus StartOAuth/login stub
- verified: tsc 0 err, 70 test pass, build dist/zesdex; headless 'pong' real; TUI
  interactive render + real turn 'say pong in one word' -> assistant 'pong'
2026-09-03 01:19:27 +07:00
asepharyana 34322e492a chore: hapus infra Nix Rust mati; deploy.yml rombak ke Bun native
Semua flake/default/shell merujuk Cargo.lock + crate 'zesdex-gateway' yang
sudah dihapus saat rombak monorepo ke project tunggal Bun, sehingga
nix build .#default pasti gagal. Hapus infra Nix yang tak bisa dipakai:

- hapus flake.nix, default.nix, shell.nix, flakehub-publish-rolling.yaml
- deploy.yml: ganti nix build .#default -> setup-bun + bun build --compile
  + scp dist/zesdex -> /usr/local/bin/zesdex -> systemctl restart zesdex
- docker-compose.yml: RUST_LOG usang -> ZESDEX_API_PORT (env TS)

verify: tsc 0 err, bun test 79 pass, dist/zesdex build OK.
2026-09-03 00:14:22 +07:00
asepharyana 3e260e6077 refactor(tui): rombak renderer ke OpenTUI React bindings
Ganti renderer ANSI line-based (render.ts) dan event loop raw stdin dengan
OpenTUI React bindings (@opentui/react + @opentui/core, React 19.2):

- ui.tsx: komponen ZesdexApp (header/scrollbox transkrip/input/footer),
  adaptor toControllerKey (OpenTUI KeyEvent -> controller KeyEvent),
  bootTui (createCliRenderer + createRoot + mount).
- run.ts: pakai bootTui, jalankan OpenTUI sampai user quit.
- state/action/command/controller tetap logika murni (tidak tersentuh).
- 5 unit test baru untuk toControllerKey; 79 test total hijau.
- tsconfig: jsx react-jsx + jsxImportSource @opentui/react.
- hapus render.ts (tidak terpakai); hapus script format rusak (bun fmt).
- deps: @opentui/core, @opentui/react, react, @types/react.

verify: tsc 0 err, bun test 79 pass, dist/zesdex merender TUI via pty.
2026-09-03 00:10:01 +07:00
210 changed files with 4206 additions and 4957 deletions

No files matched your search

+19 -17
View File
@@ -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
+5 -3
View File
@@ -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
+39 -22
View File
@@ -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.
+62
View File
@@ -1,3 +1,65 @@
# [1.25.0](https://github.com/asepharyana/zesdex/compare/v1.24.0...v1.25.0) (2026-09-03)
### Features
* **tui:** enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing ([5c65f8f](https://github.com/asepharyana/zesdex/commit/5c65f8f6ab093dbfad682939a9fcae79a8a68661))
# [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 -4
View File
@@ -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`.
--- ---
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-60
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "zesdex", "name": "zesdex",
"version": "1.22.0", "version": "1.25.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"
} }
} }
-17
View File
@@ -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);
});
});
-358
View File
@@ -1,358 +0,0 @@
/**
* Agent turn service — the core adaptive turn loop. Mirrors
* `apps/application/src/agent/turn_service.rs`.
*/
import {
type AgentTurnParams,
type ChatMessage,
type StreamEvent,
type ToolCall,
type ToolDef,
type TurnEventSink,
type JsonValue,
systemMessage,
userMessage,
assistantMessage,
toolResultMessage,
mainAgentPromptWithProjectContext,
compactionPrompt,
errorRecoveryNote,
sanitizeToolArguments,
} from "@zesdex/domain";
import type { ProviderService } from "../ports/index.ts";
import type { ToolExecutor } from "./index.ts";
/* -------------------------------------------------------------------------- */
/* Constants (mirrors Rust) */
/* -------------------------------------------------------------------------- */
const MAX_TURN_ITERATIONS = 50;
const MAX_CONSECUTIVE_TOOL_ERRORS = 3;
const MAX_TOTAL_TOOL_ERRORS = 8;
const TOOL_OUTPUT_MAX_CHARS = 12_000;
const AUTO_COMPACT_CHARS = 60_000;
const MAX_PARALLEL_TOOLS = 8;
const PROJECT_CONTEXT_MAX_CHARS = 12_000;
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
const COMPACT_KEEP_TAIL = 6;
/** Whether the output string denotes a tool error. */
function isErrorOutput(output: string): boolean {
return output.startsWith("Error:");
}
/** Truncate a long tool output, preserving the head + truncation marker. */
export function truncateToolOutput(output: string): string {
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
const head = output.slice(0, TOOL_OUTPUT_MAX_CHARS);
return `${head}\n...[truncated ${output.length - TOOL_OUTPUT_MAX_CHARS} chars]`;
}
/** Pick a `max_tokens` budget based on the user's request length. */
export function adaptiveMaxTokens(requestLen: number): number {
if (requestLen <= 80) return 800;
if (requestLen <= 400) return 1600;
return 4096;
}
/** Sum character length of message content as a context-size proxy. */
export function conversationChars(messages: ChatMessage[]): number {
return messages.reduce((acc, m) => acc + (m.content?.length ?? 0), 0);
}
/** Best-effort build of project context from convention rule files. */
export function buildProjectContext(root: string): string {
let ctx = "";
for (const file of RULE_FILENAMES) {
try {
const content = requireNodeFsReadFile(root, file);
ctx += `\n### ${file}\n\`\`\`\n${content.trim()}\n\`\`\``;
} catch {
/* file missing — skip */
}
}
const context = ctx.trim();
if (context.length <= PROJECT_CONTEXT_MAX_CHARS) return context;
return `${context.slice(0, PROJECT_CONTEXT_MAX_CHARS)}\n...[project context truncated]`;
}
/** Read a repo rule file synchronously (Bun-compatible). */
function requireNodeFsReadFile(root: string, file: string): string {
const fs = require("node:fs");
return fs.readFileSync(`${root}/${file}`, "utf8");
}
/* -------------------------------------------------------------------------- */
/* ErrorTracker */
/* -------------------------------------------------------------------------- */
/** Track repeated tool-call errors so the loop can recover. */
export class ErrorTracker {
consecutive = 0;
total = 0;
lastTool: string | null = null;
lastError = "";
record(toolName: string, error: string, messages: ChatMessage[]): void {
if (this.lastTool === toolName) {
this.consecutive += 1;
} else {
this.consecutive = 1;
}
this.lastTool = toolName;
this.lastError = error;
this.total += 1;
const sysNoteInContext = messages.some((m) => m.content?.includes("[System note]"));
if (this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS && !sysNoteInContext) {
messages.push(systemMessage(errorRecoveryNote(toolName, error)));
}
}
shouldStop(): boolean {
return this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || this.total >= MAX_TOTAL_TOOL_ERRORS;
}
}
/* -------------------------------------------------------------------------- */
/* Tool execution */
/* -------------------------------------------------------------------------- */
/** Execute one tool call, push events, return the result string. */
async function executeToolCall(
executor: ToolExecutor,
sink: TurnEventSink,
tc: ToolCall,
): Promise<string> {
const name = tc.function.name;
const args = sanitizeToolArguments(tc.function.arguments);
let output: string;
try {
output = await executor.execute(name, args as JsonValue);
} catch (e) {
output = `Error: ${(e as Error).message}`;
}
const isError = isErrorOutput(output);
const truncated = truncateToolOutput(output);
sink.push({
kind: "tool_result",
tool_call_id: tc.id,
tool_name: name,
output: truncated,
is_error: isError,
path: null,
});
return truncated;
}
/** Execute a batch of read-only tool calls concurrently (bounded) in original order. */
async function executeToolCallsInParallel(
executor: ToolExecutor,
sink: TurnEventSink,
toolCalls: ToolCall[],
): Promise<string[]> {
// Simple bounded concurrency preserving input order.
const results: string[] = new Array(toolCalls.length);
let next = 0;
async function worker() {
while (true) {
const idx = next++;
if (idx >= toolCalls.length) return;
results[idx] = await executeToolCall(executor, sink, toolCalls[idx]!);
}
}
const workers = Array.from({ length: Math.min(MAX_PARALLEL_TOOLS, toolCalls.length) }, () => worker());
await Promise.all(workers);
return results;
}
/* -------------------------------------------------------------------------- */
/* Compaction */
/* -------------------------------------------------------------------------- */
/**
* Compact oversized conversation history using AI summarisation. At most once
* per turn. Keeps the last COMPACT_KEEP_TAIL messages.
*/
export async function compactMessagesWithAi(
messages: ChatMessage[],
provider: ProviderService,
): Promise<void> {
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
const splitIdx = messages.length - COMPACT_KEEP_TAIL;
const evicted = messages.splice(0, splitIdx);
const summaryPrompt: ChatMessage[] = [systemMessage(compactionPrompt()), ...evicted, userMessage("Please summarise our previous conversation above for context continuity.")];
try {
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
const summaryText = message.content ?? "Previous context summarised.";
messages.unshift(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText.trim()}`));
} catch {
messages.unshift(systemMessage("[Earlier conversation messages compacted to save context window]"));
}
}
/* -------------------------------------------------------------------------- */
/* AgentTurnServiceImpl */
/* -------------------------------------------------------------------------- */
/** Service implementation for executing an agent turn asynchronously. */
export class AgentTurnServiceImpl {
private provider: ProviderService;
private toolExecutor: ToolExecutor;
private toolDefs: ToolDef[];
constructor(provider: ProviderService, toolExecutor: ToolExecutor, toolDefs: ToolDef[]) {
this.provider = provider;
this.toolExecutor = toolExecutor;
this.toolDefs = toolDefs;
}
/** Emit a TurnEvent onto the sink (no-op if the sink is missing). */
private push(sink: TurnEventSink, event: Parameters<TurnEventSink["push"]>[0]): void {
sink.push(event);
}
/** Execute a single LLM stream call, forwarding tokens and checking abort. */
private async callLlm(
messages: ChatMessage[],
abort: AbortController,
sink: TurnEventSink,
maxTokens: number,
temperature: number,
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
const onEvent = (event: StreamEvent): boolean => {
if (abort.signal.aborted) return false;
if (event.kind === "token") this.push(sink, { kind: "stream_token", content: event.content });
else if (event.kind === "reasoning") this.push(sink, { kind: "stream_reasoning", content: event.content });
return true;
};
try {
return await this.provider.chatStream(messages, this.toolDefs, maxTokens, temperature, onEvent, abort.signal);
} catch (e) {
throw new Error(`LLM error: ${(e as Error).message}`);
}
}
/** Auto-compact history in place if it exceeds the threshold. */
private async autoCompactIfNeeded(messages: ChatMessage[]): Promise<void> {
if (conversationChars(messages) <= AUTO_COMPACT_CHARS) return;
const sys = messages[0];
if (!sys) return;
const rest = messages.splice(1);
const before = rest.length;
try {
await compactMessagesWithAi(rest, this.provider);
} catch (e) {
console.warn(`auto-compact failed (non-fatal): ${(e as Error).message}`);
}
messages.length = 0;
messages.push(sys, ...rest);
console.info(`auto-compacted history: ${before} messages -> ${rest.length}`);
}
/** Run the full agent turn loop. */
async runTurn(params: AgentTurnParams): Promise<void> {
const sink = params.turn_events;
const abort = params.abort;
const { in_flight } = params;
// Insert system prompt at index 0 with repo conventions loaded.
const projectContext = buildProjectContext(params.workspace_roots[0] ?? ".");
const systemPrompt = mainAgentPromptWithProjectContext(projectContext);
params.messages.unshift(systemMessage(systemPrompt));
const originalCount = params.messages.length;
// Estimate request complexity from the last user message.
const last = params.messages[params.messages.length - 1];
const requestLen = last?.content?.length ?? 0;
const errors = new ErrorTracker();
let sawToolCalls = false;
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
// Check abort flag.
if (abort.signal.aborted) {
this.push(sink, { kind: "system_note", systemKind: "info", message: "Turn aborted by user" });
break;
}
if (errors.shouldStop()) {
this.push(sink, { kind: "system_note", systemKind: "warn", message: "Stopping: repeated tool errors without progress" });
break;
}
// Auto-compact oversized history before the LLM call.
await this.autoCompactIfNeeded(params.messages);
// Adaptive generation parameters.
const maxTokens = adaptiveMaxTokens(requestLen);
const temperature = sawToolCalls ? 0.2 : 0.7;
this.push(sink, { kind: "stream_start" });
let result;
try {
result = await this.callLlm(params.messages, abort, sink, maxTokens, temperature);
} catch (e) {
const msg = (e as Error).message;
console.warn(msg);
this.push(sink, { kind: "error", message: msg });
break;
}
const { message: assistantMsg, usage } = result;
const content = assistantMsg.content ?? "";
const toolCalls = assistantMsg.tool_calls ?? [];
this.push(sink, { kind: "stream_done", message: assistantMsg });
if (usage) this.push(sink, { kind: "usage", tokens_in: usage[0], tokens_out: usage[1] });
// No tool calls → assistant is done.
if (toolCalls.length === 0) {
params.messages.push(assistantMessage(content));
break;
}
sawToolCalls = true;
params.messages.push(assistantMsg);
// Execute tool calls — parallel when all read-only, else sequential.
const isParallelSafe = (tc: ToolCall): boolean =>
typeof this.toolExecutor.isParallelSafe === "function"
? this.toolExecutor.isParallelSafe(tc.function.name)
: false;
const parallel = toolCalls.length > 1 && toolCalls.every(isParallelSafe);
const outputs = parallel
? await executeToolCallsInParallel(this.toolExecutor, sink, toolCalls)
: await (async () => {
const seq: string[] = [];
for (const tc of toolCalls) seq.push(await executeToolCall(this.toolExecutor, sink, tc));
return seq;
})();
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i]!;
const output = outputs[i]!;
if (isErrorOutput(output)) errors.record(tc.function.name, output, params.messages);
params.messages.push(toolResultMessage(tc.id, output));
}
}
// Remove the synthetic sys_msg before emitting to the transcript.
const compacted = params.messages.splice(originalCount - 1);
this.push(sink, { kind: "compacted", messages: compacted });
this.push(sink, { kind: "done" });
in_flight.value = false;
}
}
-3
View File
@@ -1,3 +0,0 @@
/** Auth application module — OAuth PKCE + session management use-cases. */
export * from "./oauth_service.ts";
export * from "./session_service.ts";
-141
View File
@@ -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");
});
});
-121
View File
@@ -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);
}
}
-44
View File
@@ -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);
}
}
-9
View File
@@ -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";
-68
View File
@@ -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.`;
}
-11
View File
@@ -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 };
}
-47
View File
@@ -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;
}
}
-20
View File
@@ -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";
-40
View File
@@ -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],
};
}
-39
View File
@@ -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;
}
-35
View File
@@ -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;
}
-56
View File
@@ -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");
}
-25
View File
@@ -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");
});
});
-41
View File
@@ -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);
}
-120
View File
@@ -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;
}
}
}
-7
View File
@@ -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";
-10
View File
@@ -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,386 @@
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[]>;
chatCalls: Array<ChatMessage[]>;
} {
const seen: Array<ChatMessage[]> = [];
const chatCalls: 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.
chatCalls.push([...messages]);
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, chatCalls };
}
/** 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 };
}
describe("runTurn", () => {
it("runs a simple one-shot prompt to completion", async () => {
const { provider } = makeScriptedProvider([{ content: "here is the answer" }]);
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
const { params } = buildParams("hello");
await svc.runTurn(params);
expect(params.in_flight.value).toBe(false);
});
});
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);
});
it("passes the work-in-progress to the reviewer (not an empty slate)", async () => {
const { provider, chatCalls } = makeScriptedProvider([
{ tools: [{ name: "edit" }] },
{ content: "fixed" },
]);
(provider as unknown as { setChatReply(s: string): void }).setChatReply(
"- [PRIORITY: medium] add a null check",
);
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({ edit: "changed" }), [] as never);
const { params } = buildParams("harden the parser");
await svc.runTurn(params);
// The REVIEWER chat call carries the digest in its user message.
const reviewerCall = chatCalls.find((c) =>
c.some((m) => (m.content ?? "").includes("Review the work-in-progress below")),
);
expect(reviewerCall).toBeTruthy();
expect(reviewerCall!.some((m) => (m.content ?? "").includes("harden the parser"))).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);
});
});
@@ -0,0 +1,638 @@
/**
* Agent turn service — the core adaptive turn loop. Mirrors
* `apps/application/src/agent/turn_service.rs`.
*/
import {
type ChatMessage,
type StreamEvent,
type ToolCall,
type ToolDef,
type JsonValue,
Roles,
systemMessage,
userMessage,
assistantMessage,
toolResultMessage,
sanitizeToolArguments,
} from "@zesdex/domain";
import {
type AgentTurnParams,
type TurnEventSink,
mainAgentPromptWithProjectContext,
compactionPrompt,
errorRecoveryNote,
reviewerPrompt,
} from "@zesdex/agent";
import type { ProviderService } from "./ports.ts";
import type { ToolExecutor } from "./index.ts";
/* -------------------------------------------------------------------------- */
/* Constants (mirrors Rust) */
/* -------------------------------------------------------------------------- */
const MAX_TURN_ITERATIONS = 50;
const MAX_CONSECUTIVE_TOOL_ERRORS = 3;
const MAX_TOTAL_TOOL_ERRORS = 8;
const TOOL_OUTPUT_MAX_CHARS = 12_000;
const AUTO_COMPACT_CHARS = 60_000;
const MAX_PARALLEL_TOOLS = 8;
const PROJECT_CONTEXT_MAX_CHARS = 12_000;
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
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;
/** Per-tool-call wall-clock budget before the tool is considered hung. */
const TOOL_TIMEOUT_MS = 90_000;
/** Whether the output string denotes a tool error. */
function isErrorOutput(output: string): boolean {
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. */
export function truncateToolOutput(output: string): string {
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
const head = output.slice(0, TOOL_OUTPUT_MAX_CHARS);
return `${head}\n...[truncated ${output.length - TOOL_OUTPUT_MAX_CHARS} chars]`;
}
/** Pick a `max_tokens` budget based on the user's request length. */
export function adaptiveMaxTokens(requestLen: number): number {
if (requestLen <= 80) return 800;
if (requestLen <= 400) return 1600;
return 4096;
}
/** Sum character length of message content as a context-size proxy. */
export function conversationChars(messages: ChatMessage[]): number {
return messages.reduce((acc, m) => acc + (m.content?.length ?? 0), 0);
}
/** Best-effort build of project context from convention rule files. */
export function buildProjectContext(root: string): string {
let ctx = "";
for (const file of RULE_FILENAMES) {
try {
const content = requireNodeFsReadFile(root, file);
ctx += `\n### ${file}\n\`\`\`\n${content.trim()}\n\`\`\``;
} catch {
/* file missing — skip */
}
}
const context = ctx.trim();
if (context.length <= PROJECT_CONTEXT_MAX_CHARS) return context;
return `${context.slice(0, PROJECT_CONTEXT_MAX_CHARS)}\n...[project context truncated]`;
}
/** Read a repo rule file synchronously (Bun-compatible). */
function requireNodeFsReadFile(root: string, file: string): string {
const fs = require("node:fs");
return fs.readFileSync(`${root}/${file}`, "utf8");
}
/* -------------------------------------------------------------------------- */
/* ErrorTracker */
/* -------------------------------------------------------------------------- */
/** Track repeated tool-call errors so the loop can recover. */
export class ErrorTracker {
consecutive = 0;
total = 0;
lastTool: string | null = null;
lastError = "";
record(toolName: string, error: string, messages: ChatMessage[]): void {
if (this.lastTool === toolName) {
this.consecutive += 1;
} else {
this.consecutive = 1;
}
this.lastTool = toolName;
this.lastError = error;
this.total += 1;
const sysNoteInContext = messages.some((m) => m.content?.includes("[System note]"));
if (this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS && !sysNoteInContext) {
messages.push(systemMessage(errorRecoveryNote(toolName, error)));
}
}
shouldStop(): boolean {
return this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || this.total >= MAX_TOTAL_TOOL_ERRORS;
}
}
/* -------------------------------------------------------------------------- */
/* Tool execution */
/* -------------------------------------------------------------------------- */
/** Execute one tool call, push events, return the result string. */
async function executeToolCall(
executor: ToolExecutor,
sink: TurnEventSink,
tc: ToolCall,
signal?: AbortSignal,
): Promise<string> {
const name = tc.function.name;
const args = sanitizeToolArguments(tc.function.arguments);
const run = executor.execute(name, args as JsonValue).catch((e) => `Error: ${(e as Error).message}`);
// Correct async cancellation: enforce a per-tool time budget and stop
// immediately if the turn is aborted while the tool is still running.
let timer: ReturnType<typeof setTimeout> | undefined;
const output = await Promise.race([
run,
new Promise<string>((resolve) => {
const onAbort = () => resolve("Error: Turn aborted by user");
timer = setTimeout(() => resolve(`Error: Tool timed out after ${TOOL_TIMEOUT_MS / 1000}s`), TOOL_TIMEOUT_MS);
signal?.addEventListener("abort", onAbort, { once: true });
// Release the abort listener + timer once the tool settles either way.
void run.finally(() => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
});
}),
]);
if (timer) clearTimeout(timer);
const isError = isToolFailure(name, output);
const truncated = truncateToolOutput(output);
sink.push({
kind: "tool_result",
tool_call_id: tc.id,
tool_name: name,
output: truncated,
is_error: isError,
path: null,
});
return truncated;
}
/** Execute a batch of read-only tool calls concurrently (bounded) in original order. */
async function executeToolCallsInParallel(
executor: ToolExecutor,
sink: TurnEventSink,
toolCalls: ToolCall[],
signal?: AbortSignal,
): Promise<string[]> {
// Simple bounded concurrency preserving input order.
const results: string[] = new Array(toolCalls.length);
let next = 0;
async function worker() {
while (true) {
const idx = next++;
if (idx >= toolCalls.length) return;
results[idx] = await executeToolCall(executor, sink, toolCalls[idx]!, signal);
}
}
const workers = Array.from({ length: Math.min(MAX_PARALLEL_TOOLS, toolCalls.length) }, () => worker());
await Promise.all(workers);
return results;
}
/* -------------------------------------------------------------------------- */
/* Compaction */
/* -------------------------------------------------------------------------- */
/**
* Split history into a recent tail (kept verbatim, bound by CHAR budget so
* 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(
messages: ChatMessage[],
provider: ProviderService,
): Promise<void> {
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
const { tail, evicted } = takeRecentTail(messages);
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()),
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 {
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
const summaryText = message.content?.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 {
// 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);
}
}
/* -------------------------------------------------------------------------- */
/* AgentTurnServiceImpl */
/* -------------------------------------------------------------------------- */
/** Service implementation for executing an agent turn asynchronously. */
export class AgentTurnServiceImpl {
private provider: ProviderService;
private toolExecutor: ToolExecutor;
private toolDefs: ToolDef[];
constructor(provider: ProviderService, toolExecutor: ToolExecutor, toolDefs: ToolDef[]) {
this.provider = provider;
this.toolExecutor = toolExecutor;
this.toolDefs = toolDefs;
}
/** Emit a TurnEvent onto the sink (no-op if the sink is missing). */
private push(sink: TurnEventSink, event: Parameters<TurnEventSink["push"]>[0]): void {
sink.push(event);
}
/** Execute a single LLM stream call, forwarding tokens and checking abort. */
private async callLlm(
messages: ChatMessage[],
abort: AbortController,
sink: TurnEventSink,
maxTokens: number,
temperature: number,
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
const onEvent = (event: StreamEvent): boolean => {
if (abort.signal.aborted) return false;
if (event.kind === "token") this.push(sink, { kind: "stream_token", content: event.content });
else if (event.kind === "reasoning") this.push(sink, { kind: "stream_reasoning", content: event.content });
return true;
};
try {
return await this.provider.chatStream(messages, this.toolDefs, maxTokens, temperature, onEvent, abort.signal);
} catch (e) {
throw new Error(`LLM error: ${(e as Error).message}`);
}
}
/** Auto-compact history in place if it exceeds the threshold. */
private async autoCompactIfNeeded(messages: ChatMessage[]): Promise<void> {
if (conversationChars(messages) <= AUTO_COMPACT_CHARS) return;
const sys = messages[0];
if (!sys) return;
const rest = messages.splice(1);
const before = rest.length;
try {
await compactMessagesWithAi(rest, this.provider);
} catch (e) {
console.warn(`auto-compact failed (non-fatal): ${(e as Error).message}`);
}
messages.length = 0;
messages.push(sys, ...rest);
console.info(`auto-compacted history: ${before} messages -> ${rest.length}`);
}
/** Run the full agent turn loop. */
async runTurn(params: AgentTurnParams): Promise<void> {
const sink = params.turn_events;
const abort = params.abort;
const { in_flight } = params;
// Insert system prompt at index 0 with repo conventions + verify command.
const root = params.workspace_roots[0] ?? ".";
const projectContext = buildProjectContext(root);
const verifyCommand = deriveVerifyCommand(root);
const systemPrompt = mainAgentPromptWithProjectContext(projectContext, verifyCommand);
params.messages.unshift(systemMessage(systemPrompt));
const originalCount = params.messages.length;
// Estimate request complexity from the last user message.
const last = params.messages[params.messages.length - 1];
const requestLen = last?.content?.length ?? 0;
const errors = new ErrorTracker();
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++) {
// Check abort flag.
if (abort.signal.aborted) {
this.push(sink, { kind: "system_note", systemKind: "info", message: "Turn aborted by user" });
break;
}
if (errors.shouldStop()) {
this.push(sink, { kind: "system_note", systemKind: "warn", message: "Stopping: repeated tool errors without progress" });
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.
await this.autoCompactIfNeeded(params.messages);
// Generation parameters: a config override wins, else fall back to the
// adaptive defaults (lower temperature once the model is doing tool work).
const maxTokens = params.max_tokens ?? adaptiveMaxTokens(requestLen);
const temperature = params.temperature ?? (sawToolCalls ? 0.2 : 0.7);
this.push(sink, { kind: "stream_start" });
let result;
try {
result = await this.callLlm(params.messages, abort, sink, maxTokens, temperature);
} catch (e) {
const msg = (e as Error).message;
console.warn(msg);
this.push(sink, { kind: "error", message: msg });
break;
}
const { message: assistantMsg, usage } = result;
const content = assistantMsg.content ?? "";
const toolCalls = assistantMsg.tool_calls ?? [];
this.push(sink, { kind: "stream_done", message: assistantMsg });
if (usage) this.push(sink, { kind: "usage", tokens_in: usage[0], tokens_out: usage[1] });
// No tool calls → assistant is done.
if (toolCalls.length === 0) {
params.messages.push(assistantMessage(content));
break;
}
sawToolCalls = true;
params.messages.push(assistantMsg);
// Execute tool calls — parallel when all read-only, else sequential.
const isParallelSafe = (tc: ToolCall): boolean =>
typeof this.toolExecutor.isParallelSafe === "function"
? this.toolExecutor.isParallelSafe(tc.function.name)
: false;
const parallel = toolCalls.length > 1 && toolCalls.every(isParallelSafe);
const outputs = parallel
? await executeToolCallsInParallel(this.toolExecutor, sink, toolCalls, abort.signal)
: await (async () => {
const seq: string[] = [];
for (const tc of toolCalls) seq.push(await executeToolCall(this.toolExecutor, sink, tc, abort.signal));
return seq;
})();
let mutated = false;
let verified = false;
let batchSignature = "";
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i]!;
const output = outputs[i]!;
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));
}
// 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;
// Do NOT re-arm verifyPrompted: the verify instruction is already in
// context, so re-injecting it on every edit batch only bloats tokens.
}
if (verified) {
pendingVerify = false; // a successful bash run satisfies the verify nudge
verifyPrompted = false; // a later edit batch may nudge again
}
// 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.
const compacted = params.messages.splice(originalCount - 1);
this.push(sink, { kind: "compacted", messages: compacted });
this.push(sink, { kind: "done" });
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;
// Reduce the work-in-progress to a focused digest so the reviewer has real
// context to critique (not an empty slate that invites hallucinated issues).
const digest = reduceMessagesToDigest(messages.slice(-80));
if (digest.trim() === "") return;
try {
const result = await this.provider.chat(
[
systemMessage(reviewerPrompt()),
userMessage(`Review the work-in-progress below and list actionable issues:\n\n---\n${digest}`),
],
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. */
@@ -62,7 +62,6 @@ export function agentStatusDisplay(s: AgentStatus, error?: string): string {
/** Events emitted onto the turn-event queue while an agent turn runs. */ /** Events emitted onto the turn-event queue while an agent turn runs. */
export type TurnEvent = export type TurnEvent =
| { kind: "assistant_message"; message: ChatMessage }
| { kind: "tool_result"; tool_call_id: string; tool_name: string; output: string; is_error: boolean; path: string | null } | { kind: "tool_result"; tool_call_id: string; tool_name: string; output: string; is_error: boolean; path: string | null }
| { kind: "system_note"; systemKind: string; message: string } | { kind: "system_note"; systemKind: string; message: string }
| { kind: "stream_start" } | { kind: "stream_start" }
@@ -183,6 +182,10 @@ export interface AgentTurnParams {
api_key: string; api_key: string;
model: string; model: string;
api_base?: string; api_base?: string;
/** Optional override for the LLM max_tokens ceiling (defaults to adaptive). */
max_tokens?: number;
/** Optional override for the LLM temperature (defaults to 0.7 / 0.2 tooling). */
temperature?: number;
} }
/** Minimal event-sink abstraction (backs the Rust `Arc<Mutex<VecDeque>>`). */ /** Minimal event-sink abstraction (backs the Rust `Arc<Mutex<VecDeque>>`). */
File renamed without changes.
+105
View File
@@ -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]");
});
});
+139
View File
@@ -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.`;
}
+5
View File
@@ -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";
@@ -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;
@@ -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[],
@@ -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 {
Loaded 50 of 210 files, more files were not shown because too many files have changed in this diff. Show more