@moikapy/lich 0.6.0 → 0.7.1

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +59 -7
  3. package/dist/{chunk-JC2G3XH2.js → chunk-CVX7LZWC.js} +2 -2
  4. package/dist/chunk-PZNYVGD4.js +58 -0
  5. package/dist/chunk-PZNYVGD4.js.map +1 -0
  6. package/dist/{chunk-7HLVKVIG.js → chunk-WNFBIX4E.js} +1344 -162
  7. package/dist/chunk-WNFBIX4E.js.map +1 -0
  8. package/dist/cli.js +246 -18
  9. package/dist/cli.js.map +1 -1
  10. package/dist/{gateway-RJFZJEUZ.js → gateway-44QTIJTJ.js} +108 -49
  11. package/dist/gateway-44QTIJTJ.js.map +1 -0
  12. package/dist/index.d.ts +288 -154
  13. package/dist/index.js +5 -3
  14. package/dist/{tui-LOUJVZ6A.js → tui-MEJGEILU.js} +25 -10
  15. package/dist/tui-MEJGEILU.js.map +1 -0
  16. package/docs/.vitepress/config.mts +4 -0
  17. package/docs/architecture/overview.md +30 -18
  18. package/docs/architecture/plugins.md +1 -1
  19. package/docs/architecture/tools.md +36 -4
  20. package/docs/getting-started.md +7 -6
  21. package/docs/index.md +5 -4
  22. package/docs/user-guide/cli.md +21 -8
  23. package/docs/user-guide/games.md +1 -1
  24. package/docs/user-guide/gateway.md +70 -16
  25. package/docs/user-guide/godot.md +3 -1
  26. package/docs/user-guide/library.md +4 -2
  27. package/docs/user-guide/plugins.md +2 -2
  28. package/docs/user-guide/redot.md +93 -0
  29. package/docs/user-guide/tui.md +2 -2
  30. package/examples/game_bridge/README.md +2 -0
  31. package/optional-mcps/godot/manifest.json +6 -0
  32. package/optional-mcps/redot/manifest.json +18 -0
  33. package/package.json +2 -1
  34. package/dist/chunk-6M6OAQGN.js +0 -17
  35. package/dist/chunk-6M6OAQGN.js.map +0 -1
  36. package/dist/chunk-7HLVKVIG.js.map +0 -1
  37. package/dist/gateway-RJFZJEUZ.js.map +0 -1
  38. package/dist/tui-LOUJVZ6A.js.map +0 -1
  39. /package/dist/{chunk-JC2G3XH2.js.map → chunk-CVX7LZWC.js.map} +0 -0
