@moltzap/client 2026.511.0 → 2026.518.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.
Files changed (194) hide show
  1. package/dist/auth.d.ts +12 -6
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +67 -29
  4. package/dist/auth.js.map +1 -1
  5. package/dist/auth.test.js +113 -76
  6. package/dist/auth.test.js.map +1 -1
  7. package/dist/channel-core-context.test.d.ts +2 -0
  8. package/dist/channel-core-context.test.d.ts.map +1 -0
  9. package/dist/channel-core-context.test.js +334 -0
  10. package/dist/channel-core-context.test.js.map +1 -0
  11. package/dist/channel-core-dispatch.test.d.ts +2 -0
  12. package/dist/channel-core-dispatch.test.d.ts.map +1 -0
  13. package/dist/channel-core-dispatch.test.js +550 -0
  14. package/dist/channel-core-dispatch.test.js.map +1 -0
  15. package/dist/channel-core-enrichment.d.ts +8 -0
  16. package/dist/channel-core-enrichment.d.ts.map +1 -0
  17. package/dist/channel-core-enrichment.js +134 -0
  18. package/dist/channel-core-enrichment.js.map +1 -0
  19. package/dist/channel-core-errors.d.ts +20 -0
  20. package/dist/channel-core-errors.d.ts.map +1 -0
  21. package/dist/channel-core-errors.js +12 -0
  22. package/dist/channel-core-errors.js.map +1 -0
  23. package/dist/channel-core-test-support.d.ts +48 -0
  24. package/dist/channel-core-test-support.d.ts.map +1 -0
  25. package/dist/channel-core-test-support.js +111 -0
  26. package/dist/channel-core-test-support.js.map +1 -0
  27. package/dist/channel-core.d.ts +30 -7
  28. package/dist/channel-core.d.ts.map +1 -1
  29. package/dist/channel-core.js +214 -237
  30. package/dist/channel-core.js.map +1 -1
  31. package/dist/channel-core.test.js +216 -1082
  32. package/dist/channel-core.test.js.map +1 -1
  33. package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
  34. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
  35. package/dist/cli/commands/conversations-v2.test.js +64 -54
  36. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  37. package/dist/cli/commands/conversations.d.ts +5 -5
  38. package/dist/cli/commands/conversations.d.ts.map +1 -1
  39. package/dist/cli/commands/conversations.js +43 -36
  40. package/dist/cli/commands/conversations.js.map +1 -1
  41. package/dist/cli/commands/messages.d.ts +4 -4
  42. package/dist/cli/commands/messages.d.ts.map +1 -1
  43. package/dist/cli/commands/messages.js +4 -4
  44. package/dist/cli/commands/messages.js.map +1 -1
  45. package/dist/cli/commands/messages.test.js +61 -49
  46. package/dist/cli/commands/messages.test.js.map +1 -1
  47. package/dist/cli/commands/ping.d.ts +0 -9
  48. package/dist/cli/commands/ping.d.ts.map +1 -1
  49. package/dist/cli/commands/ping.js +1 -1
  50. package/dist/cli/commands/ping.js.map +1 -1
  51. package/dist/cli/commands/presence.d.ts +1 -1
  52. package/dist/cli/commands/presence.js +1 -1
  53. package/dist/cli/commands/register.d.ts +2 -2
  54. package/dist/cli/commands/register.d.ts.map +1 -1
  55. package/dist/cli/commands/register.js +98 -77
  56. package/dist/cli/commands/register.js.map +1 -1
  57. package/dist/cli/commands/register.test.js +63 -39
  58. package/dist/cli/commands/register.test.js.map +1 -1
  59. package/dist/cli/commands/send.d.ts +6 -6
  60. package/dist/cli/commands/send.js +6 -6
  61. package/dist/cli/commands/send.test.js +32 -28
  62. package/dist/cli/commands/send.test.js.map +1 -1
  63. package/dist/cli/commands/status.d.ts.map +1 -1
  64. package/dist/cli/commands/status.js +3 -4
  65. package/dist/cli/commands/status.js.map +1 -1
  66. package/dist/cli/config.d.ts +0 -2
  67. package/dist/cli/config.d.ts.map +1 -1
  68. package/dist/cli/config.js +86 -76
  69. package/dist/cli/config.js.map +1 -1
  70. package/dist/cli/config.test.js +80 -42
  71. package/dist/cli/config.test.js.map +1 -1
  72. package/dist/cli/index.d.ts +1 -16
  73. package/dist/cli/index.d.ts.map +1 -1
  74. package/dist/cli/index.js +50 -113
  75. package/dist/cli/index.js.map +1 -1
  76. package/dist/cli/process-argv.d.ts +2 -0
  77. package/dist/cli/process-argv.d.ts.map +1 -0
  78. package/dist/cli/process-argv.js +2 -0
  79. package/dist/cli/process-argv.js.map +1 -0
  80. package/dist/cli/profile.d.ts +3 -10
  81. package/dist/cli/profile.d.ts.map +1 -1
  82. package/dist/cli/profile.js +105 -104
  83. package/dist/cli/profile.js.map +1 -1
  84. package/dist/cli/profile.test.js +338 -232
  85. package/dist/cli/profile.test.js.map +1 -1
  86. package/dist/cli/runtime.d.ts +5 -17
  87. package/dist/cli/runtime.d.ts.map +1 -1
  88. package/dist/cli/runtime.js +6 -59
  89. package/dist/cli/runtime.js.map +1 -1
  90. package/dist/cli/socket-client.d.ts +10 -15
  91. package/dist/cli/socket-client.d.ts.map +1 -1
  92. package/dist/cli/socket-client.js +74 -89
  93. package/dist/cli/socket-client.js.map +1 -1
  94. package/dist/cli/transport.d.ts +9 -5
  95. package/dist/cli/transport.d.ts.map +1 -1
  96. package/dist/cli/transport.js +45 -42
  97. package/dist/cli/transport.js.map +1 -1
  98. package/dist/cli/transport.test.js +227 -133
  99. package/dist/cli/transport.test.js.map +1 -1
  100. package/dist/index.d.ts +4 -1
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/local-daemon-rpc.d.ts +13 -0
  105. package/dist/local-daemon-rpc.d.ts.map +1 -0
  106. package/dist/local-daemon-rpc.js +17 -0
  107. package/dist/local-daemon-rpc.js.map +1 -0
  108. package/dist/local-paths.d.ts +7 -0
  109. package/dist/local-paths.d.ts.map +1 -0
  110. package/dist/local-paths.js +20 -0
  111. package/dist/local-paths.js.map +1 -0
  112. package/dist/runtime/close-info.d.ts +3 -12
  113. package/dist/runtime/close-info.d.ts.map +1 -1
  114. package/dist/runtime/close-info.js +28 -39
  115. package/dist/runtime/close-info.js.map +1 -1
  116. package/dist/runtime/close-info.test.js +77 -70
  117. package/dist/runtime/close-info.test.js.map +1 -1
  118. package/dist/runtime/errors.test.js +42 -28
  119. package/dist/runtime/errors.test.js.map +1 -1
  120. package/dist/runtime/frame.d.ts +4 -31
  121. package/dist/runtime/frame.d.ts.map +1 -1
  122. package/dist/runtime/frame.js +7 -116
  123. package/dist/runtime/frame.js.map +1 -1
  124. package/dist/runtime/frame.test.js +41 -54
  125. package/dist/runtime/frame.test.js.map +1 -1
  126. package/dist/runtime/index.d.ts +7 -0
  127. package/dist/runtime/index.d.ts.map +1 -1
  128. package/dist/runtime/index.js +7 -11
  129. package/dist/runtime/index.js.map +1 -1
  130. package/dist/runtime/local-history.d.ts +67 -0
  131. package/dist/runtime/local-history.d.ts.map +1 -0
  132. package/dist/runtime/local-history.js +77 -0
  133. package/dist/runtime/local-history.js.map +1 -0
  134. package/dist/runtime/local-service-commands.d.ts +19 -0
  135. package/dist/runtime/local-service-commands.d.ts.map +1 -1
  136. package/dist/runtime/local-service-commands.js +23 -0
  137. package/dist/runtime/local-service-commands.js.map +1 -1
  138. package/dist/runtime/local-socket-server.d.ts +20 -0
  139. package/dist/runtime/local-socket-server.d.ts.map +1 -0
  140. package/dist/runtime/local-socket-server.js +94 -0
  141. package/dist/runtime/local-socket-server.js.map +1 -0
  142. package/dist/runtime/refs.d.ts +1 -1
  143. package/dist/runtime/refs.js +1 -1
  144. package/dist/runtime/runtime.test.js +29 -21
  145. package/dist/runtime/runtime.test.js.map +1 -1
  146. package/dist/runtime/service-helpers.d.ts +32 -0
  147. package/dist/runtime/service-helpers.d.ts.map +1 -0
  148. package/dist/runtime/service-helpers.js +85 -0
  149. package/dist/runtime/service-helpers.js.map +1 -0
  150. package/dist/runtime/subscribers.d.ts +13 -17
  151. package/dist/runtime/subscribers.d.ts.map +1 -1
  152. package/dist/runtime/subscribers.js +37 -32
  153. package/dist/runtime/subscribers.js.map +1 -1
  154. package/dist/runtime/subscribers.test.js +139 -131
  155. package/dist/runtime/subscribers.test.js.map +1 -1
  156. package/dist/service-socket-path.test.d.ts +2 -0
  157. package/dist/service-socket-path.test.d.ts.map +1 -0
  158. package/dist/service-socket-path.test.js +87 -0
  159. package/dist/service-socket-path.test.js.map +1 -0
  160. package/dist/service.d.ts +46 -20
  161. package/dist/service.d.ts.map +1 -1
  162. package/dist/service.js +243 -340
  163. package/dist/service.js.map +1 -1
  164. package/dist/service.test.js +650 -571
  165. package/dist/service.test.js.map +1 -1
  166. package/dist/test/index.d.ts +30 -14
  167. package/dist/test/index.d.ts.map +1 -1
  168. package/dist/test/index.js +25 -21
  169. package/dist/test/index.js.map +1 -1
  170. package/dist/test-utils/channel-service-fixture.d.ts +1 -0
  171. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  172. package/dist/test-utils/channel-service-fixture.js +139 -98
  173. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  174. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  175. package/dist/test-utils/conformance-adapter.js +145 -124
  176. package/dist/test-utils/conformance-adapter.js.map +1 -1
  177. package/dist/test-utils/fake-service.d.ts.map +1 -1
  178. package/dist/test-utils/fake-service.js +3 -3
  179. package/dist/test-utils/fake-service.js.map +1 -1
  180. package/dist/test-utils/index.d.ts +25 -0
  181. package/dist/test-utils/index.d.ts.map +1 -1
  182. package/dist/test-utils/index.js +25 -8
  183. package/dist/test-utils/index.js.map +1 -1
  184. package/dist/ws-client-test-support.d.ts +180 -0
  185. package/dist/ws-client-test-support.d.ts.map +1 -0
  186. package/dist/ws-client-test-support.js +399 -0
  187. package/dist/ws-client-test-support.js.map +1 -0
  188. package/dist/ws-client.d.ts +59 -32
  189. package/dist/ws-client.d.ts.map +1 -1
  190. package/dist/ws-client.js +272 -321
  191. package/dist/ws-client.js.map +1 -1
  192. package/dist/ws-client.test.js +388 -800
  193. package/dist/ws-client.test.js.map +1 -1
  194. package/package.json +14 -10
