# sai-deci

The bundled Sai decisions extension for Selesai. One entrypoint composes three components that share
a single bounded classifier client:

- **advisory routing** — the memory and recommendations routes that run before an eligible prompt;
- **agent tools** — `ask_sai` (typed questions about prose, files, commands, and bounded local
  images) and `sai_find` (a bounded text/code finder);
- **tool ranking** — the `tool_search` / codemode `searchTools()` ranker.

Sai calls the TokenIn gateway's decisions deployments through `modelRegistry.classify()`; the shared
default is `tokenin` / `cloudflare/clef`.

## Structure

```text
src/extensions/sai-deci/
  package.json             # private bundle; pi.extensions lists only ./index.ts
  index.ts                 # the single entrypoint; calls each component factory once
  src/
    decisions.ts           # settings, payload builder, transport, telemetry, abstention handling
    classifier-context.ts  # public envelope conversion, wire payload, shared image gate
    classifier.ts          # TokenIn decisions registrations and System One transport hooks
    advisory-routing.ts    # memory and recommendation routes, eligibility, lifecycle
    ask-tool.ts            # ask_sai and sai_find tool handlers plus their local safety helpers
    tool-ranker.ts         # tool_search / searchTools() ranker with a local BM25 fallback
  tests/                   # component suites and shared test support
```

The bundle is loaded by `./sai-deci` in `src/extensions/package.json`; the components are never
auto-loaded on their own.

## Configuration

Read `saiAdvisory` from the agent settings file (`~/.selesai/agent/settings.json` by default,
or the directory selected by `SELESAI_CODING_AGENT_DIR`). These routes read that file directly;
project settings are not merged into it. Only the `saiAdvisory` key is read — there is no alias for
the previous settings name.

| Setting | Default | Notes |
|---|---|---|
| `saiAdvisory.provider` | `tokenin` | Provider serving the Sai decisions deployment |
| `saiAdvisory.model` | `cloudflare/clef` | Decisions model every route calls |
| `saiAdvisory.baseUrl` | inherited | Base URL override; defaults to a registered model of `provider` |
| `saiAdvisory.routes.memory.enabled` | `true` | On by default; no explicit memory cue required |
| `saiAdvisory.routes.recommendations.enabled` | `false` | Opt-in; suggestions only, never executed |
| `saiAdvisory.routes.ask.enabled` | `true` | On by default; serves both `ask_sai` and `sai_find` |
| `saiAdvisory.routes.toolSearch.enabled` | `true` | On by default; local BM25 fallback |
| `saiAdvisory.routes.subagent.enabled` | `false` | Opt-in; can only narrow a subagent launch |
| `saiAdvisory.routes.subagent.tiers` | unset | `{ simple?, complex?, reasoning? }` → `provider/id`; needs at least two |
| `saiAdvisory.routes.subagent.genericAgents` | `["delegate"]` | Agents Sai may replace with a specialist |

Each route also accepts `timeoutMs`, `minConfidence`, `contextTurns`, `contextChars`, `payloadBytes`,
and optionally its own `model` / `provider`. Defaults: `timeoutMs` 8000 (the memory request is capped
at 750 ms, `ask` defaults to 15000, `toolSearch` to 6000, `subagent` to 5000), `minConfidence` 0.6,
`contextTurns` 4, `contextChars` 4000, `payloadBytes` 8192 (`ask` and `toolSearch` default to 32768,
`subagent` to 16384). `ask` and `toolSearch` ignore `minConfidence`, `contextTurns`, and
`contextChars`.

A missing classifier registration, a missing credential, a timeout, a malformed or low-confidence
answer, an invalid image set, or an oversized payload is an abstention: the caller's deterministic
behavior stays in effect and no other model is selected.

## Tools

`ask_sai` — the agent passes its own prose (`state`), up to eight file `paths`, one `command`, up to
four `imagePaths`, and up to eight `choice`/`score`/`noul` questions. It gets typed answers with
confidences back; file contents and command output are never returned to the agent or stored in the
session.

