@moltzap/client 2026.413.0 → 2026.425.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/channel-core.d.ts +33 -15
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +113 -64
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +176 -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 +86 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +481 -290
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +386 -59
- 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 +3 -1
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +42 -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 +24 -15
- package/LICENSE +0 -21
- 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 -999
- 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,90 +31,144 @@ export function formatCrossConversationBlock(entries, opts) {
|
|
|
28
31
|
"</system-reminder>",
|
|
29
32
|
].join("\n");
|
|
30
33
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
42
57
|
}
|
|
43
58
|
}
|
|
44
59
|
/**
|
|
45
60
|
* Stateful MoltZap client that manages connection, conversation tracking,
|
|
46
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.
|
|
47
69
|
*/
|
|
48
70
|
export class MoltZapService {
|
|
49
71
|
opts;
|
|
50
72
|
client = null;
|
|
51
73
|
_connected = false;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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()));
|
|
58
80
|
messageHandlers = [];
|
|
59
81
|
rawEventHandlers = [];
|
|
60
82
|
disconnectHandlers = [];
|
|
61
83
|
reconnectHandlers = [];
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
permissionRequiredHandlers = [];
|
|
85
|
+
_ownAgentId;
|
|
64
86
|
constructor(opts) {
|
|
65
87
|
this.opts = opts;
|
|
66
88
|
}
|
|
67
89
|
get connected() {
|
|
68
90
|
return this._connected;
|
|
69
91
|
}
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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;
|
|
88
127
|
});
|
|
89
|
-
const helloOk = (await this.client.connect());
|
|
90
|
-
this._connected = true;
|
|
91
|
-
this.ownAgentId = helloOk.agentId;
|
|
92
|
-
this.populateFromHello(helloOk);
|
|
93
|
-
return helloOk;
|
|
94
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
|
+
*/
|
|
95
135
|
close() {
|
|
96
136
|
this._connected = false;
|
|
97
137
|
this.stopSocketServer();
|
|
98
|
-
this.client
|
|
138
|
+
if (this.client) {
|
|
139
|
+
void Effect.runPromise(this.client.close());
|
|
140
|
+
}
|
|
99
141
|
this.client = null;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
106
154
|
}
|
|
107
155
|
// --- Socket Server ---
|
|
108
156
|
socketServer = null;
|
|
109
157
|
activeSocketPath = null;
|
|
110
158
|
/** Default socket path for CLI discovery. Per-instance path uses agentId. */
|
|
111
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
|
+
}
|
|
112
169
|
/** Per-instance socket path based on connected agentId. */
|
|
113
170
|
get socketPath() {
|
|
114
|
-
const id = this.ownAgentId ?? "default";
|
|
171
|
+
const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
|
|
115
172
|
return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
|
|
116
173
|
}
|
|
117
174
|
startSocketServer() {
|
|
@@ -120,7 +177,14 @@ export class MoltZapService {
|
|
|
120
177
|
try {
|
|
121
178
|
fs.unlinkSync(sockPath);
|
|
122
179
|
}
|
|
123
|
-
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
|
+
}
|
|
124
188
|
fs.mkdirSync(path.dirname(sockPath), { recursive: true });
|
|
125
189
|
this.socketServer = net.createServer((conn) => {
|
|
126
190
|
let buffer = "";
|
|
@@ -131,20 +195,39 @@ export class MoltZapService {
|
|
|
131
195
|
for (const line of lines) {
|
|
132
196
|
if (!line.trim())
|
|
133
197
|
continue;
|
|
134
|
-
|
|
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));
|
|
135
202
|
}
|
|
136
203
|
});
|
|
137
204
|
});
|
|
138
|
-
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
|
+
});
|
|
139
216
|
// Symlink default path to this instance for CLI discovery
|
|
140
217
|
try {
|
|
141
218
|
fs.unlinkSync(MoltZapService.SOCKET_PATH);
|
|
142
219
|
}
|
|
143
|
-
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
|
+
}
|
|
144
225
|
try {
|
|
145
226
|
fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
|
|
146
227
|
}
|
|
147
|
-
catch {
|
|
228
|
+
catch (err) {
|
|
229
|
+
this.opts.logger?.warn("symlink default socket failed", err);
|
|
230
|
+
}
|
|
148
231
|
}
|
|
149
232
|
stopSocketServer() {
|
|
150
233
|
this.socketServer?.close();
|
|
@@ -154,193 +237,221 @@ export class MoltZapService {
|
|
|
154
237
|
try {
|
|
155
238
|
fs.unlinkSync(sockPath);
|
|
156
239
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
240
|
+
catch (err) {
|
|
241
|
+
this.opts.logger?.info("unlink socket path", err);
|
|
242
|
+
}
|
|
243
|
+
// Remove default symlink only if it points to this instance.
|
|
159
244
|
try {
|
|
160
245
|
const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
|
|
161
246
|
if (target === sockPath)
|
|
162
247
|
fs.unlinkSync(MoltZapService.SOCKET_PATH);
|
|
163
248
|
}
|
|
164
|
-
catch {
|
|
249
|
+
catch (err) {
|
|
250
|
+
this.opts.logger?.info("cleanup default symlink", err);
|
|
251
|
+
}
|
|
165
252
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
270
|
if (typeof req.method !== "string" || !req.method) {
|
|
170
|
-
|
|
271
|
+
return Effect.sync(() => conn.write(JSON.stringify({
|
|
272
|
+
error: "method is required and must be a string",
|
|
273
|
+
}) + "\n"));
|
|
171
274
|
}
|
|
172
275
|
const params = req.params != null && typeof req.params === "object"
|
|
173
|
-
? req.params
|
|
276
|
+
? req.params // #ignore-sloppy-code[record-cast]: req.params is untyped JSON from socket
|
|
174
277
|
: {};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
});
|
|
183
308
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
188
|
-
|
|
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);
|
|
189
359
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
193
367
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const beforeSeq = params.beforeSeq;
|
|
207
|
-
const result = (await this.sendRpc("messages/list", {
|
|
208
|
-
conversationId: convId,
|
|
209
|
-
limit,
|
|
210
|
-
...(afterSeq !== undefined ? { afterSeq } : {}),
|
|
211
|
-
...(beforeSeq !== undefined ? { beforeSeq } : {}),
|
|
212
|
-
}));
|
|
213
|
-
// Resolve agent names (batch) + fetch conversation metadata (concurrent)
|
|
214
|
-
const unknownAgentIds = [
|
|
215
|
-
...new Set(result.messages
|
|
216
|
-
.filter((m) => m.sender.type === "agent")
|
|
217
|
-
.map((m) => m.sender.id)),
|
|
218
|
-
].filter((id) => !this.agentNames.has(id));
|
|
219
|
-
const [, convMeta] = await Promise.all([
|
|
220
|
-
unknownAgentIds.length > 0
|
|
221
|
-
? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).then((res) => {
|
|
222
|
-
for (const a of res.agents) {
|
|
223
|
-
this.agentNames.set(a.id, a.name);
|
|
224
|
-
}
|
|
225
|
-
})
|
|
226
|
-
: Promise.resolve(),
|
|
227
|
-
this.sendRpc("conversations/get", { conversationId: convId })
|
|
228
|
-
.then((res) => res
|
|
229
|
-
.conversation)
|
|
230
|
-
.catch(() => undefined),
|
|
231
|
-
]);
|
|
232
|
-
// Determine what's "new" using lastRead (not lastNotified).
|
|
233
|
-
// lastNotified is advanced by getContext() (system-reminder).
|
|
234
|
-
// lastRead is advanced here when the agent explicitly reads history.
|
|
235
|
-
// This means messages stay "new" until the agent reads them via history,
|
|
236
|
-
// even if the system-reminder already notified about them.
|
|
237
|
-
const readMarkers = sessionKey
|
|
238
|
-
? (this.lastRead.get(sessionKey) ?? new Map())
|
|
239
|
-
: null;
|
|
240
|
-
const lastReadSeq = readMarkers?.get(convId) ?? 0;
|
|
241
|
-
const messages = result.messages.map((m) => {
|
|
242
|
-
const text = m.parts.map(renderPart).join(" ");
|
|
243
|
-
return {
|
|
244
|
-
seq: m.seq,
|
|
245
|
-
senderId: m.sender.id,
|
|
246
|
-
senderName: m.sender.id === this.ownAgentId
|
|
247
|
-
? "you"
|
|
248
|
-
: (this.agentNames.get(m.sender.id) ?? m.sender.id),
|
|
249
|
-
isOwn: m.sender.id === this.ownAgentId,
|
|
250
|
-
text,
|
|
251
|
-
createdAt: m.createdAt,
|
|
252
|
-
isNew: sessionKey ? m.seq > lastReadSeq : false,
|
|
253
|
-
};
|
|
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));
|
|
254
380
|
});
|
|
255
|
-
// Advance lastRead to the highest seq in the returned page.
|
|
256
|
-
// The agent has seen these messages; older ones (if paginated) remain
|
|
257
|
-
// accessible via afterSeq/beforeSeq cursors.
|
|
258
|
-
if (sessionKey && result.messages.length > 0) {
|
|
259
|
-
const maxSeq = Math.max(...result.messages.map((m) => m.seq));
|
|
260
|
-
if (!this.lastRead.has(sessionKey)) {
|
|
261
|
-
this.lastRead.set(sessionKey, new Map());
|
|
262
|
-
}
|
|
263
|
-
const current = this.lastRead.get(sessionKey).get(convId) ?? 0;
|
|
264
|
-
if (maxSeq > current) {
|
|
265
|
-
this.lastRead.get(sessionKey).set(convId, maxSeq);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return {
|
|
269
|
-
messages,
|
|
270
|
-
hasMore: result.hasMore,
|
|
271
|
-
conversationMeta: convMeta,
|
|
272
|
-
newCount: messages.filter((m) => m.isNew).length,
|
|
273
|
-
};
|
|
274
381
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
382
|
+
return {
|
|
383
|
+
messages,
|
|
384
|
+
hasMore: result.hasMore,
|
|
385
|
+
conversationMeta: convMeta,
|
|
386
|
+
newCount: messages.filter((m) => m.isNew).length,
|
|
387
|
+
};
|
|
388
|
+
});
|
|
279
389
|
}
|
|
280
390
|
// --- Conversations ---
|
|
281
391
|
getConversation(convId) {
|
|
282
|
-
return
|
|
392
|
+
return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
|
|
283
393
|
}
|
|
284
394
|
getConversations() {
|
|
285
|
-
return [...
|
|
395
|
+
return [...HashMap.values(snapshot(this.conversationsRef))];
|
|
286
396
|
}
|
|
287
397
|
// --- Messages ---
|
|
288
|
-
getHistory(convId, limit
|
|
289
|
-
const msgs = this.
|
|
290
|
-
return msgs.slice(-limit);
|
|
398
|
+
getHistory(convId, limit) {
|
|
399
|
+
const msgs = getOr(snapshot(this.messagesRef), convId, () => []);
|
|
400
|
+
return limit ? msgs.slice(-limit) : [...msgs];
|
|
291
401
|
}
|
|
292
402
|
// --- Agent Names ---
|
|
293
403
|
getAgentName(agentId) {
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
})
|
|
314
|
-
this.pendingNameLookups.set(agentId, promise);
|
|
315
|
-
try {
|
|
316
|
-
return await promise;
|
|
317
|
-
}
|
|
318
|
-
finally {
|
|
319
|
-
this.pendingNameLookups.delete(agentId);
|
|
320
|
-
}
|
|
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
|
+
});
|
|
321
424
|
}
|
|
322
425
|
// --- Messaging ---
|
|
323
|
-
|
|
324
|
-
|
|
426
|
+
send(convId, text, opts) {
|
|
427
|
+
return Effect.asVoid(this.sendRpc("messages/send", {
|
|
325
428
|
conversationId: convId,
|
|
326
429
|
parts: [{ type: "text", text }],
|
|
327
430
|
...(opts?.replyTo ? { replyToId: opts.replyTo } : {}),
|
|
328
|
-
});
|
|
431
|
+
}));
|
|
329
432
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
});
|
|
344
455
|
}
|
|
345
456
|
// --- Cross-Conversation Context ---
|
|
346
457
|
/**
|
|
@@ -366,44 +477,108 @@ export class MoltZapService {
|
|
|
366
477
|
peekContextEntries(currentConvId, opts) {
|
|
367
478
|
const maxConvs = opts?.maxConversations ?? 5;
|
|
368
479
|
const maxMsgsPerConv = opts?.maxMessagesPerConv ?? 3;
|
|
369
|
-
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);
|
|
370
496
|
const entries = [];
|
|
371
497
|
const pendingAdvances = [];
|
|
372
|
-
for (const
|
|
373
|
-
if (convId === currentConvId || msgs.length === 0)
|
|
374
|
-
continue;
|
|
375
|
-
if (entries.length >= maxConvs)
|
|
376
|
-
break;
|
|
377
|
-
const lastSeenSeq = viewMarkers.get(convId) ?? 0;
|
|
378
|
-
const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
|
|
379
|
-
if (newMsgs.length === 0)
|
|
380
|
-
continue;
|
|
498
|
+
for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
|
|
381
499
|
const reportable = newMsgs.slice(-maxMsgsPerConv);
|
|
382
500
|
const last = reportable[reportable.length - 1];
|
|
383
|
-
const senderName =
|
|
501
|
+
const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
|
|
384
502
|
const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) / 60_000));
|
|
385
|
-
const text = last.parts.map(renderPart).join(" ");
|
|
386
503
|
entries.push({
|
|
387
504
|
conversationId: convId,
|
|
388
|
-
conversationName:
|
|
505
|
+
conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
|
|
389
506
|
senderName,
|
|
390
|
-
text,
|
|
507
|
+
text: last.parts.map(renderPart).join(" "),
|
|
391
508
|
minutesAgo,
|
|
392
509
|
count: reportable.length,
|
|
393
510
|
});
|
|
394
|
-
pendingAdvances.push([convId, last.
|
|
511
|
+
pendingAdvances.push([convId, last.id]);
|
|
395
512
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
513
|
+
return {
|
|
514
|
+
entries,
|
|
515
|
+
commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Return all new messages from all other conversations as full transcripts,
|
|
520
|
+
* sorted chronologically. Uses the same lastNotified markers as
|
|
521
|
+
* peekContextEntries. Call commit() to advance markers.
|
|
522
|
+
*/
|
|
523
|
+
peekFullMessages(currentConvId) {
|
|
524
|
+
const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
|
|
525
|
+
const allMessages = [];
|
|
526
|
+
const pendingAdvances = [];
|
|
527
|
+
for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
|
|
528
|
+
const convName = Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name;
|
|
529
|
+
for (const m of newMsgs) {
|
|
530
|
+
allMessages.push({
|
|
531
|
+
conversationId: convId,
|
|
532
|
+
conversationName: convName,
|
|
533
|
+
senderName: getOr(agentNamesMap, m.senderId, () => m.senderId),
|
|
534
|
+
senderId: m.senderId,
|
|
535
|
+
text: m.parts.map(renderPart).join(" "),
|
|
536
|
+
timestamp: m.createdAt,
|
|
537
|
+
});
|
|
399
538
|
}
|
|
400
|
-
|
|
539
|
+
pendingAdvances.push([convId, newMsgs[newMsgs.length - 1].id]);
|
|
540
|
+
}
|
|
541
|
+
allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
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()),
|
|
401
554
|
};
|
|
402
|
-
return { entries, commit };
|
|
403
555
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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) {
|
|
407
582
|
switch (event) {
|
|
408
583
|
case "message":
|
|
409
584
|
this.messageHandlers.push(handler);
|
|
@@ -417,13 +592,22 @@ export class MoltZapService {
|
|
|
417
592
|
case "reconnect":
|
|
418
593
|
this.reconnectHandlers.push(handler);
|
|
419
594
|
break;
|
|
595
|
+
case "permissionRequired":
|
|
596
|
+
this.permissionRequiredHandlers.push(handler);
|
|
597
|
+
break;
|
|
420
598
|
}
|
|
421
599
|
}
|
|
422
600
|
// --- RPC passthrough ---
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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));
|
|
427
611
|
}
|
|
428
612
|
// --- Internals ---
|
|
429
613
|
/**
|
|
@@ -431,85 +615,92 @@ export class MoltZapService {
|
|
|
431
615
|
* into the local cache. Called on ConversationCreated events because the
|
|
432
616
|
* event schema omits the participants list — see protocol/events.ts.
|
|
433
617
|
*/
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
catch {
|
|
448
|
-
// Best-effort refresh. Leave the existing entry in place on failure.
|
|
449
|
-
}
|
|
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));
|
|
450
631
|
}
|
|
451
632
|
populateFromHello(hello) {
|
|
452
633
|
if (!hello.conversations)
|
|
453
634
|
return;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
+
}));
|
|
462
648
|
}
|
|
463
649
|
handleEvent(event) {
|
|
464
|
-
|
|
465
|
-
|
|
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.
|
|
466
654
|
switch (event.event) {
|
|
467
655
|
case EventNames.MessageReceived: {
|
|
468
656
|
const msg = event.data.message;
|
|
469
657
|
this.storeMessage(msg);
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// Emit to external handlers (only non-own messages)
|
|
476
|
-
if (msg.sender.id !== this.ownAgentId) {
|
|
477
|
-
for (const h of this.messageHandlers)
|
|
478
|
-
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);
|
|
479
663
|
}
|
|
480
664
|
break;
|
|
481
665
|
}
|
|
666
|
+
case EventNames.PermissionsRequired: {
|
|
667
|
+
fanout(this.permissionRequiredHandlers, event.data, this.opts.logger);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
482
670
|
case EventNames.ConversationCreated:
|
|
483
671
|
case EventNames.ConversationUpdated: {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}));
|
|
492
682
|
// The ConversationCreated event schema doesn't carry participants
|
|
493
683
|
// (protocol/schema/events.ts: ConversationSchema is id/type/name/
|
|
494
684
|
// createdBy/timestamps only). Fetch full details asynchronously so
|
|
495
685
|
// downstream code that reads getConversation(id).participants sees
|
|
496
686
|
// a populated list within a round-trip of the event.
|
|
497
687
|
if (event.event === EventNames.ConversationCreated) {
|
|
498
|
-
|
|
688
|
+
// Fire-and-forget: refreshConversationParticipants never fails.
|
|
689
|
+
Effect.runFork(this.refreshConversationParticipants(conversation.id));
|
|
499
690
|
}
|
|
500
691
|
break;
|
|
501
692
|
}
|
|
502
693
|
}
|
|
503
694
|
}
|
|
504
695
|
storeMessage(msg) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
}));
|
|
513
704
|
}
|
|
514
705
|
}
|
|
515
706
|
//# sourceMappingURL=service.js.map
|