package/dist/service.js CHANGED
@@ -1,50 +1,39 @@
1
- import * as net from "node:net";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import * as os from "node:os";
1
+ import { FileSystem, Path } from "@effect/platform";
2
+ import { NodeContext } from "@effect/platform-node";
5
3
  import { AgentsLookup, AgentsLookupByName, ConversationsCreate, ConversationsGet, DispatchRequest, DispatchRelease, DispatchesConsumed, DispatchesExpired, DispatchesGet, MessageReceivedNotificationDefinition, ConversationCreatedNotificationDefinition, ConversationUpdatedNotificationDefinition, ConversationArchivedNotificationDefinition, ConversationUnarchivedNotificationDefinition, ConversationArchivedError, MessagesList, MessagesSend, NotConnectedError, rpcMethods, RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
6
- import { Config, ConfigProvider, Data, Effect, HashMap, Match, Option, Ref, } from "effect";
7
- import { MoltZapWsClient, } from "./ws-client.js";
4
+ import { Config, ConfigProvider, Effect, HashMap, Option, Ref, Scope, } from "effect";
5
+ import { MoltZapWsClient } from "./ws-client.js";
8
6
  import { AgentNotFoundError } from "./runtime/errors.js";
9
7
  import { getOr, snapshot } from "./runtime/refs.js";
10
8
  import { LocalServiceCommands } from "./runtime/local-service-commands.js";
11
- const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ import { getMoltZapAgentServiceSocketPath, getMoltZapServiceSocketPath, } from "./local-paths.js";
10
+ import { startLocalSocketServer, stopLocalSocketServer, } from "./runtime/local-socket-server.js";
11
+ import { buildContextEntries, makeContextCandidate, newMessagesForConversation, notificationTraceRecord, renderPart, ServiceInputError, } from "./runtime/service-helpers.js";
12
+ import { decodeHistoryRequest, formatHistoryMessage, lastReadIdsForSession, } from "./runtime/local-history.js";
12
13
  const CROSS_CONTEXT_TEXT_LIMIT = 120;
13
- const DEFAULT_HISTORY_LIMIT = 10;
14
14
  const DEFAULT_MAX_CONTEXT_CONVERSATIONS = 5;
15
15
  const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 3;
16
16
  const HISTORY_LOOKUP_CONCURRENCY = 2;
17
- const MILLISECONDS_PER_MINUTE = 60_000;
18
- const SOCKET_FILE_MODE = 0o600;
19
- class ServiceInputError extends Data.TaggedError("ServiceInputError") {
20
- }
21
17
  const ClientEventLogDir = Config.option(Config.string("MOLTZAP_CLIENT_EVENT_LOG_DIR"));
22
18
  const getClientEventLogDir = () => Option.getOrUndefined(Effect.runSync(ClientEventLogDir.pipe(Effect.withConfigProvider(ConfigProvider.fromEnv()))));
23
19
  function appendClientEventTrace(record) {
24
20
  const dir = getClientEventLogDir();
25
21
  if (!dir)
26
- return;
27
- try {
28
- fs.mkdirSync(dir, { recursive: true });
22
+ return Effect.void;
23
+ return Effect.gen(function* () {
24
+ const fileSystem = yield* FileSystem.FileSystem;
25
+ const path = yield* Path.Path;
29
26
  const agentId = typeof record["agentId"] === "string" ? record["agentId"] : "unknown";
30
27
  const safeAgentId = /^[A-Za-z0-9_-]+$/.test(agentId) ? agentId : "unknown";
31
- fs.appendFileSync(path.join(dir, `client-events-${safeAgentId}.jsonl`), JSON.stringify(record) + "\n");
32
- }
33
- catch (err) {
34
- try {
35
- process.stderr.write(`moltzap client event trace write failed: ${err instanceof Error ? err.message : String(err)}\n`);
36
- }
37
- catch (stderrErr) {
38
- void stderrErr;
39
- }
40
- }
28
+ yield* Effect.zipRight(fileSystem.makeDirectory(dir, { recursive: true }), fileSystem.writeFileString(path.join(dir, `client-events-${safeAgentId}.jsonl`), JSON.stringify(record) + "\n", { flag: "a" }));
29
+ }).pipe(Effect.withSpan("appendClientEventTrace"), Effect.provide(NodeContext.layer), Effect.catchAll((err) => Effect.logWarning("moltzap client event trace write failed", err)));
41
30
  }
42
- /** Escape `<`, `>`, `&` so sender content can't escape a `<system-reminder>` block. */
31
+ /** Escape `&lt;`, `>`, `&amp;` so sender content can't escape a `&lt;system-reminder>` block. */
43
32
  export function sanitizeForSystemReminder(s) {
44
33
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
34
  }
46
35
  /**
47
- * Format CrossConversationEntry[] as a `<system-reminder>` block. Adapters
36
+ * Format CrossConversationEntry[] as a `&lt;system-reminder>` block. Adapters
48
37
  * that inline context into prompt text (nanoclaw) and `MoltZapService.getContext`
49
38
  * share this formatter so sanitization and line shape stay in one place.
50
39
  */
@@ -63,28 +52,30 @@ export function formatCrossConversationBlock(entries, opts) {
63
52
  "</system-reminder>",
64
53
  ].join("\n");
65
54
  }
