diff --git a/packages/store/package.json b/packages/store/package.json index e9e891f3..3a1c0c1a 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -8,13 +8,9 @@ "./*": "./src/*.ts" }, "dependencies": { - "@multica/sdk": "workspace:*", - "react": "catalog:", - "uuid": "^13.0.0", - "zustand": "catalog:" + "@multica/sdk": "workspace:*" }, "devDependencies": { - "@types/react": "catalog:", "typescript": "catalog:" } } diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts deleted file mode 100644 index bd6893f1..00000000 --- a/packages/store/src/connection-store.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Connection Store - manages WebSocket connection lifecycle - * - * Responsibilities: - * 1. Persist deviceId (auto-generated on first run, restored from localStorage) - * 2. Establish WebSocket connection to Gateway using connection code (from QR/paste) - * 3. Maintain connection state (disconnected → connecting → connected → registered) - * 4. Route incoming stream messages from Hub to MessagesStore - * 5. Provide send() for MessagesStore to send messages - * - * Data flow: - * connection code → connect() → GatewayClient(Socket.io) → Gateway server - * ↓ - * onMessage callback → MessagesStore - */ -import { create } from "zustand" -import { persist } from "zustand/middleware" -import { v7 as uuidv7 } from "uuid" -import { - GatewayClient, - StreamAction, - type ConnectionState, - type StreamPayload, - type AgentEvent, - type CompactionEndEvent, - type GetAgentMessagesResult, - type ContentBlock, -} from "@multica/sdk" -import { useMessagesStore, type Message } from "./messages" -import { clearConnection, type ConnectionInfo } from "./connection" - -interface ConnectionStoreState { - deviceId: string - gatewayUrl: string | null - hubId: string | null - agentId: string | null - connectionState: ConnectionState - lastError: { code: string; message: string } | null - /** Whether the current connection required Owner approval (new device) */ - isNewDevice: boolean | null -} - -interface ConnectionStoreActions { - connect: (code: ConnectionInfo) => void - disconnect: () => void - send: (to: string, action: string, payload: unknown) => void -} - -export type ConnectionStore = ConnectionStoreState & ConnectionStoreActions - -// Module-level singleton — only one WebSocket connection per app -let client: GatewayClient | null = null - -/** - * Create a GatewayClient and bind message-handling callbacks. - * - * GatewayClient is defined in packages/sdk/src/client.ts - * It wraps Socket.io and exposes: - * - connect() establish WebSocket connection - * - disconnect() tear down connection - * - send(to, action, payload) send message to a specific device - * - request(to, method, params) send RPC request and await response - * - onStateChange(cb) listen for connection state changes - * - onMessage(cb) listen for incoming messages - * - onSendError(cb) listen for send failures - * - isRegistered / isConnected connection state checks - * - * Connection requires two params: - * - url: Gateway server address (from connection code's gateway field) - * - deviceId: unique device identifier (persisted in this store) - * - * Sending messages requires two routing params: - * - hubId: which Hub to send to (from connection code) - * - agentId: which Agent within the Hub (from connection code) - */ -function createClient( - url: string, - deviceId: string, - hubId: string, - token: string, - set: (s: Partial) => void, - getState: () => ConnectionStoreState, -): GatewayClient { - return new GatewayClient({ - url, - deviceId, - deviceType: "client", - hubId, - token, - }) - // Sync connection state changes to the store - .onStateChange((connectionState) => { - set({ connectionState }) - // Fetch message history after successful registration - if (connectionState === "registered") { - void fetchHistory(getState()) - } - }) - // Route incoming messages to MessagesStore - .onMessage((msg) => { - // Streaming messages: Agent replies arrive in chunks - if (msg.action === StreamAction) { - const payload = msg.payload as StreamPayload - const store = useMessagesStore.getState() - const { event } = payload - - switch (event.type) { - case "message_start": { - store.startStream(payload.streamId, payload.agentId) - const content = extractContent(event) - if (content.length) store.appendStream(payload.streamId, content) - break - } - case "message_update": { - const content = extractContent(event) - store.appendStream(payload.streamId, content) - break - } - case "message_end": { - const content = extractContent(event) - const stopReason = "message" in event - ? (event.message as { stopReason?: string })?.stopReason - : undefined - store.endStream(payload.streamId, content, stopReason) - break - } - case "tool_execution_start": { - store.startToolExecution( - payload.agentId, - event.toolCallId, - event.toolName, - event.args, - ) - break - } - case "tool_execution_end": { - store.endToolExecution( - event.toolCallId, - event.result, - event.isError, - ) - break - } - case "tool_execution_update": - // Partial results — not rendered yet, ignored for now - break - case "compaction_start": { - store.startCompaction() - break - } - case "compaction_end": { - const evt = event as CompactionEndEvent - store.endCompaction({ - removed: evt.removed, - kept: evt.kept, - tokensRemoved: evt.tokensRemoved, - tokensKept: evt.tokensKept, - reason: evt.reason, - }) - break - } - } - return - } - - // Handle error messages from Hub (e.g. UNAUTHORIZED) - if (msg.action === "error") { - const payload = msg.payload as { code: string; message: string } - set({ lastError: { code: payload.code, message: payload.message } }) - return - } - - // Handle direct (non-streaming) messages - const payload = msg.payload as { agentId?: string; content?: string } - if (payload?.agentId && payload?.content) { - useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) - } - }) - .onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false })) - .onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } })) - .onSendError((error) => set({ lastError: { code: error.code, message: error.error } })) -} - -/** Fetch message history from Hub via RPC after connection is established */ -async function fetchHistory(state: ConnectionStoreState): Promise { - const { hubId, agentId } = state - if (!client || !hubId || !agentId) return - - try { - const result = await client.request( - hubId, "getAgentMessages", { agentId, limit: 200 }, - ) - - // Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks - const toolCallArgsMap = new Map }>() - for (const m of result.messages) { - if (m.role === "assistant") { - for (const block of m.content) { - if (block.type === "toolCall") { - toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments }) - } - } - } - } - - // Mirror the backend message array directly - const messages: Message[] = [] - for (const m of result.messages) { - if (m.role === "user") { - messages.push({ - id: uuidv7(), - role: "user", - content: toContentBlocks(m.content), - agentId, - }) - } else if (m.role === "assistant") { - messages.push({ - id: uuidv7(), - role: "assistant", - content: toContentBlocks(m.content), - agentId, - stopReason: m.stopReason, - }) - } else if (m.role === "toolResult") { - const callInfo = toolCallArgsMap.get(m.toolCallId) - messages.push({ - id: uuidv7(), - role: "toolResult", - content: toContentBlocks(m.content), - agentId, - toolCallId: m.toolCallId, - toolName: m.toolName, - toolArgs: callInfo?.args, - toolStatus: m.isError ? "error" : "success", - isError: m.isError, - }) - } - } - - if (messages.length > 0) { - useMessagesStore.getState().loadMessages(messages) - } - } catch { - // History fetch is best-effort — connection still works without it - } -} - -/** Convert raw backend content (string or block array) to ContentBlock[] */ -function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] { - if (typeof content === "string") { - return content ? [{ type: "text", text: content }] : [] - } - if (Array.isArray(content)) return content - return [] -} - -/** Extract content blocks from an AgentEvent that carries a message */ -function extractContent(event: AgentEvent): ContentBlock[] { - if (!("message" in event)) return [] - const msg = event.message - if (!msg || !("content" in msg)) return [] - const content = msg.content - return Array.isArray(content) ? content as ContentBlock[] : [] -} - -export const useConnectionStore = create()( - persist( - (set, get) => ({ - deviceId: uuidv7(), - gatewayUrl: null, - hubId: null, - agentId: null, - connectionState: "disconnected", - lastError: null, - isNewDevice: null, - - // Connect using a connection code (disconnect existing connection first) - connect: (code) => { - if (client) { - client.disconnect() - client = null - } - - set({ - gatewayUrl: code.gateway, - hubId: code.hubId, - agentId: code.agentId, - }) - - client = createClient(code.gateway, get().deviceId, code.hubId, code.token, set, get) - client.connect() - }, - - // Disconnect and clear all state (messages + saved connection code) - disconnect: () => { - if (client) { - client.disconnect() - client = null - } - useMessagesStore.getState().clearMessages() - clearConnection() - set({ - connectionState: "disconnected", - gatewayUrl: null, - hubId: null, - agentId: null, - lastError: null, - isNewDevice: null, - }) - }, - - // Send a message to a target device (called by MessagesStore.sendMessage) - send: (to, action, payload) => { - if (!client?.isRegistered) return - client.send(to, action, payload) - }, - }), - { - name: "multica-device", - // Only persist deviceId — other fields are runtime state - partialize: (state) => ({ deviceId: state.deviceId }), - }, - ), -) diff --git a/packages/store/src/connection.ts b/packages/store/src/connection.ts index 42e4f067..ca820c0d 100644 --- a/packages/store/src/connection.ts +++ b/packages/store/src/connection.ts @@ -1,5 +1,3 @@ -const STORAGE_KEY = "multica-connection" - export interface ConnectionInfo { type: "multica-connect" gateway: string @@ -88,29 +86,3 @@ export function parseConnectionCode(input: string): ConnectionInfo { return parsed } - -export function saveConnection(info: ConnectionInfo): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(info)) -} - -export function loadConnection(): ConnectionInfo | null { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - - try { - const info = JSON.parse(raw) - if (!isConnectionInfo(info)) return null - if (isExpired(info.expires)) { - localStorage.removeItem(STORAGE_KEY) - return null - } - return info - } catch { - localStorage.removeItem(STORAGE_KEY) - return null - } -} - -export function clearConnection(): void { - localStorage.removeItem(STORAGE_KEY) -} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73c86225..a048368d 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,7 +1,3 @@ -export { useConnectionStore } from "./connection-store" -export type { ConnectionStore } from "./connection-store" -export { useAutoConnect } from "./use-auto-connect" -export { useMessagesStore } from "./messages" -export type { Message, MessagesStore, SendContext, ToolStatus, CompactionStats } from "./messages" -export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection" +export type { Message, ToolStatus } from "./types" +export { parseConnectionCode } from "./connection" export type { ConnectionInfo } from "./connection" diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts deleted file mode 100644 index c887e45f..00000000 --- a/packages/store/src/messages.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Messages Store - manages chat messages and streaming state - * - * Data model mirrors the backend (pi-ai / pi-agent-core) exactly: - * - UserMessage: { role: "user", content: ContentBlock[] } - * - AssistantMessage: { role: "assistant", content: ContentBlock[] } - * - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError } - * - * Streaming simply updates the content of the current assistant message in-place. - * Tool execution events (start/end) create / update toolResult messages. - */ -import { create } from "zustand" -import { v7 as uuidv7 } from "uuid" -import type { ContentBlock } from "@multica/sdk" - -export type ToolStatus = "running" | "success" | "error" | "interrupted" - -export interface CompactionStats { - removed: number - kept: number - tokensRemoved?: number - tokensKept?: number - reason: string -} - -export interface Message { - id: string - role: "user" | "assistant" | "toolResult" - content: ContentBlock[] - agentId: string - // AssistantMessage metadata - stopReason?: string - // ToolResult fields (only when role === "toolResult") - toolCallId?: string - toolName?: string - toolArgs?: Record - toolStatus?: ToolStatus - isError?: boolean -} - -/** Parameters needed to route a message through the gateway */ -export interface SendContext { - hubId: string - agentId: string - send: (to: string, action: string, payload: unknown) => void -} - -interface MessagesState { - messages: Message[] - streamingIds: Set - compacting: boolean - lastCompaction: CompactionStats | null -} - -interface MessagesActions { - sendMessage: (text: string, ctx: SendContext) => void - addUserMessage: (content: string, agentId: string) => void - addAssistantMessage: (content: string, agentId: string) => void - updateMessage: (id: string, content: ContentBlock[]) => void - loadMessages: (msgs: Message[]) => void - clearMessages: () => void - // Streaming - startStream: (streamId: string, agentId: string) => void - appendStream: (streamId: string, content: ContentBlock[]) => void - endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void - // Tool execution lifecycle - startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void - endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void - // Compaction lifecycle - startCompaction: () => void - endCompaction: (stats: CompactionStats) => void -} - -export type MessagesStore = MessagesState & MessagesActions - -export const useMessagesStore = create()((set, get) => ({ - messages: [], - streamingIds: new Set(), - compacting: false, - lastCompaction: null, - - sendMessage: (text, ctx) => { - get().addUserMessage(text, ctx.agentId) - ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text }) - }, - - addUserMessage: (content, agentId) => { - set((s) => ({ - messages: [...s.messages, { - id: uuidv7(), - role: "user", - content: [{ type: "text" as const, text: content }], - agentId, - }], - })) - }, - - addAssistantMessage: (content, agentId) => { - set((s) => ({ - messages: [...s.messages, { - id: uuidv7(), - role: "assistant", - content: [{ type: "text" as const, text: content }], - agentId, - }], - })) - }, - - updateMessage: (id, content) => { - set((s) => ({ - messages: s.messages.map((m) => (m.id === id ? { ...m, content } : m)), - })) - }, - - loadMessages: (msgs) => { - set({ messages: msgs }) - }, - - clearMessages: () => { - set({ messages: [], streamingIds: new Set(), compacting: false, lastCompaction: null }) - }, - - // --- Streaming: build assistant message incrementally --- - - startStream: (streamId, agentId) => { - set((s) => { - const ids = new Set(s.streamingIds) - ids.add(streamId) - return { - messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }], - streamingIds: ids, - } - }) - }, - - // Replace the entire content array with the latest partial snapshot - appendStream: (streamId, content) => { - set((s) => ({ - messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), - })) - }, - - endStream: (streamId, content, stopReason) => { - set((s) => { - const ids = new Set(s.streamingIds) - ids.delete(streamId) - // Find the agentId of the stream being ended to scope tool interruption - const streamMsg = s.messages.find((m) => m.id === streamId) - const streamAgentId = streamMsg?.agentId - return { - messages: s.messages.map((m) => { - if (m.id === streamId) return { ...m, content, stopReason } - // Interrupt running tool executions belonging to the same agent - if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === streamAgentId) { - return { ...m, toolStatus: "interrupted" as ToolStatus } - } - return m - }), - streamingIds: ids, - } - }) - }, - - // --- Tool execution: create / update toolResult messages --- - - startToolExecution: (agentId, toolCallId, toolName, args) => { - set((s) => ({ - messages: [...s.messages, { - id: uuidv7(), - role: "toolResult" as const, - content: [], - agentId, - toolCallId, - toolName, - toolArgs: args as Record | undefined, - toolStatus: "running" as ToolStatus, - isError: false, - }], - })) - }, - - endToolExecution: (toolCallId, result, isError) => { - set((s) => ({ - messages: s.messages.map((m) => - m.role === "toolResult" && m.toolCallId === toolCallId - ? { - ...m, - toolStatus: (isError ? "error" : "success") as ToolStatus, - isError: isError ?? false, - content: result != null - ? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }] - : [], - } - : m - ), - })) - }, - - // --- Compaction lifecycle --- - - startCompaction: () => { - set({ compacting: true }) - }, - - endCompaction: (stats) => { - set({ compacting: false, lastCompaction: stats }) - }, -})) diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts new file mode 100644 index 00000000..40654954 --- /dev/null +++ b/packages/store/src/types.ts @@ -0,0 +1,16 @@ +import type { ContentBlock } from "@multica/sdk" + +export type ToolStatus = "running" | "success" | "error" | "interrupted" + +export interface Message { + id: string + role: "user" | "assistant" | "toolResult" + content: ContentBlock[] + agentId: string + stopReason?: string + toolCallId?: string + toolName?: string + toolArgs?: Record + toolStatus?: ToolStatus + isError?: boolean +} diff --git a/packages/store/src/use-auto-connect.ts b/packages/store/src/use-auto-connect.ts deleted file mode 100644 index e01a7b1f..00000000 --- a/packages/store/src/use-auto-connect.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client" - -import { useState, useEffect } from "react" -import { useConnectionStore } from "./connection-store" -import { loadConnection } from "./connection" - -/** Auto-connect from saved connection code on mount, skip if already connected */ -export function useAutoConnect(): { loading: boolean } { - const connectionState = useConnectionStore((s) => s.connectionState) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const state = useConnectionStore.getState() - if (state.connectionState !== "disconnected") { - setLoading(false) - return - } - const saved = loadConnection() - if (saved) { - state.connect(saved) - } else { - setLoading(false) - } - }, []) - - useEffect(() => { - if (connectionState !== "disconnected") { - setLoading(false) - } - }, [connectionState]) - - return { loading } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e418556c..4e60a83f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,13 +34,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -457,10 +457,10 @@ importers: devDependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 @@ -473,19 +473,7 @@ importers: '@multica/sdk': specifier: workspace:* version: link:../sdk - react: - specifier: 'catalog:' - version: 19.2.3 - uuid: - specifier: ^13.0.0 - version: 13.0.0 - zustand: - specifier: 'catalog:' - version: 5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: - '@types/react': - specifier: 'catalog:' - version: 19.1.17 typescript: specifier: 'catalog:' version: 5.9.3 @@ -11596,12 +11584,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2