@moikapy/lich 0.3.1 → 0.4.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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  create_agent,
7
7
  parse_agent_config,
8
8
  safe_json_parse
9
- } from "./chunk-P52U5M3L.js";
9
+ } from "./chunk-MLFJW4JU.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import { readFileSync as readFileSync2 } from "fs";
@@ -346,11 +346,11 @@ async function run_chat(config) {
346
346
  }
347
347
  }
348
348
  async function run_tui_entry(config) {
349
- const { run_tui } = await import("./tui-K3EPRXTV.js");
349
+ const { run_tui } = await import("./tui-VYBJSGRV.js");
350
350
  return run_tui(config);
351
351
  }
352
352
  async function run_gateway_entry(config, platforms) {
353
- const { run_gateway } = await import("./gateway-CWPVIU3W.js");
353
+ const { run_gateway } = await import("./gateway-XTYDYT67.js");
354
354
  return run_gateway(config, platforms.length === 0 ? ["webhook"] : platforms);
355
355
  }
356
356
  function is_main_module() {
@@ -2,7 +2,7 @@ import {
2
2
  create_agent,
3
3
  logger,
4
4
  sleep
5
- } from "./chunk-P52U5M3L.js";
5
+ } from "./chunk-MLFJW4JU.js";
6
6
 
7
7
  // src/gateway/types.ts
8
8
  var ERROR_SNIPPET_CHARS = 300;
