salt-agent-sdk 0.1.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +941 -30
  3. package/dist/actions.d.ts +21 -5
  4. package/dist/actions.d.ts.map +1 -1
  5. package/dist/actions.js +673 -54
  6. package/dist/actions.js.map +1 -1
  7. package/dist/ask.d.ts +83 -0
  8. package/dist/ask.d.ts.map +1 -0
  9. package/dist/ask.js +215 -0
  10. package/dist/ask.js.map +1 -0
  11. package/dist/chatPrivacy.d.ts +34 -0
  12. package/dist/chatPrivacy.d.ts.map +1 -0
  13. package/dist/chatPrivacy.js +60 -0
  14. package/dist/chatPrivacy.js.map +1 -0
  15. package/dist/client.d.ts +2783 -35
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +1200 -143
  18. package/dist/client.js.map +1 -1
  19. package/dist/config.d.ts +16 -6
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +16 -5
  22. package/dist/config.js.map +1 -1
  23. package/dist/crypto.d.ts +62 -0
  24. package/dist/crypto.d.ts.map +1 -1
  25. package/dist/crypto.js +121 -0
  26. package/dist/crypto.js.map +1 -1
  27. package/dist/delegations.d.ts +25 -8
  28. package/dist/delegations.d.ts.map +1 -1
  29. package/dist/delegations.js +67 -11
  30. package/dist/delegations.js.map +1 -1
  31. package/dist/device/client.d.ts +161 -0
  32. package/dist/device/client.d.ts.map +1 -0
  33. package/dist/device/client.js +601 -0
  34. package/dist/device/client.js.map +1 -0
  35. package/dist/device/host.d.ts +222 -0
  36. package/dist/device/host.d.ts.map +1 -0
  37. package/dist/device/host.js +891 -0
  38. package/dist/device/host.js.map +1 -0
  39. package/dist/device/http.d.ts +12 -0
  40. package/dist/device/http.d.ts.map +1 -0
  41. package/dist/device/http.js +124 -0
  42. package/dist/device/http.js.map +1 -0
  43. package/dist/device/index.d.ts +5 -0
  44. package/dist/device/index.d.ts.map +1 -0
  45. package/dist/device/index.js +27 -0
  46. package/dist/device/index.js.map +1 -0
  47. package/dist/device/protocol.d.ts +312 -0
  48. package/dist/device/protocol.d.ts.map +1 -0
  49. package/dist/device/protocol.js +435 -0
  50. package/dist/device/protocol.js.map +1 -0
  51. package/dist/identities.d.ts +9 -2
  52. package/dist/identities.d.ts.map +1 -1
  53. package/dist/identities.js +33 -3
  54. package/dist/identities.js.map +1 -1
  55. package/dist/identity.d.ts +155 -0
  56. package/dist/identity.d.ts.map +1 -0
  57. package/dist/identity.js +196 -0
  58. package/dist/identity.js.map +1 -0
  59. package/dist/identityShare.d.ts +129 -0
  60. package/dist/identityShare.d.ts.map +1 -0
  61. package/dist/identityShare.js +308 -0
  62. package/dist/identityShare.js.map +1 -0
  63. package/dist/ids.d.ts +11 -0
  64. package/dist/ids.d.ts.map +1 -0
  65. package/dist/ids.js +17 -0
  66. package/dist/ids.js.map +1 -0
  67. package/dist/index.d.ts +13 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +13 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/reconcile.d.ts +25 -0
  72. package/dist/reconcile.d.ts.map +1 -0
  73. package/dist/reconcile.js +60 -0
  74. package/dist/reconcile.js.map +1 -0
  75. package/dist/recovery.d.ts +51 -0
  76. package/dist/recovery.d.ts.map +1 -0
  77. package/dist/recovery.js +59 -0
  78. package/dist/recovery.js.map +1 -0
  79. package/dist/register.d.ts +32 -0
  80. package/dist/register.d.ts.map +1 -0
  81. package/dist/register.js +65 -0
  82. package/dist/register.js.map +1 -0
  83. package/dist/scriptJob.d.ts +92 -0
  84. package/dist/scriptJob.d.ts.map +1 -0
  85. package/dist/scriptJob.js +270 -0
  86. package/dist/scriptJob.js.map +1 -0
  87. package/dist/sessions.d.ts +131 -0
  88. package/dist/sessions.d.ts.map +1 -0
  89. package/dist/sessions.js +240 -0
  90. package/dist/sessions.js.map +1 -0
  91. package/dist/socket.d.ts +115 -0
  92. package/dist/socket.d.ts.map +1 -0
  93. package/dist/socket.js +1038 -0
  94. package/dist/socket.js.map +1 -0
  95. package/dist/webhook.d.ts +613 -24
  96. package/dist/webhook.d.ts.map +1 -1
  97. package/dist/webhook.js +1384 -83
  98. package/dist/webhook.js.map +1 -1
  99. package/dist/work.d.ts +57 -0
  100. package/dist/work.d.ts.map +1 -0
  101. package/dist/work.js +213 -0
  102. package/dist/work.js.map +1 -0
  103. package/package.json +26 -4
package/dist/webhook.js CHANGED
@@ -50,31 +50,237 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
51
51
  };
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
- exports.HANDOFF_BRIEFING_MARKER = void 0;
53
+ exports.DEVICE_LOST_CONTACT = exports.HANDOFF_BRIEFING_MARKER = void 0;
54
+ exports.createDispatcher = createDispatcher;
54
55
  exports.createWebhookServer = createWebhookServer;
56
+ const node_crypto_1 = require("node:crypto");
55
57
  const express_1 = __importDefault(require("express"));
58
+ const asks = __importStar(require("./ask.js"));
56
59
  const pgp = __importStar(require("./crypto"));
57
60
  const delegations = __importStar(require("./delegations"));
61
+ const identityShare = __importStar(require("./identityShare.js"));
62
+ const identity_js_1 = require("./identity.js");
63
+ const ids_js_1 = require("./ids.js");
64
+ const reconcile_1 = require("./reconcile");
65
+ const sessions = __importStar(require("./sessions"));
66
+ const work_1 = require("./work");
58
67
  const PGP_MESSAGE_RE = /^-----BEGIN PGP MESSAGE/;
68
+ const DELIVERED_BECAUSE_VALUES = new Set(["mention", "reply", "keyword", "all"]);
69
+ /** Narrows salt-api's `message.delivered_because` (a plain string, only
70
+ * ever present on an open-room delivery) to the known union -- anything
71
+ * else (missing, or a value this SDK version doesn't recognise yet)
72
+ * becomes undefined rather than an unchecked cast, so a future kind added
73
+ * server-side degrades to "unknown reason" instead of lying about it. */
74
+ function parseDeliveredBecause(raw) {
75
+ return typeof raw === "string" && DELIVERED_BECAUSE_VALUES.has(raw) ? raw : undefined;
76
+ }
77
+ function parseReplyParent(raw) {
78
+ if (!raw || typeof raw !== "object")
79
+ return undefined;
80
+ const p = raw;
81
+ if (p.id == null)
82
+ return undefined;
83
+ return { id: String(p.id), author: p.author ?? null, excerpt: typeof p.excerpt === "string" ? p.excerpt : "" };
84
+ }
59
85
  /** The exact marker a hand-off briefing must start with, so the incoming
60
86
  * agent's polling loop (below) can detect it arrived. Consumers building
61
87
  * the outgoing briefing prompt must instruct their model to emit this
62
88
  * exact line first. */
63
89
  exports.HANDOFF_BRIEFING_MARKER = "[[SALT-HANDOFF-BRIEFING]]";
90
+ /** Content types decrypted straight to UTF-8 text in `DecryptedAttachment.text`,
91
+ * in addition to the raw bytes in `data`. Deliberately excludes PDF (see
92
+ * DecryptedAttachment.text's own comment) and images/audio/video (never
93
+ * meaningfully "text"). */
94
+ function isTextLikeAttachment(contentType) {
95
+ if (contentType.startsWith("text/"))
96
+ return true;
97
+ return ["application/json", "application/csv", "application/markdown"].includes(contentType);
98
+ }
99
+ // --- Device moments --------------------------------------------------------
100
+ //
101
+ // Two agent-rail deliveries (kind `device_session`) tell an agent that the
102
+ // person's computer went quiet in the middle of its work and that it came
103
+ // back. Plaintext metadata only -- ids, the computer's name, timestamps --
104
+ // delivered like card_interaction/invoice_paid, never a chat message. Source
105
+ // of truth: salt-api docs/DEVICE_PROTOCOL.md.
106
+ /** The `end_reason` of a device session. `lost_contact` is the one this SDK names: the computer stopped answering for 15 s mid-session. Any other value is passed through verbatim. */
107
+ exports.DEVICE_LOST_CONTACT = "lost_contact";
64
108
  const consoleLogger = {
65
109
  info: (msg) => console.log(msg),
66
110
  error: (msg) => console.error(msg),
67
111
  };
68
112
  /**
69
- * Creates the Express app for a Salt agent's webhook endpoint. Mount it
70
- * yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
71
- * to compose it into a bigger server).
113
+ * Everything about the Salt protocol that is NOT "how do bytes get to this
114
+ * process" -- signature verification and the decrypt/route/dedup/reply
115
+ * logic for every webhook event kind. `createWebhookServer` below wraps
116
+ * this in an Express POST route (the only transport before K2); socket.ts's
117
+ * `createSocketClient` wraps the SAME dispatcher around a websocket push
118
+ * connection instead, so a consumer switches delivery mode with no change
119
+ * to onMessage/onCardInteraction/etc. -- see socket.ts's own doc comment.
72
120
  */
