@moikapy/lich 0.3.1 → 0.5.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/CHANGELOG.md +42 -0
- package/README.md +12 -2
- package/dist/{chunk-P52U5M3L.js → chunk-CV2YH3FH.js} +449 -71
- package/dist/chunk-CV2YH3FH.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +590 -25
- package/dist/cli.js.map +1 -1
- package/dist/{gateway-CWPVIU3W.js → gateway-W6S43ETE.js} +27 -18
- package/dist/gateway-W6S43ETE.js.map +1 -0
- package/dist/index.d.ts +58 -11
- package/dist/index.js +1 -1
- package/dist/{tui-K3EPRXTV.js → tui-DT7XWDTX.js} +8 -5
- package/dist/tui-DT7XWDTX.js.map +1 -0
- package/docs/architecture/overview.md +8 -6
- package/docs/architecture/plugins.md +57 -4
- package/docs/architecture/tools.md +16 -4
- package/docs/getting-started.md +15 -4
- package/docs/index.md +4 -3
- package/docs/user-guide/cli.md +24 -3
- package/docs/user-guide/godot.md +160 -0
- package/docs/user-guide/library.md +4 -2
- package/docs/user-guide/plugins.md +79 -5
- package/docs/user-guide/tui.md +4 -3
- package/examples/game_bridge/README.md +68 -0
- package/examples/game_bridge/bridge_io.mjs +60 -0
- package/examples/game_bridge/bridge_paths.mjs +11 -0
- package/examples/game_bridge/dungeon_memory.mjs +56 -0
- package/examples/game_bridge/enemy_actions.mjs +44 -0
- package/examples/game_bridge/game_bridge.plugin.mjs +17 -0
- package/examples/game_bridge/meteor_veto.mjs +22 -0
- package/examples/game_bridge/schemas.mjs +48 -0
- package/examples/game_bridge/snapshot.mjs +19 -0
- package/examples/game_bridge/validate_order.mjs +44 -0
- package/package.json +2 -1
- package/dist/chunk-P52U5M3L.js.map +0 -1
- package/dist/gateway-CWPVIU3W.js.map +0 -1
- package/dist/tui-K3EPRXTV.js.map +0 -1
package/docs/user-guide/cli.md
CHANGED
|
@@ -2,22 +2,28 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
##
|
|
5
|
+
## Entry points
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
+
lich # open the TUI; first run on a TTY starts the setup wizard
|
|
9
|
+
lich init # write .lich/config.json without the wizard (flags apply; never overwrites)
|
|
8
10
|
lich "one shot task" # run a single task and print the reply
|
|
9
11
|
lich chat # interactive chat (commands: /exit, /quit)
|
|
10
12
|
lich tui # interactive terminal UI (ink)
|
|
11
13
|
lich gateway <plat..> # messaging gateway (webhook|telegram|discord|twitch)
|
|
12
14
|
lich config # print a starter config template
|
|
15
|
+
lich update # install a newer npm release, if one exists
|
|
13
16
|
lich --help # usage text
|
|
14
17
|
lich --version # print 0.3.0
|
|
15
18
|
```
|
|
16
19
|
|
|
20
|
+
- **Bare `lich`** opens the same TUI as `lich tui`. It does not print usage. On a TTY, if neither `.lich/config.json` nor `~/.config/lich/config.json` exists and `LICH_MODEL` / `--model` is unset, a setup wizard runs first (name, provider, optional gateway env-var names, optional plugins) and writes `.lich/config.json` once. An existing `.lich/config.json` skips the wizard and is not replaced. Non-TTY stdin skips the wizard and prints guidance instead of hanging. `lich --help` still prints usage.
|
|
21
|
+
- **`lich init`** writes that starter file without prompts, using the same writer as the wizard. Existing flags such as `--model` are written into the file and win over `LICH_MODEL`. It never overwrites an existing `.lich/config.json`. `.lich/` is gitignored.
|
|
17
22
|
- **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
23
|
- **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
24
|
- **TUI** launches the ink interface. See the [TUI guide](tui.md).
|
|
20
25
|
- **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`.
|
|
26
|
+
- **Update** compares the installed version to the npm registry and, when a newer release exists, runs `npm install -g @moikapy/lich@latest`. Exit any running TUI or gateway first; npm cannot replace the package while those processes are running. A git clone is told to `git pull`. See [Updating](../getting-started.md#updating).
|
|
21
27
|
|
|
22
28
|
The installed `lich` binary and `bun src/cli.ts` (from a repository clone) accept identical arguments.
|
|
23
29
|
|
|
@@ -105,6 +111,8 @@ Validated by zod (top-level unknown keys are silently stripped; extra keys insid
|
|
|
105
111
|
| `providers[].timeout_ms` | positive int | none | Per-request abort deadline. |
|
|
106
112
|
| `providers[].think` | boolean | – | Ollama only: request thinking mode. |
|
|
107
113
|
| `providers[].keep_alive` | string | – | Ollama only: model residency (e.g. `"10m"`). |
|
|
114
|
+
| `agent_name` | string | `lich` | Display name in the TUI banner. |
|
|
115
|
+
| `gateway` | object | omitted | Optional. `platforms` (`webhook` \| `telegram` \| `discord` \| `twitch`) and `token_envs` (platform → env-var name). Secrets stay in the environment. |
|
|
108
116
|
| `system_prompt` | string | built-in | Replaces the default system prompt. |
|
|
109
117
|
| `max_turns` | int >= 1 | `25` | Turn budget per run. |
|
|
110
118
|
| `work_dir` | string | cwd | Root for all file tools; paths outside are rejected. |
|
|
@@ -129,6 +137,19 @@ Minimal per-provider examples:
|
|
|
129
137
|
|
|
130
138
|
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
139
|
|
|
140
|
+
## Self-improvement environment
|
|
141
|
+
|
|
142
|
+
These are process-env knobs, not config fields. They are assembled in code and
|
|
143
|
+
never accepted as a config passthrough.
|
|
144
|
+
|
|
145
|
+
| Variable | Meaning |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `LICH_ALLOW_SELF_COMMIT` | Set to `1` to allow one gated `git_commit` per run. Unset or any other value is fail-closed. Read at agent construction. |
|
|
148
|
+
| `LICH_TEST_COMMAND` | Command `run_tests` runs in `work_dir` (default `node node_modules/vitest/vitest.mjs run`). An optional `filter` argument is appended. |
|
|
149
|
+
|
|
150
|
+
Veto reasons, the terminal git denylist, skills, and `MEMORY.md` are in the
|
|
151
|
+
[plugins guide](plugins.md#self-improvement-loop).
|
|
152
|
+
|
|
132
153
|
## Session files
|
|
133
154
|
|
|
134
155
|
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.
|
|
@@ -145,8 +166,8 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content // "(too
|
|
|
145
166
|
|
|
146
167
|
| Code | Meaning |
|
|
147
168
|
| --- | --- |
|
|
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,
|
|
169
|
+
| `0` | Success: final answer produced (also `--help`, `--version`, `config`, `init`, and a TUI that exits cleanly). |
|
|
170
|
+
| `1` | Any failure: unknown flag, missing model, unreadable config, provider error after failover, aborted run, budget exhaustion, non-TTY bare `lich`, or a cancelled setup wizard. |
|
|
150
171
|
|
|
151
172
|
## Log levels
|
|
152
173
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Godot guide
|
|
2
|
+
|
|
3
|
+
> What you'll learn: how to run lich beside a Godot game — the webhook call, the `game_bridge` plugin path, and the `.lich/game/` files Godot drains. Godot never links lich.
|
|
4
|
+
|
|
5
|
+
lich is the AI brain in a separate process. Godot speaks HTTP. This page is the recipe; the file contract and tool list live in [`examples/game_bridge/README.md`](https://github.com/Moikapy/lich/blob/main/examples/game_bridge/README.md). Plugin authoring is the [plugins guide](plugins.md). Platform setup beyond the webhook is the [gateway guide](gateway.md). A TypeScript game backend that embeds the library uses the [library guide](library.md) — Godot itself does not.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
Two tiers:
|
|
10
|
+
|
|
11
|
+
- **Dialogue.** `lich gateway webhook` plus a `chat_id`. No plugin. The reply text is the line.
|
|
12
|
+
- **Combat commander.** The same webhook, with [`examples/game_bridge/game_bridge.plugin.mjs`](https://github.com/Moikapy/lich/blob/main/examples/game_bridge/game_bridge.plugin.mjs) loaded. The model queues a round by calling `enemy_actions`. Godot never parses tool calls out of `reply`; it drains the order file the tool appends.
|
|
13
|
+
|
|
14
|
+
LLM latency is seconds, not frames. Call lich **once per combat round**, never per frame and never from input-handling logic. Mask the wait in the turn: the enemy commander looks over the field, then the round resolves. Do not start round N+1 for the same `chat_id` until round N's HTTP call has finished — the gateway already serializes that conversation, so an early follow-up only queues behind the slow one.
|
|
15
|
+
|
|
16
|
+
```mermaid
|
|
17
|
+
flowchart LR
|
|
18
|
+
A[round start] --> B[optional state.json]
|
|
19
|
+
B --> C["POST /message"]
|
|
20
|
+
C --> D[diegetic wait]
|
|
21
|
+
D --> E[reply or local timeout]
|
|
22
|
+
E --> F[read orders.jsonl]
|
|
23
|
+
F --> G[apply, then truncate]
|
|
24
|
+
G --> H[resolve the round]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A game backend may instead call `run_agent`, which loads `config.plugins`. `create_agent` does not. Godot still reaches that backend over HTTP; it does not import the package.
|
|
28
|
+
|
|
29
|
+
## Gateway contract
|
|
30
|
+
|
|
31
|
+
`lich gateway webhook` binds `0.0.0.0` on `LICH_GATEWAY_PORT` (default `8089`). `GET /health` is `200 {"status":"ok"}` — the process is up, not that a provider is healthy. Check it before the first round.
|
|
32
|
+
|
|
33
|
+
`POST /message`. Only `text` is required. Omitted fields default to `platform` `"webhook"`, `chat_id` `"default"`, `user_id` `"anonymous"`.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
LICH_GATEWAY_TOKEN=s3cret lich gateway webhook
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
curl -s -X POST http://127.0.0.1:8089/message \
|
|
41
|
+
-H "x-lich-token: s3cret" -H "content-type: application/json" \
|
|
42
|
+
-d '{"text":"round 1: hero1 at full. goblin is the only living enemy.","chat_id":"run-1"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Success is exactly one JSON object. This endpoint always sends `usage: null` — it does not forward provider token counts:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{"reply":"...","usage":null}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Status | Body |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `400` | `{"error":"text is required"}` |
|
|
54
|
+
| `401` | `{"error":"unauthorized"}` when `LICH_GATEWAY_TOKEN` is set and `x-lich-token` does not match |
|
|
55
|
+
| `404` | `{"error":"not found"}` for any other method or path |
|
|
56
|
+
| `500` | `{"error":"internal error"}` if the handler throws before a response is sent |
|
|
57
|
+
|
|
58
|
+
A failed run is still `200`. `reply` is then a sanitized `agent error: ...` line. There is no streaming, pagination, or cursor. Full platform notes: [webhook API](gateway.md#webhook-api-reference).
|
|
59
|
+
|
|
60
|
+
Memory is keyed `platform:chat_id`, capped at 40 messages (oldest dropped) and 200 conversations (oldest dropped). For a roguelike, `chat_id` = the run id gives the commander that process's memory of the run. A new run id starts a fresh history. That history is in memory only — restarting the gateway clears it. Restate facts the digest still needs. Durable notes are a different file, below.
|
|
61
|
+
|
|
62
|
+
## Wire the example plugin
|
|
63
|
+
|
|
64
|
+
Node `>=20` loads the `.mjs` entry. Restart to reload; there is no hot reload. Paths in `config.plugins` are relative to `work_dir`.
|
|
65
|
+
|
|
66
|
+
If `work_dir` is the lich checkout:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"plugins": ["./examples/game_bridge/game_bridge.plugin.mjs"]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If `work_dir` is the game repo, copy the `examples/game_bridge/` folder into that repo and point `plugins` at the copy the same way. Restart the gateway (or whichever entry you use) after changing the list. The gateway loads plugins; a config entry is not ignored.
|
|
75
|
+
|
|
76
|
+
The prompt `text` is yours. The plugin does not parse it. Put the battle digest there — party, living enemies, kits, last round — as plain text or as JSON inside the string.
|
|
77
|
+
|
|
78
|
+
## Dialogue autoload
|
|
79
|
+
|
|
80
|
+
Sketch only. The game repo owns the real autoload. One `HTTPRequest` per in-flight call; `CONNECT_ONE_SHOT` so two speakers do not share a callback. Set `timeout` yourself (Godot's `0` means no timeout). On timeout, non-200, or a body that is not the object above, emit your own fallback and resolve the beat. Do not wait on the frame path.
|
|
81
|
+
|
|
82
|
+
```gdscript
|
|
83
|
+
# Sketch — not shipped as a Godot project in this repo.
|
|
84
|
+
extends Node
|
|
85
|
+
signal reply_received(text: String)
|
|
86
|
+
var request: HTTPRequest
|
|
87
|
+
|
|
88
|
+
func _ready() -> void:
|
|
89
|
+
request = HTTPRequest.new()
|
|
90
|
+
request.timeout = 45.0
|
|
91
|
+
add_child(request)
|
|
92
|
+
|
|
93
|
+
func ask(chat_id: String, text: String, token: String) -> void:
|
|
94
|
+
var body := JSON.stringify({"text": text, "chat_id": chat_id})
|
|
95
|
+
var headers := PackedStringArray([
|
|
96
|
+
"Content-Type: application/json", "x-lich-token: %s" % token
|
|
97
|
+
])
|
|
98
|
+
request.request_completed.connect(_on_done, CONNECT_ONE_SHOT)
|
|
99
|
+
request.request(
|
|
100
|
+
"http://127.0.0.1:8089/message", headers, HTTPClient.METHOD_POST, body
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
func _on_done(result: int, code: int, _headers: PackedStringArray, raw: PackedByteArray) -> void:
|
|
104
|
+
var reply := ""
|
|
105
|
+
if result == HTTPRequest.RESULT_SUCCESS and code == 200:
|
|
106
|
+
var parsed: Variant = JSON.parse_string(raw.get_string_from_utf8())
|
|
107
|
+
if parsed is Dictionary:
|
|
108
|
+
reply = str(parsed.get("reply", ""))
|
|
109
|
+
reply_received.emit(reply)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Combat tick
|
|
113
|
+
|
|
114
|
+
Godot, each tick, against files under `work_dir/.lich/game/` — not `res://` unless that directory is `work_dir`:
|
|
115
|
+
|
|
116
|
+
1. Read `orders.jsonl`.
|
|
117
|
+
2. Apply the lines.
|
|
118
|
+
3. Truncate the file. Read then truncate; do not rewrite lines in place.
|
|
119
|
+
4. Optionally refresh `state.json` with the current snapshot (`round`, hero ids, enemy ids).
|
|
120
|
+
|
|
121
|
+
Append-only writes keep a drain race from corrupting a line. A line appended during truncate can still be lost; drain under the game's own lock if that matters. The first call creates `.lich/game/` if it is missing.
|
|
122
|
+
|
|
123
|
+
`enemy_actions` appends one JSONL line and echoes the action count (`appended 2 orders for round 1`). An order line:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{"ts":"2026-01-01T00:00:00.000Z","round":1,"actions":[{"enemy_id":"goblin","action":"attack","target_ref":"hero:hero1"}],"rationale":"open with a strike"}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`action` is an ability id from that enemy's kit, or `attack`, `defend`, or `flee`. `target_ref` is `hero:<id>` or `enemy:<id>`. `rationale` is the combat-log line. One call per round is the intended cadence. The plugin does not stop a second call.
|
|
130
|
+
|
|
131
|
+
`state.json` is written by Godot and read when `enemy_actions` runs. The plugin compares `round` only:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{"round":1,"heroes":[{"id":"hero1"}],"enemies":[{"id":"goblin"}]}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A round that does not match is still appended. Tool output then includes `snapshot_round_mismatch: snapshot=<n>`. A missing or unreadable snapshot is ignored. Refresh `state.json` before the POST if that comparison should see this round; the tick's optional refresh is for the next read.
|
|
138
|
+
|
|
139
|
+
`dungeon_memory_write` / `dungeon_memory_read` are a separate append-only `memory.jsonl` (read returns the latest 20 notes, or `(none)`). That is not the combat queue. Do not drain it as orders. Shape and failure strings: [`examples/game_bridge/README.md`](https://github.com/Moikapy/lich/blob/main/examples/game_bridge/README.md).
|
|
140
|
+
|
|
141
|
+
The plugin checks the line shape and returns `{ok: false, error}` instead of throwing. It does not know your units. Unknown ids, dead units, abilities outside a kit, whether a boss may `flee`, and duplicate lines for one round stay in the game. `flee` is a valid literal even in a boss fight. Drain even when `reply` looks fine — the model may never have called the tool, or it may have called it twice.
|
|
142
|
+
|
|
143
|
+
## Meteor gate
|
|
144
|
+
|
|
145
|
+
`before_tool_call` vetoes `enemy_actions` when any `action` string contains `meteor` and `round` is below 3. The executor does not run, so no line is written. The model sees `blocked_by_plugin: meteor_gates_closed_until_round_3` as an error tool result and can call again in the same run. Round 3 and later are not vetoed. That is the whole gate — one hardcoded check, not a table of encounter caps.
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
Set `LICH_GATEWAY_TOKEN`. The webhook binds all interfaces, so an open port is an open chatbot with your provider keys and your tools. Mismatch or a missing header is `401`.
|
|
150
|
+
|
|
151
|
+
Players' Godot clients do not talk to lich in production. Godot talks to your backend; the backend holds the token, sets `chat_id` / `user_id`, and rate-limits. Same split as embedding the library in that backend.
|
|
152
|
+
|
|
153
|
+
Plugins run in-process with the agent's privileges (files, network, environment). Load only plugins you wrote or audited. The order file is the crossing into the game, and the game still applies its own rules to every line.
|
|
154
|
+
|
|
155
|
+
## Limits
|
|
156
|
+
|
|
157
|
+
- A long run evicts gateway history past 40 messages. Put habits that still matter in the digest, or in `memory.jsonl` if they must survive a restart.
|
|
158
|
+
- More than 200 concurrent `chat_id`s on one process drops the oldest conversation. Fine for one developer machine; a host of many runs should know the cap.
|
|
159
|
+
- Combat must finish if the gateway is down, the call times out, or `orders.jsonl` is empty or garbage. The game's fallback is the game's — this repo does not ship one.
|
|
160
|
+
- Same-`chat_id` calls run one after another. Different `chat_id`s run concurrently. The plugin itself makes no concurrency guarantee; one bridge per `chat_id` is the intended pattern.
|
|
@@ -34,7 +34,7 @@ console.log(result.outcome.final?.content);
|
|
|
34
34
|
console.log(`tokens: ${result.usage_total.total_tokens}`);
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
`run_agent(config, input)` loads `config.plugins`, then runs once. `create_agent` does not load plugins.
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
40
|
import { run_agent } from "@moikapy/lich";
|
|
@@ -47,7 +47,7 @@ const result = await run_agent(
|
|
|
47
47
|
|
|
48
48
|
## Agent class
|
|
49
49
|
|
|
50
|
-
`new Agent(config)` (or `create_agent(raw)`) builds the provider router, registers the
|
|
50
|
+
`new Agent(config)` (or `create_agent(raw)`) builds the provider router, registers the builtin tools (filtered by `tools_enabled`) plus the gatekeeper's `git_commit`, and exposes:
|
|
51
51
|
|
|
52
52
|
| Member | Type | Purpose |
|
|
53
53
|
| --- | --- | --- |
|
|
@@ -141,6 +141,8 @@ const config = {
|
|
|
141
141
|
|
|
142
142
|
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.
|
|
143
143
|
|
|
144
|
+
`LICH_ALLOW_SELF_COMMIT` and `LICH_TEST_COMMAND` are process-env knobs, not config fields. See the [CLI environment](cli.md#self-improvement-environment).
|
|
145
|
+
|
|
144
146
|
## Custom tool filtering
|
|
145
147
|
|
|
146
148
|
`tools_enabled` accepts `"all"` (default) or an array of builtin tool names to register; everything else stays unregistered and invisible to the model:
|
|
@@ -47,11 +47,11 @@ All hooks are awaited. Hook errors are logged as warnings and skipped — a brok
|
|
|
47
47
|
| Hook | Signature | Purpose |
|
|
48
48
|
| --- | --- | --- |
|
|
49
49
|
| `before_tool_call` | `(info: {tool_name, args}, ctx) => {block?: boolean, reason?: string} \| void` | Runs before each tool call in plugin registration order. Return `{block: true, reason}` to veto. |
|
|
50
|
-
| `after_tool_call` | `(info: {tool_name, args, result_summary}, ctx) => void` | Runs after each tool call with a 300-char
|
|
50
|
+
| `after_tool_call` | `(info: {tool_name, args, result_summary, ok, error?}, ctx) => void` | Runs after each tool call with a 300-char summary plus structured `ok`/`error`. |
|
|
51
51
|
| `on_run_start` | `(info: {input_chars}, ctx) => void` | Runs once before the conversation loop starts. |
|
|
52
52
|
| `on_run_end` | `(info: {stopped_reason, turns_used}, ctx) => void` | Runs once after the loop ends with the outcome. |
|
|
53
53
|
|
|
54
|
-
`ctx` is `{work_dir
|
|
54
|
+
`ctx` is `{work_dir, state?}` — the agent's working directory plus that plugin's per-run bag.
|
|
55
55
|
|
|
56
56
|
## Tool authoring
|
|
57
57
|
|
|
@@ -112,9 +112,83 @@ Failures are contained at every layer:
|
|
|
112
112
|
|
|
113
113
|
## Runtime notes
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
`package.json` `engines.node` is `>=20`. The CLI loads `config.plugins` before the run. A broken entry logs one `plugin load errors` warning and the run continues without that plugin.
|
|
116
|
+
|
|
117
|
+
`.mjs` and other plain JS (no type syntax) are the form that matches `engines.node` `>=20`. They load on Node and on Bun with no plugin-load warning.
|
|
118
|
+
|
|
119
|
+
Checked with Node 26.8.2 (`node dist/cli.js`) and Bun 1.3.14 (`bun src/cli.ts`):
|
|
120
|
+
|
|
121
|
+
- Erasable `.ts` (`import type`, annotations) loads under `node dist/cli.js` only where Node strips types by default (22.18+, 23.6+, 24+, 26). Node 22.18's disable flag is `--no-experimental-strip-types`. Bun 1.3.14 loads that same file with no plugin-load warning. The Node 26.8.2 check type-strips by default and does not bundle. `node --no-strip-types` warns (`Unknown file extension ".ts"`) and continues.
|
|
122
|
+
- On Node 20 and Node 22 before 22.18, a `.ts` entry still warns and the run continues without that plugin.
|
|
123
|
+
- Syntax Node cannot strip (for example `enum`) warns and continues. Bun runs that same file with no plugin-load warning. A syntax error warns on both and the run continues.
|
|
124
|
+
|
|
125
|
+
The combat-commander reference is `examples/game_bridge/game_bridge.plugin.mjs`. Point `config.plugins` at `./examples/game_bridge/game_bridge.plugin.mjs` (relative to `work_dir`). See `examples/game_bridge/README.md`. Embedding it beside a Godot game: [Godot guide](godot.md).
|
|
126
|
+
|
|
127
|
+
Plain JS (matches `engines.node` `>=20`):
|
|
128
|
+
|
|
129
|
+
```mjs
|
|
130
|
+
// .lich/plugins/my-plugin.mjs
|
|
131
|
+
const my_plugin = {
|
|
132
|
+
name: "my-plugin",
|
|
133
|
+
tools: [],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default my_plugin;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Self-improvement loop
|
|
140
|
+
|
|
141
|
+
The agent can write a tool, prove it with `run_tests`, and commit it with
|
|
142
|
+
`git_commit` — one commit per run, and only when you opt in. The gatekeeper
|
|
143
|
+
is constructed in code (not listed in `config.plugins`). If it does not
|
|
144
|
+
register, `git_commit` is absent. A config plugin naming `git_commit` is
|
|
145
|
+
inside the plugin-trust floor only when the gatekeeper is off; while it is
|
|
146
|
+
on, first-wins keeps the gatekeeper's tool.
|
|
147
|
+
|
|
148
|
+
Set `LICH_ALLOW_SELF_COMMIT=1` before startup. Unset, or any other value, is
|
|
149
|
+
fail-closed. `git_commit` is vetoed unless every condition holds; the reason
|
|
150
|
+
names the first failure, and the model sees `blocked_by_plugin: <reason>`:
|
|
151
|
+
|
|
152
|
+
| Failed condition | Reason |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `LICH_ALLOW_SELF_COMMIT` is not `1` | `self_commit_disabled` |
|
|
155
|
+
| no green `run_tests` yet this run | `tests_not_ok` |
|
|
156
|
+
| a `write_file` or `edit_file` succeeded after that green run | `worktree_dirty` |
|
|
157
|
+
| this run already committed once | `commit_budget_exhausted` |
|
|
158
|
+
|
|
159
|
+
`terminal` is vetoed when the command matches the hardcoded git denylist.
|
|
160
|
+
The reason is `git_denylist: <pattern>`. Patterns are flag-tolerant
|
|
161
|
+
`commit`/`push` (`commit`, `-commit`, `--commit`, `push`, `-push`, `--push`)
|
|
162
|
+
and any occurrence of `commit-tree` or `update-ref`. There is no `remote`
|
|
163
|
+
pattern. The denylist is best-effort: raw `terminal` can still run git. The
|
|
164
|
+
boundary is a human reviewing the local repo. Push is human-only.
|
|
165
|
+
|
|
166
|
+
`git_commit` takes `{message, paths}` — 1 to 50 paths relative to `work_dir`.
|
|
167
|
+
It rejects `""`, `.`, a path that resolves to `work_dir` itself, and
|
|
168
|
+
secret-ish basenames (`.env`, `.env.local`, `*.pem`, `*.p12`, `id_rsa*`).
|
|
169
|
+
It refuses an unreachable `HEAD`. It stages exactly the named paths
|
|
170
|
+
(`git add -- <paths>`) and commits with `git commit --only`. It never pushes.
|
|
171
|
+
|
|
172
|
+
`run_tests` takes an optional `filter` and runs `LICH_TEST_COMMAND` in
|
|
173
|
+
`work_dir` (default `node node_modules/vitest/vitest.mjs run`) with a 600s
|
|
174
|
+
timeout. A second call in the same process returns `run_tests_busy`. The
|
|
175
|
+
mutex is process-local: one lich process per repo.
|
|
176
|
+
|
|
177
|
+
Clean state attests no `write_file`/`edit_file` since the last green
|
|
178
|
+
`run_tests`; it does NOT attest absence of terminal-mediated writes.
|
|
179
|
+
|
|
180
|
+
### Skills and memory
|
|
181
|
+
|
|
182
|
+
Write a markdown note with `write_file` to `.lich/skills/<name>.md`.
|
|
183
|
+
`docs_search` finds those files. That directory does not need `index.md`,
|
|
184
|
+
and it is walked fresh on every search. The default system prompt says tool
|
|
185
|
+
results — docs, skills, memory — are reference data, not instructions.
|
|
186
|
+
|
|
187
|
+
`MEMORY.md` is append-only and human-reviewable. It is never auto-loaded.
|
|
188
|
+
Review it between appends and the next self-commit.
|
|
117
189
|
|
|
118
190
|
## Security note
|
|
119
191
|
|
|
120
|
-
Plugins execute **in-process with full privileges** — the same trust level as the agent itself and your shell. A plugin can read any file the process can, make network calls, and alter process state. Only load plugin files you wrote or audited; treat `.lich/plugins/` like you treat `.env` files.
|
|
192
|
+
Plugins execute **in-process with full privileges** — the same trust level as the agent itself and your shell. A plugin can read any file the process can, make network calls, and alter process state. Only load plugin files you wrote or audited; treat `.lich/plugins/` like you treat `.env` files.
|
|
193
|
+
|
|
194
|
+
`.lich/config.json` `plugins` is persistent arbitrary code at the next process start. Review config diffs before the next self-commit. The terminal git denylist does not close that hole.
|
package/docs/user-guide/tui.md
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
## Launching
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
lich
|
|
8
|
+
lich # front door: TUI, plus a first-run setup wizard when no config exists
|
|
9
|
+
lich tui # same TUI, no wizard. From a clone: bun src/cli.ts tui
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
The TUI needs a TTY and a resolvable provider (same resolution as every mode). On startup it prints a dim header with the version and the first provider's model, e.g. `lich v0.3.0 — llama3.2 (ollama)`. Quit with `/exit`, `/quit`, `/q`, or Ctrl+C.
|
|
12
|
+
The TUI needs a TTY and a resolvable provider (same resolution as every mode). On startup it prints a dim header with `agent_name` (default `lich`), the version, and the first provider's model, e.g. `lich v0.3.0 — llama3.2 (ollama)`. Quit with `/exit`, `/quit`, `/q`, or Ctrl+C.
|
|
12
13
|
|
|
13
14
|
## Anatomy
|
|
14
15
|
|
|
15
16
|
```
|
|
16
|
-
lich v0.3.0 — llama3.2 (ollama) <- header: version, model,
|
|
17
|
+
lich v0.3.0 — llama3.2 (ollama) <- header: agent_name (default lich), version, model, kind
|
|
17
18
|
you › list the files here <- your input, echoed into the transcript
|
|
18
19
|
⏺ list_dir({}) <- live tool-call row (name + args preview)
|
|
19
20
|
⏷ list_dir: ok (d src/ d test/ ...) <- result row (ok/error + output preview)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# game_bridge
|
|
2
|
+
|
|
3
|
+
Reference plugin: a lich agent queues Final-Fantasy-style enemy turns for a Godot process. lich and Godot do not share memory. Tools write files under `.lich/game/`; Godot drains them each tick. Godot-side code lives in the game repo.
|
|
4
|
+
|
|
5
|
+
Node `>=20` loads this entry. Point `config.plugins` at the `.mjs` file (paths are relative to `work_dir`):
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"plugins": ["./examples/game_bridge/game_bridge.plugin.mjs"]
|
|
10
|
+
}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Restart the agent after changing the list. There is no hot reload. Copy this folder into the game repo if `work_dir` is not the lich checkout, and point `plugins` at that copy the same way.
|
|
14
|
+
|
|
15
|
+
## Round cadence
|
|
16
|
+
|
|
17
|
+
One `enemy_actions` call per combat round decides every enemy. The gateway serializes runs per conversation, so one bridge per `chat_id` is the intended pattern. The plugin itself makes no concurrency guarantee.
|
|
18
|
+
|
|
19
|
+
Godot, each tick:
|
|
20
|
+
|
|
21
|
+
1. Read `.lich/game/orders.jsonl`.
|
|
22
|
+
2. Apply the lines.
|
|
23
|
+
3. Truncate the file (read + truncate; do not rewrite lines in place).
|
|
24
|
+
4. Optionally refresh `.lich/game/state.json` with the current battle snapshot (`round`, hero ids, enemy ids).
|
|
25
|
+
|
|
26
|
+
Append-only writes keep a drain race from corrupting a line. A line appended during truncate can still be lost; Godot should drain under its own lock if that matters. Duplicate lines for one round are possible if the model calls twice. Dedupe on the Godot side.
|
|
27
|
+
|
|
28
|
+
`rationale` is the replayable combat-log entry. The agent session JSONL already stores tool-call arguments, so the rationale is in that transcript. This plugin does not write the session store.
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
| Tool | Effect |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `enemy_actions` | Appends one JSONL order line to `.lich/game/orders.jsonl`. Output echoes the appended action count. |
|
|
35
|
+
| `dungeon_memory_write` | Appends one note to `.lich/game/memory.jsonl`. |
|
|
36
|
+
| `dungeon_memory_read` | Returns the most recent 20 notes, joined by newlines. Empty memory returns `(none)`. |
|
|
37
|
+
|
|
38
|
+
`action` is an ability id from that enemy's kit, or one of `attack`, `defend`, `flee`. `target_ref` is `hero:<id>` or `enemy:<id>`. `flee` is a valid literal even in a boss fight; Godot decides whether it is allowed.
|
|
39
|
+
|
|
40
|
+
## File contract
|
|
41
|
+
|
|
42
|
+
Order line (`orders.jsonl`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{"ts":"2026-01-01T00:00:00.000Z","round":1,"actions":[{"enemy_id":"goblin","action":"attack","target_ref":"hero:hero1"}],"rationale":"open with a strike"}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Memory line (`memory.jsonl`):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{"ts":"2026-01-01T00:00:00.000Z","note":"the player always heals below half"}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Snapshot (`state.json`, written by Godot, read by `enemy_actions`):
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{"round":1,"heroes":[{"id":"hero1"}],"enemies":[{"id":"goblin"}]}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A round that does not match `state.json` is still appended. The tool output then includes `snapshot_round_mismatch: snapshot=<n>`. A missing or unreadable snapshot is ignored.
|
|
61
|
+
|
|
62
|
+
## Difficulty gate
|
|
63
|
+
|
|
64
|
+
`before_tool_call` blocks `enemy_actions` when any `action` contains `meteor` and `round` is below 3. The model sees `blocked_by_plugin: meteor_gates_closed_until_round_3` and re-plans in the same run. Round 3 and later pass through. The executor never runs on a blocked call, so no order line is written.
|
|
65
|
+
|
|
66
|
+
## Failures
|
|
67
|
+
|
|
68
|
+
Tools return `{ok: false, error}` and do not throw. Reads skip malformed JSONL lines. The first call creates `.lich/game/` if it is missing. `memory.jsonl` grows forever; only the read is capped.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function tool_failure(error) {
|
|
5
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6
|
+
return { ok: false, output: "", error: message };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function ensure_game_dir(work_dir) {
|
|
10
|
+
await mkdir(path.join(work_dir, ".lich", "game"), { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function append_jsonl(file_path, record) {
|
|
14
|
+
let line;
|
|
15
|
+
try {
|
|
16
|
+
line = JSON.stringify(record);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return tool_failure(error);
|
|
19
|
+
}
|
|
20
|
+
await appendFile(file_path, `${line}\n`, "utf8");
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function read_jsonl_records(file_path) {
|
|
25
|
+
let raw = "";
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(file_path, "utf8");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (is_missing(error) === true) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
const records = [];
|
|
35
|
+
for (const line of raw.split("\n")) {
|
|
36
|
+
const parsed = parse_line(line);
|
|
37
|
+
if (parsed !== undefined) {
|
|
38
|
+
records.push(parsed);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return records;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parse_line(line) {
|
|
45
|
+
if (line.length === 0) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(line);
|
|
50
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function is_missing(error) {
|
|
59
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const ORDERS_FILE = "orders.jsonl";
|
|
4
|
+
export const MEMORY_FILE = "memory.jsonl";
|
|
5
|
+
export const STATE_FILE = "state.json";
|
|
6
|
+
/** Most recent notes returned by dungeon_memory_read; writes stay append-only. */
|
|
7
|
+
export const MEMORY_READ_LIMIT = 20;
|
|
8
|
+
|
|
9
|
+
export function game_file(work_dir, file_name) {
|
|
10
|
+
return path.join(work_dir, ".lich", "game", file_name);
|
|
11
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { append_jsonl, ensure_game_dir, read_jsonl_records, tool_failure } from "./bridge_io.mjs";
|
|
2
|
+
import { MEMORY_FILE, MEMORY_READ_LIMIT, game_file } from "./bridge_paths.mjs";
|
|
3
|
+
import { dungeon_memory_read_schema, dungeon_memory_write_schema } from "./schemas.mjs";
|
|
4
|
+
|
|
5
|
+
export const dungeon_memory_read_tool = {
|
|
6
|
+
name: "dungeon_memory_read",
|
|
7
|
+
description: "Read the most recent durable cross-run notes about the player.",
|
|
8
|
+
parameters: dungeon_memory_read_schema,
|
|
9
|
+
execute: read_memory,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const dungeon_memory_write_tool = {
|
|
13
|
+
name: "dungeon_memory_write",
|
|
14
|
+
description: "Append one durable cross-run observation about the player.",
|
|
15
|
+
parameters: dungeon_memory_write_schema,
|
|
16
|
+
execute: write_memory,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function read_memory(_args, context) {
|
|
20
|
+
try {
|
|
21
|
+
const records = await read_jsonl_records(game_file(context.work_dir, MEMORY_FILE));
|
|
22
|
+
const notes = recent_notes(records);
|
|
23
|
+
return { ok: true, output: notes.length > 0 ? notes.join("\n") : "(none)" };
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return tool_failure(error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function write_memory(args, context) {
|
|
30
|
+
if (typeof args.note !== "string" || args.note.length === 0) {
|
|
31
|
+
return { ok: false, output: "", error: "note_required" };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await ensure_game_dir(context.work_dir);
|
|
35
|
+
const written = await append_jsonl(game_file(context.work_dir, MEMORY_FILE), {
|
|
36
|
+
ts: new Date().toISOString(),
|
|
37
|
+
note: args.note,
|
|
38
|
+
});
|
|
39
|
+
if (written.ok === false) {
|
|
40
|
+
return { ok: false, output: "", error: written.error };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, output: "appended 1 note" };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return tool_failure(error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recent_notes(records) {
|
|
49
|
+
const notes = [];
|
|
50
|
+
for (const record of records) {
|
|
51
|
+
if (typeof record.note === "string") {
|
|
52
|
+
notes.push(record.note);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return notes.slice(-MEMORY_READ_LIMIT);
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { append_jsonl, ensure_game_dir, tool_failure } from "./bridge_io.mjs";
|
|
2
|
+
import { ORDERS_FILE, game_file } from "./bridge_paths.mjs";
|
|
3
|
+
import { read_snapshot_round } from "./snapshot.mjs";
|
|
4
|
+
import { enemy_actions_schema } from "./schemas.mjs";
|
|
5
|
+
import { normalize_actions, validate_enemy_actions } from "./validate_order.mjs";
|
|
6
|
+
|
|
7
|
+
export const enemy_actions_tool = {
|
|
8
|
+
name: "enemy_actions",
|
|
9
|
+
description: "Queue every enemy action for one combat round. Godot drains the order next tick.",
|
|
10
|
+
parameters: enemy_actions_schema,
|
|
11
|
+
execute: queue_enemy_actions,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async function queue_enemy_actions(args, context) {
|
|
15
|
+
const problem = validate_enemy_actions(args);
|
|
16
|
+
if (problem !== undefined) {
|
|
17
|
+
return { ok: false, output: "", error: problem };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
await ensure_game_dir(context.work_dir);
|
|
21
|
+
const actions = normalize_actions(args.actions);
|
|
22
|
+
const written = await append_jsonl(game_file(context.work_dir, ORDERS_FILE), {
|
|
23
|
+
ts: new Date().toISOString(),
|
|
24
|
+
round: args.round,
|
|
25
|
+
actions,
|
|
26
|
+
rationale: args.rationale,
|
|
27
|
+
});
|
|
28
|
+
if (written.ok === false) {
|
|
29
|
+
return { ok: false, output: "", error: written.error };
|
|
30
|
+
}
|
|
31
|
+
const snapshot_round = await read_snapshot_round(context.work_dir);
|
|
32
|
+
return { ok: true, output: format_count(actions.length, args.round, snapshot_round) };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return tool_failure(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function format_count(count, round, snapshot_round) {
|
|
39
|
+
const base = `appended ${count} orders for round ${round}`;
|
|
40
|
+
if (snapshot_round === undefined || snapshot_round === round) {
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
return `${base}; snapshot_round_mismatch: snapshot=${snapshot_round}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combat-commander bridge. Tools write `.lich/game/` files; Godot drains them.
|
|
3
|
+
* Plain `.mjs` so Node (>=20) loads it from config.plugins with no type-strip.
|
|
4
|
+
*/
|
|
5
|
+
import { dungeon_memory_read_tool, dungeon_memory_write_tool } from "./dungeon_memory.mjs";
|
|
6
|
+
import { enemy_actions_tool } from "./enemy_actions.mjs";
|
|
7
|
+
import { meteor_veto } from "./meteor_veto.mjs";
|
|
8
|
+
|
|
9
|
+
const game_bridge = {
|
|
10
|
+
name: "game_bridge",
|
|
11
|
+
tools: [enemy_actions_tool, dungeon_memory_read_tool, dungeon_memory_write_tool],
|
|
12
|
+
hooks: {
|
|
13
|
+
before_tool_call: meteor_veto,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default game_bridge;
|