@@ -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 try {\n const theme = load_theme(config.theme);\n const instance = render(<TuiApp agent={agent} theme={theme} />);\n await instance.waitUntilExit();\n return 0;\n } finally {\n agent.close();\n }\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 if (busy === true) {\n return;\n }\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 /** Newest-first ring: Up walks toward older (higher index), Down toward newer. */\n const walk_recall = (direction: 1 | -1): void => {\n if (recall_ring.length === 0) {\n return;\n }\n if (direction === -1 && recall_index === 0) {\n set_recall_index(undefined);\n set_buffer(\"\");\n return;\n }\n if (direction === -1 && recall_index === undefined) {\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}\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"]}
@@ -29,6 +29,9 @@ export default defineConfig({
29
29
  { text: 'TUI guide', link: '/user-guide/tui' },
30
30
  { text: 'Gateway guide', link: '/user-guide/gateway' },
31
31
  { text: 'Library guide', link: '/user-guide/library' },
32
+ { text: 'Plugins guide', link: '/user-guide/plugins' },
33
+ { text: 'Godot guide', link: '/user-guide/godot' },
34
+ { text: 'Redot guide', link: '/user-guide/redot' },
32
35
  { text: 'Games guide', link: '/user-guide/games' }
33
36
  ]
34
37
  },
@@ -39,6 +42,7 @@ export default defineConfig({
39
42
  { text: 'Agent loop', link: '/architecture/agent-loop' },
40
43
  { text: 'Providers', link: '/architecture/providers' },
41
44
  { text: 'Tools', link: '/architecture/tools' },
45
+ { text: 'Plugins', link: '/architecture/plugins' },
42
46
  { text: 'Extending', link: '/architecture/extending' }
43
47
  ]
44
48
  }
@@ -1,21 +1,23 @@
1
1
  # Architecture Overview
2
2
 
3
- Lich v0.3.0 is a small TypeScript AI agent harness: it drives a chat model in a
3
+ Lich is a small TypeScript AI agent harness: it drives a chat model in a
4
4
  think-act-observe loop, lets the model call tools, compresses history when the
5
- context budget demands it, and persists transcripts. It runs on Bun, is ESM
6
- with NodeNext resolution, and its only runtime dependencies are `zod` (config
7
- validation) and `ink` (the TUI). Everything else is Node/Bun built-ins.
5
+ context budget demands it, and persists transcripts. The published npm package
6
+ is 0.7.0, including editor MCP (`mcp_servers`, `lich mcp`). It runs on
7
+ Node >= 20 and on Bun, is ESM with NodeNext resolution, and its runtime
8
+ dependencies are `zod` (config validation), `ink`, and `react` (the TUI).
9
+ Everything else is Node/Bun built-ins.
8
10
 
9
11
  This page is the map. The follow-up pages go deep on each area:
10
12
  [agent loop](./agent-loop.md), [providers](./providers.md),
11
- [tools](./tools.md), and [extending](./extending.md).
13
+ [tools](./tools.md), [plugins](./plugins.md), and [extending](./extending.md).
12
14
 
13
15
  ## Layer diagram
14
16
 
15
17
  ```mermaid
16
18
  flowchart TB
17
19
  subgraph entry["Entry surfaces"]
18
- CLI["src/cli.ts<br/>one-shot and chat"]
20
+ CLI["src/cli.ts<br/>one-shot, chat, tui,<br/>gateway, mcp"]
19
21
  TUI["src/tui/app.tsx<br/>ink TUI"]
20
22
  GW["src/gateway/runner.ts<br/>webhook, telegram,<br/>discord, twitch"]
21
23
  LIB["src/index.ts<br/>library exports"]
@@ -94,36 +96,42 @@ Why this matters:
94
96
  Walkthrough of a single `Agent.run({ input })` call
95
97
  ([`src/agent/agent.ts`](../../src/agent/agent.ts)):
96
98
 
97
- 1. **Usage collector attached.** `run()` subscribes a `collect_usage` handler
99
+ 1. **MCP attach, once.** Before the first model call, enabled `mcp_servers`
100
+ are connected (`initialize`, `notifications/initialized`, then `tools/list`)
101
+ and registered as `mcp_<server>_<tool>`. An empty `tools_enabled` never
102
+ connects. Disabled servers are skipped. A connect failure logs a warning
103
+ and the run continues. This client ships in 0.7.0.
104
+ 2. **Usage collector attached.** `run()` subscribes a `collect_usage` handler
98
105
  on `agent.events`; every `llm_end` event adds the call's token usage into a
99
106
  per-run `Usage` total. The subscription is removed in a `finally` block.
100
- 2. **Seed messages.** The caller's `history` (if any) is copied into a fresh
107
+ 3. **Seed messages.** The caller's `history` (if any) is copied into a fresh
101
108
  array and the new user message is appended. The caller's array is never
102
109
  mutated.
103
- 3. **Loop starts.** `run_conversation(deps, seed, params)` first applies the
110
+ 4. **Loop starts.** `run_conversation(deps, seed, params)` first applies the
104
111
  system prompt via `seed_system_prompt` (prepend, or replace an existing
105
112
  system message if its content differs) and then enters the turn loop
106
113
  described in [agent loop](./agent-loop.md).
107
- 4. **Each turn.** Abort check at the top of the turn, optional compression
114
+ 5. **Each turn.** Abort check at the top of the turn, optional compression
108
115
  check, then one LLM call through the router (`chat_with_failover`, which
109
116
  walks providers with bounded in-place retries). The assistant message is
110
117
  pushed onto the history.
111
- 5. **Tools.** If the assistant message carries `tool_calls`, each call runs
112
- through the `ToolExecutor` (30 s timeout, abort linking, output clamping)
113
- and a `tool` message is appended per call. The loop then starts the next
118
+ 6. **Tools.** If the assistant message carries `tool_calls`, each call runs
119
+ through the `ToolExecutor` (per-tool `timeout_ms`, else 30 s; abort linking,
120
+ output clamping) and a `tool` message is appended per call. `terminal` sets
121
+ 300000 ms; `run_tests` sets 600000. The loop then starts the next
114
122
  turn. A turn with no tool calls is the final turn.
115
- 6. **Outcome.** The loop returns a `LoopOutcome`: the full `messages` array,
123
+ 7. **Outcome.** The loop returns a `LoopOutcome`: the full `messages` array,
116
124
  the final assistant message (or the last one seen), the last `ChatResult`
117
125
  on a real final, `turns_used`, and a `stopped_reason` of `final`, `budget`,
118
126
  or `aborted`.
119
- 7. **Session persist.** `persist_session()` appends one `meta` record
127
+ 8. **Session persist.** `persist_session()` appends one `meta` record
120
128
  (`run_start`), then one `message` record per outcome message, then a
121
129
  `budget_exhausted` meta record if the budget stopped the run, then a
122
130
  `run_end` meta record (`stopped_reason`, `usage`) for every completed
123
131
  run, to a JSONL file under `session_dir` (default
124
132
  `<work_dir>/.lich/sessions`). Persistence is best-effort: failures are
125
133
  logged and the run still succeeds with `session_path: undefined`.
126
- 8. **Return.** `AgentRunResult` bundles the outcome, the full transcript
134
+ 9. **Return.** `AgentRunResult` bundles the outcome, the full transcript
127
135
  (prior history plus the new exchange), the collected `usage_total`, and the
128
136
  session path.
129
137
 
@@ -150,8 +158,11 @@ Walkthrough of a single `Agent.run({ input })` call
150
158
  | Path | Responsibility |
151
159
  | --- | --- |
152
160
  | `src/index.ts` | Public library surface; pure re-exports plus `LICH_VERSION`. |
153
- | `src/cli.ts` | Zero-dependency CLI: one-shot, `chat`, `tui`, `gateway`, `config`. |
154
- | `src/cli_config.ts` | Config file discovery, loading, flag overrides, template. |
161
+ | `src/cli.ts` | CLI: one-shot, `chat`, `tui`, `gateway`, `init`, `config`, `update`, `mcp`. |
162
+ | `src/cli_config.ts` | Config file discovery, loading, flag overrides, template, writer. |
163
+ | `src/cli_update.ts` | `lich update`: npm view, then `npm install -g` when newer. Git clones are told to `git pull`. |
164
+ | `src/cli_mcp.ts` | `lich mcp` list/add/enable/disable/remove against work-dir config. |
165
+ | `src/mcp/*` | MCP client: catalog, stdio/loopback HTTP, tool registration. |
155
166
  | `src/agent/agent.ts` | `Agent`: wires router, registry, executor; sessions; usage. |
156
167
  | `src/agent/loop.ts` | `run_conversation`: the think-act-observe loop. |
157
168
  | `src/agent/config.ts` | Zod config schema, defaults, derived `session_dir`, freeze. |
@@ -170,6 +181,7 @@ Walkthrough of a single `Agent.run({ input })` call
170
181
  | `src/tools/registry.ts` | Name-keyed tool registry; duplicate rejection. |
171
182
  | `src/tools/executor.ts` | Never-throw execution with timeout and abort. |
172
183
  | `src/tools/builtin/*` | Builtin tools, including `run_tests` (see [tools](./tools.md)). |
184
+ | `src/plugins/*` | Plugin loader and hooks. Gatekeeper registers `git_commit` in code. |
173
185
  | `src/gateway/bus.ts` | Conversation-keyed runner over one shared `Agent`. |
174
186
  | `src/gateway/runner.ts` | Adapter construction, signal handling, process lifetime. |
175
187
  | `src/gateway/{telegram,discord,twitch,webhook}.ts` | Platform adapters. |
@@ -59,7 +59,7 @@ sequenceDiagram
59
59
  end
60
60
  ```
61
61
 
62
- Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({input_chars})` before `run_conversation` and `call_run_end({stopped_reason, turns_used})` after it (including the abort/throw path, via `finally`). Both are best-effort: hook throws are logged at `warn` and the run proceeds.
62
+ Lifecycle fan-outs live on the same wrapper: `Agent.run` calls `call_run_start({input_chars})` before `run_conversation` and `call_run_end({stopped_reason, turns_used})` in `finally` only when the loop returned an outcome (`final`, `budget`, or `aborted`). A provider throw leaves `outcome` undefined, so `on_run_end` is skipped and no `run_end` session record is written. Both fan-outs are best-effort: hook throws are logged at `warn` and the run proceeds.
63
63
 
64
64
  ## Builtin gatekeeper
65
65
 
@@ -38,8 +38,11 @@ result either way.
38
38
 
39
39
  **`ToolContext`** gives each execution a working directory (`work_dir`, the
40
40
  confinement root), a process environment map (the agent injects
41
- `LICH_TERMINAL_TIMEOUT_MS`), and an abort `signal` that fires on caller abort
42
- **or** the executor's own 30 s deadline.
41
+ `LICH_TERMINAL_TIMEOUT_MS`), and an optional abort `signal` the agent passes
42
+ from `Agent.run` so tools cancel on caller abort **or** the executor deadline
43
+ (`tool.timeout_ms`, else
44
+ `DEFAULT_TOOL_TIMEOUT_MS` = 30000). `terminal` sets 300000, `run_tests` sets
45
+ 600000, and registered MCP tools set 120000.
43
46
 
44
47
  **Parameter schemas.** `parameters` is a `JsonSchemaObject`
45
48
  (`src/util/json_schema.ts`) passed through verbatim into provider requests.
@@ -149,7 +152,7 @@ Docs tools join the list only when a docs root resolves.
149
152
  | `edit_file` | `path`, `old_string`, `new_string`, `replace_all?` | Fails `old_string_not_found` / `old_string_not_unique (N)` unless `replace_all` - an exact-match protocol that forces the model to anchor edits. |
150
153
  | `list_dir` | `path?`, `depth?` (1-4) | Iterative worklist (no recursion), dirs-first sorting, skips `node_modules`/`.git`/`dist`/`.lich`/`.cursor`, caps at 500 entries, file sizes via `stat`. |
151
154
  | `terminal` | `command`, `timeout_ms?` | Spawns `bash -lc`, streams and caps stdout+stderr at 50 K chars, SIGKILLs on deadline, appends `[exit N]`; `ok` requires exit code 0 and no cancellation. |
152
- | `grep_files` | `pattern`, `path?`, `glob?`, `max_results?` | Explicit stack walk (no recursion), binary sniff (NUL byte in first 1000 bytes), 1 MB file cap, `*.ext` suffix-glob matcher, overcollect-by-one to report suppressed counts. |
155
+ | `grep_files` | `pattern`, `path?`, `glob?`, `max_results?` | Explicit stack walk (no recursion), skips `SKIP_DIRS` entries and symbolic links, per-file `assert_file_tool_access` check (`.lich/config.json` is denied), binary sniff (NUL byte in first 1000 bytes), 1 MB file cap, `*.ext` suffix-glob matcher, overcollect-by-one to report suppressed counts. |
153
156
  | `fetch_url` | `url`, `max_chars?`, `timeout_ms?` | GET only; rejects non-http(s) protocols; refuses images/octet-stream; tags HTML bodies with `[html content]`; status/type header line first. |
154
157
  | `web_search` | `query`, `max_results?` | Scrapes DuckDuckGo's HTML endpoint (no API key); unwraps `uddg=` redirect links; decodes the handful of entities DDG emits. |
155
158
  | `http_request` | `url`, `method?`, `headers?`, `body?`, ... | Method allowlist (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS); stringified caller headers; reports `content-length`, `ratelimit-remaining`, `retry-after`. |
@@ -164,6 +167,32 @@ allowlist), `compose_abort_signal` (per-call `AbortSignal.timeout` merged
164
167
  with the executor's cancellation via `AbortSignal.any`), and `clamp_int_arg`
165
168
  (floored, bounded to `[1, max]`).
166
169
 
170
+ `grep_files` also runs `assert_file_tool_access` on every candidate path
171
+ (the same deny list as `read_file`/`write_file`/`edit_file`): a direct
172
+ grep of `.lich/config.json` fails with `forbidden_path:
173
+ .lich/config.json`. During the walk, symbolic links are skipped on the
174
+ same branch as `SKIP_DIRS`, so symlink entries are neither followed nor
175
+ searched.
176
+
177
+ ### HTTP tools and `safe_fetch` - pinned outbound requests
178
+
179
+ `fetch_url` and `http_request` send every request through `safe_fetch`
180
+ ([`src/tools/url_guard.ts`](../../src/tools/url_guard.ts)). Per hop:
181
+
182
+ 1. `resolve_public_ip` resolves the hostname and rejects private,
183
+ loopback, link-local, and ULA addresses (`blocked_url:`), including
184
+ `localhost` / `*.localhost` / `*.local` names.
185
+ 2. The request is then issued with the **original hostname** kept for
186
+ TLS/SNI and the `Host` header - the URL is not rewritten to the IP.
187
+ The connect is pinned instead: a custom DNS `lookup` function handed
188
+ to `http(s).request` returns only the already-vetted public IP.
189
+ 3. Redirects are followed manually (`redirect: "manual"`) and every
190
+ `Location` hop is re-resolved and re-vetted, up to 5 hops.
191
+
192
+ The operator opt-out is exact: `LICH_ALLOW_PRIVATE_URLS=1` allows
193
+ private targets (the value is compared to `"1"`), while unset or any
194
+ other value is fail-closed and private URLs stay blocked.
195
+
167
196
  ## Docs search and skills
168
197
 
169
198
  `docs_search` scores sections under the resolved docs root (memoized) and, when
@@ -187,6 +216,9 @@ instructions. See the [plugins guide](../user-guide/plugins.md#skills-and-memory
187
216
  - **Enabling a subset** is done by rebuilding a fresh registry: `Agent`'s
188
217
  `filter_registry` (src/agent/agent.ts) iterates `base.list()` and registers
189
218
  only allowed names onto a new `ToolRegistry` when `tools_enabled` is a list
190
- (`"all"` returns the base registry untouched).
219
+ (`"all"` returns the base registry untouched). Plugin tools, including the
220
+ gatekeeper's `git_commit`, register after that filter, so `tools_enabled: []`
221
+ still leaves `git_commit`. MCP tools register later, on first `run()`, and
222
+ only when the allowlist is `"all"` or names an `mcp_` tool.
191
223
 
192
224
  For building your own tool, see [extending](./extending.md#add-a-builtin-tool).
@@ -10,12 +10,12 @@
10
10
 
11
11
  ```sh
12
12
  npm install -g @moikapy/lich
13
- lich --version # -> 0.3.0
13
+ lich --version # -> 0.7.0 (reads package.json)
14
14
  ```
15
15
 
16
16
  ## Choose a configuration path
17
17
 
18
- Lich needs exactly one thing before it runs: a model. You can provide it three ways, and they can be mixed (flags override env vars, and both override the config file).
18
+ Lich needs exactly one thing before it runs: a model. You can provide it three ways. Flags override a config file and override `LICH_*` env vars. Those env vars apply only when no config file is found; they are not merged into a discovered file.
19
19
 
20
20
  ### Path A: environment variables only
21
21
 
@@ -42,7 +42,7 @@ 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.
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, runs a setup wizard and writes `.lich/config.json` once before opening the TUI. `LICH_MODEL` / `--model` prefills the model prompt; it does not skip the wizard. Non-TTY stdin skips the wizard. `.lich/` is gitignored.
46
46
 
47
47
  ### Path C: an explicit config file
48
48
 
@@ -121,7 +121,7 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content)"' .lich
121
121
  | Symptom | Cause and fix |
122
122
  | --- | --- |
123
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`). |
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. |
124
+ | `lich: cannot use config file <path>: ...` | `--config` was given a path that does not exist or is not readable JSON. 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
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. |
@@ -153,7 +153,7 @@ To hack on Lich itself, run the CLI straight from a clone instead of the npm pac
153
153
  ```sh
154
154
  git clone https://github.com/Moikapy/lich.git && cd lich
155
155
  bun install
156
- bun src/cli.ts --version # -> 0.3.0
156
+ bun src/cli.ts --version # -> 0.7.0 (package.json)
157
157
  ```
158
158
 
159
159
  `bun src/cli.ts` accepts the same arguments as the installed `lich` binary, so every command on this page works unchanged.
@@ -164,4 +164,5 @@ bun src/cli.ts --version # -> 0.3.0
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
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).
167
+ - Session JSONL as a combat log: [Games guide](user-guide/games.md).
168
+ - Editor MCP (`lich mcp`): [Redot guide](user-guide/redot.md). Play is still the [Godot guide](user-guide/godot.md).
package/docs/index.md CHANGED
@@ -8,7 +8,7 @@ outline: [2, 3]
8
8
 
9
9
  Lich is a TypeScript AI agent harness: a library and a CLI that run a chat model inside a Think-Act-Observe loop. A chat wrapper forwards one prompt and prints one completion. A harness keeps going: the model plans (think), calls tools such as `read_file` or `terminal` (act), reads the tool results (observe), and repeats until it can produce a final answer. Lich wraps that loop with the machinery real deployments need: provider failover with bounded retries, path confinement and output clamps on every tool, context compression when the transcript grows past a token budget, and append-only JSONL session transcripts.
10
10
 
11
- One package, four ways to drive the same agent: a one-shot CLI, an interactive chat REPL, an ink-based terminal UI, and a long-running messaging gateway that bridges Telegram, Discord, Twitch, and a zero-config HTTP webhook. All four share the same builtin tools, the same provider configuration, and the same session store.
11
+ One package, four ways to drive the same agent: a one-shot CLI, an interactive chat REPL, an ink-based terminal UI, and a long-running messaging gateway that bridges Telegram, Discord, Twitch, and a zero-config HTTP webhook. All four share the same builtin tools, the same provider configuration, and the same session store. `lich init`, `lich config`, `lich update`, and `lich mcp` do not start that loop.
12
12
 
13
13
  ## Feature overview
14
14
 
@@ -18,7 +18,7 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
18
18
  | Tools | Builtins (file read/write/edit, directory listing, shell, grep, HTTP fetch/request, web search, process list, disk usage, env inspection, `run_tests`), all confined to the working directory. `git_commit` is the gatekeeper's tool, not a config plugin. |
19
19
  | Context compression | Transcript summarized in place when estimated tokens cross `compress_threshold` of `context_budget_tokens`; the 8 most recent turns always stay verbatim. |
20
20
  | Sessions | Every run persists a `.jsonl` transcript under `.lich/sessions/`, labeled by origin (`tui`, `gw:<platform>:<chat>`). |
21
- | CLI | One-shot tasks, chat REPL, TUI, gateway, and a `config` template command, all with flag/env/config-file configuration. |
21
+ | CLI | One-shot tasks, chat REPL, TUI, gateway, `init`, `update`, `config`, and (in this source) `lich mcp`. Flag, env, and config-file configuration. |
22
22
  | TUI | Live ink transcript with tool-call rows, status bar (model, turns, tokens, session path), and slash commands. |
23
23
  | Gateway | One shared agent behind webhook/Telegram/Discord/Twitch with per-conversation memory (40-message history cap) and per-platform message splitting. |
24
24
  | Library | `create_agent` / `run_agent` with typed events (`AgentEmitter`), multi-turn history, and `ProviderError` kinds for error handling. |
@@ -29,12 +29,13 @@ One package, four ways to drive the same agent: a one-shot CLI, an interactive c
29
29
  | Page | Read it to |
30
30
  | --- | --- |
31
31
  | [Getting started](getting-started.md) | Install, configure a provider, and get your first reply in any mode. |
32
- | [CLI reference](user-guide/cli.md) | Master all four modes, flags, provider resolution, and config files. |
32
+ | [CLI reference](user-guide/cli.md) | Modes, flags, provider resolution, config files, and `lich mcp`. |
33
33
  | [TUI guide](user-guide/tui.md) | Run the terminal UI and use slash commands and the status bar. |
34
34
  | [Gateway guide](user-guide/gateway.md) | Wire Telegram, Discord, Twitch, and the HTTP webhook to one agent. |
35
35
  | [Library guide](user-guide/library.md) | Embed the agent in TypeScript with events and multi-turn history. |
36
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
+ | [Redot guide](user-guide/redot.md) | Add editor MCP servers (Redot catalog entry). Play still uses the game bridge, the opposite direction. |
38
39
  | [Games guide](user-guide/games.md) | Replay session JSONL as a combat log, including token totals. |
39
40
  | [Architecture overview](architecture/overview.md) | Understand how the harness works inside. |
40
41
 
@@ -67,4 +68,4 @@ Working from a clone of the repository? `bun install`, then run the same command
67
68
 
68
69
  ## Version compatibility
69
70
 
70
- Documented for **v0.3.0**. The npm package requires Node >= 20 (`engines` in `package.json`); Bun is the recommended runtime for development from a clone (`bun src/cli.ts ...`). The TUI needs a TTY; the gateway and library run headless on both runtimes.
71
+ The published npm package is **0.7.0**. `lich --version` reads `package.json`. Editor MCP (`mcp_servers`, `lich mcp`) ships in 0.7.0. Node >= 20 (`engines` in `package.json`); Bun is the recommended runtime for development from a clone (`bun src/cli.ts ...`). The TUI needs a TTY; the gateway and library run headless on both runtimes.
@@ -13,19 +13,25 @@ lich tui # interactive terminal UI (ink)
13
13
  lich gateway <plat..> # messaging gateway (webhook|telegram|discord|twitch)
14
14
  lich config # print a starter config template
15
15
  lich update # install a newer npm release, if one exists
16
+ lich mcp list # list servers in this work dir's .lich/config.json
17
+ lich mcp add <name> # catalog or --command/--url; stays disabled
18
+ lich mcp enable <name> # set enabled true
19
+ lich mcp disable <name>
20
+ lich mcp remove <name>
16
21
  lich --help # usage text
17
- lich --version # print 0.3.0
22
+ lich --version # package.json version (published package and this tree: 0.7.0)
18
23
  ```
19
24
 
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.
25
+ - **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, a setup wizard runs first (name, provider, optional gateway env-var names, optional plugins) and writes `.lich/config.json` once. `LICH_MODEL` / `--model` prefills the model prompt; it does not skip the wizard. An existing config in that chain 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
26
  - **`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.
22
27
  - **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.
23
28
  - **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.
24
29
  - **TUI** launches the ink interface. See the [TUI guide](tui.md).
25
30
  - **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
31
  - **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).
32
+ - **MCP** ships in 0.7.0 (`lich mcp`). It edits only `mcp_servers` in `<work-dir>/.lich/config.json` through the same writer as `lich init` (`update` mode, so other keys stay). A missing file lists as empty; `add` creates the file if needed. New entries stay disabled until `enable`. Names must match `^[a-z][a-z0-9_]*$`. Catalog names use `optional-mcps/`; otherwise pass `--command` and repeatable `--arg`, or `--url` (loopback only), not both. Redot still needs `--project-path` for the catalog args, and the command basename must be `redot`. No prompts. See the [Redot guide](redot.md).
27
33
 
28
- The installed `lich` binary and `bun src/cli.ts` (from a repository clone) accept identical arguments.
34
+ A repository clone's `bun src/cli.ts` matches that checkout. The published 0.7.0 binary includes `lich mcp`.
29
35
 
30
36
  ## Flags
31
37
 
@@ -44,6 +50,10 @@ Flags work before or after the subcommand. Every value flag can also be set via
44
50
  | `--session-dir <path>` | Transcript directory. | `<work_dir>/.lich/sessions` |
45
51
  | `--log-level <level>` | `debug` \| `info` \| `warn` \| `error`. | `info` |
46
52
  | `--theme <name>` | Display theme loaded once at startup. `lich` is built-in; other names read `~/.lich/themes/<name>.json`. | `lich` |
53
+ | `--command <bin>` | `lich mcp add` only: local stdio binary. | – |
54
+ | `--arg <value>` | `lich mcp add` only: repeatable stdio arg. May start with `--`. | – |
55
+ | `--url <url>` | `lich mcp add` only: loopback HTTP MCP URL. | – |
56
+ | `--project-path <path>` | `lich mcp add` only: catalog `${project_path}` substitute. | – |
47
57
 
48
58
  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`.
49
59
 
@@ -51,7 +61,7 @@ Passing `--max-turns 0` or a non-integer fails with `--max-turns must be a posit
51
61
 
52
62
  The effective provider for a run is decided in this order:
53
63
 
54
- 1. If `--config <path>` was passed, that file is the whole configuration (it must exist, or the CLI fails with `config not found`).
64
+ 1. If `--config <path>` was passed, that file is the whole configuration (it must exist and be a JSON object, or the CLI fails with `cannot use config file <path>: ...`).
55
65
  2. Otherwise the discovery chain is walked: `./.lich/config.json`, then `~/.config/lich/config.json`. The first file found becomes the config. `LICH_*` env vars are *not* merged into a discovered file.
56
66
  3. If no config file exists, one is built from the environment: `--provider-kind` / `LICH_PROVIDER_KIND` (default `openai_compat`), `--model` / `LICH_MODEL` (required — without it the CLI fails with `no model configured`), `--base-url` / `LICH_BASE_URL`, and `--api-key-env` / `LICH_API_KEY_ENV`, each falling back to the per-kind defaults below.
57
67
  4. Provider override flags (`--model`, `--provider-kind`, `--base-url`, `--api-key-env`) always win over the chosen source: with a config file present they patch `providers[0]` in place; without one they seed a fresh provider from the environment.
@@ -66,7 +76,7 @@ Per-kind defaults:
66
76
 
67
77
  ## Config file reference
68
78
 
69
- Validated by zod (top-level unknown keys are silently stripped; extra keys inside a `providers[]` entry are passed through). Full example with every field:
79
+ Validated by zod (top-level unknown keys are silently stripped; extra keys inside a `providers[]` entry are passed through; each `mcp_servers` entry is strict). Example:
70
80
 
71
81
  ```json
72
82
  {
@@ -114,12 +124,14 @@ Validated by zod (top-level unknown keys are silently stripped; extra keys insid
114
124
  | `providers[].think` | boolean | – | Ollama only: request thinking mode. |
115
125
  | `providers[].keep_alive` | string | – | Ollama only: model residency (e.g. `"10m"`). |
116
126
  | `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). |
127
+ | `theme` | string | `lich` | Display theme name. See [Themes](https://github.com/Moikapy/lich/blob/main/README.md#themes). |
118
128
  | `gateway` | object | omitted | Optional. `platforms` (`webhook` \| `telegram` \| `discord` \| `twitch`) and `token_envs` (platform → env-var name). Secrets stay in the environment. |
129
+ | `plugins` | string array | `[]` | Module paths relative to `work_dir` or absolute. Bare `lich`, one-shot, chat, tui, and gateway load them through `create_agent_with_plugins`. `run_agent` does too. `create_agent` does not. See the [plugins guide](plugins.md). |
130
+ | `mcp_servers` | object | omitted | Optional. Closed record of named servers. Each entry is stdio `{command, args, env?}` or loopback http `{url}`. `enabled` defaults to false. Unknown keys are rejected. Ships in 0.7.0. See the [Redot guide](redot.md). |
119
131
  | `system_prompt` | string | built-in | Replaces the default system prompt. |
120
132
  | `max_turns` | int >= 1 | `25` | Turn budget per run. |
121
133
  | `work_dir` | string | cwd | Root for all file tools; paths outside are rejected. |
122
- | `tools_enabled` | `"all"` or name array | `"all"` | Restrict the registry to these builtin tools. |
134
+ | `tools_enabled` | `"all"` or name array | `"all"` | Restrict the builtin registry to these names. `[]` drops builtins and MCP tools and does not connect to MCP servers. Plugin tools still register afterward, including the gatekeeper's `git_commit`. |
123
135
  | `temperature` | 0–2 | – | Sampling temperature. |
124
136
  | `max_tokens` | positive int | – | Completion cap. |
125
137
  | `context_budget_tokens` | positive int | `100000` | Estimated budget before compression triggers. |
@@ -148,6 +160,7 @@ never accepted as a config passthrough.
148
160
  | Variable | Meaning |
149
161
  | --- | --- |
150
162
  | `LICH_ALLOW_SELF_COMMIT` | Set to `1` to allow one gated `git_commit` per run. Unset or any other value is fail-closed. Read at agent construction. |
163
+ | `LICH_ALLOW_PRIVATE_URLS` | Set to exactly `1` to let `fetch_url` / `http_request` reach private or loopback URLs. Unset or any other value is fail-closed (they are blocked). |
151
164
  | `LICH_TEST_COMMAND` | Command `run_tests` runs in `work_dir` (default `node node_modules/vitest/vitest.mjs run`). An optional `filter` argument is appended. |
152
165
 
153
166
  Veto reasons, the terminal git denylist, skills, and `MEMORY.md` are in the
@@ -169,7 +182,7 @@ jq -r 'select(.kind=="message") | "\(.message.role): \(.message.content // "(too
169
182
 
170
183
  | Code | Meaning |
171
184
  | --- | --- |
172
- | `0` | Success: final answer produced (also `--help`, `--version`, `config`, `init`, and a TUI that exits cleanly). |
185
+ | `0` | Success: final answer produced (also `--help`, `--version`, `config`, `init`, a successful `mcp` action, `update` when nothing newer is installed or the install succeeds, and a TUI that exits cleanly). |
173
186
  | `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. |
174
187
 
175
188
  ## Log levels
@@ -4,7 +4,7 @@
4
4
 
5
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
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.
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](https://github.com/Moikapy/lich/blob/main/examples/persona_orchestrator/README.md) — a pattern the game repo copies, not a second agent core.
8
8
 
9
9
  ## Session files as combat logs
10
10