story-kit: create 174_story_matrix_chatbot_interface_for_story_kit

This commit is contained in:
Dave
2026-02-25 11:40:09 +00:00
parent c0b882f29c
commit 48223b517d
3 changed files with 211 additions and 23 deletions

View File

@@ -10,7 +10,7 @@ As a developer, I want to interact with Story Kit through a Matrix chat room so
## Background ## 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) - Mobile access (manage agents from your phone)
- Group collaboration (multiple people in a room managing work together) - Group collaboration (multiple people in a room managing work together)
@@ -26,35 +26,44 @@ Matrix is the right platform because:
## Architecture ## 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: The Matrix bot is built into the server process as a module (`server/src/matrix/`). It:
1. Joins rooms (group or 1:1 DM) 1. Connects to the Matrix homeserver as a bot user on server startup
2. Listens for messages (commands or natural language) 2. Joins configured room(s)
3. Calls the existing Story Kit HTTP API 3. Listens for messages (commands)
4. Posts pipeline updates back to the room (sourced from WebSocket events) 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 ## Acceptance Criteria
### Phase 1: Core Bot Infrastructure ### Phase 1: Core Bot Infrastructure
- [ ] A new Rust binary `story-kit-bot` that connects to a Matrix homeserver as a bot user - [ ] 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), Story Kit server URL) - [ ] 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 joins configured room(s) on startup
- [ ] Bot responds to a `!status` command with current pipeline state (counts per stage) - [ ] 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 - [ ] Bot responds to `!pipeline` with a formatted list of all stories across all stages
### Phase 2: Story Management ### Phase 2: Story Management
- [ ] `!create story <title>` 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/` - [ ] `!create bug <title>` creates a new bug in `1_upcoming/`
- [ ] `!assign <story_id>` starts a coder agent on the given story - [ ] `!assign <story_id>` starts a coder agent on the given story
- [ ] `!stop <story_id>` stops the agent working on the given story - [ ] `!stop <story_id>` stops the agent working on the given story
- [ ] `!agents` lists all agents and their current status - [ ] `!agents` lists all agents and their current status
### Phase 3: Live Updates ### 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 - [ ] Agent state changes (started, completed, failed) appear as room messages
- [ ] Stories moving between pipeline stages generate notifications - [ ] Stories moving between pipeline stages generate notifications
- [ ] Messages are concise and formatted for readability (not noisy) - [ ] 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 ## Technical Notes
- Use `matrix-sdk` crate for Matrix client - Use `matrix-sdk` crate for Matrix client
- Bot is a separate binary in the workspace (`bot/` directory sibling to `server/` and `frontend/`) - Module lives at `server/src/matrix/` (mod.rs + submodules as needed)
- Reuse existing HTTP API endpoints — no new server endpoints 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 - 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 ## Future Considerations
- LLM-powered natural language commands ("put a coder on the auth story") - LLM-powered natural language commands ("put a coder on the auth story")
- Thread-based story discussions (Matrix threads per story) - Thread-based story discussions (Matrix threads per story)
- Code review in-chat (show diffs, approve/reject) - 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 - Bridge to Signal/WhatsApp via Matrix bridges

View File

@@ -5,12 +5,20 @@ import { useChatHistory } from "./useChatHistory";
const PROJECT = "/tmp/test-project"; const PROJECT = "/tmp/test-project";
const STORAGE_KEY = `storykit-chat-history:${PROJECT}`; const STORAGE_KEY = `storykit-chat-history:${PROJECT}`;
const LIMIT_KEY = `storykit-chat-history-limit:${PROJECT}`;
const sampleMessages: Message[] = [ const sampleMessages: Message[] = [
{ role: "user", content: "Hello" }, { role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" }, { 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", () => { describe("useChatHistory", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
@@ -143,4 +151,125 @@ describe("useChatHistory", () => {
expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 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);
});
}); });

View File

@@ -2,11 +2,37 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "../types"; import type { Message } from "../types";
const STORAGE_KEY_PREFIX = "storykit-chat-history:"; const STORAGE_KEY_PREFIX = "storykit-chat-history:";
const LIMIT_KEY_PREFIX = "storykit-chat-history-limit:";
const DEFAULT_LIMIT = 200;
function storageKey(projectPath: string): string { function storageKey(projectPath: string): string {
return `${STORAGE_KEY_PREFIX}${projectPath}`; 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[] { function loadMessages(projectPath: string): Message[] {
try { try {
const raw = localStorage.getItem(storageKey(projectPath)); 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 { try {
if (messages.length === 0) { const pruned = pruneMessages(messages, limit);
if (pruned.length === 0) {
localStorage.removeItem(storageKey(projectPath)); localStorage.removeItem(storageKey(projectPath));
} else { } else {
localStorage.setItem(storageKey(projectPath), JSON.stringify(messages)); localStorage.setItem(storageKey(projectPath), JSON.stringify(pruned));
} }
} catch (e) { } catch (e) {
console.warn("Failed to persist chat history to localStorage:", 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[]>(() => const [messages, setMessagesState] = useState<Message[]>(() =>
loadMessages(projectPath), loadMessages(projectPath),
); );
const [maxMessages, setMaxMessagesState] = useState<number>(() =>
loadLimit(projectPath),
);
const projectPathRef = useRef(projectPath); const projectPathRef = useRef(projectPath);
// Keep the ref in sync so the effect closure always has the latest path. // Keep the ref in sync so the effect closure always has the latest path.
projectPathRef.current = projectPath; projectPathRef.current = projectPath;
// Persist whenever messages change. // Persist whenever messages or limit change.
useEffect(() => { useEffect(() => {
saveMessages(projectPathRef.current, messages); saveMessages(projectPathRef.current, messages, maxMessages);
}, [messages]); }, [messages, maxMessages]);
const setMessages = useCallback( const setMessages = useCallback(
(update: Message[] | ((prev: Message[]) => Message[])) => { (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(() => { const clearMessages = useCallback(() => {
setMessagesState([]); setMessagesState([]);
// Eagerly remove from storage so clearSession doesn't depend on the // 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;
} }