@@ -749,4 +749,4 @@ function install_signal_handlers(bus, adapters) {
749
749
  export {
750
750
  run_gateway
751
751
  };
752
- //# sourceMappingURL=gateway-CWPVIU3W.js.map
752
+ //# sourceMappingURL=gateway-XTYDYT67.js.map
package/dist/index.d.ts CHANGED
@@ -37,6 +37,8 @@ interface Tool {
37
37
  name: string;
38
38
  description: string;
39
39
  parameters: JsonSchemaObject;
40
+ /** Per-tool executor timeout override in ms; unset tools get the 30s default. */
41
+ timeout_ms?: number;
40
42
  execute(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult>;
41
43
  }
42
44
  interface Toolset {
@@ -55,6 +57,12 @@ interface Toolset {
55
57
  /** Runtime info handed to every hook call. */
56
58
  interface HookContext {
57
59
  work_dir: string;
60
+ /**
61
+ * This plugin's own per-run state sub-map. Every hook invocation receives
62
+ * a ctx exposing only the invoking plugin's bag; the bag is swapped fresh
63
+ * at each run start. Absent only on hand-built contexts outside the runner.
64
+ */
65
+ state?: Map<string, unknown>;
58
66
  }
59
67
  /** Argument passed to before_tool_call hooks. */
60
68
  interface BeforeToolCallInfo {
@@ -69,6 +77,9 @@ interface BeforeToolCallResult {
69
77
  /** Argument passed to after_tool_call hooks. */
70
78
  interface AfterToolCallInfo extends BeforeToolCallInfo {
71
79
  result_summary: string;
80
+ /** Structured executor outcome; gate on this, never parse result_summary. */
81
+ ok: boolean;
82
+ error?: string;
72
83
  }
73
84
  interface RunEndInfo {
74
85
  stopped_reason: string;
@@ -445,6 +456,7 @@ declare class Agent {
445
456
  private readonly hook_runner;
446
457
  constructor(config: AgentConfig, plugins?: readonly LoadedPlugin[]);
447
458
  run(options: AgentRunOptions): Promise<AgentRunResult>;
459
+ /** Per-run deps: the built-once ToolContext threads through every tool execution. */
448
460
  private loop_deps;
449
461
  /** Best-effort on_run_start fan-out; hook errors are logged, never fatal. */
450
462
  private call_plugin_run_start;
@@ -503,7 +515,11 @@ declare function register_builtin_tools(registry: ToolRegistry, context?: ToolCo
503
515
  *
504
516
  * before_tool_call hooks run in registration order and may veto a call (first
505
517
  * blocker wins; the wrapped executor is never called). Hook errors are warned
506
- * and skipped, never fatal. after_tool_call hooks observe the result summary.
518
+ * and skipped, never fatal. after_tool_call hooks observe the result summary
519
+ * plus the executor's structured ok/error fields. Every hook invocation
520
+ * receives a ctx exposing only its own plugin's state sub-map: the bag is a
521
+ * module-internal WeakMap keyed on the plugin object, swapped fresh at each
522
+ * call_run_start and shared by the lifecycle and per-tool ctx build sites.
507
523
  */
508
524
 
509
525
  /** Structural ToolRunner shape accepted from the wrapped executor. */
@@ -511,27 +527,25 @@ interface WrappedToolRunner {
511
527
  execute(name: string, args: Record<string, unknown>, context?: ToolContext): Promise<ToolResult>;
512
528
  }
513
529
  /**
514
- * All hook arrays are pre-flattened at construction so the per-call hot path
515
- * does no concat; hooks always run in plugin registration order.
530
+ * Hooks stay attached to their plugin (no flattening) so each invocation can
531
+ * be handed a ctx exposing only that plugin's sub-map; hooks always run in
532
+ * plugin registration order.
516
533
  */
517
534
  declare class HookedToolRunner {
518
535
  private readonly wrapped;
519
- private readonly before_hooks;
520
- private readonly after_hooks;
521
- private readonly run_start_hooks;
522
- private readonly run_end_hooks;
523
- constructor(wrapped: WrappedToolRunner, hooks: readonly PluginHooks[]);
536
+ private readonly hooked_plugins;
537
+ constructor(wrapped: WrappedToolRunner, plugins: readonly Plugin[]);
524
538
  /** Run before hooks in order; the first {block: true} verdict wins. */
525
539
  private run_before_hooks;
526
540
  /** Fire-and-forget in spirit but awaited here so runs settle cleanly. */
527
541
  private run_after_hooks;
528
542
  execute(name: string, args: Record<string, unknown>, context?: ToolContext): Promise<ToolResult>;
529
- /** Best-effort on_run_start fan-out used by Agent.run; never throws. */
543
+ /** Best-effort on_run_start fan-out used by Agent.run; never throws. Swaps in a fresh state sub-map per hooked plugin first. */
530
544
  call_run_start(info: {
531
545
  input_chars: number;
532
- }, ctx: HookContext): Promise<void>;
546
+ }, base: HookContext): Promise<void>;
533
547
  /** Best-effort on_run_end fan-out used by Agent.run; never throws. */
534
- call_run_end(info: RunEndInfo, ctx: HookContext): Promise<void>;
548
+ call_run_end(info: RunEndInfo, base: HookContext): Promise<void>;
535
549
  }
536
550
 
537
551
  /**
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  plugin_errors_summary,
16
16
  register_builtin_tools,
17
17
  run_agent
18
- } from "./chunk-P52U5M3L.js";
18
+ } from "./chunk-MLFJW4JU.js";
19
19
  export {
20
20
  Agent,
21
21
  AgentEmitter,
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  create_agent,
6
6
  truncate_text
7
- } from "./chunk-P52U5M3L.js";
7
+ } from "./chunk-MLFJW4JU.js";
8
8
 
9
9
  // src/tui.tsx
10
10
  import { render } from "ink";
@@ -430,4 +430,4 @@ async function run_tui(config) {
430
430
  export {
431
431
  run_tui
432
432
  };
433
- //# sourceMappingURL=tui-K3EPRXTV.js.map
433
+ //# sourceMappingURL=tui-VYBJSGRV.js.map
@@ -28,7 +28,7 @@ flowchart TB
28
28
  CLIENTS["openai_compat / anthropic / ollama<br/>HTTP clients"]
29
29
  EXEC["ToolExecutor<br/>(src/tools/executor.ts)"]
30
30
  REG["ToolRegistry<br/>(src/tools/registry.ts)"]
31
- BUILTIN["12 builtin tools<br/>(src/tools/builtin/*)"]
31
+ BUILTIN["builtin tools<br/>(src/tools/builtin/*)"]
32
32
  COMP["ContextCompressor<br/>(src/context/compressor.ts)"]
33
33
  SESSION["SessionStore<br/>(src/session/store.ts)"]
34
34
 
@@ -59,18 +59,20 @@ structural interfaces ([`src/agent/loop.ts`](../../src/agent/loop.ts)):
59
59
 
60
60
  - `ChatFn` - `(messages, tools, options?) => Promise<ChatResult>` (declared in
61
61
  `src/context/compressor.ts`, since compression needs the same shape).
62
- - `ToolRunner` - `{ execute(name, args) => Promise<ToolResult> }`.
62
+ - `ToolRunner` - `{ execute(name, args, context?) => Promise<ToolResult> }`.
63
63
 
64
64
  `run_conversation` receives a `LoopDeps` object holding a `ChatFn`, a
65
- `ToolRunner`, a `definitions()` callback for tool schemas, and an optional
66
- emitter. The `Agent` class (`src/agent/agent.ts`) is the composition root: its
67
- `loop_deps()` method wires the real implementations -
65
+ `ToolRunner`, a `definitions()` callback for tool schemas, an optional
66
+ emitter, and an optional per-run `tool_context` threaded to every tool
67
+ execution. The `Agent` class (`src/agent/agent.ts`) is the composition root:
68
+ its `loop_deps()` method wires the real implementations -
68
69
 
69
70
  ```ts
70
71
  chat: (messages, tools, chat_options) => this.router.chat_with_failover(messages, tools, chat_options),
71
72
  tools: this.executor,
72
73
  definitions: () => this.registry.definitions(),
73
74
  emitter: this.events,
75
+ tool_context,
74
76
  ```
75
77
 
76
78
  (src/agent/agent.ts, `loop_deps()`)
@@ -166,7 +168,7 @@ Walkthrough of a single `Agent.run({ input })` call
166
168
  | `src/tools/guard.ts` | Path confinement, timeouts, clamping, arg coercion. |
167
169
  | `src/tools/registry.ts` | Name-keyed tool registry; duplicate rejection. |
168
170
  | `src/tools/executor.ts` | Never-throw execution with timeout and abort. |
169
- | `src/tools/builtin/*` | The 12 builtin tools (see [tools](./tools.md)). |
171
+ | `src/tools/builtin/*` | Builtin tools, including `run_tests` (see [tools](./tools.md)). |
170
172
  | `src/gateway/bus.ts` | Conversation-keyed runner over one shared `Agent`. |
171
173
  | `src/gateway/runner.ts` | Adapter construction, signal handling, process lifetime. |
172
174
  | `src/gateway/{telegram,discord,twitch,webhook}.ts` | Platform adapters. |
@@ -23,7 +23,7 @@ flowchart LR
23
23
  K -- yes --> M["LoadedPlugin collected"]
24
24
  M --> N["Agent constructor"]
25
25
  N --> O["registry merge:\nplugin tools appended\n(dup tool name → warn+skip)"]
26
- N --> P["hook concat:\nPluginHooks[] in config order"]
26
+ N --> P["hooked plugins kept\nwhole (per-plugin state channel)"]
27
27
  P --> Q["HookedToolRunner wraps\nToolExecutor when hooks exist"]
28
28
  E --> R["warn + continue"]
29
29
  H --> R
@@ -53,14 +53,66 @@ sequenceDiagram
53
53
  else no blocker
54
54
  H->>E: execute(name, args, context?)
55
55
  E-->>H: ToolResult
56
- H->>A: await hook({...info, result_summary}, ctx)
57
- note over A: summary = 300 chars of output/error
56
+ H->>A: await hook({...info, result_summary, ok, error?}, ctx)
57
+ note over A: summary = 300 chars of output/error; ok/error are structured
58
58
  H-->>L: ToolResult unchanged
59
59
  end
60
60
  ```
61
61
 
62
62
  Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({input_chars})` before `run_conversation` and `call_run_end({stopped_reason, turns_used})` after it (including the abort/throw path, via `finally`). Both are best-effort: hook throws are logged at `warn` and the run proceeds.
63
63
 
64
+ ## Builtin gatekeeper
65
+
66
+ `Agent` constructs `gatekeeper_plugin` in code, before config plugins, and
67
+ pushes it as a synthetic `LoadedPlugin` through tool registration and the
68
+ hook runner. The config loader never sees it. Construction failure means
69
+ `git_commit` is not in the registry at all. "No gatekeeper → no `git_commit`"
70
+ is that trusted path: with the gatekeeper off, a config plugin may still name
71
+ a tool `git_commit` (the documented plugin-trust floor). While the gatekeeper
72
+ is registered, first-wins keeps its tool.
73
+
74
+ `LICH_ALLOW_SELF_COMMIT` is read from process env at construction. The spec
75
+ value is `1`. Unset or any other value is fail-closed.
76
+
77
+ Per-run state starts `tests_ok=false`, `dirty=true`, `commits=0` (swapped at
78
+ `call_run_start`). `after_tool_call` updates only on structured `ok`:
79
+
80
+ - `write_file` / `edit_file` success → `dirty=true`
81
+ - `run_tests` success → `tests_ok=true`, `dirty=false`
82
+ - `git_commit` success → `commits++`
83
+
84
+ `before_tool_call` vetoes `git_commit` unless
85
+ `allow_self_commit && tests_ok && !dirty && commits < 1`. The reason names
86
+ the failed condition: `self_commit_disabled`, `tests_not_ok`,
87
+ `worktree_dirty`, `commit_budget_exhausted`. The model sees
88
+ `blocked_by_plugin: <reason>`.
89
+
90
+ `terminal` is vetoed on a hardcoded denylist match; the reason is
91
+ `git_denylist: <pattern>`. Patterns: flag-tolerant `commit` and `push`
92
+ (`commit`, `-commit`, `--commit`, `push`, `-push`, `--push`) plus
93
+ any-occurrence `commit-tree` and `update-ref`. No `remote` pattern. One
94
+ commit per run, hardcoded.
95
+
96
+ `git_commit` args are `{message, paths}` with 1–50 paths relative to
97
+ `work_dir`. It rejects `""`, `.`, anything resolving to `work_dir`, and
98
+ secret-ish basenames (`.env`, `.env.local`, `*.pem`, `*.p12`, `id_rsa*`).
99
+ Unreachable `HEAD` is fail-closed. Recipe: scoped `git add -- <paths>`, then
100
+ `git commit --only` with explicit identity `-c` flags. `timeout_ms` is 60000.
101
+ It never pushes.
102
+
103
+ **Attestation:** clean state attests no `write_file`/`edit_file` since the
104
+ last green `run_tests`; it does NOT attest absence of terminal-mediated
105
+ writes — that sits with the documented terminal floor.
106
+
107
+ Floors, stated not closed:
108
+
109
+ - Terminal floor: raw `terminal` can run arbitrary git; the denylist is
110
+ best-effort. The boundary is human review of the local repo; push is
111
+ human-only.
112
+ - Plugin-trust floor: `.lich/config.json` `plugins` is persistent arbitrary
113
+ code at next process start. Review config diffs.
114
+ - One lich process per repo (the `run_tests` mutex is process-local).
115
+
64
116
  ## Design decisions
65
117
 
66
118
  - **Explicit entries, no directory scan.** v1 loads only the files you list in `config.plugins`. Directory scanning would make runs depend on whatever happens to sit in a folder — non-reproducible, and a footgun for tools that write into `.lich/`. Explicit entries make the agent's tool surface a function of the config alone.
@@ -74,7 +126,8 @@ Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({
74
126
  | --- | --- | --- |
75
127
  | `Plugin` | type | `{name, version?, tools?, hooks?}` — what a plugin module exports. |
76
128
  | `PluginHooks` | type | The four optional lifecycle hooks with their signatures. |
77
- | `HookContext` | type | `{work_dir}` passed to every hook. |
129
+ | `HookContext` | type | `{work_dir, state?}` passed to every hook; `state` is the invoking plugin's own per-run bag. |
130
+ | `AfterToolCallInfo` | type | Tool name/args plus the 300-char `result_summary` and structured `ok`/`error` fields. |
78
131
  | `LoadedPlugin` | type | `{plugin, entry}` — a loaded plugin and its source path. |
79
132
  | `load_plugins` | function | `(entries, base_dir) => {plugins, errors}` — dynamic import + shape validation. |
80
133
  | `plugin_errors_summary` | function | Joins error entries into one warn-able string. |
@@ -118,9 +118,10 @@ args, context?)`:
118
118
  defaults (falling back to `process.cwd()`) plus the configured `env`.
119
119
  3. **Cancelled fast path**: if the context signal is already aborted, return
120
120
  `{ ok: false, output: "", error: "cancelled" }` without running the tool.
121
- 4. **Signal merge + 30 s timeout**: a fresh `AbortController` is aborted by
121
+ 4. **Signal merge + timeout**: a fresh `AbortController` is aborted by
122
122
  the external signal (an `abort` listener), by the `with_timeout` deadline
123
- (`DEFAULT_TOOL_TIMEOUT_MS = 30000`), and the merged signal is what the tool
123
+ (the tool's own `timeout_ms` when declared, else
124
+ `DEFAULT_TOOL_TIMEOUT_MS = 30000`), and the merged signal is what the tool
124
125
  receives. The external listener is removed in a `finally`.
125
126
  5. **Output clamping**: successful results pass through `clamp_result`
126
127
  (`clamp_output`, 20 000 chars).
@@ -137,8 +138,9 @@ form back with `parse_tool_message_content` (src/tui/state.ts).
137
138
 
138
139
  ## Builtin catalog
139
140
 
140
- Twelve tools, registered by `register_builtin_tools`
141
- ([`src/tools/builtin/index.ts`](../../src/tools/builtin/index.ts)):
141
+ Registered by `register_builtin_tools`
142
+ ([`src/tools/builtin/index.ts`](../../src/tools/builtin/index.ts)).
143
+ Docs tools join the list only when a docs root resolves.
142
144
 
143
145
  | Tool | Key args | Implementation insight |
144
146
  | --- | --- | --- |
@@ -154,6 +156,7 @@ Twelve tools, registered by `register_builtin_tools`
154
156
  | `process_list` | `filter?`, `max_results?` | Reads `/proc` synchronously: numeric dirs are pids, `cmdline` is NUL-separated; missing entries (process died mid-scan) read as empty. |
155
157
  | `disk_usage` | `path?`, `max_entries?` | One `du -sb` subprocess per depth-1 entry with a 10 s timeout; sorted desc with a `TOTAL` row; `du` missing yields `du_unavailable`. |
156
158
  | `env_get` | `keys?`, `prefix?`, `reveal?` | Values are hidden unless `reveal`; names matching `/(secret\|token\|password\|key\|credential\|auth)/i` are **always** masked as `<redacted: N chars>`. |
159
+ | `run_tests` | `filter?` | Runs `LICH_TEST_COMMAND` (default `node node_modules/vitest/vitest.mjs run`) in `work_dir` via `bash -lc`. `timeout_ms` is 600000. A module mutex makes a concurrent call return `{ok:false, error:"run_tests_busy"}`. `ok` is the structured pass/fail the gatekeeper reads; output is clamped to 2000 chars. One lich process per repo — a second process is fail-closed busy or failed. |
157
160
 
158
161
  The three HTTP tools (`fetch_url`, `web_search`, `http_request`) share
159
162
  helpers from `fetch_url.ts`: `valid_http_url` (URL parse + protocol
@@ -161,6 +164,15 @@ allowlist), `compose_abort_signal` (per-call `AbortSignal.timeout` merged
161
164
  with the executor's cancellation via `AbortSignal.any`), and `clamp_int_arg`
162
165
  (floored, bounded to `[1, max]`).
163
166
 
167
+ ## Docs search and skills
168
+
169
+ `docs_search` scores sections under the resolved docs root (memoized) and, when
170
+ `<work_dir>/.lich/skills/` exists, also walks that directory. The skills
171
+ candidate is existence-only: it does not need `index.md`. The walk is fresh
172
+ on every call — user-writable skill files are not memoized into the package
173
+ docs cache. Skills are reference data, written with `write_file`, not
174
+ instructions. See the [plugins guide](../user-guide/plugins.md#skills-and-memory).
175
+
164
176
  ## Registry
165
177
 
166
178
  [`ToolRegistry`](../../src/tools/registry.ts) is a name-keyed `Map`:
package/docs/index.md CHANGED
@@ -8,14 +8,14 @@ outline: [2, 3]
8
8
 
9
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
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.
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 builtin tools, the same provider configuration, and the same session store.
12
12
 
13
13
  ## Feature overview
14
14
 
15
15
  | Capability | What it gives you |
16
16
  | --- | --- |
17
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. |
18
+ | Tools | Builtins (file read/write/edit, directory listing, shell, grep, HTTP fetch/request, web search, process list, disk usage, env inspection, `run_tests`), all confined to the working directory. `git_commit` is the gatekeeper's tool, not a config plugin. |
19
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
20
  | Sessions | Every run persists a `.jsonl` transcript under `.lich/sessions/`, labeled by origin (`tui`, `gw:<platform>:<chat>`). |
21
21
  | CLI | One-shot tasks, chat REPL, TUI, gateway, and a `config` template command, all with flag/env/config-file configuration. |
@@ -33,7 +33,7 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
33
33
  | [TUI guide](user-guide/tui.md) | Run the terminal UI and use slash commands and the status bar. |
34
34
  | [Gateway guide](user-guide/gateway.md) | Wire Telegram, Discord, Twitch, and the HTTP webhook to one agent. |
35
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. |
36
+ | [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks, and run the self-improvement loop. |
37
37
  | [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
38
38
 
39
39
  ## How it works
@@ -129,6 +129,19 @@ Minimal per-provider examples:
129
129
 
130
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
131
 
132
+ ## Self-improvement environment
133
+
134
+ These are process-env knobs, not config fields. They are assembled in code and
135
+ never accepted as a config passthrough.
136
+
137
+ | Variable | Meaning |
138
+ | --- | --- |
139
+ | `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. |
140
+ | `LICH_TEST_COMMAND` | Command `run_tests` runs in `work_dir` (default `node node_modules/vitest/vitest.mjs run`). An optional `filter` argument is appended. |
141
+
142
+ Veto reasons, the terminal git denylist, skills, and `MEMORY.md` are in the
143
+ [plugins guide](plugins.md#self-improvement-loop).
144
+
132
145
  ## Session files
133
146
 
134
147
  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.
@@ -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 twelve builtin tools (filtered by `tools_enabled`), and exposes:
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 result summary. |
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: string}` — the agent's working directory.
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
 
@@ -115,6 +115,59 @@ Failures are contained at every layer:
115
115
  - **Bun** runs TypeScript plugin files natively — `.ts` entries just work (`bun src/cli.ts ...` from a clone).
116
116
  - **Node** (the built `dist/cli.js`) uses the native ESM loader, which does not compile TS. For node deployments, compile your plugin or ship it as `.mjs`/plain JS and list that file in `plugins`.
117
117
 
118
+ ## Self-improvement loop
119
+
120
+ The agent can write a tool, prove it with `run_tests`, and commit it with
121
+ `git_commit` — one commit per run, and only when you opt in. The gatekeeper
122
+ is constructed in code (not listed in `config.plugins`). If it does not
123
+ register, `git_commit` is absent. A config plugin naming `git_commit` is
124
+ inside the plugin-trust floor only when the gatekeeper is off; while it is
125
+ on, first-wins keeps the gatekeeper's tool.
126
+
127
+ Set `LICH_ALLOW_SELF_COMMIT=1` before startup. Unset, or any other value, is
128
+ fail-closed. `git_commit` is vetoed unless every condition holds; the reason
129
+ names the first failure, and the model sees `blocked_by_plugin: <reason>`:
130
+
131
+ | Failed condition | Reason |
132
+ | --- | --- |
133
+ | `LICH_ALLOW_SELF_COMMIT` is not `1` | `self_commit_disabled` |
134
+ | no green `run_tests` yet this run | `tests_not_ok` |
135
+ | a `write_file` or `edit_file` succeeded after that green run | `worktree_dirty` |
136
+ | this run already committed once | `commit_budget_exhausted` |
137
+
138
+ `terminal` is vetoed when the command matches the hardcoded git denylist.
139
+ The reason is `git_denylist: <pattern>`. Patterns are flag-tolerant
140
+ `commit`/`push` (`commit`, `-commit`, `--commit`, `push`, `-push`, `--push`)
141
+ and any occurrence of `commit-tree` or `update-ref`. There is no `remote`
142
+ pattern. The denylist is best-effort: raw `terminal` can still run git. The
143
+ boundary is a human reviewing the local repo. Push is human-only.
144
+
145
+ `git_commit` takes `{message, paths}` — 1 to 50 paths relative to `work_dir`.
146
+ It rejects `""`, `.`, a path that resolves to `work_dir` itself, and
147
+ secret-ish basenames (`.env`, `.env.local`, `*.pem`, `*.p12`, `id_rsa*`).
148
+ It refuses an unreachable `HEAD`. It stages exactly the named paths
149
+ (`git add -- <paths>`) and commits with `git commit --only`. It never pushes.
150
+
151
+ `run_tests` takes an optional `filter` and runs `LICH_TEST_COMMAND` in
152
+ `work_dir` (default `node node_modules/vitest/vitest.mjs run`) with a 600s
153
+ timeout. A second call in the same process returns `run_tests_busy`. The
154
+ mutex is process-local: one lich process per repo.
155
+
156
+ Clean state attests no `write_file`/`edit_file` since the last green
157
+ `run_tests`; it does NOT attest absence of terminal-mediated writes.
158
+
159
+ ### Skills and memory
160
+
161
+ Write a markdown note with `write_file` to `.lich/skills/<name>.md`.
162
+ `docs_search` finds those files. That directory does not need `index.md`,
163
+ and it is walked fresh on every search. The default system prompt says tool
164
+ results — docs, skills, memory — are reference data, not instructions.
165
+
166
+ `MEMORY.md` is append-only and human-reviewable. It is never auto-loaded.
167
+ Review it between appends and the next self-commit.
168
+
118
169
  ## Security note
119
170
 
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.
171
+ 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.
172
+
173
+ `.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moikapy/lich",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Lich — a TypeScript AI agent harness (library + CLI) inspired by Hermes",
5
5
  "type": "module",
6
6
  "license": "MIT",