salt-agent-sdk 0.1.0 → 0.19.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 +3123 -35
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +1267 -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 +627 -28
  96. package/dist/webhook.d.ts.map +1 -1
  97. package/dist/webhook.js +1388 -84
  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", "team_lead"]);
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) {
214
- return async (text) => {
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) {
612
+ return async (text, opts) => {
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,26 @@ 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
+ // Explicit `opts.mentions` (a team lead waking a member, a member waking the lead) win over the
644
+ // automatic addressing: the author wrote the "@handle" in the text, so nothing is prefixed.
645
+ const explicitMentions = opts?.mentions && opts.mentions.length > 0 ? opts.mentions : undefined;
646
+ const outgoing = !explicitMentions && addressHandle ? `@${addressHandle} ${text}` : text;
647
+ // The ids, not the text: Salt only ever sees ciphertext, so an "@handle"
648
+ // in the body is invisible to it and notifies nobody by itself.
649
+ const mentions = explicitMentions ?? (addressHandle ? [addressee.id] : undefined);
231
650
  let encryptedMessage, senderMessage;
232
651
  try {
233
- encryptedMessage = await pgp.encryptFor(text, recipientKeys);
234
- senderMessage = await pgp.encryptFor(text, [identity.publicKey]);
652
+ encryptedMessage = await pgp.encryptFor(outgoing, recipientKeys);
653
+ senderMessage = await pgp.encryptFor(outgoing, [identity.publicKey]);
235
654
  }
236
655
  catch (err) {
237
656
  logger.error(`[chat ${chatId}] encryption failed: ${err.message}`);
@@ -239,7 +658,7 @@ function createWebhookServer(options) {
239
658
  }
240
659
  const trail = delegations.drainTrail(identity.saltAppId, chatId);
241
660
  try {
242
- await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail);
661
+ await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail, mentions);
243
662
  }
244
663
  catch (err) {
245
664
  logger.error(`[chat ${chatId}] posting reply failed: ${err.message}`);
@@ -252,7 +671,159 @@ function createWebhookServer(options) {
252
671
  });
253
672
  };
254
673
  }