`imagePaths` accepts at most four explicit local PNG/JPEG/WebP files whose aggregate base64 data fits
the shared 2 MiB encoded cap. Each path is resolved and validated inside the canonical working
directory, refused when it is outside, secret-named, not a regular nonempty file, or changed while
read, and read deliberately in bounded form only after the credential and the classifier's image
input are confirmed. An invalid, unsupported, unreadable, or oversized set refuses the whole call —
never a clip, drop, or text-only downgrade — before any optional command or classification. Caller
cancellation is forwarded to the request.

`sai_find` — the agent passes a plain-words `question`, optionally with a ripgrep `pattern`, `glob`,
`path`, `ignoreCase`, and a result `limit` (default 8). ripgrep gathers candidates; Sai narrows
directories, then files, then source units. It returns ranked files with reading leads and the
accepted source verbatim with original line numbers, at most 12 Sai requests and 16 KiB of source per
call, plus sampled discovery diagnostics under a separate 4 KiB budget. It respects `.gitignore`,
stays inside the working directory, skips secret-named files, and falls back to ripgrep candidates
with local keyword-window source when Sai cannot judge. It remains a text/code finder; it does not
harvest images or history.

## Safety and budgets

- Eligibility: idle, top-level, interactive or RPC prompts that carry text or images. Slash
  commands, queued steering/follow-up input, extension-injected turns, and input with neither text
  nor images are skipped; each turn is routed at most once. Only the current turn's final prompt and
  attachments are judged, never history images.
- Memory: Sai chooses `none` or one scope; the lookup is local, read-only, bounded to at most five
  results before injection. The classifier request has a 750 ms ceiling. Memory entries are never sent to the classifier
  and no memory is written. An image-only request is classified with bounded neutral text, and that
  text is never used as a memory search query.
- Text: the serialized decision request is capped at 64 KiB of bytes, a payload budget separate from
  the model's token context window. `ask_sai` clips its text parts to the route payload budget (32 KB
  by default); `sai_find` fits or drops batches that cannot serialize within the route budget.
- Images: at most four PNG/JPEG/WebP files, 2 MiB of aggregate encoded base64, validated by one
  shared gate. Unsupported models and invalid sets are refused, never downgraded to text.
- Files: `ask_sai` refuses paths outside the working directory and secret-named files, judged both
  on the requested path and on the symlink-resolved target; commands run through the bash tool's
  local shell. Reads are bounded, and the image reader revalidates the canonical path, file identity,
  size, and timestamps before and after its bounded read. Ordinary filesystem APIs cannot make
  validation and reading one atomic operation against a hostile local filesystem.
- Telemetry: `sai-routing` events carry route, outcome, counts, a confidence bucket, and timing —
  never prompt text, memory contents, commands, image data, or credentials.
- Billing: TokenIn classifier results use service-only billing. Only the gateway's reported charge
  (`x-litellm-response-cost`, zero on an `x-litellm-cache-key` cache hit) is exposed as
  `reportedCost`; token counts without a price are `unpricedUsage`. Zero catalog costs are
  placeholders, not a free-price claim, and a reported charge is not independently reconciled
  account spend.

## Development

All of these run offline; none requires credentials:

```sh
# Component suites (also included in the repository's `npm test` list)
npx vitest run src/extensions/sai-deci/tests

# Offline benchmark dry runs: executable fixtures and pinned lines only, no API calls
TSX_TSCONFIG_PATH=scripts/bench-sai-deci/tsconfig.json node --import tsx scripts/bench-sai-deci/run.ts
npx vitest run scripts/bench-sai-deci

# Whitespace/conflict check
git diff --check
```

Live benchmark runs are gated by an explicit total call budget: `--live` requires
`--max-live-calls 1..16`, which counts every outgoing classifier dispatch and chat completion in the
run. Run one only with owner approval and credentials; the shared client turns a classifier error
into an abstention, so an unapproved or missing budget must never be worked around. Live calls are
not part of this documentation change.

Release gates: image-dependent Clef acceptance and account-spend reconciliation are not yet verified
live. Clef's image input is configured, not proven, so do not claim verified vision support from the
tests or dry runs; see `scripts/bench-sai-deci/README.md` for the current benchmark contract and
`docs/settings.md` for the settings reference.
