@mulmobridge/chat-service 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @mulmobridge/chat-service
2
+
3
+ Server-side chat service for [MulmoBridge](https://github.com/receptron/mulmoclaude) — provides socket.io + REST endpoints that connect external bridges (CLI, Telegram, etc.) to a Claude Code agent.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @mulmobridge/chat-service express socket.io
9
+ ```
10
+
11
+ > `express` and `socket.io` are peer dependencies.
12
+
13
+ ## Overview
14
+
15
+ The chat-service is a **DI-pure factory** — all host-app concerns (agent runner, session events, role lookup, file persistence, logger) are injected via `ChatServiceDeps`. No direct imports from the host application.
16
+
17
+ ```typescript
18
+ import { createChatService } from "@mulmobridge/chat-service";
19
+
20
+ const chatService = createChatService({
21
+ startChat, // your agent entry point
22
+ onSessionEvent, // session event subscriber
23
+ loadAllRoles, // role list provider
24
+ getRole, // single role lookup
25
+ defaultRoleId, // fallback role
26
+ transportsDir, // directory for transport state files
27
+ logger, // structured logger ({ error, warn, info, debug })
28
+ tokenProvider, // optional: bearer token for socket.io auth
29
+ });
30
+
31
+ // Mount the Express router
32
+ app.use(chatService.router);
33
+
34
+ // Attach socket.io to the HTTP server
35
+ chatService.attachSocket(httpServer);
36
+ ```
37
+
38
+ ## Architecture
39
+
40
+ ```text
41
+ ┌─────────────┐ socket.io ┌──────────────────┐
42
+ │ CLI bridge │ ◄──────────────► │ chat-service │
43
+ │ TG bridge │ /ws/chat │ (this package) │
44
+ │ ... │ │ │
45
+ └─────────────┘ REST │ ┌─────────────┐ │
46
+ /api/transports │ │ relay.ts │ │ ──► startChat()
47
+ │ │ socket.ts │ │ ──► onSessionEvent()
48
+ │ │ chat-state │ │ ──► file persistence
49
+ │ │ commands.ts │ │ ──► /reset, /role
50
+ │ │ push-queue │ │ ──► server→bridge push
51
+ │ └─────────────┘ │
52
+ └──────────────────┘
53
+ ```
54
+
55
+ ## Exports
56
+
57
+ | Export | Description |
58
+ |---|---|
59
+ | `createChatService(deps)` | Factory — returns `{ router, attachSocket, pushToBridge }` |
60
+ | `createRelay(deps)` | Core relay logic (HTTP + socket.io both call this) |
61
+ | `CHAT_SOCKET_EVENTS` | Re-exported from `@mulmobridge/protocol` |
62
+ | `ChatServiceDeps` | Dependency injection interface |
63
+ | `StartChatFn` / `StartChatParams` / `StartChatResult` | Agent entry point types |
64
+ | `Attachment` | File attachment interface |
65
+
66
+ ## Part of the MulmoBridge ecosystem
67
+
68
+ | Package | Description |
69
+ |---|---|
70
+ | [@mulmobridge/protocol](https://www.npmjs.com/package/@mulmobridge/protocol) | Wire protocol types and constants |
71
+ | **@mulmobridge/chat-service** | Server-side chat service (this package) |
72
+ | [@mulmobridge/client](https://www.npmjs.com/package/@mulmobridge/client) | Bridge client library |
73
+ | [@mulmobridge/cli](https://www.npmjs.com/package/@mulmobridge/cli) | CLI bridge |
74
+ | [@mulmobridge/telegram](https://www.npmjs.com/package/@mulmobridge/telegram) | Telegram bridge |
75
+
76
+ ## License
77
+
78
+ MIT — [Receptron Team](https://github.com/receptron)
@@ -0,0 +1 @@
1
+ export declare function writeFileAtomic(filePath: string, content: string): Promise<void>;
@@ -0,0 +1,21 @@
1
+ // Lightweight atomic write — write to a sibling tmp file, then rename.
2
+ //
3
+ // Inlined in this package (not imported from the host app) so
4
+ // @mulmobridge/chat-service stays dependency-free beyond protocol.
5
+ // Same contract as the host's writeFileAtomic: readers always see
6
+ // either the old file or the new file — never a half-written one.
7
+ import { writeFile, rename, unlink, mkdir } from "fs/promises";
8
+ import path from "path";
9
+ import { randomUUID } from "crypto";
10
+ export async function writeFileAtomic(filePath, content) {
11
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
12
+ await mkdir(path.dirname(filePath), { recursive: true });
13
+ try {
14
+ await writeFile(tmp, content, "utf-8");
15
+ await rename(tmp, filePath);
16
+ }
17
+ catch (err) {
18
+ await unlink(tmp).catch(() => { });
19
+ throw err;
20
+ }
21
+ }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from "./types.js";
2
+ export interface TransportChatState {
3
+ externalChatId: string;
4
+ sessionId: string;
5
+ roleId: string;
6
+ claudeSessionId?: string;
7
+ startedAt: string;
8
+ updatedAt: string;
9
+ }
10
+ export interface ChatStateStore {
11
+ getChatState(transportId: string, externalChatId: string): Promise<TransportChatState | null>;
12
+ setChatState(transportId: string, state: TransportChatState): Promise<void>;
13
+ resetChatState(transportId: string, externalChatId: string, roleId: string): Promise<TransportChatState>;
14
+ connectSession(transportId: string, externalChatId: string, chatSessionId: string): Promise<TransportChatState | null>;
15
+ generateSessionId(transportId: string, externalChatId: string): string;
16
+ }
17
+ export declare function createChatStateStore(opts: {
18
+ transportsDir: string;
19
+ logger: Logger;
20
+ }): ChatStateStore;
@@ -0,0 +1,84 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Persists per-transport chat state (which session a given
4
+ // external chat id currently points at, which role, timestamps).
5
+ // Kept DI-pure so the module can be extracted as a standalone npm
6
+ // package: the transports directory path and logger arrive via the
7
+ // factory, never through a direct `../workspace-paths.js` import.
8
+ import { mkdir, readFile } from "fs/promises";
9
+ import path from "path";
10
+ import { writeFileAtomic } from "./atomic-write.js";
11
+ // ── Path / id helpers ────────────────────────────────────────
12
+ // Allow alphanumeric, hyphen, underscore, dot. Rejects empty
13
+ // strings and anything >200 chars so we never let a transport id
14
+ // escape the transports directory via path traversal.
15
+ function isSafeId(id) {
16
+ return /^[\w.-]+$/.test(id) && id.length > 0 && id.length <= 200;
17
+ }
18
+ // ── Factory ──────────────────────────────────────────────────
19
+ export function createChatStateStore(opts) {
20
+ const { transportsDir, logger } = opts;
21
+ const transportDir = (transportId) => path.join(transportsDir, transportId, "chats");
22
+ const statePath = (transportId, externalChatId) => path.join(transportDir(transportId), `${externalChatId}.json`);
23
+ const generateSessionId = (transportId, externalChatId) => `${transportId}-${externalChatId}-${Date.now()}`;
24
+ const getChatState = async (transportId, externalChatId) => {
25
+ if (!isSafeId(transportId) || !isSafeId(externalChatId))
26
+ return null;
27
+ try {
28
+ const raw = await readFile(statePath(transportId, externalChatId), "utf-8");
29
+ const parsed = JSON.parse(raw);
30
+ return parsed;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ const setChatState = async (transportId, state) => {
37
+ if (!isSafeId(transportId) || !isSafeId(state.externalChatId)) {
38
+ throw new Error("Invalid transport or chat ID");
39
+ }
40
+ await mkdir(transportDir(transportId), { recursive: true });
41
+ await writeFileAtomic(statePath(transportId, state.externalChatId), JSON.stringify(state, null, 2));
42
+ };
43
+ const resetChatState = async (transportId, externalChatId, roleId) => {
44
+ const now = new Date().toISOString();
45
+ const state = {
46
+ externalChatId,
47
+ sessionId: generateSessionId(transportId, externalChatId),
48
+ roleId,
49
+ startedAt: now,
50
+ updatedAt: now,
51
+ };
52
+ await setChatState(transportId, state);
53
+ logger.info("chat-state", "reset", {
54
+ transportId,
55
+ externalChatId,
56
+ sessionId: state.sessionId,
57
+ });
58
+ return state;
59
+ };
60
+ const connectSession = async (transportId, externalChatId, chatSessionId) => {
61
+ const existing = await getChatState(transportId, externalChatId);
62
+ if (!existing)
63
+ return null;
64
+ const updated = {
65
+ ...existing,
66
+ sessionId: chatSessionId,
67
+ updatedAt: new Date().toISOString(),
68
+ };
69
+ await setChatState(transportId, updated);
70
+ logger.info("chat-state", "connected", {
71
+ transportId,
72
+ externalChatId,
73
+ sessionId: chatSessionId,
74
+ });
75
+ return updated;
76
+ };
77
+ return {
78
+ getChatState,
79
+ setChatState,
80
+ resetChatState,
81
+ connectSession,
82
+ generateSessionId,
83
+ };
84
+ }
@@ -0,0 +1,12 @@
1
+ import type { Role } from "./types.js";
2
+ import type { ChatStateStore, TransportChatState } from "./chat-state.js";
3
+ export interface CommandResult {
4
+ reply: string;
5
+ nextState?: TransportChatState;
6
+ }
7
+ export type CommandHandler = (text: string, transportId: string, chatState: TransportChatState) => Promise<CommandResult | null>;
8
+ export declare function createCommandHandler(opts: {
9
+ loadAllRoles: () => Role[];
10
+ getRole: (roleId: string) => Role;
11
+ resetChatState: ChatStateStore["resetChatState"];
12
+ }): CommandHandler;
@@ -0,0 +1,75 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Parses and executes slash commands (/reset, /help, /roles, /role,
4
+ // /status) for the transport chat bridge. Role lookups and state
5
+ // reset arrive via the factory so this file has zero imports from
6
+ // the host app — only sibling module types.
7
+ // ── Factory ──────────────────────────────────────────────────
8
+ export function createCommandHandler(opts) {
9
+ const { loadAllRoles, getRole, resetChatState } = opts;
10
+ const getRolesText = () => [
11
+ "Available roles:",
12
+ ...loadAllRoles().map((r) => ` ${r.id} — ${r.name}`),
13
+ ].join("\n");
14
+ const getHelpText = () => [
15
+ "Commands:",
16
+ " /reset — Start a new session",
17
+ " /help — Show this help",
18
+ " /roles — List available roles",
19
+ " /role <id> — Switch role",
20
+ " /status — Show current session info",
21
+ "",
22
+ "Send any other text to chat with the assistant.",
23
+ ].join("\n");
24
+ const handleReset = async (transportId, chatState) => {
25
+ const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
26
+ return {
27
+ reply: `Session reset. Role: ${nextState.roleId}`,
28
+ nextState,
29
+ };
30
+ };
31
+ const handleRole = async (transportId, chatState, requestedRoleId) => {
32
+ if (!requestedRoleId) {
33
+ return { reply: `Usage: /role <id>\n\n${getRolesText()}` };
34
+ }
35
+ const role = loadAllRoles().find((r) => r.id === requestedRoleId);
36
+ if (!role) {
37
+ return { reply: `Unknown role: ${requestedRoleId}\n\n${getRolesText()}` };
38
+ }
39
+ const nextState = await resetChatState(transportId, chatState.externalChatId, role.id);
40
+ return {
41
+ reply: `Switched to ${role.name} (${role.id}). New session started.`,
42
+ nextState,
43
+ };
44
+ };
45
+ const handleStatus = (chatState) => {
46
+ const role = getRole(chatState.roleId);
47
+ return {
48
+ reply: [
49
+ `Role: ${role.name} (${role.id})`,
50
+ `Session: ${chatState.sessionId}`,
51
+ `Last activity: ${chatState.updatedAt}`,
52
+ ].join("\n"),
53
+ };
54
+ };
55
+ const handleCommand = async (text, transportId, chatState) => {
56
+ if (!text.startsWith("/"))
57
+ return null;
58
+ const [command, ...args] = text.split(/\s+/);
59
+ switch (command) {
60
+ case "/reset":
61
+ return handleReset(transportId, chatState);
62
+ case "/help":
63
+ return { reply: getHelpText() };
64
+ case "/roles":
65
+ return { reply: getRolesText() };
66
+ case "/role":
67
+ return handleRole(transportId, chatState, args[0]);
68
+ case "/status":
69
+ return handleStatus(chatState);
70
+ default:
71
+ return { reply: `Unknown command: ${command}\n\n${getHelpText()}` };
72
+ }
73
+ };
74
+ return handleCommand;
75
+ }
@@ -0,0 +1,20 @@
1
+ import type http from "http";
2
+ import { Router } from "express";
3
+ import type { RelayFn } from "./relay.js";
4
+ import type { PushFn } from "./socket.js";
5
+ import type { ChatServiceDeps } from "./types.js";
6
+ export interface ChatService {
7
+ router: Router;
8
+ /** Relay used by the HTTP router. Exposed so alternate transports
9
+ * or tests can share the same flow without going through HTTP. */
10
+ relay: RelayFn;
11
+ /** Mount the socket.io transport at `/ws/chat` on the host HTTP server. */
12
+ attachSocket(httpServer: http.Server): void;
13
+ /** Server → bridge async push (Phase B of #268). Safe to call
14
+ * before `attachSocket`: the message is queued and flushes on
15
+ * the next bridge connection for that transport. */
16
+ pushToBridge: PushFn;
17
+ }
18
+ export declare function createChatService(deps: ChatServiceDeps): ChatService;
19
+ export type { ChatServiceDeps, StartChatFn, OnSessionEventFn, } from "./types.js";
20
+ export { writeFileAtomic } from "./atomic-write.js";
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Factory for the transport chat bridge. `createChatService(deps)`
4
+ // returns:
5
+ // - an Express `Router` for the legacy HTTP transport
6
+ // - an `attachSocket(httpServer)` helper that mounts the socket.io
7
+ // transport at `/ws/chat` (Phase A of #268)
8
+ // - the shared `relay` function both transports dispatch through
9
+ // - `pushToBridge(transportId, chatId, message)` for server→bridge
10
+ // async push (Phase B of #268). Before `attachSocket` is called
11
+ // pushes go straight to the in-memory queue; once a bridge is
12
+ // connected they emit live and the queue drains on join.
13
+ //
14
+ // All host-app dependencies arrive via `deps`; the module has no
15
+ // direct imports from `../routes/…`, `../roles.js`,
16
+ // `../session-store/…`, or `../logger/…` so it can be lifted into a
17
+ // standalone npm package without internal edits. See #269 / #305.
18
+ import { Router } from "express";
19
+ import { CHAT_SERVICE_ROUTES } from "@mulmobridge/protocol";
20
+ import { createChatStateStore } from "./chat-state.js";
21
+ import { createCommandHandler } from "./commands.js";
22
+ import { createRelay } from "./relay.js";
23
+ import { createPushQueue } from "./push-queue.js";
24
+ import { attachChatSocket } from "./socket.js";
25
+ // Inlined (not imported from `../utils/httpError.js`) so the module
26
+ // has no outbound dependency on the host app's utility modules.
27
+ // See `@package-contract` in ./types.ts.
28
+ const badRequest = (res, error) => res.status(400).json({ error });
29
+ const notFound = (res, error) => res.status(404).json({ error });
30
+ // ── Factory ──────────────────────────────────────────────────
31
+ export function createChatService(deps) {
32
+ const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider, } = deps;
33
+ const logger = deps.logger;
34
+ const store = createChatStateStore({
35
+ transportsDir: deps.transportsDir,
36
+ logger,
37
+ });
38
+ const handleCommand = createCommandHandler({
39
+ loadAllRoles,
40
+ getRole,
41
+ resetChatState: store.resetChatState,
42
+ });
43
+ const relay = createRelay({
44
+ store,
45
+ handleCommand,
46
+ startChat,
47
+ onSessionEvent,
48
+ getRole,
49
+ defaultRoleId,
50
+ logger,
51
+ });
52
+ const queue = createPushQueue();
53
+ // Until `attachSocket` runs, `livePush` is null and pushes go
54
+ // straight to the queue. After attach, this reference flips to
55
+ // the real emitter so live bridges get the message immediately.
56
+ // The queue is shared with the socket layer so any pushes
57
+ // enqueued during the pre-attach window flush on first connect.
58
+ let livePush = null;
59
+ const pushToBridge = (transportId, chatId, message) => {
60
+ if (livePush) {
61
+ livePush(transportId, chatId, message);
62
+ return;
63
+ }
64
+ queue.enqueue(transportId, { chatId, message, enqueuedAt: Date.now() });
65
+ logger.info("chat-service", "push queued (socket not attached yet)", {
66
+ transportId,
67
+ chatId,
68
+ queueSize: queue.sizeFor(transportId),
69
+ });
70
+ };
71
+ const router = Router();
72
+ // POST /api/transports/:transportId/chats/:externalChatId — send text, get a reply.
73
+ router.post(CHAT_SERVICE_ROUTES.message, async (req, res) => {
74
+ const { transportId, externalChatId } = req.params;
75
+ const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
76
+ if (!text) {
77
+ badRequest(res, "text is required");
78
+ return;
79
+ }
80
+ const result = await relay({ transportId, externalChatId, text });
81
+ if (result.kind === "ok") {
82
+ res.json({ reply: result.reply });
83
+ return;
84
+ }
85
+ res.status(result.status).json({ reply: result.message });
86
+ });
87
+ // POST /api/transports/:transportId/chats/:externalChatId/connect —
88
+ // reassign the active session pointer for a transport chat.
89
+ router.post(CHAT_SERVICE_ROUTES.connect, async (req, res) => {
90
+ const { transportId, externalChatId } = req.params;
91
+ const chatSessionId = typeof req.body?.chatSessionId === "string"
92
+ ? req.body.chatSessionId.trim()
93
+ : "";
94
+ if (!chatSessionId) {
95
+ badRequest(res, "chatSessionId is required");
96
+ return;
97
+ }
98
+ const updated = await store.connectSession(transportId, externalChatId, chatSessionId);
99
+ if (!updated) {
100
+ notFound(res, "No chat state found for this transport");
101
+ return;
102
+ }
103
+ res.json({ ok: true });
104
+ });
105
+ return {
106
+ router,
107
+ relay,
108
+ attachSocket: (httpServer) => {
109
+ const handle = attachChatSocket(httpServer, {
110
+ relay,
111
+ queue,
112
+ logger,
113
+ tokenProvider,
114
+ });
115
+ livePush = handle.pushToBridge;
116
+ },
117
+ pushToBridge,
118
+ };
119
+ }
120
+ export { writeFileAtomic } from "./atomic-write.js";
@@ -0,0 +1,14 @@
1
+ export interface PushMessage {
2
+ chatId: string;
3
+ message: string;
4
+ enqueuedAt: number;
5
+ }
6
+ export interface PushQueue {
7
+ /** Append a message to the transport's queue. */
8
+ enqueue(transportId: string, message: PushMessage): void;
9
+ /** Remove and return all queued messages for `transportId`. */
10
+ drainFor(transportId: string): PushMessage[];
11
+ /** How many messages are currently queued for `transportId` (test aid). */
12
+ sizeFor(transportId: string): number;
13
+ }
14
+ export declare function createPushQueue(): PushQueue;
@@ -0,0 +1,37 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // In-memory FIFO queue for server→bridge pushes that fire while no
4
+ // bridge socket is connected. One queue per transportId. On socket
5
+ // reconnect, the attaching socket's handler drains its transport's
6
+ // queue and emits the messages to that specific socket.
7
+ //
8
+ // Kept DI-free: no host-app imports, no fs writes. A future Phase
9
+ // B.2 can swap this out for a durable queue with the same interface.
10
+ //
11
+ // Not bounded — bridges reconnect quickly enough in normal operation
12
+ // that the steady state is zero. An adversarial producer could OOM
13
+ // the process; revisit if that threat model changes.
14
+ export function createPushQueue() {
15
+ const queues = new Map();
16
+ return {
17
+ enqueue(transportId, message) {
18
+ const existing = queues.get(transportId);
19
+ if (existing) {
20
+ existing.push(message);
21
+ }
22
+ else {
23
+ queues.set(transportId, [message]);
24
+ }
25
+ },
26
+ drainFor(transportId) {
27
+ const existing = queues.get(transportId);
28
+ if (!existing)
29
+ return [];
30
+ queues.delete(transportId);
31
+ return existing;
32
+ },
33
+ sizeFor(transportId) {
34
+ return queues.get(transportId)?.length ?? 0;
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,32 @@
1
+ import type { ChatStateStore } from "./chat-state.js";
2
+ import type { CommandHandler } from "./commands.js";
3
+ import type { Attachment, Logger, OnSessionEventFn, Role, StartChatFn } from "./types.js";
4
+ export interface RelayParams {
5
+ transportId: string;
6
+ externalChatId: string;
7
+ text: string;
8
+ attachments?: Attachment[];
9
+ /** Called for each text chunk as the agent generates it. Used by
10
+ * the socket transport to stream text to the bridge in real time
11
+ * (Phase C of #268). */
12
+ onChunk?: (text: string) => void;
13
+ }
14
+ export type RelayResult = {
15
+ kind: "ok";
16
+ reply: string;
17
+ } | {
18
+ kind: "error";
19
+ status: number;
20
+ message: string;
21
+ };
22
+ export type RelayFn = (params: RelayParams) => Promise<RelayResult>;
23
+ export interface RelayDeps {
24
+ store: ChatStateStore;
25
+ handleCommand: CommandHandler;
26
+ startChat: StartChatFn;
27
+ onSessionEvent: OnSessionEventFn;
28
+ getRole: (roleId: string) => Role;
29
+ defaultRoleId: string;
30
+ logger: Logger;
31
+ }
32
+ export declare function createRelay(deps: RelayDeps): RelayFn;
package/dist/relay.js ADDED
@@ -0,0 +1,122 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Shared core of the bridge chat flow. HTTP (router) and socket.io
4
+ // transports both call the `RelayFn` this factory returns. DI-pure:
5
+ // all host-app concerns (state store, command handler, agent entry
6
+ // point, session events, role lookup, logger) arrive through
7
+ // `createRelay(deps)` so the module has no direct imports from the
8
+ // host.
9
+ import { EVENT_TYPES } from "@mulmobridge/protocol";
10
+ // ── Constants ────────────────────────────────────────────────
11
+ const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
12
+ // ── Factory ──────────────────────────────────────────────────
13
+ export function createRelay(deps) {
14
+ const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger, } = deps;
15
+ return async function relayMessage(params) {
16
+ const { transportId, externalChatId, text, attachments } = params;
17
+ // Log attachment summary (count + mimeTypes) — NEVER log raw
18
+ // base64 data (performance, log size, information leak risk).
19
+ const attachmentSummary = attachments
20
+ ? {
21
+ count: attachments.length,
22
+ mimeTypes: attachments.map((a) => a.mimeType),
23
+ }
24
+ : undefined;
25
+ logger.info("chat-service", "message received", {
26
+ transportId,
27
+ externalChatId,
28
+ textLength: text.length,
29
+ ...(attachmentSummary ? { attachments: attachmentSummary } : {}),
30
+ });
31
+ let chatState = await store.getChatState(transportId, externalChatId);
32
+ if (!chatState) {
33
+ const defaultRole = getRole(defaultRoleId);
34
+ chatState = await store.resetChatState(transportId, externalChatId, defaultRole.id);
35
+ }
36
+ const commandResult = await handleCommand(text, transportId, chatState);
37
+ if (commandResult) {
38
+ return { kind: "ok", reply: commandResult.reply };
39
+ }
40
+ const result = await startChat({
41
+ message: text,
42
+ roleId: chatState.roleId,
43
+ chatSessionId: chatState.sessionId,
44
+ attachments,
45
+ });
46
+ if (result.kind === "error") {
47
+ const status = result.status ?? 500;
48
+ if (status === 409) {
49
+ // Session busy — tell the bridge to retry. Keep the HTTP
50
+ // response shape the old handler returned (status 409 on
51
+ // the HTTP side, "ok" reply text on the socket side — both
52
+ // layers decide how to serialise).
53
+ return {
54
+ kind: "ok",
55
+ reply: "A previous message is still being processed. Please wait.",
56
+ };
57
+ }
58
+ logger.error("chat-service", "startChat failed", {
59
+ transportId,
60
+ externalChatId,
61
+ error: result.error,
62
+ });
63
+ return {
64
+ kind: "error",
65
+ status,
66
+ message: `Error: ${result.error}`,
67
+ };
68
+ }
69
+ try {
70
+ const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
71
+ await store.setChatState(transportId, {
72
+ ...chatState,
73
+ updatedAt: new Date().toISOString(),
74
+ });
75
+ return { kind: "ok", reply };
76
+ }
77
+ catch (err) {
78
+ logger.error("chat-service", "reply collection failed", {
79
+ transportId,
80
+ externalChatId,
81
+ error: String(err),
82
+ });
83
+ return {
84
+ kind: "error",
85
+ status: 500,
86
+ message: "Error: failed to collect agent reply",
87
+ };
88
+ }
89
+ };
90
+ }
91
+ // ── Internals ────────────────────────────────────────────────
92
+ // Kept out of the factory closure so future packaging doesn't need
93
+ // to re-capture anything; `onSessionEvent` arrives as a plain param.
94
+ function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
95
+ return new Promise((resolve) => {
96
+ const textChunks = [];
97
+ const timer = setTimeout(() => {
98
+ unsubscribe();
99
+ resolve(textChunks.join("") ||
100
+ "The request timed out before a reply was generated.");
101
+ }, REPLY_TIMEOUT_MS);
102
+ const unsubscribe = onSessionEvent(chatSessionId, (event) => {
103
+ const type = event.type;
104
+ if (type === EVENT_TYPES.text) {
105
+ const chunk = event.message;
106
+ textChunks.push(chunk);
107
+ onChunk?.(chunk);
108
+ }
109
+ if (type === EVENT_TYPES.error) {
110
+ clearTimeout(timer);
111
+ unsubscribe();
112
+ resolve(`Error: ${event.message}`);
113
+ }
114
+ if (type === EVENT_TYPES.sessionFinished) {
115
+ clearTimeout(timer);
116
+ unsubscribe();
117
+ resolve(textChunks.join("") ||
118
+ "The assistant completed the request but produced no text reply.");
119
+ }
120
+ });
121
+ });
122
+ }
@@ -0,0 +1,43 @@
1
+ import type http from "http";
2
+ import { Server as SocketServer } from "socket.io";
3
+ import type { RelayFn } from "./relay.js";
4
+ import type { PushQueue } from "./push-queue.js";
5
+ import type { Logger } from "./types.js";
6
+ export declare const CHAT_SOCKET_PATH = "/ws/chat";
7
+ /**
8
+ * Custom socket.io events the chat transport defines. Keys mirror
9
+ * values so grep-and-rename is safe; the union type is what every
10
+ * on/emit site should reference instead of raw string literals.
11
+ * Socket.io built-ins (`connect`, `disconnect`, `connect_error`) are
12
+ * intentionally omitted — those are part of socket.io's own contract,
13
+ * not ours to rename.
14
+ */
15
+ export declare const CHAT_SOCKET_EVENTS: {
16
+ /** bridge → server request (body: `{ externalChatId, text }`); ack
17
+ * carries `{ ok, reply, error?, status? }`. */
18
+ readonly message: "message";
19
+ /** server → bridge async push (Phase B of #268); body:
20
+ * `{ chatId, message }`. */
21
+ readonly push: "push";
22
+ /** server → bridge streaming text chunk (Phase C of #268). */
23
+ readonly textChunk: "textChunk";
24
+ };
25
+ export type ChatSocketEvent = (typeof CHAT_SOCKET_EVENTS)[keyof typeof CHAT_SOCKET_EVENTS];
26
+ export type PushFn = (transportId: string, chatId: string, message: string) => void;
27
+ export interface ChatSocketDeps {
28
+ relay: RelayFn;
29
+ queue: PushQueue;
30
+ logger: Logger;
31
+ /** Current bearer token the handshake must carry. Null means
32
+ * bootstrap in progress — reject everything. Omit to disable. */
33
+ tokenProvider?: () => string | null;
34
+ }
35
+ export interface ChatSocketHandle {
36
+ io: SocketServer;
37
+ /** Fire-and-forget push to every bridge in `bridge:${transportId}`.
38
+ * If none are connected, the message is queued for the next
39
+ * joiner. */
40
+ pushToBridge: PushFn;
41
+ }
42
+ export declare function bridgeRoom(transportId: string): string;
43
+ export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
package/dist/socket.js ADDED
@@ -0,0 +1,232 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Socket.io transport for the bridge chat flow.
4
+ //
5
+ // Phase A (#268) — bridge → server req/res:
6
+ // handshake.auth: { transportId: string; token?: string }
7
+ // emit("message", { externalChatId, text }, ack)
8
+ // ack receives { ok: true, reply }
9
+ // | { ok: false, error, status? }
10
+ //
11
+ // Phase B (#268) — server → bridge async push:
12
+ // Each connected bridge joins room `bridge:${transportId}`.
13
+ // Server emits `push` { chatId, message } to that room via the
14
+ // `pushToBridge(transportId, chatId, message)` helper this module
15
+ // returns. If no sockets are in the room at push time, the
16
+ // message goes to an in-memory queue; the next socket that joins
17
+ // the room drains its transport's queue on connect.
18
+ //
19
+ // Auth: when `tokenProvider` is supplied, the handshake is rejected
20
+ // unless `auth.token` equals `tokenProvider()`. When omitted (tests,
21
+ // unauth environments) only `transportId` is validated.
22
+ //
23
+ // Future phases:
24
+ // C — streaming text chunks via `reply.chunk`
25
+ // D — HTTP endpoint deprecation
26
+ //
27
+ // See plans/feat-chat-socketio.md and plans/feat-chat-socketio-phase-b.md.
28
+ import { Server as SocketServer } from "socket.io";
29
+ export const CHAT_SOCKET_PATH = "/ws/chat";
30
+ /**
31
+ * Custom socket.io events the chat transport defines. Keys mirror
32
+ * values so grep-and-rename is safe; the union type is what every
33
+ * on/emit site should reference instead of raw string literals.
34
+ * Socket.io built-ins (`connect`, `disconnect`, `connect_error`) are
35
+ * intentionally omitted — those are part of socket.io's own contract,
36
+ * not ours to rename.
37
+ */
38
+ export const CHAT_SOCKET_EVENTS = {
39
+ /** bridge → server request (body: `{ externalChatId, text }`); ack
40
+ * carries `{ ok, reply, error?, status? }`. */
41
+ message: "message",
42
+ /** server → bridge async push (Phase B of #268); body:
43
+ * `{ chatId, message }`. */
44
+ push: "push",
45
+ /** server → bridge streaming text chunk (Phase C of #268). */
46
+ textChunk: "textChunk",
47
+ };
48
+ export function bridgeRoom(transportId) {
49
+ return `bridge:${transportId}`;
50
+ }
51
+ export function attachChatSocket(server, deps) {
52
+ const { relay, queue, logger, tokenProvider } = deps;
53
+ const io = new SocketServer(server, {
54
+ path: CHAT_SOCKET_PATH,
55
+ // Loopback-only deployment; skip long-polling negotiation for
56
+ // the same reason `/ws/pubsub` does (#311).
57
+ transports: ["websocket"],
58
+ });
59
+ io.use((socket, next) => {
60
+ const result = validateHandshake(socket.handshake.auth, tokenProvider);
61
+ if (!result.ok) {
62
+ next(new Error(result.error));
63
+ return;
64
+ }
65
+ socket.data.transportId = result.transportId;
66
+ next();
67
+ });
68
+ io.on("connection", (socket) => {
69
+ const transportId = socket.data.transportId;
70
+ const room = bridgeRoom(transportId);
71
+ socket.join(room);
72
+ // Flush any messages queued while this transport had no live
73
+ // socket. Emit only to *this* socket, not the room, so a
74
+ // second bridge joining seconds later doesn't re-receive the
75
+ // already-drained messages.
76
+ const queued = queue.drainFor(transportId);
77
+ if (queued.length > 0) {
78
+ logger.info("chat-service", "flushing push queue", {
79
+ socketId: socket.id,
80
+ transportId,
81
+ count: queued.length,
82
+ });
83
+ for (const item of queued) {
84
+ socket.emit(CHAT_SOCKET_EVENTS.push, {
85
+ chatId: item.chatId,
86
+ message: item.message,
87
+ });
88
+ }
89
+ }
90
+ logger.info("chat-service", "socket connected", {
91
+ socketId: socket.id,
92
+ transportId,
93
+ });
94
+ socket.on("disconnect", (reason) => {
95
+ logger.info("chat-service", "socket disconnected", {
96
+ socketId: socket.id,
97
+ transportId,
98
+ reason,
99
+ });
100
+ });
101
+ socket.on(CHAT_SOCKET_EVENTS.message, async (payload, ack) => {
102
+ if (typeof ack !== "function") {
103
+ logger.warn("chat-service", "socket message missing ack", {
104
+ socketId: socket.id,
105
+ transportId,
106
+ });
107
+ return;
108
+ }
109
+ const parsed = parseMessagePayload(payload);
110
+ if (!parsed.ok) {
111
+ ack({ ok: false, error: parsed.error, status: 400 });
112
+ return;
113
+ }
114
+ const result = await relay({
115
+ transportId,
116
+ externalChatId: parsed.externalChatId,
117
+ text: parsed.text,
118
+ attachments: parsed.attachments,
119
+ // Stream text chunks to this bridge socket in real time
120
+ // (Phase C of #268). The ack still returns the full text
121
+ // for backward compatibility.
122
+ onChunk: (text) => {
123
+ socket.emit(CHAT_SOCKET_EVENTS.textChunk, { text });
124
+ },
125
+ });
126
+ if (result.kind === "ok") {
127
+ ack({ ok: true, reply: result.reply });
128
+ }
129
+ else {
130
+ ack({ ok: false, error: result.message, status: result.status });
131
+ }
132
+ });
133
+ });
134
+ const pushToBridge = (transportId, chatId, message) => {
135
+ const room = bridgeRoom(transportId);
136
+ // `io.sockets.adapter.rooms` is the authoritative view of which
137
+ // rooms have members right now. Using it here (vs. a separate
138
+ // membership counter we'd have to maintain) keeps us honest
139
+ // about what socket.io actually knows.
140
+ const hasLive = (io.sockets.adapter.rooms.get(room)?.size ?? 0) > 0;
141
+ if (hasLive) {
142
+ io.to(room).emit(CHAT_SOCKET_EVENTS.push, { chatId, message });
143
+ return;
144
+ }
145
+ queue.enqueue(transportId, { chatId, message, enqueuedAt: Date.now() });
146
+ logger.info("chat-service", "push queued (no live bridge)", {
147
+ transportId,
148
+ chatId,
149
+ queueSize: queue.sizeFor(transportId),
150
+ });
151
+ };
152
+ return { io, pushToBridge };
153
+ }
154
+ function validateHandshake(auth, tokenProvider) {
155
+ if (!auth || typeof auth !== "object") {
156
+ return { ok: false, error: "handshake auth is required" };
157
+ }
158
+ const transportIdRaw = auth.transportId;
159
+ if (typeof transportIdRaw !== "string" ||
160
+ transportIdRaw.trim().length === 0) {
161
+ return { ok: false, error: "transportId is required" };
162
+ }
163
+ const transportId = transportIdRaw.trim();
164
+ if (!tokenProvider) {
165
+ return { ok: true, transportId };
166
+ }
167
+ const expected = tokenProvider();
168
+ if (expected === null || expected.length === 0) {
169
+ // Server auth not bootstrapped yet, or token absent. Reject so
170
+ // the bridge falls back to its connect-error path instead of
171
+ // silently succeeding.
172
+ return { ok: false, error: "server auth not ready" };
173
+ }
174
+ const provided = auth.token;
175
+ if (typeof provided !== "string" || provided.length === 0) {
176
+ return { ok: false, error: "token is required" };
177
+ }
178
+ if (provided !== expected) {
179
+ return { ok: false, error: "invalid token" };
180
+ }
181
+ return { ok: true, transportId };
182
+ }
183
+ function parseMessagePayload(payload) {
184
+ if (!payload || typeof payload !== "object") {
185
+ return { ok: false, error: "payload must be an object" };
186
+ }
187
+ const externalChatId = typeof payload.externalChatId === "string"
188
+ ? payload.externalChatId.trim()
189
+ : "";
190
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
191
+ if (!externalChatId) {
192
+ return { ok: false, error: "externalChatId is required" };
193
+ }
194
+ if (!text) {
195
+ return { ok: false, error: "text is required" };
196
+ }
197
+ const attachments = parseAttachments(payload.attachments);
198
+ return { ok: true, externalChatId, text, attachments };
199
+ }
200
+ // Hard limits to prevent oversized payloads from bridges (DoS /
201
+ // accidental misconfiguration). Express's JSON body limit (50 MB)
202
+ // is the outer gate; these are tighter, attachment-specific caps.
203
+ const MAX_ATTACHMENT_COUNT = 10;
204
+ const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024; // 20 MB base64
205
+ function parseAttachments(raw) {
206
+ if (!Array.isArray(raw) || raw.length === 0)
207
+ return undefined;
208
+ const valid = [];
209
+ let totalBytes = 0;
210
+ for (const item of raw) {
211
+ if (valid.length >= MAX_ATTACHMENT_COUNT)
212
+ break;
213
+ if (item &&
214
+ typeof item === "object" &&
215
+ typeof item.mimeType === "string" &&
216
+ typeof item.data === "string") {
217
+ const data = item.data;
218
+ totalBytes += data.length;
219
+ if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES)
220
+ break;
221
+ const entry = {
222
+ mimeType: item.mimeType,
223
+ data,
224
+ };
225
+ const fn = item.filename;
226
+ if (typeof fn === "string" && fn.length > 0)
227
+ entry.filename = fn;
228
+ valid.push(entry);
229
+ }
230
+ }
231
+ return valid.length > 0 ? valid : undefined;
232
+ }
@@ -0,0 +1,59 @@
1
+ export interface Role {
2
+ id: string;
3
+ name: string;
4
+ }
5
+ export interface Logger {
6
+ error(prefix: string, message: string, data?: Record<string, unknown>): void;
7
+ warn(prefix: string, message: string, data?: Record<string, unknown>): void;
8
+ info(prefix: string, message: string, data?: Record<string, unknown>): void;
9
+ debug(prefix: string, message: string, data?: Record<string, unknown>): void;
10
+ }
11
+ /** A file attached to a bridge message. Generic enough for images,
12
+ * PDFs, documents, videos, etc. The server decides what to do with
13
+ * each based on mimeType — images become vision content blocks,
14
+ * unsupported types are ignored with a log. */
15
+ export interface Attachment {
16
+ mimeType: string;
17
+ data: string;
18
+ filename?: string;
19
+ }
20
+ export interface StartChatParams {
21
+ message: string;
22
+ roleId: string;
23
+ chatSessionId: string;
24
+ selectedImageData?: string;
25
+ attachments?: Attachment[];
26
+ }
27
+ export type StartChatResult = {
28
+ kind: "started";
29
+ chatSessionId: string;
30
+ } | {
31
+ kind: "error";
32
+ error: string;
33
+ status?: number;
34
+ };
35
+ export type StartChatFn = (params: StartChatParams) => Promise<StartChatResult>;
36
+ export type SessionEventListener = (event: Record<string, unknown>) => void;
37
+ export type OnSessionEventFn = (sessionId: string, listener: SessionEventListener) => () => void;
38
+ export interface ChatServiceDeps {
39
+ /** Relay a user turn into the agent loop. */
40
+ startChat: StartChatFn;
41
+ /** Subscribe to a session's event stream; returns an unsubscribe function. */
42
+ onSessionEvent: OnSessionEventFn;
43
+ /** All roles (built-in + custom). */
44
+ loadAllRoles: () => Role[];
45
+ /** Look up a single role by id; MUST fall back to default if unknown. */
46
+ getRole: (roleId: string) => Role;
47
+ /** Id used when a fresh transport chat has no role selected yet. */
48
+ defaultRoleId: string;
49
+ /** Absolute path to the transports workspace dir (one subdir per transportId). */
50
+ transportsDir: string;
51
+ logger: Logger;
52
+ /**
53
+ * Returns the current bearer token the socket transport should
54
+ * accept at handshake, or null if auth isn't bootstrapped yet.
55
+ * Omit in tests / unauth environments to skip the check. See
56
+ * `attachChatSocket` in ./socket.ts.
57
+ */
58
+ tokenProvider?: () => string | null;
59
+ }
package/dist/types.js ADDED
@@ -0,0 +1,17 @@
1
+ // @package-contract
2
+ //
3
+ // This module is designed to be extractable as a standalone npm
4
+ // package (e.g. `@mulmoclaude/chat-service`) at any time. To keep
5
+ // that path open, the rules are:
6
+ //
7
+ // 1. NO raw imports from `../` outside this directory — all host
8
+ // dependencies MUST be passed through `ChatServiceDeps`.
9
+ // 2. Types declared here are STRUCTURAL duplicates of what the
10
+ // host app uses. They look like the real `Role` / `Logger` /
11
+ // `StartChatParams` types so the same functions plug in, but
12
+ // they are defined here so the package has no compile-time
13
+ // link back to the host.
14
+ // 3. When you add a new dependency, extend `ChatServiceDeps` and
15
+ // thread it through the factory functions — do NOT reach out
16
+ // to a module import. See #269 / #305 for the rationale.
17
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@mulmobridge/chat-service",
3
+ "version": "0.1.0",
4
+ "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepack": "yarn build",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "tsx --test test/test_*.ts",
25
+ "lint": "eslint src test"
26
+ },
27
+ "license": "MIT",
28
+ "author": "Receptron Team",
29
+ "dependencies": {
30
+ "@mulmobridge/protocol": "^0.1.0"
31
+ },
32
+ "peerDependencies": {
33
+ "express": "^5.0.0",
34
+ "socket.io": "^4.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/express": "^5.0.0",
38
+ "typescript": "^6.0.3"
39
+ }
40
+ }