@moikapy/lich 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/index.md ADDED
@@ -0,0 +1,68 @@
1
+ ---
2
+ outline: [2, 3]
3
+ ---
4
+
5
+ # Lich documentation
6
+
7
+ > What you'll learn: what Lich is, what it ships, and which page to read next — plus a 60-second quickstart.
8
+
9
+ Lich is a TypeScript AI agent harness: a library and a CLI that run a chat model inside a Think-Act-Observe loop. A chat wrapper forwards one prompt and prints one completion. A harness keeps going: the model plans (think), calls tools such as `read_file` or `terminal` (act), reads the tool results (observe), and repeats until it can produce a final answer. Lich wraps that loop with the machinery real deployments need: provider failover with bounded retries, path confinement and output clamps on every tool, context compression when the transcript grows past a token budget, and append-only JSONL session transcripts.
10
+
11
+ One package, four ways to drive the same agent: a one-shot CLI, an interactive chat REPL, an ink-based terminal UI, and a long-running messaging gateway that bridges Telegram, Discord, Twitch, and a zero-config HTTP webhook. All four share the same twelve builtin tools, the same provider configuration, and the same session store.
12
+
13
+ ## Feature overview
14
+
15
+ | Capability | What it gives you |
16
+ | --- | --- |
17
+ | Providers | `openai_compat`, `anthropic`, and `ollama` with automatic failover between configured providers; 429/5xx and network errors retry with backoff before failing over. |
18
+ | Tools | Twelve builtins (file read/write/edit, directory listing, shell, grep, HTTP fetch/request, web search, process list, disk usage, env inspection), all confined to the working directory. |
19
+ | Context compression | Transcript summarized in place when estimated tokens cross `compress_threshold` of `context_budget_tokens`; the 8 most recent turns always stay verbatim. |
20
+ | Sessions | Every run persists a `.jsonl` transcript under `.lich/sessions/`, labeled by origin (`tui`, `gw:<platform>:<chat>`). |
21
+ | CLI | One-shot tasks, chat REPL, TUI, gateway, and a `config` template command, all with flag/env/config-file configuration. |
22
+ | TUI | Live ink transcript with tool-call rows, status bar (model, turns, tokens, session path), and slash commands. |
23
+ | Gateway | One shared agent behind webhook/Telegram/Discord/Twitch with per-conversation memory (40-message history cap) and per-platform message splitting. |
24
+ | Library | `create_agent` / `run_agent` with typed events (`AgentEmitter`), multi-turn history, and `ProviderError` kinds for error handling. |
25
+ | Plugins | User-supplied tools and lifecycle hooks (`before_tool_call` veto, run lifecycle) loaded at startup from explicit module paths. |
26
+
27
+ ## Page map
28
+
29
+ | Page | Read it to |
30
+ | --- | --- |
31
+ | [Getting started](getting-started.md) | Install, configure a provider, and get your first reply in any mode. |
32
+ | [CLI reference](user-guide/cli.md) | Master all four modes, flags, provider resolution, and config files. |
33
+ | [TUI guide](user-guide/tui.md) | Run the terminal UI and use slash commands and the status bar. |
34
+ | [Gateway guide](user-guide/gateway.md) | Wire Telegram, Discord, Twitch, and the HTTP webhook to one agent. |
35
+ | [Library guide](user-guide/library.md) | Embed the agent in TypeScript with events and multi-turn history. |
36
+ | [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks to the agent. |
37
+ | [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
38
+
39
+ ## How it works
40
+
41
+ For the internals — the agent loop, provider failover, tool guardrails, and how to extend each layer — read the architecture track: [overview](architecture/overview.md), [agent loop](architecture/agent-loop.md), [providers](architecture/providers.md), [tools](architecture/tools.md), [plugins](architecture/plugins.md), and [extending](architecture/extending.md).
42
+
43
+ ## 60-second quickstart
44
+
45
+ Requires Node >= 20 (or Bun) and access to one model endpoint (local Ollama, OpenAI, Anthropic, or any OpenAI-compatible API such as OpenRouter).
46
+
47
+ ```sh
48
+ # from a clone of the repository
49
+ bun install
50
+
51
+ # generate a starter config, then edit the model name
52
+ mkdir -p .lich && bun src/cli.ts config > .lich/config.json
53
+
54
+ # chat TUI (exit with /exit or Ctrl+C)
55
+ bun src/cli.ts tui
56
+
57
+ # or a one-shot task
58
+ bun src/cli.ts "list the files in this repo and summarize it"
59
+
60
+ # or a messaging gateway on http://localhost:8089
61
+ bun src/cli.ts gateway webhook
62
+ ```
63
+
64
+ After `npm install`-ing the built package the same commands work as `lich tui`, `lich "task"`, and `lich gateway webhook`.
65
+
66
+ ## Version compatibility
67
+
68
+ Documented for **v0.3.0**. Requires Node >= 20 (`engines` in `package.json`); Bun is the recommended runtime for development (`bun src/cli.ts ...`) and Node 20+ works for the built `dist/cli.js`. The TUI needs a TTY; the gateway and library run headless on both runtimes.
@@ -0,0 +1,182 @@
1
+ # CLI reference
2
+
3
+ > What you'll learn: every CLI mode, flag, and default; how provider/model resolution works; the config file schema; session files, exit codes, and log levels; and practical recipes.
4
+
5
+ ## The four modes
6
+
7
+ ```sh
8
+ lich "one shot task" # run a single task and print the reply
9
+ lich chat # interactive chat (commands: /exit, /quit)
10
+ lich tui # interactive terminal UI (ink)
11
+ lich gateway <plat..> # messaging gateway (webhook|telegram|discord|twitch)
12
+ lich config # print a starter config template
13
+ lich --help # usage text
14
+ lich --version # print 0.2.0
15
+ ```
16
+
17
+ - **One-shot** joins all positional words into a single task, runs the agent loop, prints the final answer to stdout, and exits. Progress (turn numbers, tool results) goes to stderr.
18
+ - **Chat** is a readline REPL over one long-lived agent: each line is a turn, memory persists across lines, and an empty line, `/exit`, or `/quit` ends the session. After each turn it prints a `[turns N | tokens M]` footer.
19
+ - **TUI** launches the ink interface. See the [TUI guide](tui.md).
20
+ - **Gateway** runs platform adapters (defaults to `webhook` when no platform is given). See the [Gateway guide](gateway.md). Unknown platform names are skipped with a warning; if none remain, the CLI exits `1`.
21
+
22
+ `bun src/cli.ts` and the installed `lich` binary accept identical arguments.
23
+
24
+ ## Flags
25
+
26
+ Flags work before or after the subcommand. Every value flag can also be set via an environment variable or the config file (precedence below).
27
+
28
+ | Flag | Meaning | Default |
29
+ | --- | --- | --- |
30
+ | `--config <path>` | JSON config file (must exist). Disables config discovery. | discovery chain |
31
+ | `--work-dir <path>` | Working directory for tools and config/session resolution. | process cwd |
32
+ | `--max-turns <n>` | Turn budget for the agent loop. | `25` |
33
+ | `--model <m>` | Model name for `providers[0]`. | `LICH_MODEL` |
34
+ | `--provider-kind <k>` | `openai_compat` \| `anthropic` \| `ollama`. | `LICH_PROVIDER_KIND`, else `openai_compat` |
35
+ | `--base-url <u>` | Provider base URL for `providers[0]`. | `LICH_BASE_URL`, else per-kind default |
36
+ | `--api-key-env <NAME>` | Env var holding the api key for `providers[0]`. | `LICH_API_KEY_ENV`, else per-kind default |
37
+ | `--system-prompt <s>` | Replaces the default system prompt. | built-in concise-assistant prompt |
38
+ | `--session-dir <path>` | Transcript directory. | `<work_dir>/.lich/sessions` |
39
+ | `--log-level <level>` | `debug` \| `info` \| `warn` \| `error`. | `info` |
40
+
41
+ Passing `--max-turns 0` or a non-integer fails with `--max-turns must be a positive integer`. Unknown flags fail with `unknown flag: --foo`. A flag missing its value fails with `<flag> requires a value`.
42
+
43
+ ## Provider resolution
44
+
45
+ The effective provider for a run is decided in this order:
46
+
47
+ 1. If `--config <path>` was passed, that file is the whole configuration (it must exist, or the CLI fails with `config not found`).
48
+ 2. Otherwise the discovery chain is walked: `./.lich/config.json`, then `~/.config/lich/config.json`. The first file found becomes the config. `LICH_*` env vars are *not* merged into a discovered file.
49
+ 3. If no config file exists, one is built from the environment: `--provider-kind` / `LICH_PROVIDER_KIND` (default `openai_compat`), `--model` / `LICH_MODEL` (required — without it the CLI fails with `no model configured`), `--base-url` / `LICH_BASE_URL`, and `--api-key-env` / `LICH_API_KEY_ENV`, each falling back to the per-kind defaults below.
50
+ 4. Provider override flags (`--model`, `--provider-kind`, `--base-url`, `--api-key-env`) always win over the chosen source: with a config file present they patch `providers[0]` in place; without one they seed a fresh provider from the environment.
51
+
52
+ Per-kind defaults:
53
+
54
+ | Kind | Default base URL | Default key env | Notes |
55
+ | --- | --- | --- | --- |
56
+ | `openai_compat` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | Works with any OpenAI-shaped `/chat/completions` API. |
57
+ | `anthropic` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | |
58
+ | `ollama` | `http://localhost:11434` | none | No key needed; `api_key`/`api_key_env` are sent as a Bearer header for cloud proxies when set. |
59
+
60
+ ## Config file reference
61
+
62
+ Validated by zod (top-level unknown keys are silently stripped; extra keys inside a `providers[]` entry are passed through). Full example with every field:
63
+
64
+ ```json
65
+ {
66
+ "providers": [
67
+ {
68
+ "kind": "openai_compat",
69
+ "name": "openrouter",
70
+ "model": "anthropic/claude-sonnet-4",
71
+ "base_url": "https://openrouter.ai/api/v1",
72
+ "api_key_env": "OPENROUTER_API_KEY"
73
+ },
74
+ {
75
+ "kind": "ollama",
76
+ "name": "local",
77
+ "model": "llama3.2:latest",
78
+ "base_url": "http://localhost:11434",
79
+ "keep_alive": "10m"
80
+ }
81
+ ],
82
+ "system_prompt": "You are a careful code reviewer.",
83
+ "max_turns": 40,
84
+ "work_dir": "/home/me/project",
85
+ "tools_enabled": ["read_file", "grep_files", "terminal"],
86
+ "temperature": 0.2,
87
+ "max_tokens": 4096,
88
+ "context_budget_tokens": 100000,
89
+ "compress_threshold": 0.8,
90
+ "session_dir": "/home/me/project/.lich/sessions",
91
+ "terminal_timeout_ms": 60000,
92
+ "log_level": "info"
93
+ }
94
+ ```
95
+
96
+ | Field | Type | Default | Meaning |
97
+ | --- | --- | --- | --- |
98
+ | `providers` | array, min 1 | required | Failover chain, tried in order. |
99
+ | `providers[].kind` | `"openai_compat" \| "anthropic" \| "ollama"` | required | Wire protocol. |
100
+ | `providers[].name` | string | required | Label used in logs and `ChatResult.provider_name`. |
101
+ | `providers[].model` | string | required | Model name sent to the provider. |
102
+ | `providers[].base_url` | string | per kind | Endpoint base (see table above). |
103
+ | `providers[].api_key` | string | – | Inline key (prefer `api_key_env`). |
104
+ | `providers[].api_key_env` | string | per kind | Env var to read the key from. |
105
+ | `providers[].timeout_ms` | positive int | none | Per-request abort deadline. |
106
+ | `providers[].think` | boolean | – | Ollama only: request thinking mode. |
107
+ | `providers[].keep_alive` | string | – | Ollama only: model residency (e.g. `"10m"`). |
108
+ | `system_prompt` | string | built-in | Replaces the default system prompt. |
109
+ | `max_turns` | int >= 1 | `25` | Turn budget per run. |
110
+ | `work_dir` | string | cwd | Root for all file tools; paths outside are rejected. |
111
+ | `tools_enabled` | `"all"` or name array | `"all"` | Restrict the registry to these builtin tools. |
112
+ | `temperature` | 0–2 | – | Sampling temperature. |
113
+ | `max_tokens` | positive int | – | Completion cap. |
114
+ | `context_budget_tokens` | positive int | `100000` | Estimated budget before compression triggers. |
115
+ | `compress_threshold` | 0.1–0.95 | `0.8` | Compress when usage >= this fraction of the budget. |
116
+ | `session_dir` | string | `<work_dir>/.lich/sessions` | Transcript directory. |
117
+ | `terminal_timeout_ms` | positive int | `60000` | Default timeout injected into the `terminal` tool. |
118
+ | `log_level` | enum | `info` | Logger verbosity. |
119
+
120
+ Minimal per-provider examples:
121
+
122
+ ```json
123
+ { "providers": [{ "kind": "ollama", "name": "local", "model": "llama3.2" }] }
124
+ ```
125
+
126
+ ```json
127
+ { "providers": [{ "kind": "anthropic", "name": "main", "model": "claude-sonnet-4", "api_key_env": "ANTHROPIC_API_KEY" }] }
128
+ ```
129
+
130
+ Listed providers form a failover chain: the router walks them in order, retrying `rate_limit`/`network` errors (bounded backoff) on the current provider before moving on, and failing over immediately on `auth`, `overflow`, and `bad_request`.
131
+
132
+ ## Session files
133
+
134
+ Each run writes `.lich/sessions/<timestamp36>-<counter>[-label].jsonl` where the label is the run origin: `-tui`, or `-gw-<platform>-<chat_id>` for gateway conversations. One-shot and chat runs get no label. Records are JSON lines of two kinds: `{"ts","kind":"meta","meta":{...}}` (run start, budget exhaustion) and `{"ts","kind":"message","message":{...}}` for each system/user/assistant/tool message.
135
+
136
+ ```sh
137
+ # follow the newest session
138
+ ls -t .lich/sessions/*.jsonl | head -1
139
+
140
+ # print the conversation
141
+ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content // "(tool call)")"' .lich/sessions/<file>.jsonl
142
+ ```
143
+
144
+ ## Exit codes
145
+
146
+ | Code | Meaning |
147
+ | --- | --- |
148
+ | `0` | Success: final answer produced (also `--help`, `--version`, `config`). |
149
+ | `1` | Any failure: unknown flag, missing model, unreadable config, provider error after failover, aborted run, or budget exhaustion (`[lich] budget exhausted after N turns` is printed to stderr). |
150
+
151
+ ## Log levels
152
+
153
+ `--log-level` / config `log_level` sets the logger: `debug` (tool calls, retries, compression detail), `info` (lifecycle: gateway starts, adapter starts), `warn` (failover, degraded adapters, failed session persistence), `error` (provider and loop failures). Logger output goes to stderr. The one-shot/chat `[lich] turn N` / `tool: ok` progress lines are separate and always shown.
154
+
155
+ ## Recipes
156
+
157
+ Review a file with a scoped working directory:
158
+
159
+ ```sh
160
+ bun src/cli.ts --work-dir ./myproject --max-turns 15 \
161
+ "Review src/payments/retry.ts for correctness bugs. List each with a line number and a suggested fix."
162
+ ```
163
+
164
+ Web research (search, then fetch the promising pages):
165
+
166
+ ```sh
167
+ bun src/cli.ts \
168
+ "Find the current LTS version of Node.js using web_search, fetch the release page with fetch_url, and summarize the support schedule."
169
+ ```
170
+
171
+ Note: `web_search` scrapes DuckDuckGo's HTML endpoint without an api key and can be blocked with `search_failed` errors when rate-limited; retrying later usually works.
172
+
173
+ Batch one-shots from a script, checking each exit code:
174
+
175
+ ```sh
176
+ #!/usr/bin/env bash
177
+ set -u
178
+ for task in "summarize README.md" "list the largest files with disk_usage" "grep for TODO comments"; do
179
+ echo "== $task"
180
+ LICH_PROVIDER_KIND=ollama LICH_MODEL=llama3.2 bun src/cli.ts --max-turns 10 "$task" || echo "FAILED ($?)"
181
+ done
182
+ ```
@@ -0,0 +1,168 @@
1
+ # Gateway guide
2
+
3
+ > What you'll learn: how `lich gateway` bridges Telegram, Discord, Twitch, and an HTTP webhook into one shared agent, how to set up each platform, and the webhook API reference.
4
+
5
+ ## How it works
6
+
7
+ `lich gateway <platform...>` runs a long-lived process that forwards inbound chat messages to **one shared agent** and routes replies back. Per-conversation memory is keyed `platform:chat_id` (Telegram/Discord chat ids, Twitch channel names, webhook `chat_id` field): each conversation keeps its own bounded history capped at 40 messages (oldest evicted; conversations beyond 200 are evicted oldest-first). Messages for the same conversation are serialized, so overlapping messages never interleave histories; different conversations can run concurrently. Failures become a safe one-line reply: `agent error: <flattened message, 300 chars max>`.
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ A[Webhook / TG / Discord / Twitch] -->|inbound msg| B[platform adapter]
12
+ B -->|platform, chat_id, user_id, text| C[GatewayBus]
13
+ C -->|history for platform:chat_id| D[shared Agent]
14
+ D -->|tools, failover, compression| E[reply text]
15
+ E -->|split to platform limit| F[adapter send]
16
+ F --> G[user]
17
+ ```
18
+
19
+ Platforms: `webhook` (HTTP server), `telegram` (long-poll), `discord` (gateway WebSocket), `twitch` (IRC over WebSocket). Pick any combination:
20
+
21
+ ```sh
22
+ bun src/cli.ts gateway webhook # http only
23
+ bun src/cli.ts gateway webhook telegram # http + telegram polling
24
+ bun src/cli.ts gateway telegram discord twitch # no webhook server
25
+ bun src/cli.ts gateway # defaults to webhook
26
+ ```
27
+
28
+ The gateway is silent after startup: Telegram/Discord/Twitch respond only in chats, channels, or servers the bot can see or has joined, and the webhook only serves HTTP. Telegram media messages arrive as the placeholder text `media not supported yet`; other non-text events are ignored. Telegram `/start` is answered like a plain "hello".
29
+
30
+ ## Setup: webhook
31
+
32
+ Zero configuration — the server binds `0.0.0.0:$LICH_GATEWAY_PORT` (default 8089).
33
+
34
+ ```sh
35
+ bun src/cli.ts gateway webhook
36
+ ```
37
+
38
+ ```sh
39
+ curl -s -X POST http://localhost:8089/message \
40
+ -H "content-type: application/json" -d '{"text": "hello"}'
41
+ # -> {"reply":"...","usage":null}
42
+
43
+ curl -s http://localhost:8089/health
44
+ # -> {"status":"ok"}
45
+ ```
46
+
47
+ With token auth, every POST must carry the exact `x-lich-token` header; mismatched or missing tokens get `401 {"error":"unauthorized"}`:
48
+
49
+ ```sh
50
+ LICH_GATEWAY_TOKEN=s3cret bun src/cli.ts gateway webhook
51
+ curl -s -X POST http://localhost:8089/message \
52
+ -H "x-lich-token: s3cret" -H "content-type: application/json" -d '{"text": "hello"}'
53
+ ```
54
+
55
+ Payload fields (all optional except `text`): `platform` (default `"webhook"`), `chat_id` (default `"default"`), `user_id` (default `"anonymous"`), `text` (required; missing `text` is a `400`). Use distinct `chat_id` values to keep independent conversation memories.
56
+
57
+ ## Setup: Telegram
58
+
59
+ 1. Message [@BotFather](https://t.me/BotFather) → `/newbot` → copy the token.
60
+ 2. Export it and run:
61
+
62
+ ```sh
63
+ export LICH_TELEGRAM_BOT_TOKEN=123456:ABC-your-token
64
+ bun src/cli.ts gateway telegram
65
+ ```
66
+
67
+ 3. Open your bot in Telegram, send a message, get a reply. Media messages arrive as the text `media not supported yet`; the bot replies from there.
68
+
69
+ Telegram uses long polling (no public URL needed). Replies split at 4096 chars.
70
+
71
+ ## Setup: Discord
72
+
73
+ 1. Create an application at the [Discord developer portal](https://discord.com/developers/applications), add a **Bot**, and copy the bot token.
74
+ 2. Enable the **Message Content Intent** (Bot settings → Privileged Gateway Intents) — the adapter requests intents `512 | 32768`, which includes message content.
75
+ 3. Invite the bot with the `bot` scope (OAuth2 → URL Generator; no extra permissions needed beyond sending messages in target channels).
76
+ 4. Export the token (and the bot's application/user id, so leading `<@BOT_ID>` mentions are stripped) and run:
77
+
78
+ ```sh
79
+ export LICH_DISCORD_BOT_TOKEN=your-bot-token
80
+ export LICH_DISCORD_BOT_ID=123456789012345678
81
+ bun src.cli.ts gateway discord
82
+ ```
83
+
84
+ 5. Send the bot a message (DM or any channel it can read — every non-bot message gets a reply); each channel has its own conversation memory (keyed by `channel_id`). Replies split at 2000 chars. Bot-authored messages are ignored (no loops).
85
+
86
+ **Known limitation:** the Discord adapter has no reconnect resume. If its gateway WebSocket drops, messages sent while offline are missed permanently; the adapter reconnects fresh after 5s. If guaranteed delivery across disconnects matters, run webhook or Telegram instead.
87
+
88
+ ## Setup: Twitch
89
+
90
+ 1. Generate an OAuth token with the `chat:read` and `chat:edit` scopes (for example via [twitchtokengen](https://twitchtokengen.com)).
91
+ 2. Export it, your bot account's nickname, and the channels to join (comma-separated, lowercased by the adapter):
92
+
93
+ ```sh
94
+ export LICH_TWITCH_OAUTH_TOKEN=oauth:abc123...
95
+ export LICH_TWITCH_NICK=mylichbot
96
+ export LICH_TWITCH_CHANNELS=channelone,channeltwo
97
+ bun src/cli.ts gateway twitch
98
+ ```
99
+
100
+ 3. The bot joins `#channelone` and `#channeltwo` and replies in chat (own messages are ignored). Replies split at 512 chars; IRC PING/PONG is answered automatically.
101
+
102
+ All three fields (`token`, `nick`, `channels`) are required — a missing one idles the adapter.
103
+
104
+ ## Running multiple platforms at once
105
+
106
+ List the platforms in one command; all configured adapters start together and share the agent:
107
+
108
+ ```sh
109
+ LICH_TELEGRAM_BOT_TOKEN=... LICH_DISCORD_BOT_TOKEN=... \
110
+ bun src/cli.ts gateway webhook telegram discord
111
+ ```
112
+
113
+ Adapters whose credentials are missing start **idle** (a warning is logged, e.g. `gateway discord adapter idle: LICH_DISCORD_BOT_TOKEN not set`) and the rest keep running — so the same command works on machines with partial credentials. If no platform name is valid, the CLI exits `1` with `gateway needs at least one valid platform`.
114
+
115
+ ## Environment variables
116
+
117
+ | Variable | Default | Purpose |
118
+ | --- | --- | --- |
119
+ | `LICH_GATEWAY_PORT` | `8089` | Webhook server port (invalid/empty values fall back to 8089). |
120
+ | `LICH_GATEWAY_TOKEN` | unset | If set, POST `/message` requires header `x-lich-token` to match; else 401. |
121
+ | `LICH_TELEGRAM_BOT_TOKEN` | unset | Bot token from BotFather; adapter idles without it. |
122
+ | `LICH_DISCORD_BOT_TOKEN` | unset | Bot token from the developer portal; adapter idles without it. |
123
+ | `LICH_DISCORD_BOT_ID` | unset | Bot user id; strips a leading `<@id>` mention from messages. |
124
+ | `LICH_TWITCH_OAUTH_TOKEN` | unset | IRC oauth token (`oauth:` prefix optional); adapter idles without it. |
125
+ | `LICH_TWITCH_NICK` | unset | Bot account nickname; required with the token. |
126
+ | `LICH_TWITCH_CHANNELS` | unset | Comma-separated channels to join; required. |
127
+
128
+ Provider/model configuration comes from the same resolution as every mode (`LICH_MODEL` or config file).
129
+
130
+ ## Operational notes
131
+
132
+ - **Graceful stop:** SIGINT (Ctrl+C) or SIGTERM stops every adapter, then exits `0`. No other signals are handled.
133
+ - **Idle adapters:** any adapter missing its token/credentials logs a warning once and does nothing; the process stays up for the others.
134
+ - **Message splitting:** replies are split per platform — Telegram 4096 chars, Discord 2000, Twitch 512. Telegram/Discord splits prefer whitespace; Twitch hard-cuts at the limit.
135
+ - **One shared agent:** all platforms share one agent instance, tool set, and provider failover chain; only conversation memory is per-chat.
136
+ - **Debugging tool calls:** with `--log-level debug`, completed tool calls are logged as `tool <name> ok|failed`.
137
+ - **Restart loses Discord messages:** see the [Discord note](#setup-discord) — the offline window is not replayed.
138
+
139
+ ## Webhook API reference
140
+
141
+ ### `POST /message`
142
+
143
+ Request:
144
+
145
+ ```json
146
+ {"text": "hello", "platform": "webhook", "chat_id": "default", "user_id": "anonymous"}
147
+ ```
148
+
149
+ Only `text` is required. Success (`200`):
150
+
151
+ ```json
152
+ {"reply":"Hello! How can I help you today? ...","usage":null}
153
+ ```
154
+
155
+ `reply` is the agent's final answer; `usage` is always `null` on this endpoint (webhook replies are formatted without usage stats, unlike the chat/TUI footers). Errors:
156
+
157
+ | Status | When |
158
+ | --- | --- |
159
+ | `400` | Body missing or has no `text` field: `{"error":"text is required"}`. |
160
+ | `401` | `LICH_GATEWAY_TOKEN` is set and the `x-lich-token` header does not match. |
161
+ | `404` | Anything other than `POST /message` or `GET /health`. |
162
+ | `500` | Internal dispatch failure: `{"error":"internal error"}`. |
163
+
164
+ Agent-level failures (e.g. every provider failed) return `200` with `reply` set to a sanitized one-line `agent error: ...` string, so callers always get a deliverable text.
165
+
166
+ ### `GET /health`
167
+
168
+ `200 {"status":"ok"}` unconditionally — the webhook server itself is alive; it does not reflect platform adapters or provider health.
@@ -0,0 +1,181 @@
1
+ # Library guide
2
+
3
+ > What you'll learn: how to install the package and embed the `Agent` class in TypeScript — basic runs, event subscription, multi-turn history, config, tool filtering, error handling, and session access.
4
+
5
+ ## Install
6
+
7
+ From a checkout of this repository (or a published tarball):
8
+
9
+ ```sh
10
+ npm install /path/to/lich-0.2.0.tgz # after `npm run build` in the lich repo
11
+ # or point package.json at the git repo
12
+ npm install git+ssh://example.com/you/lich.git
13
+ ```
14
+
15
+ The package ships ESM (`dist/index.js`, types at `dist/index.d.ts`, binary at `dist/cli.js`); `main`/`types`/`bin` are wired in `package.json`.
16
+
17
+ ## Minimal example
18
+
19
+ `create_agent(raw_config)` validates the config (zod, defaults applied, frozen result) and returns an `Agent` with a `.run()` loop:
20
+
21
+ ```ts
22
+ import { create_agent } from "lich";
23
+
24
+ const agent = create_agent({
25
+ providers: [{ kind: "ollama", name: "local", model: "llama3.2:latest" }],
26
+ });
27
+
28
+ const result = await agent.run({ input: "Use list_dir to list the files, then summarize." });
29
+ console.log(result.outcome.final?.content);
30
+ console.log(`tokens: ${result.usage_total.total_tokens}`);
31
+ ```
32
+
33
+ The one-liner `run_agent(config, input)` is equivalent when you only need a single run:
34
+
35
+ ```ts
36
+ import { run_agent } from "lich";
37
+
38
+ const result = await run_agent(
39
+ { providers: [{ kind: "ollama", name: "local", model: "llama3.2:latest" }] },
40
+ "Reply with ok",
41
+ );
42
+ ```
43
+
44
+ ## Agent class
45
+
46
+ `new Agent(config)` (or `create_agent(raw)`) builds the provider router, registers the twelve builtin tools (filtered by `tools_enabled`), and exposes:
47
+
48
+ | Member | Type | Purpose |
49
+ | --- | --- | --- |
50
+ | `run(options)` | `(AgentRunOptions) => Promise<AgentRunResult>` | Run the loop to a final answer, budget exhaustion, or abort. |
51
+ | `events` | `AgentEmitter` | Subscribe with `events.on(handler)`; the returned function unsubscribes. |
52
+ | `config` | `AgentConfig` | Frozen, fully-resolved config (defaults filled in). |
53
+
54
+ `AgentRunOptions`:
55
+
56
+ | Field | Type | Meaning |
57
+ | --- | --- | --- |
58
+ | `input` | `string` | Required user message for this run. |
59
+ | `history` | `Message[]` | Prior conversation to continue (multi-turn). |
60
+ | `signal` | `AbortSignal` | Cooperative cancellation; the loop returns `outcome.stopped_reason: "aborted"`. |
61
+ | `label` | `string` | Origin tag for the session filename (e.g. `"tui"`, `"gw:webhook:default"`). |
62
+
63
+ `AgentRunResult`:
64
+
65
+ | Field | Type | Meaning |
66
+ | --- | --- | --- |
67
+ | `outcome.final` | `AssistantMessage \| undefined` | The final assistant reply (undefined on abort/budget without content). |
68
+ | `outcome.turns_used` | `number` | Turns consumed this run. |
69
+ | `outcome.stopped_reason` | `"final" \| "budget" \| "aborted"` | Why the loop ended. |
70
+ | `messages` | `Message[]` | Full transcript: your `history` plus the new exchange. |
71
+ | `usage_total` | `Usage` | Summed `{prompt_tokens, completion_tokens, total_tokens}`. |
72
+ | `session_path` | `string \| undefined` | Session JSONL path, or `undefined` if persistence failed (logged warning, never throws). |
73
+
74
+ ## Events
75
+
76
+ Handlers receive a discriminated `AgentEvent` union; throwing handlers are logged, never fatal:
77
+
78
+ | Event | Payload |
79
+ | --- | --- |
80
+ | `turn_start` / `turn_end` | `{ turn }` |
81
+ | `llm_start` | `{ turn }` |
82
+ | `llm_end` | `{ turn, result: ChatResult }` — carries `usage` per call. |
83
+ | `tool_call_start` | `{ turn, call: ToolCall }` |
84
+ | `tool_call_end` | `{ turn, call, result: ToolResult }` |
85
+ | `compress_start` | `{ estimated_tokens }` |
86
+ | `compress_end` | `{ summary_chars }` |
87
+ | `final` | `{ message, result }` — the answer that ends the run. |
88
+ | `budget_exhausted` | `{ turns_used }` |
89
+ | `error` | `{ error }` — provider/loop errors; the run may still recover via failover. |
90
+
91
+ Print every tool call as it happens:
92
+
93
+ ```ts
94
+ const agent = create_agent(config);
95
+ const unsubscribe = agent.events.on((event) => {
96
+ if (event.type === "tool_call_end") {
97
+ const status = event.result.ok ? "ok" : `error: ${event.result.error}`;
98
+ console.log(`[tool] ${event.call.name}(${JSON.stringify(event.call.args)}) -> ${status}`);
99
+ }
100
+ });
101
+ try {
102
+ await agent.run({ input: "List the repo and find TODO comments" });
103
+ } finally {
104
+ unsubscribe();
105
+ }
106
+ ```
107
+
108
+ ## Multi-turn conversations
109
+
110
+ Pass prior `result.messages` back in as `history`:
111
+
112
+ ```ts
113
+ let history: Message[] = [];
114
+ for (const question of ["What files are in the repo?", "Which one is largest?"]) {
115
+ const result = await agent.run({ input: question, history });
116
+ console.log(result.outcome.final?.content);
117
+ history = result.messages;
118
+ }
119
+ ```
120
+
121
+ ## Config reference
122
+
123
+ Same schema as the CLI config file — see the [config file reference](cli.md#config-file-reference) for the full field table. As a library caller you normally construct it directly:
124
+
125
+ ```ts
126
+ const config = {
127
+ providers: [
128
+ { kind: "openai_compat", name: "openrouter", model: "meta-llama/llama-3.1-8b-instruct",
129
+ base_url: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY" },
130
+ { kind: "ollama", name: "local", model: "llama3.2" }, // failover target
131
+ ],
132
+ max_turns: 25,
133
+ tools_enabled: ["read_file", "list_dir", "terminal", "web_search", "fetch_url"],
134
+ session_dir: "./.lich/sessions",
135
+ };
136
+ ```
137
+
138
+ Listed providers form a failover chain tried in order: `rate_limit`/`network` errors retry with backoff (3 attempts) on the current provider before failing over; `auth`, `overflow`, and `bad_request` fail over immediately. The last error is rethrown when all providers fail.
139
+
140
+ ## Custom tool filtering
141
+
142
+ `tools_enabled` accepts `"all"` (default) or an array of builtin tool names to register; everything else stays unregistered and invisible to the model:
143
+
144
+ ```ts
145
+ const agent = create_agent({
146
+ providers: [{ kind: "ollama", name: "local", model: "llama3.2" }],
147
+ tools_enabled: ["read_file", "grep_files", "list_dir"],
148
+ });
149
+ ```
150
+
151
+ ## Error handling
152
+
153
+ Provider failures throw `ProviderError`, an `Error` subclass with `kind`, `provider_name`, optional `status` and `retry_after_ms`:
154
+
155
+ | `kind` | Meaning | Failover behavior |
156
+ | --- | --- | --- |
157
+ | `auth` | 401/403 or bad credentials. | Immediate failover to the next provider. |
158
+ | `rate_limit` | 429 or 5xx (with `retry_after_ms` when the server sends it). | 3 attempts with backoff, then failover. |
159
+ | `network` | Fetch failed, timeout, or abort while connecting. | 3 attempts with backoff, then failover. |
160
+ | `overflow` | Request exceeded the model's context window. | Immediate failover. |
161
+ | `bad_request` | 400/422 or an unparseable success payload. | Immediate failover. |
162
+ | `unknown` | Non-provider errors (e.g. tool crashes surfaced as strings). | Treated as fatal for the provider. |
163
+
164
+ ```ts
165
+ import { ProviderError } from "lich";
166
+
167
+ try {
168
+ await agent.run({ input: "hello" });
169
+ } catch (error) {
170
+ if (error instanceof ProviderError) {
171
+ console.error(`${error.provider_name} failed: ${error.kind} — ${error.message}`);
172
+ }
173
+ throw error;
174
+ }
175
+ ```
176
+
177
+ When every configured provider fails, the last `ProviderError` is thrown. Tool failures are *not* exceptions: they return `{ ok: false, output, error }` into the loop as tool messages for the model to react to. Cancellation via `signal` ends the run with `stopped_reason: "aborted"` rather than throwing.
178
+
179
+ ## Session access
180
+
181
+ Each `run()` appends a transcript line-by-line under `config.session_dir` (default `<work_dir>/.lich/sessions`); `result.session_path` gives the exact file. Records carry `{ts, kind: "message"|"meta", message?, meta?}`; read them with `jq` or the exported `read_session_messages(path)` helper from `src/session/store.ts`. Persistence is best-effort: a write failure logs a warning, returns `session_path: undefined`, and never fails the run.