diff --git a/AGENTS.md b/AGENTS.md index a4f67968..ae3efd46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ Kassiber is currently in **dev mode**: renaming commands, breaking flags, and re ## Command surface -- `init`, `status`, `daemon`, `context {show,current,set}` +- `init`, `status`, `daemon`, `chat`, `context {show,current,set}` - `secrets {init,init-resume,change-passphrase,verify,status,migrate-credentials}` - `backup {export,import}` - `workspaces {list,create}` @@ -168,7 +168,8 @@ Kassiber is currently in **dev mode**: renaming commands, breaking flags, and re - `rates {pairs,sync,rebuild,latest,range,set}` - `diagnostics {collect}` - `ai providers {list,get,create,update,delete,set-default,clear-default}` -- `ai {models,chat}` +- `ai {models,chat}` — provider/model management plus legacy provider-only chat; + use top-level `chat` for the daemon-backed tool loop with consent/cancel parity. ## Pagination @@ -336,6 +337,7 @@ uv run python -m kassiber reports export-austrian-e1kv-csv --help uv run python -m kassiber reports balance-history --help uv run python -m kassiber rates --help uv run python -m kassiber diagnostics collect --help +uv run python -m kassiber chat --help uv run python -m kassiber ai --help uv run python -m kassiber ai providers --help uv run python -m kassiber ai providers create --help diff --git a/README.md b/README.md index d93ea592..e02240ce 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,9 @@ workflow almost nobody else covers. [jlopp/physical-bitcoin-attacks](https://github.com/jlopp/physical-bitcoin-attacks) catalog covers the threats this addresses. - **Local AI Chat** — assistant defaults to local - [Ollama](https://ollama.com/); off-device providers require explicit - per-provider acknowledgement and per-tool consent. + [Ollama](https://ollama.com/); the desktop Assistant and `kassiber chat` + both use the same daemon tool loop. Off-device providers require explicit + per-provider acknowledgement and mutating tools require consent. - **AGPL 3.0** — auditable, forkable, no vendor lock-in. ## Highlights @@ -131,8 +132,9 @@ python3 -m kassiber reports summary For transfer pairing, swap matching, source-of-funds, Austrian E 1kv, BTCPay reconciliation, and the concept model, see -[docs/quickstart.md](docs/quickstart.md). Both surfaces speak the same -Python daemon, so a daily flow can move freely between them. +[docs/quickstart.md](docs/quickstart.md). The desktop GUI is optional: +the Assistant sidebar and `kassiber chat` speak the same Python daemon, so a +daily flow can move freely between them. ## Architecture diff --git a/docs/reference/ai.md b/docs/reference/ai.md index 77f073ef..f43d153b 100644 --- a/docs/reference/ai.md +++ b/docs/reference/ai.md @@ -18,8 +18,8 @@ Two surfaces ship today: assistants. - An **in-app assistant** in the desktop UI that streams chat from an OpenAI-compatible endpoint or fixed Claude/Codex CLI adapter, plus a - parallel CLI surface (`kassiber ai providers …`, `kassiber ai models`, - `kassiber ai chat`) that reuses the same provider config. + parallel CLI surface (`kassiber chat`, `kassiber ai providers …`, + `kassiber ai models`) that reuses the same provider config. The repo-local skill helps an AI assistant use the Kassiber CLI safely and correctly for: @@ -138,9 +138,23 @@ printf '%s\n' "$OPENAI_API_KEY" | kassiber ai providers create openai --base-url kassiber ai providers create claude-cli --base-url claude-cli://default --kind remote --acknowledge --default-model default kassiber ai providers set-default openai kassiber ai models -kassiber ai chat "Summarise the last week of imports." +kassiber chat "Summarise the last week of imports." +kassiber chat ``` +`kassiber chat` is the CLI client for the same daemon-backed assistant used by +the desktop UI. It starts a local daemon transport, sends `ai.chat` requests +with `tools_enabled=true`, renders streaming deltas in the terminal, and sends +`ai.tool_call.consent` decisions when mutating tools ask for approval. Omit the +prompt for REPL mode; pass a prompt positionally or with `--prompt` for one +turn. `kassiber ai chat` remains a provider-only compatibility command and +does not run the daemon tool loop. + +For automation, `kassiber chat --yes "..."` approves mutating tool requests for +that chat session without prompting. Prefer the narrower +`--allow-tool ui.journals.process` form when a script should approve only one +tool; unlisted mutating tools are denied without a TTY. + Provider API-key entry supports `--api-key-stdin` and `--api-key-fd FD`. The legacy `--api-key ` form still works as a warning-on-use compatibility shim, but docs and tests avoid it because argv can land in shell history and @@ -198,7 +212,8 @@ with phases such as `preparing`, `connecting`, and `waiting_for_model`. These records are UI progress hints only; chain-of-thought is shown only when the provider emits inline `` content or structured `reasoning` deltas. -Pressing **Stop** sends `ai.chat.cancel` with +Pressing **Stop** in the desktop UI, choosing cancel at a terminal consent +prompt, or interrupting `kassiber chat` sends `ai.chat.cancel` with `args.target_request_id = `. Cancellation is best-effort and cooperative: Kassiber stops forwarding deltas once the Python worker returns between provider chunks, then emits the terminal `ai.chat` @@ -207,7 +222,7 @@ tokens already generated or in flight may still be billed. ## Tool use -The in-app assistant can opt into a bounded tool loop with +The desktop assistant and `kassiber chat` opt into a bounded tool loop with `ai.chat` top-level args: ```json diff --git a/kassiber/cli/chat.py b/kassiber/cli/chat.py new file mode 100644 index 00000000..613ee736 --- /dev/null +++ b/kassiber/cli/chat.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import uuid +from dataclasses import dataclass, field +from typing import Any, Iterable, TextIO + +from ..errors import AppError + + +_CONSENT_DECISIONS = {"allow_once", "allow_session", "deny"} + + +@dataclass +class ChatTurnResult: + content: str + terminal: dict[str, Any] + tool_calls: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class ChatSessionResult: + provider: str | None = None + model: str | None = None + turns: list[ChatTurnResult] = field(default_factory=list) + + def to_payload(self) -> dict[str, Any]: + last = self.turns[-1] if self.turns else None + terminal_data = last.terminal.get("data", {}) if last else {} + return { + "provider": terminal_data.get("provider", self.provider), + "model": terminal_data.get("model", self.model), + "message": { + "role": "assistant", + "content": last.content if last else "", + }, + "finish_reason": terminal_data.get("finish_reason") if last else None, + "provenance": terminal_data.get("provenance") if last else None, + "tool_calls": last.tool_calls if last else [], + } + + +class _DaemonChatClient: + def __init__(self, args: Any) -> None: + self._pass_fds: tuple[int, ...] = () + self._duplicated_fd: int | None = None + command = self._daemon_command(args) + self._proc = subprocess.Popen( + command, + cwd=os.getcwd(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + pass_fds=self._pass_fds, + ) + if self._duplicated_fd is not None: + os.close(self._duplicated_fd) + self._duplicated_fd = None + ready = self.read() + if ready.get("kind") != "daemon.ready": + self.close() + raise AppError( + "daemon did not start cleanly", + code="daemon_start_failed", + details={"envelope": ready}, + retryable=False, + ) + + def _daemon_command(self, args: Any) -> list[str]: + command = [ + sys.executable, + "-m", + "kassiber", + "--data-root", + args.data_root, + ] + if getattr(args, "env_file", None): + command.extend(["--env-file", args.env_file]) + passphrase_fd = getattr(args, "db_passphrase_fd", None) + if passphrase_fd is not None: + self._duplicated_fd = os.dup(passphrase_fd) + self._pass_fds = (self._duplicated_fd,) + command.extend(["--db-passphrase-fd", str(self._duplicated_fd)]) + command.append("daemon") + return command + + def send(self, payload: dict[str, Any]) -> None: + if self._proc.stdin is None: + raise AppError( + "daemon input stream is closed", + code="daemon_closed", + retryable=False, + ) + self._proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + self._proc.stdin.flush() + + def read(self) -> dict[str, Any]: + if self._proc.stdout is None: + raise AppError( + "daemon output stream is closed", + code="daemon_closed", + retryable=False, + ) + line = self._proc.stdout.readline() + if line == "": + stderr = self._proc.stderr.read() if self._proc.stderr is not None else "" + raise AppError( + "daemon exited before chat completed", + code="daemon_closed", + details={"stderr": stderr[-2000:]}, + retryable=False, + ) + try: + payload = json.loads(line) + except json.JSONDecodeError as exc: + raise AppError( + "daemon emitted invalid JSON", + code="daemon_protocol_error", + details={"line": line[:1000], "error": str(exc)}, + retryable=False, + ) from None + if not isinstance(payload, dict): + raise AppError( + "daemon emitted a non-object JSON record", + code="daemon_protocol_error", + retryable=False, + ) + return payload + + def close(self) -> None: + if self._proc.stdin is not None and not self._proc.stdin.closed: + self._proc.stdin.close() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.terminate() + try: + self._proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait(timeout=2) + if self._proc.stdout is not None: + self._proc.stdout.close() + if self._proc.stderr is not None: + self._proc.stderr.close() + + +def _split_tool_names(values: Iterable[str] | None) -> set[str]: + names: set[str] = set() + for value in values or (): + for item in value.split(","): + stripped = item.strip() + if stripped: + names.add(stripped) + names.add(stripped.replace("_", ".")) + return names + + +def _chat_options(args: Any) -> dict[str, Any] | None: + options: dict[str, Any] = {} + if getattr(args, "temperature", None) is not None: + options["temperature"] = args.temperature + if getattr(args, "max_tokens", None) is not None: + options["max_tokens"] = args.max_tokens + effort = getattr(args, "reasoning_effort", None) + if effort and effort != "auto": + options["reasoning_effort"] = effort + return options or None + + +def _resolve_prompt(args: Any) -> str | None: + prompt = getattr(args, "prompt", None) + prompt_flag = getattr(args, "prompt_text", None) + if prompt and prompt_flag: + raise AppError( + "pass the chat prompt either positionally or with --prompt, not both", + code="validation", + retryable=False, + ) + return prompt_flag if prompt_flag is not None else prompt + + +def _build_chat_args(args: Any, messages: list[dict[str, str]]) -> dict[str, Any]: + payload: dict[str, Any] = { + "provider": getattr(args, "provider", None), + "model": args.model, + "messages": messages, + "tools_enabled": not getattr(args, "no_tools", False), + "tool_loop_max_iterations": getattr(args, "tool_loop_max_iterations", 8), + "system_prompt_kind": "kassiber", + } + options = _chat_options(args) + if options: + payload["options"] = options + return payload + + +def _provider_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + data = payload.get("data") + if isinstance(data, dict) and isinstance(data.get("providers"), list): + return [row for row in data["providers"] if isinstance(row, dict)] + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + return [] + + +def _read_control_response(client: _DaemonChatClient, request_id: str) -> dict[str, Any]: + while True: + record = client.read() + if record.get("request_id") != request_id: + continue + if record.get("kind") == "error": + error = record.get("error", {}) + raise AppError( + error.get("message", "daemon request failed"), + code=error.get("code", "daemon_request_failed"), + details=error.get("details"), + hint=error.get("hint"), + retryable=bool(error.get("retryable")), + ) + return record + + +def _resolve_default_model(client: _DaemonChatClient, args: Any) -> None: + if getattr(args, "model", None): + return + request_id = f"chat-provider-list-{uuid.uuid4().hex}" + client.send({"request_id": request_id, "kind": "ai.providers.list", "args": {}}) + rows = _provider_rows(_read_control_response(client, request_id)) + selected: dict[str, Any] | None = None + provider_name = getattr(args, "provider", None) + if provider_name: + selected = next((row for row in rows if row.get("name") == provider_name), None) + else: + selected = next((row for row in rows if row.get("is_default")), None) + if selected is None: + raise AppError( + "AI chat requires a provider", + code="validation", + hint="Set a default provider or pass --provider.", + retryable=False, + ) + model = selected.get("default_model") + if not isinstance(model, str) or not model.strip(): + name = selected.get("name") or provider_name or "selected provider" + raise AppError( + "AI chat requires a model", + code="validation", + hint=f"Pass --model, or set --default-model on provider '{name}'.", + retryable=False, + ) + args.model = model + + +def _write(text: str, out: TextIO) -> None: + out.write(text) + out.flush() + + +def _interactive_consent( + *, + name: str, + summary: str, + arguments_preview: dict[str, Any], + stdin: TextIO, + out: TextIO, +) -> str: + _write(f"\nConsent required: {summary or name}\n", out) + _write(f"Tool: {name}\n", out) + if arguments_preview: + _write( + "Arguments:\n" + + json.dumps(arguments_preview, indent=2, sort_keys=True) + + "\n", + out, + ) + while True: + _write("Allow? [y] once, [s] session, [n] deny, [c] cancel: ", out) + choice = stdin.readline() + if choice == "": + return "deny" + normalized = choice.strip().lower() + if normalized in {"y", "yes"}: + return "allow_once" + if normalized in {"s", "session"}: + return "allow_session" + if normalized in {"n", "no", "deny"}: + return "deny" + if normalized in {"c", "cancel"}: + return "cancel" + + +def _policy_decision(args: Any, tool_name: str, stdin: TextIO) -> str | None: + allow_tools = _split_tool_names(getattr(args, "allow_tool", None)) + if getattr(args, "yes", False): + return "allow_session" + if tool_name in allow_tools: + return "allow_session" + if not stdin.isatty(): + return "deny" + return None + + +def _send_consent( + client: _DaemonChatClient, + *, + target_request_id: str, + call_id: str, + decision: str, +) -> str: + request_id = f"chat-consent-{uuid.uuid4().hex}" + client.send( + { + "request_id": request_id, + "kind": "ai.tool_call.consent", + "args": { + "target_request_id": target_request_id, + "call_id": call_id, + "decision": decision, + }, + } + ) + return request_id + + +def _send_cancel(client: _DaemonChatClient, target_request_id: str) -> str: + request_id = f"chat-cancel-{uuid.uuid4().hex}" + client.send( + { + "request_id": request_id, + "kind": "ai.chat.cancel", + "args": {"target_request_id": target_request_id}, + } + ) + return request_id + + +def _run_turn( + client: _DaemonChatClient, + args: Any, + messages: list[dict[str, str]], + *, + stdin: TextIO, + out: TextIO, + render: bool, +) -> ChatTurnResult: + request_id = f"chat-{uuid.uuid4().hex}" + client.send( + { + "request_id": request_id, + "kind": "ai.chat", + "args": _build_chat_args(args, messages), + } + ) + content_parts: list[str] = [] + tool_calls: dict[str, dict[str, Any]] = {} + control_requests: set[str] = set() + try: + while True: + record = client.read() + kind = record.get("kind") + record_request_id = record.get("request_id") + if record_request_id in control_requests: + if kind == "error": + raise AppError( + record.get("error", {}).get("message", "chat control failed"), + code=record.get("error", {}).get("code", "chat_control_failed"), + details=record.get("error", {}).get("details"), + hint=record.get("error", {}).get("hint"), + retryable=bool(record.get("error", {}).get("retryable")), + ) + continue + if record_request_id != request_id: + continue + if kind == "error": + error = record.get("error", {}) + raise AppError( + error.get("message", "chat failed"), + code=error.get("code", "chat_failed"), + details=error.get("details"), + hint=error.get("hint"), + retryable=bool(error.get("retryable")), + ) + data = record.get("data") if isinstance(record.get("data"), dict) else {} + if kind == "ai.chat.status": + if render and data.get("label"): + _write(f"\n{data['label']}...\n", out) + elif kind == "ai.chat.delta": + delta = data.get("delta") if isinstance(data.get("delta"), dict) else {} + visible = delta.get("content") + if isinstance(visible, str) and visible: + content_parts.append(visible) + if render: + _write(visible, out) + elif kind == "ai.chat.tool_call": + call_id = data.get("call_id") + if isinstance(call_id, str): + tool_calls[call_id] = { + "call_id": call_id, + "name": data.get("name"), + "arguments": data.get("arguments") or {}, + "kind_class": data.get("kind_class", "unknown"), + "needs_consent": bool(data.get("needs_consent")), + "status": "awaiting_consent" + if data.get("needs_consent") + else "running", + } + if render: + suffix = " (needs consent)" if data.get("needs_consent") else "" + _write(f"\nTool: {data.get('name', 'unknown')}{suffix}\n", out) + elif kind == "ai.chat.tool_consent_required": + call_id = data.get("call_id") + name = data.get("name") + if not isinstance(call_id, str) or not isinstance(name, str): + continue + decision = _policy_decision(args, name, stdin) + if decision is None: + decision = _interactive_consent( + name=name, + summary=str(data.get("summary") or name), + arguments_preview=data.get("arguments_preview") or {}, + stdin=stdin, + out=out, + ) + if decision == "cancel": + control_requests.add(_send_cancel(client, request_id)) + elif decision in _CONSENT_DECISIONS: + control_requests.add( + _send_consent( + client, + target_request_id=request_id, + call_id=call_id, + decision=decision, + ) + ) + else: + raise AppError( + "invalid consent decision", + code="validation", + details={"decision": decision}, + retryable=False, + ) + elif kind == "ai.chat.tool_result": + call_id = data.get("call_id") + if isinstance(call_id, str): + existing = tool_calls.setdefault( + call_id, + { + "call_id": call_id, + "name": "Tool", + "arguments": {}, + "kind_class": "unknown", + "needs_consent": False, + }, + ) + existing["status"] = "done" if data.get("ok") else "denied" + existing["reason"] = data.get("reason") + existing["ok"] = bool(data.get("ok")) + if render and not data.get("ok") and data.get("reason"): + _write(f"\nTool result: {data['reason']}\n", out) + elif kind == "ai.chat": + if render: + _write("\n", out) + return ChatTurnResult( + content="".join(content_parts), + terminal=record, + tool_calls=list(tool_calls.values()), + ) + except KeyboardInterrupt: + _send_cancel(client, request_id) + raise + + +def run_chat_command( + args: Any, + *, + stdin: TextIO | None = None, + stdout: TextIO | None = None, +) -> ChatSessionResult: + input_stream = stdin or sys.stdin + output_stream = stdout or sys.stdout + one_shot_prompt = _resolve_prompt(args) + if getattr(args, "format", None) == "json" and one_shot_prompt is None: + raise AppError( + "--machine chat requires a one-shot prompt", + code="validation", + retryable=False, + ) + if one_shot_prompt is None and not input_stream.isatty(): + raise AppError( + "interactive chat requires a TTY; pass a prompt for one-shot mode", + code="validation", + hint="Use `kassiber chat \"...\"` or `kassiber chat --prompt \"...\"`.", + retryable=False, + ) + + client = _DaemonChatClient(args) + try: + _resolve_default_model(client, args) + session = ChatSessionResult( + provider=getattr(args, "provider", None), + model=args.model, + ) + messages: list[dict[str, str]] = [] + render = getattr(args, "format", None) != "json" + try: + if one_shot_prompt is not None: + messages.append({"role": "user", "content": one_shot_prompt}) + result = _run_turn( + client, + args, + messages, + stdin=input_stream, + out=output_stream, + render=render, + ) + session.turns.append(result) + messages.append({"role": "assistant", "content": result.content}) + return session + + _write("Kassiber chat. Type /exit to quit.\n", output_stream) + while True: + _write("> ", output_stream) + prompt = input_stream.readline() + if prompt == "": + break + prompt = prompt.strip() + if not prompt: + continue + if prompt in {"/exit", "/quit"}: + break + messages.append({"role": "user", "content": prompt}) + result = _run_turn( + client, + args, + messages, + stdin=input_stream, + out=output_stream, + render=True, + ) + session.turns.append(result) + messages.append({"role": "assistant", "content": result.content}) + return session + except KeyboardInterrupt: + if render: + _write("\nCancelled.\n", output_stream) + raise AppError("chat cancelled", code="cancelled", retryable=False) from None + finally: + client.close() diff --git a/kassiber/cli/main.py b/kassiber/cli/main.py index f60face9..8931acda 100644 --- a/kassiber/cli/main.py +++ b/kassiber/cli/main.py @@ -118,6 +118,7 @@ ) from ..tax_policy import supported_tax_countries from ..wallet_descriptors import MAX_DESCRIPTOR_GAP_LIMIT +from .chat import run_chat_command _AI_PROVIDER_KINDS_LIST = AI_PROVIDER_KINDS @@ -457,6 +458,48 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("init") sub.add_parser("status") + chat = sub.add_parser( + "chat", + description="Interactive daemon-backed Kassiber AI assistant.", + ) + chat.add_argument("prompt", nargs="?", help="One-shot prompt. Omit for REPL mode.") + chat.add_argument("--prompt", dest="prompt_text", help="One-shot prompt text.") + chat.add_argument("--provider", help="Provider name (defaults to the stored default)") + chat.add_argument("--model", help="Model id (defaults to the provider's default_model)") + chat.add_argument("--temperature", type=float) + chat.add_argument("--max-tokens", type=int) + chat.add_argument( + "--reasoning-effort", + choices=("auto", "low", "medium", "high"), + default="auto", + help="Forward a provider-specific reasoning effort option when supported.", + ) + chat.add_argument( + "--tool-loop-max-iterations", + type=int, + default=8, + help="Maximum daemon tool-loop iterations for one assistant turn.", + ) + chat.add_argument( + "--no-tools", + action="store_true", + help="Disable daemon AI tools for this chat.", + ) + chat.add_argument( + "--yes", + action="store_true", + help="Non-interactively allow mutating AI tools for this chat session.", + ) + chat.add_argument( + "--allow-tool", + action="append", + help=( + "Non-interactively allow only this mutating tool name; repeat or " + "pass comma-separated names. Other mutating tools still prompt on a TTY " + "or deny without one." + ), + ) + add_secrets_parser(sub) add_backup_parser(sub) @@ -1743,6 +1786,11 @@ def build_parser() -> argparse.ArgumentParser: def dispatch(conn: sqlite3.Connection | None, args: argparse.Namespace) -> Any: if args.command == "daemon": return daemon_runtime.run(conn, args) + if args.command == "chat": + result = run_chat_command(args) + if args.format == "json": + return emit(args, result.to_payload(), kind="chat") + return None if args.command == "init": return cmd_init(conn, args) if args.command == "status": @@ -3306,6 +3354,8 @@ def dispatch(conn: sqlite3.Connection | None, args: argparse.Namespace) -> Any: def command_needs_db(args: argparse.Namespace) -> bool: if args.command == "daemon": return False + if args.command == "chat": + return False if args.command == "backends" and getattr(args, "backends_command", None) == "kinds": return False if args.command == "wallets" and getattr(args, "wallets_command", None) == "kinds": diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index 70d6fcb7..c03cc28c 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -42,6 +42,7 @@ run py -m unittest tests.test_report_contract_drift -v run py -m unittest tests.test_homebrew_cask -v run py -m unittest tests.test_btcpay_commercial_provenance -v run py -m unittest tests.test_core_maintenance -v +run py -m unittest tests.test_cli_chat -v run py -m unittest tests.test_cli_smoke -v run py -m unittest tests.test_rp2_packaging -v run py -m unittest tests.test_source_funds_cli -v @@ -84,6 +85,7 @@ smoke_py -m kassiber reports export-source-funds-pdf --help >/dev/null smoke_py -m kassiber reports balance-history --help >/dev/null smoke_py -m kassiber rates --help >/dev/null smoke_py -m kassiber diagnostics collect --help >/dev/null +smoke_py -m kassiber chat --help >/dev/null smoke_py -m kassiber ai --help >/dev/null smoke_py -m kassiber ai providers --help >/dev/null smoke_py -m kassiber ai providers create --help >/dev/null diff --git a/skills/kassiber/SKILL.md b/skills/kassiber/SKILL.md index 7a2a4b64..6a4e2b7a 100644 --- a/skills/kassiber/SKILL.md +++ b/skills/kassiber/SKILL.md @@ -25,6 +25,7 @@ Use these without opening extra references when the request clearly matches: | Largest outbound transactions | `kassiber --machine transactions list --direction outbound --sort amount --order desc --limit 10` | | Smallest inbound transactions | `kassiber --machine transactions list --direction inbound --sort amount --order asc --limit 10` | | Smallest outbound transactions | `kassiber --machine transactions list --direction outbound --sort amount --order asc --limit 10` | +| Ask the in-product assistant with tools | `kassiber chat ""` | If a fast-path command returns a structured error, inspect the envelope and take the hinted next step. For example, stale reports usually mean running `kassiber --machine journals process` once, then retrying the same report. @@ -68,6 +69,8 @@ If a fast-path command returns a structured error, inspect the envelope and take 36. `kassiber secrets init` is a one-time migration from plaintext to SQLCipher. After it runs, the original plaintext file is preserved as `kassiber.pre-encryption.sqlite3.bak`; Kassiber refuses to overwrite an existing rollback file at that path. Advise the user to verify the encrypted DB opens (`kassiber secrets verify`) and then `rm` the `.bak` themselves once they trust the new file. Forgetting the passphrase means data loss — there is no recovery path and `.kassiber` backups do not help. 37. `.kassiber` backup files are `tar | age` envelopes that wrap a SQLCipher copy of the DB plus the attachments tree and `backends.env`. They are recoverable with stock `age` + `tar` + `sqlcipher` even without Kassiber installed. Backup decryption uses an outer age passphrase (`--backup-passphrase-fd`) that is independent of the DB passphrase. 38. The dotenv bootstrap (`backends.env`) is for non-secret addressing only: URLs, `KIND`, chain, network, batch sizes. Tokens, passwords, auth headers, and basic-auth usernames belong in the encrypted DB. If Kassiber warns at startup that the dotenv still has plaintext secrets, run `kassiber secrets migrate-credentials` (or `--dry-run` first) to lift them into the encrypted backends table; the file is rewritten with non-secret rows preserved and a `.pre-credentials-migration-.bak` snapshot is saved. +39. Use `kassiber chat` for the daemon-backed assistant when the user wants tool-aware AI help from the CLI. It uses the same `ai.chat` / `ai.tool_call.consent` loop as the GUI; `kassiber ai chat` is provider-only and should not be described as tool-capable. +40. For scripted `kassiber chat` runs, prefer `--allow-tool ` over broad `--yes`. Without a TTY, unapproved mutating tool requests are denied and fed back to the model as `user_denied`. ## Gotchas diff --git a/skills/kassiber/references/command-templates.md b/skills/kassiber/references/command-templates.md index 4b7cd545..ab67832f 100644 --- a/skills/kassiber/references/command-templates.md +++ b/skills/kassiber/references/command-templates.md @@ -17,6 +17,25 @@ kassiber --format csv --output capital-gains.csv reports capital-gains Do not append `--machine` or `--format` after the subcommand tree. +## AI chat + +Use top-level `chat` for the daemon-backed assistant: + +```bash +kassiber chat "Summarise report blockers and suggest next actions." +kassiber chat +kassiber chat --allow-tool ui.journals.process "Refresh journals, then summarize blockers." +kassiber chat --yes "Sync allowed wallets and summarize what changed." +``` + +`kassiber chat` mirrors the desktop Assistant and drives daemon `ai.chat`, +`ai.tool_call.consent`, and `ai.chat.cancel`. Mutating tools prompt on a TTY. +For scripts, `--allow-tool ` approves only that tool; +`--yes` approves all mutating tools for the chat session. Without a TTY and +without an allow policy, mutating tool requests are denied and fed back to the +model. Do not use `kassiber ai chat` when tool use, stale-journal refresh, +consent, or cancellation are required; that command is provider-only. + ## Fast paths Common requests should not require exploratory commands: diff --git a/tests/test_cli_chat.py b/tests/test_cli_chat.py new file mode 100644 index 00000000..9c94892f --- /dev/null +++ b/tests/test_cli_chat.py @@ -0,0 +1,305 @@ +import json +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent + + +def _chat_completion_response(message, finish_reason="stop"): + return { + "choices": [ + { + "message": message, + "finish_reason": finish_reason, + } + ] + } + + +def _tool_call_message(name, arguments="{}", call_id="call_1"): + return _chat_completion_response( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + }, + finish_reason="tool_calls", + ) + + +class _ToolChatHandler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/v1/chat/completions": + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("content-length") or "0") + body = json.loads(self.rfile.read(length).decode("utf-8")) if length else {} + self.server.requests.append(body) # type: ignore[attr-defined] + try: + payload = self.server.responses.pop(0) # type: ignore[attr-defined] + except IndexError: + payload = _chat_completion_response({"role": "assistant", "content": "done"}) + if body.get("stream"): + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.send_header("cache-control", "no-cache") + self.end_headers() + choice = payload["choices"][0] + message = choice["message"] + delta = {} + if "tool_calls" in message: + delta["tool_calls"] = message["tool_calls"] + if message.get("content"): + delta["content"] = message["content"] + chunk = { + "choices": [ + { + "delta": delta, + "finish_reason": choice.get("finish_reason"), + } + ] + } + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + raw = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def log_message(self, *args): + return + + +def _start_tool_chat_server(responses): + server = ThreadingHTTPServer(("127.0.0.1", 0), _ToolChatHandler) + server.responses = list(responses) # type: ignore[attr-defined] + server.requests = [] # type: ignore[attr-defined] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + + +def _stop_server(server): + server.shutdown() + server.server_close() + + +def _run(data_root, *args): + return subprocess.run( + [ + sys.executable, + "-m", + "kassiber", + "--data-root", + str(data_root), + *args, + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + + +def _run_json(data_root, *args): + result = _run(data_root, "--machine", *args) + if result.returncode != 0: + raise AssertionError( + f"command failed: {args}; stdout={result.stdout}; stderr={result.stderr}" + ) + return json.loads(result.stdout) + + +def _seed_provider(data_root, base_url): + _run_json(data_root, "init") + _run_json(data_root, "workspaces", "create", "Demo") + _run_json(data_root, "profiles", "create", "Main") + _run_json( + data_root, + "ai", + "providers", + "create", + "tool-local", + "--base-url", + base_url, + "--kind", + "local", + "--default-model", + "test-model", + ) + _run_json(data_root, "ai", "providers", "set-default", "tool-local") + + +class CliChatTest(unittest.TestCase): + def test_chat_runs_daemon_tool_loop(self): + server = _start_tool_chat_server( + [ + _tool_call_message("status"), + _chat_completion_response( + {"role": "assistant", "content": "Kassiber is ready."}, + ), + ] + ) + try: + with tempfile.TemporaryDirectory(prefix="kassiber-chat-") as tmp: + data_root = Path(tmp) / "data" + _seed_provider(data_root, f"http://127.0.0.1:{server.server_port}/v1") + payload = _run_json( + data_root, + "chat", + "--provider", + "tool-local", + "Check local status", + ) + finally: + _stop_server(server) + + self.assertEqual(payload["kind"], "chat") + self.assertEqual(payload["data"]["message"]["content"], "Kassiber is ready.") + self.assertEqual(payload["data"]["tool_calls"][0]["name"], "status") + self.assertEqual(payload["data"]["tool_calls"][0]["status"], "done") + self.assertEqual(len(server.requests), 2) # type: ignore[attr-defined] + self.assertTrue( + any( + message.get("role") == "tool" + for message in server.requests[1]["messages"] # type: ignore[attr-defined] + ) + ) + + def test_chat_yes_approves_mutating_consent(self): + server = _start_tool_chat_server( + [ + _tool_call_message("ui_journals_process"), + _chat_completion_response( + {"role": "assistant", "content": "Journals processed."}, + ), + ] + ) + try: + with tempfile.TemporaryDirectory(prefix="kassiber-chat-") as tmp: + data_root = Path(tmp) / "data" + _seed_provider(data_root, f"http://127.0.0.1:{server.server_port}/v1") + payload = _run_json( + data_root, + "chat", + "--provider", + "tool-local", + "--yes", + "Process journals", + ) + finally: + _stop_server(server) + + tool = next( + item + for item in payload["data"]["tool_calls"] + if item["name"] == "ui.journals.process" + ) + self.assertEqual(tool["status"], "done") + self.assertNotEqual(tool.get("reason"), "user_denied") + self.assertTrue( + any( + message.get("role") == "tool" + and "user_denied" not in message.get("content", "") + for message in server.requests[1]["messages"] # type: ignore[attr-defined] + ) + ) + + def test_chat_allow_tool_approves_only_listed_mutating_tool(self): + server = _start_tool_chat_server( + [ + _tool_call_message("ui_journals_process"), + _chat_completion_response( + {"role": "assistant", "content": "Journals processed."}, + ), + ] + ) + try: + with tempfile.TemporaryDirectory(prefix="kassiber-chat-") as tmp: + data_root = Path(tmp) / "data" + _seed_provider(data_root, f"http://127.0.0.1:{server.server_port}/v1") + payload = _run_json( + data_root, + "chat", + "--provider", + "tool-local", + "--allow-tool", + "ui.journals.process", + "Process journals", + ) + finally: + _stop_server(server) + + tool = next( + item + for item in payload["data"]["tool_calls"] + if item["name"] == "ui.journals.process" + ) + self.assertEqual(tool["status"], "done") + self.assertTrue( + any( + message.get("role") == "tool" + and "user_denied" not in message.get("content", "") + for message in server.requests[1]["messages"] # type: ignore[attr-defined] + ) + ) + + def test_chat_non_tty_denies_mutating_consent_without_allow_policy(self): + server = _start_tool_chat_server( + [ + _tool_call_message("ui_journals_process"), + _chat_completion_response( + {"role": "assistant", "content": "I did not process journals."}, + ), + ] + ) + try: + with tempfile.TemporaryDirectory(prefix="kassiber-chat-") as tmp: + data_root = Path(tmp) / "data" + _seed_provider(data_root, f"http://127.0.0.1:{server.server_port}/v1") + payload = _run_json( + data_root, + "chat", + "--provider", + "tool-local", + "Process journals", + ) + finally: + _stop_server(server) + + tool = next( + item + for item in payload["data"]["tool_calls"] + if item["name"] == "ui.journals.process" + ) + self.assertEqual(tool["status"], "denied") + self.assertEqual(tool["reason"], "user_denied") + self.assertTrue( + any( + message.get("role") == "tool" + and "user_denied" in message.get("content", "") + for message in server.requests[1]["messages"] # type: ignore[attr-defined] + ) + ) + + +if __name__ == "__main__": + unittest.main()