@moikapy/lich 0.4.0 → 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 +24 -0
- package/dist/{chunk-MLFJW4JU.js → chunk-CV2YH3FH.js} +51 -2
- 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-XTYDYT67.js → gateway-W6S43ETE.js} +27 -18
- package/dist/gateway-W6S43ETE.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +1 -1
- package/dist/{tui-VYBJSGRV.js → tui-DT7XWDTX.js} +8 -5
- package/dist/tui-DT7XWDTX.js.map +1 -0
- package/docs/getting-started.md +15 -4
- package/docs/index.md +1 -0
- package/docs/user-guide/cli.md +11 -3
- package/docs/user-guide/godot.md +160 -0
- package/docs/user-guide/library.md +1 -1
- package/docs/user-guide/plugins.md +23 -2
- 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-MLFJW4JU.js.map +0 -1
- package/dist/gateway-XTYDYT67.js.map +0 -1
- package/dist/tui-VYBJSGRV.js.map +0 -1
|
@@ -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\";\n\nexport async function run_tui(config: AgentConfig): Promise<number> {\n const agent = await create_agent_with_plugins(config);\n const instance = render(<TuiApp agent={agent} />);\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 { 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): 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)];\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): 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, 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 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));\n }, [add_blocks, set_state]);\n\n const start_message_run = useCallback(\n (text: string): void => {\n add_blocks([{ role: \"user\", lines: [`you › ${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)].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],\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(agent: Agent, add_blocks: AddBlocks, set_blocks: SetBlocks, total_tokens: number): (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).then((block) => add_blocks([block]));\n } else {\n add_blocks([unknown_command_block(parsed.name)]);\n }\n },\n [agent, add_blocks, set_blocks, total_tokens],\n );\n return handle;\n}\n\ninterface TuiAppProps {\n readonly agent: Agent;\n}\n\nexport function TuiApp({ agent }: 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, add_blocks, set_state, set_blocks);\n const handle_slash = use_slash_commands(agent, 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(agent.config.agent_name, LICH_VERSION, provider?.model ?? \"unknown\", provider?.kind ?? \"unknown\")}</Text>\n <MessageView blocks={blocks} state={state} />\n <StatusBar state={state} model={provider?.model ?? \"unknown\"} />\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\";\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 line: agent name, version, model, provider kind. */\nexport function tui_banner_text(agent_name: string, version: string, model: string, kind: string): string {\n return `${agent_name} v${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): string[] {\n if (message.role === \"user\") {\n return [`you \\u203a ${message.content}`];\n }\n if (message.role === \"assistant\") {\n const lines = [`lich \\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): 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) };\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) };\n}\n\n/** Keep the newest `cap` non-system messages as renderable blocks. */\nexport function split_history_blocks(messages: readonly Message[], cap: number): 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(format_message_block);\n}\n\nfunction assistant_result_block(message: AssistantMessage): HistoryBlock | undefined {\n if (message.content.length === 0) {\n return undefined;\n }\n return { role: \"lich\", lines: [`lich \\u203a ${message.content}`] };\n}\n\n/** Post-run meta blocks: compression notices, budget, errors, final answer. */\nexport function run_notice_blocks(result: AgentRunResult): HistoryBlock[] {\n const blocks: HistoryBlock[] = [];\n if (result.outcome.stopped_reason === \"budget\") {\n blocks.push({ role: \"error\", lines: [\"\\u00b7 budget exhausted (turn cap reached)\"] });\n }\n const final_block = result.outcome.final === undefined ? undefined : assistant_result_block(result.outcome.final);\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): HistoryBlock {\n return { role: \"meta\", lines: [`\\u00b7 context compressed (summary ${summary_chars} chars)`] };\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[], 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 lines: string[] = [`\\u00b7 sessions (${sorted.length}):`];\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 { format_usage, type UiState } from \"./state.js\";\n\nconst PHASE_LABELS: Record<UiState[\"phase\"], string> = {\n idle: \"idle\",\n thinking: \"thinking\",\n tool: \"tool\",\n};\n\ninterface StatusBarProps {\n readonly state: UiState;\n readonly model: string;\n}\n\nexport function StatusBar({ state, model }: StatusBarProps): React.JSX.Element {\n const phase = 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\"> · 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"]}
|
package/docs/getting-started.md
CHANGED
|
@@ -42,6 +42,8 @@ lich config > .lich/config.json
|
|
|
42
42
|
|
|
43
43
|
`lich config` honors `LICH_PROVIDER_KIND` and `LICH_MODEL` when you have them set, and otherwise prints an ollama-oriented template. The file is picked up automatically from `.lich/config.json` in the working directory (or `~/.config/lich/config.json` as a fallback) — after this, plain `lich "task"` needs no env vars.
|
|
44
44
|
|
|
45
|
+
`lich init` writes that same starter file for you (it creates `.lich/` and never overwrites an existing `.lich/config.json`). Bare `lich` on a TTY, with no config in that search chain and no `LICH_MODEL`, runs a setup wizard and writes `.lich/config.json` once before opening the TUI. `.lich/` is gitignored.
|
|
46
|
+
|
|
45
47
|
### Path C: an explicit config file
|
|
46
48
|
|
|
47
49
|
```sh
|
|
@@ -70,7 +72,8 @@ Exit code `0` means the model produced a final answer; `1` means the turn budget
|
|
|
70
72
|
## Your first TUI session
|
|
71
73
|
|
|
72
74
|
```sh
|
|
73
|
-
lich
|
|
75
|
+
lich # TUI; first run on a TTY opens the setup wizard
|
|
76
|
+
lich tui # same TUI, no wizard
|
|
74
77
|
```
|
|
75
78
|
|
|
76
79
|
Type a message and press Enter. The transcript shows your line, live tool-call rows while the agent works, and the reply; the status bar at the bottom tracks turns, tokens, and the session file path. Slash commands: `/help`, `/model`, `/usage`, `/clear`, `/sessions`, `/exit`. Details in [the TUI guide](user-guide/tui.md).
|
|
@@ -117,7 +120,7 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
|
|
|
117
120
|
|
|
118
121
|
| Symptom | Cause and fix |
|
|
119
122
|
| --- | --- |
|
|
120
|
-
| `no model configured: set LICH_MODEL, pass --model, or create .lich/config.json` | No provider was resolvable. Set `LICH_MODEL`, pass `--model`, or save a config file (`lich config`). |
|
|
123
|
+
| `no model configured: set LICH_MODEL, pass --model, or create .lich/config.json` | No provider was resolvable. Set `LICH_MODEL`, pass `--model`, or save a config file (`lich init` or `lich config`). |
|
|
121
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. |
|
|
122
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. |
|
|
123
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`. |
|
|
@@ -126,13 +129,21 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
|
|
|
126
129
|
|
|
127
130
|
## Updating
|
|
128
131
|
|
|
129
|
-
|
|
132
|
+
Check the npm registry and install a newer release with:
|
|
130
133
|
|
|
131
134
|
```sh
|
|
132
|
-
|
|
135
|
+
lich update
|
|
133
136
|
lich --version # -> the version you just installed
|
|
134
137
|
```
|
|
135
138
|
|
|
139
|
+
`lich update` compares the installed version to `npm view @moikapy/lich version`. When the registry copy is newer, it runs the equivalent command:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npm install -g @moikapy/lich@latest
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Exit any running `lich tui` or `lich gateway` first — npm cannot replace the package while those processes are running. A git clone updates with `git pull` instead; `npx` cannot persist an update.
|
|
146
|
+
|
|
136
147
|
Updates never touch your data: the per-project `.lich/` directory holds your config and session transcripts, installers neither read nor migrate it, and it is gitignored by design so a checkout never collides with it. For what changed between versions, see the [changelog](https://github.com/moikapy/lich/blob/main/CHANGELOG.md).
|
|
137
148
|
|
|
138
149
|
## Development install (from source)
|
package/docs/index.md
CHANGED
|
@@ -34,6 +34,7 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
|
|
|
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
36
|
| [Plugins guide](user-guide/plugins.md) | Add your own tools and lifecycle hooks, and run the self-improvement loop. |
|
|
37
|
+
| [Godot guide](user-guide/godot.md) | Run lich beside a Godot game and drain `.lich/game/` orders each tick. |
|
|
37
38
|
| [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
|
|
38
39
|
|
|
39
40
|
## How it works
|
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. |
|
|
@@ -158,8 +166,8 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content // "(too
|
|
|
158
166
|
|
|
159
167
|
| Code | Meaning |
|
|
160
168
|
| --- | --- |
|
|
161
|
-
| `0` | Success: final answer produced (also `--help`, `--version`, `config`). |
|
|
162
|
-
| `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. |
|
|
163
171
|
|
|
164
172
|
## Log levels
|
|
165
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";
|
|
@@ -112,8 +112,29 @@ 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
|
+
```
|
|
117
138
|
|
|
118
139
|
## Self-improvement loop
|
|
119
140
|
|
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
|
+
}
|