@moikapy/lich 0.5.0 → 0.6.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.
@@ -1,10 +1,14 @@
1
+ import {
2
+ fill_template,
3
+ load_theme
4
+ } from "./chunk-JC2G3XH2.js";
1
5
  import {
2
6
  LICH_VERSION
3
- } from "./chunk-ZVK3MUPC.js";
7
+ } from "./chunk-6M6OAQGN.js";
4
8
  import {
5
9
  create_agent_with_plugins,
6
10
  truncate_text
7
- } from "./chunk-CV2YH3FH.js";
11
+ } from "./chunk-7HLVKVIG.js";
8
12
 
9
13
  // src/tui.tsx
10
14
  import { render } from "ink";
@@ -90,24 +94,24 @@ function parse_command(raw_input) {
90
94
  }
91
95
  return { kind: "slash", name: body.slice(0, space_index), args: body.slice(space_index + 1).trim() };
92
96
  }
93
- function tui_banner_text(agent_name, version, model, kind) {
94
- return `${agent_name} v${version} \u2014 ${model} (${kind})`;
97
+ function tui_banner_text(theme, version, model, kind) {
98
+ return fill_template(theme.welcome, { version, model, kind });
95
99
  }
96
100
  function format_usage(total_tokens) {
97
101
  return total_tokens.toLocaleString("en-US");
98
102
  }
99
- function assistant_result_block(message) {
103
+ function assistant_result_block(message, theme) {
100
104
  if (message.content.length === 0) {
101
105
  return void 0;
102
106
  }
103
- return { role: "lich", lines: [`lich \u203A ${message.content}`] };
107
+ return { role: "lich", lines: [`${theme.response_label} \u203A ${message.content}`] };
104
108
  }
105
- function run_notice_blocks(result) {
109
+ function run_notice_blocks(result, theme) {
106
110
  const blocks = [];
107
111
  if (result.outcome.stopped_reason === "budget") {
108
- blocks.push({ role: "error", lines: ["\xB7 budget exhausted (turn cap reached)"] });
112
+ blocks.push({ role: "error", lines: [`\xB7 ${fill_template(theme.notices.budget_exhausted, {})}`] });
109
113
  }
110
- const final_block = result.outcome.final === void 0 ? void 0 : assistant_result_block(result.outcome.final);
114
+ const final_block = result.outcome.final === void 0 ? void 0 : assistant_result_block(result.outcome.final, theme);
111
115
  if (final_block !== void 0) {
112
116
  blocks.push(final_block);
113
117
  }
@@ -124,8 +128,9 @@ function tool_result_block(call, ok, result_content) {
124
128
  ];
125
129
  return { role: ok === true ? "tool" : "error", lines };
126
130
  }
127
- function compress_notice_block(summary_chars) {
128
- return { role: "meta", lines: [`\xB7 context compressed (summary ${summary_chars} chars)`] };
131
+ function compress_notice_block(summary_chars, theme) {
132
+ const line = fill_template(theme.notices.compressed, { chars: summary_chars });
133
+ return { role: "meta", lines: [`\xB7 ${line}`] };
129
134
  }
130
135
  function error_notice_block(message) {
131
136
  return { role: "error", lines: [`\xB7 error: ${truncate_text(message, ERROR_PREVIEW_CHARS)}`] };
@@ -150,12 +155,13 @@ function usage_notice_block(total_tokens) {
150
155
  function unknown_command_block(name) {
151
156
  return { role: "error", lines: [`\xB7 unknown command: /${name} (try /help)`] };
152
157
  }
153
- function session_list_block(entries, cap = 10) {
158
+ function session_list_block(entries, theme, cap = 10) {
154
159
  const sorted = [...entries].sort((a, b) => b.mtime_ms - a.mtime_ms).slice(0, cap);
155
160
  if (sorted.length === 0) {
156
161
  return { role: "meta", lines: ["\xB7 no session files yet"] };
157
162
  }
158
- const lines = [`\xB7 sessions (${sorted.length}):`];
163
+ const label = fill_template(theme.notices.sessions, { count: sorted.length });
164
+ const lines = [`\xB7 ${label}`];
159
165
  for (const entry of sorted) {
160
166
  lines.push(` ${entry.name} (${format_usage(entry.size_bytes)} bytes)`);
161
167
  }
@@ -211,20 +217,15 @@ function MessageView({ blocks, state }) {
211
217
  // src/tui/status_bar.tsx
212
218
  import { Box as Box2, Text as Text2 } from "ink";
213
219
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
214
- var PHASE_LABELS = {
215
- idle: "idle",
216
- thinking: "thinking",
217
- tool: "tool"
218
- };
219
- function StatusBar({ state, model }) {
220
- const phase = PHASE_LABELS[state.phase];
220
+ function StatusBar({ state, model, theme }) {
221
+ const phase = theme.phase_labels[state.phase];
221
222
  return /* @__PURE__ */ jsxs2(Box2, { children: [
222
223
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
223
224
  `model ${model} \xB7 turns ${state.turns_used} \xB7 tokens ${format_usage(state.usage.total_tokens)} \xB7 [${phase}]`,
224
225
  state.compress_count > 0 ? ` \xB7 compressed ${state.compress_count}` : "",
225
226
  state.session_path !== void 0 ? ` \xB7 ${state.session_path}` : ""
226
227
  ] }),
227
- state.budget_exhausted ? /* @__PURE__ */ jsx2(Text2, { color: "red", children: " \xB7 budget exhausted" }) : null
228
+ state.budget_exhausted ? /* @__PURE__ */ jsx2(Text2, { color: "red", children: ` \xB7 ${theme.notices.budget_exhausted}` }) : null
228
229
  ] });
229
230
  }
230
231
 
@@ -292,12 +293,12 @@ function CommandBar({ busy, on_submit }) {
292
293
  // src/tui/app.tsx
293
294
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
294
295
  var SESSION_LIST_CAP = 10;
295
- function event_blocks(event) {
296
+ function event_blocks(event, theme) {
296
297
  if (event.type === "tool_call_end") {
297
298
  return [tool_result_block(event.call, event.result.ok === true, event.result.output)];
298
299
  }
299
300
  if (event.type === "compress_end") {
300
- return [compress_notice_block(event.summary_chars)];
301
+ return [compress_notice_block(event.summary_chars, theme)];
301
302
  }
302
303
  if (event.type === "error") {
303
304
  return [error_notice_block(event.error instanceof Error ? event.error.message : String(event.error))];
@@ -313,7 +314,7 @@ async function run_agent_turn(agent, history, input, on_event, on_done, signal)
313
314
  stop_listening();
314
315
  }
315
316
  }
316
- async function sessions_block(config) {
317
+ async function sessions_block(config, theme) {
317
318
  try {
318
319
  const dir_entries = await readdir(config.session_dir, { withFileTypes: true });
319
320
  const entries = [];
@@ -324,7 +325,7 @@ async function sessions_block(config) {
324
325
  const info = await stat(`${config.session_dir}/${entry.name}`);
325
326
  entries.push({ name: entry.name, size_bytes: info.size, mtime_ms: info.mtimeMs });
326
327
  }
327
- return session_list_block(entries, SESSION_LIST_CAP);
328
+ return session_list_block(entries, theme, SESSION_LIST_CAP);
328
329
  } catch {
329
330
  return { role: "meta", lines: ["\xB7 no session files yet"] };
330
331
  }
@@ -332,23 +333,23 @@ async function sessions_block(config) {
332
333
  function run_error_text(error) {
333
334
  return error instanceof Error ? error.message : String(error);
334
335
  }
335
- function use_agent_run(agent, add_blocks, set_state, set_blocks) {
336
+ function use_agent_run(agent, theme, add_blocks, set_state, set_blocks) {
336
337
  const history_ref = useRef([]);
337
338
  const controller_ref = useRef(void 0);
338
339
  const finish_run = useCallback((result) => {
339
340
  history_ref.current = result.messages;
340
341
  set_state((current) => apply_run_result(current, result));
341
- add_blocks(run_notice_blocks(result));
342
- }, [add_blocks, set_state]);
342
+ add_blocks(run_notice_blocks(result, theme));
343
+ }, [add_blocks, set_state, theme]);
343
344
  const start_message_run = useCallback(
344
345
  (text) => {
345
- add_blocks([{ role: "user", lines: [`you \u203A ${text}`] }]);
346
+ add_blocks([{ role: "user", lines: [`${theme.user_label} \u203A ${text}`] }]);
346
347
  set_state((current) => ({ ...current, phase: "thinking", active_tool: void 0 }));
347
348
  const controller = new AbortController();
348
349
  controller_ref.current = controller;
349
350
  const on_event = (event) => {
350
351
  set_state((current) => apply_event(current, event));
351
- set_blocks((current) => [...current, ...event_blocks(event)].slice(-HISTORY_CAP));
352
+ set_blocks((current) => [...current, ...event_blocks(event, theme)].slice(-HISTORY_CAP));
352
353
  };
353
354
  void run_agent_turn(agent, history_ref.current, text, on_event, finish_run, controller.signal).catch((error) => {
354
355
  add_blocks([error_notice_block(run_error_text(error))]);
@@ -359,12 +360,12 @@ function use_agent_run(agent, add_blocks, set_state, set_blocks) {
359
360
  }
360
361
  });
361
362
  },
362
- [agent, add_blocks, finish_run, set_blocks, set_state]
363
+ [agent, add_blocks, finish_run, set_blocks, set_state, theme]
363
364
  );
364
365
  useEffect2(() => () => controller_ref.current?.abort(), []);
365
366
  return start_message_run;
366
367
  }
367
- function use_slash_commands(agent, add_blocks, set_blocks, total_tokens) {
368
+ function use_slash_commands(agent, theme, add_blocks, set_blocks, total_tokens) {
368
369
  const handle = useCallback(
369
370
  (parsed) => {
370
371
  if (parsed.name === "exit" || parsed.name === "quit" || parsed.name === "q") {
@@ -380,16 +381,16 @@ function use_slash_commands(agent, add_blocks, set_blocks, total_tokens) {
380
381
  } else if (parsed.name === "clear") {
381
382
  set_blocks([]);
382
383
  } else if (parsed.name === "sessions") {
383
- void sessions_block(agent.config).then((block) => add_blocks([block]));
384
+ void sessions_block(agent.config, theme).then((block) => add_blocks([block]));
384
385
  } else {
385
386
  add_blocks([unknown_command_block(parsed.name)]);
386
387
  }
387
388
  },
388
- [agent, add_blocks, set_blocks, total_tokens]
389
+ [agent, add_blocks, set_blocks, theme, total_tokens]
389
390
  );
390
391
  return handle;
391
392
  }
392
- function TuiApp({ agent }) {
393
+ function TuiApp({ agent, theme }) {
393
394
  const [blocks, set_blocks] = useState3([]);
394
395
  const [state, set_state] = useState3(INITIAL_UI_STATE);
395
396
  const add_blocks = useCallback((added) => {
@@ -398,8 +399,8 @@ function TuiApp({ agent }) {
398
399
  }
399
400
  set_blocks((current) => [...current, ...added].slice(-HISTORY_CAP));
400
401
  }, []);
401
- const start_message_run = use_agent_run(agent, add_blocks, set_state, set_blocks);
402
- const handle_slash = use_slash_commands(agent, add_blocks, set_blocks, state.usage.total_tokens);
402
+ const start_message_run = use_agent_run(agent, theme, add_blocks, set_state, set_blocks);
403
+ const handle_slash = use_slash_commands(agent, theme, add_blocks, set_blocks, state.usage.total_tokens);
403
404
  const submit = useCallback(
404
405
  (text) => {
405
406
  const parsed = parse_command(text);
@@ -415,9 +416,9 @@ function TuiApp({ agent }) {
415
416
  );
416
417
  const provider = agent.config.providers[0];
417
418
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", minHeight: 8, children: [
418
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: tui_banner_text(agent.config.agent_name, LICH_VERSION, provider?.model ?? "unknown", provider?.kind ?? "unknown") }),
419
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: tui_banner_text(theme, LICH_VERSION, provider?.model ?? "unknown", provider?.kind ?? "unknown") }),
419
420
  /* @__PURE__ */ jsx4(MessageView, { blocks, state }),
420
- /* @__PURE__ */ jsx4(StatusBar, { state, model: provider?.model ?? "unknown" }),
421
+ /* @__PURE__ */ jsx4(StatusBar, { state, model: provider?.model ?? "unknown", theme }),
421
422
  /* @__PURE__ */ jsx4(CommandBar, { busy: state.phase !== "idle", on_submit: submit })
422
423
  ] });
423
424
  }
@@ -426,11 +427,12 @@ function TuiApp({ agent }) {
426
427
  import { jsx as jsx5 } from "react/jsx-runtime";
427
428
  async function run_tui(config) {
428
429
  const agent = await create_agent_with_plugins(config);
429
- const instance = render(/* @__PURE__ */ jsx5(TuiApp, { agent }));
430
+ const theme = load_theme(config.theme);
431
+ const instance = render(/* @__PURE__ */ jsx5(TuiApp, { agent, theme }));
430
432
  await instance.waitUntilExit();
431
433
  return 0;
432
434
  }
433
435
  export {
434
436
  run_tui
435
437
  };
436
- //# sourceMappingURL=tui-DT7XWDTX.js.map
438
+ //# sourceMappingURL=tui-LOUJVZ6A.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/tui.tsx","../src/tui/app.tsx","../src/tui/state.ts","../src/tui/message_view.tsx","../src/tui/status_bar.tsx","../src/tui/command_bar.tsx"],"sourcesContent":["/**\n * TUI entry: builds the agent from the parsed config and renders the ink app\n * until exit. The CLI dynamic-imports this module for `lich tui`.\n */\nimport { render } from \"ink\";\nimport { create_agent_with_plugins } from \"./agent/agent.js\";\nimport type { AgentConfig } from \"./agent/config.js\";\nimport { TuiApp } from \"./tui/app.js\";\nimport { load_theme } from \"./util/theme.js\";\n\nexport async function run_tui(config: AgentConfig): Promise<number> {\n const agent = await create_agent_with_plugins(config);\n const theme = load_theme(config.theme);\n const instance = render(<TuiApp agent={agent} theme={theme} />);\n await instance.waitUntilExit();\n return 0;\n}","/**\n * Root ink component for the lich TUI: wires agent events into the UI state\n * machine, drives agent.run with history continuity, and lays out header,\n * transcript, status bar, and the command input row.\n */\nimport { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from \"react\";\nimport { Box, Text } from \"ink\";\nimport type { Agent, AgentRunResult } from \"../agent/agent.js\";\nimport type { AgentEvent } from \"../agent/events.js\";\nimport type { AgentConfig } from \"../agent/config.js\";\nimport type { Message } from \"../providers/types.js\";\nimport { LICH_VERSION } from \"../index.js\";\nimport type { ThemeSpec } from \"../util/lore.js\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport {\n apply_event,\n apply_run_result,\n compress_notice_block,\n error_notice_block,\n help_block,\n HISTORY_CAP,\n INITIAL_UI_STATE,\n model_label_block,\n parse_command,\n tui_banner_text,\n run_notice_blocks,\n session_list_block,\n tool_result_block,\n unknown_command_block,\n usage_notice_block,\n type HistoryBlock,\n type ParsedInput,\n type SessionEntryInfo,\n type UiState,\n} from \"./state.js\";\nimport { MessageView } from \"./message_view.js\";\nimport { StatusBar } from \"./status_bar.js\";\nimport { CommandBar } from \"./command_bar.js\";\n\nconst SESSION_LIST_CAP = 10;\n\ntype SlashInput = Extract<ParsedInput, { kind: \"slash\" }>;\ntype AddBlocks = (added: readonly HistoryBlock[]) => void;\ntype SetUiState = Dispatch<SetStateAction<UiState>>;\ntype SetBlocks = Dispatch<SetStateAction<readonly HistoryBlock[]>>;\n\n/** Map one agent event to optional transcript blocks (tool rows, notices). */\nfunction event_blocks(event: AgentEvent, theme: ThemeSpec): readonly HistoryBlock[] {\n if (event.type === \"tool_call_end\") {\n return [tool_result_block(event.call, event.result.ok === true, event.result.output)];\n }\n if (event.type === \"compress_end\") {\n return [compress_notice_block(event.summary_chars, theme)];\n }\n if (event.type === \"error\") {\n return [error_notice_block(event.error instanceof Error ? event.error.message : String(event.error))];\n }\n return [];\n}\n\n/** One agent turn: subscribe to events, run, unsubscribe in finally. */\nasync function run_agent_turn(\n agent: Agent,\n history: readonly Message[],\n input: string,\n on_event: (event: AgentEvent) => void,\n on_done: (result: AgentRunResult) => void,\n signal: AbortSignal,\n): Promise<void> {\n const stop_listening = agent.events.on(on_event);\n try {\n const result = await agent.run({ input, history, signal, label: \"tui\" });\n on_done(result);\n } finally {\n stop_listening();\n }\n}\n\n/** Async /sessions listing as a meta block (never throws). */\nasync function sessions_block(config: AgentConfig, theme: ThemeSpec): Promise<HistoryBlock> {\n try {\n const dir_entries = await readdir(config.session_dir, { withFileTypes: true });\n const entries: SessionEntryInfo[] = [];\n for (const entry of dir_entries) {\n if (entry.isFile() === false || entry.name.endsWith(\".jsonl\") === false) {\n continue;\n }\n const info = await stat(`${config.session_dir}/${entry.name}`);\n entries.push({ name: entry.name, size_bytes: info.size, mtime_ms: info.mtimeMs });\n }\n return session_list_block(entries, theme, SESSION_LIST_CAP);\n } catch {\n return { role: \"meta\", lines: [\"· no session files yet\"] };\n }\n}\n\nfunction run_error_text(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Runs one message exchange; owns history continuity and abort wiring. */\nfunction use_agent_run(\n agent: Agent,\n theme: ThemeSpec,\n add_blocks: AddBlocks,\n set_state: SetUiState,\n set_blocks: SetBlocks,\n): (text: string) => void {\n const history_ref = useRef<readonly Message[]>([]);\n const controller_ref = useRef<AbortController | undefined>(undefined);\n\n const finish_run = useCallback((result: AgentRunResult): void => {\n history_ref.current = result.messages;\n set_state((current) => apply_run_result(current, result));\n add_blocks(run_notice_blocks(result, theme));\n }, [add_blocks, set_state, theme]);\n\n const start_message_run = useCallback(\n (text: string): void => {\n add_blocks([{ role: \"user\", lines: [`${theme.user_label} › ${text}`] }]);\n set_state((current) => ({ ...current, phase: \"thinking\", active_tool: undefined }));\n const controller = new AbortController();\n controller_ref.current = controller;\n const on_event = (event: AgentEvent): void => {\n set_state((current) => apply_event(current, event));\n set_blocks((current) => [...current, ...event_blocks(event, theme)].slice(-HISTORY_CAP));\n };\n void run_agent_turn(agent, history_ref.current, text, on_event, finish_run, controller.signal)\n .catch((error: unknown) => {\n add_blocks([error_notice_block(run_error_text(error))]);\n set_state((current) => ({ ...current, phase: \"idle\" }));\n })\n .finally(() => {\n if (controller_ref.current === controller) {\n controller_ref.current = undefined;\n }\n });\n },\n [agent, add_blocks, finish_run, set_blocks, set_state, theme],\n );\n\n useEffect(() => () => controller_ref.current?.abort(), []);\n\n return start_message_run;\n}\n\n/** Slash-command dispatch: pure client-side actions, never hits the agent. */\nfunction use_slash_commands(\n agent: Agent,\n theme: ThemeSpec,\n add_blocks: AddBlocks,\n set_blocks: SetBlocks,\n total_tokens: number,\n): (parsed: SlashInput) => void {\n const handle = useCallback(\n (parsed: SlashInput): void => {\n if (parsed.name === \"exit\" || parsed.name === \"quit\" || parsed.name === \"q\") {\n process.exit(0);\n return;\n }\n if (parsed.name === \"help\") {\n add_blocks([help_block()]);\n } else if (parsed.name === \"model\") {\n add_blocks([model_label_block(agent.config)]);\n } else if (parsed.name === \"usage\") {\n add_blocks([usage_notice_block(total_tokens)]);\n } else if (parsed.name === \"clear\") {\n set_blocks([]);\n } else if (parsed.name === \"sessions\") {\n void sessions_block(agent.config, theme).then((block) => add_blocks([block]));\n } else {\n add_blocks([unknown_command_block(parsed.name)]);\n }\n },\n [agent, add_blocks, set_blocks, theme, total_tokens],\n );\n return handle;\n}\n\ninterface TuiAppProps {\n readonly agent: Agent;\n readonly theme: ThemeSpec;\n}\n\nexport function TuiApp({ agent, theme }: TuiAppProps): React.JSX.Element {\n const [blocks, set_blocks] = useState<readonly HistoryBlock[]>([]);\n const [state, set_state] = useState(INITIAL_UI_STATE);\n\n const add_blocks = useCallback<AddBlocks>((added: readonly HistoryBlock[]): void => {\n if (added.length === 0) {\n return;\n }\n set_blocks((current) => [...current, ...added].slice(-HISTORY_CAP));\n }, []);\n\n const start_message_run = use_agent_run(agent, theme, add_blocks, set_state, set_blocks);\n const handle_slash = use_slash_commands(agent, theme, add_blocks, set_blocks, state.usage.total_tokens);\n\n const submit = useCallback(\n (text: string): void => {\n const parsed = parse_command(text);\n if (parsed.kind === \"message\") {\n if (parsed.text.length > 0) {\n start_message_run(parsed.text);\n }\n return;\n }\n handle_slash(parsed);\n },\n [handle_slash, start_message_run],\n );\n\n const provider = agent.config.providers[0];\n return (\n <Box flexDirection=\"column\" minHeight={8}>\n <Text dimColor>{tui_banner_text(theme, LICH_VERSION, provider?.model ?? \"unknown\", provider?.kind ?? \"unknown\")}</Text>\n <MessageView blocks={blocks} state={state} />\n <StatusBar state={state} model={provider?.model ?? \"unknown\"} theme={theme} />\n <CommandBar busy={state.phase !== \"idle\"} on_submit={submit} />\n </Box>\n );\n}","/**\n * Pure state logic for the ink TUI: UI-state transitions from agent events,\n * slash-command parsing, transcript block mapping, and display formatters.\n * No ink/react imports here — this module is unit-tested without a TTY.\n */\nimport type { AgentEvent } from \"../agent/events.js\";\nimport type { AgentRunResult } from \"../agent/agent.js\";\nimport type { AssistantMessage, Message, ToolCall, Usage } from \"../providers/types.js\";\nimport { safe_json_parse, truncate_text } from \"../util/json.js\";\nimport type { ThemeSpec } from \"../util/lore.js\";\nimport { fill_template } from \"../util/theme.js\";\n\nexport type UiPhase = \"idle\" | \"thinking\" | \"tool\";\n\nexport interface UiState {\n readonly phase: UiPhase;\n readonly turns_used: number;\n readonly usage: Usage;\n readonly session_path: string | undefined;\n readonly compress_count: number;\n readonly budget_exhausted: boolean;\n readonly last_error: string | undefined;\n readonly active_tool: ToolCall | undefined;\n}\n\nexport const INITIAL_UI_STATE: UiState = {\n phase: \"idle\",\n turns_used: 0,\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n session_path: undefined,\n compress_count: 0,\n budget_exhausted: false,\n last_error: undefined,\n active_tool: undefined,\n};\n\nexport const HISTORY_CAP = 50;\nconst TOOL_ARGS_PREVIEW_CHARS = 80;\nconst TOOL_OUTPUT_PREVIEW_CHARS = 120;\nconst ERROR_PREVIEW_CHARS = 300;\n\nfunction error_text(error: unknown): string {\n if (error instanceof Error) {\n return error.message;\n }\n return String(error);\n}\n\n/** Reducer over UiState; one pure mapping per AgentEvent variant. */\nexport function apply_event(state: UiState, event: AgentEvent): UiState {\n switch (event.type) {\n case \"llm_start\":\n return { ...state, phase: \"thinking\", active_tool: undefined };\n case \"llm_end\":\n return {\n ...state,\n usage: {\n prompt_tokens: state.usage.prompt_tokens + event.result.usage.prompt_tokens,\n completion_tokens: state.usage.completion_tokens + event.result.usage.completion_tokens,\n total_tokens: state.usage.total_tokens + event.result.usage.total_tokens,\n },\n };\n case \"tool_call_start\":\n return { ...state, phase: \"tool\", active_tool: event.call };\n case \"tool_call_end\":\n return {\n ...state,\n phase: \"thinking\",\n active_tool: undefined,\n last_error: event.result.ok === true ? state.last_error : (event.result.error ?? \"tool failed\"),\n };\n case \"turn_end\":\n return { ...state, turns_used: event.turn };\n case \"compress_start\":\n return { ...state, compress_count: state.compress_count + 1 };\n case \"budget_exhausted\":\n return { ...state, budget_exhausted: true };\n case \"error\":\n return { ...state, last_error: error_text(event.error) };\n default:\n return state;\n }\n}\n\n/** Fold an AgentRunResult back into UiState after the run promise resolves. */\nexport function apply_run_result(state: UiState, result: AgentRunResult): UiState {\n return {\n ...state,\n phase: \"idle\",\n turns_used: result.outcome.turns_used,\n session_path: result.session_path ?? state.session_path,\n last_error: result.outcome.stopped_reason === \"aborted\" ? \"run aborted\" : state.last_error,\n };\n}\n\nexport type ParsedInput = { kind: \"slash\"; name: string; args: string } | { kind: \"message\"; text: string };\n\n/** Split trimmed input into slash command vs plain message (empty input = message). */\nexport function parse_command(raw_input: string): ParsedInput {\n const text = raw_input.trim();\n if (text.startsWith(\"/\") === false) {\n return { kind: \"message\", text };\n }\n const body = text.slice(1);\n const space_index = body.indexOf(\" \");\n if (space_index === -1) {\n return { kind: \"slash\", name: body, args: \"\" };\n }\n return { kind: \"slash\", name: body.slice(0, space_index), args: body.slice(space_index + 1).trim() };\n}\n\n/** Dim header: the theme welcome string, the one tagline placement. */\nexport function tui_banner_text(theme: ThemeSpec, version: string, model: string, kind: string): string {\n return fill_template(theme.welcome, { version, model, kind });\n}\n\n/** 1234567 -> \"1,234,567\" (US grouping, matching the status bar style). */\nexport function format_usage(total_tokens: number): string {\n return total_tokens.toLocaleString(\"en-US\");\n}\n\nexport type BlockRole = \"user\" | \"lich\" | \"tool\" | \"meta\" | \"error\";\n\nexport interface HistoryBlock {\n readonly role: BlockRole;\n readonly lines: readonly string[];\n}\n\nfunction assistant_tool_line(call: ToolCall): string {\n const args_json = JSON.stringify(call.args) ?? \"{}\";\n return ` \\u23bf ${truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS)}`;\n}\n\nfunction format_message_lines(message: Message, theme: ThemeSpec): string[] {\n if (message.role === \"user\") {\n return [`${theme.user_label} \\u203a ${message.content}`];\n }\n if (message.role === \"assistant\") {\n const lines = [`${theme.response_label} \\u203a ${message.content}`];\n for (const call of message.tool_calls ?? []) {\n lines.push(assistant_tool_line(call));\n }\n return lines;\n }\n if (message.role === \"tool\") {\n const flag = message.is_error === true ? \"error\" : \"ok\";\n return [` \\u23bf ${message.name}: ${flag} (${truncate_text(message.content, TOOL_OUTPUT_PREVIEW_CHARS)})`];\n }\n return [`\\u00b7 system: ${message.content}`];\n}\n\n/** Map one transcript Message to display lines with its role tag. */\nexport function format_message_block(message: Message, theme: ThemeSpec): HistoryBlock {\n if (message.role === \"tool\") {\n const ok = message.is_error !== true;\n return { role: ok === true ? \"tool\" : \"error\", lines: format_message_lines(message, theme) };\n }\n const roles: Record<Exclude<Message[\"role\"], \"tool\">, BlockRole> = {\n system: \"meta\",\n user: \"user\",\n assistant: \"lich\",\n };\n return { role: roles[message.role], lines: format_message_lines(message, theme) };\n}\n\n/** Keep the newest `cap` non-system messages as renderable blocks. */\nexport function split_history_blocks(messages: readonly Message[], cap: number, theme: ThemeSpec): HistoryBlock[] {\n const visible = messages.filter((message) => message.role !== \"system\");\n const start = Math.max(0, visible.length - cap);\n return visible.slice(start).map((message) => format_message_block(message, theme));\n}\n\nfunction assistant_result_block(message: AssistantMessage, theme: ThemeSpec): HistoryBlock | undefined {\n if (message.content.length === 0) {\n return undefined;\n }\n return { role: \"lich\", lines: [`${theme.response_label} \\u203a ${message.content}`] };\n}\n\n/** Post-run meta blocks: compression notices, budget, errors, final answer. */\nexport function run_notice_blocks(result: AgentRunResult, theme: ThemeSpec): HistoryBlock[] {\n const blocks: HistoryBlock[] = [];\n if (result.outcome.stopped_reason === \"budget\") {\n blocks.push({ role: \"error\", lines: [`\\u00b7 ${fill_template(theme.notices.budget_exhausted, {})}`] });\n }\n const final_block = result.outcome.final === undefined ? undefined : assistant_result_block(result.outcome.final, theme);\n if (final_block !== undefined) {\n blocks.push(final_block);\n }\n return blocks;\n}\n\nfunction truncate_tool_preview(result_content: string, ok: boolean): string {\n return ` \\u23bf ${ok === true ? \"ok\" : \"error\"} (${truncate_text(result_content, TOOL_OUTPUT_PREVIEW_CHARS)})`;\n}\n\n/** One finalized live tool row; falls back to the transcript copy when absent. */\nexport function tool_result_block(call: ToolCall, ok: boolean, result_content: string): HistoryBlock {\n const args_json = JSON.stringify(call.args) ?? \"{}\";\n const lines = [\n `\\u23fa ${call.name}(${truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS)})`,\n truncate_tool_preview(result_content, ok),\n ];\n return { role: ok === true ? \"tool\" : \"error\", lines };\n}\n\nexport function parse_tool_message_content(content: string): { ok: boolean; output: string } {\n const parsed = safe_json_parse<{ ok?: unknown; output?: unknown }>(content);\n if (parsed !== undefined && typeof parsed.ok === \"boolean\" && typeof parsed.output === \"string\") {\n return { ok: parsed.ok, output: parsed.output };\n }\n return { ok: true, output: content };\n}\n\nexport function compress_notice_block(summary_chars: number, theme: ThemeSpec): HistoryBlock {\n const line = fill_template(theme.notices.compressed, { chars: summary_chars });\n return { role: \"meta\", lines: [`\\u00b7 ${line}`] };\n}\n\nexport function error_notice_block(message: string): HistoryBlock {\n return { role: \"error\", lines: [`\\u00b7 error: ${truncate_text(message, ERROR_PREVIEW_CHARS)}`] };\n}\n\nexport function tool_args_preview(args: Record<string, unknown>): string {\n const args_json = JSON.stringify(args) ?? \"{}\";\n return truncate_text(args_json, TOOL_ARGS_PREVIEW_CHARS);\n}\n\nexport function help_block(): HistoryBlock {\n return { role: \"meta\", lines: [...HELP_LINES] };\n}\n\nexport function model_label_block(config: { providers: readonly { model: string; kind: string }[] }): HistoryBlock {\n const provider = config.providers[0];\n return {\n role: \"meta\",\n lines: [`\\u00b7 model: ${provider?.model ?? \"unknown\"} \\u00b7 provider: ${provider?.kind ?? \"unknown\"}`],\n };\n}\n\nexport function usage_notice_block(total_tokens: number): HistoryBlock {\n return { role: \"meta\", lines: [`\\u00b7 tokens used this session: ${format_usage(total_tokens)}`] };\n}\n\nexport function unknown_command_block(name: string): HistoryBlock {\n return { role: \"error\", lines: [`\\u00b7 unknown command: /${name} (try /help)`] };\n}\n\nexport interface SessionEntryInfo {\n readonly name: string;\n readonly size_bytes: number;\n readonly mtime_ms: number;\n}\n\n/** Newest-first session listing, capped at `cap` entries. */\nexport function session_list_block(entries: readonly SessionEntryInfo[], theme: ThemeSpec, cap: number = 10): HistoryBlock {\n const sorted = [...entries].sort((a, b) => b.mtime_ms - a.mtime_ms).slice(0, cap);\n if (sorted.length === 0) {\n return { role: \"meta\", lines: [\"\\u00b7 no session files yet\"] };\n }\n const label = fill_template(theme.notices.sessions, { count: sorted.length });\n const lines: string[] = [`\\u00b7 ${label}`];\n for (const entry of sorted) {\n lines.push(` ${entry.name} (${format_usage(entry.size_bytes)} bytes)`);\n }\n return { role: \"meta\", lines };\n}\n\nexport const SLASH_COMMAND_NAMES: readonly string[] = [\n \"exit\",\n \"quit\",\n \"q\",\n \"help\",\n \"model\",\n \"usage\",\n \"clear\",\n \"sessions\",\n];\n\nexport const HELP_LINES: readonly string[] = [\n \"commands: /help /model /usage /clear /sessions /exit (aliases: /quit /q)\",\n \"enter submits \\u00b7 backspace deletes \\u00b7 up/down recalls history \\u00b7 pasted newlines become spaces\",\n];","/**\n * Transcript rendering: maps HistoryBlock descriptors to ink elements with\n * role-based colors, and shows an animated braille spinner while thinking.\n * Blocks are pre-capped by the app, so a plain flex column is sufficient.\n */\nimport { useEffect, useState } from \"react\";\nimport { Box, Text } from \"ink\";\nimport { tool_args_preview, type HistoryBlock, type UiState } from \"./state.js\";\n\nconst SPINNER_FRAMES: readonly string[] = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst SPINNER_INTERVAL_MS = 80;\n\n/** Braille spinner frames on an 80ms interval; clears on unmount. */\nfunction use_spinner(): string {\n const [frame, set_frame] = useState(SPINNER_FRAMES[0] ?? \"⠋\");\n useEffect(() => {\n const timer = setInterval(() => {\n const next = SPINNER_FRAMES[(SPINNER_FRAMES.indexOf(frame) + 1) % SPINNER_FRAMES.length];\n set_frame(next ?? \"⠋\");\n }, SPINNER_INTERVAL_MS);\n return () => {\n clearInterval(timer);\n };\n }, [frame]);\n return frame;\n}\n\nfunction ThinkingLine(): React.JSX.Element {\n const frame = use_spinner();\n return <Text dimColor>{`${frame} thinking…`}</Text>;\n}\n\nconst ROLE_COLORS: Record<HistoryBlock[\"role\"], string | undefined> = {\n user: \"white\",\n lich: \"green\",\n tool: \"cyan\",\n meta: undefined,\n error: \"red\",\n};\n\nfunction BlockLines({ block }: { block: HistoryBlock }): React.JSX.Element {\n const color = ROLE_COLORS[block.role];\n return (\n <>\n {block.lines.map((line, index) => (\n <Text key={index} color={color} dimColor={color === undefined}>{line}</Text>\n ))}\n </>\n );\n}\n\ninterface MessageViewProps {\n readonly blocks: readonly HistoryBlock[];\n readonly state: UiState;\n}\n\n/** Transcript column plus the live phase line (spinner / running tool row). */\nexport function MessageView({ blocks, state }: MessageViewProps): React.JSX.Element {\n return (\n <Box flexDirection=\"column\" flexGrow={1}>\n {blocks.map((block, index) => (\n <Box key={index} flexDirection=\"column\">\n <BlockLines block={block} />\n </Box>\n ))}\n {state.phase === \"thinking\" ? <ThinkingLine /> : null}\n {state.phase === \"tool\" && state.active_tool !== undefined ? (\n <Text color=\"cyan\">{`⏺ ${state.active_tool.name}(${tool_args_preview(state.active_tool.args)})`}</Text>\n ) : null}\n </Box>\n );\n}","/**\n * Bottom status line: model, turns, token totals, phase tag, compression\n * count, and the session path once the agent has persisted a transcript.\n */\nimport { Box, Text } from \"ink\";\nimport type { ThemeSpec } from \"../util/lore.js\";\nimport { format_usage, type UiState } from \"./state.js\";\n\ninterface StatusBarProps {\n readonly state: UiState;\n readonly model: string;\n readonly theme: ThemeSpec;\n}\n\nexport function StatusBar({ state, model, theme }: StatusBarProps): React.JSX.Element {\n const phase = theme.phase_labels[state.phase];\n return (\n <Box>\n <Text dimColor>\n {`model ${model} · turns ${state.turns_used} · tokens ${format_usage(state.usage.total_tokens)} · [${phase}]`}\n {state.compress_count > 0 ? ` · compressed ${state.compress_count}` : \"\"}\n {state.session_path !== undefined ? ` · ${state.session_path}` : \"\"}\n </Text>\n {state.budget_exhausted ? <Text color=\"red\">{` · ${theme.notices.budget_exhausted}`}</Text> : null}\n </Box>\n );\n}","/**\n * Input row: printable characters accumulate in a buffer, Enter submits,\n * Backspace/Delete edits, Up/Down walk a 20-entry recall ring, and pasted\n * newlines collapse to spaces. Ctrl+C is left to ink's default handling.\n */\nimport { useState } from \"react\";\nimport { Box, Text, useInput } from \"ink\";\n\nconst INPUT_HISTORY_CAP = 20;\n\ninterface CommandBarProps {\n readonly busy: boolean;\n readonly on_submit: (text: string) => void;\n}\n\n/** Push onto a capped ring (newest first) without mutating the source. */\nfunction push_history(ring: readonly string[], entry: string): readonly string[] {\n return [entry, ...ring.filter((item) => item !== entry)].slice(0, INPUT_HISTORY_CAP);\n}\n\nexport function CommandBar({ busy, on_submit }: CommandBarProps): React.JSX.Element {\n const [buffer, set_buffer] = useState(\"\");\n const [recall_ring, set_recall_ring] = useState<readonly string[]>([]);\n const [recall_index, set_recall_index] = useState<number | undefined>(undefined);\n\n const submit_buffer = (): void => {\n const text = buffer.trim();\n set_buffer(\"\");\n set_recall_index(undefined);\n if (text.length > 0) {\n set_recall_ring((current) => push_history(current, text));\n on_submit(text);\n }\n };\n\n const walk_recall = (direction: 1 | -1): void => {\n if (recall_ring.length === 0) {\n return;\n }\n const current = recall_index ?? -direction;\n const next = Math.min(Math.max(current + direction, 0), recall_ring.length - 1);\n set_recall_index(next);\n set_buffer(recall_ring[next] ?? \"\");\n };\n\n useInput((input, key) => {\n if (key.return === true) {\n submit_buffer();\n return;\n }\n if (key.upArrow === true) {\n walk_recall(-1);\n return;\n }\n if (key.downArrow === true) {\n walk_recall(1);\n return;\n }\n if (key.backspace === true || key.delete === true) {\n set_buffer((current) => current.slice(0, -1));\n return;\n }\n if (key.ctrl === true || key.escape === true || key.tab === true || key.meta === true) {\n return;\n }\n if (input.length > 0) {\n set_buffer((current) => current + input.replaceAll(\"\\n\", \" \").replaceAll(\"\\r\", \" \"));\n }\n });\n\n return (\n <Box>\n <Text dimColor>{busy ? \" … \" : \"› \"}</Text>\n <Text>{buffer}</Text>\n <Text dimColor>▌</Text>\n </Box>\n );\n}"],"mappings":"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","names":["useEffect","useState","Box","Text","Box","Text","jsx","jsxs","Box","Text","useState","Box","Text","jsx","jsxs","jsx","jsxs","useEffect","useState","Box","Text","jsx"]}
@@ -28,7 +28,8 @@ export default defineConfig({
28
28
  { text: 'CLI reference', link: '/user-guide/cli' },
29
29
  { text: 'TUI guide', link: '/user-guide/tui' },
30
30
  { text: 'Gateway guide', link: '/user-guide/gateway' },
31
- { text: 'Library guide', link: '/user-guide/library' }
31
+ { text: 'Library guide', link: '/user-guide/library' },
32
+ { text: 'Games guide', link: '/user-guide/games' }
32
33
  ]
33
34
  },
34
35
  {
@@ -187,9 +187,11 @@ coarse - see the trade-off note in [overview](./overview.md#design-trade-offs).
187
187
 
188
188
  What gets persisted, per run (`Agent.persist_session`): a `run_start` meta
189
189
  record, one `message` record per outcome message (including the seeded system
190
- message and tool messages), and a `budget_exhausted` meta record when the run
191
- stopped on budget. Everything is best-effort: any error logs a warning and
192
- returns `session_path: undefined` instead of failing the run.
190
+ message and tool messages), a `budget_exhausted` meta record when the run
191
+ stopped on budget, and a `run_end` meta record (`stopped_reason`, `usage`)
192
+ on every completed run. `usage` is the run's `usage_total`. Everything is
193
+ best-effort: any error logs a warning and returns `session_path: undefined`
194
+ instead of failing the run.
193
195
 
194
196
  `read_session_messages(path)` parses a file back into `Message[]`: per line it
195
197
  JSON-parses leniently, accepts only records with a `kind: "message"`-shaped
@@ -118,10 +118,11 @@ Walkthrough of a single `Agent.run({ input })` call
118
118
  or `aborted`.
119
119
  7. **Session persist.** `persist_session()` appends one `meta` record
120
120
  (`run_start`), then one `message` record per outcome message, then a
121
- `budget_exhausted` meta record if the budget stopped the run, to a JSONL
122
- file under `session_dir` (default `<work_dir>/.lich/sessions`). Persistence
123
- is best-effort: failures are logged and the run still succeeds with
124
- `session_path: undefined`.
121
+ `budget_exhausted` meta record if the budget stopped the run, then a
122
+ `run_end` meta record (`stopped_reason`, `usage`) for every completed
123
+ run, to a JSONL file under `session_dir` (default
124
+ `<work_dir>/.lich/sessions`). Persistence is best-effort: failures are
125
+ logged and the run still succeeds with `session_path: undefined`.
125
126
  8. **Return.** `AgentRunResult` bundles the outcome, the full transcript
126
127
  (prior history plus the new exchange), the collected `usage_total`, and the
127
128
  session path.
@@ -124,7 +124,7 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
124
124
  | `lich: config not found: <path>` | `--config` was given a path that does not exist. Check the path or drop the flag to use discovery. |
125
125
  | Provider error `kind=auth`, http 401/403 | The api key is missing or wrong. Verify the env var named by `LICH_API_KEY_ENV` (default `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`) is exported in the same shell. |
126
126
  | `fetch failed` / connection refused | The endpoint is unreachable. For ollama, check `ollama serve` is running on `http://localhost:11434`; for remote APIs, check `LICH_BASE_URL`. |
127
- | `[lich] budget exhausted after N turns` | The task did not finish within `max_turns` (default 25). Raise it with `--max-turns 50` or in config. |
127
+ | `[lich] budget exhausted after N turns — the ritual is spent` | The task did not finish within `max_turns` (default 25). The `budget exhausted` keyword stays; the flavor suffix comes from the theme. Raise the cap with `--max-turns 50` or in config. |
128
128
  | `unknown flag: --foo` | Flag typo, or the flag was placed where a subcommand is expected. Run `lich --help`. |
129
129
 
130
130
  ## Updating
@@ -163,4 +163,5 @@ bun src/cli.ts --version # -> 0.3.0
163
163
  - All four CLI modes, flags, and provider resolution: [CLI reference](user-guide/cli.md).
164
164
  - Slash commands and the status bar: [TUI guide](user-guide/tui.md).
165
165
  - Telegram, Discord, Twitch, and webhook setup: [Gateway guide](user-guide/gateway.md).
166
- - Embedding the agent in your own TypeScript: [Library guide](user-guide/library.md).
166
+ - Embedding the agent in your own TypeScript: [Library guide](user-guide/library.md).
167
+ - Session JSONL as a combat log: [Games guide](user-guide/games.md).
package/docs/index.md CHANGED
@@ -35,6 +35,7 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
35
35
  | [Library guide](user-guide/library.md) | Embed the agent in TypeScript with events and multi-turn history. |
36
36
  | [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks, and run the self-improvement loop. |
37
37
  | [Godot guide](user-guide/godot.md) | Run lich beside a Godot game and drain `.lich/game/` orders each tick. |
38
+ | [Games guide](user-guide/games.md) | Replay session JSONL as a combat log, including token totals. |
38
39
  | [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
39
40
 
40
41
  ## How it works
@@ -43,6 +43,7 @@ Flags work before or after the subcommand. Every value flag can also be set via
43
43
  | `--system-prompt <s>` | Replaces the default system prompt. | built-in concise-assistant prompt |
44
44
  | `--session-dir <path>` | Transcript directory. | `<work_dir>/.lich/sessions` |
45
45
  | `--log-level <level>` | `debug` \| `info` \| `warn` \| `error`. | `info` |
46
+ | `--theme <name>` | Display theme loaded once at startup. `lich` is built-in; other names read `~/.lich/themes/<name>.json`. | `lich` |
46
47
 
47
48
  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`.
48
49
 
@@ -95,7 +96,8 @@ Validated by zod (top-level unknown keys are silently stripped; extra keys insid
95
96
  "compress_threshold": 0.8,
96
97
  "session_dir": "/home/me/project/.lich/sessions",
97
98
  "terminal_timeout_ms": 60000,
98
- "log_level": "info"
99
+ "log_level": "info",
100
+ "theme": "lich"
99
101
  }
100
102
  ```
101
103
 
@@ -111,7 +113,8 @@ Validated by zod (top-level unknown keys are silently stripped; extra keys insid
111
113
  | `providers[].timeout_ms` | positive int | none | Per-request abort deadline. |
112
114
  | `providers[].think` | boolean | – | Ollama only: request thinking mode. |
113
115
  | `providers[].keep_alive` | string | – | Ollama only: model residency (e.g. `"10m"`). |
114
- | `agent_name` | string | `lich` | Display name in the TUI banner. |
116
+ | `agent_name` | string | `lich` | Wizard label. The TUI banner uses the active theme welcome string, not this field. |
117
+ | `theme` | string | `lich` | Display theme name. See [Themes](../../README.md#themes). |
115
118
  | `gateway` | object | omitted | Optional. `platforms` (`webhook` \| `telegram` \| `discord` \| `twitch`) and `token_envs` (platform → env-var name). Secrets stay in the environment. |
116
119
  | `system_prompt` | string | built-in | Replaces the default system prompt. |
117
120
  | `max_turns` | int >= 1 | `25` | Turn budget per run. |
@@ -0,0 +1,93 @@
1
+ # Games
2
+
3
+ > What you'll learn: how to treat session JSONL as a combat log, which fields the recipes read, and how a playthrough's token spend shows up after a run.
4
+
5
+ lich does not replay combat from RNG seeds. The commander's choices are sampled. The JSONL transcript is the replay. Godot still drains `.lich/game/`; these recipes read `.lich/sessions/`, not the order file.
6
+
7
+ Tool shapes live in [`examples/game_bridge/README.md`](https://github.com/Moikapy/lich/blob/main/examples/game_bridge/README.md). This page does not repeat them. The plugin entry is `examples/game_bridge/game_bridge.plugin.mjs`, loaded through `config.plugins` and `create_agent_with_plugins`. A per-persona HTTP front for that plugin is the [orchestrator example](../../examples/persona_orchestrator/README.md) — a pattern the game repo copies, not a second agent core.
8
+
9
+ ## Session files as combat logs
10
+
11
+ Each `Agent.run` appends one `.jsonl` file under `session_dir` (default `<work_dir>/.lich/sessions`). Records are `{ts, kind: "message"|"meta", message?, meta?}`.
12
+
13
+ | What you want | Where it is |
14
+ | --- | --- |
15
+ | What the commander saw | `kind: "message"`, `message.role: "user"` — the battle digest you posted |
16
+ | What it chose | assistant `message.tool_calls[]` with `name`, `args` |
17
+ | Why | `args.rationale` on `enemy_actions` |
18
+ | What happened | `message.role: "tool"`, `content`, optional `is_error` |
19
+ | Token spend | `kind: "meta"`, `meta.event: "run_end"`, `meta.usage` |
20
+
21
+ Assistant tool calls are the internal shape `{id, name, args}`, not the provider wire format. Tool failures are `JSON.stringify({ok, output, error})` in `content`, with `is_error: true`. A plugin veto is that object with `error` starting `blocked_by_plugin:` (the meteor gate uses `blocked_by_plugin: meteor_gates_closed_until_round_3`). Non-JSON tool content is not an error for the recipes: `fromjson?` skips it.
22
+
23
+ `run_end` is written for every completed loop (`stopped_reason` `final`, `budget`, or `aborted`). `usage` is `{prompt_tokens, completion_tokens, total_tokens}` and matches the `usage_total` returned to the caller. A budget stop also writes `meta.event: "budget_exhausted"` before `run_end`. Recipes that filter `kind=="message"` stay valid as meta events change. A provider throw never reaches persistence — there is no outcome to close. A failed persist logs a warning and returns `session_path: undefined`; a run missing from disk is a gap, not a zero-spend run.
24
+
25
+ Filenames are `<base36-timestamp>-<counter>[-<label>].jsonl`. The timestamp prefix sorts chronologically. The gateway (and the orchestrator example) labels `gw:<platform>:<chat_id>`. With `chat_id` = run id, files for one playthrough share that label. The label slug is truncated to 40 characters.
26
+
27
+ `read_session_messages(path)` in `src/session/store.ts` is the programmatic reader for a source checkout. It is not a package export. It returns messages only and skips meta. Offline analysis should use `jq` (a user tool, not a lich dependency).
28
+
29
+ ## Recipes
30
+
31
+ `jq` is a prerequisite for these commands, not a package dependency. Paths assume you are in `work_dir`.
32
+
33
+ ```bash
34
+ # (1) rationale
35
+ jq -r 'select(.kind=="message") | select(.message.role=="assistant")
36
+ | .message.tool_calls[]? | select(.name=="enemy_actions")
37
+ | .args.rationale' .lich/sessions/*.jsonl
38
+ ```
39
+
40
+ ```bash
41
+ # (2) histogram
42
+ jq -s '[.[] | .message? | select(.role=="assistant")
43
+ | .tool_calls[]? | select(.name=="enemy_actions")
44
+ | .args.actions[]?.action]
45
+ | group_by(.) | map({action: .[0], uses: length}) | sort_by(-.uses)' \
46
+ .lich/sessions/*.jsonl
47
+ ```
48
+
49
+ ```bash
50
+ # (3) veto
51
+ jq -r 'select(.kind=="message") | select(.message.role=="tool")
52
+ | .message.content | fromjson? | select(.error? // "" | startswith("blocked_by_plugin"))
53
+ | .error' .lich/sessions/*.jsonl
54
+ ```
55
+
56
+ ```bash
57
+ # (4) is_error
58
+ jq -r 'select(.kind=="message") | select(.message.role=="tool" and .message.is_error==true)
59
+ | [.ts, .message.name, .message.content] | @tsv' .lich/sessions/*.jsonl
60
+ ```
61
+
62
+ ```bash
63
+ # (5) pacing
64
+ jq -r 'select(.kind=="message") | select(.message.role=="user" or .message.role=="assistant")
65
+ | [.ts, .message.role] | @tsv' .lich/sessions/<run>.jsonl
66
+ ```
67
+
68
+ ```bash
69
+ # (usage) run_end tokens
70
+ jq -s '[.[] | select(.kind=="meta" and .meta.event=="run_end") | .meta.usage.total_tokens] | add' \
71
+ .lich/sessions/*.jsonl
72
+ ```
73
+
74
+ Recipe 2 counts assistant tool-call arguments, including orders a hook later vetoed. It is not the line set Godot applied. Recipe 3 is not a veto table. The only gate in the shipped plugin is meteor before round 3. Other `blocked_by_plugin:` strings, if a game plugin adds them, show up in the same query because the loop formats every veto the same way.
75
+
76
+ ## Do not glob a playthrough blindly
77
+
78
+ A gateway conversation of N posts writes N files. `Agent.run` seeds from `history`, and `persist_session` appends all of `outcome.messages`, so each file is a superset of the previous exchange. Globbing `*.jsonl` double-counts. Take the newest file per label (names sort by timestamp prefix), or dedupe on `tool_call.id`, which stays stable when the same call is replayed into the next file.
79
+
80
+ Compression can rewrite a long run in place: when estimated tokens cross `compress_threshold` of `context_budget_tokens`, older messages become one summary and the 8 most recent non-system messages stay verbatim. Early rounds may survive only as that summary. The recipes see the file on disk, not the pre-compression transcript.
81
+
82
+ ## Player modeling
83
+
84
+ Cross-run notes are tool arguments, not a second memory agent. `dungeon_memory_write` args (`note`) and `dungeon_memory_read` results are in the JSONL. The file on disk is `.lich/game/memory.jsonl`; Godot does not drain it as orders. `MEMORY.md` is never auto-loaded and is not this paper trail.
85
+
86
+ Replay `dungeon_memory_read` calls around a boss fight, then read `enemy_actions` `rationale` after them, to see which notes the commander actually used. The notes are reference data. A note in the tool result is not an instruction, and the session file does not promote it into the system prompt.
87
+
88
+ ```bash
89
+ # (memory) dungeon notes
90
+ jq -r 'select(.kind=="message") | select(.message.role=="assistant")
91
+ | .message.tool_calls[]? | select(.name=="dungeon_memory_write")
92
+ | .args.note' .lich/sessions/*.jsonl
93
+ ```
@@ -24,7 +24,7 @@ flowchart LR
24
24
  G --> H[resolve the round]
25
25
  ```
26
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.
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. Several personas means several agents behind that HTTP process — the pattern is [`examples/persona_orchestrator`](../../examples/persona_orchestrator/README.md), and the service is the game's. Session replay is the [games guide](games.md).
28
28
 
29
29
  ## Gateway contract
30
30
 
@@ -145,7 +145,7 @@ Listed providers form a failover chain tried in order: `rate_limit`/`network` er
145
145
 
146
146
  ## Custom tool filtering
147
147
 
148
- `tools_enabled` accepts `"all"` (default) or an array of builtin tool names to register; everything else stays unregistered and invisible to the model:
148
+ `tools_enabled` accepts `"all"` (default) or an array of builtin tool names to register; everything else stays unregistered and invisible to the model. The filter does not apply to plugin tools: they register afterward, including the gatekeeper's `git_commit`. `[]` strips every builtin and does not throw.
149
149
 
150
150
  ```ts
151
151
  const agent = create_agent({
@@ -182,6 +182,10 @@ try {
182
182
 
183
183
  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.
184
184
 
185
+ ## Games
186
+
187
+ A Godot client does not embed the library. A game backend that does is still one `Agent` per persona, not a second loop. The pattern — factory, history cap, per-conversation queue, `POST /message` → `{reply, usage}` — is [`examples/persona_orchestrator`](../../examples/persona_orchestrator/README.md). The service itself is game-repo work. Session files as a combat log: [games guide](games.md).
188
+
185
189
  ## Session access
186
190
 
187
- 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 `read_session_messages(path)` helper if you are working from a source checkout). Persistence is best-effort: a write failure logs a warning, returns `session_path: undefined`, and never fails the run.
191
+ 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?}`. A completed run (`final`, `budget`, or `aborted` returned by the loop) closes with `{event: "run_end", stopped_reason, usage}` where `usage` equals `usage_total`. A budget stop also writes `{event: "budget_exhausted"}` before that. Provider throws do not persist. Read transcripts with `jq` (see the [games guide](games.md)) or `read_session_messages(path)` from a source checkout it is not a package export. Persistence is best-effort: a write failure logs a warning, returns `session_path: undefined`, and never fails the run.
@@ -9,22 +9,22 @@ lich # front door: TUI, plus a first-run setup wizard when no config exi
9
9
  lich tui # same TUI, no wizard. From a clone: bun src/cli.ts tui
10
10
  ```
11
11
 
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
+ The TUI needs a TTY and a resolvable provider (same resolution as every mode). On startup it prints a dim header from the active theme welcome string, e.g. `⚱ lich v0.5.1the agent that will not stay dead · llama3.2 (ollama)`. That banner is the only tagline placement. Quit with `/exit`, `/quit`, `/q`, or Ctrl+C.
13
13
 
14
14
  ## Anatomy
15
15
 
16
16
  ```
17
- lich v0.3.0llama3.2 (ollama) <- header: agent_name (default lich), version, model, kind
18
- you › list the files here <- your input, echoed into the transcript
17
+ lich v0.5.1the agent that will not stay dead · llama3.2 (ollama)
18
+ mortal › list the files here <- your input, echoed into the transcript
19
19
  ⏺ list_dir({}) <- live tool-call row (name + args preview)
20
20
  ⏷ list_dir: ok (d src/ d test/ ...) <- result row (ok/error + output preview)
21
- lich › Here is what I found ... <- the agent's reply
22
- model llama3.2 · turns 2 · tokens 1,204 · [idle] · /path/.lich/sessions/...jsonl
21
+ lich › Here is what I found ... <- the agent's reply (`response_label`)
22
+ model llama3.2 · turns 2 · tokens 1,204 · [dormant] · /path/.lich/sessions/...jsonl
23
23
  › ▌ <- input row (cursor block)
24
24
  ```
25
25
 
26
- - **Header** — static version/model/provider line.
27
- - **Transcript** — user lines (`you ›`), replies (`lich ›`), tool rows (`⏺ name(args)` with a result line), and meta notices (`· context compressed ...`, `· error: ...`). The view keeps the newest 50 blocks; older lines scroll out of the transcript (session JSONLs still hold everything — see [limitations](#known-limitations)).
26
+ - **Header** — theme welcome string (version, model, provider kind). The tagline appears only here.
27
+ - **Transcript** — user lines (`mortal ›` by default), replies (`lich ›`, or the theme `response_label`), tool rows (`⏺ name(args)` with a result line), and meta notices (`· context compressed — memories distilled ...`, `· error: ...`). The view keeps the newest 50 blocks; older lines scroll out of the transcript (session JSONLs still hold everything — see [limitations](#known-limitations)).
28
28
  - **Input row** — `› ` when idle, `… ` while the agent works; Enter submits, Backspace edits, pasted newlines collapse to spaces.
29
29
  - **Status bar** — see below.
30
30
 
@@ -36,7 +36,7 @@ model llama3.2 · turns 2 · tokens 1,204 · [idle] · /path/.lich/sessions/...j
36
36
  | `/model` | Show the active model and provider kind (from `providers[0]`). |
37
37
  | `/usage` | Show tokens used this session (cumulative across turns). |
38
38
  | `/clear` | Wipe the on-screen transcript. Does **not** reset agent memory — the next message still sees prior turns. |
39
- | `/sessions` | List the 10 newest `.jsonl` files in `session_dir` with sizes. |
39
+ | `/sessions` | List the 10 newest `.jsonl` files in `session_dir` with sizes. The heading uses the theme sessions label (`phylacteries (n):` by default). |
40
40
  | `/exit`, `/quit`, `/q` | Exit the TUI. |
41
41
 
42
42
  Unknown commands print `· unknown command: /x (try /help)`. Slash commands are handled client-side and never invoke the model.
@@ -54,14 +54,14 @@ The bottom line shows, left to right:
54
54
  | `model <name>` | First provider's model from config. |
55
55
  | `turns N` | Turns used by the most recent run (resets each message). |
56
56
  | `tokens N` | Cumulative session token usage (prompt + completion, across all turns). |
57
- | `[idle]` / `[thinking]` / `[tool]` | Current phase: waiting for input, calling the model, or executing a tool. |
57
+ | `[dormant]` / `[deliberating]` / `[casting]` | Current phase: waiting for input, calling the model, or executing a tool. Labels come from the theme. |
58
58
  | `compressed N` | How many times context compression fired this session (hidden when 0). |
59
59
  | `<session path>` | Path of the newest persisted transcript (appears after the first run). |
60
- | `· budget exhausted` | Red notice when a run hit the turn cap. |
60
+ | `· budget exhausted — the ritual is spent (turn cap reached)` | Red notice when a run hit the turn cap. The keyword stays; the flavor comes from the theme. |
61
61
 
62
62
  ## Multi-turn memory
63
63
 
64
- The TUI keeps one conversation: every submitted message is sent together with the full prior message history, so the agent remembers earlier turns for as long as the session stays open. When estimated tokens cross `compress_threshold` of `context_budget_tokens`, older turns are replaced by an LLM-generated summary (the 8 most recent messages always stay verbatim) and a `· context compressed` notice appears. There is deliberately no per-conversation reset command — `/clear` only clears the display; start a fresh `lich tui` process for an empty context.
64
+ The TUI keeps one conversation: every submitted message is sent together with the full prior message history, so the agent remembers earlier turns for as long as the session stays open. When estimated tokens cross `compress_threshold` of `context_budget_tokens`, older turns are replaced by an LLM-generated summary (the 8 most recent messages always stay verbatim) and a `· context compressed — memories distilled` notice appears. There is deliberately no per-conversation reset command — `/clear` only clears the display; start a fresh `lich tui` process for an empty context.
65
65
 
66
66
  ## Known limitations
67
67