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
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 <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/`
- [ ] `!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

View File

@@ -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);
});
});

View File

@@ -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;
}