@mulmobridge/chat-service 0.1.0 → 0.1.1

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.
@@ -1,4 +1,4 @@
1
- import type { Role } from "./types.js";
1
+ import type { Role, SessionSummary } from "./types.js";
2
2
  import type { ChatStateStore, TransportChatState } from "./chat-state.js";
3
3
  export interface CommandResult {
4
4
  reply: string;
@@ -9,4 +9,22 @@ export declare function createCommandHandler(opts: {
9
9
  loadAllRoles: () => Role[];
10
10
  getRole: (roleId: string) => Role;
11
11
  resetChatState: ChatStateStore["resetChatState"];
12
+ connectSession: ChatStateStore["connectSession"];
13
+ listSessions?: (opts: {
14
+ limit: number;
15
+ offset: number;
16
+ }) => Promise<{
17
+ sessions: SessionSummary[];
18
+ total: number;
19
+ }>;
20
+ getSessionHistory?: (sessionId: string, opts: {
21
+ limit: number;
22
+ offset: number;
23
+ }) => Promise<{
24
+ messages: Array<{
25
+ source: string;
26
+ text: string;
27
+ }>;
28
+ total: number;
29
+ }>;
12
30
  }): CommandHandler;
package/dist/commands.js CHANGED
@@ -4,16 +4,59 @@
4
4
  // /status) for the transport chat bridge. Role lookups and state
5
5
  // reset arrive via the factory so this file has zero imports from
6
6
  // the host app — only sibling module types.
7
+ // Mirror server/utils/time.ts names but declared locally since
8
+ // the chat-service package must not import from the host app.
9
+ const ONE_MINUTE_MS = 60_000;
10
+ const ONE_HOUR_MS = 3_600_000;
11
+ const ONE_DAY_MS = 86_400_000;
12
+ function formatRelativeTime(isoDate) {
13
+ const diffMs = Date.now() - new Date(isoDate).getTime();
14
+ const minutes = Math.floor(diffMs / ONE_MINUTE_MS);
15
+ if (minutes < 1)
16
+ return "just now";
17
+ if (minutes < 60)
18
+ return `${minutes} min ago`;
19
+ const hours = Math.floor(diffMs / ONE_HOUR_MS);
20
+ if (hours < 24)
21
+ return `${hours}h ago`;
22
+ const days = Math.floor(diffMs / ONE_DAY_MS);
23
+ return `${days}d ago`;
24
+ }
7
25
  // ── Factory ──────────────────────────────────────────────────
8
26
  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");
27
+ const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory } = opts;
28
+ // Cache /sessions results per chat so /switch resolves to the correct list.
29
+ // Key: "transportId:externalChatId". Bounded with max entries + TTL.
30
+ // See docs/bridge-session-design.md for multi-user scaling plan.
31
+ const MAX_CACHE_ENTRIES = 1000;
32
+ const CACHE_TTL_MS = 5 * ONE_MINUTE_MS;
33
+ const sessionListCache = new Map();
34
+ const cacheKey = (transportId, externalChatId) => `${transportId}:${externalChatId}`;
35
+ function getCachedSessions(key) {
36
+ const entry = sessionListCache.get(key);
37
+ if (!entry)
38
+ return null;
39
+ if (Date.now() - entry.createdAt > CACHE_TTL_MS) {
40
+ sessionListCache.delete(key);
41
+ return null;
42
+ }
43
+ return entry.sessions;
44
+ }
45
+ function setCachedSessions(key, sessions) {
46
+ if (sessionListCache.size >= MAX_CACHE_ENTRIES) {
47
+ const oldest = sessionListCache.keys().next().value;
48
+ if (oldest !== undefined)
49
+ sessionListCache.delete(oldest);
50
+ }
51
+ sessionListCache.set(key, { sessions, createdAt: Date.now() });
52
+ }
53
+ const getRolesText = () => ["Available roles:", ...loadAllRoles().map((r) => ` ${r.id} — ${r.name}`)].join("\n");
14
54
  const getHelpText = () => [
15
55
  "Commands:",
16
56
  " /reset — Start a new session",
57
+ " /sessions [page] — List recent sessions (e.g. /sessions 2)",
58
+ " /switch <number|sessionId> — Switch to a session",
59
+ " /history [page] — Show recent messages in current session",
17
60
  " /help — Show this help",
18
61
  " /roles — List available roles",
19
62
  " /role <id> — Switch role",
@@ -45,13 +88,126 @@ export function createCommandHandler(opts) {
45
88
  const handleStatus = (chatState) => {
46
89
  const role = getRole(chatState.roleId);
47
90
  return {
48
- reply: [
49
- `Role: ${role.name} (${role.id})`,
50
- `Session: ${chatState.sessionId}`,
51
- `Last activity: ${chatState.updatedAt}`,
52
- ].join("\n"),
91
+ reply: [`Role: ${role.name} (${role.id})`, `Session: ${chatState.sessionId}`, `Last activity: ${chatState.updatedAt}`].join("\n"),
53
92
  };
54
93
  };
94
+ const SESSIONS_PAGE_SIZE = 10;
95
+ const handleSessions = async (transportId, chatState, pageArg) => {
96
+ if (!listSessions) {
97
+ return { reply: "Session listing is not available." };
98
+ }
99
+ const page = Math.max(1, parseInt(pageArg ?? "1", 10) || 1);
100
+ const offset = (page - 1) * SESSIONS_PAGE_SIZE;
101
+ const { sessions, total } = await listSessions({
102
+ limit: SESSIONS_PAGE_SIZE,
103
+ offset,
104
+ });
105
+ if (sessions.length === 0 && total === 0) {
106
+ return { reply: "No sessions found." };
107
+ }
108
+ if (sessions.length === 0) {
109
+ return { reply: `No more sessions. Total: ${total}` };
110
+ }
111
+ // Cache full page for /switch (keyed by offset so numbers are absolute)
112
+ const key = cacheKey(transportId, chatState.externalChatId);
113
+ const existing = getCachedSessions(key) ?? [];
114
+ // Merge into cache at correct positions
115
+ const merged = [...existing];
116
+ sessions.forEach((s, i) => {
117
+ merged[offset + i] = s;
118
+ });
119
+ setCachedSessions(key, merged);
120
+ const totalPages = Math.ceil(total / SESSIONS_PAGE_SIZE);
121
+ const lines = sessions.map((s, i) => {
122
+ const num = offset + i + 1;
123
+ const preview = s.preview.length > 40 ? s.preview.slice(0, 40) + "..." : s.preview;
124
+ return ` ${num}. [${s.roleId}] ${preview || "(no title)"} — ${formatRelativeTime(s.updatedAt)}`;
125
+ });
126
+ const header = `Sessions (page ${page}/${totalPages}, total ${total}):`;
127
+ const parts = [header, ...lines];
128
+ if (page < totalPages) {
129
+ parts.push(`\n/sessions ${page + 1} for next page`);
130
+ }
131
+ parts.push("Use /switch <number> or /switch <sessionId> to connect.");
132
+ return { reply: parts.join("\n") };
133
+ };
134
+ const handleSwitch = async (transportId, chatState, arg) => {
135
+ if (!arg) {
136
+ return {
137
+ reply: "Usage: /switch <number|sessionId>\nRun /sessions first to see the list.",
138
+ };
139
+ }
140
+ const key = cacheKey(transportId, chatState.externalChatId);
141
+ const cached = getCachedSessions(key) ?? [];
142
+ let target;
143
+ if (/^\d+$/.test(arg)) {
144
+ // Numeric — index into cached list
145
+ const index = parseInt(arg, 10);
146
+ if (index < 1 || index > cached.length) {
147
+ return {
148
+ reply: cached.length > 0 ? `Invalid number. Pick 1-${cached.length} from the /sessions list.` : "Run /sessions first to see available sessions.",
149
+ };
150
+ }
151
+ target = cached[index - 1];
152
+ // Guard against sparse cache (user loaded page 2 but not page 1)
153
+ if (!target) {
154
+ const page = Math.ceil(index / SESSIONS_PAGE_SIZE);
155
+ return {
156
+ reply: `Run /sessions ${page} first to load that page.`,
157
+ };
158
+ }
159
+ }
160
+ else {
161
+ // Non-numeric — treat as session ID
162
+ target = cached.find((s) => s.id === arg);
163
+ if (!target) {
164
+ return {
165
+ reply: `Session "${arg}" not found. Run /sessions to see available sessions.`,
166
+ };
167
+ }
168
+ }
169
+ const updated = await connectSession(transportId, chatState.externalChatId, target.id);
170
+ if (!updated) {
171
+ return { reply: "Failed to switch session." };
172
+ }
173
+ const role = getRole(target.roleId);
174
+ const preview = target.preview || "(no title)";
175
+ return {
176
+ reply: `Connected to "${preview}" (${role.name}). Send a message to continue.`,
177
+ nextState: updated,
178
+ };
179
+ };
180
+ const HISTORY_PAGE_SIZE = 5;
181
+ const MAX_MESSAGE_LENGTH = 200;
182
+ const handleHistory = async (chatState, pageArg) => {
183
+ if (!getSessionHistory) {
184
+ return { reply: "History is not available." };
185
+ }
186
+ const page = Math.max(1, parseInt(pageArg ?? "1", 10) || 1);
187
+ const offset = (page - 1) * HISTORY_PAGE_SIZE;
188
+ const { messages, total } = await getSessionHistory(chatState.sessionId, {
189
+ limit: HISTORY_PAGE_SIZE,
190
+ offset,
191
+ });
192
+ if (messages.length === 0 && total === 0) {
193
+ return { reply: "No messages in this session." };
194
+ }
195
+ if (messages.length === 0) {
196
+ return { reply: `No more messages. Total: ${total}` };
197
+ }
198
+ const totalPages = Math.ceil(total / HISTORY_PAGE_SIZE);
199
+ const lines = messages.map((m) => {
200
+ const label = m.source === "user" ? "You" : "AI";
201
+ const text = m.text.length > MAX_MESSAGE_LENGTH ? m.text.slice(0, MAX_MESSAGE_LENGTH) + "..." : m.text;
202
+ return `[${label}] ${text}`;
203
+ });
204
+ const header = `History (page ${page}/${totalPages}):`;
205
+ const parts = [header, "", ...lines];
206
+ if (page < totalPages) {
207
+ parts.push(`\n/history ${page + 1} for older messages`);
208
+ }
209
+ return { reply: parts.join("\n\n") };
210
+ };
55
211
  const handleCommand = async (text, transportId, chatState) => {
56
212
  if (!text.startsWith("/"))
57
213
  return null;
@@ -59,6 +215,12 @@ export function createCommandHandler(opts) {
59
215
  switch (command) {
60
216
  case "/reset":
61
217
  return handleReset(transportId, chatState);
218
+ case "/sessions":
219
+ return handleSessions(transportId, chatState, args[0]);
220
+ case "/switch":
221
+ return handleSwitch(transportId, chatState, args[0]);
222
+ case "/history":
223
+ return handleHistory(chatState, args[0]);
62
224
  case "/help":
63
225
  return { reply: getHelpText() };
64
226
  case "/roles":
package/dist/index.d.ts CHANGED
@@ -16,5 +16,5 @@ export interface ChatService {
16
16
  pushToBridge: PushFn;
17
17
  }
18
18
  export declare function createChatService(deps: ChatServiceDeps): ChatService;
19
- export type { ChatServiceDeps, StartChatFn, OnSessionEventFn, } from "./types.js";
19
+ export type { ChatServiceDeps, StartChatFn, OnSessionEventFn } from "./types.js";
20
20
  export { writeFileAtomic } from "./atomic-write.js";
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ const badRequest = (res, error) => res.status(400).json({ error });
29
29
  const notFound = (res, error) => res.status(404).json({ error });
30
30
  // ── Factory ──────────────────────────────────────────────────
31
31
  export function createChatService(deps) {
32
- const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider, } = deps;
32
+ const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider } = deps;
33
33
  const logger = deps.logger;
34
34
  const store = createChatStateStore({
35
35
  transportsDir: deps.transportsDir,
@@ -39,6 +39,9 @@ export function createChatService(deps) {
39
39
  loadAllRoles,
40
40
  getRole,
41
41
  resetChatState: store.resetChatState,
42
+ connectSession: store.connectSession,
43
+ listSessions: deps.listSessions,
44
+ getSessionHistory: deps.getSessionHistory,
42
45
  });
43
46
  const relay = createRelay({
44
47
  store,
@@ -88,9 +91,7 @@ export function createChatService(deps) {
88
91
  // reassign the active session pointer for a transport chat.
89
92
  router.post(CHAT_SERVICE_ROUTES.connect, async (req, res) => {
90
93
  const { transportId, externalChatId } = req.params;
91
- const chatSessionId = typeof req.body?.chatSessionId === "string"
92
- ? req.body.chatSessionId.trim()
93
- : "";
94
+ const chatSessionId = typeof req.body?.chatSessionId === "string" ? req.body.chatSessionId.trim() : "";
94
95
  if (!chatSessionId) {
95
96
  badRequest(res, "chatSessionId is required");
96
97
  return;
package/dist/relay.js CHANGED
@@ -11,7 +11,7 @@ import { EVENT_TYPES } from "@mulmobridge/protocol";
11
11
  const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
12
12
  // ── Factory ──────────────────────────────────────────────────
13
13
  export function createRelay(deps) {
14
- const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger, } = deps;
14
+ const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
15
15
  return async function relayMessage(params) {
16
16
  const { transportId, externalChatId, text, attachments } = params;
17
17
  // Log attachment summary (count + mimeTypes) — NEVER log raw
@@ -42,6 +42,7 @@ export function createRelay(deps) {
42
42
  roleId: chatState.roleId,
43
43
  chatSessionId: chatState.sessionId,
44
44
  attachments,
45
+ origin: "bridge",
45
46
  });
46
47
  if (result.kind === "error") {
47
48
  const status = result.status ?? 500;
@@ -96,8 +97,7 @@ function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
96
97
  const textChunks = [];
97
98
  const timer = setTimeout(() => {
98
99
  unsubscribe();
99
- resolve(textChunks.join("") ||
100
- "The request timed out before a reply was generated.");
100
+ resolve(textChunks.join("") || "The request timed out before a reply was generated.");
101
101
  }, REPLY_TIMEOUT_MS);
102
102
  const unsubscribe = onSessionEvent(chatSessionId, (event) => {
103
103
  const type = event.type;
@@ -114,8 +114,7 @@ function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
114
114
  if (type === EVENT_TYPES.sessionFinished) {
115
115
  clearTimeout(timer);
116
116
  unsubscribe();
117
- resolve(textChunks.join("") ||
118
- "The assistant completed the request but produced no text reply.");
117
+ resolve(textChunks.join("") || "The assistant completed the request but produced no text reply.");
119
118
  }
120
119
  });
121
120
  });
package/dist/socket.js CHANGED
@@ -156,8 +156,7 @@ function validateHandshake(auth, tokenProvider) {
156
156
  return { ok: false, error: "handshake auth is required" };
157
157
  }
158
158
  const transportIdRaw = auth.transportId;
159
- if (typeof transportIdRaw !== "string" ||
160
- transportIdRaw.trim().length === 0) {
159
+ if (typeof transportIdRaw !== "string" || transportIdRaw.trim().length === 0) {
161
160
  return { ok: false, error: "transportId is required" };
162
161
  }
163
162
  const transportId = transportIdRaw.trim();
@@ -184,9 +183,7 @@ function parseMessagePayload(payload) {
184
183
  if (!payload || typeof payload !== "object") {
185
184
  return { ok: false, error: "payload must be an object" };
186
185
  }
187
- const externalChatId = typeof payload.externalChatId === "string"
188
- ? payload.externalChatId.trim()
189
- : "";
186
+ const externalChatId = typeof payload.externalChatId === "string" ? payload.externalChatId.trim() : "";
190
187
  const text = typeof payload.text === "string" ? payload.text.trim() : "";
191
188
  if (!externalChatId) {
192
189
  return { ok: false, error: "externalChatId is required" };
package/dist/types.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface StartChatParams {
23
23
  chatSessionId: string;
24
24
  selectedImageData?: string;
25
25
  attachments?: Attachment[];
26
+ /** Session origin — application-defined (e.g. "human", "bridge") */
27
+ origin?: string;
26
28
  }
27
29
  export type StartChatResult = {
28
30
  kind: "started";
@@ -35,6 +37,13 @@ export type StartChatResult = {
35
37
  export type StartChatFn = (params: StartChatParams) => Promise<StartChatResult>;
36
38
  export type SessionEventListener = (event: Record<string, unknown>) => void;
37
39
  export type OnSessionEventFn = (sessionId: string, listener: SessionEventListener) => () => void;
40
+ /** Summary of a chat session — returned by listSessions. */
41
+ export interface SessionSummary {
42
+ id: string;
43
+ roleId: string;
44
+ preview: string;
45
+ updatedAt: string;
46
+ }
38
47
  export interface ChatServiceDeps {
39
48
  /** Relay a user turn into the agent loop. */
40
49
  startChat: StartChatFn;
@@ -56,4 +65,30 @@ export interface ChatServiceDeps {
56
65
  * `attachChatSocket` in ./socket.ts.
57
66
  */
58
67
  tokenProvider?: () => string | null;
68
+ /**
69
+ * List recent sessions from the server. Used by /sessions command.
70
+ * Omit if session listing is not available (command will reply
71
+ * "not available").
72
+ */
73
+ listSessions?: (opts: {
74
+ limit: number;
75
+ offset: number;
76
+ }) => Promise<{
77
+ sessions: SessionSummary[];
78
+ total: number;
79
+ }>;
80
+ /**
81
+ * Get recent messages from a session. Used by /history command.
82
+ * Returns newest-first array of {source, text} pairs.
83
+ */
84
+ getSessionHistory?: (sessionId: string, opts: {
85
+ limit: number;
86
+ offset: number;
87
+ }) => Promise<{
88
+ messages: Array<{
89
+ source: string;
90
+ text: string;
91
+ }>;
92
+ total: number;
93
+ }>;
59
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "license": "MIT",
28
28
  "author": "Receptron Team",
29
29
  "dependencies": {
30
- "@mulmobridge/protocol": "^0.1.0"
30
+ "@mulmobridge/protocol": "^0.1.3"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "express": "^5.0.0",