@moltzap/client 2026.414.0 → 2026.425.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.
- package/dist/channel-core.d.ts +28 -15
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +113 -74
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +126 -34
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts +2 -0
- package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts.map +1 -0
- package/dist/cli/__tests__/cli-multi-agent.int.test.js +26 -0
- package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -0
- package/dist/cli/commands/agents.d.ts +13 -2
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +30 -18
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/apps.d.ts +96 -0
- package/dist/cli/commands/apps.d.ts.map +1 -0
- package/dist/cli/commands/apps.js +193 -0
- package/dist/cli/commands/apps.js.map +1 -0
- package/dist/cli/commands/apps.test.d.ts +2 -0
- package/dist/cli/commands/apps.test.d.ts.map +1 -0
- package/dist/cli/commands/apps.test.js +220 -0
- package/dist/cli/commands/apps.test.js.map +1 -0
- package/dist/cli/commands/contacts.d.ts +18 -2
- package/dist/cli/commands/contacts.d.ts.map +1 -1
- package/dist/cli/commands/contacts.js +44 -52
- package/dist/cli/commands/contacts.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.d.ts +2 -0
- package/dist/cli/commands/conversations-v2.test.d.ts.map +1 -0
- package/dist/cli/commands/conversations-v2.test.js +92 -0
- package/dist/cli/commands/conversations-v2.test.js.map +1 -0
- package/dist/cli/commands/conversations.d.ts +73 -3
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +157 -108
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/invite.d.ts +6 -2
- package/dist/cli/commands/invite.d.ts.map +1 -1
- package/dist/cli/commands/invite.js +8 -6
- package/dist/cli/commands/invite.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +48 -0
- package/dist/cli/commands/messages.d.ts.map +1 -0
- package/dist/cli/commands/messages.js +68 -0
- package/dist/cli/commands/messages.js.map +1 -0
- package/dist/cli/commands/messages.test.d.ts +2 -0
- package/dist/cli/commands/messages.test.d.ts.map +1 -0
- package/dist/cli/commands/messages.test.js +73 -0
- package/dist/cli/commands/messages.test.js.map +1 -0
- package/dist/cli/commands/permissions.d.ts +66 -0
- package/dist/cli/commands/permissions.d.ts.map +1 -0
- package/dist/cli/commands/permissions.js +101 -0
- package/dist/cli/commands/permissions.js.map +1 -0
- package/dist/cli/commands/permissions.test.d.ts +2 -0
- package/dist/cli/commands/permissions.test.d.ts.map +1 -0
- package/dist/cli/commands/permissions.test.js +126 -0
- package/dist/cli/commands/permissions.test.js.map +1 -0
- package/dist/cli/commands/ping.d.ts +6 -2
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +21 -20
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts +10 -2
- package/dist/cli/commands/presence.d.ts.map +1 -1
- package/dist/cli/commands/presence.js +27 -15
- package/dist/cli/commands/presence.js.map +1 -1
- package/dist/cli/commands/register.d.ts +24 -2
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +123 -47
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/register.test.js +45 -27
- package/dist/cli/commands/register.test.js.map +1 -1
- package/dist/cli/commands/send.d.ts +43 -2
- package/dist/cli/commands/send.d.ts.map +1 -1
- package/dist/cli/commands/send.js +55 -13
- package/dist/cli/commands/send.js.map +1 -1
- package/dist/cli/commands/send.test.js +20 -19
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.d.ts +6 -2
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +16 -10
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/whoami.d.ts +7 -2
- package/dist/cli/commands/whoami.d.ts.map +1 -1
- package/dist/cli/commands/whoami.js +13 -7
- package/dist/cli/commands/whoami.js.map +1 -1
- package/dist/cli/config.d.ts +24 -8
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +65 -39
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/config.test.js +12 -10
- package/dist/cli/config.test.js.map +1 -1
- package/dist/cli/http-client.d.ts +6 -2
- package/dist/cli/http-client.d.ts.map +1 -1
- package/dist/cli/http-client.js +25 -24
- package/dist/cli/http-client.js.map +1 -1
- package/dist/cli/index.d.ts +16 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +160 -27
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/profile.d.ts +134 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +217 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +263 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/runtime.d.ts +34 -0
- package/dist/cli/runtime.d.ts.map +1 -0
- package/dist/cli/runtime.js +94 -0
- package/dist/cli/runtime.js.map +1 -0
- package/dist/cli/socket-client.d.ts +15 -12
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +72 -75
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +181 -0
- package/dist/cli/transport.d.ts.map +1 -0
- package/dist/cli/transport.js +324 -0
- package/dist/cli/transport.js.map +1 -0
- package/dist/cli/transport.test.d.ts +2 -0
- package/dist/cli/transport.test.d.ts.map +1 -0
- package/dist/cli/transport.test.js +197 -0
- package/dist/cli/transport.test.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/close-info.d.ts +94 -0
- package/dist/runtime/close-info.d.ts.map +1 -0
- package/dist/runtime/close-info.js +109 -0
- package/dist/runtime/close-info.js.map +1 -0
- package/dist/runtime/close-info.test.d.ts +2 -0
- package/dist/runtime/close-info.test.d.ts.map +1 -0
- package/dist/runtime/close-info.test.js +102 -0
- package/dist/runtime/close-info.test.js.map +1 -0
- package/dist/runtime/errors.d.ts +47 -0
- package/dist/runtime/errors.d.ts.map +1 -0
- package/dist/runtime/errors.js +20 -0
- package/dist/runtime/errors.js.map +1 -0
- package/dist/runtime/errors.test.d.ts +2 -0
- package/dist/runtime/errors.test.d.ts.map +1 -0
- package/dist/runtime/errors.test.js +66 -0
- package/dist/runtime/errors.test.js.map +1 -0
- package/dist/runtime/frame.d.ts +25 -0
- package/dist/runtime/frame.d.ts.map +1 -0
- package/dist/runtime/frame.js +106 -0
- package/dist/runtime/frame.js.map +1 -0
- package/dist/runtime/frame.test.d.ts +2 -0
- package/dist/runtime/frame.test.d.ts.map +1 -0
- package/dist/runtime/frame.test.js +32 -0
- package/dist/runtime/frame.test.js.map +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/refs.d.ts +10 -0
- package/dist/runtime/refs.d.ts.map +1 -0
- package/dist/runtime/refs.js +10 -0
- package/dist/runtime/refs.js.map +1 -0
- package/dist/runtime/runtime.test.d.ts +2 -0
- package/dist/runtime/runtime.test.d.ts.map +1 -0
- package/dist/runtime/runtime.test.js +29 -0
- package/dist/runtime/runtime.test.js.map +1 -0
- package/dist/runtime/subscribers.d.ts +157 -0
- package/dist/runtime/subscribers.d.ts.map +1 -0
- package/dist/runtime/subscribers.js +131 -0
- package/dist/runtime/subscribers.js.map +1 -0
- package/dist/runtime/subscribers.test.d.ts +2 -0
- package/dist/runtime/subscribers.test.d.ts.map +1 -0
- package/dist/runtime/subscribers.test.js +155 -0
- package/dist/runtime/subscribers.test.js.map +1 -0
- package/dist/service.d.ts +68 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +461 -307
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +266 -77
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +33 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +44 -0
- package/dist/test/index.js.map +1 -0
- package/dist/test-utils/channel-service-fixture.d.ts +2 -1
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +31 -11
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts +50 -0
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -0
- package/dist/test-utils/conformance-adapter.js +206 -0
- package/dist/test-utils/conformance-adapter.js.map +1 -0
- package/dist/test-utils/fake-service.d.ts +85 -0
- package/dist/test-utils/fake-service.d.ts.map +1 -0
- package/dist/test-utils/fake-service.js +103 -0
- package/dist/test-utils/fake-service.js.map +1 -0
- package/dist/test-utils/index.d.ts +2 -0
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +3 -2
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client.d.ts +157 -18
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +432 -94
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.d.ts +2 -0
- package/dist/ws-client.test.d.ts.map +1 -0
- package/dist/ws-client.test.js +672 -0
- package/dist/ws-client.test.js.map +1 -0
- package/package.json +17 -8
- package/dist/__tests__/service.integration.test.d.ts +0 -2
- package/dist/__tests__/service.integration.test.d.ts.map +0 -1
- package/dist/__tests__/service.integration.test.js +0 -1087
- package/dist/__tests__/service.integration.test.js.map +0 -1
- package/dist/cli/commands/delete.d.ts +0 -3
- package/dist/cli/commands/delete.d.ts.map +0 -1
- package/dist/cli/commands/delete.js +0 -10
- package/dist/cli/commands/delete.js.map +0 -1
- package/dist/cli/commands/react.d.ts +0 -3
- package/dist/cli/commands/react.d.ts.map +0 -1
- package/dist/cli/commands/react.js +0 -18
- package/dist/cli/commands/react.js.map +0 -1
package/dist/service.js
CHANGED
|
@@ -3,7 +3,10 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import { EventNames, } from "@moltzap/protocol";
|
|
6
|
+
import { Effect, HashMap, Match, Option, Ref } from "effect";
|
|
6
7
|
import { MoltZapWsClient } from "./ws-client.js";
|
|
8
|
+
import { AgentNotFoundError, NotConnectedError, RpcServerError, RpcTimeoutError, } from "./runtime/errors.js";
|
|
9
|
+
import { getOr, snapshot } from "./runtime/refs.js";
|
|
7
10
|
/** Escape `<`, `>`, `&` so sender content can't escape a `<system-reminder>` block. */
|
|
8
11
|
export function sanitizeForSystemReminder(s) {
|
|
9
12
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -28,89 +31,144 @@ export function formatCrossConversationBlock(entries, opts) {
|
|
|
28
31
|
"</system-reminder>",
|
|
29
32
|
].join("\n");
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
const renderPart = Match.type().pipe(Match.discriminatorsExhaustive("type")({
|
|
35
|
+
text: (t) => t.text,
|
|
36
|
+
image: () => "[image]",
|
|
37
|
+
file: (f) => `[file: ${f.name}]`,
|
|
38
|
+
}));
|
|
39
|
+
/**
|
|
40
|
+
* Per-conversation message cap. Older messages are evicted FIFO; the
|
|
41
|
+
* on-disk history remains the source of truth. Sized for typical CLI
|
|
42
|
+
* display windows — `conversations get` shows at most a few hundred.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_MESSAGES_PER_CONV = 1000;
|
|
45
|
+
/**
|
|
46
|
+
* Invoke every handler with `arg`, isolating throws so one bad handler
|
|
47
|
+
* doesn't abort the remaining fanout. Logs via the optional client logger.
|
|
48
|
+
*/
|
|
49
|
+
function fanout(handlers, arg, logger) {
|
|
50
|
+
for (const h of handlers) {
|
|
51
|
+
try {
|
|
52
|
+
h(arg);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
logger?.error("event handler threw", err);
|
|
56
|
+
}
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
59
|
/**
|
|
44
60
|
* Stateful MoltZap client that manages connection, conversation tracking,
|
|
45
61
|
* agent name resolution, and cross-conversation context generation.
|
|
62
|
+
*
|
|
63
|
+
* API contract: **every fallible method returns `Effect`.** There are no
|
|
64
|
+
* `*Async` Promise siblings (unlike `@moltzap/app-sdk`'s `MoltZapApp`);
|
|
65
|
+
* async/await consumers run the Effect at the edge with `Effect.runPromise`.
|
|
66
|
+
* `@moltzap/app-sdk` is the public app-facing surface and layers its own
|
|
67
|
+
* `*Async` wrappers on top. Keep this class Effect-only so downstream
|
|
68
|
+
* callers compose failures and cancellation explicitly.
|
|
46
69
|
*/
|
|
47
70
|
export class MoltZapService {
|
|
48
71
|
opts;
|
|
49
72
|
client = null;
|
|
50
73
|
_connected = false;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
conversationsRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
75
|
+
messagesRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
76
|
+
agentNamesRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
77
|
+
agentConversationCacheRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
78
|
+
lastNotifiedRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
79
|
+
lastReadRef = Effect.runSync(Ref.make(HashMap.empty()));
|
|
57
80
|
messageHandlers = [];
|
|
58
81
|
rawEventHandlers = [];
|
|
59
82
|
disconnectHandlers = [];
|
|
60
83
|
reconnectHandlers = [];
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
permissionRequiredHandlers = [];
|
|
85
|
+
_ownAgentId;
|
|
63
86
|
constructor(opts) {
|
|
64
87
|
this.opts = opts;
|
|
65
88
|
}
|
|
66
89
|
get connected() {
|
|
67
90
|
return this._connected;
|
|
68
91
|
}
|
|
69
|
-
|
|
70
|
-
this.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
get ownAgentId() {
|
|
93
|
+
return this._ownAgentId;
|
|
94
|
+
}
|
|
95
|
+
/** Effect-native: compose via `yield*` or bridge at the edge via `Effect.runPromise`. */
|
|
96
|
+
connect() {
|
|
97
|
+
return Effect.gen(this, function* () {
|
|
98
|
+
this.client = new MoltZapWsClient({
|
|
99
|
+
serverUrl: this.opts.serverUrl,
|
|
100
|
+
agentKey: this.opts.agentKey,
|
|
101
|
+
logger: this.opts.logger,
|
|
102
|
+
// Spec #222 OQ-6: arg required. The body doesn't branch on
|
|
103
|
+
// close metadata today; signature kept explicit so a future
|
|
104
|
+
// disconnect-handler chain can plumb code/reason through.
|
|
105
|
+
onDisconnect: (_close) => {
|
|
106
|
+
this._connected = false;
|
|
107
|
+
fanout(this.disconnectHandlers, undefined, this.opts.logger);
|
|
108
|
+
},
|
|
109
|
+
onReconnect: (helloOk) => {
|
|
110
|
+
this._connected = true;
|
|
111
|
+
const hello = helloOk;
|
|
112
|
+
this.populateFromHello(hello);
|
|
113
|
+
fanout(this.reconnectHandlers, hello, this.opts.logger);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
// Spec #222 OQ-4 deletion: per-event `onEvent` callback is gone.
|
|
117
|
+
// Replacement: register a `{}` filter subscription before
|
|
118
|
+
// `connect()` so every inbound event still fans out to
|
|
119
|
+
// `handleEvent`. Pre-connect registration is supported by the
|
|
120
|
+
// registry.
|
|
121
|
+
yield* this.client.subscribe({}, (event) => Effect.sync(() => this.handleEvent(event)));
|
|
122
|
+
const helloOk = (yield* this.client.connect());
|
|
123
|
+
this._connected = true;
|
|
124
|
+
this._ownAgentId = helloOk.agentId;
|
|
125
|
+
this.populateFromHello(helloOk);
|
|
126
|
+
return helloOk;
|
|
87
127
|
});
|
|
88
|
-
const helloOk = (await this.client.connect());
|
|
89
|
-
this._connected = true;
|
|
90
|
-
this.ownAgentId = helloOk.agentId;
|
|
91
|
-
this.populateFromHello(helloOk);
|
|
92
|
-
return helloOk;
|
|
93
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Tear down the service. `close()` is sync because it fans out to the
|
|
131
|
+
* socket server, Refs, and the ws-client — the ws-client's own close
|
|
132
|
+
* Effect is run via `Effect.runSync` here so the caller gets an immediate
|
|
133
|
+
* shutdown the way existing code expects.
|
|
134
|
+
*/
|
|
94
135
|
close() {
|
|
95
136
|
this._connected = false;
|
|
96
137
|
this.stopSocketServer();
|
|
97
|
-
this.client
|
|
138
|
+
if (this.client) {
|
|
139
|
+
void Effect.runPromise(this.client.close());
|
|
140
|
+
}
|
|
98
141
|
this.client = null;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
Effect.runSync(Effect.all([
|
|
143
|
+
Ref.set(this.conversationsRef, HashMap.empty()),
|
|
144
|
+
Ref.set(this.messagesRef, HashMap.empty()),
|
|
145
|
+
Ref.set(this.agentNamesRef, HashMap.empty()),
|
|
146
|
+
Ref.set(this.agentConversationCacheRef, HashMap.empty()),
|
|
147
|
+
Ref.set(this.lastNotifiedRef, HashMap.empty()),
|
|
148
|
+
Ref.set(this.lastReadRef, HashMap.empty()),
|
|
149
|
+
]));
|
|
150
|
+
// Handlers are preserved across close()/connect() cycles. MoltZapChannelCore
|
|
151
|
+
// subscribes once in its constructor; clearing handlers here would silently
|
|
152
|
+
// drop inbound/reconnect dispatch on any subsequent reconnect of the same
|
|
153
|
+
// service instance.
|
|
105
154
|
}
|
|
106
155
|
// --- Socket Server ---
|
|
107
156
|
socketServer = null;
|
|
108
157
|
activeSocketPath = null;
|
|
109
158
|
/** Default socket path for CLI discovery. Per-instance path uses agentId. */
|
|
110
159
|
static SOCKET_PATH = path.join(os.homedir(), ".moltzap", "service.sock");
|
|
160
|
+
/**
|
|
161
|
+
* `agentId` is a server-assigned string. Treat it as untrusted: if a
|
|
162
|
+
* compromised or malicious server returns an id containing `..` or a
|
|
163
|
+
* path separator, a naive `path.join(... , agentId)` escapes `~/.moltzap`.
|
|
164
|
+
* Reject anything that isn't a safe identifier.
|
|
165
|
+
*/
|
|
166
|
+
static safeAgentIdSegment(id) {
|
|
167
|
+
return /^[A-Za-z0-9_-]+$/.test(id) ? id : "default";
|
|
168
|
+
}
|
|
111
169
|
/** Per-instance socket path based on connected agentId. */
|
|
112
170
|
get socketPath() {
|
|
113
|
-
const id = this.ownAgentId ?? "default";
|
|
171
|
+
const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
|
|
114
172
|
return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
|
|
115
173
|
}
|
|
116
174
|
startSocketServer() {
|
|
@@ -119,7 +177,14 @@ export class MoltZapService {
|
|
|
119
177
|
try {
|
|
120
178
|
fs.unlinkSync(sockPath);
|
|
121
179
|
}
|
|
122
|
-
catch {
|
|
180
|
+
catch (err) {
|
|
181
|
+
// ENOENT is the normal case (no stale socket); log everything
|
|
182
|
+
// else (permission denied, EACCES) so operators can diagnose.
|
|
183
|
+
const code = err.code;
|
|
184
|
+
if (code !== "ENOENT") {
|
|
185
|
+
this.opts.logger?.warn("unlink existing socket failed", err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
123
188
|
fs.mkdirSync(path.dirname(sockPath), { recursive: true });
|
|
124
189
|
this.socketServer = net.createServer((conn) => {
|
|
125
190
|
let buffer = "";
|
|
@@ -130,20 +195,39 @@ export class MoltZapService {
|
|
|
130
195
|
for (const line of lines) {
|
|
131
196
|
if (!line.trim())
|
|
132
197
|
continue;
|
|
133
|
-
|
|
198
|
+
// Fork the line-handler Effect onto the Effect runtime. Each line
|
|
199
|
+
// dispatches independently; the fiber runs until it writes a
|
|
200
|
+
// response or error frame back onto the socket.
|
|
201
|
+
Effect.runFork(this.handleSocketLineEffect(line, conn));
|
|
134
202
|
}
|
|
135
203
|
});
|
|
136
204
|
});
|
|
137
|
-
this.socketServer.listen(sockPath)
|
|
205
|
+
this.socketServer.listen(sockPath, () => {
|
|
206
|
+
// Owner-only permissions: the socket exposes an RPC passthrough that
|
|
207
|
+
// impersonates this agent to the server, so other local users on the
|
|
208
|
+
// host must not be able to connect.
|
|
209
|
+
try {
|
|
210
|
+
fs.chmodSync(sockPath, 0o600);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
this.opts.logger?.warn("chmod 0600 on socket failed", err);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
138
216
|
// Symlink default path to this instance for CLI discovery
|
|
139
217
|
try {
|
|
140
218
|
fs.unlinkSync(MoltZapService.SOCKET_PATH);
|
|
141
219
|
}
|
|
142
|
-
catch {
|
|
220
|
+
catch (err) {
|
|
221
|
+
// Stale/missing default symlink is expected most of the time; log
|
|
222
|
+
// at debug so real permission errors still surface.
|
|
223
|
+
this.opts.logger?.info("unlink default socket symlink", err);
|
|
224
|
+
}
|
|
143
225
|
try {
|
|
144
226
|
fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
|
|
145
227
|
}
|
|
146
|
-
catch {
|
|
228
|
+
catch (err) {
|
|
229
|
+
this.opts.logger?.warn("symlink default socket failed", err);
|
|
230
|
+
}
|
|
147
231
|
}
|
|
148
232
|
stopSocketServer() {
|
|
149
233
|
this.socketServer?.close();
|
|
@@ -153,193 +237,221 @@ export class MoltZapService {
|
|
|
153
237
|
try {
|
|
154
238
|
fs.unlinkSync(sockPath);
|
|
155
239
|
}
|
|
156
|
-
catch {
|
|
157
|
-
|
|
240
|
+
catch (err) {
|
|
241
|
+
this.opts.logger?.info("unlink socket path", err);
|
|
242
|
+
}
|
|
243
|
+
// Remove default symlink only if it points to this instance.
|
|
158
244
|
try {
|
|
159
245
|
const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
|
|
160
246
|
if (target === sockPath)
|
|
161
247
|
fs.unlinkSync(MoltZapService.SOCKET_PATH);
|
|
162
248
|
}
|
|
163
|
-
catch {
|
|
249
|
+
catch (err) {
|
|
250
|
+
this.opts.logger?.info("cleanup default symlink", err);
|
|
251
|
+
}
|
|
164
252
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Handle one JSON line from the unix socket as an Effect. Parses the
|
|
255
|
+
* request, dispatches through `handleSocketRequestEffect`, and writes
|
|
256
|
+
* either a `{result}` or `{error}` frame back onto the connection.
|
|
257
|
+
* Never fails — all branches resolve into a `conn.write` side effect.
|
|
258
|
+
*/
|
|
259
|
+
handleSocketLineEffect(line, conn) {
|
|
260
|
+
return Effect.suspend(() => {
|
|
261
|
+
let req;
|
|
262
|
+
try {
|
|
263
|
+
req = JSON.parse(line); // #ignore-sloppy-code[record-cast]: JSON.parse boundary from unix socket line protocol
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
return Effect.sync(() => conn.write(JSON.stringify({
|
|
267
|
+
error: err instanceof Error ? err.message : String(err),
|
|
268
|
+
}) + "\n"));
|
|
269
|
+
}
|
|
168
270
|
if (typeof req.method !== "string" || !req.method) {
|
|
169
|
-
|
|
271
|
+
return Effect.sync(() => conn.write(JSON.stringify({
|
|
272
|
+
error: "method is required and must be a string",
|
|
273
|
+
}) + "\n"));
|
|
170
274
|
}
|
|
171
275
|
const params = req.params != null && typeof req.params === "object"
|
|
172
|
-
? req.params
|
|
276
|
+
? req.params // #ignore-sloppy-code[record-cast]: req.params is untyped JSON from socket
|
|
173
277
|
: {};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
278
|
+
return this.handleSocketRequestEffect(req.method, params).pipe(Effect.match({
|
|
279
|
+
onSuccess: (result) => {
|
|
280
|
+
conn.write(JSON.stringify({ result }) + "\n");
|
|
281
|
+
},
|
|
282
|
+
onFailure: (err) => {
|
|
283
|
+
conn.write(JSON.stringify({
|
|
284
|
+
error: err instanceof Error ? err.message : String(err),
|
|
285
|
+
}) + "\n");
|
|
286
|
+
},
|
|
287
|
+
}));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
handleSocketRequestEffect(method, params) {
|
|
291
|
+
return Effect.suspend(() => {
|
|
292
|
+
switch (method) {
|
|
293
|
+
case "ping":
|
|
294
|
+
return Effect.succeed({ ok: true, agentId: this.ownAgentId });
|
|
295
|
+
case "status":
|
|
296
|
+
return Effect.succeed({
|
|
297
|
+
agentId: this.ownAgentId,
|
|
298
|
+
connected: this._connected,
|
|
299
|
+
conversations: this.getConversations().length,
|
|
300
|
+
});
|
|
301
|
+
case "history":
|
|
302
|
+
return this.handleHistoryRequest(params);
|
|
303
|
+
default:
|
|
304
|
+
// Passthrough: forward any other method to the MoltZap server via RPC
|
|
305
|
+
return this.sendRpc(method, params);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
182
308
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return
|
|
187
|
-
|
|
309
|
+
handleHistoryRequest(params) {
|
|
310
|
+
return Effect.gen(this, function* () {
|
|
311
|
+
if (typeof params.conversationId !== "string" || !params.conversationId) {
|
|
312
|
+
return yield* Effect.fail(new Error("conversationId is required and must be a string"));
|
|
313
|
+
}
|
|
314
|
+
if (params.limit !== undefined && typeof params.limit !== "number") {
|
|
315
|
+
return yield* Effect.fail(new Error("limit must be a number"));
|
|
316
|
+
}
|
|
317
|
+
const convId = params.conversationId;
|
|
318
|
+
const limit = params.limit ?? 10;
|
|
319
|
+
const sessionKey = params.sessionKey;
|
|
320
|
+
const result = (yield* this.sendRpc("messages/list", {
|
|
321
|
+
conversationId: convId,
|
|
322
|
+
limit,
|
|
323
|
+
}));
|
|
324
|
+
// Resolve agent names (batch) + fetch conversation metadata (concurrent)
|
|
325
|
+
const knownNames = yield* Ref.get(this.agentNamesRef);
|
|
326
|
+
const unknownAgentIds = [
|
|
327
|
+
...new Set(result.messages.map((m) => m.senderId)),
|
|
328
|
+
].filter((id) => !HashMap.has(knownNames, id));
|
|
329
|
+
const lookupEff = unknownAgentIds.length > 0
|
|
330
|
+
? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).pipe(Effect.tap((res) => {
|
|
331
|
+
const agents = res.agents;
|
|
332
|
+
return Ref.update(this.agentNamesRef, (names) => {
|
|
333
|
+
let next = names;
|
|
334
|
+
for (const a of agents) {
|
|
335
|
+
next = HashMap.set(next, a.id, a.name);
|
|
336
|
+
}
|
|
337
|
+
return next;
|
|
338
|
+
});
|
|
339
|
+
}), Effect.asVoid, Effect.catchAll(() => Effect.void))
|
|
340
|
+
: Effect.void;
|
|
341
|
+
const metaEff = this.sendRpc("conversations/get", {
|
|
342
|
+
conversationId: convId,
|
|
343
|
+
}).pipe(Effect.map((res) => res
|
|
344
|
+
.conversation), Effect.catchAll(() => Effect.succeed(undefined)));
|
|
345
|
+
const [, convMeta] = yield* Effect.all([lookupEff, metaEff], {
|
|
346
|
+
concurrency: "unbounded",
|
|
347
|
+
});
|
|
348
|
+
// Determine what's "new" using lastRead (not lastNotified).
|
|
349
|
+
// lastNotified is advanced by getContext() (system-reminder).
|
|
350
|
+
// lastRead is advanced here when the agent explicitly reads history.
|
|
351
|
+
const allAgentNames = yield* Ref.get(this.agentNamesRef);
|
|
352
|
+
const lastReadMap = yield* Ref.get(this.lastReadRef);
|
|
353
|
+
const lastReadIds = sessionKey
|
|
354
|
+
? Option.getOrElse(Option.flatMap(HashMap.get(lastReadMap, sessionKey), (perConv) => HashMap.get(perConv, convId)), () => new Set())
|
|
355
|
+
: new Set();
|
|
356
|
+
const messages = result.messages.map((m) => {
|
|
357
|
+
const text = m.parts.map(renderPart).join(" ");
|
|
358
|
+
const senderName = Option.getOrElse(HashMap.get(allAgentNames, m.senderId), () => m.senderId);
|
|
188
359
|
return {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
360
|
+
id: m.id,
|
|
361
|
+
senderId: m.senderId,
|
|
362
|
+
senderName: m.senderId === this.ownAgentId ? "you" : senderName,
|
|
363
|
+
isOwn: m.senderId === this.ownAgentId,
|
|
364
|
+
text,
|
|
365
|
+
createdAt: m.createdAt,
|
|
366
|
+
isNew: sessionKey ? !lastReadIds.has(m.id) : false,
|
|
192
367
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const beforeSeq = params.beforeSeq;
|
|
206
|
-
const result = (await this.sendRpc("messages/list", {
|
|
207
|
-
conversationId: convId,
|
|
208
|
-
limit,
|
|
209
|
-
...(afterSeq !== undefined ? { afterSeq } : {}),
|
|
210
|
-
...(beforeSeq !== undefined ? { beforeSeq } : {}),
|
|
211
|
-
}));
|
|
212
|
-
// Resolve agent names (batch) + fetch conversation metadata (concurrent)
|
|
213
|
-
const unknownAgentIds = [
|
|
214
|
-
...new Set(result.messages
|
|
215
|
-
.filter((m) => m.sender.type === "agent")
|
|
216
|
-
.map((m) => m.sender.id)),
|
|
217
|
-
].filter((id) => !this.agentNames.has(id));
|
|
218
|
-
const [, convMeta] = await Promise.all([
|
|
219
|
-
unknownAgentIds.length > 0
|
|
220
|
-
? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).then((res) => {
|
|
221
|
-
for (const a of res.agents) {
|
|
222
|
-
this.agentNames.set(a.id, a.name);
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
: Promise.resolve(),
|
|
226
|
-
this.sendRpc("conversations/get", { conversationId: convId })
|
|
227
|
-
.then((res) => res
|
|
228
|
-
.conversation)
|
|
229
|
-
.catch(() => undefined),
|
|
230
|
-
]);
|
|
231
|
-
// Determine what's "new" using lastRead (not lastNotified).
|
|
232
|
-
// lastNotified is advanced by getContext() (system-reminder).
|
|
233
|
-
// lastRead is advanced here when the agent explicitly reads history.
|
|
234
|
-
// This means messages stay "new" until the agent reads them via history,
|
|
235
|
-
// even if the system-reminder already notified about them.
|
|
236
|
-
const readMarkers = sessionKey
|
|
237
|
-
? (this.lastRead.get(sessionKey) ?? new Map())
|
|
238
|
-
: null;
|
|
239
|
-
const lastReadSeq = readMarkers?.get(convId) ?? 0;
|
|
240
|
-
const messages = result.messages.map((m) => {
|
|
241
|
-
const text = m.parts.map(renderPart).join(" ");
|
|
242
|
-
return {
|
|
243
|
-
seq: m.seq,
|
|
244
|
-
senderId: m.sender.id,
|
|
245
|
-
senderName: m.sender.id === this.ownAgentId
|
|
246
|
-
? "you"
|
|
247
|
-
: (this.agentNames.get(m.sender.id) ?? m.sender.id),
|
|
248
|
-
isOwn: m.sender.id === this.ownAgentId,
|
|
249
|
-
text,
|
|
250
|
-
createdAt: m.createdAt,
|
|
251
|
-
isNew: sessionKey ? m.seq > lastReadSeq : false,
|
|
252
|
-
};
|
|
368
|
+
});
|
|
369
|
+
// Advance lastRead to include all message IDs in the returned page.
|
|
370
|
+
if (sessionKey && result.messages.length > 0) {
|
|
371
|
+
yield* Ref.update(this.lastReadRef, (outer) => {
|
|
372
|
+
const perSession = getOr(outer, sessionKey, () => HashMap.empty());
|
|
373
|
+
const existing = getOr(perSession, convId, () => new Set());
|
|
374
|
+
if (result.messages.every((m) => existing.has(m.id)))
|
|
375
|
+
return outer;
|
|
376
|
+
const nextSet = new Set(existing);
|
|
377
|
+
for (const m of result.messages)
|
|
378
|
+
nextSet.add(m.id);
|
|
379
|
+
return HashMap.set(outer, sessionKey, HashMap.set(perSession, convId, nextSet));
|
|
253
380
|
});
|
|
254
|
-
// Advance lastRead to the highest seq in the returned page.
|
|
255
|
-
// The agent has seen these messages; older ones (if paginated) remain
|
|
256
|
-
// accessible via afterSeq/beforeSeq cursors.
|
|
257
|
-
if (sessionKey && result.messages.length > 0) {
|
|
258
|
-
const maxSeq = Math.max(...result.messages.map((m) => m.seq));
|
|
259
|
-
if (!this.lastRead.has(sessionKey)) {
|
|
260
|
-
this.lastRead.set(sessionKey, new Map());
|
|
261
|
-
}
|
|
262
|
-
const current = this.lastRead.get(sessionKey).get(convId) ?? 0;
|
|
263
|
-
if (maxSeq > current) {
|
|
264
|
-
this.lastRead.get(sessionKey).set(convId, maxSeq);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return {
|
|
268
|
-
messages,
|
|
269
|
-
hasMore: result.hasMore,
|
|
270
|
-
conversationMeta: convMeta,
|
|
271
|
-
newCount: messages.filter((m) => m.isNew).length,
|
|
272
|
-
};
|
|
273
381
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
382
|
+
return {
|
|
383
|
+
messages,
|
|
384
|
+
hasMore: result.hasMore,
|
|
385
|
+
conversationMeta: convMeta,
|
|
386
|
+
newCount: messages.filter((m) => m.isNew).length,
|
|
387
|
+
};
|
|
388
|
+
});
|
|
278
389
|
}
|
|
279
390
|
// --- Conversations ---
|
|
280
391
|
getConversation(convId) {
|
|
281
|
-
return
|
|
392
|
+
return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
|
|
282
393
|
}
|
|
283
394
|
getConversations() {
|
|
284
|
-
return [...
|
|
395
|
+
return [...HashMap.values(snapshot(this.conversationsRef))];
|
|
285
396
|
}
|
|
286
397
|
// --- Messages ---
|
|
287
398
|
getHistory(convId, limit) {
|
|
288
|
-
const msgs = this.
|
|
399
|
+
const msgs = getOr(snapshot(this.messagesRef), convId, () => []);
|
|
289
400
|
return limit ? msgs.slice(-limit) : [...msgs];
|
|
290
401
|
}
|
|
291
402
|
// --- Agent Names ---
|
|
292
403
|
getAgentName(agentId) {
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
})
|
|
313
|
-
this.pendingNameLookups.set(agentId, promise);
|
|
314
|
-
try {
|
|
315
|
-
return await promise;
|
|
316
|
-
}
|
|
317
|
-
finally {
|
|
318
|
-
this.pendingNameLookups.delete(agentId);
|
|
319
|
-
}
|
|
404
|
+
return Option.getOrUndefined(HashMap.get(snapshot(this.agentNamesRef), agentId));
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Cache-first agent-name lookup. Never fails: falls back to `agentId`
|
|
408
|
+
* when the RPC errors or the server has no record. The error path logs
|
|
409
|
+
* so ops can see repeated lookup failures; the empty-response path is
|
|
410
|
+
* silent (a cold agent is an expected transient state).
|
|
411
|
+
*/
|
|
412
|
+
resolveAgentName(agentId) {
|
|
413
|
+
return Effect.gen(this, function* () {
|
|
414
|
+
const cached = Option.getOrUndefined(HashMap.get(snapshot(this.agentNamesRef), agentId));
|
|
415
|
+
if (cached !== undefined)
|
|
416
|
+
return cached;
|
|
417
|
+
return yield* this.sendRpc("agents/lookup", { agentIds: [agentId] }).pipe(Effect.flatMap((result) => {
|
|
418
|
+
const agent = result.agents[0];
|
|
419
|
+
if (!agent)
|
|
420
|
+
return Effect.succeed(agentId);
|
|
421
|
+
return Ref.update(this.agentNamesRef, (names) => HashMap.set(names, agentId, agent.name)).pipe(Effect.as(agent.name));
|
|
422
|
+
}), Effect.catchAll((err) => Effect.logWarning("agents/lookup failed; falling back to agentId").pipe(Effect.annotateLogs({ agentId, err: String(err) }), Effect.as(agentId))));
|
|
423
|
+
});
|
|
320
424
|
}
|
|
321
425
|
// --- Messaging ---
|
|
322
|
-
|
|
323
|
-
|
|
426
|
+
send(convId, text, opts) {
|
|
427
|
+
return Effect.asVoid(this.sendRpc("messages/send", {
|
|
324
428
|
conversationId: convId,
|
|
325
429
|
parts: [{ type: "text", text }],
|
|
326
430
|
...(opts?.replyTo ? { replyToId: opts.replyTo } : {}),
|
|
327
|
-
});
|
|
431
|
+
}));
|
|
328
432
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
433
|
+
sendToAgent(agentName, text, opts) {
|
|
434
|
+
return Effect.gen(this, function* () {
|
|
435
|
+
const cache = yield* Ref.get(this.agentConversationCacheRef);
|
|
436
|
+
let conversationId = Option.getOrUndefined(HashMap.get(cache, agentName));
|
|
437
|
+
if (!conversationId) {
|
|
438
|
+
const lookupResult = (yield* this.sendRpc("agents/lookupByName", {
|
|
439
|
+
names: [agentName],
|
|
440
|
+
}));
|
|
441
|
+
const agent = lookupResult.agents[0];
|
|
442
|
+
if (!agent) {
|
|
443
|
+
return yield* Effect.fail(new AgentNotFoundError({ agentName }));
|
|
444
|
+
}
|
|
445
|
+
const createResult = (yield* this.sendRpc("conversations/create", {
|
|
446
|
+
type: "dm",
|
|
447
|
+
participants: [{ type: "agent", id: agent.id }],
|
|
448
|
+
}));
|
|
449
|
+
conversationId = createResult.conversation.id;
|
|
450
|
+
const newId = conversationId;
|
|
451
|
+
yield* Ref.update(this.agentConversationCacheRef, (m) => HashMap.set(m, agentName, newId));
|
|
452
|
+
}
|
|
453
|
+
yield* this.send(conversationId, text, opts);
|
|
454
|
+
});
|
|
343
455
|
}
|
|
344
456
|
// --- Cross-Conversation Context ---
|
|
345
457
|
/**
|
|
@@ -365,40 +477,43 @@ export class MoltZapService {
|
|
|
365
477
|
peekContextEntries(currentConvId, opts) {
|
|
366
478
|
const maxConvs = opts?.maxConversations ?? 5;
|
|
367
479
|
const maxMsgsPerConv = opts?.maxMessagesPerConv ?? 3;
|
|
368
|
-
const viewMarkers = this.
|
|
480
|
+
const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
|
|
481
|
+
// Collect all candidate conversations with their last-message timestamp,
|
|
482
|
+
// then sort by recency before applying `maxConvs`. HashMap iteration
|
|
483
|
+
// order is hash-based (not insertion/recency), so without the sort the
|
|
484
|
+
// maxConvs truncation would pick an arbitrary subset and could omit the
|
|
485
|
+
// freshest conversations.
|
|
486
|
+
const candidates = [];
|
|
487
|
+
for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
|
|
488
|
+
const last = newMsgs[newMsgs.length - 1];
|
|
489
|
+
candidates.push({
|
|
490
|
+
convId,
|
|
491
|
+
newMsgs,
|
|
492
|
+
lastTs: new Date(last.createdAt).getTime(),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
candidates.sort((a, b) => b.lastTs - a.lastTs);
|
|
369
496
|
const entries = [];
|
|
370
497
|
const pendingAdvances = [];
|
|
371
|
-
for (const
|
|
372
|
-
if (convId === currentConvId || msgs.length === 0)
|
|
373
|
-
continue;
|
|
374
|
-
if (entries.length >= maxConvs)
|
|
375
|
-
break;
|
|
376
|
-
const lastSeenSeq = viewMarkers.get(convId) ?? 0;
|
|
377
|
-
const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
|
|
378
|
-
if (newMsgs.length === 0)
|
|
379
|
-
continue;
|
|
498
|
+
for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
|
|
380
499
|
const reportable = newMsgs.slice(-maxMsgsPerConv);
|
|
381
500
|
const last = reportable[reportable.length - 1];
|
|
382
|
-
const senderName =
|
|
501
|
+
const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
|
|
383
502
|
const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) / 60_000));
|
|
384
|
-
const text = last.parts.map(renderPart).join(" ");
|
|
385
503
|
entries.push({
|
|
386
504
|
conversationId: convId,
|
|
387
|
-
conversationName:
|
|
505
|
+
conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
|
|
388
506
|
senderName,
|
|
389
|
-
text,
|
|
507
|
+
text: last.parts.map(renderPart).join(" "),
|
|
390
508
|
minutesAgo,
|
|
391
509
|
count: reportable.length,
|
|
392
510
|
});
|
|
393
|
-
pendingAdvances.push([convId, last.
|
|
511
|
+
pendingAdvances.push([convId, last.id]);
|
|
394
512
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
this.lastNotified.set(currentConvId, viewMarkers);
|
|
513
|
+
return {
|
|
514
|
+
entries,
|
|
515
|
+
commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
|
|
400
516
|
};
|
|
401
|
-
return { entries, commit };
|
|
402
517
|
}
|
|
403
518
|
/**
|
|
404
519
|
* Return all new messages from all other conversations as full transcripts,
|
|
@@ -406,43 +521,64 @@ export class MoltZapService {
|
|
|
406
521
|
* peekContextEntries. Call commit() to advance markers.
|
|
407
522
|
*/
|
|
408
523
|
peekFullMessages(currentConvId) {
|
|
409
|
-
const viewMarkers = this.
|
|
524
|
+
const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
|
|
410
525
|
const allMessages = [];
|
|
411
526
|
const pendingAdvances = [];
|
|
412
|
-
for (const [convId,
|
|
413
|
-
|
|
414
|
-
continue;
|
|
415
|
-
const lastSeenSeq = viewMarkers.get(convId) ?? 0;
|
|
416
|
-
const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
|
|
417
|
-
if (newMsgs.length === 0)
|
|
418
|
-
continue;
|
|
419
|
-
const convName = this.conversations.get(convId)?.name;
|
|
527
|
+
for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
|
|
528
|
+
const convName = Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name;
|
|
420
529
|
for (const m of newMsgs) {
|
|
421
|
-
const text = m.parts.map(renderPart).join(" ");
|
|
422
530
|
allMessages.push({
|
|
423
531
|
conversationId: convId,
|
|
424
532
|
conversationName: convName,
|
|
425
|
-
senderName:
|
|
426
|
-
senderId: m.
|
|
427
|
-
text,
|
|
533
|
+
senderName: getOr(agentNamesMap, m.senderId, () => m.senderId),
|
|
534
|
+
senderId: m.senderId,
|
|
535
|
+
text: m.parts.map(renderPart).join(" "),
|
|
428
536
|
timestamp: m.createdAt,
|
|
429
537
|
});
|
|
430
538
|
}
|
|
431
|
-
|
|
432
|
-
pendingAdvances.push([convId, lastSeq]);
|
|
539
|
+
pendingAdvances.push([convId, newMsgs[newMsgs.length - 1].id]);
|
|
433
540
|
}
|
|
434
541
|
allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
542
|
+
return {
|
|
543
|
+
messages: allMessages,
|
|
544
|
+
commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
readCrossConvState(currentConvId) {
|
|
548
|
+
const lastNotifiedMap = snapshot(this.lastNotifiedRef);
|
|
549
|
+
return {
|
|
550
|
+
messagesMap: snapshot(this.messagesRef),
|
|
551
|
+
conversationsMap: snapshot(this.conversationsRef),
|
|
552
|
+
agentNamesMap: snapshot(this.agentNamesRef),
|
|
553
|
+
viewMarkers: getOr(lastNotifiedMap, currentConvId, () => HashMap.empty()),
|
|
440
554
|
};
|
|
441
|
-
return { messages: allMessages, commit };
|
|
442
555
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
556
|
+
*iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId) {
|
|
557
|
+
for (const [convId, msgs] of messagesMap) {
|
|
558
|
+
if (convId === currentConvId || msgs.length === 0)
|
|
559
|
+
continue;
|
|
560
|
+
const lastSeenId = Option.getOrUndefined(HashMap.get(viewMarkers, convId));
|
|
561
|
+
const lastSeenIdx = lastSeenId
|
|
562
|
+
? msgs.findIndex((m) => m.id === lastSeenId)
|
|
563
|
+
: -1;
|
|
564
|
+
const newMsgs = msgs.slice(lastSeenIdx + 1);
|
|
565
|
+
if (newMsgs.length === 0)
|
|
566
|
+
continue;
|
|
567
|
+
yield [convId, newMsgs];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
advanceLastNotified(currentConvId, pendingAdvances) {
|
|
571
|
+
if (pendingAdvances.length === 0)
|
|
572
|
+
return;
|
|
573
|
+
Effect.runSync(Ref.update(this.lastNotifiedRef, (outer) => {
|
|
574
|
+
let markers = getOr(outer, currentConvId, () => HashMap.empty());
|
|
575
|
+
for (const [convId, msgId] of pendingAdvances) {
|
|
576
|
+
markers = HashMap.set(markers, convId, msgId);
|
|
577
|
+
}
|
|
578
|
+
return HashMap.set(outer, currentConvId, markers);
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
on(event, handler) {
|
|
446
582
|
switch (event) {
|
|
447
583
|
case "message":
|
|
448
584
|
this.messageHandlers.push(handler);
|
|
@@ -456,13 +592,22 @@ export class MoltZapService {
|
|
|
456
592
|
case "reconnect":
|
|
457
593
|
this.reconnectHandlers.push(handler);
|
|
458
594
|
break;
|
|
595
|
+
case "permissionRequired":
|
|
596
|
+
this.permissionRequiredHandlers.push(handler);
|
|
597
|
+
break;
|
|
459
598
|
}
|
|
460
599
|
}
|
|
461
600
|
// --- RPC passthrough ---
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
601
|
+
sendRpc(method, params) {
|
|
602
|
+
return Effect.suspend(() => {
|
|
603
|
+
if (!this.client) {
|
|
604
|
+
return Effect.fail(new NotConnectedError({ message: "Not connected" }));
|
|
605
|
+
}
|
|
606
|
+
return this.client.sendRpc(method, params);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
grantPermission(params) {
|
|
610
|
+
return Effect.asVoid(this.sendRpc("permissions/grant", params));
|
|
466
611
|
}
|
|
467
612
|
// --- Internals ---
|
|
468
613
|
/**
|
|
@@ -470,83 +615,92 @@ export class MoltZapService {
|
|
|
470
615
|
* into the local cache. Called on ConversationCreated events because the
|
|
471
616
|
* event schema omits the participants list — see protocol/events.ts.
|
|
472
617
|
*/
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
catch {
|
|
487
|
-
// Best-effort refresh. Leave the existing entry in place on failure.
|
|
488
|
-
}
|
|
618
|
+
refreshConversationParticipants(conversationId) {
|
|
619
|
+
return this.sendRpc("conversations/get", { conversationId }).pipe(Effect.tap((res) => {
|
|
620
|
+
const typed = res;
|
|
621
|
+
const meta = {
|
|
622
|
+
id: typed.conversation.id,
|
|
623
|
+
type: typed.conversation.type,
|
|
624
|
+
name: typed.conversation.name,
|
|
625
|
+
participants: typed.participants.map((p) => `${p.participant.type}:${p.participant.id}`),
|
|
626
|
+
};
|
|
627
|
+
return Ref.update(this.conversationsRef, (m) => HashMap.set(m, conversationId, meta));
|
|
628
|
+
}), Effect.asVoid,
|
|
629
|
+
// Best-effort refresh. Leave the existing entry in place on failure.
|
|
630
|
+
Effect.catchAll(() => Effect.void));
|
|
489
631
|
}
|
|
490
632
|
populateFromHello(hello) {
|
|
491
633
|
if (!hello.conversations)
|
|
492
634
|
return;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
635
|
+
const incoming = hello.conversations;
|
|
636
|
+
Effect.runSync(Ref.update(this.conversationsRef, (m) => {
|
|
637
|
+
let next = m;
|
|
638
|
+
for (const conv of incoming) {
|
|
639
|
+
next = HashMap.set(next, conv.id, {
|
|
640
|
+
id: conv.id,
|
|
641
|
+
type: conv.type,
|
|
642
|
+
name: conv.name,
|
|
643
|
+
participants: (conv.participants ?? []).map((p) => `${p.type}:${p.id}`),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return next;
|
|
647
|
+
}));
|
|
501
648
|
}
|
|
502
649
|
handleEvent(event) {
|
|
503
|
-
|
|
504
|
-
|
|
650
|
+
fanout(this.rawEventHandlers, event, this.opts.logger);
|
|
651
|
+
// The server validates event.data against each event's schema before
|
|
652
|
+
// emitting; each case casts to the typed Static<> payload for that
|
|
653
|
+
// specific event.
|
|
505
654
|
switch (event.event) {
|
|
506
655
|
case EventNames.MessageReceived: {
|
|
507
656
|
const msg = event.data.message;
|
|
508
657
|
this.storeMessage(msg);
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// Emit to external handlers (only non-own messages)
|
|
515
|
-
if (msg.sender.id !== this.ownAgentId) {
|
|
516
|
-
for (const h of this.messageHandlers)
|
|
517
|
-
h(msg);
|
|
658
|
+
// Name resolution is driven lazily by channel-core's serialized
|
|
659
|
+
// consumer via resolveAgentName(), which populates agentNamesRef on
|
|
660
|
+
// first miss and hits the cache on every subsequent message.
|
|
661
|
+
if (msg.senderId !== this._ownAgentId) {
|
|
662
|
+
fanout(this.messageHandlers, msg, this.opts.logger);
|
|
518
663
|
}
|
|
519
664
|
break;
|
|
520
665
|
}
|
|
666
|
+
case EventNames.PermissionsRequired: {
|
|
667
|
+
fanout(this.permissionRequiredHandlers, event.data, this.opts.logger);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
521
670
|
case EventNames.ConversationCreated:
|
|
522
671
|
case EventNames.ConversationUpdated: {
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
672
|
+
const { conversation } = event.data;
|
|
673
|
+
Effect.runSync(Ref.update(this.conversationsRef, (m) => {
|
|
674
|
+
const existing = Option.getOrUndefined(HashMap.get(m, conversation.id));
|
|
675
|
+
return HashMap.set(m, conversation.id, {
|
|
676
|
+
id: conversation.id,
|
|
677
|
+
type: conversation.type,
|
|
678
|
+
name: conversation.name,
|
|
679
|
+
participants: existing?.participants ?? [],
|
|
680
|
+
});
|
|
681
|
+
}));
|
|
531
682
|
// The ConversationCreated event schema doesn't carry participants
|
|
532
683
|
// (protocol/schema/events.ts: ConversationSchema is id/type/name/
|
|
533
684
|
// createdBy/timestamps only). Fetch full details asynchronously so
|
|
534
685
|
// downstream code that reads getConversation(id).participants sees
|
|
535
686
|
// a populated list within a round-trip of the event.
|
|
536
687
|
if (event.event === EventNames.ConversationCreated) {
|
|
537
|
-
|
|
688
|
+
// Fire-and-forget: refreshConversationParticipants never fails.
|
|
689
|
+
Effect.runFork(this.refreshConversationParticipants(conversation.id));
|
|
538
690
|
}
|
|
539
691
|
break;
|
|
540
692
|
}
|
|
541
693
|
}
|
|
542
694
|
}
|
|
543
695
|
storeMessage(msg) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
696
|
+
Effect.runSync(Ref.update(this.messagesRef, (m) => {
|
|
697
|
+
const existing = getOr(m, msg.conversationId, () => []);
|
|
698
|
+
const appended = [...existing, msg];
|
|
699
|
+
const capped = appended.length > MAX_MESSAGES_PER_CONV
|
|
700
|
+
? appended.slice(-MAX_MESSAGES_PER_CONV)
|
|
701
|
+
: appended;
|
|
702
|
+
return HashMap.set(m, msg.conversationId, capped);
|
|
703
|
+
}));
|
|
550
704
|
}
|
|
551
705
|
}
|
|
552
706
|
//# sourceMappingURL=service.js.map
|