@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.
- package/dist/auth.d.ts +12 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +67 -29
- package/dist/auth.js.map +1 -1
- package/dist/auth.test.js +113 -76
- package/dist/auth.test.js.map +1 -1
- package/dist/channel-core-context.test.d.ts +2 -0
- package/dist/channel-core-context.test.d.ts.map +1 -0
- package/dist/channel-core-context.test.js +334 -0
- package/dist/channel-core-context.test.js.map +1 -0
- package/dist/channel-core-dispatch.test.d.ts +2 -0
- package/dist/channel-core-dispatch.test.d.ts.map +1 -0
- package/dist/channel-core-dispatch.test.js +550 -0
- package/dist/channel-core-dispatch.test.js.map +1 -0
- package/dist/channel-core-enrichment.d.ts +8 -0
- package/dist/channel-core-enrichment.d.ts.map +1 -0
- package/dist/channel-core-enrichment.js +134 -0
- package/dist/channel-core-enrichment.js.map +1 -0
- package/dist/channel-core-errors.d.ts +20 -0
- package/dist/channel-core-errors.d.ts.map +1 -0
- package/dist/channel-core-errors.js +12 -0
- package/dist/channel-core-errors.js.map +1 -0
- package/dist/channel-core-test-support.d.ts +48 -0
- package/dist/channel-core-test-support.d.ts.map +1 -0
- package/dist/channel-core-test-support.js +111 -0
- package/dist/channel-core-test-support.js.map +1 -0
- package/dist/channel-core.d.ts +30 -7
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +214 -237
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +216 -1082
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
- package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.js +64 -54
- package/dist/cli/commands/conversations-v2.test.js.map +1 -1
- package/dist/cli/commands/conversations.d.ts +5 -5
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +43 -36
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +4 -4
- package/dist/cli/commands/messages.d.ts.map +1 -1
- package/dist/cli/commands/messages.js +4 -4
- package/dist/cli/commands/messages.js.map +1 -1
- package/dist/cli/commands/messages.test.js +61 -49
- package/dist/cli/commands/messages.test.js.map +1 -1
- package/dist/cli/commands/ping.d.ts +0 -9
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +1 -1
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts +1 -1
- package/dist/cli/commands/presence.js +1 -1
- package/dist/cli/commands/register.d.ts +2 -2
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +98 -77
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/register.test.js +63 -39
- package/dist/cli/commands/register.test.js.map +1 -1
- package/dist/cli/commands/send.d.ts +6 -6
- package/dist/cli/commands/send.js +6 -6
- package/dist/cli/commands/send.test.js +32 -28
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +3 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config.d.ts +0 -2
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -76
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/config.test.js +80 -42
- package/dist/cli/config.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -16
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -113
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/process-argv.d.ts +2 -0
- package/dist/cli/process-argv.d.ts.map +1 -0
- package/dist/cli/process-argv.js +2 -0
- package/dist/cli/process-argv.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -10
- package/dist/cli/profile.d.ts.map +1 -1
- package/dist/cli/profile.js +105 -104
- package/dist/cli/profile.js.map +1 -1
- package/dist/cli/profile.test.js +338 -232
- package/dist/cli/profile.test.js.map +1 -1
- package/dist/cli/runtime.d.ts +5 -17
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +6 -59
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli/socket-client.d.ts +10 -15
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +74 -89
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +9 -5
- package/dist/cli/transport.d.ts.map +1 -1
- package/dist/cli/transport.js +45 -42
- package/dist/cli/transport.js.map +1 -1
- package/dist/cli/transport.test.js +227 -133
- package/dist/cli/transport.test.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-daemon-rpc.d.ts +13 -0
- package/dist/local-daemon-rpc.d.ts.map +1 -0
- package/dist/local-daemon-rpc.js +17 -0
- package/dist/local-daemon-rpc.js.map +1 -0
- package/dist/local-paths.d.ts +7 -0
- package/dist/local-paths.d.ts.map +1 -0
- package/dist/local-paths.js +20 -0
- package/dist/local-paths.js.map +1 -0
- package/dist/runtime/close-info.d.ts +3 -12
- package/dist/runtime/close-info.d.ts.map +1 -1
- package/dist/runtime/close-info.js +28 -39
- package/dist/runtime/close-info.js.map +1 -1
- package/dist/runtime/close-info.test.js +77 -70
- package/dist/runtime/close-info.test.js.map +1 -1
- package/dist/runtime/errors.test.js +42 -28
- package/dist/runtime/errors.test.js.map +1 -1
- package/dist/runtime/frame.d.ts +4 -31
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +7 -116
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/frame.test.js +41 -54
- package/dist/runtime/frame.test.js.map +1 -1
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/local-history.d.ts +67 -0
- package/dist/runtime/local-history.d.ts.map +1 -0
- package/dist/runtime/local-history.js +77 -0
- package/dist/runtime/local-history.js.map +1 -0
- package/dist/runtime/local-service-commands.d.ts +19 -0
- package/dist/runtime/local-service-commands.d.ts.map +1 -1
- package/dist/runtime/local-service-commands.js +23 -0
- package/dist/runtime/local-service-commands.js.map +1 -1
- package/dist/runtime/local-socket-server.d.ts +20 -0
- package/dist/runtime/local-socket-server.d.ts.map +1 -0
- package/dist/runtime/local-socket-server.js +94 -0
- package/dist/runtime/local-socket-server.js.map +1 -0
- package/dist/runtime/refs.d.ts +1 -1
- package/dist/runtime/refs.js +1 -1
- package/dist/runtime/runtime.test.js +29 -21
- package/dist/runtime/runtime.test.js.map +1 -1
- package/dist/runtime/service-helpers.d.ts +32 -0
- package/dist/runtime/service-helpers.d.ts.map +1 -0
- package/dist/runtime/service-helpers.js +85 -0
- package/dist/runtime/service-helpers.js.map +1 -0
- package/dist/runtime/subscribers.d.ts +13 -17
- package/dist/runtime/subscribers.d.ts.map +1 -1
- package/dist/runtime/subscribers.js +37 -32
- package/dist/runtime/subscribers.js.map +1 -1
- package/dist/runtime/subscribers.test.js +139 -131
- package/dist/runtime/subscribers.test.js.map +1 -1
- package/dist/service-socket-path.test.d.ts +2 -0
- package/dist/service-socket-path.test.d.ts.map +1 -0
- package/dist/service-socket-path.test.js +87 -0
- package/dist/service-socket-path.test.js.map +1 -0
- package/dist/service.d.ts +46 -20
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +243 -340
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +650 -571
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +30 -14
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +25 -21
- package/dist/test/index.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -0
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +139 -98
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
- package/dist/test-utils/conformance-adapter.js +145 -124
- package/dist/test-utils/conformance-adapter.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +3 -3
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/test-utils/index.d.ts +25 -0
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +25 -8
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client-test-support.d.ts +180 -0
- package/dist/ws-client-test-support.d.ts.map +1 -0
- package/dist/ws-client-test-support.js +399 -0
- package/dist/ws-client-test-support.js.map +1 -0
- package/dist/ws-client.d.ts +59 -32
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +272 -321
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +388 -800
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +14 -10
package/dist/service.js
CHANGED
|
@@ -1,50 +1,39 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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,
|
|
7
|
-
import { MoltZapWsClient
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
31
|
+
/** Escape `<`, `>`, `&` so sender content can't escape a `<system-reminder>` block. */
|
|
43
32
|
export function sanitizeForSystemReminder(s) {
|
|
44
33
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
45
34
|
}
|
|
46
35
|
/**
|
|
47
|
-
* Format CrossConversationEntry[] as a
|
|
36
|
+
* Format CrossConversationEntry[] as a `<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.
|
|
70
|
+
* doesn't abort the remaining fanout.
|
|
80
71
|
*/
|
|
81
|
-
function fanout(handlers, arg
|
|
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
|
-
|
|
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: (
|
|
145
|
-
void close;
|
|
178
|
+
onDisconnect: () => {
|
|
146
179
|
this._connected = false;
|
|
147
|
-
fanout(this.handlers.disconnect, undefined
|
|
180
|
+
fanout(this.handlers.disconnect, undefined);
|
|
148
181
|
},
|
|
149
182
|
onReconnect: (helloOk) => {
|
|
150
183
|
this._connected = true;
|
|
151
|
-
fanout(this.handlers.reconnect, helloOk
|
|
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
|
|
169
|
-
*
|
|
170
|
-
* shutdown
|
|
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
|
-
|
|
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
|
-
|
|
228
|
+
socketServerScope = null;
|
|
195
229
|
activeSocketPath = null;
|
|
196
230
|
/** Default socket path for CLI discovery. Per-instance path uses agentId. */
|
|
197
|
-
static SOCKET_PATH =
|
|
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
|
|
244
|
+
return getMoltZapAgentServiceSocketPath(id);
|
|
211
245
|
}
|
|
212
246
|
startSocketServer() {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
271
|
-
this.
|
|
272
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
372
|
-
limit,
|
|
308
|
+
conversationId: request.conversationId,
|
|
309
|
+
limit: request.limit,
|
|
373
310
|
});
|
|
374
|
-
|
|
375
|
-
const
|
|
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 =
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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((
|
|
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
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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)),
|