255
- async function handleMessage(body) {
674
+ // ctx.ask/ctx.approve (K3, ask.ts) -- bound to one (identity, chatId) the
675
+ // same way makeReply above is, and handed to every context type that
676
+ // gets a reply(). The actual card post/update and pending-wait registry
677
+ // live in ask.ts; resolveCardInteraction/resolveMessage below (wired into
678
+ // handleCardInteraction/handleMessage) are what settle a wait this
679
+ // creates.
680
+ //
681
+ // `defaultAnswererId` (M2, security review) is each context's natural
682
+ // "whoever this turn is answering" -- see the individual ctx-construction
683
+ // call sites for what each context type passes (a message's senderRaw,
684
+ // chat_opened's openedBy, invoice_paid's buyer; the two hand-off contexts
685
+ // have no natural default and pass none, so their ask()/approve() calls
686
+ // must supply `answererId` explicitly). A caller's own `opts.answererId`
687
+ // always wins over this default.
688
+ function makeAsk(identity, chatId, defaultAnswererId) {
689
+ return (question, opts) => asks.ask(client, identity, chatId, question, { answererId: defaultAnswererId, ...opts });
690
+ }
691
+ function makeApprove(identity, chatId, defaultAnswererId) {
692
+ return (summary, opts) => asks.approve(client, identity, chatId, summary, { answererId: defaultAnswererId, ...opts });
693
+ }
694
+ // ctx.shareIdentity (identityShare.ts) -- bound to one (identity, chatId)
695
+ // the same way makeAsk/makeApprove above are.
696
+ function makeShareIdentity(identity, chatId) {
697
+ return (keys, opts) => identitySharer.share(identity, chatId, keys, opts);
698
+ }
699
+ // True when `chatId` has at least one human (account_type !== "Agent")
700
+ // member who is NOT a silent observer. A consult lane's human -- the
701
+ // delegation chain's auditor added per actions.ts's delegation-
702
+ // observability note -- is only ever an observer there, so a lane reads as
703
+ // agent-only even though a person can technically decrypt it. `chatMeta`'s
704
+ // own member list is used when a caller already supplied one (mainly
705
+ // tests); the real webhook payload's `chat` never carries members (see
706
+ // salt-api's WebhookJob#user_send allowlist), so this normally falls back
707
+ // to fetching the roster -- the same call makeReply above already makes to
708
+ // encrypt a reply.
709
+ async function chatHasNonObserverHuman(identity, chatId, chatMeta) {
710
+ const inlineMembers = Array.isArray(chatMeta?.users) ? chatMeta.users : undefined;
711
+ let members = inlineMembers;
712
+ if (!members) {
713
+ try {
714
+ members = await client.getChatMembers(identity.apiKey, chatId);
715
+ }
716
+ catch (err) {
717
+ logger.error(`[chat ${chatId}] fetching members for mention-gating failed: ${err.message}`);
718
+ return false;
719
+ }
720
+ }
721
+ return members.some((m) => m.account_type !== "Agent" && m.account_type !== "Device" && !m.observer);
722
+ }
723
+ // Runs `options.onIdentityAsk` for an incoming SALT-IDENTITY-ASK and
724
+ // answers it -- a SLICE (carrying `ask=parsed.id`) for the keys the
725
+ // handler chose to share, a DECLINE (same) for null/false/an empty
726
+ // array. Never lets a handler or send failure escape to the caller: an
727
+ // ask that can't be answered must not crash the webhook pass that
728
+ // received it, the same convention every other handler-invoking helper
729
+ // in this file follows.
730
+ async function handleIdentityAsk(identity, chatId, senderId, parsed) {
731
+ let decision;
732
+ try {
733
+ decision = await options.onIdentityAsk({ id: parsed.id, keys: parsed.keys, text: parsed.text, chatId, from: senderId });
734
+ }
735
+ catch (err) {
736
+ logger.error(`[chat ${chatId}] onIdentityAsk failed: ${err.message}`);
737
+ return;
738
+ }
739
+ try {
740
+ if (Array.isArray(decision) && decision.length > 0) {
741
+ await identitySharer.share(identity, chatId, decision, { ask: parsed.id });
742
+ }
743
+ else {
744
+ await identitySharer.decline(identity, chatId, parsed.id);
745
+ }
746
+ }
747
+ catch (err) {
748
+ logger.error(`[chat ${chatId}] answering identity ask ${parsed.id} failed: ${err.message}`);
749
+ }
750
+ }
751
+ // Handles an incoming SLICE, DECLINE or REVOKE from someone else. A
752
+ // SLICE is verified against the sender's own public key (fetched fresh
753
+ // from the chat's member list -- the same call makeReply already makes
754
+ // to encrypt a reply) and, when verified, recorded on this (identity,
755
+ // chat) session before `onIdentityShared` fires; a REVOKE removes any
756
+ // slice recorded under the same id first. Never lets a verification,
757
+ // session, or handler failure escape -- same convention as
758
+ // handleIdentityAsk above.
759
+ async function handleIdentitySharedMarker(identity, chatId, senderId, parsed) {
760
+ if (parsed.kind === "slice") {
761
+ let verified = false;
762
+ try {
763
+ const members = await client.getChatMembers(identity.apiKey, chatId);
764
+ const sender = members.find((m) => (0, ids_js_1.sameId)(m.id, senderId));
765
+ if (sender?.public_key) {
766
+ verified = await pgp.verifyDetached((0, identity_js_1.canonicalizeJcs)(parsed.payload.sections), parsed.payload.signature, sender.public_key);
767
+ }
768
+ }
769
+ catch (err) {
770
+ logger.error(`[chat ${chatId}] verifying identity slice ${parsed.id} failed: ${err.message}`);
771
+ }
772
+ try {
773
+ const session = await loadOrRebuildSession(identity, chatId);
774
+ sessions.recordReceivedIdentitySlice(session, senderId, {
775
+ id: parsed.id,
776
+ sections: parsed.payload.sections,
777
+ verified,
778
+ receivedAt: Date.now(),
779
+ });
780
+ session.updatedAt = Date.now();
781
+ await sessionStore.put(identity.saltAppId, chatId, session);
782
+ }
783
+ catch (err) {
784
+ logger.error(`[session] recording identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
785
+ }
786
+ if (options.onIdentityShared) {
787
+ try {
788
+ await options.onIdentityShared({
789
+ kind: "slice",
790
+ id: parsed.id,
791
+ askId: parsed.askId,
792
+ from: senderId,
793
+ chatId,
794
+ sections: parsed.payload.sections,
795
+ verified,
796
+ });
797
+ }
798
+ catch (err) {
799
+ logger.error(`[chat ${chatId}] onIdentityShared (slice) failed: ${err.message}`);
800
+ }
801
+ }
802
+ return;
803
+ }
804
+ if (parsed.kind === "revoke") {
805
+ try {
806
+ const session = await loadOrRebuildSession(identity, chatId);
807
+ sessions.forgetReceivedIdentitySlice(session, senderId, parsed.id);
808
+ session.updatedAt = Date.now();
809
+ await sessionStore.put(identity.saltAppId, chatId, session);
810
+ }
811
+ catch (err) {
812
+ logger.error(`[session] forgetting revoked identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
813
+ }
814
+ }
815
+ if (options.onIdentityShared) {
816
+ try {
817
+ await options.onIdentityShared(parsed.kind === "decline"
818
+ ? { kind: "decline", id: parsed.id, askId: parsed.askId, from: senderId, chatId }
819
+ : { kind: "revoke", id: parsed.id, from: senderId, chatId });
820
+ }
821
+ catch (err) {
822
+ logger.error(`[chat ${chatId}] onIdentityShared (${parsed.kind}) failed: ${err.message}`);
823
+ }
824
+ }
825
+ }
826
+ async function handleMessage(body, headerAgentId) {
256
827
  const message = body.message;
257
828
  const chatId = message.chat_id;
258
829
  const chatMeta = body.chat;
@@ -260,15 +831,50 @@ function createWebhookServer(options) {
260
831
  return;
261
832
  if (message.event_type)
262
833
  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.`);
834
+ const rawMessage = message.message;
835
+ if (typeof rawMessage !== "string")
269
836
  return;
837
+ // Open rooms (salt-api 0.6x): a plain chat delivers `encrypted: false`
838
+ // and `message` is the text itself, not a PGP blob -- there is nothing
839
+ // to trial-decrypt, and no way to recover which hosted identity this
840
+ // delivery was addressed to except the header salt-api signs every
841
+ // callback with. Without it (a header-less transport, or a header
842
+ // naming an identity this process doesn't host) there is no safe
843
+ // fallback the way trial-decryption gives the ciphertext path below --
844
+ // guessing would risk this identity replying as if it heard a message
845
+ // that was actually addressed to a sibling identity sharing this
846
+ // process.
847
+ const isPlaintext = message.encrypted === false;
848
+ let identity;
849
+ let caption;
850
+ if (isPlaintext) {
851
+ const preferred = headerAgentId ? identities.get(headerAgentId) : undefined;
852
+ if (!preferred) {
853
+ logger.error(`[chat ${chatId}] no known identity for this plaintext message (X-Salt-Agent-Id ${headerAgentId ?? "missing"}); ignoring.`);
854
+ return;
855
+ }
856
+ identity = preferred;
857
+ caption = rawMessage;
858
+ }
859
+ else {
860
+ if (!PGP_MESSAGE_RE.test(rawMessage))
861
+ return;
862
+ const resolved = await resolveIdentity(rawMessage, headerAgentId);
863
+ if (!resolved) {
864
+ logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
865
+ return;
866
+ }
867
+ identity = resolved.identity;
868
+ caption = resolved.plaintext;
270
869
  }
271
- let { identity, plaintext: caption } = resolved;
870
+ // Wire protocol, never a prompt: a hand-off briefing is consumed by
871
+ // handleHandoffReceived's poll (above HANDOFF_BRIEFING_MARKER's
872
+ // definition), and a bare session-note line is consumed by whichever
873
+ // hand-off leg reads it back out (sessions.ts). Either one reaching
874
+ // onMessage reads to a viewer as this identity replying to its own
875
+ // colleague's internal handoff chatter.
876
+ if (caption.startsWith(exports.HANDOFF_BRIEFING_MARKER) || caption.startsWith(sessions.SESSION_NOTE_MARKER))
877
+ return;
272
878
  // GACM routing fix: salt-api sends one webhook delivery PER agent
273
879
  // member, but they all land on this shared server and message-id dedup
274
880
  // collapses them into a single pass -- whose decrypt-resolved identity
@@ -277,51 +883,180 @@ function createWebhookServer(options) {
277
883
  // THAT identity; the plaintext is identical under any key, only "who
278
884
  // replies" changes.
279
885
  if (chatMeta?.active_agent_id) {
280
- const active = identities.get(parseInt(String(chatMeta.active_agent_id), 10));
886
+ const active = identities.get(String(chatMeta.active_agent_id));
281
887
  if (active)
282
888
  identity = active;
283
889
  }
890
+ // A message with no sender is not a prompt, and everything below -- the
891
+ // self-reply loop break, the delegation-reply match, the agent-to-agent
892
+ // cap -- is a question about who sent it. This used to fall through with
893
+ // senderId = NaN, which compared equal to nothing and unequal to
894
+ // everything, so all three guards inverted rather than being skipped. The
895
+ // context handed to consumers already asserted a sender existed.
284
896
  const senderRaw = message.user;
285
- const senderId = senderRaw ? parseInt(String(senderRaw.id), 10) : NaN;
897
+ if (!senderRaw)
898
+ return;
899
+ const senderId = String(senderRaw.id);
286
900
  // Reply to a pending delegation call? Hand it to that waiting promise
287
901
  // instead of starting a fresh reply cycle -- see delegations.ts.
288
902
  if (delegations.resolveIfPending(chatId, senderId, caption))
289
903
  return;
904
+ // The answer to a pending ctx.ask, typed rather than tapped? Settle
905
+ // that wait instead of starting a fresh reply cycle -- see ask.ts.
906
+ // Only a human's plain reply counts (an agent addressed by handle
907
+ // gets its own webhook/turn, same reasoning as the mention-gating
908
+ // below); `caption` is the raw decrypted text, same as delegations'
909
+ // check just above.
910
+ if (asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption))
911
+ return;
290
912
  // Never reply to our own messages (the reply we post is itself
291
913
  // delivered back to us as a webhook) -- this is what prevents an
292
914
  // infinite loop.
293
- if (senderId === identity.saltAppId)
915
+ if ((0, ids_js_1.sameId)(senderId, identity.saltAppId))
916
+ return;
917
+ // A device lane is wire protocol, not conversation: hand it to the device
918
+ // hook before any conversational gate can drop it (see onDeviceLaneMessage).
919
+ if (chatMeta?.lane_kind === "device" && options.onDeviceLaneMessage) {
920
+ try {
921
+ Promise.resolve(options.onDeviceLaneMessage({ identity, chatId, senderId, sender: senderRaw, text: caption, chatMeta })).catch((err) => {
922
+ logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
923
+ });
924
+ }
925
+ catch (err) {
926
+ logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
927
+ }
928
+ return;
929
+ }
930
+ // request_floor (actions.ts): the consulted agent is asking to be
931
+ // brought into the room directly instead of relaying further through
932
+ // this lane. Wire protocol, never a prompt -- and only actionable by
933
+ // whichever hosted identity actually opened this lane as the asker.
934
+ if (caption.startsWith(delegations.FLOOR_REQUEST_MARKER)) {
935
+ const asker = delegations.consultAskerFor(chatId);
936
+ if (asker && (0, ids_js_1.sameId)(asker.askerId, identity.saltAppId)) {
937
+ const reason = caption.slice(delegations.FLOOR_REQUEST_MARKER.length).replace(/^\n/, "").trim() || undefined;
938
+ pendingConsultHandoffs.set(mapKey(asker.roomId), chatId);
939
+ try {
940
+ await client.handOff(identity.apiKey, asker.roomId, senderId, reason);
941
+ }
942
+ catch (err) {
943
+ pendingConsultHandoffs.delete(mapKey(asker.roomId));
944
+ logger.error(`[chat ${asker.roomId}] auto hand-off on floor request failed: ${err.message}`);
945
+ }
946
+ }
947
+ return;
948
+ }
949
+ // Identity (R3/R4, identityShare.ts): a SALT-IDENTITY-ASK/SLICE/
950
+ // DECLINE/REVOKE is wire protocol, never a prompt -- with one
951
+ // deliberate exception. An ASK with no onIdentityAsk handler registered
952
+ // is NOT intercepted at all: it falls through as an ordinary message,
953
+ // marker stripped, so a consumer that hasn't wired up the handler yet
954
+ // sees whatever text (if any) rode alongside it rather than losing the
955
+ // message outright. SLICE/DECLINE/REVOKE are always intercepted,
956
+ // whether or not onIdentityShared is set -- there is no "ordinary
957
+ // text" reading of a signed slice or a revoke.
958
+ if (caption.startsWith(identityShare.IDENTITY_MARKER_PREFIX)) {
959
+ const parsed = identityShare.parseIdentityMarker(caption);
960
+ if (parsed?.kind === "ask") {
961
+ if (options.onIdentityAsk) {
962
+ await handleIdentityAsk(identity, chatId, senderId, parsed);
963
+ return;
964
+ }
965
+ caption = parsed.text || "";
966
+ }
967
+ else if (parsed) {
968
+ await handleIdentitySharedMarker(identity, chatId, senderId, parsed);
969
+ return;
970
+ }
971
+ else {
972
+ // Starts with the prefix but didn't parse into anything usable --
973
+ // a garbled or foreign marker either way, never a prompt.
974
+ return;
975
+ }
976
+ }
977
+ // Cancel (work.ts's [[SALT-WORK-CANCEL id=...]]): the person cancelling
978
+ // a scheduled report of theirs, sent as a whole message into the lane
979
+ // that report was posted into. Wire protocol, never a prompt -- always
980
+ // intercepted, whether or not onWorkCancel is set, same as a SLICE/
981
+ // DECLINE/REVOKE above (there's no "ordinary text" reading of it).
982
+ const cancelWorkId = (0, work_1.parseWorkCancel)(caption);
983
+ if (cancelWorkId) {
984
+ if (options.onWorkCancel) {
985
+ const cancelRoomId = chatMeta?.coaching_for_chat_id || chatId;
986
+ try {
987
+ await options.onWorkCancel({ identity, workId: cancelWorkId, chatId: cancelRoomId, laneId: chatId, sender: senderRaw });
988
+ }
989
+ catch (err) {
990
+ logger.error(`[chat ${chatId}] onWorkCancel failed: ${err.message}`);
991
+ }
992
+ }
294
993
  return;
994
+ }
295
995
  // Mediated Chat: this identity is the configured Mediator, and this
296
996
  // message is in the SHARED chat it silently observes -- present for
297
997
  // context, but it only actually speaks in each human's own private
298
998
  // coaching chat (coaching_for_chat_id pointing back at this one).
299
- const isMediator = !!mediatorAgentId && identity.saltAppId === mediatorAgentId;
999
+ const isMediator = !!mediatorAgentId && (0, ids_js_1.sameId)(identity.saltAppId, mediatorAgentId);
300
1000
  if (isMediator && chatMeta?.mediator_agent_id && !chatMeta.coaching_for_chat_id)
301
1001
  return;
302
1002
  // Global Agent Chat Mode: when the chat declares an active agent and
303
1003
  // it isn't this identity, stay silent. Lets retired/handed-off-from
304
1004
  // agents remain in the room without ever double-replying.
305
- if (chatMeta?.active_agent_id && String(chatMeta.active_agent_id) !== String(identity.saltAppId))
1005
+ if (chatMeta?.active_agent_id && !(0, ids_js_1.sameId)(chatMeta.active_agent_id, identity.saltAppId))
306
1006
  return;
307
1007
  // A human sender always resets the count -- only agent-to-agent
308
1008
  // volleys are capped, never a real conversation with a person.
309
- const senderIsAgent = !!identities.get(senderId) || senderRaw?.account_type === "Agent";
1009
+ const senderIsAgent = !!identities.get(senderId) || senderRaw.account_type === "Agent";
1010
+ const replyCountKey = mapKey(chatId);
1011
+ if (chatMeta?.coaching_for_chat_id) {
1012
+ laneRoomOf.set(replyCountKey, mapKey(chatMeta.coaching_for_chat_id));
1013
+ }
310
1014
  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;
1015
+ // A real person changes the rule entirely: an agent-authored message
1016
+ // is only ours to answer when it @mentions us -- the same gate
1017
+ // salt-api applies to whether the webhook is even sent in a group
1018
+ // chat, belt-and-suspenders for whatever still reaches us (a hand-off
1019
+ // farewell, a briefing after item 1 above missed a variant, another
1020
+ // agent's aside). Two agents alone in a lane have no one to perform
1021
+ // for, so the runaway cap below still governs those exactly as before.
1022
+ if (await chatHasNonObserverHuman(identity, chatId, chatMeta)) {
1023
+ const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map(String);
1024
+ if (!mentions.some((id) => (0, ids_js_1.sameId)(id, identity.saltAppId))) {
1025
+ logger.error(`[chat ${chatId}] agent message not addressed to us in a chat with a person present; not auto-replying to ${senderId}.`);
1026
+ return;
1027
+ }
1028
+ }
1029
+ else {
1030
+ const limit = chatMeta?.lane_kind === "consult" ? CONSULT_RUNAWAY_LIMIT : MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT;
1031
+ const count = (agentToAgentReplyCounts.get(replyCountKey) || 0) + 1;
1032
+ agentToAgentReplyCounts.set(replyCountKey, count);
1033
+ if (count > limit) {
1034
+ logger.error(`[chat ${chatId}] agent-to-agent reply cap reached; not auto-replying to ${senderId} again.`);
1035
+ return;
1036
+ }
316
1037
  }
317
1038
  }
318
1039
  else {
319
- agentToAgentReplyCounts.delete(chatId);
1040
+ agentToAgentReplyCounts.delete(replyCountKey);
1041
+ // A human speaking in a ROOM also resets every consult lane rooted
1042
+ // there -- otherwise a lane capped mid-conversation stays capped even
1043
+ // right after the person who asked for it weighs in.
1044
+ for (const [laneKey, roomKey] of laneRoomOf) {
1045
+ if (roomKey === replyCountKey)
1046
+ agentToAgentReplyCounts.delete(laneKey);
1047
+ }
320
1048
  }
321
- const { depth, text: strippedCaption } = delegations.parseIncoming(caption);
1049
+ const { depth, text: afterDepthMarker } = delegations.parseIncoming(caption);
1050
+ const strippedCaption = delegations.stripConsultMarker(afterDepthMarker);
322
1051
  let mediatorSharedContext;
323
1052
  let attachment;
324
- if (isMediator && chatMeta?.coaching_for_chat_id) {
1053
+ // The shared conversation is fetched only for the MUTUAL Mediator lane,
1054
+ // where this identity is an observer on that chat and both parties can see
1055
+ // it is there. A private advisor lane is excluded: that advisor was never
1056
+ // added to the shared chat, so every message would fail to decrypt and be
1057
+ // skipped anyway -- but a guarantee that rests on a failed decrypt is one
1058
+ // membership bug away from being no guarantee, so we do not ask at all.
1059
+ if (isMediator && chatMeta?.coaching_for_chat_id && !chatMeta.private_lane) {
325
1060
  mediatorSharedContext = await buildSharedChatContext(identity, chatMeta.coaching_for_chat_id);
326
1061
  }
327
1062
  else if (message.resource_type === "Attachment") {
@@ -329,20 +1064,63 @@ function createWebhookServer(options) {
329
1064
  }
330
1065
  if (!options.onMessage)
331
1066
  return;
1067
+ const roomId = chatMeta?.coaching_for_chat_id || chatId;
1068
+ const session = await loadOrRebuildSession(identity, chatId, chatMeta);
1069
+ // Wraps the shared reply() closure to also remember what was sent, so
1070
+ // it can be appended to the session as an assistant turn once the
1071
+ // handler returns successfully -- see the persistence step below.
1072
+ // Nothing about what actually gets sent changes.
1073
+ const repliesForSession = [];
1074
+ const baseReply = makeReply(identity, chatId, senderRaw);
1075
+ const trackedReply = async (text, opts) => {
1076
+ repliesForSession.push(text);
1077
+ await baseReply(text, opts);
1078
+ };
332
1079
  const ctx = {
333
1080
  identity,
334
1081
  chatId,
335
1082
  senderId,
336
1083
  sender: senderRaw,
337
1084
  text: strippedCaption,
1085
+ encrypted: !isPlaintext,
1086
+ deliveredBecause: parseDeliveredBecause(message.delivered_because),
1087
+ replyTo: message.reply_to_message_id != null ? String(message.reply_to_message_id) : undefined,
1088
+ parent: parseReplyParent(message.parent),
338
1089
  delegationDepth: depth,
339
1090
  chatMeta,
1091
+ roomId,
340
1092
  mediatorSharedContext,
341
1093
  attachment,
342
- reply: makeReply(identity, chatId),
1094
+ session,
1095
+ reply: trackedReply,
1096
+ messageId: message.message_id,
1097
+ react: (emoji) => client.react(identity.apiKey, message.message_id, emoji),
1098
+ // M2: default answerer is whoever sent THIS message -- never an
1099
+ // agent (an agent sender has its own webhook/turn; ctx.ask is for
1100
+ // asking a person).
1101
+ ask: makeAsk(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
1102
+ approve: makeApprove(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
1103
+ shareIdentity: makeShareIdentity(identity, chatId),
343
1104
  };
344
1105
  try {
345
1106
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onMessage(ctx)));
1107
+ try {
1108
+ sessions.appendTurn(session, {
1109
+ role: "user",
1110
+ content: strippedCaption,
1111
+ at: Date.now(),
1112
+ from: String(senderRaw.username || senderRaw.display_name || senderId),
1113
+ });
1114
+ for (const replyText of repliesForSession) {
1115
+ sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
1116
+ }
1117
+ session.note = sessions.boundNote(session.note);
1118
+ session.updatedAt = Date.now();
1119
+ await sessionStore.put(identity.saltAppId, chatId, session);
1120
+ }
1121
+ catch (err) {
1122
+ logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
1123
+ }
346
1124
  }
347
1125
  catch (err) {
348
1126
  logger.error(`[chat ${chatId}] onMessage failed: ${err.message}`);
@@ -352,9 +1130,19 @@ function createWebhookServer(options) {
352
1130
  }
353
1131
  }
354
1132
  async function handleCardInteraction(body) {
355
- const identity = identities.get(parseInt(String(body.owner_id), 10));
1133
+ // A tap answering a pending ctx.ask's own card is wire-level bookkeeping
1134
+ // for ask.ts, never something the consumer's onCardInteraction should
1135
+ // also see -- checked before identity resolution/onCardInteraction even
1136
+ // matter, since this can settle an ask() with no consumer handler
1137
+ // registered for cards at all.
1138
+ if (asks.resolveCardInteraction(body.owner_id, body.chat_id, body.card_id, body.action_id, body.user))
1139
+ return;
1140
+ const identity = identities.get(String(body.owner_id));
356
1141
  if (!identity || !options.onCardInteraction)
357
1142
  return;
1143
+ // No reply() on this context (a card update IS the response), so just
1144
+ // load -- nothing new here to append or persist afterward.
1145
+ const session = await loadOrRebuildSession(identity, body.chat_id);
358
1146
  try {
359
1147
  await options.onCardInteraction({
360
1148
  identity,
@@ -363,17 +1151,67 @@ function createWebhookServer(options) {
363
1151
  actionId: body.action_id,
364
1152
  user: body.user,
365
1153
  blocks: body.state?.blocks,
1154
+ values: body.values || {},
1155
+ session,
366
1156
  });
367
1157
  }
368
1158
  catch (err) {
369
1159
  logger.error(`[chat ${body.chat_id}] onCardInteraction failed: ${err.message}`);
370
1160
  }
371
1161
  }
1162
+ async function handleAppAction(body, headerAgentId) {
1163
+ // No owner_id/seller_id in this body to fall back on (unlike
1164
+ // card_interaction/invoice_paid) -- X-Salt-Agent-Id is the only signal,
1165
+ // same as the mandate rail events further down. verifyEnvelope already
1166
+ // proved the signature against that exact identity's own key.
1167
+ const identity = headerAgentId != null ? identities.get(String(headerAgentId)) : undefined;
1168
+ if (!identity || !options.onAppAction)
1169
+ return;
1170
+ const chatId = body.chat_id ?? null;
1171
+ // A personal ("Your apps") installation has no chat at all -- nothing
1172
+ // for a reply to post into, same as salt.post's "Not in a chat."
1173
+ // rejection on the bridge side. Fails soft (logged), the same
1174
+ // convention every other reply() failure in this file follows.
1175
+ const reply = chatId != null
1176
+ ? makeReply(identity, chatId)
1177
+ : async (_text) => {
1178
+ logger.error(`[app ${body.installation_id}] reply() has no chat -- this is a personal installation.`);
1179
+ };
1180
+ const ctx = {
1181
+ identity,
1182
+ installationId: body.installation_id,
1183
+ app: body.app,
1184
+ chatId,
1185
+ text: body.text,
1186
+ data: body.data,
1187
+ stateVersion: body.state_version,
1188
+ sender: body.user,
1189
+ reply,
1190
+ };
1191
+ try {
1192
+ if (chatId != null) {
1193
+ await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onAppAction(ctx)));
1194
+ }
1195
+ else {
1196
+ await options.onAppAction(ctx);
1197
+ }
1198
+ }
1199
+ catch (err) {
1200
+ logger.error(`[app ${body.installation_id}] onAppAction failed: ${err.message}`);
1201
+ }
1202
+ }
372
1203
  async function handleInvoicePaid(body) {
373
- const identity = identities.get(parseInt(String(body.seller_id), 10));
1204
+ const identity = identities.get(String(body.seller_id));
374
1205
  if (!identity || !body.chat_id || !options.onInvoicePaid)
375
1206
  return;
376
1207
  const chatId = body.chat_id;
1208
+ const session = await loadOrRebuildSession(identity, chatId);
1209
+ const repliesForSession = [];
1210
+ const baseReply = makeReply(identity, chatId);
1211
+ const trackedReply = async (text, opts) => {
1212
+ repliesForSession.push(text);
1213
+ await baseReply(text, opts);
1214
+ };
377
1215
  const ctx = {
378
1216
  identity,
379
1217
  chatId,
@@ -382,30 +1220,143 @@ function createWebhookServer(options) {
382
1220
  amount: body.amount,
383
1221
  isTopUp: !!body.billing_account_id,
384
1222
  transferRequestId: body.transfer_request_id,
385
- reply: makeReply(identity, chatId),
1223
+ session,
1224
+ reply: trackedReply,
1225
+ // M2: default answerer is the buyer (never an agent -- an agent
1226
+ // buyer has no need to be asked a question through its own purchase).
1227
+ ask: makeAsk(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
1228
+ approve: makeApprove(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
1229
+ shareIdentity: makeShareIdentity(identity, chatId),
386
1230
  };
387
1231
  try {
388
1232
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onInvoicePaid(ctx)));
1233
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
389
1234
  }
390
1235
  catch (err) {
391
1236
  logger.error(`[chat ${chatId}] onInvoicePaid failed: ${err.message}`);
392
1237
  }
393
1238
  }
1239
+ async function handleChatOpened(body) {
1240
+ if (!options.onChatOpened)
1241
+ return;
1242
+ const chat = body.chat;
1243
+ const chatId = chat?.id;
1244
+ if (chatId == null)
1245
+ return;
1246
+ // Unlike card_interaction/invoice_paid, this payload carries no explicit
1247
+ // owner/seller id naming which hosted identity it's for -- salt-api
1248
+ // delivers it once per member with a callback URL, same as everything
1249
+ // else, but the only way this process (which can host several identities
1250
+ // on one endpoint) learns WHICH of its identities the event is about is
1251
+ // by noticing itself in the member list. Prefer the chat's declared
1252
+ // active agent when that's one of ours (GACM), the same tie-break
1253
+ // handleMessage uses, in case delegation ever puts two hosted identities
1254
+ // in the same chat.
1255
+ let identity;
1256
+ if (chat?.active_agent_id)
1257
+ identity = identities.get(String(chat.active_agent_id));
1258
+ if (!identity) {
1259
+ for (const member of body.members || []) {
1260
+ identity = identities.get(String(member.id));
1261
+ if (identity)
1262
+ break;
1263
+ }
1264
+ }
1265
+ if (!identity) {
1266
+ logger.error(`[chat ${chatId}] chat_opened: no hosted identity found among members; ignoring.`);
1267
+ return;
1268
+ }
1269
+ if (alreadyGreeted(identity.saltAppId, chatId))
1270
+ return;
1271
+ const session = await loadOrRebuildSession(identity, chatId, chat);
1272
+ const repliesForSession = [];
1273
+ const baseReply = makeReply(identity, chatId);
1274
+ const trackedReply = async (text, opts) => {
1275
+ repliesForSession.push(text);
1276
+ await baseReply(text, opts);
1277
+ };
1278
+ const ctx = {
1279
+ identity,
1280
+ chatId,
1281
+ chat: chat,
1282
+ encrypted: chat?.encrypted !== false,
1283
+ openedBy: body.opened_by,
1284
+ members: body.members || [],
1285
+ openedAt: body.opened_at,
1286
+ session,
1287
+ reply: trackedReply,
1288
+ // M2: default answerer is whoever opened the chat (never an agent).
1289
+ ask: makeAsk(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
1290
+ approve: makeApprove(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
1291
+ shareIdentity: makeShareIdentity(identity, chatId),
1292
+ };
1293
+ try {
1294
+ await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onChatOpened(ctx)));
1295
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
1296
+ }
1297
+ catch (err) {
1298
+ logger.error(`[chat ${chatId}] onChatOpened failed: ${err.message}`);
1299
+ }
1300
+ }
394
1301
  async function handleHandoffConfirmed(body) {
395
- const identity = identities.get(parseInt(String(body.from_agent_id), 10));
1302
+ const identity = identities.get(String(body.from_agent_id));
396
1303
  if (!identity || !options.onHandoffConfirmed)
397
1304
  return;
398
1305
  const chatId = body.chat_id;
399
- const ctx = { identity, chatId, reason: body.reason, reply: makeReply(identity, chatId) };
1306
+ // A request_floor call may have just auto-triggered this same hand-off
1307
+ // (webhook.ts's floor-request handler, above) -- if so, give the
1308
+ // briefing its consult lane's transcript.
1309
+ const laneId = pendingConsultHandoffs.get(mapKey(chatId));
1310
+ if (laneId)
1311
+ pendingConsultHandoffs.delete(mapKey(chatId));
1312
+ let consultTranscript;
1313
+ if (laneId) {
1314
+ try {
1315
+ consultTranscript = await buildConsultTranscript(identity, laneId);
1316
+ }
1317
+ catch (err) {
1318
+ logger.error(`[chat ${chatId}] fetching consult transcript for hand-off failed: ${err.message}`);
1319
+ }
1320
+ }
1321
+ const session = await loadOrRebuildSession(identity, chatId);
1322
+ // Appends the outgoing session's note (if any) as the briefing's final
1323
+ // wire-protocol line -- see sessions.ts's SESSION_NOTE_MARKER and
1324
+ // handleHandoffReceived below, which parses it back out on the other
1325
+ // end. Transparent to the consumer: whatever text they reply() with is
1326
+ // what a human sees; this just rides along after it. Reads `session.note`
1327
+ // directly rather than re-fetching -- the same object ctx.session is, so
1328
+ // a handler that wrote to it before calling reply() is picked up.
1329
+ const repliesForSession = [];
1330
+ const baseReply = makeReply(identity, chatId);
1331
+ const reply = async (text, opts) => {
1332
+ repliesForSession.push(text);
1333
+ const noteLine = sessions.formatSessionNoteLine(session.note);
1334
+ await baseReply(noteLine ? `${text}\n${noteLine}` : text, opts);
1335
+ };
1336
+ const ctx = {
1337
+ identity,
1338
+ chatId,
1339
+ reason: body.reason,
1340
+ consultTranscript,
1341
+ session,
1342
+ reply,
1343
+ // M2: no natural default here (a hand-off has no single "whoever
1344
+ // this answers") -- ask()/approve() from this context must be given
1345
+ // answererId explicitly, or they throw synchronously.
1346
+ ask: makeAsk(identity, chatId),
1347
+ approve: makeApprove(identity, chatId),
1348
+ shareIdentity: makeShareIdentity(identity, chatId),
1349
+ };
400
1350
  try {
401
1351
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffConfirmed(ctx)));
1352
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
402
1353
  }
403
1354
  catch (err) {
404
1355
  logger.error(`[chat ${chatId}] onHandoffConfirmed failed: ${err.message}`);
405
1356
  }
406
1357
  }
407
1358
  async function handleHandoffReceived(body) {
408
- const identity = identities.get(parseInt(String(body.to_agent_id), 10));
1359
+ const identity = identities.get(String(body.to_agent_id));
409
1360
  if (!identity || !options.onHandoffReceived)
410
1361
  return;
411
1362
  const chatId = body.chat_id;
@@ -421,44 +1372,397 @@ function createWebhookServer(options) {
421
1372
  break;
422
1373
  await new Promise((r) => setTimeout(r, 2000));
423
1374
  }
424
- const ctx = { identity, chatId, reason: body.reason, context, reply: makeReply(identity, chatId) };
1375
+ // Seed this identity's own session for `chatId` from whatever's already
1376
+ // stored (a fresh one if nothing is), fold in the outgoing agent's note
1377
+ // if the briefing carried one, and persist that much right away --
1378
+ // independent of whether onHandoffReceived itself succeeds below, same
1379
+ // as before this session had a `session` field on its ctx at all.
1380
+ // Stripped from `context` either way: wire protocol, never something
1381
+ // the incoming agent's own prompt-building should see verbatim.
1382
+ let session;
1383
+ try {
1384
+ session = (await sessionStore.get(identity.saltAppId, chatId)) ?? sessions.emptySession(chatId, chatId);
1385
+ }
1386
+ catch (err) {
1387
+ logger.error(`[session] loading ${chatId} failed: ${err.message}`);
1388
+ session = sessions.emptySession(chatId, chatId);
1389
+ }
1390
+ const note = sessions.extractSessionNote(context);
1391
+ if (note) {
1392
+ session.note = sessions.boundNote(note);
1393
+ session.updatedAt = Date.now();
1394
+ try {
1395
+ await sessionStore.put(identity.saltAppId, chatId, session);
1396
+ }
1397
+ catch (err) {
1398
+ logger.error(`[session] persisting handed-off note for ${chatId} failed: ${err.message}`);
1399
+ }
1400
+ }
1401
+ context = sessions.stripSessionNoteLines(context);
1402
+ // Whatever the introduction reply() sends here is appended below,
1403
+ // exactly like onMessage does for its own reply -- so it's already in
1404
+ // transcriptTail the next time this chat's onMessage runs.
1405
+ const repliesForSession = [];
1406
+ const baseReply = makeReply(identity, chatId);
1407
+ const trackedReply = async (text, opts) => {
1408
+ repliesForSession.push(text);
1409
+ await baseReply(text, opts);
1410
+ };
1411
+ const ctx = {
1412
+ identity,
1413
+ chatId,
1414
+ reason: body.reason,
1415
+ context,
1416
+ session,
1417
+ reply: trackedReply,
1418
+ // M2: no natural default here either -- see HandoffConfirmedContext's comment above.
1419
+ ask: makeAsk(identity, chatId),
1420
+ approve: makeApprove(identity, chatId),
1421
+ shareIdentity: makeShareIdentity(identity, chatId),
1422
+ };
425
1423
  try {
426
1424
  await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffReceived(ctx)));
1425
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
427
1426
  }
428
1427
  catch (err) {
429
1428
  logger.error(`[chat ${chatId}] onHandoffReceived failed: ${err.message}`);
430
1429
  }
431
1430
  }
432
- async function dispatch(body) {
1431
+ // Resolves which of this process's hosted identities a mandate/exercise event is FOR. The
1432
+ // envelope's X-Salt-Agent-Id is authoritative (verifyEnvelope already checked the signature
1433
+ // against that exact identity's own key, so it names a real recipient) -- the `candidateIds`
1434
+ // fallback (party ids off the payload itself) only matters for a caller that runs the dispatcher
1435
+ // directly with no header (e.g. a test, or a future transport that doesn't have one).
1436
+ function resolveMandateIdentity(headerAgentId, ...candidateIds) {
1437
+ if (headerAgentId != null) {
1438
+ const byHeader = identities.get(String(headerAgentId));
1439
+ if (byHeader)
1440
+ return byHeader;
1441
+ }
1442
+ for (const id of candidateIds) {
1443
+ if (id == null)
1444
+ continue;
1445
+ const found = identities.get(String(id));
1446
+ if (found)
1447
+ return found;
1448
+ }
1449
+ return undefined;
1450
+ }
1451
+ async function handleMandateOffered(body, headerAgentId) {
1452
+ const identity = resolveMandateIdentity(headerAgentId, body.mandate?.grantee?.id);
1453
+ if (!identity || !options.onMandateOffered)
1454
+ return;
1455
+ const mandate = body.mandate;
1456
+ const ctx = {
1457
+ identity,
1458
+ mandate,
1459
+ accept: () => client.mandates.accept(identity.apiKey, mandate.id),
1460
+ };
1461
+ try {
1462
+ await options.onMandateOffered(ctx);
1463
+ }
1464
+ catch (err) {
1465
+ logger.error(`[mandate ${mandate.id}] onMandateOffered failed: ${err.message}`);
1466
+ }
1467
+ }
1468
+ async function handleMandateLifecycleEvent(kind, body, headerAgentId) {
1469
+ const mandate = body.mandate;
1470
+ const identity = resolveMandateIdentity(headerAgentId, mandate?.grantee?.id, mandate?.grantor?.id);
1471
+ const handler = kind === "activated" ? options.onMandateActivated : kind === "paused" ? options.onMandatePaused : options.onMandateRevoked;
1472
+ if (!identity || !handler)
1473
+ return;
1474
+ try {
1475
+ await handler({ identity, mandate });
1476
+ }
1477
+ catch (err) {
1478
+ logger.error(`[mandate ${mandate.id}] onMandate${kind[0].toUpperCase()}${kind.slice(1)} failed: ${err.message}`);
1479
+ }
1480
+ }
1481
+ async function handleApprovalRequested(body, headerAgentId) {
1482
+ const exercise = body.exercise;
1483
+ const identity = resolveMandateIdentity(headerAgentId, exercise?.principal?.id);
1484
+ if (!identity || !options.onApprovalRequested)
1485
+ return;
1486
+ const ctx = {
1487
+ identity,
1488
+ exercise,
1489
+ decide: (decision, note) => client.mandates.decide(identity.apiKey, exercise.id, decision, note),
1490
+ };
1491
+ try {
1492
+ await options.onApprovalRequested(ctx);
1493
+ }
1494
+ catch (err) {
1495
+ logger.error(`[exercise ${exercise.id}] onApprovalRequested failed: ${err.message}`);
1496
+ }
1497
+ }
1498
+ async function handleApprovalDecided(body, headerAgentId) {
1499
+ const exercise = body.exercise;
1500
+ const identity = resolveMandateIdentity(headerAgentId, exercise?.actor?.id);
1501
+ if (!identity || !options.onApprovalDecided)
1502
+ return;
1503
+ try {
1504
+ await options.onApprovalDecided({ identity, exercise });
1505
+ }
1506
+ catch (err) {
1507
+ logger.error(`[exercise ${exercise.id}] onApprovalDecided failed: ${err.message}`);
1508
+ }
1509
+ }
1510
+ async function handleDeviceSessionEnded(body, headerAgentId) {
1511
+ if (!options.onDeviceSessionEnded)
1512
+ return;
1513
+ const identity = resolveMandateIdentity(headerAgentId, body.agent_id);
1514
+ if (!identity)
1515
+ return;
1516
+ // The same delivery also goes to the computer itself; it is not the agent's to speak of.
1517
+ if ((0, ids_js_1.sameId)(identity.saltAppId, body.device_id))
1518
+ return;
1519
+ const sessionId = body.session_id ?? body.id;
1520
+ if (sessionId == null)
1521
+ return;
1522
+ const ctx = {
1523
+ identity,
1524
+ sessionId,
1525
+ deviceId: body.device_id,
1526
+ mandateId: body.mandate_id ?? null,
1527
+ laneChatId: body.chat_id ?? body.chat?.id ?? null,
1528
+ endReason: body.end_reason ?? null,
1529
+ lostContact: body.end_reason === exports.DEVICE_LOST_CONTACT,
1530
+ intent: typeof body.intent === "string" ? body.intent : null,
1531
+ endedAt: body.ended_at ?? null,
1532
+ body,
1533
+ };
1534
+ try {
1535
+ await options.onDeviceSessionEnded(ctx);
1536
+ }
1537
+ catch (err) {
1538
+ logger.error(`[device session ${sessionId}] onDeviceSessionEnded failed: ${err.message}`);
1539
+ }
1540
+ }
1541
+ async function handleDeviceBack(body, headerAgentId) {
1542
+ if (!options.onDeviceBack)
1543
+ return;
1544
+ const identity = resolveMandateIdentity(headerAgentId);
1545
+ if (!identity)
1546
+ return;
1547
+ const chatId = body.chat_id ?? null;
1548
+ const session = chatId != null ? await loadOrRebuildSession(identity, chatId) : null;
1549
+ const repliesForSession = [];
1550
+ const baseReply = chatId != null ? makeReply(identity, chatId) : null;
1551
+ const ctx = {
1552
+ identity,
1553
+ deviceId: body.device_id,
1554
+ deviceName: body.device_name,
1555
+ previousSessionId: body.previous_session_id,
1556
+ offlineAt: body.offline_at ?? null,
1557
+ backAt: body.back_at,
1558
+ chatId,
1559
+ session,
1560
+ body,
1561
+ reply: async (text, opts) => {
1562
+ if (!baseReply)
1563
+ throw new Error("device_back: the person has no 1:1 with this agent (chat_id is null), so there is nowhere to reply");
1564
+ repliesForSession.push(text);
1565
+ await baseReply(text, opts);
1566
+ },
1567
+ };
1568
+ try {
1569
+ await options.onDeviceBack(ctx);
1570
+ if (chatId != null && session)
1571
+ await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
1572
+ }
1573
+ catch (err) {
1574
+ logger.error(`[device ${body.device_id}] onDeviceBack failed: ${err.message}`);
1575
+ }
1576
+ }
1577
+ async function handleDescribeYourself(body, headerAgentId) {
1578
+ const agent = (body.agent ?? {});
1579
+ const identity = resolveMandateIdentity(headerAgentId, agent?.id);
1580
+ if (!identity || !options.onDescribeYourself)
1581
+ return;
1582
+ const maxLength = typeof body.max_length === "number" ? body.max_length : 300;
1583
+ const ctx = {
1584
+ identity,
1585
+ reason: String(body.reason ?? "created"),
1586
+ agent,
1587
+ ownerDescription: typeof body.owner_description === "string" ? body.owner_description : null,
1588
+ currentDescription: typeof body.current_description === "string" ? body.current_description : null,
1589
+ capabilities: Array.isArray(body.capabilities) ? body.capabilities : [],
1590
+ guidance: String(body.guidance ?? ""),
1591
+ maxLength,
1592
+ write: async (text) => {
1593
+ const bio = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, maxLength);
1594
+ if (!bio)
1595
+ return;
1596
+ await client.setIdentity(identity.apiKey, { bio });
1597
+ },
1598
+ };
1599
+ try {
1600
+ await options.onDescribeYourself(ctx);
1601
+ }
1602
+ catch (err) {
1603
+ logger.error(`[agent ${agent?.id}] onDescribeYourself failed: ${err.message}`);
1604
+ }
1605
+ }
1606
+ // Answers to a pending ctx.ask (or a pending delegation reply), resolved
1607
+ // WITHOUT running a handler. socket.ts feeds frames through one serial
1608
+ // queue, and the handler that called `await ctx.ask(...)` occupies it, so
1609
+ // the frame carrying the answer would wait behind the very handler that is
1610
+ // waiting for it. socket.ts therefore calls this at frame arrival, ahead
1611
+ // of the queue. Same checks, same order as handleMessage /
1612
+ // handleCardInteraction (which still run for anything this does not
1613
+ // consume), so nothing is answered by a sender those would not accept.
1614
+ // Returns true when the frame was consumed and must not be dispatched.
1615
+ async function resolveAnswer(body, headerAgentId) {
1616
+ if (!asks.hasPendingAsks())
1617
+ return false;
1618
+ if (body?.type === "card_interaction") {
1619
+ const b = body;
1620
+ return asks.resolveCardInteraction(b.owner_id, b.chat_id, b.card_id, b.action_id, b.user);
1621
+ }
1622
+ if (body?.type !== undefined || !body?.message)
1623
+ return false;
1624
+ const message = body.message;
1625
+ if (message.event_type)
1626
+ return false;
1627
+ const rawMessage = message.message;
1628
+ if (typeof rawMessage !== "string")
1629
+ return false;
1630
+ const chatId = message.chat_id;
1631
+ let identity;
1632
+ let caption;
1633
+ if (message.encrypted === false) {
1634
+ identity = headerAgentId ? identities.get(headerAgentId) : undefined;
1635
+ caption = rawMessage;
1636
+ }
1637
+ else {
1638
+ if (!PGP_MESSAGE_RE.test(rawMessage))
1639
+ return false;
1640
+ const resolved = await resolveIdentity(rawMessage, headerAgentId);
1641
+ identity = resolved?.identity;
1642
+ caption = resolved?.plaintext ?? "";
1643
+ }
1644
+ if (!identity)
1645
+ return false;
1646
+ const activeId = body.chat?.active_agent_id;
1647
+ if (activeId)
1648
+ identity = identities.get(String(activeId)) ?? identity;
1649
+ const senderRaw = message.user;
1650
+ if (!senderRaw)
1651
+ return false;
1652
+ const senderId = String(senderRaw.id);
1653
+ if (delegations.resolveIfPending(chatId, senderId, caption))
1654
+ return true;
1655
+ return asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption);
1656
+ }
1657
+ async function dispatch(body, headerAgentId) {
433
1658
  if (body?.type === "card_interaction")
434
1659
  return handleCardInteraction(body);
1660
+ if (body?.type === "app_action")
1661
+ return handleAppAction(body, headerAgentId);
435
1662
  if (body?.type === "invoice_paid")
436
1663
  return handleInvoicePaid(body);
1664
+ if (body?.type === "chat_opened")
1665
+ return handleChatOpened(body);
437
1666
  if (body?.type === "handoff_confirmed")
438
1667
  return handleHandoffConfirmed(body);
439
1668
  if (body?.type === "handoff_received")
440
1669
  return handleHandoffReceived(body);
1670
+ if (body?.type === "mandate_offered")
1671
+ return handleMandateOffered(body, headerAgentId);
1672
+ if (body?.type === "mandate_activated")
1673
+ return handleMandateLifecycleEvent("activated", body, headerAgentId);
1674
+ if (body?.type === "mandate_paused")
1675
+ return handleMandateLifecycleEvent("paused", body, headerAgentId);
1676
+ if (body?.type === "mandate_revoked")
1677
+ return handleMandateLifecycleEvent("revoked", body, headerAgentId);
1678
+ if (body?.type === "approval_requested")
1679
+ return handleApprovalRequested(body, headerAgentId);
1680
+ if (body?.type === "approval_decided")
1681
+ return handleApprovalDecided(body, headerAgentId);
1682
+ if (body?.type === "describe_yourself")
1683
+ return handleDescribeYourself(body, headerAgentId);
1684
+ // device_session_ended and device_back are the AGENT's moments and must stay
1685
+ // ahead of the device_session* catch-all below. device_session_ended also
1686
+ // goes on to onDeviceDelivery (set only by a process that hosts a device):
1687
+ // the session controller there needs the end too, and handleDeviceSessionEnded
1688
+ // itself ignores the device's own copy.
1689
+ if (body?.type === "device_session_ended") {
1690
+ await handleDeviceSessionEnded(body, headerAgentId);
1691
+ await options.onDeviceDelivery?.(body.type, body);
1692
+ return;
1693
+ }
1694
+ if (body?.type === "device_back")
1695
+ return handleDeviceBack(body, headerAgentId);
1696
+ 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")) {
1697
+ await options.onDeviceDelivery?.(body.type, body);
1698
+ return;
1699
+ }
441
1700
  if (body?.message)
442
- return handleMessage(body);
1701
+ return handleMessage(body, headerAgentId);
443
1702
  }
1703
+ return { dispatch, verifyEnvelope, resolveAnswer };
1704
+ }
1705
+ /**
1706
+ * Creates the Express app for a Salt agent's webhook endpoint. Mount it
1707
+ * yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
1708
+ * to compose it into a bigger server).
1709
+ *
1710
+ * A thin transport wrapper around createDispatcher -- everything about the
1711
+ * Salt protocol itself lives there. Switching a consumer from this to
1712
+ * socket.ts's createSocketClient (K2, an agent with no public URL) is the
1713
+ * same `options` object with `.listen(port)` replaced by `.start()`;
1714
+ * onMessage/onCardInteraction/etc. never change.
1715
+ */
1716
+ function createWebhookServer(options) {
1717
+ const logger = options.logger ?? consoleLogger;
1718
+ const dispatcher = createDispatcher(options);
1719
+ const app = (0, express_1.default)();
1720
+ // Keep the raw bytes: the HMAC covers exactly what salt-api sent, and
1721
+ // re-serialising the parsed object would digest different bytes (key order,
1722
+ // whitespace, number formatting) and never match.
1723
+ app.use(express_1.default.json({
1724
+ verify: (req, _res, buf) => {
1725
+ req.rawBody = buf.toString("utf8");
1726
+ },
1727
+ }));
1728
+ app.get("/health", (_req, res) => {
1729
+ res.json({
1730
+ status: "ok",
1731
+ identities: options.identities.all().map((i) => ({ salt_app_id: i.saltAppId, username: i.username })),
1732
+ ...(options.healthExtra ? options.healthExtra() : {}),
1733
+ });
1734
+ });
444
1735
  // 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.
1736
+ // event body for card/invoice/chat_opened/handoff). Ack immediately (200)
1737
+ // and process in the background so a slow agent-loop call can't make
1738
+ // Salt's webhook delivery time out.
448
1739
  //
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.
1740
+ // When this server is publicly reachable, anyone can POST here. Encrypted-
1741
+ // message webhooks fail safe (forged ciphertext never decrypts), but
1742
+ // card_interaction/invoice_paid/chat_opened payloads are PLAINTEXT and
1743
+ // would be acted on -- so every POST must carry a valid HMAC signed with
1744
+ // the RECIPIENT identity's own key (see Dispatcher#verifyEnvelope above).
455
1745
  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}`));
1746
+ const rawBody = req.rawBody ?? "";
1747
+ // Verify BEFORE acknowledging: an unsigned POST must never reach dispatch,
1748
+ // because card_interaction/invoice_paid/chat_opened are plaintext and
1749
+ // actionable.
1750
+ dispatcher
1751
+ .verifyEnvelope(req.get("X-Salt-Agent-Id") || undefined, req.get("X-Salt-Signature") || undefined, rawBody)
1752
+ .then((result) => {
1753
+ if (!result.ok) {
1754
+ logger.error(`[webhook] rejected: ${result.reason}`);
1755
+ res.status(401).json({ error: "invalid signature" });
1756
+ return;
1757
+ }
1758
+ res.status(200).json({ status: "accepted" });
1759
+ const headerAgentId = req.get("X-Salt-Agent-Id") || undefined;
1760
+ dispatcher.dispatch(req.body, headerAgentId).catch((err) => logger.error(`[webhook] unhandled error: ${err.message}`));
1761
+ })
1762
+ .catch((err) => {
1763
+ logger.error(`[webhook] verification failed: ${err.message}`);
1764
+ res.status(401).json({ error: "invalid signature" });
1765
+ });
462
1766
  });
463
1767
  return {
464
1768
  app,