story-kit: create 174_story_matrix_chatbot_interface_for_story_kit
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user