66
- const renderPart = Match.type().pipe(Match.discriminatorsExhaustive("type")({
67
- text: (t) => t.text,
68
- image: () => "[image]",
69
- file: (f) => `[file: ${f.name}]`,
70
- }));
71
55
  /**
72
56
  * Per-conversation message cap. Older messages are evicted FIFO; the
73
57
  * on-disk history remains the source of truth. Sized for typical CLI
74
58
  * display windows — `conversations get` shows at most a few hundred.
75
59
  */
76
60
  const MAX_MESSAGES_PER_CONV = 1000;
61
+ /**
62
+ * Per-conversation dedup window. Inbound messageIds are tracked in a
63
+ * Set bounded to this size; once full, the insertion-ordered oldest
64
+ * entry is evicted to make room. 1000 × 36 bytes per UUID ≈ 36 KB per
65
+ * conversation — bounded and negligible for the expected conversation count.
66
+ */
67
+ const DEDUP_WINDOW_PER_CONV = 1000;
77
68
  /**
78
69
  * Invoke every handler with `arg`, isolating throws so one bad handler
79
- * doesn't abort the remaining fanout. Logs via the optional client logger.
70
+ * doesn't abort the remaining fanout.
80
71
  */
81
- function fanout(handlers, arg, logger) {
72
+ function fanout(handlers, arg) {
82
73
  for (const h of handlers) {
83
74
  try {
84
75
  h(arg);
85
76
  }
86
77
  catch (err) {
87
- logger?.error("notification handler threw", err);
78
+ Effect.runFork(Effect.logError("notification handler threw", err));
88
79
  }
89
80
  }
90
81
  }
@@ -110,6 +101,16 @@ export class MoltZapService {
110
101
  lastNotifiedRef = Effect.runSync(Ref.make(HashMap.empty()));
111
102
  lastReadRef = Effect.runSync(Ref.make(HashMap.empty()));
112
103
  archivedConversationIds = new Set();
104
+ /**
105
+ * Insertion-ordered set of recently seen messageIds per conversation.
106
+ * Bounded at DEDUP_WINDOW_PER_CONV entries per conversation; oldest entry
107
+ * is evicted when the window is full. Set#keys() preserves insertion
108
+ * order in V8 / the spec, so eviction via `.next()` is O(1).
109
+ *
110
+ * Keyed and valued by their branded ids so the compiler rejects a
111
+ * `MessageId` accidentally used as a conversation key (or vice versa).
112
+ */
113
+ seenMessageIds = new Map();
113
114
  handlers = {
114
115
  message: [],
115
116
  rawNotification: [],
@@ -121,6 +122,40 @@ export class MoltZapService {
121
122
  dispatchesConsumed: [],
122
123
  dispatchesExpired: [],
123
124
  };
125
+ notificationDispatchers = new Map([
126
+ [
127
+ MessageReceivedNotificationDefinition,
128
+ (notification) => this.handleMessageReceivedNotification(notification.params),
129
+ ],
130
+ [
131
+ ConversationCreatedNotificationDefinition,
132
+ (notification) => this.handleConversationCreatedNotification(notification.params),
133
+ ],
134
+ [
135
+ ConversationUpdatedNotificationDefinition,
136
+ (notification) => this.handleConversationUpdatedNotification(notification.params),
137
+ ],
138
+ [
139
+ ConversationArchivedNotificationDefinition,
140
+ (notification) => this.handleConversationArchivedNotification(notification.params),
141
+ ],
142
+ [
143
+ ConversationUnarchivedNotificationDefinition,
144
+ (notification) => this.handleConversationUnarchivedNotification(notification.params),
145
+ ],
146
+ [
147
+ DispatchRelease,
148
+ (notification) => fanout(this.handlers.dispatchRelease, notification.params),
149
+ ],
150
+ [
151
+ DispatchesConsumed,
152
+ (notification) => fanout(this.handlers.dispatchesConsumed, notification.params),
153
+ ],
154
+ [
155
+ DispatchesExpired,
156
+ (notification) => fanout(this.handlers.dispatchesExpired, notification.params),
157
+ ],
158
+ ]);
124
159
  _ownAgentId;
125
160
  constructor(opts) {
126
161
  this.opts = opts;
@@ -137,18 +172,16 @@ export class MoltZapService {
137
172
  this.client = new MoltZapWsClient({
138
173
  serverUrl: this.opts.serverUrl,
139
174
  agentKey: this.opts.agentKey,
140
- logger: this.opts.logger,
141
175
  // Spec #222 OQ-6: arg required. The body doesn't branch on
142
176
  // close metadata today; signature kept explicit so a future
143
177
  // disconnect-handler chain can plumb code/reason through.
144
- onDisconnect: (close) => {
145
- void close;
178
+ onDisconnect: () => {
146
179
  this._connected = false;
147
- fanout(this.handlers.disconnect, undefined, this.opts.logger);
180
+ fanout(this.handlers.disconnect, undefined);
148
181
  },
149
182
  onReconnect: (helloOk) => {
150
183
  this._connected = true;
151
- fanout(this.handlers.reconnect, helloOk, this.opts.logger);
184
+ fanout(this.handlers.reconnect, helloOk);
152
185
  },
153
186
  });
154
187
  // Spec #222 OQ-4: per-notification callback is replaced by subscriptions.
@@ -165,15 +198,15 @@ export class MoltZapService {
165
198
  }
166
199
  /**
167
200
  * Tear down the service. `close()` is sync because it fans out to the
168
- * socket server, Refs, and the ws-client — the ws-client's own close
169
- * Effect is run via `Effect.runSync` here so the caller gets an immediate
170
- * shutdown the way existing code expects.
201
+ * socket server, Refs, and the ws-client. Effectful network/filesystem
202
+ * cleanup is forked at the edge so existing callers still get immediate
203
+ * shutdown.
171
204
  */
172
205
  close() {
173
206
  this._connected = false;
174
- this.stopSocketServer();
207
+ Effect.runFork(this.stopSocketServer());
175
208
  if (this.client) {
176
- void Effect.runPromise(this.client.close());
209
+ Effect.runFork(this.client.close());
177
210
  }
178
211
  this.client = null;
179
212
  Effect.runSync(Effect.all([
@@ -185,16 +218,17 @@ export class MoltZapService {
185
218
  Ref.set(this.lastReadRef, HashMap.empty()),
186
219
  ]));
187
220
  this.archivedConversationIds.clear();
221
+ this.seenMessageIds.clear();
188
222
  // Handlers are preserved across close()/connect() cycles. MoltZapChannelCore
189
223
  // subscribes once in its constructor; clearing handlers here would silently
190
224
  // drop inbound/reconnect dispatch on any subsequent reconnect of the same
191
225
  // service instance.
192
226
  }
193
227
  // --- Socket Server ---
194
- socketServer = null;
228
+ socketServerScope = null;
195
229
  activeSocketPath = null;
196
230
  /** Default socket path for CLI discovery. Per-instance path uses agentId. */
197
- static SOCKET_PATH = path.join(os.homedir(), ".moltzap", "service.sock");
231
+ static SOCKET_PATH = getMoltZapServiceSocketPath();
198
232
  /**
199
233
  * `agentId` is a server-assigned string. Treat it as untrusted: if a
200
234
  * compromised or malicious server returns an id containing `..` or a
@@ -207,127 +241,39 @@ export class MoltZapService {
207
241
  /** Per-instance socket path based on connected agentId. */
208
242
  get socketPath() {
209
243
  const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
210
- return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
244
+ return getMoltZapAgentServiceSocketPath(id);
211
245
  }
212
246
  startSocketServer() {
213
- const sockPath = this.socketPath;
214
- this.activeSocketPath = sockPath;
215
- try {
216
- fs.unlinkSync(sockPath);
217
- }
218
- catch (err) {
219
- // ENOENT is the normal case (no stale socket); log everything
220
- // else (permission denied, EACCES) so operators can diagnose.
221
- const code = err.code;
222
- if (code !== "ENOENT") {
223
- this.opts.logger?.warn("unlink existing socket failed", err);
224
- }
225
- }
226
- fs.mkdirSync(path.dirname(sockPath), { recursive: true });
227
- this.socketServer = net.createServer((conn) => {
228
- let buffer = "";
229
- conn.on("data", (chunk) => {
230
- buffer += chunk.toString();
231
- const lines = buffer.split("\n");
232
- buffer = lines.pop();
233
- for (const line of lines) {
234
- if (!line.trim())
235
- continue;
236
- // Fork the line-handler Effect onto the Effect runtime. Each line
237
- // dispatches independently; the fiber runs until it writes a
238
- // response or error frame back onto the socket.
239
- Effect.runFork(this.handleSocketLineEffect(line, conn));
240
- }
247
+ return Effect.gen(this, function* () {
248
+ const previous = this.resetSocketServerState();
249
+ yield* stopLocalSocketServer({
250
+ socketScope: previous.socketScope,
251
+ socketPath: previous.sockPath,
252
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
241
253
  });
242
- });
243
- this.socketServer.listen(sockPath, () => {
244
- // Owner-only permissions: the socket exposes an RPC passthrough that
245
- // impersonates this agent to the server, so other local users on the
246
- // host must not be able to connect.
247
- try {
248
- fs.chmodSync(sockPath, SOCKET_FILE_MODE);
249
- }
250
- catch (err) {
251
- this.opts.logger?.warn("chmod 0600 on socket failed", err);
252
- }
253
- });
254
- // Symlink default path to this instance for CLI discovery
255
- try {
256
- fs.unlinkSync(MoltZapService.SOCKET_PATH);
257
- }
258
- catch (err) {
259
- // Stale/missing default symlink is expected most of the time; log
260
- // at debug so real permission errors still surface.
261
- this.opts.logger?.info("unlink default socket symlink", err);
262
- }
263
- try {
264
- fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
265
- }
266
- catch (err) {
267
- this.opts.logger?.warn("symlink default socket failed", err);
268
- }
254
+ const running = yield* startLocalSocketServer({
255
+ socketPath: this.socketPath,
256
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
257
+ handleRequest: (method, params) => this.handleSocketRequestEffect(method, params),
258
+ });
259
+ this.socketServerScope = running.socketScope;
260
+ this.activeSocketPath = running.socketPath;
261
+ }).pipe(Effect.withSpan("MoltZapService.startSocketServer"));
269
262
  }
270
- stopSocketServer() {
271
- this.socketServer?.close();
272
- this.socketServer = null;
263
+ resetSocketServerState() {
264
+ const socketScope = this.socketServerScope;
265
+ this.socketServerScope = null;
273
266
  const sockPath = this.activeSocketPath ?? this.socketPath;
274
267
  this.activeSocketPath = null;
275
- try {
276
- fs.unlinkSync(sockPath);
277
- }
278
- catch (err) {
279
- this.opts.logger?.info("unlink socket path", err);
280
- }
281
- // Remove default symlink only if it points to this instance.
282
- try {
283
- const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
284
- if (target === sockPath)
285
- fs.unlinkSync(MoltZapService.SOCKET_PATH);
286
- }
287
- catch (err) {
288
- this.opts.logger?.info("cleanup default symlink", err);
289
- }
268
+ return { socketScope, sockPath };
290
269
  }
291
- /**
292
- * Handle one JSON line from the unix socket as an Effect. Parses the
293
- * request, dispatches through `handleSocketRequestEffect`, and writes
294
- * either a `{result}` or `{error}` frame back onto the connection.
295
- * Never fails — all branches resolve into a `conn.write` side effect.
296
- */
297
- handleSocketLineEffect(line, conn) {
298
- return Effect.suspend(() => {
299
- let req;
300
- try {
301
- const parsed = JSON.parse(line);
302
- if (!isPlainRecord(parsed)) {
303
- return Effect.sync(() => conn.write(JSON.stringify({
304
- error: "request must be a JSON object",
305
- }) + "\n"));
306
- }
307
- req = parsed;
308
- }
309
- catch (err) {
310
- return Effect.sync(() => conn.write(JSON.stringify({
311
- error: err instanceof Error ? err.message : String(err),
312
- }) + "\n"));
313
- }
314
- if (typeof req.method !== "string" || !req.method) {
315
- return Effect.sync(() => conn.write(JSON.stringify({
316
- error: "method is required and must be a string",
317
- }) + "\n"));
318
- }
319
- const params = isPlainRecord(req.params) ? req.params : {};
320
- return this.handleSocketRequestEffect(req.method, params).pipe(Effect.match({
321
- onSuccess: (result) => {
322
- conn.write(JSON.stringify({ result }) + "\n");
323
- },
324
- onFailure: (err) => {
325
- conn.write(JSON.stringify({
326
- error: err instanceof Error ? err.message : String(err),
327
- }) + "\n");
328
- },
329
- }));
330
- });
270
+ stopSocketServer() {
271
+ const { socketScope, sockPath } = this.resetSocketServerState();
272
+ return stopLocalSocketServer({
273
+ socketScope,
274
+ socketPath: sockPath,
275
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
276
+ }).pipe(Effect.withSpan("MoltZapService.stopSocketServer"));
331
277
  }
332
278
  handleSocketRequestEffect(method, params) {
333
279
  return Effect.suspend(() => {
@@ -344,7 +290,8 @@ export class MoltZapService {
344
290
  return this.handleHistoryRequest(params);
345
291
  default: {
346
292
  const definition = rpcMethods.find((d) => d.name === method);
347
- if (definition === undefined || !definition.validateParams(params)) {
293
+ if (definition === undefined ||
294
+ !definition.validateParams(params)) {
348
295
  return Effect.fail(new ServiceInputError({
349
296
  message: `unknown or invalid RPC method: ${method}`,
350
297
  }));
@@ -356,85 +303,80 @@ export class MoltZapService {
356
303
  }
357
304
  handleHistoryRequest(params) {
358
305
  return Effect.gen(this, function* () {
359
- if (typeof params.conversationId !== "string" || !params.conversationId) {
360
- return yield* Effect.fail(new ServiceInputError({
361
- message: "conversationId is required and must be a string",
362
- }));
363
- }
364
- if (params.limit !== undefined && typeof params.limit !== "number") {
365
- return yield* Effect.fail(new ServiceInputError({ message: "limit must be a number" }));
366
- }
367
- const convId = params.conversationId;
368
- const limit = params.limit ?? DEFAULT_HISTORY_LIMIT;
369
- const sessionKey = params.sessionKey;
306
+ const request = yield* decodeHistoryRequest(params);
370
307
  const result = yield* this.sendRpc(MessagesList, {
371
- conversationId: convId,
372
- limit,
308
+ conversationId: request.conversationId,
309
+ limit: request.limit,
373
310
  });
374
- // Resolve agent names (batch) + fetch conversation metadata (concurrent)
375
- const knownNames = yield* Ref.get(this.agentNamesRef);
376
- const unknownAgentIds = [
377
- ...new Set(result.messages.map((m) => m.senderId)),
378
- ].filter((id) => !HashMap.has(knownNames, id));
379
- const lookupEff = unknownAgentIds.length > 0
380
- ? this.sendRpc(AgentsLookup, { agentIds: unknownAgentIds }).pipe(Effect.tap((res) => {
381
- return Ref.update(this.agentNamesRef, (names) => {
382
- let next = names;
383
- for (const a of res.agents) {
384
- next = HashMap.set(next, a.id, a.name);
385
- }
386
- return next;
387
- });
388
- }), Effect.asVoid, Effect.catchAll(() => Effect.void))
389
- : Effect.void;
390
- const metaEff = this.sendRpc(ConversationsGet, {
391
- conversationId: convId,
392
- }).pipe(Effect.map((res) => res.conversation), Effect.catchAll(() => Effect.succeed(undefined)));
393
- const [, convMeta] = yield* Effect.all([lookupEff, metaEff], {
394
- concurrency: HISTORY_LOOKUP_CONCURRENCY,
395
- });
396
- // Determine what's "new" using lastRead (not lastNotified).
397
- // lastNotified is advanced by getContext() (system-reminder).
398
- // lastRead is advanced here when the agent explicitly reads history.
399
- const allAgentNames = yield* Ref.get(this.agentNamesRef);
311
+ const convMeta = yield* this.loadHistorySupportData(request.conversationId, result.messages);
312
+ const agentNames = yield* Ref.get(this.agentNamesRef);
400
313
  const lastReadMap = yield* Ref.get(this.lastReadRef);
401
- const lastReadIds = sessionKey
402
- ? Option.getOrElse(Option.flatMap(HashMap.get(lastReadMap, sessionKey), (perConv) => HashMap.get(perConv, convId)), () => new Set())
403
- : new Set();
404
- const messages = result.messages.map((m) => {
405
- const text = m.parts.map(renderPart).join(" ");
406
- const senderName = Option.getOrElse(HashMap.get(allAgentNames, m.senderId), () => m.senderId);
407
- return {
408
- id: m.id,
409
- senderId: m.senderId,
410
- senderName: m.senderId === this.ownAgentId ? "you" : senderName,
411
- isOwn: m.senderId === this.ownAgentId,
412
- text,
413
- createdAt: m.createdAt,
414
- isNew: sessionKey ? !lastReadIds.has(m.id) : false,
415
- };
416
- });
417
- // Advance lastRead to include all message IDs in the returned page.
418
- if (sessionKey && result.messages.length > 0) {
419
- yield* Ref.update(this.lastReadRef, (outer) => {
420
- const perSession = getOr(outer, sessionKey, () => HashMap.empty());
421
- const existing = getOr(perSession, convId, () => new Set());
422
- if (result.messages.every((m) => existing.has(m.id)))
423
- return outer;
424
- const nextSet = new Set(existing);
425
- for (const m of result.messages)
426
- nextSet.add(m.id);
427
- return HashMap.set(outer, sessionKey, HashMap.set(perSession, convId, nextSet));
428
- });
429
- }
314
+ const lastReadIds = lastReadIdsForSession(lastReadMap, request);
315
+ const messages = result.messages.map((message) => formatHistoryMessage(message, {
316
+ agentNames,
317
+ ownAgentId: this.ownAgentId,
318
+ lastReadIds,
319
+ hasSessionKey: request.sessionKey !== undefined,
320
+ }));
321
+ yield* this.advanceHistoryLastRead(request, result.messages);
430
322
  return {
431
323
  messages,
432
324
  hasMore: result.hasMore,
433
325
  conversationMeta: convMeta,
434
- newCount: messages.filter((m) => m.isNew).length,
326
+ newCount: messages.filter((message) => message.isNew).length,
435
327
  };
436
328
  });
437
329
  }
330
+ loadHistorySupportData(convId, messages) {
331
+ return Effect.gen(this, function* () {
332
+ const [, convMeta] = yield* Effect.all([
333
+ this.refreshHistoryAgentNames(messages),
334
+ this.fetchHistoryConversationMeta(convId),
335
+ ], {
336
+ concurrency: HISTORY_LOOKUP_CONCURRENCY,
337
+ });
338
+ return convMeta;
339
+ });
340
+ }
341
+ refreshHistoryAgentNames(messages) {
342
+ return Effect.gen(this, function* () {
343
+ const knownNames = yield* Ref.get(this.agentNamesRef);
344
+ const unknownAgentIds = [
345
+ ...new Set(messages.map((message) => message.senderId)),
346
+ ].filter((id) => !HashMap.has(knownNames, id));
347
+ if (unknownAgentIds.length === 0)
348
+ return;
349
+ yield* this.sendRpc(AgentsLookup, { agentIds: unknownAgentIds }).pipe(Effect.tap((result) => Ref.update(this.agentNamesRef, (names) => {
350
+ let next = names;
351
+ for (const agent of result.agents) {
352
+ next = HashMap.set(next, agent.id, agent.name);
353
+ }
354
+ return next;
355
+ })), Effect.asVoid, Effect.catchAll(() => Effect.void));
356
+ });
357
+ }
358
+ fetchHistoryConversationMeta(convId) {
359
+ return this.sendRpc(ConversationsGet, {
360
+ conversationId: convId,
361
+ }).pipe(Effect.map((result) => result.conversation), Effect.catchAll(() => Effect.succeed(undefined)));
362
+ }
363
+ advanceHistoryLastRead(request, messages) {
364
+ if (request.sessionKey === undefined || messages.length === 0) {
365
+ return Effect.void;
366
+ }
367
+ const { conversationId, sessionKey } = request;
368
+ return Ref.update(this.lastReadRef, (outer) => {
369
+ const perSession = getOr(outer, sessionKey, () => HashMap.empty());
370
+ const existing = getOr(perSession, conversationId, () => new Set());
371
+ if (messages.every((message) => existing.has(message.id)))
372
+ return outer;
373
+ const nextSet = new Set(existing);
374
+ for (const message of messages) {
375
+ nextSet.add(message.id);
376
+ }
377
+ return HashMap.set(outer, sessionKey, HashMap.set(perSession, conversationId, nextSet));
378
+ });
379
+ }
438
380
  // --- Conversations ---
439
381
  getConversation(convId) {
440
382
  return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
@@ -538,7 +480,8 @@ export class MoltZapService {
538
480
  * per viewing conversation and advanced after notification.
539
481
  */
540
482
  getContext(currentConvId, opts) {
541
- const { entries, commit } = this.peekContextEntries(currentConvId, opts);
483
+ const contextOptions = opts ?? {};
484
+ const { entries, commit } = this.peekContextEntries(currentConvId, contextOptions);
542
485
  if (entries.length === 0)
543
486
  return null;
544
487
  commit();
@@ -555,40 +498,9 @@ export class MoltZapService {
555
498
  peekContextEntries(currentConvId, opts) {
556
499
  const maxConvs = opts?.maxConversations ?? DEFAULT_MAX_CONTEXT_CONVERSATIONS;
557
500
  const maxMsgsPerConv = opts?.maxMessagesPerConv ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION;
558
- const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
559
- // Collect all candidate conversations with their last-message timestamp,
560
- // then sort by recency before applying `maxConvs`. HashMap iteration
561
- // order is hash-based (not insertion/recency), so without the sort the
562
- // maxConvs truncation would pick an arbitrary subset and could omit the
563
- // freshest conversations.
564
- const candidates = [];
565
- for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
566
- const last = newMsgs[newMsgs.length - 1];
567
- candidates.push({
568
- convId,
569
- newMsgs,
570
- lastTs: new Date(last.createdAt).getTime(),
571
- });
572
- }
573
- candidates.sort((a, b) => b.lastTs - a.lastTs);
574
- const entries = [];
575
- const pendingAdvances = [];
576
- for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
577
- const reportable = newMsgs.slice(-maxMsgsPerConv);
578
- const last = reportable[reportable.length - 1];
579
- const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
580
- const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) /
581
- MILLISECONDS_PER_MINUTE));
582
- entries.push({
583
- conversationId: convId,
584
- conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
585
- senderName,
586
- text: last.parts.map(renderPart).join(" "),
587
- minutesAgo,
588
- count: reportable.length,
589
- });
590
- pendingAdvances.push([convId, last.id]);
591
- }
501
+ const state = this.readCrossConvState(currentConvId);
502
+ const candidates = this.collectContextCandidates(state, currentConvId);
503
+ const { entries, pendingAdvances } = buildContextEntries(candidates.slice(0, maxConvs), state, maxMsgsPerConv);
592
504
  return {
593
505
  entries,
594
506
  commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
@@ -632,18 +544,19 @@ export class MoltZapService {
632
544
  viewMarkers: getOr(lastNotifiedMap, currentConvId, () => HashMap.empty()),
633
545
  };
634
546
  }
547
+ collectContextCandidates(state, currentConvId) {
548
+ const candidates = [];
549
+ for (const [convId, newMsgs] of this.iterNewMessagesByConv(state.messagesMap, state.viewMarkers, currentConvId)) {
550
+ candidates.push(makeContextCandidate(convId, newMsgs));
551
+ }
552
+ candidates.sort((a, b) => b.lastTs - a.lastTs);
553
+ return candidates;
554
+ }
635
555
  *iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId) {
636
556
  for (const [convId, msgs] of messagesMap) {
637
- if (convId === currentConvId || msgs.length === 0)
638
- continue;
639
- const lastSeenId = Option.getOrUndefined(HashMap.get(viewMarkers, convId));
640
- const lastSeenIdx = lastSeenId
641
- ? msgs.findIndex((m) => m.id === lastSeenId)
642
- : -1;
643
- const newMsgs = msgs.slice(lastSeenIdx + 1);
644
- if (newMsgs.length === 0)
645
- continue;
646
- yield [convId, newMsgs];
557
+ const newMsgs = newMessagesForConversation(convId, msgs, viewMarkers, currentConvId);
558
+ if (newMsgs.length > 0)
559
+ yield [convId, newMsgs];
647
560
  }
648
561
  }
649
562
  advanceLastNotified(currentConvId, pendingAdvances) {
@@ -698,79 +611,60 @@ export class MoltZapService {
698
611
  Effect.catchAll(() => Effect.void));
699
612
  }
700
613
  handleNotification(notification) {
701
- const params = isPlainRecord(notification.params)
702
- ? notification.params
703
- : {};
704
- const message = isPlainRecord(params["message"])
705
- ? params["message"]
706
- : undefined;
707
- const conversation = isPlainRecord(params["conversation"])
708
- ? params["conversation"]
709
- : undefined;
710
- const notificationConversationId = typeof params["conversationId"] === "string"
711
- ? params["conversationId"]
712
- : undefined;
713
- appendClientEventTrace({
714
- ts: new Date().toISOString(),
715
- agentId: this._ownAgentId ?? "unknown",
716
- notification: notification.method,
717
- messageId: message?.["id"],
718
- messageConversationId: message?.["conversationId"],
719
- messageSenderId: message?.["senderId"],
720
- conversationId: conversation?.["id"] ?? notificationConversationId,
721
- conversationName: conversation?.["name"],
722
- });
723
- fanout(this.handlers.rawNotification, notification, this.opts.logger);
724
- // Direct descriptor-keyed dispatch. Each branch narrows on the
725
- // descriptor identity carried by the decoded notification — same
726
- // payload-validation guarantee as before, without the extra
727
- // group-decoder indirection (params were validated upstream by
728
- // `decodeServerInbound` / `decodeFrames`).
729
- switch (notification.definition) {
730
- case MessageReceivedNotificationDefinition:
731
- this.handleMessageReceivedNotification(notification.params);
732
- return;
733
- case ConversationCreatedNotificationDefinition:
734
- this.handleConversationCreatedNotification(notification.params);
735
- return;
736
- case ConversationUpdatedNotificationDefinition:
737
- this.handleConversationUpdatedNotification(notification.params);
738
- return;
739
- case ConversationArchivedNotificationDefinition: {
740
- const params = notification.params;
741
- this.markConversationArchived(params.conversationId);
742
- fanout(this.handlers.conversationArchived, params, this.opts.logger);
743
- return;
744
- }
745
- case ConversationUnarchivedNotificationDefinition: {
746
- const params = notification.params;
747
- this.archivedConversationIds.delete(params.conversationId);
748
- fanout(this.handlers.conversationUnarchived, params, this.opts.logger);
749
- return;
750
- }
751
- case DispatchRelease:
752
- fanout(this.handlers.dispatchRelease, notification.params, this.opts.logger);
753
- return;
754
- case DispatchesConsumed:
755
- fanout(this.handlers.dispatchesConsumed, notification.params, this.opts.logger);
756
- return;
757
- case DispatchesExpired:
758
- fanout(this.handlers.dispatchesExpired, notification.params, this.opts.logger);
759
- return;
760
- default:
761
- // Other notification descriptors flow through `rawNotification`
762
- // fanout above; the service has no typed bridge for them.
763
- return;
614
+ this.recordNotificationTrace(notification);
615
+ fanout(this.handlers.rawNotification, notification);
616
+ this.dispatchTypedNotification(notification);
617
+ }
618
+ recordNotificationTrace(notification) {
619
+ Effect.runFork(appendClientEventTrace(notificationTraceRecord(notification, this._ownAgentId)));
620
+ }
621
+ dispatchTypedNotification(notification) {
622
+ const dispatch = this.notificationDispatchers.get(notification.definition);
623
+ if (dispatch === undefined) {
624
+ return;
764
625
  }
626
+ dispatch(notification);
627
+ }
628
+ /**
629
+ * Record `messageId` in the per-conversation dedup window. Returns
630
+ * `true` when the id is new (caller proceeds), `false` when the id is
631
+ * a duplicate within the window (caller drops). On a new id, evicts
632
+ * the oldest entry if the window is full.
633
+ *
634
+ * Bound to the live `messages/received` notification path: a single
635
+ * server-side broadcast that surfaces the same id twice (network
636
+ * replay, dual subscription) is suppressed to one
637
+ * `on("message", ...)` event. `messages/list` returns raw history
638
+ * unfiltered; consumers that combine both feeds dedup themselves.
639
+ */
640
+ recordMessageIdIfNew(convId, msgId) {
641
+ let dedupSet = this.seenMessageIds.get(convId);
642
+ if (dedupSet === undefined) {
643
+ dedupSet = new Set();
644
+ this.seenMessageIds.set(convId, dedupSet);
645
+ }
646
+ if (dedupSet.has(msgId)) {
647
+ return false;
648
+ }
649
+ if (dedupSet.size >= DEDUP_WINDOW_PER_CONV) {
650
+ // size >= 1 guaranteed by the guard above; next().value is defined.
651
+ dedupSet.delete(dedupSet.keys().next().value);
652
+ }
653
+ dedupSet.add(msgId);
654
+ return true;
765
655
  }
766
656
  handleMessageReceivedNotification(notification) {
767
657
  const msg = notification.message;
658
+ const convId = msg.conversationId;
659
+ if (!this.recordMessageIdIfNew(convId, msg.id)) {
660
+ return;
661
+ }
768
662
  this.storeMessage(msg);
769
663
  // Name resolution is driven lazily by channel-core's serialized consumer
770
664
  // via resolveAgentName(), which populates agentNamesRef on first miss and
771
665
  // hits the cache on every subsequent message.
772
666
  if (msg.senderId !== this._ownAgentId) {
773
- fanout(this.handlers.message, msg, this.opts.logger);
667
+ fanout(this.handlers.message, msg);
774
668
  }
775
669
  }
776
670
  handleConversationCreatedNotification(notification) {
@@ -780,6 +674,14 @@ export class MoltZapService {
780
674
  handleConversationUpdatedNotification(notification) {
781
675
  this.upsertConversationNotification(notification);
782
676
  }
677
+ handleConversationArchivedNotification(notification) {
678
+ this.markConversationArchived(notification.conversationId);
679
+ fanout(this.handlers.conversationArchived, notification);
680
+ }
681
+ handleConversationUnarchivedNotification(notification) {
682
+ this.archivedConversationIds.delete(notification.conversationId);
683
+ fanout(this.handlers.conversationUnarchived, notification);
684
+ }
783
685
  upsertConversationNotification(notification) {
784
686
  const { conversation } = notification;
785
687
  this.archivedConversationIds.delete(conversation.id);
@@ -796,6 +698,7 @@ export class MoltZapService {
796
698
  }));
797
699
  }
798
700
  markConversationArchived(conversationId) {
701
+ this.seenMessageIds.delete(conversationId);
799
702
  this.archivedConversationIds.add(conversationId);
800
703
  Effect.runSync(Effect.all([
801
704
  Ref.update(this.conversationsRef, (m) => HashMap.remove(m, conversationId)),