diff --git a/.story_kit/work/1_upcoming/174_story_matrix_chatbot_interface_for_story_kit.md b/.story_kit/work/1_upcoming/174_story_matrix_chatbot_interface_for_story_kit.md index 86f1244..e233f9f 100644 --- a/.story_kit/work/1_upcoming/174_story_matrix_chatbot_interface_for_story_kit.md +++ b/.story_kit/work/1_upcoming/174_story_matrix_chatbot_interface_for_story_kit.md @@ -10,7 +10,7 @@ As a developer, I want to interact with Story Kit through a Matrix chat room so ## Background -Story Kit currently requires the web UI or direct file manipulation to manage the pipeline. A Matrix bot bridges the gap between the existing HTTP/WebSocket API and a conversational interface, enabling: +Story Kit currently requires the web UI or direct file manipulation to manage the pipeline. A Matrix bot built into the server bridges the gap between the existing internals and a conversational interface, enabling: - Mobile access (manage agents from your phone) - Group collaboration (multiple people in a room managing work together) @@ -26,35 +26,44 @@ Matrix is the right platform because: ## Architecture ``` -Matrix (Conduit) <-> Story Kit Bot (matrix-sdk) <-> Story Kit Server (HTTP API) +Matrix (Conduit) <-> Story Kit Server (matrix-sdk built in) + | + AppContext, AgentPool, watcher_tx (direct access) ``` -The bot is a Matrix client that: -1. Joins rooms (group or 1:1 DM) -2. Listens for messages (commands or natural language) -3. Calls the existing Story Kit HTTP API -4. Posts pipeline updates back to the room (sourced from WebSocket events) +The Matrix bot is built into the server process as a module (`server/src/matrix/`). It: +1. Connects to the Matrix homeserver as a bot user on server startup +2. Joins configured room(s) +3. Listens for messages (commands) +4. Calls internal functions directly (no HTTP round-trip to itself) +5. Subscribes to `watcher_tx` broadcast channel for live pipeline updates +6. Posts updates back to the room -The bot runs as a separate binary/process alongside the Story Kit server. +Benefits of building it in: +- Direct access to `AppContext`, `AgentPool`, pipeline state — no self-referential HTTP calls +- Subscribes to existing broadcast channels (`watcher_tx`, `reconciliation_tx`) for live events +- Single process to manage — no "where is the server?" configuration +- Can restart cleanly with the server ## Acceptance Criteria ### Phase 1: Core Bot Infrastructure -- [ ] A new Rust binary `story-kit-bot` that connects to a Matrix homeserver as a bot user -- [ ] Bot reads configuration from `.story_kit/bot.toml` (homeserver URL, bot user credentials, room ID(s), Story Kit server URL) +- [ ] New `server/src/matrix/` module that connects to a Matrix homeserver using `matrix-sdk` +- [ ] Bot reads configuration from `.story_kit/bot.toml` (homeserver URL, bot user credentials, room ID(s)) +- [ ] Bot connection is optional — server starts normally if `bot.toml` is missing or Matrix is disabled - [ ] Bot joins configured room(s) on startup - [ ] Bot responds to a `!status` command with current pipeline state (counts per stage) - [ ] Bot responds to `!pipeline` with a formatted list of all stories across all stages ### Phase 2: Story Management -- [ ] `!create story ` creates a new story in `1_upcoming/` via the Story Kit API +- [ ] `!create story <title>` creates a new story in `1_upcoming/` - [ ] `!create bug <title>` creates a new bug in `1_upcoming/` - [ ] `!assign <story_id>` starts a coder agent on the given story - [ ] `!stop <story_id>` stops the agent working on the given story - [ ] `!agents` lists all agents and their current status ### Phase 3: Live Updates -- [ ] Bot subscribes to Story Kit WebSocket and posts pipeline changes to the room +- [ ] Bot subscribes to `watcher_tx` broadcast channel and posts pipeline changes to the room - [ ] Agent state changes (started, completed, failed) appear as room messages - [ ] Stories moving between pipeline stages generate notifications - [ ] Messages are concise and formatted for readability (not noisy) @@ -69,14 +78,14 @@ The bot runs as a separate binary/process alongside the Story Kit server. ## Technical Notes - Use `matrix-sdk` crate for Matrix client -- Bot is a separate binary in the workspace (`bot/` directory sibling to `server/` and `frontend/`) -- Reuse existing HTTP API endpoints — no new server endpoints needed +- Module lives at `server/src/matrix/` (mod.rs + submodules as needed) +- Bot receives `Arc<AppContext>` (or relevant sub-fields) at startup to access internals directly - Configuration in `.story_kit/bot.toml` keeps bot config alongside project config -- Consider `reqwest` for HTTP calls to the Story Kit API (already in the workspace) +- Bot spawns as a `tokio::spawn` task from `main.rs`, similar to the watcher and reaper tasks ## Future Considerations - LLM-powered natural language commands ("put a coder on the auth story") - Thread-based story discussions (Matrix threads per story) - Code review in-chat (show diffs, approve/reject) -- Distributed mode: multiple bots on different machines sharing a repo +- Distributed mode: multiple servers in the same Matrix room, coordinating via git - Bridge to Signal/WhatsApp via Matrix bridges diff --git a/frontend/src/hooks/useChatHistory.test.ts b/frontend/src/hooks/useChatHistory.test.ts index 7886531..1353703 100644 --- a/frontend/src/hooks/useChatHistory.test.ts +++ b/frontend/src/hooks/useChatHistory.test.ts @@ -5,12 +5,20 @@ import { useChatHistory } from "./useChatHistory"; const PROJECT = "/tmp/test-project"; const STORAGE_KEY = `storykit-chat-history:${PROJECT}`; +const LIMIT_KEY = `storykit-chat-history-limit:${PROJECT}`; const sampleMessages: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; +function makeMessages(count: number): Message[] { + return Array.from({ length: count }, (_, i) => ({ + role: "user" as const, + content: `Message ${i + 1}`, + })); +} + describe("useChatHistory", () => { beforeEach(() => { localStorage.clear(); @@ -143,4 +151,125 @@ describe("useChatHistory", () => { expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); + + // --- Story 179: Chat history pruning tests --- + + it("S179: default limit of 200 is applied when saving to localStorage", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.maxMessages).toBe(200); + }); + + it("S179: messages are pruned from the front when exceeding the limit", () => { + // Set a small limit to make testing practical + localStorage.setItem(LIMIT_KEY, "3"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + const fiveMessages = makeMessages(5); + + act(() => { + result.current.setMessages(fiveMessages); + }); + + // localStorage should contain only the last 3 messages + const stored: Message[] = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(stored).toEqual(fiveMessages.slice(-3)); + expect(stored).toHaveLength(3); + expect(stored[0].content).toBe("Message 3"); + }); + + it("S179: messages under the limit are not pruned", () => { + localStorage.setItem(LIMIT_KEY, "10"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + const threeMessages = makeMessages(3); + + act(() => { + result.current.setMessages(threeMessages); + }); + + const stored: Message[] = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(stored).toEqual(threeMessages); + expect(stored).toHaveLength(3); + }); + + it("S179: limit is configurable via localStorage key", () => { + localStorage.setItem(LIMIT_KEY, "5"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.maxMessages).toBe(5); + }); + + it("S179: setMaxMessages updates the limit and persists it", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + + act(() => { + result.current.setMaxMessages(50); + }); + + expect(result.current.maxMessages).toBe(50); + expect(localStorage.getItem(LIMIT_KEY)).toBe("50"); + }); + + it("S179: a limit of 0 means unlimited (no pruning)", () => { + localStorage.setItem(LIMIT_KEY, "0"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + const manyMessages = makeMessages(500); + + act(() => { + result.current.setMessages(manyMessages); + }); + + const stored: Message[] = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(stored).toHaveLength(500); + expect(stored).toEqual(manyMessages); + }); + + it("S179: changing the limit re-prunes messages on next save", () => { + const { result } = renderHook(() => useChatHistory(PROJECT)); + const tenMessages = makeMessages(10); + + act(() => { + result.current.setMessages(tenMessages); + }); + + // All 10 saved (default limit 200 > 10) + let stored: Message[] = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "[]", + ); + expect(stored).toHaveLength(10); + + // Now lower the limit — the effect re-runs and prunes + act(() => { + result.current.setMaxMessages(3); + }); + + stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + expect(stored).toHaveLength(3); + expect(stored[0].content).toBe("Message 8"); + }); + + it("S179: invalid limit in localStorage falls back to default", () => { + localStorage.setItem(LIMIT_KEY, "not-a-number"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.maxMessages).toBe(200); + }); + + it("S179: negative limit in localStorage falls back to default", () => { + localStorage.setItem(LIMIT_KEY, "-5"); + + const { result } = renderHook(() => useChatHistory(PROJECT)); + + expect(result.current.maxMessages).toBe(200); + }); }); diff --git a/frontend/src/hooks/useChatHistory.ts b/frontend/src/hooks/useChatHistory.ts index 87b9cc7..0c50520 100644 --- a/frontend/src/hooks/useChatHistory.ts +++ b/frontend/src/hooks/useChatHistory.ts @@ -2,11 +2,37 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Message } from "../types"; const STORAGE_KEY_PREFIX = "storykit-chat-history:"; +const LIMIT_KEY_PREFIX = "storykit-chat-history-limit:"; +const DEFAULT_LIMIT = 200; function storageKey(projectPath: string): string { return `${STORAGE_KEY_PREFIX}${projectPath}`; } +function limitKey(projectPath: string): string { + return `${LIMIT_KEY_PREFIX}${projectPath}`; +} + +function loadLimit(projectPath: string): number { + try { + const raw = localStorage.getItem(limitKey(projectPath)); + if (raw === null) return DEFAULT_LIMIT; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_LIMIT; + return Math.floor(parsed); + } catch { + return DEFAULT_LIMIT; + } +} + +function saveLimit(projectPath: string, limit: number): void { + try { + localStorage.setItem(limitKey(projectPath), String(limit)); + } catch { + // Ignore — quota or security errors. + } +} + function loadMessages(projectPath: string): Message[] { try { const raw = localStorage.getItem(storageKey(projectPath)); @@ -19,12 +45,22 @@ function loadMessages(projectPath: string): Message[] { } } -function saveMessages(projectPath: string, messages: Message[]): void { +function pruneMessages(messages: Message[], limit: number): Message[] { + if (limit === 0 || messages.length <= limit) return messages; + return messages.slice(-limit); +} + +function saveMessages( + projectPath: string, + messages: Message[], + limit: number, +): void { try { - if (messages.length === 0) { + const pruned = pruneMessages(messages, limit); + if (pruned.length === 0) { localStorage.removeItem(storageKey(projectPath)); } else { - localStorage.setItem(storageKey(projectPath), JSON.stringify(messages)); + localStorage.setItem(storageKey(projectPath), JSON.stringify(pruned)); } } catch (e) { console.warn("Failed to persist chat history to localStorage:", e); @@ -35,15 +71,18 @@ export function useChatHistory(projectPath: string) { const [messages, setMessagesState] = useState<Message[]>(() => loadMessages(projectPath), ); + const [maxMessages, setMaxMessagesState] = useState<number>(() => + loadLimit(projectPath), + ); const projectPathRef = useRef(projectPath); // Keep the ref in sync so the effect closure always has the latest path. projectPathRef.current = projectPath; - // Persist whenever messages change. + // Persist whenever messages or limit change. useEffect(() => { - saveMessages(projectPathRef.current, messages); - }, [messages]); + saveMessages(projectPathRef.current, messages, maxMessages); + }, [messages, maxMessages]); const setMessages = useCallback( (update: Message[] | ((prev: Message[]) => Message[])) => { @@ -52,6 +91,11 @@ export function useChatHistory(projectPath: string) { [], ); + const setMaxMessages = useCallback((limit: number) => { + setMaxMessagesState(limit); + saveLimit(projectPathRef.current, limit); + }, []); + const clearMessages = useCallback(() => { setMessagesState([]); // Eagerly remove from storage so clearSession doesn't depend on the @@ -63,5 +107,11 @@ export function useChatHistory(projectPath: string) { } }, []); - return { messages, setMessages, clearMessages } as const; + return { + messages, + setMessages, + clearMessages, + maxMessages, + setMaxMessages, + } as const; }