73
- function createWebhookServer(options) {
74
- const { client, identities, pgpPassphrase, webhookSharedSecret, mediatorAgentId } = options;
121
+ function createDispatcher(options) {
122
+ const { client, identities, pgpPassphrase, mediatorAgentId } = options;
75
123
  const logger = options.logger ?? consoleLogger;
76
- const app = (0, express_1.default)();
77
- app.use(express_1.default.json());
124
+ const verifySignatures = options.verifySignatures !== false;
125
+ const signatureTolerance = options.signatureToleranceSeconds ?? 300;
126
+ const sessionStore = options.sessionStore ?? sessions.MemorySessionStore();
127
+ const identitySharer = identityShare.createIdentitySharer(client, pgpPassphrase);
128
+ const mapKey = (id) => String(id).toLowerCase();
129
+ // A lookup miss on a signed request is the signature of a stale store: the
130
+ // id salt-api signs with is not the one the identity was registered under
131
+ // (see reconcile.ts). Ask the API once, then look again. Throttled so an
132
+ // unknown-agent POST from the open internet costs at most one round of
133
+ // whoAmI calls a minute, and single-flight so a burst shares one pass.
134
+ const RECONCILE_MIN_INTERVAL_MS = 60_000;
135
+ let lastReconcileAt = 0;
136
+ let reconcileInFlight = null;
137
+ function reconcileOnMiss() {
138
+ if (reconcileInFlight)
139
+ return reconcileInFlight;
140
+ if (Date.now() - lastReconcileAt < RECONCILE_MIN_INTERVAL_MS)
141
+ return Promise.resolve();
142
+ lastReconcileAt = Date.now();
143
+ reconcileInFlight = (0, reconcile_1.reconcileIdentityIds)(identities, client, logger)
144
+ .catch((err) => logger.error(`[webhook] identity reconcile failed: ${err.message}`))
145
+ .finally(() => {
146
+ reconcileInFlight = null;
147
+ });
148
+ return reconcileInFlight;
149
+ }
150
+ // One signing key per identity, fetched lazily with that identity's own api
151
+ // key and cached. A miss is not fatal on its own -- verifyRequest decides.
152
+ const secretCache = new Map();
153
+ async function secretForAgent(agentId) {
154
+ const cacheKey = String(agentId).toLowerCase();
155
+ const cached = secretCache.get(cacheKey);
156
+ if (cached)
157
+ return cached;
158
+ let identity = identities.get(agentId);
159
+ if (!identity) {
160
+ await reconcileOnMiss();
161
+ identity = identities.get(agentId);
162
+ }
163
+ if (!identity)
164
+ return undefined;
165
+ try {
166
+ const secret = await client.getWebhookSecret(identity.apiKey);
167
+ if (secret)
168
+ secretCache.set(cacheKey, secret);
169
+ return secret;
170
+ }
171
+ catch (err) {
172
+ logger.error(`[webhook] could not fetch signing key for agent ${agentId}: ${err.message}`);
173
+ return undefined;
174
+ }
175
+ }
176
+ // R1 (round 3, 2026-09-18): a bounded, rate-limited recheck for the ONE
177
+ // case that can still legitimately race a rotation now that salt-api
178
+ // signs socket-mode envelopes fresh at serve time (LANES.md) -- a real
179
+ // webhook POST, which IS signed at send time by the enqueuing job, can
180
+ // still land here with a signature made against a secret that rotated
181
+ // a moment after the job read it but before this cache picked up the
182
+ // change. At most one uncached fetch per agent per 60s, single-flight
183
+ // (a burst of bad signatures for one agent shares the one in-flight
184
+ // check rather than each firing its own), and it NEVER evicts/replaces
185
+ // the cached secret unless the freshly fetched one actually verifies
186
+ // the failing envelope -- a fetch that doesn't fix the problem leaves
187
+ // the cache exactly as it was. Round 2's per-envelope evict-and-refetch
188
+ // (and its one-time "transient" grace) is gone entirely: with
189
+ // serve-time signing the socket path never needs it, and this bounded
190
+ // version is what's left for the webhook path.
191
+ const UNCACHED_CHECK_MIN_INTERVAL_MS = 60_000;
192
+ const lastUncachedCheckAt = new Map();
193
+ const uncachedCheckInFlight = new Map();
194
+ function uncachedSecretCheck(agentId, cacheKey) {
195
+ const inFlight = uncachedCheckInFlight.get(cacheKey);
196
+ if (inFlight)
197
+ return inFlight;
198
+ const lastAt = lastUncachedCheckAt.get(cacheKey) ?? 0;
199
+ if (Date.now() - lastAt < UNCACHED_CHECK_MIN_INTERVAL_MS)
200
+ return Promise.resolve(undefined);
201
+ lastUncachedCheckAt.set(cacheKey, Date.now());
202
+ const promise = (async () => {
203
+ const identity = identities.get(agentId);
204
+ if (!identity)
205
+ return undefined;
206
+ try {
207
+ return await client.getWebhookSecret(identity.apiKey);
208
+ }
209
+ catch (err) {
210
+ logger.error(`[webhook] uncached signing-key recheck for agent ${agentId} failed: ${err.message}`);
211
+ return undefined;
212
+ }
213
+ })().finally(() => {
214
+ uncachedCheckInFlight.delete(cacheKey);
215
+ });
216
+ uncachedCheckInFlight.set(cacheKey, promise);
217
+ return promise;
218
+ }
219
+ // Verifies one envelope's signature. Takes the two headers and the raw
220
+ // body explicitly rather than an Express Request -- the SAME check runs
221
+ // for a webhook POST (createWebhookServer passes req.get(...) and the
222
+ // captured rawBody) and for a socket-mode envelope (socket.ts passes the
223
+ // AgentUpdate row's `headers`/`body` fields verbatim), so this function
224
+ // must not assume either transport.
225
+ //
226
+ // `toleranceSeconds` overrides the default (signatureTolerance, ~300s)
227
+ // for a caller with its own reason to -- round 3 removed socket.ts's
228
+ // widened one (serve-time signing means a socket envelope's timestamp
229
+ // is always fresh too), so both transports use the same standard window
230
+ // by default now.
231
+ //
232
+ // `transient: true` marks a failure that might resolve on retry (right
233
+ // now, only "no signing key" -- secretForAgent already swallows its own
234
+ // network errors and returns undefined either way, so this can't
235
+ // distinguish "genuinely no such identity" from "the fetch failed" any
236
+ // more finely than that) -- socket.ts must not advance its cursor past a
237
+ // row that failed for a transient reason, or a real update sitting
238
+ // behind a network blip would be skipped forever.
239
+ async function verifyEnvelope(agentId, signature, rawBody, opts) {
240
+ if (!verifySignatures)
241
+ return { ok: true };
242
+ // Taken as sent. `Number(...)` here made agentId NaN for every real
243
+ // request, NaN is falsy, and so EVERY signed webhook was rejected as
244
+ // "missing signature" -- the header was present and correctly formed.
245
+ if (!agentId || !signature)
246
+ return { ok: false, reason: "missing signature", transient: false };
247
+ const t = /t=(\d+)/.exec(signature)?.[1];
248
+ const v1 = /v1=([0-9a-f]+)/.exec(signature)?.[1];
249
+ if (!t || !v1)
250
+ return { ok: false, reason: "malformed signature", transient: false };
251
+ // Replay window. Also rejects a clock far in the future.
252
+ const tolerance = opts?.toleranceSeconds ?? signatureTolerance;
253
+ const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
254
+ if (age > tolerance)
255
+ return { ok: false, reason: `stale signature (${age}s old)`, transient: false };
256
+ const secret = await secretForAgent(agentId);
257
+ if (!secret)
258
+ return { ok: false, reason: `no signing key for agent ${agentId}`, transient: true };
259
+ const cacheKey = String(agentId).toLowerCase();
260
+ // Constant-time: a fast string compare leaks the digest a byte at a time.
261
+ const verify = (withSecret) => {
262
+ const expected = (0, node_crypto_1.createHmac)("sha256", withSecret).update(`${t}.${rawBody}`).digest("hex");
263
+ const a = Buffer.from(v1, "utf8");
264
+ const b = Buffer.from(expected, "utf8");
265
+ return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
266
+ };
267
+ if (verify(secret))
268
+ return { ok: true };
269
+ // Pre-round-2 behaviour, restored: a bad signature is definitive --
270
+ // UNLESS the bounded recheck above finds a genuinely different
271
+ // secret that verifies it, in which case it self-heals silently and
272
+ // the cache picks up the new value. Never transient, never retried.
273
+ const fresh = await uncachedSecretCheck(agentId, cacheKey);
274
+ // The fresh secret comes from salt-api under the agent's own key, so it is
275
+ // authoritative: adopt it whenever it differs, even if THIS envelope doesn't
276
+ // verify under it, or a leaked old secret stays accepted after a rotation.
277
+ if (fresh && fresh !== secret) {
278
+ secretCache.set(cacheKey, fresh);
279
+ if (verify(fresh))
280
+ return { ok: true };
281
+ }
282
+ return { ok: false, reason: "bad signature", transient: false };
283
+ }
78
284
  // Salt fires one webhook per chat member with a callback URL
79
285
  // (Message#send_webhook in salt-api). When two or more identities THIS
80
286
  // process hosts share a chat -- which delegation makes possible -- that's
@@ -104,15 +310,51 @@ function createWebhookServer(options) {
104
310
  // THIS process hosts crosses this many auto-replies, the receiving
105
311
  // identity just stops answering the other one there, until a human sends
106
312
  // something new (which resets the count).
107
- const MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT = 2;
313
+ const MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT = options.maxAgentToAgentReplies ?? 2;
314
+ if (!Number.isInteger(MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT) || MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT < 1) {
315
+ throw new Error(`maxAgentToAgentReplies must be a positive integer, got ${String(options.maxAgentToAgentReplies)}`);
316
+ }
317
+ // A consult lane (actions.ts's consult_agent, chatMeta.lane_kind ===
318
+ // "consult") is a real working conversation between two agents, not an
319
+ // accidental loop -- it needs real back-and-forth room, so it gets its
320
+ // own, much higher ceiling instead of the ordinary chat's cap.
321
+ const CONSULT_RUNAWAY_LIMIT = 20;
322
+ // Keyed by the lowercased chat id, like every other id-keyed map here.
108
323
  const agentToAgentReplyCounts = new Map();
109
- app.get("/health", (_req, res) => {
110
- res.json({
111
- status: "ok",
112
- identities: identities.all().map((i) => ({ salt_app_id: i.saltAppId, username: i.username })),
113
- ...(options.healthExtra ? options.healthExtra() : {}),
114
- });
115
- });
324
+ // laneChatId (lowercased) -> its room's chat id (lowercased), learned the
325
+ // first time a message is seen in that lane. A human speaking in the ROOM
326
+ // should reset every consult lane rooted there too -- a lane's own count
327
+ // otherwise only resets from someone speaking IN the lane itself, and a
328
+ // busy pair of agents burning through CONSULT_RUNAWAY_LIMIT would stay
329
+ // capped even right after the person who asked for the consult weighs in.
330
+ const laneRoomOf = new Map();
331
+ // roomId (lowercased) -> the consult lane chat id a request_floor message
332
+ // just triggered an automatic hand-off from. Set by the floor-request
333
+ // handler right before calling client.handOff, consumed by
334
+ // handleHandoffConfirmed once salt-api's own handoff_confirmed webhook
335
+ // arrives for that room -- that's what lets it attach consultTranscript.
336
+ // Best-effort: a second floor request for the same room before the first
337
+ // hand-off confirms would overwrite this, which is an acceptable rarity.
338
+ const pendingConsultHandoffs = new Map();
339
+ // chat_opened has no message_id to dedupe on (it isn't a Message at all),
340
+ // and delivery can be retried -- so a retried delivery must not make this
341
+ // identity greet the same chat twice. Keyed by identity+chat since one
342
+ // process can host several identities and a chat could in principle be
343
+ // opened for more than one of them. Bounded the same way seenMessageIds is.
344
+ const MAX_SEEN_CHAT_OPENED = 2000;
345
+ const seenChatOpened = new Set();
346
+ function alreadyGreeted(identityId, chatId) {
347
+ const key = `${String(identityId).toLowerCase()}:${String(chatId).toLowerCase()}`;
348
+ if (seenChatOpened.has(key))
349
+ return true;
350
+ seenChatOpened.add(key);
351
+ if (seenChatOpened.size > MAX_SEEN_CHAT_OPENED) {
352
+ const oldest = seenChatOpened.values().next().value;
353
+ if (oldest !== undefined)
354
+ seenChatOpened.delete(oldest);
355
+ }
356
+ return false;
357
+ }
116
358
  // Every identity this process hosts shares this one endpoint, so the
117
359
  // first step is figuring out *which* identity a given message is
118
360
  // addressed to. Salt encrypts the message to every chat member's public
@@ -120,8 +362,30 @@ function createWebhookServer(options) {
120
362
  // private key that was actually a member of that chat, so trying each
121
363
  // known identity in turn and keeping whichever one succeeds is both
122
364
  // correct and cheap for the small number of identities a process hosts.
123
- async function resolveIdentity(ciphertext) {
365
+ async function resolveIdentity(ciphertext, headerAgentId) {
366
+ // salt-api signs every callback for a specific RECIPIENT identity and
367
+ // names it in X-Salt-Agent-Id (see rejectionReason above) -- when that
368
+ // names one of ours, try it first. Skips the ambiguity trial-decryption
369
+ // has when this process hosts more than one identity that's a member of
370
+ // the same chat (a delegation or consult lane makes that possible): any
371
+ // member's key opens the multi-recipient blob, so without the header,
372
+ // which one "wins" is just whichever was registered first.
373
+ if (headerAgentId) {
374
+ const preferred = identities.get(headerAgentId);
375
+ if (preferred) {
376
+ try {
377
+ const plaintext = await pgp.decrypt(ciphertext, preferred.privateKey, pgpPassphrase);
378
+ return { identity: preferred, plaintext };
379
+ }
380
+ catch {
381
+ // The header named a real hosted identity, but this ciphertext
382
+ // isn't addressed to it -- fall through to trial decryption.
383
+ }
384
+ }
385
+ }
124
386
  for (const candidate of identities.all()) {
387
+ if (headerAgentId && (0, ids_js_1.sameId)(candidate.saltAppId, headerAgentId))
388
+ continue; // already tried above
125
389
  try {
126
390
  const plaintext = await pgp.decrypt(ciphertext, candidate.privateKey, pgpPassphrase);
127
391
  return { identity: candidate, plaintext };
@@ -149,39 +413,162 @@ function createWebhookServer(options) {
149
413
  const lines = [];
150
414
  for (const raw of messages) {
151
415
  const m = raw;
152
- if (m.event_type || typeof m.message !== "string" || !PGP_MESSAGE_RE.test(m.message))
416
+ if (m.event_type || typeof m.message !== "string")
153
417
  continue;
154
- try {
155
- const text = await pgp.decrypt(m.message, identity.privateKey, pgpPassphrase);
156
- lines.push(`${m.user?.display_name || "someone"}: ${text}`);
418
+ let text;
419
+ if (m.encrypted === false) {
420
+ // Open room (salt-api 0.6x): already plaintext, nothing to decrypt.
421
+ text = m.message;
157
422
  }
158
- catch {
159
- // Predates this identity joining the chat -- skip silently.
423
+ else {
424
+ if (!PGP_MESSAGE_RE.test(m.message))
425
+ continue;
426
+ try {
427
+ text = await pgp.decrypt(m.message, identity.privateKey, pgpPassphrase);
428
+ }
429
+ catch {
430
+ continue; // predates this identity joining the chat -- skip silently.
431
+ }
160
432
  }
433
+ lines.push(`${m.user?.display_name || "someone"}: ${text}`);
161
434
  }
162
435
  return lines.length ? lines.join("\n") : "(no messages yet)";
163
436
  }
437
+ // Same idea as buildSharedChatContext, but for a session's cold-start
438
+ // rebuild: structured turns instead of joined display lines, capped to
439
+ // the same MAX_TRANSCRIPT_TURNS a live session is bounded to, and this
440
+ // identity's own messages come back tagged "assistant" rather than folded
441
+ // into "someone: ...". Messages from before `identity` joined fail to
442
+ // decrypt and are skipped -- same degrade-gracefully convention.
443
+ async function rebuildTranscriptTail(identity, chatId) {
444
+ let messages;
445
+ try {
446
+ messages = await client.getChatMessages(identity.apiKey, chatId);
447
+ }
448
+ catch (err) {
449
+ logger.error(`[session] fetching chat ${chatId} for cold-start rebuild failed: ${err.message}`);
450
+ return [];
451
+ }
452
+ const turns = [];
453
+ for (const raw of messages) {
454
+ const m = raw;
455
+ if (m.event_type || typeof m.message !== "string")
456
+ continue;
457
+ let plaintext;
458
+ if (m.encrypted === false) {
459
+ // Open room (salt-api 0.6x): already plaintext, nothing to decrypt.
460
+ plaintext = m.message;
461
+ }
462
+ else {
463
+ if (!PGP_MESSAGE_RE.test(m.message))
464
+ continue;
465
+ try {
466
+ plaintext = await pgp.decrypt(m.message, identity.privateKey, pgpPassphrase);
467
+ }
468
+ catch {
469
+ continue; // predates this identity joining the chat -- skip silently.
470
+ }
471
+ }
472
+ const { text: afterDepth } = delegations.parseIncoming(plaintext);
473
+ const content = delegations.stripConsultMarker(afterDepth);
474
+ const isSelf = (0, ids_js_1.sameId)(m.user?.id, identity.saltAppId);
475
+ turns.push({
476
+ role: isSelf ? "assistant" : "user",
477
+ content,
478
+ at: m.created_at ? Date.parse(m.created_at) : Date.now(),
479
+ from: isSelf ? undefined : m.user?.username || m.user?.display_name || String(m.user?.id ?? "someone"),
480
+ // Only meaningful for the OTHER party's turn -- this identity's own
481
+ // messages have no "reason it was delivered to me" to report.
482
+ deliveredBecause: isSelf ? undefined : parseDeliveredBecause(m.delivered_because),
483
+ });
484
+ }
485
+ return turns.length > sessions.MAX_TRANSCRIPT_TURNS ? turns.slice(-sessions.MAX_TRANSCRIPT_TURNS) : turns;
486
+ }
487
+ // Loads the stored session for (identity, chatId), or -- on a cold start --
488
+ // builds a fresh one from the chat's own history. Never throws: a session
489
+ // store or rebuild failure falls back to an empty session rather than
490
+ // blocking the message it's here to support.
491
+ async function loadOrRebuildSession(identity, chatId, chatMeta) {
492
+ const roomId = chatMeta?.coaching_for_chat_id || chatId;
493
+ try {
494
+ const existing = await sessionStore.get(identity.saltAppId, chatId);
495
+ if (existing)
496
+ return existing;
497
+ }
498
+ catch (err) {
499
+ logger.error(`[session] loading ${chatId} failed: ${err.message}`);
500
+ }
501
+ const role = chatMeta?.lane_kind === "consult" ? "consult" : chatMeta?.coaching_for_chat_id ? "lane" : "active";
502
+ const session = sessions.emptySession(chatId, roomId, role);
503
+ try {
504
+ session.transcriptTail = await rebuildTranscriptTail(identity, chatId);
505
+ }
506
+ catch (err) {
507
+ logger.error(`[session] rebuilding ${chatId} failed: ${err.message}`);
508
+ }
509
+ return session;
510
+ }
511
+ // Appends each of `repliesForSession` to `session` as an assistant turn,
512
+ // bounds the note, and persists -- shared by every context type that has
513
+ // a reply() worth remembering (onMessage additionally appends its own
514
+ // incoming "user" turn first; see handleMessage, which does that inline
515
+ // since it's the only one with an incoming turn to record at all). Never
516
+ // throws: a persistence failure here must not turn into an unhandled
517
+ // rejection on top of a webhook handler that already ran successfully.
518
+ async function persistSessionAfterReply(identityId, chatId, session, repliesForSession) {
519
+ try {
520
+ for (const replyText of repliesForSession) {
521
+ sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
522
+ }
523
+ session.note = sessions.boundNote(session.note);
524
+ session.updatedAt = Date.now();
525
+ await sessionStore.put(identityId, chatId, session);
526
+ }
527
+ catch (err) {
528
+ logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
529
+ }
530
+ }
531
+ // The consult lane's own transcript, for a hand-off briefing that was
532
+ // triggered by a request_floor call rather than chosen by a model turn --
533
+ // see handleHandoffConfirmed. Capped to the last few exchanges; a consult
534
+ // lane that ran long doesn't need its entire history in a briefing.
535
+ const MAX_CONSULT_TRANSCRIPT_LINES = 30;
536
+ async function buildConsultTranscript(identity, laneId) {
537
+ const full = await buildSharedChatContext(identity, laneId);
538
+ const lines = full.split("\n");
539
+ return lines.length > MAX_CONSULT_TRANSCRIPT_LINES ? lines.slice(-MAX_CONSULT_TRANSCRIPT_LINES).join("\n") : full;
540
+ }
164
541
  // Decrypts an attachment's PGP metadata blob (key/iv/filename/content_type),
165
- // downloads the ciphertext, and AES-GCM-decrypts it. Only images are
166
- // returned with actual bytes -- the SDK doesn't know how any given model
167
- // wants other file types represented, so those get a text note instead.
542
+ // downloads the ciphertext, and AES-GCM-decrypts it -- for any content
543
+ // type, up to pgp.MAX_ATTACHMENT_BYTES (the declared `size` is checked
544
+ // BEFORE downloading/decrypting, so a metadata blob that lies about its
545
+ // own size can't make this pull down and hold an unbounded blob). Text-
546
+ // shaped content (text/*, JSON, CSV, Markdown) also gets a decoded `text`
547
+ // field alongside the raw bytes; a PDF gets bytes only (see
548
+ // DecryptedAttachment.text's comment on why text extraction stops there).
168
549
  async function decryptAttachmentIfPresent(identity, message) {
169
550
  if (!message.resource?.encrypted_key)
170
551
  return undefined;
171
552
  try {
172
553
  const metaJson = await pgp.decrypt(message.resource.encrypted_key, identity.privateKey, pgpPassphrase);
173
554
  const meta = JSON.parse(metaJson);
174
- if (!meta.content_type?.startsWith("image/")) {
555
+ const filename = meta.filename;
556
+ const contentType = meta.content_type || "unknown";
557
+ if (meta.size > pgp.MAX_ATTACHMENT_BYTES) {
175
558
  return {
176
- filename: meta.filename,
177
- contentType: meta.content_type || "unknown",
559
+ filename,
560
+ contentType,
178
561
  size: meta.size,
179
- unsupportedNote: `attached file: ${meta.filename}, ${meta.content_type || "unknown type"}, ${meta.size} bytes -- not viewable, only images are.`,
562
+ unsupportedNote: `attached file: ${filename}, ${contentType}, ${meta.size} bytes -- too large to fetch (cap ${pgp.MAX_ATTACHMENT_BYTES} bytes).`,
180
563
  };
181
564
  }
182
565
  const ciphertextBytes = await client.getAttachment(identity.apiKey, message.message_id);
183
566
  const plaintext = pgp.decryptAttachment(ciphertextBytes, meta.key, meta.iv);
184
- return { filename: meta.filename, contentType: meta.content_type, size: meta.size, data: plaintext };
567
+ const result = { filename, contentType, size: meta.size, data: plaintext };
568
+ if (isTextLikeAttachment(contentType)) {
569
+ result.text = plaintext.toString("utf8");
570
+ }
571
+ return result;
185
572
  }
186
573
  catch (err) {
187
574
  logger.error(`[attachment] failed to decrypt: ${err.message}`);
@@ -210,14 +597,30 @@ function createWebhookServer(options) {
210
597
  // trail onto it + fire-and-forget metrics. Does NOT itself manage the
211
598
  // typing indicator -- see withTypingHeartbeat, which wraps the whole
212
599
  // callback (including whatever happens before reply() is even called).
213
- function makeReply(identity, chatId) {
600
+ // `addressee` is whoever this reply is answering, when there is one. In a
601
+ // GROUP chat the reply is addressed back to them by handle -- being spoken
602
+ // to by name is how a person tells which of several messages in a busy room
603
+ // is meant for them, and an agent that answers into the middle of a group
604
+ // without naming anyone reads as talking to the room.
605
+ //
606
+ // Two deliberate limits. Only in a group: in a 1:1 there is exactly one
607
+ // person it could be for, and "@dan" on every line is noise. And only for a
608
+ // HUMAN: an agent addressed by handle receives a webhook, so auto-addressing
609
+ // another agent invites exactly the ping-pong the loop caps in this file
610
+ // exist to stop.
611
+ function makeReply(identity, chatId, addressee) {
214
612
  return async (text) => {
215
613
  const startedAt = Date.now();
216
614
  let recipientKeys;
615
+ let memberCount = 0;
217
616
  try {
218
617
  const members = await client.getChatMembers(identity.apiKey, chatId);
618
+ memberCount = members.length;
219
619
  recipientKeys = members
220
- .filter((u) => parseInt(String(u.id), 10) !== identity.saltAppId && u.public_key)
620
+ // Everyone but ourselves. parseInt made both sides NaN, and
621
+ // `NaN !== NaN` is true, so the sender's own key was left in the
622
+ // recipient list rather than filtered out of it.
623
+ .filter((u) => !(0, ids_js_1.sameId)(u.id, identity.saltAppId) && u.public_key)
221
624
  .map((u) => u.public_key);
222
625
  }
223
626
  catch (err) {
@@ -228,10 +631,23 @@ function createWebhookServer(options) {
228
631
  logger.error(`[chat ${chatId}] no recipient public keys; not sending.`);
229
632
  return;
230
633
  }
634
+ // Group + human + a handle to use, and not already addressed by whoever
635
+ // wrote the reply (an agent that writes its own "@dan" keeps it, and
636
+ // does not get a second one bolted on the front).
637
+ const addressHandle = memberCount > 2 &&
638
+ addressee?.username &&
639
+ addressee.account_type !== "Agent" &&
640
+ !new RegExp(`(^|\\s)@${addressee.username}(?![\\w.-])`).test(text)
641
+ ? addressee.username
642
+ : null;
643
+ const outgoing = addressHandle ? `@${addressHandle} ${text}` : text;
644
+ // The ids, not the text: Salt only ever sees ciphertext, so an "@handle"
645
+ // in the body is invisible to it and notifies nobody by itself.
646
+ const mentions = addressHandle ? [addressee.id] : undefined;
231
647
  let encryptedMessage, senderMessage;
232
648
  try {
233
- encryptedMessage = await pgp.encryptFor(text, recipientKeys);
234
- senderMessage = await pgp.encryptFor(text, [identity.publicKey]);
649
+ encryptedMessage = await pgp.encryptFor(outgoing, recipientKeys);
650
+ senderMessage = await pgp.encryptFor(outgoing, [identity.publicKey]);
235
651
  }
236
652
  catch (err) {
237
653
  logger.error(`[chat ${chatId}] encryption failed: ${err.message}`);
@@ -239,7 +655,7 @@ function createWebhookServer(options) {
239
655
  }
240
656
  const trail = delegations.drainTrail(identity.saltAppId, chatId);
241
657
  try {
242
- await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail);
658
+ await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail, mentions);
243
659
  }
244
660
  catch (err) {
245
661
  logger.error(`[chat ${chatId}] posting reply failed: ${err.message}`);
@@ -252,7 +668,159 @@ function createWebhookServer(options) {
252
668
  });
253
669
  };
254
670
  }
255
- async function handleMessage(body) {
671
+ // ctx.ask/ctx.approve (K3, ask.ts) -- bound to one (identity, chatId) the
672
+ // same way makeReply above is, and handed to every context type that
673
+ // gets a reply(). The actual card post/update and pending-wait registry
674
+ // live in ask.ts; resolveCardInteraction/resolveMessage below (wired into
675
+ // handleCardInteraction/handleMessage) are what settle a wait this
676
+ // creates.
677
+ //
678
+ // `defaultAnswererId` (M2, security review) is each context's natural
679
+ // "whoever this turn is answering" -- see the individual ctx-construction
680
+ // call sites for what each context type passes (a message's senderRaw,
681
+ // chat_opened's openedBy, invoice_paid's buyer; the two hand-off contexts
682
+ // have no natural default and pass none, so their ask()/approve() calls
683
+ // must supply `answererId` explicitly). A caller's own `opts.answererId`
684
+ // always wins over this default.
685
+ function makeAsk(identity, chatId, defaultAnswererId) {
686
+ return (question, opts) => asks.ask(client, identity, chatId, question, { answererId: defaultAnswererId, ...opts });
687
+ }
688
+ function makeApprove(identity, chatId, defaultAnswererId) {
689
+ return (summary, opts) => asks.approve(client, identity, chatId, summary, { answererId: defaultAnswererId, ...opts });
690
+ }
691
+ // ctx.shareIdentity (identityShare.ts) -- bound to one (identity, chatId)
692
+ // the same way makeAsk/makeApprove above are.
693
+ function makeShareIdentity(identity, chatId) {
694
+ return (keys, opts) => identitySharer.share(identity, chatId, keys, opts);
695
+ }
696
+ // True when `chatId` has at least one human (account_type !== "Agent")
697
+ // member who is NOT a silent observer. A consult lane's human -- the
698
+ // delegation chain's auditor added per actions.ts's delegation-
699
+ // observability note -- is only ever an observer there, so a lane reads as
700
+ // agent-only even though a person can technically decrypt it. `chatMeta`'s
701
+ // own member list is used when a caller already supplied one (mainly
702
+ // tests); the real webhook payload's `chat` never carries members (see
703
+ // salt-api's WebhookJob#user_send allowlist), so this normally falls back
704
+ // to fetching the roster -- the same call makeReply above already makes to
705
+ // encrypt a reply.
706
+ async function chatHasNonObserverHuman(identity, chatId, chatMeta) {
707
+ const inlineMembers = Array.isArray(chatMeta?.users) ? chatMeta.users : undefined;
708
+ let members = inlineMembers;
709
+ if (!members) {
710
+ try {
711
+ members = await client.getChatMembers(identity.apiKey, chatId);
712
+ }
713
+ catch (err) {
714
+ logger.error(`[chat ${chatId}] fetching members for mention-gating failed: ${err.message}`);
715
+ return false;
716
+ }
717
+ }
718
+ return members.some((m) => m.account_type !== "Agent" && m.account_type !== "Device" && !m.observer);
719
+ }
720
+ // Runs `options.onIdentityAsk` for an incoming SALT-IDENTITY-ASK and
721
+ // answers it -- a SLICE (carrying `ask=parsed.id`) for the keys the
722
+ // handler chose to share, a DECLINE (same) for null/false/an empty
723
+ // array. Never lets a handler or send failure escape to the caller: an
724
+ // ask that can't be answered must not crash the webhook pass that
725
+ // received it, the same convention every other handler-invoking helper
726
+ // in this file follows.
727
+ async function handleIdentityAsk(identity, chatId, senderId, parsed) {
728
+ let decision;
729
+ try {
730
+ decision = await options.onIdentityAsk({ id: parsed.id, keys: parsed.keys, text: parsed.text, chatId, from: senderId });
731
+ }
732
+ catch (err) {
733
+ logger.error(`[chat ${chatId}] onIdentityAsk failed: ${err.message}`);
734
+ return;
735
+ }
736
+ try {
737
+ if (Array.isArray(decision) && decision.length > 0) {
738
+ await identitySharer.share(identity, chatId, decision, { ask: parsed.id });
739
+ }
740
+ else {
741
+ await identitySharer.decline(identity, chatId, parsed.id);
742
+ }
743
+ }
744
+ catch (err) {
745
+ logger.error(`[chat ${chatId}] answering identity ask ${parsed.id} failed: ${err.message}`);
746
+ }
747
+ }
748
+ // Handles an incoming SLICE, DECLINE or REVOKE from someone else. A
749
+ // SLICE is verified against the sender's own public key (fetched fresh
750
+ // from the chat's member list -- the same call makeReply already makes
751
+ // to encrypt a reply) and, when verified, recorded on this (identity,
752
+ // chat) session before `onIdentityShared` fires; a REVOKE removes any
753
+ // slice recorded under the same id first. Never lets a verification,
754
+ // session, or handler failure escape -- same convention as
755
+ // handleIdentityAsk above.
756
+ async function handleIdentitySharedMarker(identity, chatId, senderId, parsed) {
757
+ if (parsed.kind === "slice") {
758
+ let verified = false;
759
+ try {
760
+ const members = await client.getChatMembers(identity.apiKey, chatId);
761
+ const sender = members.find((m) => (0, ids_js_1.sameId)(m.id, senderId));
762
+ if (sender?.public_key) {
763
+ verified = await pgp.verifyDetached((0, identity_js_1.canonicalizeJcs)(parsed.payload.sections), parsed.payload.signature, sender.public_key);
764
+ }
765
+ }
766
+ catch (err) {
767
+ logger.error(`[chat ${chatId}] verifying identity slice ${parsed.id} failed: ${err.message}`);
768
+ }
769
+ try {
770
+ const session = await loadOrRebuildSession(identity, chatId);
771
+ sessions.recordReceivedIdentitySlice(session, senderId, {
772
+ id: parsed.id,
773
+ sections: parsed.payload.sections,
774
+ verified,
775
+ receivedAt: Date.now(),
776
+ });
777
+ session.updatedAt = Date.now();
778
+ await sessionStore.put(identity.saltAppId, chatId, session);
779
+ }
780
+ catch (err) {
781
+ logger.error(`[session] recording identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
782
+ }
783
+ if (options.onIdentityShared) {
784
+ try {
785
+ await options.onIdentityShared({
786
+ kind: "slice",
787
+ id: parsed.id,
788
+ askId: parsed.askId,
789
+ from: senderId,
790
+ chatId,
791
+ sections: parsed.payload.sections,
792
+ verified,
793
+ });
794
+ }
795
+ catch (err) {
796
+ logger.error(`[chat ${chatId}] onIdentityShared (slice) failed: ${err.message}`);
797
+ }
798
+ }
799
+ return;
800
+ }
801
+ if (parsed.kind === "revoke") {
802
+ try {
803
+ const session = await loadOrRebuildSession(identity, chatId);
804
+ sessions.forgetReceivedIdentitySlice(session, senderId, parsed.id);
805
+ session.updatedAt = Date.now();
806
+ await sessionStore.put(identity.saltAppId, chatId, session);
807
+ }
808
+ catch (err) {
809
+ logger.error(`[session] forgetting revoked identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
810
+ }
811
+ }
812
+ if (options.onIdentityShared) {
813
+ try {
814
+ await options.onIdentityShared(parsed.kind === "decline"
815
+ ? { kind: "decline", id: parsed.id, askId: parsed.askId, from: senderId, chatId }
816
+ : { kind: "revoke", id: parsed.id, from: senderId, chatId });
817
+ }
818
+ catch (err) {
819
+ logger.error(`[chat ${chatId}] onIdentityShared (${parsed.kind}) failed: ${err.message}`);
820
+ }
821
+ }
822
+ }
823
+ async function handleMessage(body, headerAgentId) {
256
824
  const message = body.message;
257
825
  const chatId = message.chat_id;
258
826
  const chatMeta = body.chat;
@@ -260,15 +828,50 @@ function createWebhookServer(options) {
260
828
  return;
261
829
  if (message.event_type)
262
830
  return; // system events aren't prompts
263
- const ciphertext = message.message;
264
- if (typeof ciphertext !== "string" || !PGP_MESSAGE_RE.test(ciphertext))
265
- return;
266
- const resolved = await resolveIdentity(ciphertext);
267
- if (!resolved) {
268
- logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
831
+ const rawMessage = message.message;
832
+ if (typeof rawMessage !== "string")
269
833
  return;
834
+ // Open rooms (salt-api 0.6x): a plain chat delivers `encrypted: false`
835
+ // and `message` is the text itself, not a PGP blob -- there is nothing
836
+ // to trial-decrypt, and no way to recover which hosted identity this
837
+ // delivery was addressed to except the header salt-api signs every
838
+ // callback with. Without it (a header-less transport, or a header
839
+ // naming an identity this process doesn't host) there is no safe
840
+ // fallback the way trial-decryption gives the ciphertext path below --
841
+ // guessing would risk this identity replying as if it heard a message
842
+ // that was actually addressed to a sibling identity sharing this
843
+ // process.
844
+ const isPlaintext = message.encrypted === false;
845
+ let identity;
846
+ let caption;
847
+ if (isPlaintext) {
848
+ const preferred = headerAgentId ? identities.get(headerAgentId) : undefined;
849
+ if (!preferred) {
850
+ logger.error(`[chat ${chatId}] no known identity for this plaintext message (X-Salt-Agent-Id ${headerAgentId ?? "missing"}); ignoring.`);
851
+ return;
852
+ }
853
+ identity = preferred;
854
+ caption = rawMessage;
855
+ }
856
+ else {
857
+ if (!PGP_MESSAGE_RE.test(rawMessage))
858
+ return;
859
+ const resolved = await resolveIdentity(rawMessage, headerAgentId);
860
+ if (!resolved) {
861
+ logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
862
+ return;
863
+ }
864
+ identity = resolved.identity;
865
+ caption = resolved.plaintext;
270
866
  }
271
- let { identity, plaintext: caption } = resolved;
867
+ // Wire protocol, never a prompt: a hand-off briefing is consumed by
868
+ // handleHandoffReceived's poll (above HANDOFF_BRIEFING_MARKER's
869
+ // definition), and a bare session-note line is consumed by whichever
870
+ // hand-off leg reads it back out (sessions.ts). Either one reaching
871
+ // onMessage reads to a viewer as this identity replying to its own
872
+ // colleague's internal handoff chatter.
873
+ if (caption.startsWith(exports.HANDOFF_BRIEFING_MARKER) || caption.startsWith(sessions.SESSION_NOTE_MARKER))
874
+ return;
272
875
  // GACM routing fix: salt-api sends one webhook delivery PER agent
273
876
  // member, but they all land on this shared server and message-id dedup
274
877
  // collapses them into a single pass -- whose decrypt-resolved identity
@@ -277,51 +880,180 @@ function createWebhookServer(options) {
277
880
  // THAT identity; the plaintext is identical under any key, only "who
278
881
  // replies" changes.
279
882
  if (chatMeta?.active_agent_id) {
280
- const active = identities.get(parseInt(String(chatMeta.active_agent_id), 10));
883
+ const active = identities.get(String(chatMeta.active_agent_id));
281
884
  if (active)
282
885
  identity = active;
283
886
  }
887
+ // A message with no sender is not a prompt, and everything below -- the
888
+ // self-reply loop break, the delegation-reply match, the agent-to-agent
889
+ // cap -- is a question about who sent it. This used to fall through with
890
+ // senderId = NaN, which compared equal to nothing and unequal to
891
+ // everything, so all three guards inverted rather than being skipped. The
892
+ // context handed to consumers already asserted a sender existed.
284
893
  const senderRaw = message.user;
285
- const senderId = senderRaw ? parseInt(String(senderRaw.id), 10) : NaN;
894
+ if (!senderRaw)
895
+ return;
896
+ const senderId = String(senderRaw.id);
286
897
  // Reply to a pending delegation call? Hand it to that waiting promise
287
898
  // instead of starting a fresh reply cycle -- see delegations.ts.
288
899
  if (delegations.resolveIfPending(chatId, senderId, caption))
289
900
  return;
901
+ // The answer to a pending ctx.ask, typed rather than tapped? Settle
902
+ // that wait instead of starting a fresh reply cycle -- see ask.ts.
903
+ // Only a human's plain reply counts (an agent addressed by handle
904
+ // gets its own webhook/turn, same reasoning as the mention-gating
905
+ // below); `caption` is the raw decrypted text, same as delegations'
906
+ // check just above.
907
+ if (asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption))
908
+ return;
290
909
  // Never reply to our own messages (the reply we post is itself
291
910
  // delivered back to us as a webhook) -- this is what prevents an
292
911
  // infinite loop.
293
- if (senderId === identity.saltAppId)
912
+ if ((0, ids_js_1.sameId)(senderId, identity.saltAppId))
913
+ return;
914
+ // A device lane is wire protocol, not conversation: hand it to the device
915
+ // hook before any conversational gate can drop it (see onDeviceLaneMessage).
916
+ if (chatMeta?.lane_kind === "device" && options.onDeviceLaneMessage) {
917
+ try {
918
+ Promise.resolve(options.onDeviceLaneMessage({ identity, chatId, senderId, sender: senderRaw, text: caption, chatMeta })).catch((err) => {
919
+ logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
920
+ });
921
+ }
922
+ catch (err) {
923
+ logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
924
+ }
925
+ return;
926
+ }
927
+ // request_floor (actions.ts): the consulted agent is asking to be
928
+ // brought into the room directly instead of relaying further through
929
+ // this lane. Wire protocol, never a prompt -- and only actionable by
930
+ // whichever hosted identity actually opened this lane as the asker.
931
+ if (caption.startsWith(delegations.FLOOR_REQUEST_MARKER)) {
932
+ const asker = delegations.consultAskerFor(chatId);
933
+ if (asker && (0, ids_js_1.sameId)(asker.askerId, identity.saltAppId)) {
934
+ const reason = caption.slice(delegations.FLOOR_REQUEST_MARKER.length).replace(/^\n/, "").trim() || undefined;
935
+ pendingConsultHandoffs.set(mapKey(asker.roomId), chatId);
936
+ try {
937
+ await client.handOff(identity.apiKey, asker.roomId, senderId, reason);
938
+ }
939
+ catch (err) {
940
+ pendingConsultHandoffs.delete(mapKey(asker.roomId));
941
+ logger.error(`[chat ${asker.roomId}] auto hand-off on floor request failed: ${err.message}`);
942
+ }
943
+ }
944
+ return;
945
+ }
946
+ // Identity (R3/R4, identityShare.ts): a SALT-IDENTITY-ASK/SLICE/
947
+ // DECLINE/REVOKE is wire protocol, never a prompt -- with one
948
+ // deliberate exception. An ASK with no onIdentityAsk handler registered
949
+ // is NOT intercepted at all: it falls through as an ordinary message,
950
+ // marker stripped, so a consumer that hasn't wired up the handler yet
951
+ // sees whatever text (if any) rode alongside it rather than losing the
952
+ // message outright. SLICE/DECLINE/REVOKE are always intercepted,
953
+ // whether or not onIdentityShared is set -- there is no "ordinary
954
+ // text" reading of a signed slice or a revoke.
955
+ if (caption.startsWith(identityShare.IDENTITY_MARKER_PREFIX)) {
956
+ const parsed = identityShare.parseIdentityMarker(caption);
957
+ if (parsed?.kind === "ask") {
958
+ if (options.onIdentityAsk) {
959
+ await handleIdentityAsk(identity, chatId, senderId, parsed);
960
+ return;
961
+ }
962
+ caption = parsed.text || "";
963
+ }
964
+ else if (parsed) {
965
+ await handleIdentitySharedMarker(identity, chatId, senderId, parsed);
966
+ return;
967
+ }
968
+ else {
969
+ // Starts with the prefix but didn't parse into anything usable --
970
+ // a garbled or foreign marker either way, never a prompt.
971
+ return;
972
+ }
973
+ }
974
+ // Cancel (work.ts's [[SALT-WORK-CANCEL id=...]]): the person cancelling
975
+ // a scheduled report of theirs, sent as a whole message into the lane
976
+ // that report was posted into. Wire protocol, never a prompt -- always
977
+ // intercepted, whether or not onWorkCancel is set, same as a SLICE/
978
+ // DECLINE/REVOKE above (there's no "ordinary text" reading of it).
979
+ const cancelWorkId = (0, work_1.parseWorkCancel)(caption);
980
+ if (cancelWorkId) {
981
+ if (options.onWorkCancel) {
982
+ const cancelRoomId = chatMeta?.coaching_for_chat_id || chatId;
983
+ try {
984
+ await options.onWorkCancel({ identity, workId: cancelWorkId, chatId: cancelRoomId, laneId: chatId, sender: senderRaw });
985
+ }
986
+ catch (err) {
987
+ logger.error(`[chat ${chatId}] onWorkCancel failed: ${err.message}`);
988
+ }
989
+ }
294
990
  return;
991
+ }
295
992
  // Mediated Chat: this identity is the configured Mediator, and this
296
993
  // message is in the SHARED chat it silently observes -- present for
297
994
  // context, but it only actually speaks in each human's own private
298
995
  // coaching chat (coaching_for_chat_id pointing back at this one).
299
- const isMediator = !!mediatorAgentId && identity.saltAppId === mediatorAgentId;
996
+ const isMediator = !!mediatorAgentId && (0, ids_js_1.sameId)(identity.saltAppId, mediatorAgentId);
300
997
  if (isMediator && chatMeta?.mediator_agent_id && !chatMeta.coaching_for_chat_id)
301
998
  return;
302
999
  // Global Agent Chat Mode: when the chat declares an active agent and
303
1000
  // it isn't this identity, stay silent. Lets retired/handed-off-from
304
1001
  // agents remain in the room without ever double-replying.
305
- if (chatMeta?.active_agent_id && String(chatMeta.active_agent_id) !== String(identity.saltAppId))
1002
+ if (chatMeta?.active_agent_id && !(0, ids_js_1.sameId)(chatMeta.active_agent_id, identity.saltAppId))
306
1003
  return;
307
1004
  // A human sender always resets the count -- only agent-to-agent
308
1005
  // volleys are capped, never a real conversation with a person.
309
- const senderIsAgent = !!identities.get(senderId) || senderRaw?.account_type === "Agent";
1006
+ const senderIsAgent = !!identities.get(senderId) || senderRaw.account_type === "Agent";
1007
+ const replyCountKey = mapKey(chatId);
1008
+ if (chatMeta?.coaching_for_chat_id) {
1009
+ laneRoomOf.set(replyCountKey, mapKey(chatMeta.coaching_for_chat_id));
1010
+ }
310
1011
  if (senderIsAgent) {
311
- const count = (agentToAgentReplyCounts.get(chatId) || 0) + 1;
312
- agentToAgentReplyCounts.set(chatId, count);
313
- if (count > MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT) {
314
- logger.error(`[chat ${chatId}] agent-to-agent reply cap reached; not auto-replying to ${senderId} again.`);
315
- return;
1012
+ // A real person changes the rule entirely: an agent-authored message
1013
+ // is only ours to answer when it @mentions us -- the same gate
1014
+ // salt-api applies to whether the webhook is even sent in a group
1015
+ // chat, belt-and-suspenders for whatever still reaches us (a hand-off
1016
+ // farewell, a briefing after item 1 above missed a variant, another
1017
+ // agent's aside). Two agents alone in a lane have no one to perform
1018
+ // for, so the runaway cap below still governs those exactly as before.
1019
+ if (await chatHasNonObserverHuman(identity, chatId, chatMeta)) {
1020
+ const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map(String);
1021
+ if (!mentions.some((id) => (0, ids_js_1.sameId)(id, identity.saltAppId))) {
1022
+ logger.error(`[chat ${chatId}] agent message not addressed to us in a chat with a person present; not auto-replying to ${senderId}.`);
1023
+ return;
1024
+ }
1025
+ }
1026
+ else {
1027
+ const limit = chatMeta?.lane_kind === "consult" ? CONSULT_RUNAWAY_LIMIT : MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT;
1028
+ const count = (agentToAgentReplyCounts.get(replyCountKey) || 0) + 1;
1029
+ agentToAgentReplyCounts.set(replyCountKey, count);
1030
+ if (count > limit) {
1031
+ logger.error(`[chat ${chatId}] agent-to-agent reply cap reached; not auto-replying to ${senderId} again.`);
1032
+ return;
1033
+ }
316
1034
  }
317
1035
  }
318
1036
  else {
319
- agentToAgentReplyCounts.delete(chatId);
1037
+ agentToAgentReplyCounts.delete(replyCountKey);
1038
+ // A human speaking in a ROOM also resets every consult lane rooted
1039
+ // there -- otherwise a lane capped mid-conversation stays capped even
1040
+ // right after the person who asked for it weighs in.
1041
+ for (const [laneKey, roomKey] of laneRoomOf) {
1042
+ if (roomKey === replyCountKey)
1043
+ agentToAgentReplyCounts.delete(laneKey);
1044
+ }
320
1045
  }
321
- const { depth, text: strippedCaption } = delegations.parseIncoming(caption);
1046
+ const { depth, text: afterDepthMarker } = delegations.parseIncoming(caption);
1047
+ const strippedCaption = delegations.stripConsultMarker(afterDepthMarker);
322
1048
  let mediatorSharedContext;
323
1049
  let attachment;
324
- if (isMediator && chatMeta?.coaching_for_chat_id) {
1050
+ // The shared conversation is fetched only for the MUTUAL Mediator lane,
1051
+ // where this identity is an observer on that chat and both parties can see
1052
+ // it is there. A private advisor lane is excluded: that advisor was never
1053
+ // added to the shared chat, so every message would fail to decrypt and be
1054
+ // skipped anyway -- but a guarantee that rests on a failed decrypt is one
1055
+ // membership bug away from being no guarantee, so we do not ask at all.
1056
+ if (isMediator && chatMeta?.coaching_for_chat_id && !chatMeta.private_lane) {
325
1057
  mediatorSharedContext = await buildSharedChatContext(identity, chatMeta.coaching_for_chat_id);
326
1058
  }
327
1059
  else if (message.resource_type === "Attachment") {
@@ -329,20 +1061,63 @@ function createWebhookServer(options) {
329
1061
  }
330
1062
  if (!options.onMessage)
331
1063
  return;
1064
+ const roomId = chatMeta?.coaching_for_chat_id || chatId;
1065
+ const session = await loadOrRebuildSession(identity, chatId, chatMeta);
1066
+ // Wraps the shared reply() closure to also remember what was sent, so
1067
+ // it can be appended to the session as an assistant turn once the
1068
+ // handler returns successfully -- see the persistence step below.
1069
+ // Nothing about what actually gets sent changes.
1070
+ const repliesForSession = [];
1071
+ const baseReply = makeReply(identity, chatId, senderRaw);
1072
+ const trackedReply = async (text) => {
1073
+ repliesForSession.push(text);
1074
+ await baseReply(text);
1075
+ };
332
1076
  const ctx = {
333
1077
  identity,
334
1078
  chatId,
335
1079
  senderId,
336
1080
  sender: senderRaw,
337
1081
  text: strippedCaption,
1082
+ encrypted: !isPlaintext,
1083
+ deliveredBecause: parseDeliveredBecause(message.delivered_because),
1084
+ replyTo: message.reply_to_message_id != null ? String(message.reply_to_message_id) : undefined,
1085
+ parent: parseReplyParent(message.parent),
338
1086
  delegationDepth: depth,
339
1087
  chatMeta,
1088
+ roomId,
340
1089
  mediatorSharedContext,
341
1090
  attachment,
342
- reply: makeReply(identity, chatId),
1091
+ session,
1092
+ reply: trackedReply,
1093
+ messageId: message.message_id,
1094
+ react: (emoji) => client.react(identity.apiKey, message.message_id, emoji),
1095
+ // M2: default answerer is whoever sent THIS message -- never an
1096
+ // agent (an agent sender has its own webhook/turn; ctx.ask is for
1097
+ // asking a person).
1098
+ ask: makeAsk(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
1099
+ approve: makeApprove(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
1100
+ shareIdentity: makeShareIdentity(identity, chatId),
343
1101
  };
344
1102
  try {
345
1103
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onMessage(ctx)));
1104
+ try {
1105
+ sessions.appendTurn(session, {
1106
+ role: "user",
1107
+ content: strippedCaption,
1108
+ at: Date.now(),
1109
+ from: String(senderRaw.username || senderRaw.display_name || senderId),
1110
+ });
1111
+ for (const replyText of repliesForSession) {
1112
+ sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
1113
+ }
1114
+ session.note = sessions.boundNote(session.note);
1115
+ session.updatedAt = Date.now();
1116
+ await sessionStore.put(identity.saltAppId, chatId, session);
1117
+ }
1118
+ catch (err) {
1119
+ logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
1120
+ }
346
1121
  }
347
1122
  catch (err) {
348
1123
  logger.error(`[chat ${chatId}] onMessage failed: ${err.message}`);
@@ -352,9 +1127,19 @@ function createWebhookServer(options) {
352
1127
  }
353
1128
  }
354
1129
  async function handleCardInteraction(body) {
355
- const identity = identities.get(parseInt(String(body.owner_id), 10));
1130
+ // A tap answering a pending ctx.ask's own card is wire-level bookkeeping
1131
+ // for ask.ts, never something the consumer's onCardInteraction should
1132
+ // also see -- checked before identity resolution/onCardInteraction even
1133
+ // matter, since this can settle an ask() with no consumer handler
1134
+ // registered for cards at all.
1135
+ if (asks.resolveCardInteraction(body.owner_id, body.chat_id, body.card_id, body.action_id, body.user))
1136
+ return;
1137
+ const identity = identities.get(String(body.owner_id));
356
1138
  if (!identity || !options.onCardInteraction)
357
1139
  return;
1140
+ // No reply() on this context (a card update IS the response), so just
1141
+ // load -- nothing new here to append or persist afterward.
1142
+ const session = await loadOrRebuildSession(identity, body.chat_id);
358
1143
  try {
359
1144
  await options.onCardInteraction({
360
1145
  identity,
@@ -363,17 +1148,67 @@ function createWebhookServer(options) {
363
1148
  actionId: body.action_id,
364
1149
  user: body.user,
365
1150
  blocks: body.state?.blocks,
1151
+ values: body.values || {},
1152
+ session,
366
1153
  });
367
1154
  }
368
1155
  catch (err) {
369
1156
  logger.error(`[chat ${body.chat_id}] onCardInteraction failed: ${err.message}`);
370
1157
  }
371
1158
  }
1159
+ async function handleAppAction(body, headerAgentId) {
1160
+ // No owner_id/seller_id in this body to fall back on (unlike
1161
+ // card_interaction/invoice_paid) -- X-Salt-Agent-Id is the only signal,
1162
+ // same as the mandate rail events further down. verifyEnvelope already
1163
+ // proved the signature against that exact identity's own key.
1164
+ const identity = headerAgentId != null ? identities.get(String(headerAgentId)) : undefined;
1165
+ if (!identity || !options.onAppAction)
1166
+ return;
1167
+ const chatId = body.chat_id ?? null;
1168
+ // A personal ("Your apps") installation has no chat at all -- nothing
1169
+ // for a reply to post into, same as salt.post's "Not in a chat."
1170
+ // rejection on the bridge side. Fails soft (logged), the same
1171
+ // convention every other reply() failure in this file follows.
1172
+ const reply = chatId != null
1173
+ ? makeReply(identity, chatId)
1174
+ : async (_text) => {
1175
+ logger.error(`[app ${body.installation_id}] reply() has no chat -- this is a personal installation.`);
1176
+ };
1177
+ const ctx = {
1178
+ identity,
1179
+ installationId: body.installation_id,
1180
+ app: body.app,
1181
+ chatId,
1182
+ text: body.text,
1183
+ data: body.data,
1184
+ stateVersion: body.state_version,
1185
+ sender: body.user,
1186
+ reply,
1187
+ };
1188
+ try {
1189
+ if (chatId != null) {
1190
+ await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onAppAction(ctx)));
1191
+ }
1192
+ else {
1193
+ await options.onAppAction(ctx);
1194
+ }
1195
+ }
1196
+ catch (err) {
1197
+ logger.error(`[app ${body.installation_id}] onAppAction failed: ${err.message}`);
1198
+ }
1199
+ }
372
1200
  async function handleInvoicePaid(body) {
373
- const identity = identities.get(parseInt(String(body.seller_id), 10));
1201
+ const identity = identities.get(String(body.seller_id));
374
1202
  if (!identity || !body.chat_id || !options.onInvoicePaid)
375
1203
  return;
376
1204
  const chatId = body.chat_id;
1205
+ const session = await loadOrRebuildSession(identity, chatId);
1206
+ const repliesForSession = [];
1207
+ const baseReply = makeReply(identity, chatId);
1208
+ const trackedReply = async (text) => {
1209
+ repliesForSession.push(text);
1210
+ await baseReply(text);
1211
+ };
377
1212
  const ctx = {
378
1213
  identity,
379
1214
  chatId,
@@ -382,30 +1217,143 @@ function createWebhookServer(options) {
382
1217
  amount: body.amount,
383
1218
  isTopUp: !!body.billing_account_id,
384
1219
  transferRequestId: body.transfer_request_id,
385
- reply: makeReply(identity, chatId),
1220
+ session,
1221
+ reply: trackedReply,
1222
+ // M2: default answerer is the buyer (never an agent -- an agent
1223
+ // buyer has no need to be asked a question through its own purchase).
1224
+ ask: makeAsk(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
1225
+ approve: makeApprove(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
1226
+ shareIdentity: makeShareIdentity(identity, chatId),
386
1227
  };
387
1228
  try {
388
1229
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onInvoicePaid(ctx)));
1230
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
389
1231
  }
390
1232
  catch (err) {
391
1233
  logger.error(`[chat ${chatId}] onInvoicePaid failed: ${err.message}`);
392
1234
  }
393
1235
  }
1236
+ async function handleChatOpened(body) {
1237
+ if (!options.onChatOpened)
1238
+ return;
1239
+ const chat = body.chat;
1240
+ const chatId = chat?.id;
1241
+ if (chatId == null)
1242
+ return;
1243
+ // Unlike card_interaction/invoice_paid, this payload carries no explicit
1244
+ // owner/seller id naming which hosted identity it's for -- salt-api
1245
+ // delivers it once per member with a callback URL, same as everything
1246
+ // else, but the only way this process (which can host several identities
1247
+ // on one endpoint) learns WHICH of its identities the event is about is
1248
+ // by noticing itself in the member list. Prefer the chat's declared
1249
+ // active agent when that's one of ours (GACM), the same tie-break
1250
+ // handleMessage uses, in case delegation ever puts two hosted identities
1251
+ // in the same chat.
1252
+ let identity;
1253
+ if (chat?.active_agent_id)
1254
+ identity = identities.get(String(chat.active_agent_id));
1255
+ if (!identity) {
1256
+ for (const member of body.members || []) {
1257
+ identity = identities.get(String(member.id));
1258
+ if (identity)
1259
+ break;
1260
+ }
1261
+ }
1262
+ if (!identity) {
1263
+ logger.error(`[chat ${chatId}] chat_opened: no hosted identity found among members; ignoring.`);
1264
+ return;
1265
+ }
1266
+ if (alreadyGreeted(identity.saltAppId, chatId))
1267
+ return;
1268
+ const session = await loadOrRebuildSession(identity, chatId, chat);
1269
+ const repliesForSession = [];
1270
+ const baseReply = makeReply(identity, chatId);
1271
+ const trackedReply = async (text) => {
1272
+ repliesForSession.push(text);
1273
+ await baseReply(text);
1274
+ };
1275
+ const ctx = {
1276
+ identity,
1277
+ chatId,
1278
+ chat: chat,
1279
+ encrypted: chat?.encrypted !== false,
1280
+ openedBy: body.opened_by,
1281
+ members: body.members || [],
1282
+ openedAt: body.opened_at,
1283
+ session,
1284
+ reply: trackedReply,
1285
+ // M2: default answerer is whoever opened the chat (never an agent).
1286
+ ask: makeAsk(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
1287
+ approve: makeApprove(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
1288
+ shareIdentity: makeShareIdentity(identity, chatId),
1289
+ };
1290
+ try {
1291
+ await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onChatOpened(ctx)));
1292
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
1293
+ }
1294
+ catch (err) {
1295
+ logger.error(`[chat ${chatId}] onChatOpened failed: ${err.message}`);
1296
+ }
1297
+ }
394
1298
  async function handleHandoffConfirmed(body) {
395
- const identity = identities.get(parseInt(String(body.from_agent_id), 10));
1299
+ const identity = identities.get(String(body.from_agent_id));
396
1300
  if (!identity || !options.onHandoffConfirmed)
397
1301
  return;
398
1302
  const chatId = body.chat_id;
399
- const ctx = { identity, chatId, reason: body.reason, reply: makeReply(identity, chatId) };
1303
+ // A request_floor call may have just auto-triggered this same hand-off
1304
+ // (webhook.ts's floor-request handler, above) -- if so, give the
1305
+ // briefing its consult lane's transcript.
1306
+ const laneId = pendingConsultHandoffs.get(mapKey(chatId));
1307
+ if (laneId)
1308
+ pendingConsultHandoffs.delete(mapKey(chatId));
1309
+ let consultTranscript;
1310
+ if (laneId) {
1311
+ try {
1312
+ consultTranscript = await buildConsultTranscript(identity, laneId);
1313
+ }
1314
+ catch (err) {
1315
+ logger.error(`[chat ${chatId}] fetching consult transcript for hand-off failed: ${err.message}`);
1316
+ }
1317
+ }
1318
+ const session = await loadOrRebuildSession(identity, chatId);
1319
+ // Appends the outgoing session's note (if any) as the briefing's final
1320
+ // wire-protocol line -- see sessions.ts's SESSION_NOTE_MARKER and
1321
+ // handleHandoffReceived below, which parses it back out on the other
1322
+ // end. Transparent to the consumer: whatever text they reply() with is
1323
+ // what a human sees; this just rides along after it. Reads `session.note`
1324
+ // directly rather than re-fetching -- the same object ctx.session is, so
1325
+ // a handler that wrote to it before calling reply() is picked up.
1326
+ const repliesForSession = [];
1327
+ const baseReply = makeReply(identity, chatId);
1328
+ const reply = async (text) => {
1329
+ repliesForSession.push(text);
1330
+ const noteLine = sessions.formatSessionNoteLine(session.note);
1331
+ await baseReply(noteLine ? `${text}\n${noteLine}` : text);
1332
+ };
1333
+ const ctx = {
1334
+ identity,
1335
+ chatId,
1336
+ reason: body.reason,
1337
+ consultTranscript,
1338
+ session,
1339
+ reply,
1340
+ // M2: no natural default here (a hand-off has no single "whoever
1341
+ // this answers") -- ask()/approve() from this context must be given
1342
+ // answererId explicitly, or they throw synchronously.
1343
+ ask: makeAsk(identity, chatId),
1344
+ approve: makeApprove(identity, chatId),
1345
+ shareIdentity: makeShareIdentity(identity, chatId),
1346
+ };
400
1347
  try {
401
1348
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffConfirmed(ctx)));
1349
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
402
1350
  }
403
1351
  catch (err) {
404
1352
  logger.error(`[chat ${chatId}] onHandoffConfirmed failed: ${err.message}`);
405
1353
  }
406
1354
  }
407
1355
  async function handleHandoffReceived(body) {
408
- const identity = identities.get(parseInt(String(body.to_agent_id), 10));
1356
+ const identity = identities.get(String(body.to_agent_id));
409
1357
  if (!identity || !options.onHandoffReceived)
410
1358
  return;
411
1359
  const chatId = body.chat_id;
@@ -421,44 +1369,397 @@ function createWebhookServer(options) {
421
1369
  break;
422
1370
  await new Promise((r) => setTimeout(r, 2000));
423
1371
  }
424
- const ctx = { identity, chatId, reason: body.reason, context, reply: makeReply(identity, chatId) };
1372
+ // Seed this identity's own session for `chatId` from whatever's already
1373
+ // stored (a fresh one if nothing is), fold in the outgoing agent's note
1374
+ // if the briefing carried one, and persist that much right away --
1375
+ // independent of whether onHandoffReceived itself succeeds below, same
1376
+ // as before this session had a `session` field on its ctx at all.
1377
+ // Stripped from `context` either way: wire protocol, never something
1378
+ // the incoming agent's own prompt-building should see verbatim.
1379
+ let session;
1380
+ try {
1381
+ session = (await sessionStore.get(identity.saltAppId, chatId)) ?? sessions.emptySession(chatId, chatId);
1382
+ }
1383
+ catch (err) {
1384
+ logger.error(`[session] loading ${chatId} failed: ${err.message}`);
1385
+ session = sessions.emptySession(chatId, chatId);
1386
+ }
1387
+ const note = sessions.extractSessionNote(context);
1388
+ if (note) {
1389
+ session.note = sessions.boundNote(note);
1390
+ session.updatedAt = Date.now();
1391
+ try {
1392
+ await sessionStore.put(identity.saltAppId, chatId, session);
1393
+ }
1394
+ catch (err) {
1395
+ logger.error(`[session] persisting handed-off note for ${chatId} failed: ${err.message}`);
1396
+ }
1397
+ }
1398
+ context = sessions.stripSessionNoteLines(context);
1399
+ // Whatever the introduction reply() sends here is appended below,
1400
+ // exactly like onMessage does for its own reply -- so it's already in
1401
+ // transcriptTail the next time this chat's onMessage runs.
1402
+ const repliesForSession = [];
1403
+ const baseReply = makeReply(identity, chatId);
1404
+ const trackedReply = async (text) => {
1405
+ repliesForSession.push(text);
1406
+ await baseReply(text);
1407
+ };
1408
+ const ctx = {
1409
+ identity,
1410
+ chatId,
1411
+ reason: body.reason,
1412
+ context,
1413
+ session,
1414
+ reply: trackedReply,
1415
+ // M2: no natural default here either -- see HandoffConfirmedContext's comment above.
1416
+ ask: makeAsk(identity, chatId),
1417
+ approve: makeApprove(identity, chatId),
1418
+ shareIdentity: makeShareIdentity(identity, chatId),
1419
+ };
425
1420
  try {
426
1421
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffReceived(ctx)));
1422
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
427
1423
  }
428
1424
  catch (err) {
429
1425
  logger.error(`[chat ${chatId}] onHandoffReceived failed: ${err.message}`);
430
1426
  }
431
1427
  }
432
- async function dispatch(body) {
1428
+ // Resolves which of this process's hosted identities a mandate/exercise event is FOR. The
1429
+ // envelope's X-Salt-Agent-Id is authoritative (verifyEnvelope already checked the signature
1430
+ // against that exact identity's own key, so it names a real recipient) -- the `candidateIds`
1431
+ // fallback (party ids off the payload itself) only matters for a caller that runs the dispatcher
1432
+ // directly with no header (e.g. a test, or a future transport that doesn't have one).
1433
+ function resolveMandateIdentity(headerAgentId, ...candidateIds) {
1434
+ if (headerAgentId != null) {
1435
+ const byHeader = identities.get(String(headerAgentId));
1436
+ if (byHeader)
1437
+ return byHeader;
1438
+ }
1439
+ for (const id of candidateIds) {
1440
+ if (id == null)
1441
+ continue;
1442
+ const found = identities.get(String(id));
1443
+ if (found)
1444
+ return found;
1445
+ }
1446
+ return undefined;
1447
+ }
1448
+ async function handleMandateOffered(body, headerAgentId) {
1449
+ const identity = resolveMandateIdentity(headerAgentId, body.mandate?.grantee?.id);
1450
+ if (!identity || !options.onMandateOffered)
1451
+ return;
1452
+ const mandate = body.mandate;
1453
+ const ctx = {
1454
+ identity,
1455
+ mandate,
1456
+ accept: () => client.mandates.accept(identity.apiKey, mandate.id),
1457
+ };
1458
+ try {
1459
+ await options.onMandateOffered(ctx);
1460
+ }
1461
+ catch (err) {
1462
+ logger.error(`[mandate ${mandate.id}] onMandateOffered failed: ${err.message}`);
1463
+ }
1464
+ }
1465
+ async function handleMandateLifecycleEvent(kind, body, headerAgentId) {
1466
+ const mandate = body.mandate;
1467
+ const identity = resolveMandateIdentity(headerAgentId, mandate?.grantee?.id, mandate?.grantor?.id);
1468
+ const handler = kind === "activated" ? options.onMandateActivated : kind === "paused" ? options.onMandatePaused : options.onMandateRevoked;
1469
+ if (!identity || !handler)
1470
+ return;
1471
+ try {
1472
+ await handler({ identity, mandate });
1473
+ }
1474
+ catch (err) {
1475
+ logger.error(`[mandate ${mandate.id}] onMandate${kind[0].toUpperCase()}${kind.slice(1)} failed: ${err.message}`);
1476
+ }
1477
+ }
1478
+ async function handleApprovalRequested(body, headerAgentId) {
1479
+ const exercise = body.exercise;
1480
+ const identity = resolveMandateIdentity(headerAgentId, exercise?.principal?.id);
1481
+ if (!identity || !options.onApprovalRequested)
1482
+ return;
1483
+ const ctx = {
1484
+ identity,
1485
+ exercise,
1486
+ decide: (decision, note) => client.mandates.decide(identity.apiKey, exercise.id, decision, note),
1487
+ };
1488
+ try {
1489
+ await options.onApprovalRequested(ctx);
1490
+ }
1491
+ catch (err) {
1492
+ logger.error(`[exercise ${exercise.id}] onApprovalRequested failed: ${err.message}`);
1493
+ }
1494
+ }
1495
+ async function handleApprovalDecided(body, headerAgentId) {
1496
+ const exercise = body.exercise;
1497
+ const identity = resolveMandateIdentity(headerAgentId, exercise?.actor?.id);
1498
+ if (!identity || !options.onApprovalDecided)
1499
+ return;
1500
+ try {
1501
+ await options.onApprovalDecided({ identity, exercise });
1502
+ }
1503
+ catch (err) {
1504
+ logger.error(`[exercise ${exercise.id}] onApprovalDecided failed: ${err.message}`);
1505
+ }
1506
+ }
1507
+ async function handleDeviceSessionEnded(body, headerAgentId) {
1508
+ if (!options.onDeviceSessionEnded)
1509
+ return;
1510
+ const identity = resolveMandateIdentity(headerAgentId, body.agent_id);
1511
+ if (!identity)
1512
+ return;
1513
+ // The same delivery also goes to the computer itself; it is not the agent's to speak of.
1514
+ if ((0, ids_js_1.sameId)(identity.saltAppId, body.device_id))
1515
+ return;
1516
+ const sessionId = body.session_id ?? body.id;
1517
+ if (sessionId == null)
1518
+ return;
1519
+ const ctx = {
1520
+ identity,
1521
+ sessionId,
1522
+ deviceId: body.device_id,
1523
+ mandateId: body.mandate_id ?? null,
1524
+ laneChatId: body.chat_id ?? body.chat?.id ?? null,
1525
+ endReason: body.end_reason ?? null,
1526
+ lostContact: body.end_reason === exports.DEVICE_LOST_CONTACT,
1527
+ intent: typeof body.intent === "string" ? body.intent : null,
1528
+ endedAt: body.ended_at ?? null,
1529
+ body,
1530
+ };
1531
+ try {
1532
+ await options.onDeviceSessionEnded(ctx);
1533
+ }
1534
+ catch (err) {
1535
+ logger.error(`[device session ${sessionId}] onDeviceSessionEnded failed: ${err.message}`);
1536
+ }
1537
+ }
1538
+ async function handleDeviceBack(body, headerAgentId) {
1539
+ if (!options.onDeviceBack)
1540
+ return;
1541
+ const identity = resolveMandateIdentity(headerAgentId);
1542
+ if (!identity)
1543
+ return;
1544
+ const chatId = body.chat_id ?? null;
1545
+ const session = chatId != null ? await loadOrRebuildSession(identity, chatId) : null;
1546
+ const repliesForSession = [];
1547
+ const baseReply = chatId != null ? makeReply(identity, chatId) : null;
1548
+ const ctx = {
1549
+ identity,
1550
+ deviceId: body.device_id,
1551
+ deviceName: body.device_name,
1552
+ previousSessionId: body.previous_session_id,
1553
+ offlineAt: body.offline_at ?? null,
1554
+ backAt: body.back_at,
1555
+ chatId,
1556
+ session,
1557
+ body,
1558
+ reply: async (text) => {
1559
+ if (!baseReply)
1560
+ throw new Error("device_back: the person has no 1:1 with this agent (chat_id is null), so there is nowhere to reply");
1561
+ repliesForSession.push(text);
1562
+ await baseReply(text);
1563
+ },
1564
+ };
1565
+ try {
1566
+ await options.onDeviceBack(ctx);
1567
+ if (chatId != null && session)
1568
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
1569
+ }
1570
+ catch (err) {
1571
+ logger.error(`[device ${body.device_id}] onDeviceBack failed: ${err.message}`);
1572
+ }
1573
+ }
1574
+ async function handleDescribeYourself(body, headerAgentId) {
1575
+ const agent = (body.agent ?? {});
1576
+ const identity = resolveMandateIdentity(headerAgentId, agent?.id);
1577
+ if (!identity || !options.onDescribeYourself)
1578
+ return;
1579
+ const maxLength = typeof body.max_length === "number" ? body.max_length : 300;
1580
+ const ctx = {
1581
+ identity,
1582
+ reason: String(body.reason ?? "created"),
1583
+ agent,
1584
+ ownerDescription: typeof body.owner_description === "string" ? body.owner_description : null,
1585
+ currentDescription: typeof body.current_description === "string" ? body.current_description : null,
1586
+ capabilities: Array.isArray(body.capabilities) ? body.capabilities : [],
1587
+ guidance: String(body.guidance ?? ""),
1588
+ maxLength,
1589
+ write: async (text) => {
1590
+ const bio = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, maxLength);
1591
+ if (!bio)
1592
+ return;
1593
+ await client.setIdentity(identity.apiKey, { bio });
1594
+ },
1595
+ };
1596
+ try {
1597
+ await options.onDescribeYourself(ctx);
1598
+ }
1599
+ catch (err) {
1600
+ logger.error(`[agent ${agent?.id}] onDescribeYourself failed: ${err.message}`);
1601
+ }
1602
+ }
1603
+ // Answers to a pending ctx.ask (or a pending delegation reply), resolved
1604
+ // WITHOUT running a handler. socket.ts feeds frames through one serial
1605
+ // queue, and the handler that called `await ctx.ask(...)` occupies it, so
1606
+ // the frame carrying the answer would wait behind the very handler that is
1607
+ // waiting for it. socket.ts therefore calls this at frame arrival, ahead
1608
+ // of the queue. Same checks, same order as handleMessage /
1609
+ // handleCardInteraction (which still run for anything this does not
1610
+ // consume), so nothing is answered by a sender those would not accept.
1611
+ // Returns true when the frame was consumed and must not be dispatched.
1612
+ async function resolveAnswer(body, headerAgentId) {
1613
+ if (!asks.hasPendingAsks())
1614
+ return false;
1615
+ if (body?.type === "card_interaction") {
1616
+ const b = body;
1617
+ return asks.resolveCardInteraction(b.owner_id, b.chat_id, b.card_id, b.action_id, b.user);
1618
+ }
1619
+ if (body?.type !== undefined || !body?.message)
1620
+ return false;
1621
+ const message = body.message;
1622
+ if (message.event_type)
1623
+ return false;
1624
+ const rawMessage = message.message;
1625
+ if (typeof rawMessage !== "string")
1626
+ return false;
1627
+ const chatId = message.chat_id;
1628
+ let identity;
1629
+ let caption;
1630
+ if (message.encrypted === false) {
1631
+ identity = headerAgentId ? identities.get(headerAgentId) : undefined;
1632
+ caption = rawMessage;
1633
+ }
1634
+ else {
1635
+ if (!PGP_MESSAGE_RE.test(rawMessage))
1636
+ return false;
1637
+ const resolved = await resolveIdentity(rawMessage, headerAgentId);
1638
+ identity = resolved?.identity;
1639
+ caption = resolved?.plaintext ?? "";
1640
+ }
1641
+ if (!identity)
1642
+ return false;
1643
+ const activeId = body.chat?.active_agent_id;
1644
+ if (activeId)
1645
+ identity = identities.get(String(activeId)) ?? identity;
1646
+ const senderRaw = message.user;
1647
+ if (!senderRaw)
1648
+ return false;
1649
+ const senderId = String(senderRaw.id);
1650
+ if (delegations.resolveIfPending(chatId, senderId, caption))
1651
+ return true;
1652
+ return asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption);
1653
+ }
1654
+ async function dispatch(body, headerAgentId) {
433
1655
  if (body?.type === "card_interaction")
434
1656
  return handleCardInteraction(body);
1657
+ if (body?.type === "app_action")
1658
+ return handleAppAction(body, headerAgentId);
435
1659
  if (body?.type === "invoice_paid")
436
1660
  return handleInvoicePaid(body);
1661
+ if (body?.type === "chat_opened")
1662
+ return handleChatOpened(body);
437
1663
  if (body?.type === "handoff_confirmed")
438
1664
  return handleHandoffConfirmed(body);
439
1665
  if (body?.type === "handoff_received")
440
1666
  return handleHandoffReceived(body);
1667
+ if (body?.type === "mandate_offered")
1668
+ return handleMandateOffered(body, headerAgentId);
1669
+ if (body?.type === "mandate_activated")
1670
+ return handleMandateLifecycleEvent("activated", body, headerAgentId);
1671
+ if (body?.type === "mandate_paused")
1672
+ return handleMandateLifecycleEvent("paused", body, headerAgentId);
1673
+ if (body?.type === "mandate_revoked")
1674
+ return handleMandateLifecycleEvent("revoked", body, headerAgentId);
1675
+ if (body?.type === "approval_requested")
1676
+ return handleApprovalRequested(body, headerAgentId);
1677
+ if (body?.type === "approval_decided")
1678
+ return handleApprovalDecided(body, headerAgentId);
1679
+ if (body?.type === "describe_yourself")
1680
+ return handleDescribeYourself(body, headerAgentId);
1681
+ // device_session_ended and device_back are the AGENT's moments and must stay
1682
+ // ahead of the device_session* catch-all below. device_session_ended also
1683
+ // goes on to onDeviceDelivery (set only by a process that hosts a device):
1684
+ // the session controller there needs the end too, and handleDeviceSessionEnded
1685
+ // itself ignores the device's own copy.
1686
+ if (body?.type === "device_session_ended") {
1687
+ await handleDeviceSessionEnded(body, headerAgentId);
1688
+ await options.onDeviceDelivery?.(body.type, body);
1689
+ return;
1690
+ }
1691
+ if (body?.type === "device_back")
1692
+ return handleDeviceBack(body, headerAgentId);
1693
+ if (typeof body?.type === "string" && (body.type.startsWith("device_session") || body.type === "device_queue" || body.type === "device_grants_changed" || body.type === "device_owner_changed" || body.type === "device_ask_answered")) {
1694
+ await options.onDeviceDelivery?.(body.type, body);
1695
+ return;
1696
+ }
441
1697
  if (body?.message)
442
- return handleMessage(body);
1698
+ return handleMessage(body, headerAgentId);
443
1699
  }
1700
+ return { dispatch, verifyEnvelope, resolveAnswer };
1701
+ }
1702
+ /**
1703
+ * Creates the Express app for a Salt agent's webhook endpoint. Mount it
1704
+ * yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
1705
+ * to compose it into a bigger server).
1706
+ *
1707
+ * A thin transport wrapper around createDispatcher -- everything about the
1708
+ * Salt protocol itself lives there. Switching a consumer from this to
1709
+ * socket.ts's createSocketClient (K2, an agent with no public URL) is the
1710
+ * same `options` object with `.listen(port)` replaced by `.start()`;
1711
+ * onMessage/onCardInteraction/etc. never change.
1712
+ */
1713
+ function createWebhookServer(options) {
1714
+ const logger = options.logger ?? consoleLogger;
1715
+ const dispatcher = createDispatcher(options);
1716
+ const app = (0, express_1.default)();
1717
+ // Keep the raw bytes: the HMAC covers exactly what salt-api sent, and
1718
+ // re-serialising the parsed object would digest different bytes (key order,
1719
+ // whitespace, number formatting) and never match.
1720
+ app.use(express_1.default.json({
1721
+ verify: (req, _res, buf) => {
1722
+ req.rawBody = buf.toString("utf8");
1723
+ },
1724
+ }));
1725
+ app.get("/health", (_req, res) => {
1726
+ res.json({
1727
+ status: "ok",
1728
+ identities: options.identities.all().map((i) => ({ salt_app_id: i.saltAppId, username: i.username })),
1729
+ ...(options.healthExtra ? options.healthExtra() : {}),
1730
+ });
1731
+ });
444
1732
  // salt-api's WebhookJob POSTs here with { chat, message } (or a typed
445
- // event body for card/invoice/handoff). Ack immediately (200) and process
446
- // in the background so a slow agent-loop call can't make Salt's webhook
447
- // delivery time out.
1733
+ // event body for card/invoice/chat_opened/handoff). Ack immediately (200)
1734
+ // and process in the background so a slow agent-loop call can't make
1735
+ // Salt's webhook delivery time out.
448
1736
  //
449
- // WEBHOOK_SHARED_SECRET: when this server is publicly reachable, anyone
450
- // can POST here. Encrypted-message webhooks fail safe (forged ciphertext
451
- // never decrypts), but card_interaction/invoice_paid payloads are
452
- // PLAINTEXT and would be acted on -- so when the secret is configured
453
- // (production), every POST must carry the matching header salt-api's
454
- // jobs send. Unset (local dev), the check is skipped entirely.
1737
+ // When this server is publicly reachable, anyone can POST here. Encrypted-
1738
+ // message webhooks fail safe (forged ciphertext never decrypts), but
1739
+ // card_interaction/invoice_paid/chat_opened payloads are PLAINTEXT and
1740
+ // would be acted on -- so every POST must carry a valid HMAC signed with
1741
+ // the RECIPIENT identity's own key (see Dispatcher#verifyEnvelope above).
455
1742
  app.post("/", (req, res) => {
456
- if (webhookSharedSecret && req.get("X-Salt-Webhook-Secret") !== webhookSharedSecret) {
457
- res.status(401).json({ error: "invalid webhook secret" });
458
- return;
459
- }
460
- res.status(200).json({ status: "accepted" });
461
- dispatch(req.body).catch((err) => logger.error(`[webhook] unhandled error: ${err.message}`));
1743
+ const rawBody = req.rawBody ?? "";
1744
+ // Verify BEFORE acknowledging: an unsigned POST must never reach dispatch,
1745
+ // because card_interaction/invoice_paid/chat_opened are plaintext and
1746
+ // actionable.
1747
+ dispatcher
1748
+ .verifyEnvelope(req.get("X-Salt-Agent-Id") || undefined, req.get("X-Salt-Signature") || undefined, rawBody)
1749
+ .then((result) => {
1750
+ if (!result.ok) {
1751
+ logger.error(`[webhook] rejected: ${result.reason}`);
1752
+ res.status(401).json({ error: "invalid signature" });
1753
+ return;
1754
+ }
1755
+ res.status(200).json({ status: "accepted" });
1756
+ const headerAgentId = req.get("X-Salt-Agent-Id") || undefined;
1757
+ dispatcher.dispatch(req.body, headerAgentId).catch((err) => logger.error(`[webhook] unhandled error: ${err.message}`));
1758
+ })
1759
+ .catch((err) => {
1760
+ logger.error(`[webhook] verification failed: ${err.message}`);
1761
+ res.status(401).json({ error: "invalid signature" });
1762
+ });
462
1763
  });
463
1764
  return {
464
1765
  app,