@sentry/junior 0.237.0 → 0.238.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/{context-0Et9iVSB.js → actor-BTtLaeWy.js} +1 -96
  2. package/dist/admin-leAJ2k0P.js +33 -0
  3. package/dist/{agent-hooks-otHGbhBx.js → agent-hooks-p3UA9FI1.js} +1423 -2973
  4. package/dist/{agent-runner-DMAsnPb9.js → agent-runner-DjQSEVuD.js} +171 -139
  5. package/dist/api/schema.d.ts +4 -0
  6. package/dist/api/schema.js +3 -2
  7. package/dist/api.d.ts +1 -1
  8. package/dist/api.js +31 -24
  9. package/dist/{app-MEd7wbF0.d.ts → app-FB2hmHpZ.d.ts} +1 -1
  10. package/dist/app.d.ts +1 -1
  11. package/dist/app.js +26 -22
  12. package/dist/{briefs-DDA1ugXk.js → briefs-mzUaJkBX.js} +4 -4
  13. package/dist/{catalog-runtime-DlXuhrmk.js → catalog-runtime-xq-njrMs.js} +1 -1
  14. package/dist/{checkpoint-BlIIiHG5.js → checkpoint-C37Xgtj-.js} +19 -10
  15. package/dist/cli/chat.js +13 -13
  16. package/dist/cli/check.js +3 -3
  17. package/dist/cli/main.js +6 -2
  18. package/dist/cli/plugins.js +5 -3
  19. package/dist/cli/run.d.ts +2 -1
  20. package/dist/cli/run.js +2 -1
  21. package/dist/cli/snapshot-create.js +3 -3
  22. package/dist/cli/upgrade.d.ts +1 -1
  23. package/dist/cli/upgrade.js +3 -3
  24. package/dist/{client-CPMbTmJU.js → client-BZdqKg6S.js} +2 -2
  25. package/dist/{config-CxpPG6nR.js → config-CGK_VplU.js} +2 -2
  26. package/dist/{conversation-9Fxa1Zbq.js → conversation-DwD-vquD.js} +3 -517
  27. package/dist/{conversation-privacy-Dbkdvozu.js → conversation-privacy-Ci3QLNOb.js} +1 -1
  28. package/dist/{credential-sync-D9yGg3q5.js → credential-sync-BBeYpK4k.js} +15 -14
  29. package/dist/{db-Ca9Nt5ci.d.ts → db-CWZP77Ex.d.ts} +1 -1
  30. package/dist/{executor-BNxfDS1V.js → executor-DmIad4GI.js} +4 -2
  31. package/dist/experimental-Qn9rKLgx.js +50 -0
  32. package/dist/history-BLTUgFYO.js +518 -0
  33. package/dist/{images-D6Y3FJDQ.js → images-1xUjTHGf.js} +3 -3
  34. package/dist/instrumentation.js +2 -2
  35. package/dist/{job-queue-Dq5_zt_l.js → job-queue-CXpx6KfD.js} +1 -1
  36. package/dist/{logging-rB_ngE_R.js → logging-Bne7JUXI.js} +1 -1
  37. package/dist/nitro.d.ts +1 -1
  38. package/dist/nitro.js +3 -3
  39. package/dist/{oauth-authorization-5T1S1v_1.js → oauth-authorization-O8N3knp9.js} +1 -1
  40. package/dist/{oauth-callback-server-Csw7RssK.js → oauth-callback-server-Cb-HPZPS.js} +2 -2
  41. package/dist/pending-auth-C6w4Gwve.js +433 -0
  42. package/dist/{profile-D6apIG2N.js → profile-DIcHBAxe.js} +5 -7
  43. package/dist/{prompt-Ctwy6hSI.js → prompt-CMU-B4FA.js} +1 -1
  44. package/dist/{registry-zfTzTHe6.js → registry-VSrEYeaD.js} +111 -3
  45. package/dist/{resolve-sJtTzuBO.js → resolve-BkpWmZgN.js} +2 -2
  46. package/dist/{runner-DkCk8cbc.js → runner-52ezCnZF.js} +10 -10
  47. package/dist/{sandbox-egress-signals-tSQ8OEcw.js → sandbox-egress-signals-Cmhf3P6Y.js} +1 -1
  48. package/dist/{schema-C5bgOpRg.js → schema-CxWAN2zq.js} +1 -1
  49. package/dist/{skills-BVpr_Gnv.js → skills-PHvy-ZgH.js} +2 -2
  50. package/dist/{spawn-CJhmyE4E.js → spawn-CY34_HCm.js} +2 -2
  51. package/dist/{store-DoewL5mG.d.ts → store-BxxbxBlm.d.ts} +7 -0
  52. package/dist/{task-queue-BAiZ7_CF.js → task-queue-B9uJqkal.js} +3 -3
  53. package/dist/{task-runner-Bh8XONXT.js → task-runner-BZUoctJ1.js} +20 -10
  54. package/dist/{thread-state-BnQln4jO.js → thread-state-SnFcB9Rk.js} +6 -5
  55. package/dist/{turn-failure-response-Ba7_kItn.js → turn-failure-response-CrSc1Vtn.js} +10 -9
  56. package/dist/{turn-lifecycle-D6CTbkb9.js → turn-lifecycle-D49hJbjm.js} +1 -1
  57. package/dist/{turn-session-routing-DNevs9H7.js → turn-session-routing-cMvumCLK.js} +1 -1
  58. package/dist/{validation-BNPhuAvt.js → validation-DsLyiOfn.js} +1 -1
  59. package/dist/{version-EQ4MWuUJ.js → version-Dayi_ng2.js} +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/viewer-DF3HTfW5.js +1636 -0
  62. package/dist/{web-authorization-DDt4FZld.js → web-authorization-Dg_R1tMc.js} +6 -6
  63. package/dist/{web-input-C56ivZuH.js → web-input-qVi8134S.js} +63 -433
  64. package/dist/{work-DJ7hb9Ul.js → work-BbcghZZp.js} +9 -10
  65. package/dist/{worker-DiiV8Lba.js → worker-B-uRQzKw.js} +5 -5
  66. package/migrations/0048_user_admin.sql +1 -0
  67. package/migrations/meta/0048_snapshot.json +3720 -0
  68. package/migrations/meta/_journal.json +7 -0
  69. package/package.json +5 -5
  70. package/dist/pending-auth-moCA2mTc.js +0 -59
@@ -1,2138 +1,625 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-B4FMCO8f.js";
2
- import { a as embedTexts, r as completeObject } from "./client-CPMbTmJU.js";
3
- import { F as normalizeIdentityEmail, I as isRecord$2, a as extractGenAiUsageSummary, d as logException, f as logInfo, p as logWarn, v as setSpanAttributes, w as withSpan, y as setSpanStatus } from "./logging-rB_ngE_R.js";
2
+ import { a as embedTexts, r as completeObject } from "./client-BZdqKg6S.js";
3
+ import { I as isRecord$1, a as extractGenAiUsageSummary, d as logException, f as logInfo, p as logWarn, v as setSpanAttributes, w as withSpan, y as setSpanStatus } from "./logging-Bne7JUXI.js";
4
4
  import { a as getClient$1 } from "./sentry-CgWZM_kV.js";
5
- import { t as canExposeConversationPayload } from "./conversation-privacy-Dbkdvozu.js";
6
- import { i as getChatConfig, n as botConfig, o as getSlackBotToken } from "./config-CxpPG6nR.js";
5
+ import { t as canExposeConversationPayload } from "./conversation-privacy-Ci3QLNOb.js";
6
+ import { i as getChatConfig, n as botConfig, o as getSlackBotToken } from "./config-CGK_VplU.js";
7
7
  import { r as defaultModelId } from "./model-profile-BwU8mqwD.js";
8
- import { b as parseSlackMessageTs, c as isActorUserId, h as parseSlackTeamId, m as parseSlackChannelReferenceId, u as parseActorUserId } from "./context-0Et9iVSB.js";
8
+ import { d as parseSlackTeamId, g as parseSlackMessageTs, i as isActorUserId, o as parseActorUserId, u as parseSlackChannelReferenceId } from "./actor-BTtLaeWy.js";
9
+ import { c as getConversationEventStore, d as getDb, f as getSqlExecutor, h as createSqlStore, i as resolveViewerUser, r as readActorIdentity, w as hasAgentTurnUsage } from "./viewer-DF3HTfW5.js";
9
10
  import { n as juniorDestinations } from "./destinations-BeBiJeEM.js";
10
- import { C as juniorCodeChanges, D as juniorConversations, E as conversationReadColumns, O as juniorIdentities, S as juniorConversationAnnotations, T as juniorAttachments, _ as juniorConversationBriefs, b as juniorConversationEvents, g as juniorConversationBindings, h as juniorConversationParticipants, i as juniorWorkspaces, k as juniorUsers, n as juniorSnapshots, r as juniorWorkspaceRepos, t as createJuniorSqlExecutor, v as CONVERSATION_METRICS, w as juniorCodeRepositories, y as juniorConversationMetrics } from "./executor-BNxfDS1V.js";
11
- import { $ as historyReplacementSchema, B as parseBriefInput, C as conversationReportEventSchema, N as readMessageAttachments, Q as decodeStoredConversationEvent, W as readMessageCardRefs, Y as conversationEventDataSchema, et as newConversationEventSchema, ot as usageSchema, tt as encodeHistoryPayload, z as conversationBriefSchema } from "./conversation-9Fxa1Zbq.js";
12
- import { C as getDefaultRedisStateAdapterFor, N as SANDBOX_WORKSPACE_ROOT, k as sleep$1, l as isSandboxMissingError, n as hash, v as getVercelSandboxCredentials, w as getStateAdapter, x as runNonInteractiveCommand } from "./profile-D6apIG2N.js";
13
- import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-Ctwy6hSI.js";
11
+ import { C as juniorCodeChanges, D as juniorConversations, O as juniorIdentities, S as juniorConversationAnnotations, T as juniorAttachments, _ as juniorConversationBriefs, b as juniorConversationEvents, h as juniorConversationParticipants, i as juniorWorkspaces, k as juniorUsers, n as juniorSnapshots, r as juniorWorkspaceRepos, w as juniorCodeRepositories } from "./executor-DmIad4GI.js";
12
+ import { o as decodeStoredConversationEvent } from "./history-BLTUgFYO.js";
13
+ import { p as credentialSubjectSchema } from "./registry-VSrEYeaD.js";
14
+ import { B as parseBriefInput, C as conversationReportEventSchema, N as readMessageAttachments, W as readMessageCardRefs, z as conversationBriefSchema } from "./conversation-DwD-vquD.js";
15
+ import { C as getDefaultRedisStateAdapterFor, N as SANDBOX_WORKSPACE_ROOT, k as sleep$1, l as isSandboxMissingError, n as hash, v as getVercelSandboxCredentials, w as getStateAdapter, x as runNonInteractiveCommand } from "./profile-DIcHBAxe.js";
16
+ import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-CMU-B4FA.js";
14
17
  import { AsyncLocalStorage } from "node:async_hooks";
15
18
  import { z } from "zod";
16
19
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
17
- import { and, arrayContains, asc, desc, eq, exists, getTableColumns, gt, gte, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
18
- import { codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, destinationSchema, eventInputSchema, eventMatchSchema, eventMatches, eventTypeSchema, identitySchema, locationSchema, missingToolAnnotationKeys, normalizeEventIdentifier, objectAnnotationSchema, objectIconPath, objectTypeSchema, pluginEventsSchema, promptContextSchema, promptMessageSchema, sourceSchema, stableEventMatchKey, userSchema } from "@sentry/junior-plugin-api";
20
+ import { and, arrayContains, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
21
+ import { codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, eventInputSchema, eventMatchSchema, eventMatches, eventTypeSchema, missingToolAnnotationKeys, normalizeEventIdentifier, objectAnnotationSchema, objectIconPath, objectTypeSchema, pluginEventsSchema, promptContextSchema, promptMessageSchema, stableEventMatchKey } from "@sentry/junior-plugin-api";
19
22
  import { alias } from "drizzle-orm/pg-core";
20
23
  import { WebClient } from "@slack/web-api";
21
24
  import { Sandbox } from "@vercel/sandbox";
22
- //#region src/chat/destination.ts
23
- /** Build Junior's canonical destination from Slack workspace and channel ids. */
24
- function createSlackDestination(input) {
25
- const channelId = parseSlackChannelReferenceId(input.channelId);
26
- const teamId = parseSlackTeamId(input.teamId);
27
- if (!channelId || !teamId) return;
28
- return {
29
- platform: "slack",
30
- teamId,
31
- channelId
32
- };
33
- }
34
- /** Parse and validate a serialized destination that crossed a runtime boundary. */
35
- function parseDestination(value) {
36
- const parsed = destinationSchema.safeParse(value);
37
- return parsed.success ? parsed.data : void 0;
38
- }
39
- /** Require a Slack destination at a Slack-only runtime boundary. */
40
- function requireSlackDestination(destination, action) {
41
- if (destination?.platform === "slack") return destination;
42
- throw new Error(`${action} requires a Slack destination`);
43
- }
44
- /** Compare two destinations without relying on object identity. */
45
- function sameDestination(left, right) {
46
- if (left.platform !== right.platform) return false;
47
- if (left.platform === "local" && right.platform === "local") return left.conversationId === right.conversationId;
48
- if (left.platform === "slack" && right.platform === "slack") return left.teamId === right.teamId && left.channelId === right.channelId;
49
- return false;
50
- }
51
- //#endregion
52
- //#region src/chat/identities/sql.ts
53
- function dateFromMs$1(ms) {
54
- return new Date(ms);
55
- }
56
- function tenantId$1(value) {
57
- return value ?? "";
58
- }
59
- async function upsertUser(executor, args) {
60
- const id = (await executor.db().insert(juniorUsers).values({
61
- id: randomUUID(),
62
- primaryEmail: args.email,
63
- primaryEmailNormalized: args.emailNormalized,
64
- displayName: args.displayName ?? null,
65
- createdAt: dateFromMs$1(args.nowMs),
66
- updatedAt: dateFromMs$1(args.nowMs)
67
- }).onConflictDoUpdate({
68
- target: juniorUsers.primaryEmailNormalized,
69
- set: {
70
- displayName: sql`coalesce(${juniorUsers.displayName}, excluded.display_name)`,
71
- updatedAt: sql`excluded.updated_at`
72
- }
73
- }).returning({ id: juniorUsers.id }))[0]?.id;
74
- if (!id) throw new Error("User identity upsert returned no row");
75
- return id;
76
- }
77
- async function existingIdentity(executor, identity) {
78
- return (await executor.db().select().from(juniorIdentities).where(and(eq(juniorIdentities.provider, identity.provider), eq(juniorIdentities.providerTenantId, tenantId$1(identity.providerTenantId)), eq(juniorIdentities.providerSubjectId, identity.providerSubjectId))))[0];
79
- }
80
- async function upsertIdentityRecord(executor, identity, linkedUserId, nowMs) {
81
- const emailNormalized = normalizeIdentityEmail(identity.email);
82
- const email = emailNormalized ? identity.email?.trim() || emailNormalized : void 0;
83
- const existing = await existingIdentity(executor, identity);
84
- const userEmailNormalized = existing?.emailVerified && existing.emailNormalized ? existing.emailNormalized : identity.emailVerified ? emailNormalized : void 0;
85
- const userEmail = existing?.emailVerified && existing.email ? existing.email : email ?? userEmailNormalized;
86
- const verifiedUserId = identity.kind === "user" && userEmailNormalized ? await upsertUser(executor, {
87
- email: userEmail ?? userEmailNormalized,
88
- emailNormalized: userEmailNormalized,
89
- nowMs,
90
- ...existing?.displayName || identity.displayName ? { displayName: existing?.displayName ?? identity.displayName } : void 0
91
- }) : void 0;
92
- if (existing?.userId && verifiedUserId && existing.userId !== verifiedUserId) throw new Error("Identity verified email conflicts with linked user");
93
- if (linkedUserId && verifiedUserId && linkedUserId !== verifiedUserId) throw new Error("Linked identity conflicts with verified email user");
94
- if (existing?.userId && linkedUserId && existing.userId !== linkedUserId) throw new Error("Identity conflicts with linked user");
95
- const userId = existing?.userId ?? linkedUserId ?? verifiedUserId;
96
- const row = (await executor.db().insert(juniorIdentities).values({
97
- id: randomUUID(),
98
- userId: userId ?? null,
99
- kind: identity.kind,
100
- provider: identity.provider,
101
- providerTenantId: tenantId$1(identity.providerTenantId),
102
- providerSubjectId: identity.providerSubjectId,
103
- displayName: identity.displayName ?? null,
104
- handle: identity.handle ?? null,
105
- email: email ?? null,
106
- emailNormalized: emailNormalized ?? null,
107
- emailVerified: Boolean(identity.emailVerified && emailNormalized),
108
- avatarUrl: null,
109
- metadata: identity.metadata ?? null,
110
- createdAt: dateFromMs$1(nowMs),
111
- updatedAt: dateFromMs$1(nowMs)
112
- }).onConflictDoUpdate({
113
- target: [
114
- juniorIdentities.provider,
115
- juniorIdentities.providerTenantId,
116
- juniorIdentities.providerSubjectId
117
- ],
118
- set: {
119
- kind: sql`excluded.kind`,
120
- userId: sql`coalesce(${juniorIdentities.userId}, excluded.user_id)`,
121
- displayName: linkedUserId ? sql`coalesce(excluded.display_name, ${juniorIdentities.displayName})` : sql`coalesce(${juniorIdentities.displayName}, excluded.display_name)`,
122
- handle: linkedUserId ? sql`coalesce(excluded.handle, ${juniorIdentities.handle})` : sql`coalesce(${juniorIdentities.handle}, excluded.handle)`,
123
- email: sql`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.email}, excluded.email) when excluded.email_verified then excluded.email else coalesce(${juniorIdentities.email}, excluded.email) end`,
124
- emailNormalized: sql`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) when excluded.email_verified then excluded.email_normalized else coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) end`,
125
- emailVerified: sql`${juniorIdentities.emailVerified} OR excluded.email_verified`,
126
- avatarUrl: sql`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`,
127
- metadata: sql`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`,
128
- updatedAt: sql`excluded.updated_at`
129
- },
130
- ...linkedUserId ? { setWhere: sql`${juniorIdentities.userId} IS NULL OR ${juniorIdentities.userId} = excluded.user_id` } : void 0
131
- }).returning({
132
- id: juniorIdentities.id,
133
- userId: juniorIdentities.userId
134
- }))[0];
135
- if (!row) {
136
- if (linkedUserId) throw new Error("Identity conflicts with linked user");
137
- throw new Error("Identity upsert returned no row");
138
- }
139
- return {
140
- id: row.id,
141
- ...row.userId ? { userId: row.userId } : void 0
142
- };
143
- }
144
- /** Read the saved timezone for one user. */
145
- async function readUserTimezone(db, userId) {
146
- return (await db.select({ timezone: juniorUsers.timezone }).from(juniorUsers).where(eq(juniorUsers.id, userId)).limit(1))[0]?.timezone ?? void 0;
147
- }
148
- /** Save the IANA timezone for one user. */
149
- async function saveUserTimezone(db, userId, timezone, nowMs) {
150
- await db.update(juniorUsers).set({
151
- timezone,
152
- updatedAt: dateFromMs$1(nowMs)
153
- }).where(and(eq(juniorUsers.id, userId), sql`${juniorUsers.timezone} is distinct from ${timezone}`));
154
- }
155
- /** Persist one provider identity observation and link verified emails to users. */
156
- async function upsertIdentity(executor, identity, nowMs = Date.now()) {
157
- return await upsertIdentityRecord(executor, identity, void 0, nowMs);
158
- }
159
- /**
160
- * Persist one provider identity after a trusted account-linking flow.
161
- * Callers must use provider-verified account data, such as GitHub's authenticated
162
- * user response, never identity claims inferred from content such as Git commits.
163
- */
164
- async function upsertLinkedIdentity(executor, userId, identity, nowMs = Date.now()) {
165
- return await upsertIdentityRecord(executor, identity, userId, nowMs);
166
- }
167
- /** Remove one exact provider identity owned by a workspace Slack user. */
168
- async function deleteProviderIdentityForSlackUser(executor, slackTeamId, slackUserId, provider, providerSubjectId) {
169
- const userId = (await executor.db().select({ userId: juniorIdentities.userId }).from(juniorIdentities).where(and(eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, "slack"), eq(juniorIdentities.providerTenantId, slackTeamId), eq(juniorIdentities.providerSubjectId, slackUserId))).limit(1))[0]?.userId;
170
- if (!userId) return;
171
- await executor.db().delete(juniorIdentities).where(and(eq(juniorIdentities.userId, userId), eq(juniorIdentities.provider, provider), eq(juniorIdentities.providerTenantId, ""), eq(juniorIdentities.providerSubjectId, providerSubjectId)));
172
- }
173
- //#endregion
174
- //#region src/chat/conversations/sql/participants.ts
175
- /** Resolve the root conversation id that owns personal-feed membership. */
176
- async function resolveRootConversationId(executor, conversationId) {
177
- const [row] = await executor.db().select({ rootConversationId: juniorConversations.rootConversationId }).from(juniorConversations).where(eq(juniorConversations.conversationId, conversationId)).limit(1);
178
- return row?.rootConversationId ?? conversationId;
179
- }
180
- /**
181
- * Upsert one linked user as a participant on a root conversation.
182
- * No-ops when the identity is missing, unlinked, or not a user.
183
- */
184
- async function recordConversationParticipant(executor, args) {
185
- const [identity] = await executor.db().select({
186
- kind: juniorIdentities.kind,
187
- userId: juniorIdentities.userId
188
- }).from(juniorIdentities).where(eq(juniorIdentities.id, args.actorIdentityId)).limit(1);
189
- if (!identity?.userId || identity.kind !== "user") return;
190
- const rootConversationId = await resolveRootConversationId(executor, args.conversationId);
191
- const at = new Date(args.atMs);
192
- await executor.db().insert(juniorConversationParticipants).values({
193
- userId: identity.userId,
194
- rootConversationId,
195
- lastMessageAt: at
196
- }).onConflictDoUpdate({
197
- target: [juniorConversationParticipants.userId, juniorConversationParticipants.rootConversationId],
198
- set: {
199
- lastMessageAt: sql`greatest(${juniorConversationParticipants.lastMessageAt}, excluded.last_message_at)`,
200
- ...args.restoreArchive ? { archivedAt: null } : void 0
201
- }
202
- });
25
+ //#region src/chat/conversations/structured-events.ts
26
+ /** Reserved namespace for Junior-owned conversation metadata events. */
27
+ const JUNIOR_NATIVE_EVENT_NAMESPACE = "junior";
28
+ const authenticationEventContentSchema = z.object({
29
+ accountLabel: z.string().trim().min(1).max(120).optional(),
30
+ actorId: z.string().min(1),
31
+ authorizationId: z.string().min(1).optional(),
32
+ provider: z.string().regex(/^[a-z][a-z0-9-]*$/),
33
+ providerLabel: z.string().trim().min(1).max(80).optional()
34
+ }).strict();
35
+ const agentsInstructionsUpdatedContentSchema = z.object({
36
+ action: z.enum([
37
+ "loaded",
38
+ "replaced",
39
+ "cleared"
40
+ ]),
41
+ directory: z.string().trim().min(1).max(500).optional(),
42
+ fingerprint: z.string().trim().min(1).max(128),
43
+ sources: z.array(z.object({
44
+ content: z.string().min(1).max(32768),
45
+ path: z.string().trim().min(1).max(500)
46
+ }).strict()).max(32),
47
+ textBytes: z.number().int().nonnegative().optional()
48
+ }).strict();
49
+ function providerTitle(content) {
50
+ return content.providerLabel?.trim() || content.provider;
203
51
  }
204
- //#endregion
205
- //#region src/chat/usage.ts
206
- const agentTurnUsageSchema = usageSchema;
207
- const COMPONENT_USAGE_FIELDS = [
208
- "inputTokens",
209
- "outputTokens",
210
- "cachedInputTokens",
211
- "cacheCreationTokens"
212
- ];
213
- const COST_COMPONENT_FIELDS = [
214
- "input",
215
- "output",
216
- "cacheRead",
217
- "cacheWrite"
218
- ];
219
- /** Return whether any token counter is present on a usage record. */
220
- function hasAgentTurnUsage(usage) {
221
- return Boolean(usage && (Object.entries(usage).some(([field, value]) => field !== "cost" && typeof value === "number" && Number.isFinite(value)) || Object.values(usage.cost ?? {}).some((value) => typeof value === "number" && Number.isFinite(value))));
52
+ function agentsInstructionsTitle(action) {
53
+ if (action === "loaded") return "Loaded AGENTS.md";
54
+ if (action === "replaced") return "Updated AGENTS.md";
55
+ return "Cleared AGENTS.md";
222
56
  }
223
- function getFiniteCount(value) {
224
- return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : void 0;
57
+ function sourceFilename(sourcePath) {
58
+ return sourcePath.split("/").at(-1) || sourcePath;
225
59
  }
226
- function getFiniteCost(value) {
227
- return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : void 0;
60
+ function formatBytes(bytes) {
61
+ if (bytes < 1024) return `${bytes} bytes`;
62
+ const kilobytes = bytes / 1024;
63
+ return `${Number.isInteger(kilobytes) ? kilobytes : kilobytes.toFixed(1)} KB`;
228
64
  }
229
- function addCost(left, right) {
230
- return Math.round(((left ?? 0) + right) * 0xe8d4a51000) / 0xe8d4a51000;
65
+ function agentsInstructionsPreview(content) {
66
+ const source = content.sources[0];
67
+ if (!source) return void 0;
68
+ const filename = sourceFilename(source.path);
69
+ const label = content.sources.length === 1 ? filename : `${content.sources.length} AGENTS.md files`;
70
+ return typeof content.textBytes === "number" ? `${label} · ${formatBytes(content.textBytes)}` : label;
231
71
  }
232
- function getComponentTotal(usage) {
233
- let total;
234
- for (const field of COMPONENT_USAGE_FIELDS) {
235
- const value = getFiniteCount(usage[field]);
236
- if (value === void 0) continue;
237
- total = (total ?? 0) + value;
72
+ /** Junior-owned account-link transcript event. */
73
+ const authenticationLinkedEvent = defineConversationEvent({
74
+ name: "authentication_linked",
75
+ version: 1,
76
+ schema: authenticationEventContentSchema,
77
+ renderEvent(event) {
78
+ const provider = providerTitle(event);
79
+ return {
80
+ icon: "link",
81
+ title: `${provider} connected`,
82
+ ...event.accountLabel ? { preview: `Connected as \`${event.accountLabel}\`` } : void 0,
83
+ details: [{
84
+ title: `${provider} connected`,
85
+ ...event.accountLabel ? { description: `Connected as \`${event.accountLabel}\`` } : void 0,
86
+ metadata: [event.provider]
87
+ }]
88
+ };
238
89
  }
239
- return total;
240
- }
241
- /** Estimated total USD cost from one usage record, when any cost field is present. */
242
- function agentTurnCostUsd(usage) {
243
- if (!usage?.cost) return void 0;
244
- const total = getFiniteCost(usage.cost.total);
245
- if (total !== void 0) return total;
246
- let componentTotal = 0;
247
- let hasComponent = false;
248
- for (const field of COST_COMPONENT_FIELDS) {
249
- const value = getFiniteCost(usage.cost[field]);
250
- if (value === void 0) continue;
251
- hasComponent = true;
252
- componentTotal = addCost(componentTotal, value);
90
+ });
91
+ /** Junior-owned account-unlink transcript event. */
92
+ const authenticationUnlinkedEvent = defineConversationEvent({
93
+ name: "authentication_unlinked",
94
+ version: 1,
95
+ schema: authenticationEventContentSchema,
96
+ renderEvent(event) {
97
+ const provider = providerTitle(event);
98
+ return {
99
+ icon: "key",
100
+ title: `${provider} disconnected`,
101
+ details: [{
102
+ title: `${provider} disconnected`,
103
+ metadata: [event.provider]
104
+ }]
105
+ };
253
106
  }
254
- return hasComponent ? componentTotal : void 0;
255
- }
256
- /** Total token count from one usage record, preferring component counters. */
257
- function agentTurnTotalTokens(usage) {
258
- if (!usage) return void 0;
259
- const componentTotal = getComponentTotal(usage);
260
- if (componentTotal !== void 0) return componentTotal;
261
- return getFiniteCount(usage.totalTokens);
262
- }
263
- /** Aggregate token usage across slices without double-counting provider totals. */
264
- function addAgentTurnUsage(...usages) {
265
- const components = {};
266
- let componentTotal;
267
- let totalOnlyTokens;
268
- let reasoningTokens;
269
- const cost = {};
270
- for (const usage of usages) {
271
- if (!usage) continue;
272
- const reasoning = getFiniteCount(usage.reasoningTokens);
273
- if (reasoning !== void 0) reasoningTokens = (reasoningTokens ?? 0) + reasoning;
274
- if (usage.cost) for (const field of [...COST_COMPONENT_FIELDS, "total"]) {
275
- const value = getFiniteCost(usage.cost[field]);
276
- if (value === void 0) continue;
277
- cost[field] = addCost(cost[field], value);
278
- }
279
- const usageComponentTotal = getComponentTotal(usage);
280
- if (usageComponentTotal !== void 0) {
281
- componentTotal = (componentTotal ?? 0) + usageComponentTotal;
282
- for (const field of COMPONENT_USAGE_FIELDS) {
283
- const value = getFiniteCount(usage[field]);
284
- if (value === void 0) continue;
285
- components[field] = (components[field] ?? 0) + value;
286
- }
287
- continue;
288
- }
289
- const totalTokens = getFiniteCount(usage.totalTokens);
290
- if (totalTokens !== void 0) totalOnlyTokens = (totalOnlyTokens ?? 0) + totalTokens;
107
+ });
108
+ /** Junior-owned AGENTS.md bootstrap transition for the dashboard timeline. */
109
+ const agentsInstructionsUpdatedEvent = defineConversationEvent({
110
+ name: "agents_instructions_updated",
111
+ version: 1,
112
+ schema: agentsInstructionsUpdatedContentSchema,
113
+ renderEvent(event) {
114
+ const title = agentsInstructionsTitle(event.action);
115
+ const preview = agentsInstructionsPreview(event);
116
+ return {
117
+ icon: "brain",
118
+ title,
119
+ ...preview ? { preview } : void 0,
120
+ ...event.sources.length > 0 ? { details: event.sources.map((source) => ({
121
+ title: sourceFilename(source.path),
122
+ content: source.content
123
+ })) } : void 0
124
+ };
291
125
  }
292
- if (totalOnlyTokens !== void 0) return {
293
- totalTokens: totalOnlyTokens + (componentTotal ?? 0),
294
- ...reasoningTokens !== void 0 ? { reasoningTokens } : void 0,
295
- ...Object.keys(cost).length > 0 ? { cost } : void 0
296
- };
297
- const result = {
298
- ...components,
299
- ...reasoningTokens !== void 0 ? { reasoningTokens } : void 0,
300
- ...Object.keys(cost).length > 0 ? { cost } : void 0
301
- };
302
- return hasAgentTurnUsage(result) ? result : void 0;
303
- }
304
- //#endregion
305
- //#region src/chat/conversations/metrics.ts
306
- function finite(value) {
307
- return value !== void 0 && Number.isFinite(value) && value >= 0 ? value : void 0;
308
- }
309
- /** Convert one Run summary to metric rows. */
310
- function conversationMetricValues(args) {
311
- const usage = args.usage;
312
- return {
313
- duration_ms: Math.max(0, args.durationMs),
314
- total_tokens: agentTurnTotalTokens(usage),
315
- input_tokens: finite(usage?.inputTokens),
316
- output_tokens: finite(usage?.outputTokens),
317
- cached_input_tokens: finite(usage?.cachedInputTokens),
318
- cache_creation_tokens: finite(usage?.cacheCreationTokens),
319
- reasoning_tokens: finite(usage?.reasoningTokens),
320
- cost_usd: agentTurnCostUsd(usage)
321
- };
322
- }
323
- /** Replace one Run's metric rows and refresh its Conversation totals. */
324
- async function replaceConversationMetrics(executor, args) {
325
- const [current] = await executor.db().select({ updatedAt: juniorConversationMetrics.updatedAt }).from(juniorConversationMetrics).where(and(eq(juniorConversationMetrics.conversationId, args.conversationId), eq(juniorConversationMetrics.runId, args.runId))).limit(1);
326
- if (current && current.updatedAt.getTime() > args.updatedAtMs) return;
327
- await executor.db().delete(juniorConversationMetrics).where(and(eq(juniorConversationMetrics.conversationId, args.conversationId), eq(juniorConversationMetrics.runId, args.runId)));
328
- const metricValues = conversationMetricValues({
329
- durationMs: args.durationMs,
330
- usage: args.usage
331
- });
332
- const values = CONVERSATION_METRICS.flatMap((metric) => {
333
- const value = metricValues[metric];
334
- return value === void 0 ? [] : [{
335
- conversationId: args.conversationId,
336
- runId: args.runId,
337
- metric,
338
- value,
339
- occurredAt: new Date(args.occurredAtMs),
340
- updatedAt: new Date(args.updatedAtMs)
341
- }];
342
- });
343
- if (values.length > 0) await executor.db().insert(juniorConversationMetrics).values(values);
344
- const metric = (name) => sql`(
345
- select sum(${juniorConversationMetrics.value})
346
- from ${juniorConversationMetrics}
347
- where ${juniorConversationMetrics.conversationId} = ${args.conversationId}
348
- and ${juniorConversationMetrics.metric} = ${name}
349
- )`;
350
- const totalTokens = metric("total_tokens");
351
- const componentTokens = sql`
352
- coalesce(${metric("input_tokens")}, 0)
353
- + coalesce(${metric("output_tokens")}, 0)
354
- + coalesce(${metric("cached_input_tokens")}, 0)
355
- + coalesce(${metric("cache_creation_tokens")}, 0)
356
- `;
357
- const hasComponents = sql`
358
- ${metric("input_tokens")} is not null
359
- or ${metric("output_tokens")} is not null
360
- or ${metric("cached_input_tokens")} is not null
361
- or ${metric("cache_creation_tokens")} is not null
362
- `;
363
- const cost = sql`case when ${metric("cost_usd")} is null then null
364
- else jsonb_build_object('total', ${metric("cost_usd")}) end`;
365
- await executor.db().update(juniorConversations).set({
366
- durationMs: sql`coalesce(${metric("duration_ms")}, 0)`,
367
- usage: sql`case
368
- when ${totalTokens} is not null
369
- and (${hasComponents})
370
- and ${totalTokens} <> ${componentTokens}
371
- then jsonb_strip_nulls(jsonb_build_object(
372
- 'totalTokens', ${totalTokens},
373
- 'reasoningTokens', ${metric("reasoning_tokens")},
374
- 'cost', ${cost}
375
- ))
376
- else jsonb_strip_nulls(jsonb_build_object(
377
- 'totalTokens', ${totalTokens},
378
- 'inputTokens', ${metric("input_tokens")},
379
- 'outputTokens', ${metric("output_tokens")},
380
- 'cachedInputTokens', ${metric("cached_input_tokens")},
381
- 'cacheCreationTokens', ${metric("cache_creation_tokens")},
382
- 'reasoningTokens', ${metric("reasoning_tokens")},
383
- 'cost', ${cost}
384
- ))
385
- end`
386
- }).where(eq(juniorConversations.conversationId, args.conversationId));
387
- }
388
- //#endregion
389
- //#region src/chat/source.ts
390
- /**
391
- * Normalize a Turn Source into legacy provider fields stored on a Conversation.
392
- * System Sources do not contain a Conversation Location.
393
- */
394
- function normalizeSessionSource(value) {
395
- if (!value) return;
396
- if (value.kind === "local") return {
397
- kind: "local",
398
- visibility: value.visibility,
399
- conversationId: value.conversationId
400
- };
401
- if (value.kind === "web") return {
402
- kind: "web",
403
- visibility: value.visibility,
404
- conversationId: value.conversationId
405
- };
406
- if (value.kind === "event" || value.kind === "scheduled_automation" || value.kind === "event_automation" || value.kind === "plugin_dispatch" || value.kind === "agent_invocation") return;
407
- const threadTs = value.threadTs?.trim();
408
- return {
409
- kind: "slack",
410
- visibility: value.visibility,
411
- teamId: value.teamId,
412
- channelId: value.channelId,
413
- ...threadTs ? { threadTs } : void 0
414
- };
415
- }
416
- /** Parse a serialized Source into legacy provider fields stored on a Conversation. */
417
- function parseSessionSource(value) {
418
- const parsed = sourceSchema.safeParse(value);
419
- return parsed.success ? normalizeSessionSource(parsed.data) : void 0;
126
+ });
127
+ const NATIVE_EVENT_DEFINITIONS = [
128
+ authenticationLinkedEvent,
129
+ authenticationUnlinkedEvent,
130
+ agentsInstructionsUpdatedEvent
131
+ ];
132
+ /** Resolve a registered Junior-native conversation event definition. */
133
+ function getJuniorNativeEventDefinition(args) {
134
+ return NATIVE_EVENT_DEFINITIONS.find((definition) => definition.eventName === args.name && definition.version === args.version);
420
135
  }
421
- //#endregion
422
- //#region src/chat/conversations/sql/bindings.ts
423
- /** Resolve the durable conversation bound to provider-owned coordinates. */
424
- async function getConversationIdByProviderConversation(executor, args) {
425
- return (await executor.db().select({ conversationId: juniorConversationBindings.conversationId }).from(juniorConversationBindings).where(and(eq(juniorConversationBindings.provider, args.provider), eq(juniorConversationBindings.providerTenantId, args.providerTenantId), eq(juniorConversationBindings.providerDestinationId, args.providerDestinationId), eq(juniorConversationBindings.providerConversationId, args.providerConversationId))).limit(1))[0]?.conversationId;
426
- }
427
- /** Bind provider coordinates to a durable conversation in the caller's SQL scope. */
428
- async function bindProviderConversation(executor, args) {
429
- if (((await executor.db().insert(juniorConversationBindings).values({
430
- ...args,
431
- createdAt: /* @__PURE__ */ new Date()
432
- }).onConflictDoNothing().returning({ conversationId: juniorConversationBindings.conversationId }))[0]?.conversationId ?? await getConversationIdByProviderConversation(executor, args)) !== args.conversationId) throw new Error("Provider conversation is already bound to another conversation");
136
+ /** Render one Junior-native event through its active definition. */
137
+ function renderJuniorNativeConversationEvent(args) {
138
+ if (args.namespace !== "junior") return void 0;
139
+ const definition = getJuniorNativeEventDefinition(args);
140
+ if (!definition) return void 0;
141
+ const presentation = definition.renderEvent(definition.parse(args.content));
142
+ return presentation === void 0 ? void 0 : conversationEventPresentationSchema.parse(presentation);
433
143
  }
434
144
  //#endregion
435
- //#region src/chat/conversations/sql/actor-identity.ts
436
- /** Build a durable identity observation from a stored conversation actor. */
437
- function identityFromActor(actor) {
438
- if (actor?.slackUserId) return {
439
- kind: "user",
440
- provider: "slack",
441
- providerTenantId: actor.teamId,
442
- providerSubjectId: actor.slackUserId,
443
- ...actor.fullName ? { displayName: actor.fullName } : void 0,
444
- ...actor.slackUserName ? { handle: actor.slackUserName } : void 0,
445
- ...actor.email ? {
446
- email: actor.email,
447
- emailVerified: true
448
- } : void 0,
449
- metadata: { platform: "slack" }
450
- };
451
- const email = actor?.email?.trim().toLowerCase();
452
- if (!email) return;
145
+ //#region src/chat/plugins/annotations.ts
146
+ function annotationFromRow(row) {
147
+ const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
148
+ if (!parsed.success) return void 0;
453
149
  return {
454
- kind: "user",
455
- provider: "junior",
456
- providerSubjectId: email,
457
- email,
458
- emailVerified: true,
459
- ...actor?.fullName ? { displayName: actor.fullName } : void 0,
460
- metadata: { platform: "web" }
461
- };
462
- }
463
- /** Build a system identity when a conversation has no user actor. */
464
- function systemIdentityFromSource(source) {
465
- if (source === "scheduler") return {
466
- kind: "system",
467
- provider: "junior",
468
- providerSubjectId: "scheduler",
469
- displayName: "Junior Scheduler"
470
- };
471
- if (source === "local") return {
472
- kind: "system",
473
- provider: "junior",
474
- providerSubjectId: "local-cli",
475
- displayName: "Local CLI"
476
- };
477
- if (source === "event") return {
478
- kind: "system",
479
- provider: "junior",
480
- providerSubjectId: "event",
481
- displayName: "Event"
482
- };
483
- }
484
- /** Prefer the user actor identity, else a source-derived system identity. */
485
- function actorIdentityForConversation(conversation) {
486
- return identityFromActor(conversation.actor) ?? systemIdentityFromSource(conversation.source);
487
- }
488
- /**
489
- * Reconstruct a stored actor with the linked user name and identity-scoped
490
- * provider fields.
491
- */
492
- function actorFromIdentityRow(identity, userDisplayName) {
493
- if (!identity) return;
494
- const fullName = userDisplayName?.trim() ? userDisplayName : identity.displayName;
495
- const email = identity.emailNormalized ?? identity.email ?? void 0;
496
- if (identity.provider === "slack") return {
497
- ...email ? { email } : void 0,
498
- ...fullName ? { fullName } : void 0,
499
- platform: "slack",
500
- slackUserId: identity.providerSubjectId,
501
- ...identity.handle ? { slackUserName: identity.handle } : void 0,
502
- ...identity.providerTenantId ? { teamId: identity.providerTenantId } : void 0
503
- };
504
- if (identity.provider === "junior" && email) return {
505
- email,
506
- ...fullName ? { fullName } : void 0
150
+ ...parsed.data,
151
+ plugin: row.plugin,
152
+ createdAt: row.createdAt.toISOString(),
153
+ updatedAt: row.updatedAt.toISOString()
507
154
  };
508
155
  }
509
- /** Merge later actor observations without replacing a durable Slack subject. */
510
- function mergeActor(current, next) {
511
- if (!current) return next;
512
- if (!next) return current;
513
- if (current.slackUserId && next.slackUserId && current.slackUserId !== next.slackUserId) return current;
156
+ function createPluginAnnotations(args) {
514
157
  return {
515
- ...current,
516
- ...current.email ?? next.email ? { email: current.email ?? next.email } : void 0,
517
- ...current.fullName ?? next.fullName ? { fullName: current.fullName ?? next.fullName } : void 0,
518
- ...current.platform ?? next.platform ? { platform: current.platform ?? next.platform } : void 0,
519
- ...current.slackUserId ?? next.slackUserId ? { slackUserId: current.slackUserId ?? next.slackUserId } : void 0,
520
- ...current.slackUserName ?? next.slackUserName ? { slackUserName: current.slackUserName ?? next.slackUserName } : void 0,
521
- ...current.teamId ?? next.teamId ? { teamId: current.teamId ?? next.teamId } : void 0
158
+ async upsert(input) {
159
+ const annotation = conversationAnnotationInputSchema.parse(input);
160
+ const now = /* @__PURE__ */ new Date();
161
+ await args.db.insert(juniorConversationAnnotations).values({
162
+ annotation,
163
+ conversationId: args.conversationId,
164
+ createdAt: now,
165
+ key: annotation.key,
166
+ kind: annotation.kind,
167
+ plugin: args.plugin,
168
+ updatedAt: now
169
+ }).onConflictDoUpdate({
170
+ target: [
171
+ juniorConversationAnnotations.conversationId,
172
+ juniorConversationAnnotations.plugin,
173
+ juniorConversationAnnotations.kind,
174
+ juniorConversationAnnotations.key
175
+ ],
176
+ set: {
177
+ annotation,
178
+ updatedAt: now
179
+ }
180
+ });
181
+ },
182
+ async remove(kind, key) {
183
+ await args.db.delete(juniorConversationAnnotations).where(and(eq(juniorConversationAnnotations.conversationId, args.conversationId), eq(juniorConversationAnnotations.plugin, args.plugin), eq(juniorConversationAnnotations.kind, kind), eq(juniorConversationAnnotations.key, key)));
184
+ },
185
+ async list() {
186
+ return (await listConversationAnnotations(args.db, args.conversationId)).filter((a) => a.plugin === args.plugin);
187
+ }
522
188
  };
523
189
  }
524
- //#endregion
525
- //#region src/chat/conversations/sql/location.ts
526
- /** Build the complete Location for one Conversation SQL write. */
527
- function locationForWrite(args) {
528
- const location = args.location ? locationSchema.parse(args.location) : void 0;
529
- const destination = args.destination?.platform === "slack" ? args.destination : void 0;
530
- const source = args.sessionSource?.kind === "slack" ? args.sessionSource : void 0;
531
- if (location) {
532
- if (destination && (location.id !== args.destinationId || location.teamId !== destination.teamId || location.channelId !== destination.channelId) || source && (location.teamId !== source.teamId || location.channelId !== source.channelId)) throw new Error("Conversation Location changed");
533
- const threadTs = location.threadTs ?? destination?.threadTs ?? source?.threadTs;
534
- return threadTs === location.threadTs ? location : {
535
- ...location,
536
- threadTs
537
- };
538
- }
539
- if (destination && source && (destination.teamId !== source.teamId || destination.channelId !== source.channelId)) throw new Error("Conversation Location does not match its session Source");
540
- if (!destination || !args.destinationId) return;
541
- const threadTs = destination.threadTs ?? source?.threadTs;
542
- return locationSchema.parse({
543
- id: args.destinationId,
544
- provider: "slack",
545
- teamId: destination.teamId,
546
- channelId: destination.channelId,
547
- ...threadTs ? { threadTs } : void 0
548
- });
190
+ /** Load stored annotations for one conversation, newest update first. */
191
+ async function listConversationAnnotations(db, conversationId) {
192
+ return (await listConversationAnnotationsById(db, [conversationId])).get(conversationId) ?? [];
549
193
  }
550
- /** Read one complete Location from SQL. */
551
- function locationFromRow(value, row, sessionSource) {
552
- if (value !== void 0 && value !== null) {
553
- const location = locationSchema.parse(value);
554
- const slackSource = !location.threadTs && sessionSource?.kind === "slack" && sessionSource.teamId === location.teamId && sessionSource.channelId === location.channelId ? sessionSource : void 0;
555
- return slackSource?.threadTs ? {
556
- ...location,
557
- threadTs: slackSource.threadTs
558
- } : location;
194
+ /** Load stored annotations for a bounded set of conversations. */
195
+ async function listConversationAnnotationsById(db, conversationIds) {
196
+ const byConversation = /* @__PURE__ */ new Map();
197
+ if (conversationIds.length === 0) return byConversation;
198
+ const rows = await db.select().from(juniorConversationAnnotations).where(inArray(juniorConversationAnnotations.conversationId, [...conversationIds])).orderBy(desc(juniorConversationAnnotations.updatedAt));
199
+ for (const row of rows) {
200
+ const annotation = annotationFromRow(row);
201
+ if (!annotation) continue;
202
+ const current = byConversation.get(row.conversationId);
203
+ if (current) current.push(annotation);
204
+ else byConversation.set(row.conversationId, [annotation]);
559
205
  }
560
- if (!row || row.provider === "local") return;
561
- if (row.provider !== "slack") throw new Error("Conversation Location provider is not supported");
562
- const slackSource = sessionSource?.kind === "slack" && sessionSource.teamId === row.providerTenantId && sessionSource.channelId === row.providerDestinationId ? sessionSource : void 0;
563
- return locationSchema.parse({
564
- id: row.id,
565
- provider: "slack",
566
- teamId: row.providerTenantId,
567
- channelId: row.providerDestinationId,
568
- ...slackSource?.threadTs ? { threadTs: slackSource.threadTs } : void 0
569
- });
570
- }
571
- /** Read confirmed visibility from one linked Location row. */
572
- function visibilityFromLocationRow(row) {
573
- if (!row) return;
574
- if (row.visibility === "public" || row.visibility === "private") return row.visibility;
206
+ return byConversation;
575
207
  }
576
208
  //#endregion
577
- //#region src/chat/conversations/sql/store.ts
578
- const CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation";
579
- /** Serialize one conversation's durable mutations inside a SQL transaction. */
580
- async function withConversationMutationLock(executor, conversationId, callback) {
581
- return await executor.withLock(`${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, async () => await executor.transaction(callback));
582
- }
583
- function now() {
584
- return Date.now();
585
- }
586
- function dateFromMs(ms) {
587
- return new Date(ms);
588
- }
589
- function msFromDate(value) {
590
- if (value === null || value === void 0) return;
591
- return (value instanceof Date ? value : new Date(value)).getTime();
592
- }
593
- function requiredMsFromDate(value) {
594
- const ms = msFromDate(value);
595
- if (typeof ms !== "number" || Number.isNaN(ms)) throw new Error("Conversation record timestamp is invalid");
596
- return ms;
597
- }
598
- function tenantId(value) {
599
- return value ?? "";
600
- }
601
- function sourceFromValue(value) {
602
- if (value === "api" || value === "internal" || value === "local" || value === "plugin" || value === "event" || value === "scheduler" || value === "slack" || value === "web") return value;
603
- }
604
- function originTypeFromSource(source) {
605
- return source;
606
- }
607
- function localWorkspaceFromConversationId(conversationId) {
608
- return /^local:([^:]+):/.exec(conversationId)?.[1];
609
- }
610
- function destinationUpsertFromDestination(args) {
611
- const { destination } = args;
612
- if (!destination) return;
613
- if (destination.platform === "slack") {
614
- const channelId = destination.channelId;
615
- return {
616
- kind: channelId.startsWith("D") ? "dm" : channelId.startsWith("G") ? "group" : "channel",
617
- provider: "slack",
618
- providerTenantId: destination.teamId,
619
- providerDestinationId: channelId,
620
- refreshVisibility: args.visibility !== void 0,
621
- visibility: args.visibility ?? "unknown",
622
- ...args.channelName ? { displayName: args.channelName } : void 0,
623
- metadata: { platform: "slack" }
624
- };
625
- }
626
- return {
627
- kind: "local_conversation",
628
- provider: "local",
629
- providerTenantId: localWorkspaceFromConversationId(destination.conversationId) ?? localWorkspaceFromConversationId(args.conversationId ?? ""),
630
- providerDestinationId: destination.conversationId,
631
- refreshVisibility: args.visibility !== void 0,
632
- visibility: args.visibility ?? "direct",
633
- metadata: { platform: "local" }
634
- };
209
+ //#region src/chat/conversations/annotation-results.ts
210
+ /** Save object annotations and retain returned facts in the tool result. */
211
+ async function saveObjectAnnotations(conversationId, plugin, value) {
212
+ const annotations = objectAnnotationSchema.array().parse(value);
213
+ await getDb().transaction(async (db) => {
214
+ const store = createPluginAnnotations({
215
+ conversationId,
216
+ plugin,
217
+ db
218
+ });
219
+ for (const annotation of annotations) {
220
+ await store.upsert(annotation);
221
+ await store.remove("resource_link", annotation.key);
222
+ }
223
+ });
224
+ return annotations.map((annotation) => ({
225
+ ...annotation,
226
+ plugin
227
+ }));
635
228
  }
636
- function executionStatusFromValue(value) {
637
- if (value === "awaiting_resume" || value === "paused") return "paused";
638
- if (value === "failed" || value === "idle" || value === "pending" || value === "running") return value;
639
- throw new Error("Conversation record execution status is invalid");
640
- }
641
- function executionStatusToSql(status) {
642
- return status === "paused" ? "awaiting_resume" : status;
643
- }
644
- function destinationFromRow(destination) {
645
- return parseDestination(destination?.provider === "slack" ? {
646
- platform: "slack",
647
- teamId: destination.providerTenantId,
648
- channelId: destination.providerDestinationId
649
- } : destination?.provider === "local" ? {
650
- platform: "local",
651
- conversationId: destination.providerDestinationId
652
- } : void 0);
653
- }
654
- /** Decode one SQL row and reject invalid durable conversation records. */
655
- function conversationFromRow(readRow) {
656
- const row = readRow.conversation;
657
- const visibility = visibilityFromLocationRow(readRow.destination);
658
- if (row.schemaVersion !== 1) throw new Error("Conversation record schema version is invalid");
659
- if (row.destination !== null && readRow.destination === null) throw new Error("Conversation legacy destination is not migrated");
660
- if (row.actor !== null && readRow.actorIdentity === null) throw new Error("Conversation legacy actor is not migrated");
661
- const destination = destinationFromRow(readRow.destination);
662
- const actor = actorFromIdentityRow(readRow.actorIdentity, readRow.actorUserDisplayName);
663
- if (readRow.destination !== null && !destination) throw new Error("Conversation record destination is invalid");
664
- const source = row.source === void 0 || row.source === null ? void 0 : sourceFromValue(row.source);
665
- if (row.source !== void 0 && row.source !== null && !source) throw new Error("Conversation record source is invalid");
666
- const sessionSource = row.sessionSource === void 0 || row.sessionSource === null ? void 0 : parseSessionSource(row.sessionSource);
667
- if (row.sessionSource !== void 0 && row.sessionSource !== null && !sessionSource) throw new Error("Conversation record session source is invalid");
668
- const execution = {
669
- status: executionStatusFromValue(row.executionStatus),
670
- lastCheckpointAtMs: msFromDate(row.lastCheckpointAt),
671
- lastEnqueuedAtMs: msFromDate(row.lastEnqueuedAt),
672
- ...row.runId ? { runId: row.runId } : void 0,
673
- updatedAtMs: msFromDate(row.executionUpdatedAt) ?? requiredMsFromDate(row.updatedAt)
229
+ /** Save annotations from a successful plugin tool result and attach their cards. */
230
+ async function annotateToolResult(conversationId, plugin, result) {
231
+ if (!isRecord$1(result) || result.isError === true) return result;
232
+ const details = isRecord$1(result.details) ? result.details : result;
233
+ if (details.timed_out === true || details.isError === true || !Array.isArray(details.objectAnnotations)) return result;
234
+ const cards = await saveObjectAnnotations(conversationId, plugin, details.objectAnnotations);
235
+ const { objectAnnotations: _annotations, ...rest } = details;
236
+ const annotated = {
237
+ ...rest,
238
+ objectCards: cards
674
239
  };
675
- const location = locationFromRow(row.location, readRow.destination, sessionSource);
676
- return {
677
- schemaVersion: 1,
678
- conversationId: row.conversationId,
679
- createdAtMs: requiredMsFromDate(row.createdAt),
680
- lastActivityAtMs: requiredMsFromDate(row.lastActivityAt),
681
- updatedAtMs: requiredMsFromDate(row.updatedAt),
682
- execution,
683
- executionMetrics: {
684
- durationMs: row.executionDurationMs,
685
- ...row.metricRunId ? { runId: row.metricRunId } : void 0,
686
- ...row.executionUsage ? { usage: row.executionUsage } : void 0
687
- },
688
- ...row.parentConversationId ? { parentConversationId: row.parentConversationId } : void 0,
689
- ...destination ? { destination } : void 0,
690
- ...location ? { location } : void 0,
691
- ...actor ? { actor } : void 0,
692
- ...row.channelName ? { channelName: row.channelName } : void 0,
693
- ...source ? { source } : void 0,
694
- ...sessionSource ? { sessionSource } : void 0,
695
- ...row.title ? { title: row.title } : void 0,
696
- ...msFromDate(row.transcriptPurgedAt) !== void 0 ? { transcriptPurgedAtMs: msFromDate(row.transcriptPurgedAt) } : void 0,
697
- ...visibility ? { visibility } : void 0
240
+ if (isRecord$1(result.details)) return {
241
+ ...result,
242
+ details: annotated
698
243
  };
244
+ return annotated;
245
+ }
246
+ //#endregion
247
+ //#region src/chat/conversations/sql/privacy.ts
248
+ const privacyRoot = alias(juniorConversations, "privacy_root");
249
+ /** Accept only a structurally valid persisted root as privacy authority. */
250
+ async function readRootCandidate(executor, conversationId) {
251
+ const row = (await executor.db().select({
252
+ requestedConversationId: juniorConversations.conversationId,
253
+ requestedParentConversationId: juniorConversations.parentConversationId,
254
+ requestedRootConversationId: juniorConversations.rootConversationId,
255
+ rootConversationId: privacyRoot.conversationId,
256
+ rootDestinationId: privacyRoot.destinationId,
257
+ rootParentConversationId: privacyRoot.parentConversationId,
258
+ rootRootConversationId: privacyRoot.rootConversationId
259
+ }).from(juniorConversations).leftJoin(privacyRoot, eq(privacyRoot.conversationId, juniorConversations.rootConversationId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
260
+ return row?.requestedRootConversationId !== null && (row?.requestedParentConversationId !== null || row?.requestedRootConversationId === row?.requestedConversationId) && row?.rootConversationId === row.requestedRootConversationId && row.rootRootConversationId === row.rootConversationId && row.rootParentConversationId === null && row.rootDestinationId !== null ? {
261
+ destinationId: row.rootDestinationId,
262
+ rootConversationId: row.rootConversationId
263
+ } : void 0;
699
264
  }
700
- function emptyConversation(args) {
265
+ async function readCandidateVisibility(executor, candidate) {
266
+ const destinations = await executor.db().select({ visibility: juniorDestinations.visibility }).from(juniorDestinations).where(eq(juniorDestinations.id, candidate.destinationId)).for("share");
701
267
  return {
702
- schemaVersion: 1,
703
- conversationId: args.conversationId,
704
- createdAtMs: args.nowMs,
705
- lastActivityAtMs: args.nowMs,
706
- updatedAtMs: args.nowMs,
707
- ...args.destination ? { destination: args.destination } : void 0,
708
- ...args.source ? { source: args.source } : void 0,
709
- ...args.sessionSource ? { sessionSource: args.sessionSource } : void 0,
710
- execution: {
711
- status: "idle",
712
- updatedAtMs: args.nowMs
713
- }
268
+ rootConversationId: candidate.rootConversationId,
269
+ visibility: destinations[0]?.visibility ?? null
714
270
  };
715
271
  }
716
- function assertSameConversationDestination(args) {
717
- if (!args.current || sameDestination(args.current, args.next)) return;
718
- throw new Error(`Conversation destination changed for ${args.conversationId}`);
272
+ /**
273
+ * Resolve a conversation's privacy authority from its persisted root.
274
+ *
275
+ * Missing or structurally invalid root metadata fails closed. The root
276
+ * destination is locked so callers that keep the transaction open receive a
277
+ * stable visibility decision.
278
+ */
279
+ async function resolveRootVisibility(executor, conversationId) {
280
+ const candidate = await readRootCandidate(executor, conversationId);
281
+ if (!candidate) return {
282
+ rootConversationId: conversationId,
283
+ visibility: null
284
+ };
285
+ return readCandidateVisibility(executor, candidate);
719
286
  }
720
- var SqlStore = class {
721
- executor;
722
- constructor(executor) {
723
- this.executor = executor;
724
- }
725
- async getConversationIdByProviderConversation(args) {
726
- return await getConversationIdByProviderConversation(this.executor, args);
727
- }
728
- async bindProviderConversation(args) {
729
- await bindProviderConversation(this.executor, args);
730
- }
731
- async createChild(args) {
732
- const nowMs = args.nowMs ?? now();
733
- await this.withConversationMutation(args.parentConversationId, async () => {
734
- await this.withConversationMutation(args.childConversationId, async () => {
735
- const existing = await this.get({ conversationId: args.childConversationId });
736
- if (existing) {
737
- if (existing.parentConversationId !== args.parentConversationId) throw new Error(`Conversation parent changed for ${args.childConversationId}`);
738
- return;
739
- }
740
- const parent = await this.get({ conversationId: args.parentConversationId });
741
- if (!parent) throw new Error(`Conversation parent is missing for ${args.childConversationId}`);
742
- if (parent.parentConversationId) throw new Error("Recursive agent delegation is not enabled");
743
- const child = {
744
- ...emptyConversation({
745
- conversationId: args.childConversationId,
746
- nowMs,
747
- source: args.source
748
- }),
749
- parentConversationId: args.parentConversationId
750
- };
751
- await this.upsertConversation({ conversation: child });
752
- });
753
- });
754
- }
755
- async get(args) {
756
- const row = await this.readConversationRow(args.conversationId);
757
- if (!row) return;
758
- return conversationFromRow(row);
759
- }
760
- async recordActivity(args) {
761
- const nowMs = args.nowMs ?? now();
762
- const activityAtMs = args.activityAtMs ?? nowMs;
763
- const sessionSource = normalizeSessionSource(args.sessionSource);
764
- await this.withConversationMutation(args.conversationId, async () => {
765
- const existing = await this.get({ conversationId: args.conversationId });
766
- if (existing && args.destination) assertSameConversationDestination({
767
- conversationId: args.conversationId,
768
- current: existing.destination,
769
- next: args.destination
770
- });
771
- const current = existing ?? emptyConversation({
772
- conversationId: args.conversationId,
773
- destination: args.destination,
774
- nowMs,
775
- source: args.source,
776
- ...sessionSource ? { sessionSource } : void 0
777
- });
778
- const { sessionSource: _persistedSessionSource, visibility: _persistedVisibility, ...currentWithoutPersistedSignals } = current;
779
- await this.upsertConversation({ conversation: {
780
- ...currentWithoutPersistedSignals,
781
- destination: current.destination ?? args.destination,
782
- source: current.source ?? args.source,
783
- ...sessionSource ? { sessionSource } : void 0,
784
- channelName: current.channelName ?? args.channelName,
785
- actor: mergeActor(current.actor, args.actor),
786
- title: current.title ?? args.title,
787
- lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs),
788
- updatedAtMs: nowMs,
789
- execution: {
790
- ...current.execution,
791
- updatedAtMs: current.execution.updatedAtMs ?? nowMs
792
- },
793
- ...args.visibility ? { visibility: args.visibility } : void 0
794
- } });
795
- });
796
- }
797
- async recordExecution(args) {
798
- if (args.metrics && !args.execution.runId) throw new Error("Conversation Run metrics require a Run id");
799
- await this.withConversationMutation(args.conversationId, async () => {
800
- const existingRow = await this.readConversationRow(args.conversationId);
801
- const existing = existingRow ? conversationFromRow(existingRow) : void 0;
802
- if (!existing && !args.destination) return;
803
- const incomingExecutionAt = args.execution.updatedAtMs ?? args.updatedAtMs;
804
- const incomingIsFresh = incomingExecutionAt >= (existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0);
805
- const execution = incomingIsFresh ? args.execution : existing?.execution ?? args.execution;
806
- await this.upsertConversation({ conversation: {
807
- schemaVersion: 1,
808
- conversationId: args.conversationId,
809
- createdAtMs: args.createdAtMs,
810
- lastActivityAtMs: args.lastActivityAtMs,
811
- updatedAtMs: args.updatedAtMs,
812
- ...existing?.parentConversationId ? { parentConversationId: existing.parentConversationId } : void 0,
813
- ...existing?.location ? { location: existing.location } : void 0,
814
- ...args.channelName ? { channelName: args.channelName } : void 0,
815
- ...args.destination ? { destination: args.destination } : void 0,
816
- ...args.actor ? { actor: args.actor } : void 0,
817
- ...args.source ? { source: args.source } : void 0,
818
- ...args.title ? { title: args.title } : void 0,
819
- ...args.visibility ? { visibility: args.visibility } : void 0,
820
- execution
821
- } });
822
- if (args.metrics && args.execution.runId) {
823
- await replaceConversationMetrics(this.executor, {
824
- conversationId: args.conversationId,
825
- runId: args.execution.runId,
826
- occurredAtMs: args.createdAtMs,
827
- updatedAtMs: incomingExecutionAt,
828
- ...args.metrics
829
- });
830
- if (incomingIsFresh) await this.executor.db().update(juniorConversations).set({
831
- metricRunId: args.execution.runId,
832
- executionDurationMs: args.metrics.durationMs,
833
- executionUsage: args.metrics.usage ?? null
834
- }).where(eq(juniorConversations.conversationId, args.conversationId));
835
- }
836
- });
837
- }
838
- async listByActivity(args = {}) {
839
- const rows = await this.executor.db().select({
840
- conversation: conversationReadColumns(),
841
- destination: juniorDestinations,
842
- actorIdentity: juniorIdentities,
843
- actorUserDisplayName: juniorUsers.displayName
844
- }).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).leftJoin(juniorIdentities, eq(juniorIdentities.id, juniorConversations.actorIdentityId)).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(isNull(juniorConversations.parentConversationId)).orderBy(desc(juniorConversations.lastActivityAt), asc(juniorConversations.conversationId)).limit(Math.max(0, args.limit ?? 1e4)).offset(Math.max(0, args.offset ?? 0));
845
- const conversations = [];
846
- for (const row of rows) conversations.push(conversationFromRow(row));
847
- return conversations;
848
- }
849
- async getDestinationVisibility(args) {
850
- const row = (await this.executor.db().select({ visibility: juniorDestinations.visibility }).from(juniorDestinations).where(and(eq(juniorDestinations.provider, args.provider), eq(juniorDestinations.providerTenantId, tenantId(args.providerTenantId)), eq(juniorDestinations.providerDestinationId, args.providerDestinationId))))[0];
851
- if (!row) return;
852
- if (row.visibility === "public" || row.visibility === "private") return row.visibility;
853
- }
854
- async findSlackDestinationByName(args) {
855
- const normalized = args.channelName.trim().replace(/^#/, "").toLowerCase();
856
- if (!normalized) return;
857
- const rows = await this.executor.db().select({
858
- channelId: juniorDestinations.providerDestinationId,
859
- channelName: juniorDestinations.displayName
860
- }).from(juniorDestinations).where(and(eq(juniorDestinations.provider, "slack"), eq(juniorDestinations.providerTenantId, tenantId(args.teamId)), eq(juniorDestinations.kind, "channel"), sql`lower(ltrim(${juniorDestinations.displayName}, '#')) = ${normalized}`)).limit(2);
861
- if (rows.length !== 1) return;
862
- const row = rows[0];
863
- if (!row?.channelId) return;
287
+ //#endregion
288
+ //#region src/chat/briefs/events.ts
289
+ /** Durable cost and size record for one stored Brief version. */
290
+ const briefUpdatedEvent = defineConversationEvent({
291
+ name: "brief_updated",
292
+ version: 1,
293
+ schema: z.object({
294
+ version: z.number().int().positive(),
295
+ modelId: z.string().min(1),
296
+ costUsd: z.number().finite().nonnegative().optional(),
297
+ decisions: z.number().int().nonnegative(),
298
+ openDecisions: z.number().int().nonnegative(),
299
+ links: z.number().int().nonnegative()
300
+ }).strict(),
301
+ renderEvent(event) {
864
302
  return {
865
- channelId: row.channelId,
866
- ...row.channelName ? { channelName: row.channelName } : void 0
303
+ icon: "activity",
304
+ title: `Brief updated (v${event.version})`
867
305
  };
868
306
  }
869
- /** Serialize all durable mutations for one conversation inside a SQL transaction. */
870
- async withConversationMutation(conversationId, callback) {
871
- return await withConversationMutationLock(this.executor, conversationId, callback);
872
- }
873
- async readConversationRow(conversationId) {
874
- return (await this.executor.db().select({
875
- conversation: conversationReadColumns(),
876
- destination: juniorDestinations,
877
- actorIdentity: juniorIdentities,
878
- actorUserDisplayName: juniorUsers.displayName
879
- }).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).leftJoin(juniorIdentities, eq(juniorIdentities.id, juniorConversations.actorIdentityId)).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
880
- }
881
- /** Upsert the conversation row while preserving previously discovered nullable metadata fields. */
882
- async upsertConversation(args) {
883
- const { conversation } = args;
884
- if (!conversation.parentConversationId && !conversation.destination) {
885
- if (!await this.get({ conversationId: conversation.conversationId })) throw new Error(`Conversation ${conversation.conversationId} requires a destination at create`);
886
- }
887
- const incomingExecutionVersion = sql`coalesce(excluded.execution_updated_at, excluded.updated_at)`;
888
- const currentExecutionVersion = sql`coalesce(${juniorConversations.executionUpdatedAt}, ${juniorConversations.updatedAt})`;
889
- const incomingExecutionIsFresh = sql`${incomingExecutionVersion} >= ${currentExecutionVersion}`;
890
- const destinationId = await this.upsertDestination(destinationUpsertFromDestination({
891
- channelName: conversation.channelName,
892
- conversationId: conversation.conversationId,
893
- destination: conversation.destination,
894
- ...conversation.visibility ? { visibility: conversation.visibility } : void 0
895
- }), conversation.updatedAtMs);
896
- const location = locationForWrite({
897
- destination: conversation.destination,
898
- destinationId,
899
- location: conversation.location,
900
- sessionSource: conversation.sessionSource
901
- });
902
- const actorIdentityObservation = actorIdentityForConversation(conversation);
903
- const actorIdentity = actorIdentityObservation ? await upsertIdentity(this.executor, actorIdentityObservation, conversation.updatedAtMs) : void 0;
904
- const rootConversationId = conversation.parentConversationId ? sql`(
905
- select parent.root_conversation_id
906
- from junior_conversations parent
907
- where parent.conversation_id = ${conversation.parentConversationId}
908
- )` : conversation.conversationId;
909
- const row = (await this.executor.db().insert(juniorConversations).values({
910
- conversationId: conversation.conversationId,
911
- schemaVersion: 1,
912
- source: conversation.source ?? null,
913
- sessionSource: conversation.sessionSource ?? null,
914
- originType: originTypeFromSource(conversation.source) ?? null,
915
- originId: null,
916
- originRunId: null,
917
- location: location ?? null,
918
- destinationId: destinationId ?? null,
919
- destination: null,
920
- actorIdentityId: actorIdentity?.id ?? null,
921
- creatorIdentityId: null,
922
- credentialSubjectIdentityId: null,
923
- actor: null,
924
- channelName: conversation.channelName ?? null,
925
- title: conversation.title ?? null,
926
- createdAt: dateFromMs(conversation.createdAtMs),
927
- lastActivityAt: dateFromMs(conversation.lastActivityAtMs),
928
- updatedAt: dateFromMs(conversation.updatedAtMs),
929
- executionUpdatedAt: conversation.execution.updatedAtMs === void 0 ? null : dateFromMs(conversation.execution.updatedAtMs),
930
- executionStatus: executionStatusToSql(conversation.execution.status),
931
- runId: conversation.execution.runId ?? null,
932
- lastCheckpointAt: conversation.execution.lastCheckpointAtMs === void 0 ? null : dateFromMs(conversation.execution.lastCheckpointAtMs),
933
- lastEnqueuedAt: conversation.execution.lastEnqueuedAtMs === void 0 ? null : dateFromMs(conversation.execution.lastEnqueuedAtMs),
934
- parentConversationId: conversation.parentConversationId ?? null,
935
- rootConversationId
936
- }).onConflictDoUpdate({
937
- target: juniorConversations.conversationId,
938
- set: {
939
- source: sql`coalesce(${juniorConversations.source}, excluded.source)`,
940
- sessionSource: sql`coalesce(${juniorConversations.sessionSource}, excluded.source_json)`,
941
- originType: sql`coalesce(excluded.origin_type, ${juniorConversations.originType})`,
942
- originId: sql`coalesce(excluded.origin_id, ${juniorConversations.originId})`,
943
- originRunId: sql`coalesce(excluded.origin_run_id, ${juniorConversations.originRunId})`,
944
- location: sql`coalesce(excluded.location_json, ${juniorConversations.location})`,
945
- destinationId: sql`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`,
946
- actorIdentityId: sql`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`,
947
- creatorIdentityId: sql`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`,
948
- credentialSubjectIdentityId: sql`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`,
949
- channelName: sql`coalesce(excluded.channel_name, ${juniorConversations.channelName})`,
950
- title: sql`coalesce(excluded.title, ${juniorConversations.title})`,
951
- createdAt: sql`least(${juniorConversations.createdAt}, excluded.created_at)`,
952
- lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`,
953
- updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`,
954
- executionUpdatedAt: sql`case when ${incomingExecutionIsFresh} then excluded.execution_updated_at else ${juniorConversations.executionUpdatedAt} end`,
955
- executionStatus: sql`case when ${incomingExecutionIsFresh} then excluded.execution_status else ${juniorConversations.executionStatus} end`,
956
- runId: sql`case when ${incomingExecutionIsFresh} then excluded.run_id else ${juniorConversations.runId} end`,
957
- lastCheckpointAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`,
958
- lastEnqueuedAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`,
959
- rootConversationId: sql`coalesce(${juniorConversations.rootConversationId}, excluded.root_conversation_id)`
960
- }
961
- }).returning({
962
- actorIdentityId: juniorConversations.actorIdentityId,
963
- parentConversationId: juniorConversations.parentConversationId,
964
- rootConversationId: juniorConversations.rootConversationId
965
- }))[0];
966
- if (!row?.rootConversationId) throw new Error("Conversation parent is missing its persisted root");
967
- if (row.actorIdentityId && row.parentConversationId === null) await recordConversationParticipant(this.executor, {
968
- actorIdentityId: row.actorIdentityId,
969
- conversationId: conversation.conversationId,
970
- atMs: conversation.lastActivityAtMs
971
- });
972
- }
973
- async upsertDestination(destination, nowMs) {
974
- if (!destination) return;
975
- const visibilityUpdate = destination.refreshVisibility ? sql`excluded.visibility` : juniorDestinations.visibility;
976
- return (await this.executor.db().insert(juniorDestinations).values({
977
- id: randomUUID(),
978
- provider: destination.provider,
979
- providerTenantId: tenantId(destination.providerTenantId),
980
- providerDestinationId: destination.providerDestinationId,
981
- kind: destination.kind,
982
- parentDestinationId: null,
983
- displayName: destination.displayName ?? null,
984
- visibility: destination.visibility,
985
- metadata: destination.metadata ?? null,
986
- createdAt: dateFromMs(nowMs),
987
- updatedAt: dateFromMs(nowMs)
988
- }).onConflictDoUpdate({
989
- target: [
990
- juniorDestinations.provider,
991
- juniorDestinations.providerTenantId,
992
- juniorDestinations.providerDestinationId
993
- ],
994
- set: {
995
- kind: sql`excluded.kind`,
996
- displayName: sql`coalesce(excluded.display_name, ${juniorDestinations.displayName})`,
997
- visibility: visibilityUpdate,
998
- metadata: sql`coalesce(excluded.metadata_json, ${juniorDestinations.metadata})`,
999
- updatedAt: sql`excluded.updated_at`
1000
- }
1001
- }).returning({ id: juniorDestinations.id }))[0]?.id;
1002
- }
1003
- };
1004
- /** Create a SQL-backed conversation store. */
1005
- function createSqlStore(executor) {
1006
- return new SqlStore(executor);
307
+ });
308
+ //#endregion
309
+ //#region src/chat/slack/attachments.ts
310
+ /** Save the vision summary for a stored Slack attachment. */
311
+ async function recordSlackAttachment(args) {
312
+ await args.db.db().update(juniorAttachments).set({ visionSummary: args.visionSummary }).where(and(eq(juniorAttachments.id, args.attachmentId), eq(juniorAttachments.conversationId, args.conversationId), isNull(juniorAttachments.deleteRequestedAt)));
313
+ }
314
+ /** Read a saved vision summary for one live attachment. */
315
+ async function readAttachmentVisionSummary(args) {
316
+ const [row] = await args.db.db().select({ visionSummary: juniorAttachments.visionSummary }).from(juniorAttachments).where(and(eq(juniorAttachments.id, args.attachmentId), eq(juniorAttachments.conversationId, args.conversationId), isNull(juniorAttachments.deleteRequestedAt)));
317
+ return row?.visionSummary;
318
+ }
319
+ /** Match live conversation attachments to Slack file ids. */
320
+ async function matchSlackAttachments(args) {
321
+ if (args.providerIds.length === 0) return [];
322
+ return await args.db.db().select({
323
+ id: juniorAttachments.id,
324
+ providerId: juniorAttachments.providerId,
325
+ bytes: juniorAttachments.bytes,
326
+ contentType: juniorAttachments.contentType,
327
+ filename: juniorAttachments.filename
328
+ }).from(juniorAttachments).where(and(eq(juniorAttachments.conversationId, args.conversationId), eq(juniorAttachments.provider, "slack"), inArray(juniorAttachments.providerId, args.providerIds), isNull(juniorAttachments.deleteRequestedAt)));
329
+ }
330
+ /** Read the Slack file identity retained by the Chat SDK. */
331
+ function getSlackFileId(attachment) {
332
+ const metadataId = attachment.fetchMetadata?.fileId;
333
+ if (metadataId) return metadataId;
334
+ return (attachment.url ?? attachment.fetchMetadata?.url ?? "").match(/(?:^|[-/])(F[A-Z0-9]+)(?:[-/]|$)/i)?.[1];
1007
335
  }
1008
336
  //#endregion
1009
- //#region src/chat/conversations/sql/conversation-row.ts
1010
- /**
1011
- * Establish the conversation metadata row on first contact for content writes
1012
- * (conversation events, visible transcript) that can land before activity has
1013
- * created the row, and whose tables FK to it.
1014
- *
1015
- * First contact creates the row; every later content write advances the
1016
- * activity clock so retention mirrors append-refresh semantics (each content
1017
- * append refreshes the retention window). Background observations may opt out
1018
- * and leave an existing row unchanged. The timestamp is content-intrinsic (the
1019
- * first item's `createdAtMs`), and `greatest(...)` guarantees a backfilled or
1020
- * imported historical write — which carries an old timestamp — can never
1021
- * regress `last_activity_at`.
1022
- */
1023
- async function ensureConversationRow(executor, conversationId, atMs, options = {}) {
1024
- const at = new Date(atMs);
1025
- const insert = executor.db().insert(juniorConversations).values({
337
+ //#region src/api/conversations/message-attachments.ts
338
+ /** Resolve saved Slack file references without exposing private storage details. */
339
+ async function hydrateMessageAttachments(db, conversationId, events) {
340
+ const messages = events.flatMap((event) => event.data.type === "message" ? [event.data] : []);
341
+ const ids = [...new Set(messages.flatMap((message) => slackFileIds(message.meta)))];
342
+ if (!ids.length) return;
343
+ const attachments = await matchSlackAttachments({
344
+ db,
1026
345
  conversationId,
1027
- createdAt: at,
1028
- lastActivityAt: at,
1029
- updatedAt: at,
1030
- executionStatus: "idle"
346
+ providerIds: ids
1031
347
  });
1032
- if (options.activity === "preserve") {
1033
- await insert.onConflictDoNothing({ target: juniorConversations.conversationId });
1034
- return;
348
+ const byFileId = new Map(attachments.map(({ providerId, ...attachment }) => [providerId, attachment]));
349
+ for (const message of messages) {
350
+ const stored = readMessageAttachments(message.meta?.attachments);
351
+ const matched = slackFileIds(message.meta).flatMap((id) => {
352
+ const attachment = byFileId.get(id);
353
+ return attachment ? [attachment] : [];
354
+ });
355
+ if (matched.length) message.meta = {
356
+ ...message.meta,
357
+ attachments: [...stored, ...matched]
358
+ };
1035
359
  }
1036
- await insert.onConflictDoUpdate({
1037
- target: juniorConversations.conversationId,
1038
- set: {
1039
- lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`,
1040
- updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`,
1041
- transcriptPurgedAt: null
1042
- }
1043
- });
1044
- }
1045
- //#endregion
1046
- //#region src/chat/conversations/sql/event-actor.ts
1047
- const SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
1048
- function isRecord$1(value) {
1049
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1050
- }
1051
- function readAuthor(meta) {
1052
- if (!isRecord$1(meta) || !isRecord$1(meta.author)) return {};
1053
- const author = meta.author;
1054
- return {
1055
- ...typeof author.email === "string" ? { email: author.email } : void 0,
1056
- ...typeof author.fullName === "string" ? { fullName: author.fullName } : void 0,
1057
- ...typeof author.isBot === "boolean" ? { isBot: author.isBot } : void 0,
1058
- ...typeof author.userId === "string" ? { userId: author.userId } : void 0,
1059
- ...typeof author.userName === "string" ? { userName: author.userName } : void 0
1060
- };
1061
360
  }
1062
- async function loadConversationSlackTenantId(executor, conversationId) {
1063
- const [row] = await executor.db().select({
1064
- providerTenantId: juniorDestinations.providerTenantId,
1065
- sessionSource: juniorConversations.sessionSource
1066
- }).from(juniorConversations).leftJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).where(eq(juniorConversations.conversationId, conversationId)).limit(1);
1067
- const destinationTenant = row?.providerTenantId?.trim();
1068
- if (destinationTenant) return destinationTenant;
1069
- const sessionSource = parseSessionSource(row?.sessionSource);
1070
- if (sessionSource?.kind === "slack") return sessionSource.teamId;
361
+ function slackFileIds(meta) {
362
+ const ids = meta?.slackFileIds ?? meta?.imageFileIds;
363
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1071
364
  }
1072
- async function findIdentityIdByProviderSubject(executor, args) {
1073
- return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, args.provider), eq(juniorIdentities.providerSubjectId, args.providerSubjectId), args.providerTenantId === void 0 ? void 0 : eq(juniorIdentities.providerTenantId, args.providerTenantId))).limit(1))[0]?.id;
365
+ //#endregion
366
+ //#region src/chat/sentry-links.ts
367
+ function getSentryOrgSlug() {
368
+ return process.env.SENTRY_ORG_SLUG?.trim() || void 0;
1074
369
  }
1075
- async function findJuniorIdentityIdByEmail(executor, email) {
1076
- const normalized = email.trim().toLowerCase();
1077
- if (!normalized) return void 0;
1078
- return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, "junior"), eq(juniorIdentities.kind, "user"), or(eq(juniorIdentities.providerSubjectId, normalized), and(eq(juniorIdentities.emailNormalized, normalized), eq(juniorIdentities.emailVerified, true))))).limit(1))[0]?.id;
370
+ function isSentrySaasDsnHost(host) {
371
+ return host === "sentry.io" || host.endsWith(".sentry.io");
1079
372
  }
1080
- /**
1081
- * Resolve a dashboard:<hash> author against junior identities keyed by email.
1082
- * Web actors store userId as dashboard:${sha256(email).slice(0,24)} while the
1083
- * durable identity subject remains the verified email.
1084
- */
1085
- async function findJuniorIdentityIdByDashboardAuthorId(executor, dashboardAuthorId) {
1086
- if (!dashboardAuthorId.startsWith("dashboard:")) return;
1087
- return (await executor.db().select({ id: juniorIdentities.id }).from(juniorIdentities).where(and(eq(juniorIdentities.provider, "junior"), eq(juniorIdentities.kind, "user"), eq(juniorIdentities.emailVerified, true), sql`${juniorIdentities.emailNormalized} is not null`, sql`concat(
1088
- 'dashboard:',
1089
- left(encode(digest(${juniorIdentities.emailNormalized}, 'sha256'), 'hex'), 24)
1090
- ) = ${dashboardAuthorId}`)).limit(1))[0]?.id;
373
+ function buildSentryWebBaseUrl(dsn) {
374
+ if (isSentrySaasDsnHost(dsn.host)) return "https://sentry.io";
375
+ const port = dsn.port ? `:${dsn.port}` : "";
376
+ const path = dsn.path ? `/${dsn.path}` : "";
377
+ return `${dsn.protocol}://${dsn.host}${port}${path}`;
1091
378
  }
1092
- /**
1093
- * Resolve the durable actor identity for one event payload.
1094
- * Human user messages get an identity when author data is enough; other events
1095
- * keep a null actor.
1096
- */
1097
- async function resolveEventActorIdentityId(executor, args) {
1098
- const data = args.data;
1099
- if (data.type !== "message" && data.type !== "message_updated") return;
1100
- if (typeof data.authorIdentityId === "string" && data.authorIdentityId) return data.authorIdentityId;
1101
- if (data.role !== "user") return;
1102
- const author = readAuthor(data.meta);
1103
- if (author.isBot === true) return;
1104
- const userId = parseActorUserId(author.userId);
1105
- if (!userId) return;
1106
- if (SLACK_USER_ID_PATTERN.test(userId)) {
1107
- const tenantId = await loadConversationSlackTenantId(executor, args.conversationId);
1108
- const observation = {
1109
- kind: "user",
1110
- provider: "slack",
1111
- providerSubjectId: userId,
1112
- ...tenantId ? { providerTenantId: tenantId } : void 0,
1113
- ...author.fullName ? { displayName: author.fullName } : void 0,
1114
- ...author.userName ? { handle: author.userName } : void 0,
1115
- ...author.email ? {
1116
- email: author.email,
1117
- emailVerified: true
1118
- } : void 0
1119
- };
1120
- if (tenantId) return (await upsertIdentity(executor, observation, args.nowMs)).id;
1121
- return await findIdentityIdByProviderSubject(executor, {
1122
- provider: "slack",
1123
- providerSubjectId: userId
1124
- }) ?? void 0;
1125
- }
1126
- const email = typeof author.email === "string" && author.email.includes("@") ? author.email.trim().toLowerCase() : userId.includes("@") ? userId.trim().toLowerCase() : void 0;
1127
- if (email) return (await upsertIdentity(executor, {
1128
- kind: "user",
1129
- provider: "junior",
1130
- providerSubjectId: email,
1131
- email,
1132
- emailVerified: true,
1133
- ...author.fullName ? { displayName: author.fullName } : void 0,
1134
- metadata: { platform: "web" }
1135
- }, args.nowMs)).id;
1136
- if (userId.startsWith("dashboard:")) return findJuniorIdentityIdByDashboardAuthorId(executor, userId);
1137
- return findJuniorIdentityIdByEmail(executor, userId);
1138
- }
1139
- /** Drop payload-only authorIdentityId after lifting it onto the event column. */
1140
- function stripPayloadAuthorIdentityId(data) {
1141
- if (data.type !== "message" && data.type !== "message_updated" || data.authorIdentityId === void 0) return data;
1142
- const { authorIdentityId: _authorIdentityId, ...rest } = data;
1143
- return rest;
379
+ function buildSentryOrgPath(args) {
380
+ const dsn = getClient$1()?.getDsn();
381
+ if (!dsn?.host || !dsn.projectId) return;
382
+ const orgSlug = getSentryOrgSlug();
383
+ if (!orgSlug) return;
384
+ const params = new URLSearchParams();
385
+ params.set("project", dsn.projectId);
386
+ if (args.query) for (const [key, value] of Object.entries(args.query)) params.set(key, value);
387
+ const path = `${args.path}?${params.toString()}`;
388
+ if (isSentrySaasDsnHost(dsn.host)) return `https://${orgSlug}.sentry.io/${path}`;
389
+ return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`;
1144
390
  }
1145
- //#endregion
1146
- //#region src/db/postgres-json.ts
1147
- /**
1148
- * Replace actual NUL characters that PostgreSQL cannot store in JSONB.
1149
- * Literal `\\u0000` text is preserved because it contains no NUL character.
1150
- */
1151
- function sanitizePostgresJson(value) {
1152
- if (typeof value === "string") return value.replaceAll("\0", " ");
1153
- if (Array.isArray(value)) return value.map((item) => sanitizePostgresJson(item));
1154
- if (value === null || typeof value !== "object") return value;
1155
- const prototype = Object.getPrototypeOf(value);
1156
- if (prototype !== Object.prototype && prototype !== null) return value;
1157
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizePostgresJson(item)]));
391
+ /** Build a Sentry conversation URL only when the runtime has enough Sentry config. */
392
+ function buildSentryConversationUrl(conversationId) {
393
+ return buildSentryOrgPath({ path: `explore/conversations/${encodeURIComponent(conversationId)}/` });
1158
394
  }
1159
- //#endregion
1160
- //#region src/chat/conversations/sql/event-lock.ts
1161
- /** Serialize event sequence allocation with other conversation writes. */
1162
- async function withConversationEventLock(executor, conversationId, callback) {
1163
- return executor.withLock(`junior_conversation:${conversationId}`, callback);
395
+ /** Build a Sentry event URL only when the runtime has enough Sentry config. */
396
+ function buildSentryEventUrl(eventId) {
397
+ return buildSentryOrgPath({
398
+ path: "issues/",
399
+ query: { query: eventId }
400
+ });
1164
401
  }
1165
402
  //#endregion
1166
- //#region src/chat/conversations/sql/history.ts
1167
- const messageHistoryEventTypes = [
1168
- "message",
1169
- "message_updated",
1170
- "message_handled"
1171
- ];
1172
- const HUMAN_INSTRUCTION_PLATFORMS = /* @__PURE__ */ new Set([
1173
- "slack",
1174
- "local",
1175
- "web"
1176
- ]);
1177
- /**
1178
- * Whether one event should restore an archived conversation to the feed.
1179
- *
1180
- * Archive hides finished noise until a human comes back. Events,
1181
- * turn lifecycle, compaction, and other system writes may still refresh
1182
- * activity clocks, but they must not unarchive on their own.
1183
- */
1184
- function eventUnarchivesConversation(data) {
1185
- if (data.type === "user_message") {
1186
- const provenance = data.provenance;
1187
- if (provenance.authority !== "instruction") return false;
1188
- const platform = provenance.actor?.platform;
1189
- return platform === void 0 || HUMAN_INSTRUCTION_PLATFORMS.has(platform);
1190
- }
1191
- if (data.type !== "message") return false;
1192
- if (data.role !== "user") return false;
1193
- const meta = data.meta;
1194
- if (!meta) return true;
1195
- if (typeof meta.eventType === "string" && meta.eventType.length > 0) return false;
1196
- const author = meta.author;
1197
- if (author && typeof author === "object" && !Array.isArray(author) && author.isBot === true) return false;
1198
- return true;
1199
- }
1200
- /** Split validated event data into column-lifted and JSON payload fields. */
1201
- function insertFromEvent(conversationId, seq, historyVersion, event, actorIdentityId) {
1202
- const stripped = stripPayloadAuthorIdentityId(event.data);
1203
- const data = conversationEventDataSchema.parse(stripped);
1204
- const encoded = encodeHistoryPayload(data);
1205
- return {
1206
- conversationId,
1207
- seq,
1208
- historyVersion,
1209
- schemaVersion: encoded.schemaVersion,
1210
- idempotencyKey: event.idempotencyKey ?? null,
1211
- type: data.type,
1212
- payload: sanitizePostgresJson(encoded.payload),
1213
- actorIdentityId: actorIdentityId ?? null,
1214
- createdAt: new Date(event.createdAtMs)
403
+ //#region src/api/conversations/event-model.ts
404
+ /** Apply recorded model choices without carrying a previous turn's settings forward. */
405
+ function conversationEventModel(data, current) {
406
+ if (data.type === "turn_started") return void 0;
407
+ if (data.type === "turn_routed" || data.type === "handoff") return {
408
+ modelId: data.modelId,
409
+ modelProfile: data.modelProfile,
410
+ ...data.reasoningLevel ? { reasoningLevel: data.reasoningLevel } : void 0
1215
411
  };
412
+ if (data.type === "assistant_message") {
413
+ const model = text$1(data.model);
414
+ const provider = text$1(data.provider);
415
+ if (model) {
416
+ const modelId = model.includes("/") || !provider ? model : `${provider}/${model}`;
417
+ return {
418
+ ...current,
419
+ modelId
420
+ };
421
+ }
422
+ }
423
+ return current;
424
+ }
425
+ function text$1(value) {
426
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1216
427
  }
1217
- /** Parse one physical event row into the storage-compatible domain envelope. */
1218
- function eventFromRow(row) {
1219
- const payload = row.actorIdentityId && typeof row.payload === "object" && row.payload !== null && !Array.isArray(row.payload) && (row.type === "message" || row.type === "message_updated") && !("authorIdentityId" in row.payload) ? {
1220
- ...row.payload,
1221
- authorIdentityId: row.actorIdentityId
1222
- } : row.payload;
1223
- return decodeStoredConversationEvent({
1224
- schemaVersion: row.schemaVersion,
1225
- seq: row.seq,
1226
- historyVersion: row.historyVersion,
1227
- ...row.idempotencyKey ? { idempotencyKey: row.idempotencyKey } : void 0,
1228
- createdAtMs: row.createdAt.getTime(),
1229
- type: row.type,
1230
- payload
1231
- });
1232
- }
1233
- /** Decode the summary row that defines a readable message-history suffix. */
1234
- function messagesSummarizedEventFromRow(row) {
1235
- const event = eventFromRow(row);
1236
- if (event.schemaVersion !== 1 || event.data.type !== "messages_summarized") throw new Error("Message compaction row did not decode as messages_summarized");
428
+ /** Allowlist model-call diagnostics; never expose opaque provider metadata. */
429
+ function conversationEventModelCall(data) {
430
+ if (data.type !== "assistant_message") return void 0;
431
+ const usage = extractGenAiUsageSummary(data);
432
+ const provider = text$1(data.provider);
433
+ const api = text$1(data.api);
434
+ const stopReason = text$1(data.stopReason);
1237
435
  return {
1238
- ...event,
1239
- schemaVersion: 1,
1240
- data: event.data
436
+ ...provider ? { provider } : void 0,
437
+ ...api ? { api } : void 0,
438
+ ...stopReason ? { stopReason } : void 0,
439
+ ...hasAgentTurnUsage(usage) ? { usage } : void 0
1241
440
  };
1242
441
  }
1243
- var SqlConversationEventStore = class {
1244
- executor;
1245
- constructor(executor) {
1246
- this.executor = executor;
1247
- }
1248
- async append(conversationId, events, options = {}) {
1249
- const parsed = events.map((event) => newConversationEventSchema.parse(event));
1250
- if (parsed.length === 0) return [];
1251
- return withConversationEventLock(this.executor, conversationId, async () => {
1252
- const existingKeys = parsed.map((event) => event.idempotencyKey).filter((key) => key !== void 0);
1253
- const persistedKeys = existingKeys.length === 0 ? /* @__PURE__ */ new Set() : new Set((await this.executor.db().select({ key: juniorConversationEvents.idempotencyKey }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), inArray(juniorConversationEvents.idempotencyKey, existingKeys)))).flatMap((row) => row.key ? [row.key] : []));
1254
- const acceptedKeys = new Set(persistedKeys);
1255
- const pending = parsed.filter((event) => {
1256
- if (event.idempotencyKey === void 0) return true;
1257
- if (acceptedKeys.has(event.idempotencyKey)) return false;
1258
- acceptedKeys.add(event.idempotencyKey);
1259
- return true;
1260
- });
1261
- if (pending.length === 0) return [];
1262
- const newestCreatedAtMs = Math.max(...pending.map((event) => event.createdAtMs));
1263
- await ensureConversationRow(this.executor, conversationId, newestCreatedAtMs, options);
1264
- const cursor = await this.readCursor(conversationId);
1265
- const historyVersion = cursor.maxHistoryVersion ?? 0;
1266
- let seq = cursor.nextSeq;
1267
- const rows = [];
1268
- for (const event of pending) {
1269
- const actorIdentityId = await resolveEventActorIdentityId(this.executor, {
1270
- conversationId,
1271
- data: event.data,
1272
- nowMs: event.createdAtMs
1273
- });
1274
- rows.push(insertFromEvent(conversationId, seq++, historyVersion, event, actorIdentityId));
1275
- if (actorIdentityId) await recordConversationParticipant(this.executor, {
1276
- actorIdentityId,
1277
- conversationId,
1278
- atMs: event.createdAtMs,
1279
- restoreArchive: eventUnarchivesConversation(event.data)
1280
- });
1281
- }
1282
- await this.executor.db().insert(juniorConversationEvents).values(rows);
1283
- return rows.map((row) => ({
1284
- seq: row.seq,
1285
- historyVersion: row.historyVersion
1286
- }));
1287
- });
1288
- }
1289
- async replaceHistory(conversationId, replacement) {
1290
- const parsed = historyReplacementSchema.parse(replacement);
1291
- await withConversationEventLock(this.executor, conversationId, async () => {
1292
- await ensureConversationRow(this.executor, conversationId, Date.now());
1293
- const cursor = await this.readCursor(conversationId);
1294
- const historyVersion = (cursor.maxHistoryVersion ?? 0) + 1;
1295
- await this.executor.db().insert(juniorConversationEvents).values(insertFromEvent(conversationId, cursor.nextSeq, historyVersion, parsed));
1296
- });
1297
- }
1298
- async loadCurrentHistoryVersion(conversationId) {
1299
- return (await this.readCursor(conversationId)).maxHistoryVersion ?? 0;
1300
- }
1301
- async loadCurrentHistory(conversationId) {
1302
- const cursor = await this.readCursor(conversationId);
1303
- if (cursor.maxHistoryVersion === null) return [];
1304
- return (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.historyVersion, cursor.maxHistoryVersion))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
1305
- }
1306
- async loadByIdempotencyKey(conversationId, idempotencyKey) {
1307
- const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.idempotencyKey, idempotencyKey))).limit(1);
1308
- return row ? eventFromRow(row) : void 0;
1309
- }
1310
- async loadHistoryContaining(conversationId, seq, throughSeq) {
1311
- const [boundary] = await this.executor.db().select({ historyVersion: juniorConversationEvents.historyVersion }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.seq, seq))).limit(1);
1312
- if (!boundary) return void 0;
1313
- return (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.historyVersion, boundary.historyVersion), throughSeq === void 0 ? void 0 : lte(juniorConversationEvents.seq, throughSeq))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
1314
- }
1315
- async loadLatestStructuredEvent(conversationId, namespace, name) {
1316
- const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${namespace}`, sql`${juniorConversationEvents.payload}->>'name' = ${name}`)).orderBy(desc(juniorConversationEvents.seq)).limit(1);
1317
- return row ? eventFromRow(row) : void 0;
1318
- }
1319
- async loadLatestInstruction(conversationId) {
1320
- const [row] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "user_message"), sql`${juniorConversationEvents.payload}->'provenance'->>'authority' = 'instruction'`)).orderBy(desc(juniorConversationEvents.seq)).limit(1);
1321
- return row ? eventFromRow(row) : void 0;
1322
- }
1323
- async loadMessageHistory(conversationId) {
1324
- const [compactionRow] = await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "messages_summarized"))).orderBy(desc(juniorConversationEvents.seq)).limit(1);
1325
- const compaction = compactionRow ? messagesSummarizedEventFromRow(compactionRow) : void 0;
1326
- const historyFromSeq = compaction?.data.historyFromSeq ?? 0;
1327
- return {
1328
- events: (await this.executor.db().select().from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), inArray(juniorConversationEvents.type, messageHistoryEventTypes), gte(juniorConversationEvents.seq, historyFromSeq))).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow),
1329
- compaction,
1330
- historyFromSeq
1331
- };
1332
- }
1333
- async loadHistory(conversationId) {
1334
- return (await this.executor.db().select().from(juniorConversationEvents).where(eq(juniorConversationEvents.conversationId, conversationId)).orderBy(asc(juniorConversationEvents.seq))).map(eventFromRow);
1335
- }
1336
- async query(conversationId, query) {
1337
- if (query.limit < 1) throw new Error("Conversation event query limit must be at least 1");
1338
- if (query.afterSeq !== void 0 && query.beforeSeq !== void 0 && query.afterSeq >= query.beforeSeq) return {
1339
- events: [],
1340
- hasOlder: false,
1341
- hasNewer: false
1342
- };
1343
- const typeFilter = query.types && query.types.length > 0 ? inArray(juniorConversationEvents.type, [...query.types]) : void 0;
1344
- const bounds = and(eq(juniorConversationEvents.conversationId, conversationId), query.afterSeq === void 0 ? void 0 : gt(juniorConversationEvents.seq, query.afterSeq), query.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, query.beforeSeq), typeFilter);
1345
- const newestFirst = query.afterSeq === void 0;
1346
- const rows = await this.executor.db().select().from(juniorConversationEvents).where(bounds).orderBy(newestFirst ? desc(juniorConversationEvents.seq) : asc(juniorConversationEvents.seq)).limit(query.limit + 1);
1347
- const overflow = rows.length > query.limit;
1348
- const pageRows = overflow ? rows.slice(0, query.limit) : rows;
1349
- const events = (newestFirst ? [...pageRows].reverse() : pageRows).map(eventFromRow);
1350
- if (events.length === 0) return {
1351
- events,
1352
- hasOlder: false,
1353
- hasNewer: false
1354
- };
1355
- const firstSeq = events[0].seq;
1356
- const lastSeq = events[events.length - 1].seq;
1357
- return {
1358
- events,
1359
- hasOlder: newestFirst && overflow ? true : await this.hasMatchingEvent({
1360
- conversationId,
1361
- beforeSeq: firstSeq,
1362
- typeFilter
1363
- }),
1364
- hasNewer: !newestFirst && overflow ? true : await this.hasMatchingEvent({
1365
- conversationId,
1366
- afterSeq: lastSeq,
1367
- typeFilter
1368
- })
1369
- };
1370
- }
1371
- /** Return whether any matching event exists inside the exclusive seq window. */
1372
- async hasMatchingEvent(args) {
1373
- if (args.afterSeq !== void 0 && args.beforeSeq !== void 0 && args.afterSeq >= args.beforeSeq) return false;
1374
- const [row] = await this.executor.db().select({ seq: juniorConversationEvents.seq }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, args.conversationId), args.afterSeq === void 0 ? void 0 : gt(juniorConversationEvents.seq, args.afterSeq), args.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, args.beforeSeq), args.typeFilter)).limit(1);
1375
- return row !== void 0;
1376
- }
1377
- /** Read the next sequence and active model-history version. */
1378
- async readCursor(conversationId) {
1379
- const rows = await this.executor.db().select({
1380
- maxSeq: sql`max(${juniorConversationEvents.seq})`,
1381
- maxHistoryVersion: sql`max(${juniorConversationEvents.historyVersion})`
1382
- }).from(juniorConversationEvents).where(eq(juniorConversationEvents.conversationId, conversationId));
1383
- const maxSeq = rows[0]?.maxSeq;
1384
- const maxHistoryVersion = rows[0]?.maxHistoryVersion;
1385
- return {
1386
- maxHistoryVersion: maxHistoryVersion === null || maxHistoryVersion === void 0 ? null : Number(maxHistoryVersion),
1387
- nextSeq: maxSeq === null || maxSeq === void 0 ? 0 : Number(maxSeq) + 1
1388
- };
1389
- }
1390
- };
1391
- /** Create a SQL-backed canonical conversation event store. */
1392
- function createSqlConversationEventStore(executor) {
1393
- return new SqlConversationEventStore(executor);
1394
- }
1395
442
  //#endregion
1396
- //#region src/chat/conversations/sql/message-search.ts
1397
- const EXCERPT_WITHOUT_QUERY_CHARS = 240;
1398
- var SqlConversationMessageSearchStore = class {
1399
- executor;
1400
- constructor(executor) {
1401
- this.executor = executor;
1402
- }
1403
- async search(args) {
1404
- const db = this.executor.db();
1405
- const query = args.filters.query?.trim() || void 0;
1406
- const text = sql`${juniorConversationEvents.payload}->>'text'`;
1407
- const role = sql`${juniorConversationEvents.payload}->>'role'`;
1408
- const messageId = sql`${juniorConversationEvents.payload}->>'messageId'`;
1409
- const tsquery = query ? sql`websearch_to_tsquery('english', ${query})` : void 0;
1410
- const rank = tsquery ? sql`ts_rank_cd(to_tsvector('english', ${text}), ${tsquery})` : sql`1`;
1411
- const excerpt = tsquery ? sql`ts_headline('english', ${text}, ${tsquery}, 'MaxFragments=2, MinWords=8, MaxWords=40, FragmentDelimiter=" … ", StartSel=**, StopSel=**')` : sql`left(${text}, ${EXCERPT_WITHOUT_QUERY_CHARS})`;
1412
- const conditions = [
1413
- eq(juniorConversations.source, "slack"),
1414
- isNull(juniorConversations.parentConversationId),
1415
- isNull(juniorConversations.transcriptPurgedAt),
1416
- ne(juniorConversations.conversationId, args.currentConversationId),
1417
- eq(juniorDestinations.provider, args.scope.provider),
1418
- eq(juniorDestinations.providerTenantId, args.scope.providerTenantId),
1419
- eq(juniorDestinations.visibility, "public"),
1420
- eq(juniorConversationEvents.type, "message"),
1421
- sql`${role} in ('user', 'assistant')`
1422
- ];
1423
- if (tsquery) conditions.push(sql`to_tsvector('english', ${text}) @@ ${tsquery}`);
1424
- if (args.filters.channelId) conditions.push(eq(juniorDestinations.providerDestinationId, args.filters.channelId));
1425
- if (args.filters.afterMs !== void 0 && Number.isFinite(args.filters.afterMs)) conditions.push(gte(juniorConversations.lastActivityAt, new Date(args.filters.afterMs)));
1426
- if (args.filters.beforeMs !== void 0 && Number.isFinite(args.filters.beforeMs)) conditions.push(lt(juniorConversations.lastActivityAt, new Date(args.filters.beforeMs)));
1427
- if (args.filters.annotation) {
1428
- const annotation = args.filters.annotation.toLowerCase();
1429
- const nestedAnnotationPrefix = `${annotation}#`;
1430
- conditions.push(sql`exists (
1431
- select 1
1432
- from ${juniorConversationAnnotations}
1433
- where ${and(eq(juniorConversationAnnotations.conversationId, juniorConversations.conversationId), sql`(
1434
- lower(${juniorConversationAnnotations.key}) = ${annotation}
1435
- or starts_with(
1436
- lower(${juniorConversationAnnotations.key}),
1437
- ${nestedAnnotationPrefix}
1438
- )
1439
- )`)}
1440
- )`);
1441
- }
1442
- const bestPerConversation = db.selectDistinctOn([juniorConversations.conversationId], {
1443
- channelName: juniorDestinations.displayName,
1444
- conversationId: juniorConversations.conversationId,
1445
- excerpt: excerpt.as("excerpt"),
1446
- lastActivityAt: juniorConversations.lastActivityAt,
1447
- messageCreatedAt: juniorConversationEvents.createdAt,
1448
- messageId: messageId.as("message_id"),
1449
- providerDestinationId: juniorDestinations.providerDestinationId,
1450
- rank: rank.as("rank"),
1451
- role: role.as("role")
1452
- }).from(juniorConversationEvents).innerJoin(juniorConversations, eq(juniorConversations.conversationId, juniorConversationEvents.conversationId)).innerJoin(juniorDestinations, eq(juniorDestinations.id, juniorConversations.destinationId)).where(and(...conditions)).orderBy(juniorConversations.conversationId, desc(rank), desc(juniorConversationEvents.createdAt)).as("best_conversation_matches");
1453
- return (await db.select().from(bestPerConversation).orderBy(desc(bestPerConversation.rank), desc(bestPerConversation.lastActivityAt), desc(bestPerConversation.messageCreatedAt)).limit(args.limit)).map((row) => ({
1454
- conversationId: row.conversationId,
1455
- excerpt: row.excerpt,
1456
- messageCreatedAtMs: row.messageCreatedAt.getTime(),
1457
- messageId: row.messageId,
1458
- providerDestinationId: row.providerDestinationId,
1459
- role: row.role,
1460
- ...row.channelName ? { channelName: row.channelName } : void 0
1461
- }));
1462
- }
1463
- };
1464
- /** Create a SQL-backed public workspace message search store. */
1465
- function createSqlConversationMessageSearchStore(executor) {
1466
- return new SqlConversationMessageSearchStore(executor);
443
+ //#region src/api/conversations/events.ts
444
+ /** Canonical event types that can contribute to the reporting projection. */
445
+ const conversationReportSourceEventTypes = [
446
+ "message",
447
+ "message_handled",
448
+ "assistant_message",
449
+ "tool_result",
450
+ "tool_execution_started",
451
+ "guardian_action_reviewed",
452
+ "turn_started",
453
+ "turn_context",
454
+ "structured_event",
455
+ "attachments_delivered",
456
+ "turn_routed",
457
+ "turn_completed",
458
+ "turn_failed",
459
+ "compaction",
460
+ "handoff",
461
+ "subagent_started",
462
+ "subagent_ended"
463
+ ];
464
+ const reportingAssistantMessageSchema = z.object({
465
+ type: z.literal("assistant_message"),
466
+ content: z.array(z.unknown())
467
+ }).passthrough();
468
+ const reportingToolCallPartSchema = z.object({
469
+ type: z.literal("toolCall"),
470
+ id: z.string().min(1),
471
+ name: z.string().min(1),
472
+ arguments: z.unknown()
473
+ }).passthrough();
474
+ const reportingToolResultMessageSchema = z.object({
475
+ type: z.literal("tool_result"),
476
+ toolCallId: z.string().min(1),
477
+ toolName: z.string().min(1).optional(),
478
+ name: z.string().min(1).optional(),
479
+ content: z.array(z.unknown()),
480
+ details: z.unknown().optional(),
481
+ isError: z.boolean().optional()
482
+ }).passthrough();
483
+ const reportingToolResultDetailsSchema = z.object({
484
+ ok: z.boolean().optional(),
485
+ status: z.string().optional()
486
+ }).passthrough();
487
+ const reportingTextPartSchema = z.object({
488
+ type: z.literal("text"),
489
+ text: z.string()
490
+ }).passthrough();
491
+ const reportingReasoningPartSchema = z.object({
492
+ type: z.literal("thinking"),
493
+ thinking: z.string(),
494
+ redacted: z.literal(true).optional()
495
+ }).passthrough();
496
+ const reportingMediaPartSchema = z.object({
497
+ type: z.enum(["image", "audio"]),
498
+ mimeType: z.string().min(1).optional()
499
+ }).passthrough();
500
+ const reportingMessageAuthorSchema = z.object({
501
+ fullName: z.string().min(1).optional(),
502
+ userId: z.string().min(1).optional(),
503
+ userName: z.string().min(1).optional()
504
+ }).passthrough();
505
+ /** Retain model-visible text and media descriptors, dropping all opaque fields. */
506
+ function sanitizeModelContentPart(value) {
507
+ const text = reportingTextPartSchema.safeParse(value);
508
+ if (text.success) return {
509
+ type: "text",
510
+ text: text.data.text
511
+ };
512
+ const media = reportingMediaPartSchema.safeParse(value);
513
+ if (!media.success) return void 0;
514
+ return {
515
+ type: media.data.type,
516
+ ...media.data.mimeType ? { mimeType: media.data.mimeType } : void 0
517
+ };
1467
518
  }
1468
- //#endregion
1469
- //#region src/chat/db.ts
1470
- var db_exports = /* @__PURE__ */ __exportAll({
1471
- closeDb: () => closeDb,
1472
- getConversationEventStore: () => getConversationEventStore,
1473
- getConversationMessageSearchStore: () => getConversationMessageSearchStore,
1474
- getConversationStore: () => getConversationStore,
1475
- getDb: () => getDb,
1476
- getSqlExecutor: () => getSqlExecutor
1477
- });
1478
- let current;
1479
- function createDb(args) {
1480
- return createJuniorSqlExecutor({
1481
- connectionString: args.databaseUrl,
1482
- driver: args.driver,
1483
- statementTimeoutMs: args.statementTimeoutMs
519
+ /** Convert allowlisted Pi content parts into the reporting result value. */
520
+ function modelVisibleToolOutput(content) {
521
+ const sanitized = content.map(sanitizeModelContentPart).filter((part) => part !== void 0);
522
+ if (sanitized.length === 0) return void 0;
523
+ if (sanitized.length !== 1) return sanitized;
524
+ const only = sanitized[0];
525
+ return only.type === "text" ? only.text : only;
526
+ }
527
+ function reportMessageActorIdentity(data) {
528
+ if (data.role !== "user") return void 0;
529
+ const author = reportingMessageAuthorSchema.safeParse(data.meta?.author);
530
+ if (!author.success) return void 0;
531
+ const actorIdentity = {
532
+ ...author.data.fullName ? { fullName: author.data.fullName } : void 0,
533
+ ...author.data.userId ? { slackUserId: author.data.userId } : void 0,
534
+ ...author.data.userName ? { slackUserName: author.data.userName } : void 0
535
+ };
536
+ return Object.keys(actorIdentity).length > 0 ? actorIdentity : void 0;
537
+ }
538
+ /** Project native assistant tool calls through the existing reporting shape. */
539
+ function reportToolCalls(args) {
540
+ const message = reportingAssistantMessageSchema.safeParse(args.message);
541
+ if (!message.success) return void 0;
542
+ const calls = message.data.content.flatMap((part) => {
543
+ const call = reportingToolCallPartSchema.safeParse(part);
544
+ if (!call.success) return [];
545
+ return [{
546
+ toolCallId: call.data.id,
547
+ name: call.data.name,
548
+ status: "running",
549
+ startedAt: new Date(args.createdAtMs).toISOString(),
550
+ startedSeq: args.seq,
551
+ ...args.canExposePayload && call.data.arguments !== void 0 ? { input: call.data.arguments } : void 0
552
+ }];
1484
553
  });
554
+ return calls.length > 0 ? {
555
+ type: "tool_calls",
556
+ calls
557
+ } : void 0;
1485
558
  }
1486
- /** Return the process SQL executor for SQL-specific queries and transactions. */
1487
- function getSqlExecutor() {
1488
- const { sql } = getChatConfig();
1489
- if (current?.databaseUrl !== sql.databaseUrl || current.driver !== sql.driver || current.statementTimeoutMs !== sql.statementTimeoutMs) {
1490
- if (current) {
1491
- const previous = current;
1492
- current = void 0;
1493
- previous.db.close().catch(() => void 0);
559
+ /** Project assistant reasoning with tool ids retained only for display order. */
560
+ function reportAssistantMessage(args) {
561
+ const message = reportingAssistantMessageSchema.safeParse(args.message);
562
+ if (!message.success) return void 0;
563
+ if (!message.data.content.some((part) => {
564
+ const reasoning = reportingReasoningPartSchema.safeParse(part);
565
+ return reasoning.success && reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0;
566
+ })) return reportToolCalls(args) ?? {
567
+ type: "assistant_message",
568
+ parts: []
569
+ };
570
+ const reasoningParts = [];
571
+ const assistantParts = [];
572
+ for (const part of message.data.content) {
573
+ const reasoning = reportingReasoningPartSchema.safeParse(part);
574
+ if (reasoning.success) {
575
+ if (reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0) {
576
+ const projected = {
577
+ type: "reasoning",
578
+ ...args.canExposePayload ? { text: reasoning.data.thinking } : { redacted: true }
579
+ };
580
+ reasoningParts.push(projected);
581
+ assistantParts.push(projected);
582
+ }
583
+ continue;
1494
584
  }
1495
- const db = createDb({
1496
- databaseUrl: sql.databaseUrl,
1497
- driver: sql.driver,
1498
- statementTimeoutMs: sql.statementTimeoutMs
585
+ const call = reportingToolCallPartSchema.safeParse(part);
586
+ if (call.success) assistantParts.push({
587
+ type: "tool_call",
588
+ toolCallId: call.data.id
1499
589
  });
1500
- current = {
1501
- databaseUrl: sql.databaseUrl,
1502
- driver: sql.driver,
1503
- statementTimeoutMs: sql.statementTimeoutMs,
1504
- db,
1505
- store: createSqlStore(db),
1506
- eventStore: createSqlConversationEventStore(db),
1507
- searchStore: createSqlConversationMessageSearchStore(db)
1508
- };
1509
590
  }
1510
- return current.db;
1511
- }
1512
- /** Return the process Drizzle database. */
1513
- function getDb() {
1514
- return getSqlExecutor().db();
1515
- }
1516
- /** Return the SQL-backed conversation store. */
1517
- function getConversationStore() {
1518
- getSqlExecutor();
1519
- return current.store;
1520
- }
1521
- /** Return the canonical SQL-backed conversation event store. */
1522
- function getConversationEventStore() {
1523
- getSqlExecutor();
1524
- return current.eventStore;
1525
- }
1526
- /** Return the SQL-backed public provider-tenant conversation search store. */
1527
- function getConversationMessageSearchStore() {
1528
- getSqlExecutor();
1529
- return current.searchStore;
591
+ const toolCalls = reportToolCalls(args);
592
+ if (toolCalls?.type === "tool_calls") return {
593
+ ...toolCalls,
594
+ assistant: { parts: assistantParts }
595
+ };
596
+ return reasoningParts.length > 0 ? {
597
+ type: "assistant_message",
598
+ parts: reasoningParts
599
+ } : void 0;
1530
600
  }
1531
- /** Close the process SQL database when it has been opened. */
1532
- async function closeDb() {
1533
- const previous = current;
1534
- current = void 0;
1535
- await previous?.db.close();
1536
- }
1537
- //#endregion
1538
- //#region src/chat/conversations/structured-events.ts
1539
- /** Reserved namespace for Junior-owned conversation metadata events. */
1540
- const JUNIOR_NATIVE_EVENT_NAMESPACE = "junior";
1541
- const authenticationEventContentSchema = z.object({
1542
- accountLabel: z.string().trim().min(1).max(120).optional(),
1543
- actorId: z.string().min(1),
1544
- authorizationId: z.string().min(1).optional(),
1545
- provider: z.string().regex(/^[a-z][a-z0-9-]*$/),
1546
- providerLabel: z.string().trim().min(1).max(80).optional()
1547
- }).strict();
1548
- const agentsInstructionsUpdatedContentSchema = z.object({
1549
- action: z.enum([
1550
- "loaded",
1551
- "replaced",
1552
- "cleared"
1553
- ]),
1554
- directory: z.string().trim().min(1).max(500).optional(),
1555
- fingerprint: z.string().trim().min(1).max(128),
1556
- sources: z.array(z.object({
1557
- content: z.string().min(1).max(32768),
1558
- path: z.string().trim().min(1).max(500)
1559
- }).strict()).max(32),
1560
- textBytes: z.number().int().nonnegative().optional()
1561
- }).strict();
1562
- function providerTitle(content) {
1563
- return content.providerLabel?.trim() || content.provider;
1564
- }
1565
- function agentsInstructionsTitle(action) {
1566
- if (action === "loaded") return "Loaded AGENTS.md";
1567
- if (action === "replaced") return "Updated AGENTS.md";
1568
- return "Cleared AGENTS.md";
1569
- }
1570
- function sourceFilename(sourcePath) {
1571
- return sourcePath.split("/").at(-1) || sourcePath;
1572
- }
1573
- function formatBytes(bytes) {
1574
- if (bytes < 1024) return `${bytes} bytes`;
1575
- const kilobytes = bytes / 1024;
1576
- return `${Number.isInteger(kilobytes) ? kilobytes : kilobytes.toFixed(1)} KB`;
1577
- }
1578
- function agentsInstructionsPreview(content) {
1579
- const source = content.sources[0];
1580
- if (!source) return void 0;
1581
- const filename = sourceFilename(source.path);
1582
- const label = content.sources.length === 1 ? filename : `${content.sources.length} AGENTS.md files`;
1583
- return typeof content.textBytes === "number" ? `${label} · ${formatBytes(content.textBytes)}` : label;
1584
- }
1585
- /** Junior-owned account-link transcript event. */
1586
- const authenticationLinkedEvent = defineConversationEvent({
1587
- name: "authentication_linked",
1588
- version: 1,
1589
- schema: authenticationEventContentSchema,
1590
- renderEvent(event) {
1591
- const provider = providerTitle(event);
1592
- return {
1593
- icon: "link",
1594
- title: `${provider} connected`,
1595
- ...event.accountLabel ? { preview: `Connected as \`${event.accountLabel}\`` } : void 0,
1596
- details: [{
1597
- title: `${provider} connected`,
1598
- ...event.accountLabel ? { description: `Connected as \`${event.accountLabel}\`` } : void 0,
1599
- metadata: [event.provider]
1600
- }]
1601
- };
1602
- }
1603
- });
1604
- /** Junior-owned account-unlink transcript event. */
1605
- const authenticationUnlinkedEvent = defineConversationEvent({
1606
- name: "authentication_unlinked",
1607
- version: 1,
1608
- schema: authenticationEventContentSchema,
1609
- renderEvent(event) {
1610
- const provider = providerTitle(event);
1611
- return {
1612
- icon: "key",
1613
- title: `${provider} disconnected`,
1614
- details: [{
1615
- title: `${provider} disconnected`,
1616
- metadata: [event.provider]
1617
- }]
1618
- };
1619
- }
1620
- });
1621
- /** Junior-owned AGENTS.md bootstrap transition for the dashboard timeline. */
1622
- const agentsInstructionsUpdatedEvent = defineConversationEvent({
1623
- name: "agents_instructions_updated",
1624
- version: 1,
1625
- schema: agentsInstructionsUpdatedContentSchema,
1626
- renderEvent(event) {
1627
- const title = agentsInstructionsTitle(event.action);
1628
- const preview = agentsInstructionsPreview(event);
1629
- return {
1630
- icon: "brain",
1631
- title,
1632
- ...preview ? { preview } : void 0,
1633
- ...event.sources.length > 0 ? { details: event.sources.map((source) => ({
1634
- title: sourceFilename(source.path),
1635
- content: source.content
1636
- })) } : void 0
1637
- };
1638
- }
1639
- });
1640
- const NATIVE_EVENT_DEFINITIONS = [
1641
- authenticationLinkedEvent,
1642
- authenticationUnlinkedEvent,
1643
- agentsInstructionsUpdatedEvent
1644
- ];
1645
- /** Resolve a registered Junior-native conversation event definition. */
1646
- function getJuniorNativeEventDefinition(args) {
1647
- return NATIVE_EVENT_DEFINITIONS.find((definition) => definition.eventName === args.name && definition.version === args.version);
1648
- }
1649
- /** Render one Junior-native event through its active definition. */
1650
- function renderJuniorNativeConversationEvent(args) {
1651
- if (args.namespace !== "junior") return void 0;
1652
- const definition = getJuniorNativeEventDefinition(args);
1653
- if (!definition) return void 0;
1654
- const presentation = definition.renderEvent(definition.parse(args.content));
1655
- return presentation === void 0 ? void 0 : conversationEventPresentationSchema.parse(presentation);
1656
- }
1657
- //#endregion
1658
- //#region src/chat/plugins/annotations.ts
1659
- function annotationFromRow(row) {
1660
- const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
1661
- if (!parsed.success) return void 0;
1662
- return {
1663
- ...parsed.data,
1664
- plugin: row.plugin,
1665
- createdAt: row.createdAt.toISOString(),
1666
- updatedAt: row.updatedAt.toISOString()
1667
- };
1668
- }
1669
- function createPluginAnnotations(args) {
1670
- return {
1671
- async upsert(input) {
1672
- const annotation = conversationAnnotationInputSchema.parse(input);
1673
- const now = /* @__PURE__ */ new Date();
1674
- await args.db.insert(juniorConversationAnnotations).values({
1675
- annotation,
1676
- conversationId: args.conversationId,
1677
- createdAt: now,
1678
- key: annotation.key,
1679
- kind: annotation.kind,
1680
- plugin: args.plugin,
1681
- updatedAt: now
1682
- }).onConflictDoUpdate({
1683
- target: [
1684
- juniorConversationAnnotations.conversationId,
1685
- juniorConversationAnnotations.plugin,
1686
- juniorConversationAnnotations.kind,
1687
- juniorConversationAnnotations.key
1688
- ],
1689
- set: {
1690
- annotation,
1691
- updatedAt: now
1692
- }
1693
- });
1694
- },
1695
- async remove(kind, key) {
1696
- await args.db.delete(juniorConversationAnnotations).where(and(eq(juniorConversationAnnotations.conversationId, args.conversationId), eq(juniorConversationAnnotations.plugin, args.plugin), eq(juniorConversationAnnotations.kind, kind), eq(juniorConversationAnnotations.key, key)));
1697
- },
1698
- async list() {
1699
- return (await listConversationAnnotations(args.db, args.conversationId)).filter((a) => a.plugin === args.plugin);
1700
- }
1701
- };
1702
- }
1703
- /** Load stored annotations for one conversation, newest update first. */
1704
- async function listConversationAnnotations(db, conversationId) {
1705
- return (await listConversationAnnotationsById(db, [conversationId])).get(conversationId) ?? [];
1706
- }
1707
- /** Load stored annotations for a bounded set of conversations. */
1708
- async function listConversationAnnotationsById(db, conversationIds) {
1709
- const byConversation = /* @__PURE__ */ new Map();
1710
- if (conversationIds.length === 0) return byConversation;
1711
- const rows = await db.select().from(juniorConversationAnnotations).where(inArray(juniorConversationAnnotations.conversationId, [...conversationIds])).orderBy(desc(juniorConversationAnnotations.updatedAt));
1712
- for (const row of rows) {
1713
- const annotation = annotationFromRow(row);
1714
- if (!annotation) continue;
1715
- const current = byConversation.get(row.conversationId);
1716
- if (current) current.push(annotation);
1717
- else byConversation.set(row.conversationId, [annotation]);
1718
- }
1719
- return byConversation;
1720
- }
1721
- //#endregion
1722
- //#region src/chat/conversations/annotation-results.ts
1723
- /** Save object annotations and retain returned facts in the tool result. */
1724
- async function saveObjectAnnotations(conversationId, plugin, value) {
1725
- const annotations = objectAnnotationSchema.array().parse(value);
1726
- await getDb().transaction(async (db) => {
1727
- const store = createPluginAnnotations({
1728
- conversationId,
1729
- plugin,
1730
- db
1731
- });
1732
- for (const annotation of annotations) {
1733
- await store.upsert(annotation);
1734
- await store.remove("resource_link", annotation.key);
1735
- }
1736
- });
1737
- return annotations.map((annotation) => ({
1738
- ...annotation,
1739
- plugin
1740
- }));
1741
- }
1742
- /** Save annotations from a successful plugin tool result and attach their cards. */
1743
- async function annotateToolResult(conversationId, plugin, result) {
1744
- if (!isRecord$2(result) || result.isError === true) return result;
1745
- const details = isRecord$2(result.details) ? result.details : result;
1746
- if (details.timed_out === true || details.isError === true || !Array.isArray(details.objectAnnotations)) return result;
1747
- const cards = await saveObjectAnnotations(conversationId, plugin, details.objectAnnotations);
1748
- const { objectAnnotations: _annotations, ...rest } = details;
1749
- const annotated = {
1750
- ...rest,
1751
- objectCards: cards
1752
- };
1753
- if (isRecord$2(result.details)) return {
1754
- ...result,
1755
- details: annotated
1756
- };
1757
- return annotated;
1758
- }
1759
- //#endregion
1760
- //#region src/chat/conversations/sql/privacy.ts
1761
- const privacyRoot = alias(juniorConversations, "privacy_root");
1762
- /** Accept only a structurally valid persisted root as privacy authority. */
1763
- async function readRootCandidate(executor, conversationId) {
1764
- const row = (await executor.db().select({
1765
- requestedConversationId: juniorConversations.conversationId,
1766
- requestedParentConversationId: juniorConversations.parentConversationId,
1767
- requestedRootConversationId: juniorConversations.rootConversationId,
1768
- rootConversationId: privacyRoot.conversationId,
1769
- rootDestinationId: privacyRoot.destinationId,
1770
- rootParentConversationId: privacyRoot.parentConversationId,
1771
- rootRootConversationId: privacyRoot.rootConversationId
1772
- }).from(juniorConversations).leftJoin(privacyRoot, eq(privacyRoot.conversationId, juniorConversations.rootConversationId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
1773
- return row?.requestedRootConversationId !== null && (row?.requestedParentConversationId !== null || row?.requestedRootConversationId === row?.requestedConversationId) && row?.rootConversationId === row.requestedRootConversationId && row.rootRootConversationId === row.rootConversationId && row.rootParentConversationId === null && row.rootDestinationId !== null ? {
1774
- destinationId: row.rootDestinationId,
1775
- rootConversationId: row.rootConversationId
1776
- } : void 0;
1777
- }
1778
- async function readCandidateVisibility(executor, candidate) {
1779
- const destinations = await executor.db().select({ visibility: juniorDestinations.visibility }).from(juniorDestinations).where(eq(juniorDestinations.id, candidate.destinationId)).for("share");
1780
- return {
1781
- rootConversationId: candidate.rootConversationId,
1782
- visibility: destinations[0]?.visibility ?? null
1783
- };
1784
- }
1785
- /**
1786
- * Resolve a conversation's privacy authority from its persisted root.
1787
- *
1788
- * Missing or structurally invalid root metadata fails closed. The root
1789
- * destination is locked so callers that keep the transaction open receive a
1790
- * stable visibility decision.
1791
- */
1792
- async function resolveRootVisibility(executor, conversationId) {
1793
- const candidate = await readRootCandidate(executor, conversationId);
1794
- if (!candidate) return {
1795
- rootConversationId: conversationId,
1796
- visibility: null
1797
- };
1798
- return readCandidateVisibility(executor, candidate);
1799
- }
1800
- //#endregion
1801
- //#region src/chat/briefs/events.ts
1802
- /** Durable cost and size record for one stored Brief version. */
1803
- const briefUpdatedEvent = defineConversationEvent({
1804
- name: "brief_updated",
1805
- version: 1,
1806
- schema: z.object({
1807
- version: z.number().int().positive(),
1808
- modelId: z.string().min(1),
1809
- costUsd: z.number().finite().nonnegative().optional(),
1810
- decisions: z.number().int().nonnegative(),
1811
- openDecisions: z.number().int().nonnegative(),
1812
- links: z.number().int().nonnegative()
1813
- }).strict(),
1814
- renderEvent(event) {
1815
- return {
1816
- icon: "activity",
1817
- title: `Brief updated (v${event.version})`
1818
- };
1819
- }
1820
- });
1821
- //#endregion
1822
- //#region src/chat/slack/attachments.ts
1823
- /** Save the vision summary for a stored Slack attachment. */
1824
- async function recordSlackAttachment(args) {
1825
- await args.db.db().update(juniorAttachments).set({ visionSummary: args.visionSummary }).where(and(eq(juniorAttachments.id, args.attachmentId), eq(juniorAttachments.conversationId, args.conversationId), isNull(juniorAttachments.deleteRequestedAt)));
1826
- }
1827
- /** Read a saved vision summary for one live attachment. */
1828
- async function readAttachmentVisionSummary(args) {
1829
- const [row] = await args.db.db().select({ visionSummary: juniorAttachments.visionSummary }).from(juniorAttachments).where(and(eq(juniorAttachments.id, args.attachmentId), eq(juniorAttachments.conversationId, args.conversationId), isNull(juniorAttachments.deleteRequestedAt)));
1830
- return row?.visionSummary;
1831
- }
1832
- /** Match live conversation attachments to Slack file ids. */
1833
- async function matchSlackAttachments(args) {
1834
- if (args.providerIds.length === 0) return [];
1835
- return await args.db.db().select({
1836
- id: juniorAttachments.id,
1837
- providerId: juniorAttachments.providerId,
1838
- bytes: juniorAttachments.bytes,
1839
- contentType: juniorAttachments.contentType,
1840
- filename: juniorAttachments.filename
1841
- }).from(juniorAttachments).where(and(eq(juniorAttachments.conversationId, args.conversationId), eq(juniorAttachments.provider, "slack"), inArray(juniorAttachments.providerId, args.providerIds), isNull(juniorAttachments.deleteRequestedAt)));
1842
- }
1843
- /** Read the Slack file identity retained by the Chat SDK. */
1844
- function getSlackFileId(attachment) {
1845
- const metadataId = attachment.fetchMetadata?.fileId;
1846
- if (metadataId) return metadataId;
1847
- return (attachment.url ?? attachment.fetchMetadata?.url ?? "").match(/(?:^|[-/])(F[A-Z0-9]+)(?:[-/]|$)/i)?.[1];
1848
- }
1849
- //#endregion
1850
- //#region src/api/conversations/message-attachments.ts
1851
- /** Resolve saved Slack file references without exposing private storage details. */
1852
- async function hydrateMessageAttachments(db, conversationId, events) {
1853
- const messages = events.flatMap((event) => event.data.type === "message" ? [event.data] : []);
1854
- const ids = [...new Set(messages.flatMap((message) => slackFileIds(message.meta)))];
1855
- if (!ids.length) return;
1856
- const attachments = await matchSlackAttachments({
1857
- db,
1858
- conversationId,
1859
- providerIds: ids
1860
- });
1861
- const byFileId = new Map(attachments.map(({ providerId, ...attachment }) => [providerId, attachment]));
1862
- for (const message of messages) {
1863
- const stored = readMessageAttachments(message.meta?.attachments);
1864
- const matched = slackFileIds(message.meta).flatMap((id) => {
1865
- const attachment = byFileId.get(id);
1866
- return attachment ? [attachment] : [];
1867
- });
1868
- if (matched.length) message.meta = {
1869
- ...message.meta,
1870
- attachments: [...stored, ...matched]
1871
- };
1872
- }
1873
- }
1874
- function slackFileIds(meta) {
1875
- const ids = meta?.slackFileIds ?? meta?.imageFileIds;
1876
- return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1877
- }
1878
- //#endregion
1879
- //#region src/chat/sentry-links.ts
1880
- function getSentryOrgSlug() {
1881
- return process.env.SENTRY_ORG_SLUG?.trim() || void 0;
1882
- }
1883
- function isSentrySaasDsnHost(host) {
1884
- return host === "sentry.io" || host.endsWith(".sentry.io");
1885
- }
1886
- function buildSentryWebBaseUrl(dsn) {
1887
- if (isSentrySaasDsnHost(dsn.host)) return "https://sentry.io";
1888
- const port = dsn.port ? `:${dsn.port}` : "";
1889
- const path = dsn.path ? `/${dsn.path}` : "";
1890
- return `${dsn.protocol}://${dsn.host}${port}${path}`;
1891
- }
1892
- function buildSentryOrgPath(args) {
1893
- const dsn = getClient$1()?.getDsn();
1894
- if (!dsn?.host || !dsn.projectId) return;
1895
- const orgSlug = getSentryOrgSlug();
1896
- if (!orgSlug) return;
1897
- const params = new URLSearchParams();
1898
- params.set("project", dsn.projectId);
1899
- if (args.query) for (const [key, value] of Object.entries(args.query)) params.set(key, value);
1900
- const path = `${args.path}?${params.toString()}`;
1901
- if (isSentrySaasDsnHost(dsn.host)) return `https://${orgSlug}.sentry.io/${path}`;
1902
- return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`;
1903
- }
1904
- /** Build a Sentry conversation URL only when the runtime has enough Sentry config. */
1905
- function buildSentryConversationUrl(conversationId) {
1906
- return buildSentryOrgPath({ path: `explore/conversations/${encodeURIComponent(conversationId)}/` });
1907
- }
1908
- /** Build a Sentry event URL only when the runtime has enough Sentry config. */
1909
- function buildSentryEventUrl(eventId) {
1910
- return buildSentryOrgPath({
1911
- path: "issues/",
1912
- query: { query: eventId }
1913
- });
1914
- }
1915
- //#endregion
1916
- //#region src/api/conversations/event-model.ts
1917
- /** Apply recorded model choices without carrying a previous turn's settings forward. */
1918
- function conversationEventModel(data, current) {
1919
- if (data.type === "turn_started") return void 0;
1920
- if (data.type === "turn_routed" || data.type === "handoff") return {
1921
- modelId: data.modelId,
1922
- modelProfile: data.modelProfile,
1923
- ...data.reasoningLevel ? { reasoningLevel: data.reasoningLevel } : void 0
1924
- };
1925
- if (data.type === "assistant_message") {
1926
- const model = text$1(data.model);
1927
- const provider = text$1(data.provider);
1928
- if (model) {
1929
- const modelId = model.includes("/") || !provider ? model : `${provider}/${model}`;
1930
- return {
1931
- ...current,
1932
- modelId
1933
- };
1934
- }
1935
- }
1936
- return current;
1937
- }
1938
- function text$1(value) {
1939
- return typeof value === "string" && value.length > 0 ? value : void 0;
1940
- }
1941
- /** Allowlist model-call diagnostics; never expose opaque provider metadata. */
1942
- function conversationEventModelCall(data) {
1943
- if (data.type !== "assistant_message") return void 0;
1944
- const usage = extractGenAiUsageSummary(data);
1945
- const provider = text$1(data.provider);
1946
- const api = text$1(data.api);
1947
- const stopReason = text$1(data.stopReason);
1948
- return {
1949
- ...provider ? { provider } : void 0,
1950
- ...api ? { api } : void 0,
1951
- ...stopReason ? { stopReason } : void 0,
1952
- ...hasAgentTurnUsage(usage) ? { usage } : void 0
1953
- };
1954
- }
1955
- //#endregion
1956
- //#region src/api/conversations/events.ts
1957
- /** Canonical event types that can contribute to the reporting projection. */
1958
- const conversationReportSourceEventTypes = [
1959
- "message",
1960
- "message_handled",
1961
- "assistant_message",
1962
- "tool_result",
1963
- "tool_execution_started",
1964
- "guardian_action_reviewed",
1965
- "turn_started",
1966
- "turn_context",
1967
- "structured_event",
1968
- "attachments_delivered",
1969
- "turn_routed",
1970
- "turn_completed",
1971
- "turn_failed",
1972
- "compaction",
1973
- "handoff",
1974
- "subagent_started",
1975
- "subagent_ended"
1976
- ];
1977
- const reportingAssistantMessageSchema = z.object({
1978
- type: z.literal("assistant_message"),
1979
- content: z.array(z.unknown())
1980
- }).passthrough();
1981
- const reportingToolCallPartSchema = z.object({
1982
- type: z.literal("toolCall"),
1983
- id: z.string().min(1),
1984
- name: z.string().min(1),
1985
- arguments: z.unknown()
1986
- }).passthrough();
1987
- const reportingToolResultMessageSchema = z.object({
1988
- type: z.literal("tool_result"),
1989
- toolCallId: z.string().min(1),
1990
- toolName: z.string().min(1).optional(),
1991
- name: z.string().min(1).optional(),
1992
- content: z.array(z.unknown()),
1993
- details: z.unknown().optional(),
1994
- isError: z.boolean().optional()
1995
- }).passthrough();
1996
- const reportingToolResultDetailsSchema = z.object({
1997
- ok: z.boolean().optional(),
1998
- status: z.string().optional()
1999
- }).passthrough();
2000
- const reportingTextPartSchema = z.object({
2001
- type: z.literal("text"),
2002
- text: z.string()
2003
- }).passthrough();
2004
- const reportingReasoningPartSchema = z.object({
2005
- type: z.literal("thinking"),
2006
- thinking: z.string(),
2007
- redacted: z.literal(true).optional()
2008
- }).passthrough();
2009
- const reportingMediaPartSchema = z.object({
2010
- type: z.enum(["image", "audio"]),
2011
- mimeType: z.string().min(1).optional()
2012
- }).passthrough();
2013
- const reportingMessageAuthorSchema = z.object({
2014
- fullName: z.string().min(1).optional(),
2015
- userId: z.string().min(1).optional(),
2016
- userName: z.string().min(1).optional()
2017
- }).passthrough();
2018
- /** Retain model-visible text and media descriptors, dropping all opaque fields. */
2019
- function sanitizeModelContentPart(value) {
2020
- const text = reportingTextPartSchema.safeParse(value);
2021
- if (text.success) return {
2022
- type: "text",
2023
- text: text.data.text
2024
- };
2025
- const media = reportingMediaPartSchema.safeParse(value);
2026
- if (!media.success) return void 0;
2027
- return {
2028
- type: media.data.type,
2029
- ...media.data.mimeType ? { mimeType: media.data.mimeType } : void 0
2030
- };
2031
- }
2032
- /** Convert allowlisted Pi content parts into the reporting result value. */
2033
- function modelVisibleToolOutput(content) {
2034
- const sanitized = content.map(sanitizeModelContentPart).filter((part) => part !== void 0);
2035
- if (sanitized.length === 0) return void 0;
2036
- if (sanitized.length !== 1) return sanitized;
2037
- const only = sanitized[0];
2038
- return only.type === "text" ? only.text : only;
2039
- }
2040
- function reportMessageActorIdentity(data) {
2041
- if (data.role !== "user") return void 0;
2042
- const author = reportingMessageAuthorSchema.safeParse(data.meta?.author);
2043
- if (!author.success) return void 0;
2044
- const actorIdentity = {
2045
- ...author.data.fullName ? { fullName: author.data.fullName } : void 0,
2046
- ...author.data.userId ? { slackUserId: author.data.userId } : void 0,
2047
- ...author.data.userName ? { slackUserName: author.data.userName } : void 0
2048
- };
2049
- return Object.keys(actorIdentity).length > 0 ? actorIdentity : void 0;
2050
- }
2051
- /** Project native assistant tool calls through the existing reporting shape. */
2052
- function reportToolCalls(args) {
2053
- const message = reportingAssistantMessageSchema.safeParse(args.message);
2054
- if (!message.success) return void 0;
2055
- const calls = message.data.content.flatMap((part) => {
2056
- const call = reportingToolCallPartSchema.safeParse(part);
2057
- if (!call.success) return [];
2058
- return [{
2059
- toolCallId: call.data.id,
2060
- name: call.data.name,
2061
- status: "running",
2062
- startedAt: new Date(args.createdAtMs).toISOString(),
2063
- startedSeq: args.seq,
2064
- ...args.canExposePayload && call.data.arguments !== void 0 ? { input: call.data.arguments } : void 0
2065
- }];
2066
- });
2067
- return calls.length > 0 ? {
2068
- type: "tool_calls",
2069
- calls
2070
- } : void 0;
2071
- }
2072
- /** Project assistant reasoning with tool ids retained only for display order. */
2073
- function reportAssistantMessage(args) {
2074
- const message = reportingAssistantMessageSchema.safeParse(args.message);
2075
- if (!message.success) return void 0;
2076
- if (!message.data.content.some((part) => {
2077
- const reasoning = reportingReasoningPartSchema.safeParse(part);
2078
- return reasoning.success && reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0;
2079
- })) return reportToolCalls(args) ?? {
2080
- type: "assistant_message",
2081
- parts: []
2082
- };
2083
- const reasoningParts = [];
2084
- const assistantParts = [];
2085
- for (const part of message.data.content) {
2086
- const reasoning = reportingReasoningPartSchema.safeParse(part);
2087
- if (reasoning.success) {
2088
- if (reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0) {
2089
- const projected = {
2090
- type: "reasoning",
2091
- ...args.canExposePayload ? { text: reasoning.data.thinking } : { redacted: true }
2092
- };
2093
- reasoningParts.push(projected);
2094
- assistantParts.push(projected);
2095
- }
2096
- continue;
2097
- }
2098
- const call = reportingToolCallPartSchema.safeParse(part);
2099
- if (call.success) assistantParts.push({
2100
- type: "tool_call",
2101
- toolCallId: call.data.id
2102
- });
2103
- }
2104
- const toolCalls = reportToolCalls(args);
2105
- if (toolCalls?.type === "tool_calls") return {
2106
- ...toolCalls,
2107
- assistant: { parts: assistantParts }
2108
- };
2109
- return reasoningParts.length > 0 ? {
2110
- type: "assistant_message",
2111
- parts: reasoningParts
2112
- } : void 0;
2113
- }
2114
- /** Project a native tool result without exposing host-only result details. */
2115
- function reportToolResult(args) {
2116
- const message = reportingToolResultMessageSchema.safeParse(args.message);
2117
- if (!message.success) return void 0;
2118
- const details = reportingToolResultDetailsSchema.safeParse(message.data.details);
2119
- const outcome = message.data.isError === true || details.success && (details.data.ok === false || details.data.status === "error") ? "error" : "completed";
2120
- const output = modelVisibleToolOutput(message.data.content);
2121
- const name = message.data.toolName ?? message.data.name ?? args.start?.name;
2122
- if (!name) return void 0;
2123
- return {
2124
- type: "tool_calls",
2125
- calls: [{
2126
- toolCallId: message.data.toolCallId,
2127
- name,
2128
- status: outcome,
2129
- ...args.start ? {
2130
- startedAt: new Date(args.start.createdAtMs).toISOString(),
2131
- startedSeq: args.start.seq
2132
- } : void 0,
2133
- ...args.canExposePayload && output !== void 0 ? { output } : void 0
2134
- }]
2135
- };
601
+ /** Project a native tool result without exposing host-only result details. */
602
+ function reportToolResult(args) {
603
+ const message = reportingToolResultMessageSchema.safeParse(args.message);
604
+ if (!message.success) return void 0;
605
+ const details = reportingToolResultDetailsSchema.safeParse(message.data.details);
606
+ const outcome = message.data.isError === true || details.success && (details.data.ok === false || details.data.status === "error") ? "error" : "completed";
607
+ const output = modelVisibleToolOutput(message.data.content);
608
+ const name = message.data.toolName ?? message.data.name ?? args.start?.name;
609
+ if (!name) return void 0;
610
+ return {
611
+ type: "tool_calls",
612
+ calls: [{
613
+ toolCallId: message.data.toolCallId,
614
+ name,
615
+ status: outcome,
616
+ ...args.start ? {
617
+ startedAt: new Date(args.start.createdAtMs).toISOString(),
618
+ startedSeq: args.start.seq
619
+ } : void 0,
620
+ ...args.canExposePayload && output !== void 0 ? { output } : void 0
621
+ }]
622
+ };
2136
623
  }
2137
624
  /** Return tool result ids that may need start metadata from outside a page. */
2138
625
  function conversationReportToolResultIds(events) {
@@ -3305,982 +1792,945 @@ async function updateTimerIndex(state, operation) {
3305
1792
  }
3306
1793
  }
3307
1794
  //#endregion
3308
- //#region src/chat/events/store.ts
3309
- const EVENT_PREFIX = "junior:resource_event_subscription:v5";
3310
- const INDEX_LOCK_TTL_MS = 1e4;
3311
- const SUBSCRIPTION_LOCK_TTL_MS = 1e4;
3312
- const SUBSCRIPTION_LOCK_WAIT_MS = 1e4;
3313
- const SUBSCRIPTION_LOCK_RETRY_MS = 25;
3314
- const SUBSCRIPTION_LOCK_HEARTBEAT_MS = 3e3;
3315
- const subscriptionStatusSchema = z.enum([
3316
- "active",
3317
- "cancelled",
3318
- "completed"
3319
- ]);
3320
- const subscriptionIdIndexSchema = z.array(z.string().min(1));
3321
- const subscriptionSchema = z.object({
3322
- conversationId: z.string().min(1),
3323
- createdAtMs: z.number().finite(),
3324
- events: z.array(eventTypeSchema).min(1),
3325
- expiresAtMs: z.number().finite(),
3326
- firesAtMs: z.number().finite().optional(),
3327
- id: z.string().min(1),
3328
- intent: z.string().min(1),
3329
- label: z.string().min(1),
3330
- match: eventMatchSchema.optional(),
3331
- namespace: z.string().min(1),
3332
- identifier: z.string().min(1),
3333
- resourceType: z.string().min(1),
3334
- status: subscriptionStatusSchema,
3335
- updatedAtMs: z.number().finite()
3336
- }).strict();
3337
- function digest(value) {
3338
- return createHash("sha256").update(value).digest("hex").slice(0, 32);
3339
- }
3340
- function subscriptionKey(id) {
3341
- return `${EVENT_PREFIX}:record:${id}`;
3342
- }
3343
- function subscriptionLockKey(id) {
3344
- return `${EVENT_PREFIX}:lock:${id}`;
3345
- }
3346
- function resourceIndexKey(namespace, identifier) {
3347
- return `${EVENT_PREFIX}:resource:${digest(`${namespace}\0${identifier}`)}`;
3348
- }
3349
- function conversationIndexKey(conversationId) {
3350
- return `${EVENT_PREFIX}:conversation:${digest(conversationId)}`;
1795
+ //#region src/chat/slack/workspace-context.ts
1796
+ const workspaceTeamIdStorage = new AsyncLocalStorage();
1797
+ /** Run a callback with the Slack workspace team ID for the inbound webhook. */
1798
+ function runWithWorkspaceTeamId(teamId, fn) {
1799
+ if (!teamId) return fn();
1800
+ return workspaceTeamIdStorage.run(teamId, fn);
3351
1801
  }
3352
- function indexLockKey(key) {
3353
- return `${key}:lock`;
1802
+ /** Return the Slack workspace team ID for the current inbound webhook. */
1803
+ function getWorkspaceTeamId() {
1804
+ return workspaceTeamIdStorage.getStore();
3354
1805
  }
3355
- async function sleep(ms) {
3356
- await new Promise((resolve) => {
3357
- setTimeout(resolve, ms).unref?.();
3358
- });
1806
+ //#endregion
1807
+ //#region src/chat/slack/client.ts
1808
+ var SlackActionError = class extends Error {
1809
+ code;
1810
+ apiError;
1811
+ needed;
1812
+ provided;
1813
+ statusCode;
1814
+ requestId;
1815
+ errorData;
1816
+ retryAfterSeconds;
1817
+ detail;
1818
+ detailLine;
1819
+ detailRule;
1820
+ constructor(message, code, options = {}) {
1821
+ super(message);
1822
+ this.name = "SlackActionError";
1823
+ this.code = code;
1824
+ this.apiError = options.apiError;
1825
+ this.needed = options.needed;
1826
+ this.provided = options.provided;
1827
+ this.statusCode = options.statusCode;
1828
+ this.requestId = options.requestId;
1829
+ this.errorData = options.errorData;
1830
+ this.retryAfterSeconds = options.retryAfterSeconds;
1831
+ this.detail = options.detail;
1832
+ this.detailLine = options.detailLine;
1833
+ this.detailRule = options.detailRule;
1834
+ }
1835
+ };
1836
+ function serializeSlackErrorData(data) {
1837
+ if (!data || typeof data !== "object") return;
1838
+ const filtered = Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error"));
1839
+ if (Object.keys(filtered).length === 0) return;
1840
+ try {
1841
+ const serialized = JSON.stringify(filtered);
1842
+ return serialized.length <= 600 ? serialized : `${serialized.slice(0, 597)}...`;
1843
+ } catch {
1844
+ return;
1845
+ }
3359
1846
  }
3360
- async function acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs = Date.now() + SUBSCRIPTION_LOCK_WAIT_MS) {
3361
- while (true) {
3362
- const lock = await state.acquireLock(subscriptionLockKey(subscriptionId), SUBSCRIPTION_LOCK_TTL_MS);
3363
- if (lock) return lock;
3364
- if (Date.now() >= waitDeadlineMs) throw new Error(`Could not acquire watch lock for ${subscriptionId}`);
3365
- await sleep(SUBSCRIPTION_LOCK_RETRY_MS);
1847
+ /** Extract a header value by case-insensitive name from a raw headers object. */
1848
+ function getHeaderString(headers, name) {
1849
+ if (!headers || typeof headers !== "object") return;
1850
+ const key = name.toLowerCase();
1851
+ const entries = headers;
1852
+ for (const [entryKey, value] of Object.entries(entries)) {
1853
+ if (entryKey.toLowerCase() !== key) continue;
1854
+ if (typeof value === "string") return value;
1855
+ if (Array.isArray(value)) {
1856
+ const first = value.find((entry) => typeof entry === "string");
1857
+ return typeof first === "string" ? first : void 0;
1858
+ }
3366
1859
  }
3367
1860
  }
3368
- /** Run one subscription lifecycle transition while keeping its lease alive. */
3369
- async function withSubscriptionLock(state, subscriptionId, callback, waitDeadlineMs) {
3370
- const lock = await acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs);
3371
- let lockLostError;
3372
- let heartbeatError;
3373
- let heartbeat;
3374
- const timer = setInterval(() => {
3375
- if (heartbeat) return;
3376
- heartbeat = state.extendLock(lock, SUBSCRIPTION_LOCK_TTL_MS).then((extended) => {
3377
- if (extended) heartbeatError = void 0;
3378
- else lockLostError = /* @__PURE__ */ new Error(`Watch lock was lost for ${subscriptionId}`);
3379
- }).catch((error) => {
3380
- heartbeatError = error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to extend subscription lock: ${String(error)}`);
3381
- }).finally(() => {
3382
- heartbeat = void 0;
3383
- });
3384
- }, SUBSCRIPTION_LOCK_HEARTBEAT_MS);
3385
- timer.unref?.();
3386
- let result;
3387
- try {
3388
- result = await callback();
3389
- } finally {
3390
- clearInterval(timer);
3391
- await heartbeat;
3392
- await state.releaseLock(lock);
1861
+ function parseSlackCanvasDetail(detail) {
1862
+ if (typeof detail !== "string") return {};
1863
+ const trimmed = detail.trim();
1864
+ if (!trimmed) return {};
1865
+ const parsed = { detail: trimmed };
1866
+ const lineMatch = trimmed.match(/line\s+(\d+):/i);
1867
+ if (lineMatch) {
1868
+ const line = Number.parseInt(lineMatch[1] ?? "", 10);
1869
+ if (Number.isFinite(line)) parsed.detailLine = line;
3393
1870
  }
3394
- const lockError = lockLostError ?? heartbeatError;
3395
- if (lockError) throw lockError;
3396
- return result;
1871
+ if (/unsupported heading depth/i.test(trimmed)) parsed.detailRule = "unsupported_heading_depth";
1872
+ return parsed;
3397
1873
  }
3398
- function ttlUntil(expiresAtMs, nowMs) {
3399
- return Math.max(1, expiresAtMs - nowMs);
1874
+ /**
1875
+ * Ambient destination-installation token for Slack outbound calls, bound by
1876
+ * installation-scoped entry points so writes carry the destination
1877
+ * workspace's credentials instead of the process-global env token.
1878
+ */
1879
+ const installationTokenStorage = new AsyncLocalStorage();
1880
+ const clientsByToken = /* @__PURE__ */ new Map();
1881
+ /**
1882
+ * Bind a workspace installation token for all Slack Web API calls inside
1883
+ * `fn`, so multi-workspace outbound writes use the destination workspace's
1884
+ * credentials rather than the env fallback token.
1885
+ */
1886
+ function runWithSlackInstallationToken(token, fn) {
1887
+ const trimmed = token.trim();
1888
+ if (!trimmed) throw new SlackActionError("Slack installation token binding requires a non-empty token", "missing_token");
1889
+ return installationTokenStorage.run({ token: trimmed }, fn);
3400
1890
  }
3401
- async function readSubscriptionIdIndex(state, key) {
3402
- const value = await state.get(key);
3403
- if (value === void 0 || value === null) return [];
3404
- return subscriptionIdIndexSchema.parse(value);
1891
+ /**
1892
+ * Token precedence: ambient installation token, then env token, then a hard
1893
+ * failure for team-scoped calls — a call scoped to one workspace must never
1894
+ * silently fall through to another workspace's credentials.
1895
+ */
1896
+ function resolveSlackToken() {
1897
+ const ambientToken = installationTokenStorage.getStore()?.token;
1898
+ if (ambientToken) return ambientToken;
1899
+ const envToken = getSlackBotToken();
1900
+ if (envToken) return envToken;
1901
+ const teamId = getWorkspaceTeamId();
1902
+ if (teamId) throw new SlackActionError(`Slack call is scoped to workspace ${teamId} but no installation token is bound and no default bot token is configured`, "missing_token");
1903
+ throw new SlackActionError("SLACK_BOT_TOKEN (or SLACK_BOT_USER_TOKEN) is required for Slack Web API actions in this service", "missing_token");
3405
1904
  }
3406
- function buildSubscriptionId(input) {
3407
- const eventKey = [...new Set(input.events)].sort().join("\0");
3408
- const matchKey = stableEventMatchKey(input.match);
3409
- return `resub_${digest(matchKey ? `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}\0${matchKey}` : `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}`)}`;
1905
+ /** Normalize Junior Slack references to native Slack conversation IDs. */
1906
+ function normalizeSlackConversationId(channelId) {
1907
+ return parseSlackChannelReferenceId(channelId);
3410
1908
  }
3411
- async function withIndexLock(state, key, callback) {
3412
- const lock = await state.acquireLock(indexLockKey(key), INDEX_LOCK_TTL_MS);
3413
- if (!lock) throw new Error(`Could not acquire event index lock for ${key}`);
3414
- try {
3415
- return await callback();
3416
- } finally {
3417
- await state.releaseLock(lock);
1909
+ /**
1910
+ * Return the per-token cached WebClient. `withSlackRetries` owns retry
1911
+ * classification for this boundary: the WebClient's built-in policy would
1912
+ * re-post timed-out writes (a duplicate-message hazard) and sleep for the
1913
+ * full unbounded Retry-After inside the request, so both internal behaviors
1914
+ * are disabled here.
1915
+ */
1916
+ function getClient() {
1917
+ const token = resolveSlackToken();
1918
+ let cached = clientsByToken.get(token);
1919
+ if (!cached) {
1920
+ cached = new WebClient(token, {
1921
+ retryConfig: { retries: 0 },
1922
+ rejectRateLimitedCalls: true
1923
+ });
1924
+ clientsByToken.set(token, cached);
3418
1925
  }
1926
+ return cached;
3419
1927
  }
3420
- async function addToIndex(state, key, subscriptionId, nowMs) {
3421
- await withIndexLock(state, key, async () => {
3422
- const ids = [...new Set(await readSubscriptionIdIndex(state, key))];
3423
- const next = ids.includes(subscriptionId) ? ids : [...ids, subscriptionId];
3424
- await state.set(key, next, await indexTtlMs(state, next, nowMs));
1928
+ function mapSlackError(error) {
1929
+ if (error instanceof SlackActionError) return error;
1930
+ const candidate = error;
1931
+ const apiError = candidate.data?.error;
1932
+ const message = candidate.message ?? "Slack action failed";
1933
+ const baseOptions = {
1934
+ apiError,
1935
+ statusCode: candidate.statusCode,
1936
+ requestId: getHeaderString(candidate.headers, "x-slack-req-id"),
1937
+ errorData: serializeSlackErrorData(candidate.data),
1938
+ ...parseSlackCanvasDetail(candidate.data?.detail)
1939
+ };
1940
+ if (apiError === "missing_scope") return new SlackActionError(message, "missing_scope", {
1941
+ ...baseOptions,
1942
+ needed: candidate.data?.needed,
1943
+ provided: candidate.data?.provided
3425
1944
  });
3426
- }
3427
- async function removeFromIndex(state, key, subscriptionId, nowMs) {
3428
- await withIndexLock(state, key, async () => {
3429
- const next = (await readSubscriptionIdIndex(state, key)).filter((id) => id !== subscriptionId);
3430
- await state.set(key, next, await indexTtlMs(state, next, nowMs));
1945
+ if (apiError === "not_in_channel") return new SlackActionError(message, "not_in_channel", baseOptions);
1946
+ if (apiError === "restricted_action_read_only_channel") return new SlackActionError(message, "read_only_channel", baseOptions);
1947
+ if (apiError === "already_reacted") return new SlackActionError(message, "already_reacted", baseOptions);
1948
+ if (apiError === "no_reaction") return new SlackActionError(message, "no_reaction", baseOptions);
1949
+ if (apiError === "invalid_arguments") return new SlackActionError(message, "invalid_arguments", baseOptions);
1950
+ if (apiError === "invalid_cursor") return new SlackActionError(message, "invalid_arguments", baseOptions);
1951
+ if (apiError === "invalid_name") return new SlackActionError(message, "invalid_arguments", baseOptions);
1952
+ if (apiError === "not_found" || apiError === "channel_not_found" || apiError === "message_not_found") return new SlackActionError(message, "not_found", baseOptions);
1953
+ if (apiError === "feature_not_enabled" || apiError === "not_allowed_token_type") return new SlackActionError(message, "feature_unavailable", baseOptions);
1954
+ if (apiError === "canvas_creation_failed") return new SlackActionError(message, "canvas_creation_failed", baseOptions);
1955
+ if (apiError === "canvas_editing_failed") return new SlackActionError(message, "canvas_editing_failed", baseOptions);
1956
+ if (candidate.code === "slack_webapi_rate_limited_error" || candidate.statusCode === 429) return new SlackActionError(message, "rate_limited", {
1957
+ ...baseOptions,
1958
+ retryAfterSeconds: candidate.retryAfter
3431
1959
  });
1960
+ return new SlackActionError(message, "internal_error", baseOptions);
3432
1961
  }
3433
- async function indexTtlMs(state, subscriptionIds, nowMs) {
3434
- const records = await Promise.all(subscriptionIds.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
3435
- return ttlUntil(Math.max(nowMs, ...records.filter((record) => record !== void 0 && activeAt(record, nowMs)).map((record) => record.expiresAtMs)), nowMs);
3436
- }
3437
- function parseSubscription(value) {
3438
- if (value === void 0 || value === null) return;
3439
- return subscriptionSchema.parse(value);
3440
- }
3441
- function activeAt(subscription, nowMs) {
3442
- return subscription.status === "active" && subscription.expiresAtMs > nowMs;
3443
- }
3444
- function matchesEvent(subscription, input) {
3445
- return subscription.namespace === input.namespace && subscription.identifier === input.identifier && subscription.events.includes(input.eventType) && activeAt(subscription, input.nowMs) && eventMatches(subscription.match, input.data);
3446
- }
3447
- /** Create or replace the active subscription for one conversation/resource/event set. */
3448
- async function createWatch(input, options = {}) {
3449
- const state = options.state ?? getStateAdapter();
3450
- await state.connect();
3451
- const nowMs = options.nowMs ?? Date.now();
3452
- const events = [...new Set(input.events.map((event) => event.trim()))].filter(Boolean);
3453
- if (events.length === 0) throw new Error("Watch requires at least one event");
3454
- if (input.expiresAtMs <= nowMs) throw new Error("Watch expiry must be in the future");
3455
- const match = input.match && Object.keys(input.match).length > 0 ? input.match : void 0;
3456
- const id = buildSubscriptionId({
3457
- conversationId: input.conversationId,
3458
- events,
3459
- match,
3460
- namespace: input.namespace,
3461
- identifier: input.identifier
3462
- });
3463
- const record = {
3464
- conversationId: input.conversationId,
3465
- createdAtMs: nowMs,
3466
- events,
3467
- expiresAtMs: input.expiresAtMs,
3468
- id,
3469
- intent: input.intent,
3470
- label: input.label,
3471
- ...match ? { match } : void 0,
3472
- namespace: input.namespace,
3473
- identifier: input.identifier,
3474
- resourceType: input.resourceType,
3475
- status: "active",
3476
- updatedAtMs: nowMs
3477
- };
3478
- const parsed = subscriptionSchema.parse(record);
3479
- await state.set(subscriptionKey(id), parsed, ttlUntil(parsed.expiresAtMs, nowMs));
3480
- await addToIndex(state, resourceIndexKey(input.namespace, input.identifier), id, nowMs);
3481
- await addToIndex(state, conversationIndexKey(input.conversationId), id, nowMs);
3482
- return parsed;
1962
+ const MAX_RETRY_DELAY_MS = 1e4;
1963
+ const MAX_TOTAL_RETRY_DELAY_MS = 2e4;
1964
+ const CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
1965
+ "ECONNREFUSED",
1966
+ "ECONNRESET",
1967
+ "ENOTFOUND",
1968
+ "EAI_AGAIN"
1969
+ ]);
1970
+ const TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
1971
+ "ETIMEDOUT",
1972
+ "ECONNABORTED",
1973
+ "ESOCKETTIMEDOUT"
1974
+ ]);
1975
+ function findNetworkErrorCode(error, depth = 0) {
1976
+ if (!error || typeof error !== "object" || depth > 4) return;
1977
+ const candidate = error;
1978
+ if (typeof candidate.code === "string" && (CONNECTION_ERROR_CODES.has(candidate.code) || TIMEOUT_ERROR_CODES.has(candidate.code))) return candidate.code;
1979
+ return findNetworkErrorCode(candidate.original, depth + 1) ?? findNetworkErrorCode(candidate.cause, depth + 1);
3483
1980
  }
3484
- /** Create a retry-stable timer without keeping a process open. */
3485
- async function createTimerWatch(input) {
3486
- const state = getStateAdapter();
3487
- await state.connect();
3488
- const identifier = digest(`${input.conversationId}\0${input.toolCallId}`);
3489
- const selector = {
3490
- conversationId: input.conversationId,
3491
- namespace: "junior",
3492
- identifier,
3493
- events: ["timer.fired"]
3494
- };
3495
- const id = buildSubscriptionId(selector);
3496
- return await withSubscriptionLock(state, id, async () => {
3497
- const existing = parseSubscription(await state.get(subscriptionKey(id)));
3498
- if (existing) return existing;
3499
- const nowMs = Date.now();
3500
- const firesAtMs = nowMs + input.afterMs;
3501
- const expiresAtMs = firesAtMs + 864e5;
3502
- const record = subscriptionSchema.parse({
3503
- ...selector,
3504
- id,
3505
- firesAtMs,
3506
- expiresAtMs,
3507
- resourceType: "timer",
3508
- label: "Timer",
3509
- intent: input.intent,
3510
- status: "active",
3511
- createdAtMs: nowMs,
3512
- updatedAtMs: nowMs
3513
- });
3514
- await withIndexLock(state, conversationIndexKey(input.conversationId), async () => {
3515
- const key = conversationIndexKey(input.conversationId);
3516
- const ids = [.../* @__PURE__ */ new Set([...await readSubscriptionIdIndex(state, key), id])];
3517
- await state.set(key, ids, Math.max(ttlUntil(expiresAtMs, nowMs), await indexTtlMs(state, ids, nowMs)));
3518
- await state.set(resourceIndexKey("junior", identifier), [id], ttlUntil(expiresAtMs, nowMs));
3519
- await updateTimerIndex(state, {
3520
- kind: "add",
3521
- id,
3522
- atMs: firesAtMs
3523
- });
3524
- await state.set(subscriptionKey(id), record, ttlUntil(expiresAtMs, nowMs));
3525
- });
3526
- return record;
3527
- });
1981
+ function hasSocketHangUpMessage(error) {
1982
+ return error instanceof Error && error.message.toLowerCase().includes("socket hang up");
3528
1983
  }
3529
- /** Claim live timer Watches and discard stale due-index entries. */
3530
- async function takeDueTimerWatches(nowMs) {
3531
- const state = getStateAdapter();
3532
- await state.connect();
3533
- const watches = [];
3534
- for (const id of await updateTimerIndex(state, {
3535
- kind: "claim",
3536
- nowMs
3537
- })) await withSubscriptionLock(state, id, async () => {
3538
- const watch = parseSubscription(await state.get(subscriptionKey(id)));
3539
- if (watch && activeAt(watch, nowMs) && watch.firesAtMs !== void 0) {
3540
- if (watch.firesAtMs <= nowMs) watches.push(watch);
3541
- } else await updateTimerIndex(state, {
3542
- kind: "remove",
3543
- id
3544
- });
3545
- });
3546
- return watches;
1984
+ /**
1985
+ * Classify a failed Slack call for retry: rate limits, Slack 5xx, and
1986
+ * connection-phase failures are always retryable; timeouts are retried only
1987
+ * for idempotent operations because Slack may already have accepted the
1988
+ * request.
1989
+ */
1990
+ function classifySlackRetry(raw, mapped) {
1991
+ if (mapped.code === "rate_limited") return "rate_limited";
1992
+ if (mapped.statusCode !== void 0 && mapped.statusCode >= 500) return "server_error";
1993
+ const networkCode = findNetworkErrorCode(raw);
1994
+ if (networkCode && CONNECTION_ERROR_CODES.has(networkCode)) return "connection";
1995
+ if (networkCode && TIMEOUT_ERROR_CODES.has(networkCode)) return "timeout";
1996
+ if (hasSocketHangUpMessage(raw)) return "connection";
1997
+ return "none";
3547
1998
  }
3548
- /** List active subscriptions bound to one conversation. */
3549
- async function listWatches(input) {
3550
- const state = input.state ?? getStateAdapter();
3551
- await state.connect();
3552
- const nowMs = input.nowMs ?? Date.now();
3553
- const ids = await readSubscriptionIdIndex(state, conversationIndexKey(input.conversationId));
3554
- return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && record.conversationId === input.conversationId && activeAt(record, nowMs)).sort((left, right) => left.createdAtMs - right.createdAtMs);
1999
+ /**
2000
+ * Run a Slack Web API call with bounded retries so transient platform
2001
+ * failures do not surface as turn failures, while non-idempotent posts are
2002
+ * never re-sent when Slack may already have accepted them.
2003
+ *
2004
+ * Retry classes: rate limits (bounded Retry-After), connection-phase network
2005
+ * failures, and Slack 5xx responses always retry; request timeouts retry only
2006
+ * when the caller marks the operation `idempotent`.
2007
+ */
2008
+ async function withSlackRetries(task, maxAttempts = 3, context = {}) {
2009
+ let attempt = 0;
2010
+ let totalDelayMs = 0;
2011
+ const action = context.action ?? "unknown";
2012
+ while (attempt < maxAttempts) {
2013
+ attempt += 1;
2014
+ const attemptNumber = attempt;
2015
+ try {
2016
+ return await withSpan(`POST slack.com/api/${action}`, "http.client", {}, async () => {
2017
+ try {
2018
+ return await task();
2019
+ } catch (error) {
2020
+ const mapped = mapSlackError(error);
2021
+ const errorAttrs = { "error.type": mapped.code };
2022
+ if (mapped.apiError) errorAttrs["app.slack.api_error_code"] = mapped.apiError;
2023
+ if (mapped.code === "rate_limited") {
2024
+ errorAttrs["http.response.status_code"] = 429;
2025
+ if (mapped.retryAfterSeconds) errorAttrs["app.slack.retry_after_ms"] = mapped.retryAfterSeconds * 1e3;
2026
+ } else if (mapped.statusCode != null) errorAttrs["http.response.status_code"] = mapped.statusCode;
2027
+ setSpanAttributes(errorAttrs);
2028
+ setSpanStatus("error");
2029
+ throw error;
2030
+ }
2031
+ }, {
2032
+ "http.request.method": "POST",
2033
+ "server.address": "slack.com",
2034
+ "url.scheme": "https",
2035
+ "url.path": `/api/${action}`,
2036
+ "app.slack.method": action,
2037
+ "app.retry.max_attempts": maxAttempts,
2038
+ ...attemptNumber > 1 ? { "http.request.resend_count": attemptNumber - 1 } : void 0,
2039
+ ...context.attributes ?? {},
2040
+ ...context.spanAttributes ?? {}
2041
+ });
2042
+ } catch (error) {
2043
+ const mapped = mapSlackError(error);
2044
+ const retryClass = classifySlackRetry(error, mapped);
2045
+ const isRetryable = retryClass === "rate_limited" || retryClass === "connection" || retryClass === "server_error" || retryClass === "timeout" && context.idempotent === true;
2046
+ const remainingDelayBudgetMs = MAX_TOTAL_RETRY_DELAY_MS - totalDelayMs;
2047
+ const baseLogAttributes = {
2048
+ "app.slack.action": action,
2049
+ "app.slack.error_code": mapped.code,
2050
+ ...mapped.apiError ? { "app.slack.api_error": mapped.apiError } : void 0,
2051
+ ...mapped.detail ? { "app.slack.detail": mapped.detail } : void 0,
2052
+ ...mapped.detailLine !== void 0 ? { "app.slack.detail_line": mapped.detailLine } : void 0,
2053
+ ...mapped.detailRule ? { "app.slack.detail_rule": mapped.detailRule } : void 0,
2054
+ ...mapped.requestId ? { "app.slack.request_id": mapped.requestId } : void 0,
2055
+ ...mapped.statusCode !== void 0 ? { "http.response.status_code": mapped.statusCode } : void 0,
2056
+ ...context.attributes ?? {}
2057
+ };
2058
+ if (!isRetryable || attempt >= maxAttempts || remainingDelayBudgetMs <= 0) {
2059
+ logWarn("slack.action.failed", {
2060
+ ...baseLogAttributes,
2061
+ ...mapped.errorData ? { "app.slack.error_data": mapped.errorData } : void 0
2062
+ });
2063
+ throw mapped;
2064
+ }
2065
+ logWarn("slack.action.retrying", {
2066
+ ...baseLogAttributes,
2067
+ "app.slack.retry_attempt": attempt,
2068
+ "app.slack.retry_class": retryClass
2069
+ });
2070
+ const retryAfterMs = retryClass === "rate_limited" && mapped.retryAfterSeconds && mapped.retryAfterSeconds > 0 ? mapped.retryAfterSeconds * 1e3 : void 0;
2071
+ const backoffMs = Math.min(2e3, 250 * 2 ** (attempt - 1));
2072
+ const delayMs = Math.min(retryAfterMs ?? backoffMs, MAX_RETRY_DELAY_MS, remainingDelayBudgetMs);
2073
+ totalDelayMs += delayMs;
2074
+ await sleep$1(delayMs);
2075
+ }
2076
+ }
2077
+ throw new SlackActionError("Slack action exhausted retries", "internal_error");
3555
2078
  }
3556
- /** Cancel a current-conversation subscription and remove it from match indexes. */
3557
- async function cancelWatch(input) {
3558
- const state = input.state ?? getStateAdapter();
3559
- await state.connect();
3560
- return await withSubscriptionLock(state, input.id, async () => {
3561
- const current = parseSubscription(await state.get(subscriptionKey(input.id)));
3562
- if (!current || current.conversationId !== input.conversationId) return;
3563
- const nowMs = input.nowMs ?? Date.now();
3564
- if (current.status === "completed") return current;
3565
- const next = {
3566
- ...current,
3567
- status: "cancelled",
3568
- updatedAtMs: nowMs
3569
- };
3570
- await state.set(subscriptionKey(input.id), next, JUNIOR_THREAD_STATE_TTL_MS);
3571
- await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), input.id, nowMs);
3572
- await removeFromIndex(state, conversationIndexKey(current.conversationId), input.id, nowMs);
3573
- if (current.firesAtMs !== void 0) await updateTimerIndex(state, {
3574
- kind: "remove",
3575
- id: input.id
3576
- });
3577
- return next;
2079
+ /**
2080
+ * Slack Web API client for the current destination workspace: the ambient
2081
+ * installation token when one is bound, otherwise the env bot token for
2082
+ * single-workspace deployments. Fails instead of falling back when the call
2083
+ * is workspace-scoped and no installation token can be resolved, so a write
2084
+ * never goes out with another workspace's credentials.
2085
+ */
2086
+ function getSlackClient(options) {
2087
+ if (options) return new WebClient(resolveSlackToken(), {
2088
+ retryConfig: { retries: 0 },
2089
+ rejectRateLimitedCalls: true,
2090
+ timeout: options.timeoutMs
3578
2091
  });
2092
+ return getClient();
3579
2093
  }
3580
- /** Cancel every active watch bound to one conversation. */
3581
- async function cancelSubscriptions(input) {
3582
- const subscriptions = await listWatches(input);
3583
- for (const subscription of subscriptions) await cancelWatch({
3584
- conversationId: input.conversationId,
3585
- id: subscription.id,
3586
- nowMs: input.nowMs,
3587
- state: input.state
3588
- });
2094
+ /**
2095
+ * Slack channel ID prefixes:
2096
+ * - C: channel — modern private channels also use C, so the prefix never
2097
+ * proves a channel is public
2098
+ * - G: legacy private channel / group DM
2099
+ * - D: direct message (1:1)
2100
+ */
2101
+ function isDmChannel(channelId) {
2102
+ const normalized = normalizeSlackConversationId(channelId);
2103
+ return Boolean(normalized && normalized.startsWith("D"));
3589
2104
  }
3590
2105
  /**
3591
- * Collect match keys from active watches for these identifiers and event types.
3592
- * Does not evaluate match values — only reports keys that filters use.
2106
+ * Conversation-scoped Slack contexts backed by a concrete conversation ID.
2107
+ * Includes channels/groups/DMs (C/G/D).
3593
2108
  */
3594
- async function collectEventMatchKeys(input) {
3595
- const state = input.state ?? getStateAdapter();
3596
- await state.connect();
3597
- const nowMs = input.nowMs ?? Date.now();
3598
- const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
3599
- if (eventTypes.size === 0) return [];
3600
- const keys = /* @__PURE__ */ new Set();
3601
- const identifiers = new Set(input.identifiers.map((value) => value.trim()).filter(Boolean));
3602
- for (const identifier of identifiers) {
3603
- const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, identifier));
3604
- const records = await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
3605
- for (const record of records) {
3606
- if (!record) continue;
3607
- if (record.namespace !== input.namespace) continue;
3608
- if (record.identifier !== identifier) continue;
3609
- if (!activeAt(record, nowMs)) continue;
3610
- if (!record.events.some((eventType) => eventTypes.has(eventType))) continue;
3611
- for (const key of Object.keys(record.match ?? {})) keys.add(key);
3612
- }
3613
- }
3614
- return [...keys].sort();
2109
+ function isConversationScopedChannel(channelId) {
2110
+ const normalized = normalizeSlackConversationId(channelId);
2111
+ if (!normalized) return false;
2112
+ return normalized.startsWith("C") || normalized.startsWith("G") || normalized.startsWith("D");
3615
2113
  }
3616
- /** Find active subscriptions interested in a normalized event. */
3617
- async function findMatchingWatches(input) {
3618
- const state = input.state ?? getStateAdapter();
3619
- await state.connect();
3620
- const nowMs = input.nowMs ?? Date.now();
3621
- const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, input.identifier));
3622
- return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && matchesEvent(record, {
3623
- data: input.data,
3624
- eventType: input.eventType,
3625
- nowMs,
3626
- namespace: input.namespace,
3627
- identifier: input.identifier
3628
- }));
2114
+ function isConversationChannel(channelId) {
2115
+ const normalized = normalizeSlackConversationId(channelId);
2116
+ if (!normalized) return false;
2117
+ return normalized.startsWith("C") || normalized.startsWith("G");
3629
2118
  }
3630
- /** Recheck and deliver a matched subscription while holding its status lock. */
3631
- async function deliverWatch(input) {
3632
- const state = input.state ?? getStateAdapter();
3633
- await state.connect();
3634
- return await withSubscriptionLock(state, input.subscription.id, async () => {
3635
- const nowMs = input.nowMs ?? Date.now();
3636
- const current = parseSubscription(await state.get(subscriptionKey(input.subscription.id)));
3637
- if (!current || !matchesEvent(current, {
3638
- data: input.data,
3639
- eventType: input.eventType,
3640
- nowMs,
3641
- namespace: input.namespace,
3642
- identifier: input.identifier
3643
- })) return false;
3644
- const delivered = await input.deliver(current);
3645
- if (input.terminal) {
3646
- const latest = parseSubscription(await state.get(subscriptionKey(current.id)));
3647
- if (!latest || latest.status !== current.status || latest.updatedAtMs !== current.updatedAtMs) return delivered;
3648
- const next = {
3649
- ...current,
3650
- status: "completed",
3651
- updatedAtMs: nowMs
3652
- };
3653
- await state.set(subscriptionKey(current.id), next, JUNIOR_THREAD_STATE_TTL_MS);
3654
- await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), current.id, nowMs);
3655
- await removeFromIndex(state, conversationIndexKey(current.conversationId), current.id, nowMs);
2119
+ async function getFilePermalink(fileId) {
2120
+ const client = getClient();
2121
+ return (await withSlackRetries(() => client.files.info({ file: fileId }), 3, {
2122
+ action: "files.info",
2123
+ idempotent: true,
2124
+ spanAttributes: { "app.slack.file_id": fileId }
2125
+ })).file?.permalink;
2126
+ }
2127
+ async function downloadPrivateSlackFile(url) {
2128
+ const token = resolveSlackToken();
2129
+ return withSpan("GET files.slack.com", "http.client", {}, async () => {
2130
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
2131
+ setSpanAttributes({ "http.response.status_code": response.status });
2132
+ if (!response.ok) {
2133
+ setSpanAttributes({ "error.type": String(response.status) });
2134
+ setSpanStatus("error");
2135
+ throw new Error(`Slack file download failed: ${response.status}`);
3656
2136
  }
3657
- if (input.terminal && current.firesAtMs !== void 0) await updateTimerIndex(state, {
3658
- kind: "remove",
3659
- id: current.id
3660
- });
3661
- return delivered;
3662
- }, input.waitDeadlineMs);
2137
+ return Buffer.from(await response.arrayBuffer());
2138
+ }, {
2139
+ "http.request.method": "GET",
2140
+ "server.address": "files.slack.com",
2141
+ "url.scheme": "https",
2142
+ "app.slack.method": "files.download"
2143
+ });
3663
2144
  }
3664
2145
  //#endregion
3665
- //#region src/chat/events/tool-support.ts
3666
- /** Default lifetime for temporary watches. */
3667
- const RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS = 12096e5;
3668
- /** Hard upper bound for temporary watches. */
3669
- const RESOURCE_SUBSCRIPTION_MAX_TTL_MS = 2592e6;
3670
- const STOP_WATCHING_TOOL_NAME = "stopWatchingResources";
3671
- const RESOURCE_WATCH_TOOL_SOURCE = {
3672
- id: "resource-watches",
3673
- description: "Inspect or stop watches for the current conversation."
3674
- };
3675
- //#endregion
3676
- //#region src/chat/slack/tool-support/context.ts
3677
- /** Resolve Slack tool context from the Conversation Location. */
3678
- function getSlackToolContext(context) {
3679
- const source = context.source.kind === "slack" ? context.source : void 0;
3680
- const location = context.location?.provider === "slack" ? context.location : source ? {
3681
- provider: "slack",
3682
- teamId: source.teamId,
3683
- channelId: source.channelId,
3684
- threadTs: source.threadTs
3685
- } : void 0;
3686
- if (!location) return void 0;
3687
- const destinationChannelId = parseSlackChannelReferenceId(context.destination.platform === "slack" ? context.destination.channelId : location.channelId);
3688
- const locationChannelId = parseSlackChannelReferenceId(location.channelId);
3689
- const teamId = parseSlackTeamId(location.teamId);
3690
- if (!destinationChannelId || !locationChannelId || !teamId) return;
2146
+ //#region src/chat/credentials/subject.ts
2147
+ const CREDENTIAL_SUBJECT_HMAC_CONTEXT = "junior.credential_subject.v1";
2148
+ const CREDENTIAL_SUBJECT_SIGNATURE_VERSION = "v1";
2149
+ function getCredentialSubjectSecret() {
2150
+ return process.env.JUNIOR_SECRET?.trim() || void 0;
2151
+ }
2152
+ /** Encode ordered signing fields without allowing delimiter ambiguity. */
2153
+ function buildPayload(parts) {
2154
+ return [CREDENTIAL_SUBJECT_HMAC_CONTEXT, ...parts].join("\0");
2155
+ }
2156
+ function signPayload(secret, payload) {
2157
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
2158
+ return `${CREDENTIAL_SUBJECT_SIGNATURE_VERSION}=${digest}`;
2159
+ }
2160
+ function timingSafeMatch(expected, actual) {
2161
+ const expectedBuffer = Buffer.from(expected);
2162
+ const actualBuffer = Buffer.from(actual);
2163
+ if (expectedBuffer.length !== actualBuffer.length) return false;
2164
+ return timingSafeEqual(expectedBuffer, actualBuffer);
2165
+ }
2166
+ /** Create a delegated user credential subject for a verified Slack DM turn. */
2167
+ function createSlackDirectCredentialSubject(input) {
2168
+ const channelId = normalizeSlackConversationId(input.channelId);
2169
+ const teamId = input.teamId?.trim();
2170
+ const userId = parseActorUserId(input.userId);
2171
+ if (!channelId || !teamId || !userId || !isDmChannel(channelId)) return;
3691
2172
  return {
3692
- actor: context.actor?.platform === "slack" ? context.actor : void 0,
3693
- destinationChannelId,
3694
- locationChannelId,
3695
- messageChannelId: source ? parseSlackChannelReferenceId(source.channelId) : void 0,
3696
- messageTs: parseSlackMessageTs(source?.messageTs),
3697
- teamId,
3698
- threadTs: parseSlackMessageTs(location.threadTs)
2173
+ type: "user",
2174
+ userId,
2175
+ allowedWhen: "private-direct-conversation"
3699
2176
  };
3700
2177
  }
3701
- //#endregion
3702
- //#region src/chat/plugins/viewer.ts
3703
- function identityFromRow(row) {
3704
- return identitySchema.parse({
3705
- id: row.id,
3706
- provider: row.provider,
3707
- providerSubjectId: row.providerSubjectId,
3708
- ...row.providerTenantId ? { providerTenantId: row.providerTenantId } : void 0,
3709
- ...row.displayName ? { displayName: row.displayName } : void 0,
3710
- ...row.handle ? { handle: row.handle } : void 0
3711
- });
2178
+ /** Bind a delegated user subject to the Slack DM destination being dispatched. */
2179
+ function bindSlackDirectCredentialSubject(input) {
2180
+ const channelId = normalizeSlackConversationId(input.channelId);
2181
+ const teamId = input.teamId.trim();
2182
+ const secret = getCredentialSubjectSecret();
2183
+ const { subject } = input;
2184
+ const userId = parseActorUserId(subject.userId);
2185
+ if (!channelId || !teamId || !secret || !isDmChannel(channelId) || subject.type !== "user" || !userId || subject.allowedWhen !== "private-direct-conversation") return;
2186
+ return {
2187
+ type: "user",
2188
+ userId,
2189
+ allowedWhen: subject.allowedWhen,
2190
+ binding: {
2191
+ type: "slack-direct-conversation",
2192
+ teamId,
2193
+ channelId,
2194
+ signature: signPayload(secret, buildPayload([
2195
+ subject.allowedWhen,
2196
+ teamId,
2197
+ channelId,
2198
+ userId
2199
+ ]))
2200
+ }
2201
+ };
3712
2202
  }
3713
- async function readUserById(db, userRow) {
3714
- const identityRows = await db.select().from(juniorIdentities).where(and(eq(juniorIdentities.userId, userRow.id), eq(juniorIdentities.kind, "user"))).orderBy(asc(juniorIdentities.provider), asc(juniorIdentities.providerTenantId), asc(juniorIdentities.providerSubjectId));
3715
- return userSchema.parse({
3716
- email: userRow.primaryEmail,
3717
- id: userRow.id,
3718
- identities: identityRows.map(identityFromRow),
3719
- ...userRow.displayName ? { displayName: userRow.displayName } : void 0
3720
- });
2203
+ /** Verify that a delegated subject was signed for the dispatch destination. */
2204
+ function verifySlackDirectCredentialSubject(input) {
2205
+ const channelId = normalizeSlackConversationId(input.channelId);
2206
+ const secret = getCredentialSubjectSecret();
2207
+ if (!channelId || !secret) return false;
2208
+ const { subject } = input;
2209
+ const binding = subject.binding;
2210
+ if (subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "private-direct-conversation" || !binding || binding.type !== "slack-direct-conversation" || typeof binding.signature !== "string" || !binding.signature || binding.teamId !== input.teamId || binding.channelId !== channelId) return false;
2211
+ return timingSafeMatch(signPayload(secret, buildPayload([
2212
+ subject.allowedWhen,
2213
+ binding.teamId,
2214
+ binding.channelId,
2215
+ subject.userId
2216
+ ])), binding.signature);
3721
2217
  }
3722
- /** Resolve or create the canonical user for one verified viewer email. */
3723
- async function resolveViewerUserFromSql(db, email) {
3724
- const normalizedEmail = normalizeIdentityEmail(email);
3725
- if (!normalizedEmail) return void 0;
3726
- const now = /* @__PURE__ */ new Date();
3727
- const userRow = (await db.insert(juniorUsers).values({
3728
- id: randomUUID(),
3729
- primaryEmail: email.trim(),
3730
- primaryEmailNormalized: normalizedEmail,
3731
- displayName: null,
3732
- createdAt: now,
3733
- updatedAt: now
3734
- }).onConflictDoNothing({ target: juniorUsers.primaryEmailNormalized }).returning())[0] ?? (await db.select().from(juniorUsers).where(eq(juniorUsers.primaryEmailNormalized, normalizedEmail)).limit(1))[0];
3735
- return userRow ? await readUserById(db, userRow) : void 0;
3736
- }
3737
- /** Resolve or create the canonical user for one authenticated viewer. */
3738
- async function resolveViewerUser(email) {
3739
- return await resolveViewerUserFromSql(getDb(), email);
3740
- }
3741
- /** Read one existing canonical user by verified email without creating a row. */
3742
- async function findUserByEmailFromSql(db, email) {
3743
- const normalizedEmail = normalizeIdentityEmail(email);
3744
- if (!normalizedEmail) return void 0;
3745
- const userRow = (await db.select().from(juniorUsers).where(eq(juniorUsers.primaryEmailNormalized, normalizedEmail)).limit(1))[0];
3746
- return userRow ? await readUserById(db, userRow) : void 0;
3747
- }
3748
- /** Read one existing canonical user by verified email without creating a row. */
3749
- async function findUserByEmail(email) {
3750
- return await findUserByEmailFromSql(getDb(), email);
3751
- }
3752
- /** Update one canonical user's display name in SQL. */
3753
- async function updateViewerDisplayNameFromSql(db, userId, displayName) {
3754
- const userRow = (await db.update(juniorUsers).set({
3755
- displayName,
3756
- updatedAt: /* @__PURE__ */ new Date()
3757
- }).where(eq(juniorUsers.id, userId)).returning())[0];
3758
- return userRow ? await readUserById(db, userRow) : void 0;
3759
- }
3760
- /** Update one canonical user's display name. */
3761
- async function updateViewerDisplayName(userId, displayName) {
3762
- return await updateViewerDisplayNameFromSql(getDb(), userId, displayName);
3763
- }
3764
- /** Resolve the stored identity and linked user for one runtime actor. */
3765
- async function readActorIdentityFromSql(db, actor) {
3766
- if (actor.platform === "system") return void 0;
3767
- const providerTenantId = actor.platform === "slack" ? actor.teamId : "";
3768
- const row = (await db.select({
3769
- identity: juniorIdentities,
3770
- user: juniorUsers
3771
- }).from(juniorIdentities).leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)).where(and(eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, actor.platform), eq(juniorIdentities.providerTenantId, providerTenantId), eq(juniorIdentities.providerSubjectId, actor.userId))).limit(1))[0];
3772
- if (!row) return void 0;
2218
+ /** Bind a delegated user subject to one scheduler task dispatch. */
2219
+ function bindScheduledAutomationCredentialSubject(input) {
2220
+ const secret = getCredentialSubjectSecret();
2221
+ const plugin = input.plugin.trim();
2222
+ const userId = parseActorUserId(input.subject.userId);
2223
+ if (!secret || plugin !== "scheduler" || !userId || input.subject.allowedWhen !== "scheduled-automation") return;
2224
+ const taskId = input.subject.taskId;
2225
+ if (!taskId || taskId !== taskId.trim()) return;
3773
2226
  return {
3774
- identity: identityFromRow(row.identity),
3775
- ...row.user ? { user: await readUserById(db, row.user) } : void 0
2227
+ type: "user",
2228
+ userId,
2229
+ allowedWhen: "scheduled-automation",
2230
+ taskId,
2231
+ binding: {
2232
+ type: "scheduled-automation",
2233
+ plugin,
2234
+ taskId,
2235
+ signature: signPayload(secret, buildPayload([
2236
+ "scheduled-automation",
2237
+ plugin,
2238
+ taskId,
2239
+ userId
2240
+ ]))
2241
+ }
3776
2242
  };
3777
2243
  }
3778
- /** Resolve the stored identity and linked user for one runtime actor. */
3779
- async function readActorIdentity(actor) {
3780
- return await readActorIdentityFromSql(getDb(), actor);
3781
- }
3782
- //#endregion
3783
- //#region src/chat/slack/workspace-context.ts
3784
- const workspaceTeamIdStorage = new AsyncLocalStorage();
3785
- /** Run a callback with the Slack workspace team ID for the inbound webhook. */
3786
- function runWithWorkspaceTeamId(teamId, fn) {
3787
- if (!teamId) return fn();
3788
- return workspaceTeamIdStorage.run(teamId, fn);
2244
+ /** Verify that a delegated subject was signed for one scheduler task. */
2245
+ function verifyScheduledAutomationCredentialSubject(input) {
2246
+ const secret = getCredentialSubjectSecret();
2247
+ const { subject } = input;
2248
+ const binding = subject.binding;
2249
+ if (!secret || input.plugin !== "scheduler" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "scheduled-automation" || !subject.taskId || binding.type !== "scheduled-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
2250
+ return timingSafeMatch(signPayload(secret, buildPayload([
2251
+ "scheduled-automation",
2252
+ binding.plugin,
2253
+ binding.taskId,
2254
+ subject.userId
2255
+ ])), binding.signature);
3789
2256
  }
3790
- /** Return the Slack workspace team ID for the current inbound webhook. */
3791
- function getWorkspaceTeamId() {
3792
- return workspaceTeamIdStorage.getStore();
2257
+ /** Bind a delegated user subject to one event automation dispatch. */
2258
+ function bindEventAutomationCredentialSubject(input) {
2259
+ const secret = getCredentialSubjectSecret();
2260
+ const plugin = input.plugin.trim();
2261
+ const userId = parseActorUserId(input.subject.userId);
2262
+ if (!secret || plugin !== "junior" || !userId || input.subject.allowedWhen !== "event-automation") return;
2263
+ const taskId = input.subject.taskId;
2264
+ if (!taskId || taskId !== taskId.trim()) return;
2265
+ return {
2266
+ type: "user",
2267
+ userId,
2268
+ allowedWhen: "event-automation",
2269
+ taskId,
2270
+ binding: {
2271
+ type: "event-automation",
2272
+ plugin,
2273
+ taskId,
2274
+ signature: signPayload(secret, buildPayload([
2275
+ "event-automation",
2276
+ plugin,
2277
+ taskId,
2278
+ userId
2279
+ ]))
2280
+ }
2281
+ };
3793
2282
  }
3794
- //#endregion
3795
- //#region src/chat/slack/client.ts
3796
- var SlackActionError = class extends Error {
3797
- code;
3798
- apiError;
3799
- needed;
3800
- provided;
3801
- statusCode;
3802
- requestId;
3803
- errorData;
3804
- retryAfterSeconds;
3805
- detail;
3806
- detailLine;
3807
- detailRule;
3808
- constructor(message, code, options = {}) {
3809
- super(message);
3810
- this.name = "SlackActionError";
3811
- this.code = code;
3812
- this.apiError = options.apiError;
3813
- this.needed = options.needed;
3814
- this.provided = options.provided;
3815
- this.statusCode = options.statusCode;
3816
- this.requestId = options.requestId;
3817
- this.errorData = options.errorData;
3818
- this.retryAfterSeconds = options.retryAfterSeconds;
3819
- this.detail = options.detail;
3820
- this.detailLine = options.detailLine;
3821
- this.detailRule = options.detailRule;
3822
- }
3823
- };
3824
- function serializeSlackErrorData(data) {
3825
- if (!data || typeof data !== "object") return;
3826
- const filtered = Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error"));
3827
- if (Object.keys(filtered).length === 0) return;
3828
- try {
3829
- const serialized = JSON.stringify(filtered);
3830
- return serialized.length <= 600 ? serialized : `${serialized.slice(0, 597)}...`;
3831
- } catch {
3832
- return;
3833
- }
2283
+ /** Verify that a delegated subject was signed for one event automation. */
2284
+ function verifyEventAutomationCredentialSubject(input) {
2285
+ const secret = getCredentialSubjectSecret();
2286
+ const { subject } = input;
2287
+ const binding = subject.binding;
2288
+ if (!secret || input.plugin !== "junior" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "event-automation" || !subject.taskId || binding.type !== "event-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
2289
+ return timingSafeMatch(signPayload(secret, buildPayload([
2290
+ "event-automation",
2291
+ binding.plugin,
2292
+ binding.taskId,
2293
+ subject.userId
2294
+ ])), binding.signature);
3834
2295
  }
3835
- /** Extract a header value by case-insensitive name from a raw headers object. */
3836
- function getHeaderString(headers, name) {
3837
- if (!headers || typeof headers !== "object") return;
3838
- const key = name.toLowerCase();
3839
- const entries = headers;
3840
- for (const [entryKey, value] of Object.entries(entries)) {
3841
- if (entryKey.toLowerCase() !== key) continue;
3842
- if (typeof value === "string") return value;
3843
- if (Array.isArray(value)) {
3844
- const first = value.find((entry) => typeof entry === "string");
3845
- return typeof first === "string" ? first : void 0;
2296
+ /** Bind the user who set a timer Watch to that one Watch. */
2297
+ function bindTimerWatchCredentialSubject(input) {
2298
+ const secret = getCredentialSubjectSecret();
2299
+ const userId = parseActorUserId(input.userId);
2300
+ const { conversationId, watchId } = input;
2301
+ if (!secret || !userId || !conversationId || conversationId !== conversationId.trim() || !watchId || watchId !== watchId.trim()) return;
2302
+ return {
2303
+ type: "user",
2304
+ userId,
2305
+ allowedWhen: "timer-watch",
2306
+ binding: {
2307
+ type: "timer-watch",
2308
+ conversationId,
2309
+ watchId,
2310
+ signature: signPayload(secret, buildPayload([
2311
+ "timer-watch",
2312
+ conversationId,
2313
+ watchId,
2314
+ userId
2315
+ ]))
3846
2316
  }
3847
- }
2317
+ };
3848
2318
  }
3849
- function parseSlackCanvasDetail(detail) {
3850
- if (typeof detail !== "string") return {};
3851
- const trimmed = detail.trim();
3852
- if (!trimmed) return {};
3853
- const parsed = { detail: trimmed };
3854
- const lineMatch = trimmed.match(/line\s+(\d+):/i);
3855
- if (lineMatch) {
3856
- const line = Number.parseInt(lineMatch[1] ?? "", 10);
3857
- if (Number.isFinite(line)) parsed.detailLine = line;
3858
- }
3859
- if (/unsupported heading depth/i.test(trimmed)) parsed.detailRule = "unsupported_heading_depth";
3860
- return parsed;
2319
+ /** Verify that a delegated subject was signed for one timer Watch. */
2320
+ function verifyTimerWatchCredentialSubject(input) {
2321
+ const secret = getCredentialSubjectSecret();
2322
+ const { subject } = input;
2323
+ const binding = subject.binding;
2324
+ if (!secret || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "timer-watch" || binding.type !== "timer-watch" || binding.conversationId !== input.conversationId || binding.watchId !== input.watchId) return false;
2325
+ return timingSafeMatch(signPayload(secret, buildPayload([
2326
+ "timer-watch",
2327
+ binding.conversationId,
2328
+ binding.watchId,
2329
+ subject.userId
2330
+ ])), binding.signature);
3861
2331
  }
3862
- /**
3863
- * Ambient destination-installation token for Slack outbound calls, bound by
3864
- * installation-scoped entry points so writes carry the destination
3865
- * workspace's credentials instead of the process-global env token.
3866
- */
3867
- const installationTokenStorage = new AsyncLocalStorage();
3868
- const clientsByToken = /* @__PURE__ */ new Map();
3869
- /**
3870
- * Bind a workspace installation token for all Slack Web API calls inside
3871
- * `fn`, so multi-workspace outbound writes use the destination workspace's
3872
- * credentials rather than the env fallback token.
3873
- */
3874
- function runWithSlackInstallationToken(token, fn) {
3875
- const trimmed = token.trim();
3876
- if (!trimmed) throw new SlackActionError("Slack installation token binding requires a non-empty token", "missing_token");
3877
- return installationTokenStorage.run({ token: trimmed }, fn);
2332
+ //#endregion
2333
+ //#region src/chat/events/store.ts
2334
+ const EVENT_PREFIX = "junior:resource_event_subscription:v5";
2335
+ const INDEX_LOCK_TTL_MS = 1e4;
2336
+ const SUBSCRIPTION_LOCK_TTL_MS = 1e4;
2337
+ const SUBSCRIPTION_LOCK_WAIT_MS = 1e4;
2338
+ const SUBSCRIPTION_LOCK_RETRY_MS = 25;
2339
+ const SUBSCRIPTION_LOCK_HEARTBEAT_MS = 3e3;
2340
+ const subscriptionStatusSchema = z.enum([
2341
+ "active",
2342
+ "cancelled",
2343
+ "completed"
2344
+ ]);
2345
+ const subscriptionIdIndexSchema = z.array(z.string().min(1));
2346
+ const subscriptionSchema = z.object({
2347
+ conversationId: z.string().min(1),
2348
+ createdAtMs: z.number().finite(),
2349
+ /** Timer creator whose grants the timer Turn may use. */
2350
+ credentialSubject: credentialSubjectSchema.optional(),
2351
+ events: z.array(eventTypeSchema).min(1),
2352
+ expiresAtMs: z.number().finite(),
2353
+ firesAtMs: z.number().finite().optional(),
2354
+ id: z.string().min(1),
2355
+ intent: z.string().min(1),
2356
+ label: z.string().min(1),
2357
+ match: eventMatchSchema.optional(),
2358
+ namespace: z.string().min(1),
2359
+ identifier: z.string().min(1),
2360
+ resourceType: z.string().min(1),
2361
+ status: subscriptionStatusSchema,
2362
+ updatedAtMs: z.number().finite()
2363
+ }).strict();
2364
+ function digest(value) {
2365
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
3878
2366
  }
3879
- /**
3880
- * Token precedence: ambient installation token, then env token, then a hard
3881
- * failure for team-scoped calls — a call scoped to one workspace must never
3882
- * silently fall through to another workspace's credentials.
3883
- */
3884
- function resolveSlackToken() {
3885
- const ambientToken = installationTokenStorage.getStore()?.token;
3886
- if (ambientToken) return ambientToken;
3887
- const envToken = getSlackBotToken();
3888
- if (envToken) return envToken;
3889
- const teamId = getWorkspaceTeamId();
3890
- if (teamId) throw new SlackActionError(`Slack call is scoped to workspace ${teamId} but no installation token is bound and no default bot token is configured`, "missing_token");
3891
- throw new SlackActionError("SLACK_BOT_TOKEN (or SLACK_BOT_USER_TOKEN) is required for Slack Web API actions in this service", "missing_token");
2367
+ function subscriptionKey(id) {
2368
+ return `${EVENT_PREFIX}:record:${id}`;
3892
2369
  }
3893
- /** Normalize Junior Slack references to native Slack conversation IDs. */
3894
- function normalizeSlackConversationId(channelId) {
3895
- return parseSlackChannelReferenceId(channelId);
2370
+ function subscriptionLockKey(id) {
2371
+ return `${EVENT_PREFIX}:lock:${id}`;
3896
2372
  }
3897
- /**
3898
- * Return the per-token cached WebClient. `withSlackRetries` owns retry
3899
- * classification for this boundary: the WebClient's built-in policy would
3900
- * re-post timed-out writes (a duplicate-message hazard) and sleep for the
3901
- * full unbounded Retry-After inside the request, so both internal behaviors
3902
- * are disabled here.
3903
- */
3904
- function getClient() {
3905
- const token = resolveSlackToken();
3906
- let cached = clientsByToken.get(token);
3907
- if (!cached) {
3908
- cached = new WebClient(token, {
3909
- retryConfig: { retries: 0 },
3910
- rejectRateLimitedCalls: true
3911
- });
3912
- clientsByToken.set(token, cached);
3913
- }
3914
- return cached;
2373
+ function resourceIndexKey(namespace, identifier) {
2374
+ return `${EVENT_PREFIX}:resource:${digest(`${namespace}\0${identifier}`)}`;
3915
2375
  }
3916
- function mapSlackError(error) {
3917
- if (error instanceof SlackActionError) return error;
3918
- const candidate = error;
3919
- const apiError = candidate.data?.error;
3920
- const message = candidate.message ?? "Slack action failed";
3921
- const baseOptions = {
3922
- apiError,
3923
- statusCode: candidate.statusCode,
3924
- requestId: getHeaderString(candidate.headers, "x-slack-req-id"),
3925
- errorData: serializeSlackErrorData(candidate.data),
3926
- ...parseSlackCanvasDetail(candidate.data?.detail)
3927
- };
3928
- if (apiError === "missing_scope") return new SlackActionError(message, "missing_scope", {
3929
- ...baseOptions,
3930
- needed: candidate.data?.needed,
3931
- provided: candidate.data?.provided
3932
- });
3933
- if (apiError === "not_in_channel") return new SlackActionError(message, "not_in_channel", baseOptions);
3934
- if (apiError === "restricted_action_read_only_channel") return new SlackActionError(message, "read_only_channel", baseOptions);
3935
- if (apiError === "already_reacted") return new SlackActionError(message, "already_reacted", baseOptions);
3936
- if (apiError === "no_reaction") return new SlackActionError(message, "no_reaction", baseOptions);
3937
- if (apiError === "invalid_arguments") return new SlackActionError(message, "invalid_arguments", baseOptions);
3938
- if (apiError === "invalid_cursor") return new SlackActionError(message, "invalid_arguments", baseOptions);
3939
- if (apiError === "invalid_name") return new SlackActionError(message, "invalid_arguments", baseOptions);
3940
- if (apiError === "not_found" || apiError === "channel_not_found" || apiError === "message_not_found") return new SlackActionError(message, "not_found", baseOptions);
3941
- if (apiError === "feature_not_enabled" || apiError === "not_allowed_token_type") return new SlackActionError(message, "feature_unavailable", baseOptions);
3942
- if (apiError === "canvas_creation_failed") return new SlackActionError(message, "canvas_creation_failed", baseOptions);
3943
- if (apiError === "canvas_editing_failed") return new SlackActionError(message, "canvas_editing_failed", baseOptions);
3944
- if (candidate.code === "slack_webapi_rate_limited_error" || candidate.statusCode === 429) return new SlackActionError(message, "rate_limited", {
3945
- ...baseOptions,
3946
- retryAfterSeconds: candidate.retryAfter
3947
- });
3948
- return new SlackActionError(message, "internal_error", baseOptions);
2376
+ function conversationIndexKey(conversationId) {
2377
+ return `${EVENT_PREFIX}:conversation:${digest(conversationId)}`;
3949
2378
  }
3950
- const MAX_RETRY_DELAY_MS = 1e4;
3951
- const MAX_TOTAL_RETRY_DELAY_MS = 2e4;
3952
- const CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3953
- "ECONNREFUSED",
3954
- "ECONNRESET",
3955
- "ENOTFOUND",
3956
- "EAI_AGAIN"
3957
- ]);
3958
- const TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
3959
- "ETIMEDOUT",
3960
- "ECONNABORTED",
3961
- "ESOCKETTIMEDOUT"
3962
- ]);
3963
- function findNetworkErrorCode(error, depth = 0) {
3964
- if (!error || typeof error !== "object" || depth > 4) return;
3965
- const candidate = error;
3966
- if (typeof candidate.code === "string" && (CONNECTION_ERROR_CODES.has(candidate.code) || TIMEOUT_ERROR_CODES.has(candidate.code))) return candidate.code;
3967
- return findNetworkErrorCode(candidate.original, depth + 1) ?? findNetworkErrorCode(candidate.cause, depth + 1);
2379
+ function indexLockKey(key) {
2380
+ return `${key}:lock`;
3968
2381
  }
3969
- function hasSocketHangUpMessage(error) {
3970
- return error instanceof Error && error.message.toLowerCase().includes("socket hang up");
2382
+ async function sleep(ms) {
2383
+ await new Promise((resolve) => {
2384
+ setTimeout(resolve, ms).unref?.();
2385
+ });
3971
2386
  }
3972
- /**
3973
- * Classify a failed Slack call for retry: rate limits, Slack 5xx, and
3974
- * connection-phase failures are always retryable; timeouts are retried only
3975
- * for idempotent operations because Slack may already have accepted the
3976
- * request.
3977
- */
3978
- function classifySlackRetry(raw, mapped) {
3979
- if (mapped.code === "rate_limited") return "rate_limited";
3980
- if (mapped.statusCode !== void 0 && mapped.statusCode >= 500) return "server_error";
3981
- const networkCode = findNetworkErrorCode(raw);
3982
- if (networkCode && CONNECTION_ERROR_CODES.has(networkCode)) return "connection";
3983
- if (networkCode && TIMEOUT_ERROR_CODES.has(networkCode)) return "timeout";
3984
- if (hasSocketHangUpMessage(raw)) return "connection";
3985
- return "none";
2387
+ async function acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs = Date.now() + SUBSCRIPTION_LOCK_WAIT_MS) {
2388
+ while (true) {
2389
+ const lock = await state.acquireLock(subscriptionLockKey(subscriptionId), SUBSCRIPTION_LOCK_TTL_MS);
2390
+ if (lock) return lock;
2391
+ if (Date.now() >= waitDeadlineMs) throw new Error(`Could not acquire watch lock for ${subscriptionId}`);
2392
+ await sleep(SUBSCRIPTION_LOCK_RETRY_MS);
2393
+ }
3986
2394
  }
3987
- /**
3988
- * Run a Slack Web API call with bounded retries so transient platform
3989
- * failures do not surface as turn failures, while non-idempotent posts are
3990
- * never re-sent when Slack may already have accepted them.
3991
- *
3992
- * Retry classes: rate limits (bounded Retry-After), connection-phase network
3993
- * failures, and Slack 5xx responses always retry; request timeouts retry only
3994
- * when the caller marks the operation `idempotent`.
3995
- */
3996
- async function withSlackRetries(task, maxAttempts = 3, context = {}) {
3997
- let attempt = 0;
3998
- let totalDelayMs = 0;
3999
- const action = context.action ?? "unknown";
4000
- while (attempt < maxAttempts) {
4001
- attempt += 1;
4002
- const attemptNumber = attempt;
4003
- try {
4004
- return await withSpan(`POST slack.com/api/${action}`, "http.client", {}, async () => {
4005
- try {
4006
- return await task();
4007
- } catch (error) {
4008
- const mapped = mapSlackError(error);
4009
- const errorAttrs = { "error.type": mapped.code };
4010
- if (mapped.apiError) errorAttrs["app.slack.api_error_code"] = mapped.apiError;
4011
- if (mapped.code === "rate_limited") {
4012
- errorAttrs["http.response.status_code"] = 429;
4013
- if (mapped.retryAfterSeconds) errorAttrs["app.slack.retry_after_ms"] = mapped.retryAfterSeconds * 1e3;
4014
- } else if (mapped.statusCode != null) errorAttrs["http.response.status_code"] = mapped.statusCode;
4015
- setSpanAttributes(errorAttrs);
4016
- setSpanStatus("error");
4017
- throw error;
4018
- }
4019
- }, {
4020
- "http.request.method": "POST",
4021
- "server.address": "slack.com",
4022
- "url.scheme": "https",
4023
- "url.path": `/api/${action}`,
4024
- "app.slack.method": action,
4025
- "app.retry.max_attempts": maxAttempts,
4026
- ...attemptNumber > 1 ? { "http.request.resend_count": attemptNumber - 1 } : void 0,
4027
- ...context.attributes ?? {},
4028
- ...context.spanAttributes ?? {}
4029
- });
4030
- } catch (error) {
4031
- const mapped = mapSlackError(error);
4032
- const retryClass = classifySlackRetry(error, mapped);
4033
- const isRetryable = retryClass === "rate_limited" || retryClass === "connection" || retryClass === "server_error" || retryClass === "timeout" && context.idempotent === true;
4034
- const remainingDelayBudgetMs = MAX_TOTAL_RETRY_DELAY_MS - totalDelayMs;
4035
- const baseLogAttributes = {
4036
- "app.slack.action": action,
4037
- "app.slack.error_code": mapped.code,
4038
- ...mapped.apiError ? { "app.slack.api_error": mapped.apiError } : void 0,
4039
- ...mapped.detail ? { "app.slack.detail": mapped.detail } : void 0,
4040
- ...mapped.detailLine !== void 0 ? { "app.slack.detail_line": mapped.detailLine } : void 0,
4041
- ...mapped.detailRule ? { "app.slack.detail_rule": mapped.detailRule } : void 0,
4042
- ...mapped.requestId ? { "app.slack.request_id": mapped.requestId } : void 0,
4043
- ...mapped.statusCode !== void 0 ? { "http.response.status_code": mapped.statusCode } : void 0,
4044
- ...context.attributes ?? {}
4045
- };
4046
- if (!isRetryable || attempt >= maxAttempts || remainingDelayBudgetMs <= 0) {
4047
- logWarn("slack.action.failed", {
4048
- ...baseLogAttributes,
4049
- ...mapped.errorData ? { "app.slack.error_data": mapped.errorData } : void 0
4050
- });
4051
- throw mapped;
4052
- }
4053
- logWarn("slack.action.retrying", {
4054
- ...baseLogAttributes,
4055
- "app.slack.retry_attempt": attempt,
4056
- "app.slack.retry_class": retryClass
4057
- });
4058
- const retryAfterMs = retryClass === "rate_limited" && mapped.retryAfterSeconds && mapped.retryAfterSeconds > 0 ? mapped.retryAfterSeconds * 1e3 : void 0;
4059
- const backoffMs = Math.min(2e3, 250 * 2 ** (attempt - 1));
4060
- const delayMs = Math.min(retryAfterMs ?? backoffMs, MAX_RETRY_DELAY_MS, remainingDelayBudgetMs);
4061
- totalDelayMs += delayMs;
4062
- await sleep$1(delayMs);
4063
- }
2395
+ /** Run one subscription lifecycle transition while keeping its lease alive. */
2396
+ async function withSubscriptionLock(state, subscriptionId, callback, waitDeadlineMs) {
2397
+ const lock = await acquireSubscriptionLock(state, subscriptionId, waitDeadlineMs);
2398
+ let lockLostError;
2399
+ let heartbeatError;
2400
+ let heartbeat;
2401
+ const timer = setInterval(() => {
2402
+ if (heartbeat) return;
2403
+ heartbeat = state.extendLock(lock, SUBSCRIPTION_LOCK_TTL_MS).then((extended) => {
2404
+ if (extended) heartbeatError = void 0;
2405
+ else lockLostError = /* @__PURE__ */ new Error(`Watch lock was lost for ${subscriptionId}`);
2406
+ }).catch((error) => {
2407
+ heartbeatError = error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to extend subscription lock: ${String(error)}`);
2408
+ }).finally(() => {
2409
+ heartbeat = void 0;
2410
+ });
2411
+ }, SUBSCRIPTION_LOCK_HEARTBEAT_MS);
2412
+ timer.unref?.();
2413
+ let result;
2414
+ try {
2415
+ result = await callback();
2416
+ } finally {
2417
+ clearInterval(timer);
2418
+ await heartbeat;
2419
+ await state.releaseLock(lock);
4064
2420
  }
4065
- throw new SlackActionError("Slack action exhausted retries", "internal_error");
2421
+ const lockError = lockLostError ?? heartbeatError;
2422
+ if (lockError) throw lockError;
2423
+ return result;
4066
2424
  }
4067
- /**
4068
- * Slack Web API client for the current destination workspace: the ambient
4069
- * installation token when one is bound, otherwise the env bot token for
4070
- * single-workspace deployments. Fails instead of falling back when the call
4071
- * is workspace-scoped and no installation token can be resolved, so a write
4072
- * never goes out with another workspace's credentials.
4073
- */
4074
- function getSlackClient(options) {
4075
- if (options) return new WebClient(resolveSlackToken(), {
4076
- retryConfig: { retries: 0 },
4077
- rejectRateLimitedCalls: true,
4078
- timeout: options.timeoutMs
2425
+ function ttlUntil(expiresAtMs, nowMs) {
2426
+ return Math.max(1, expiresAtMs - nowMs);
2427
+ }
2428
+ async function readSubscriptionIdIndex(state, key) {
2429
+ const value = await state.get(key);
2430
+ if (value === void 0 || value === null) return [];
2431
+ return subscriptionIdIndexSchema.parse(value);
2432
+ }
2433
+ function buildSubscriptionId(input) {
2434
+ const eventKey = [...new Set(input.events)].sort().join("\0");
2435
+ const matchKey = stableEventMatchKey(input.match);
2436
+ return `resub_${digest(matchKey ? `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}\0${matchKey}` : `${input.namespace}\0${input.identifier}\0${input.conversationId}\0${eventKey}`)}`;
2437
+ }
2438
+ async function withIndexLock(state, key, callback) {
2439
+ const lock = await state.acquireLock(indexLockKey(key), INDEX_LOCK_TTL_MS);
2440
+ if (!lock) throw new Error(`Could not acquire event index lock for ${key}`);
2441
+ try {
2442
+ return await callback();
2443
+ } finally {
2444
+ await state.releaseLock(lock);
2445
+ }
2446
+ }
2447
+ async function addToIndex(state, key, subscriptionId, nowMs) {
2448
+ await withIndexLock(state, key, async () => {
2449
+ const ids = [...new Set(await readSubscriptionIdIndex(state, key))];
2450
+ const next = ids.includes(subscriptionId) ? ids : [...ids, subscriptionId];
2451
+ await state.set(key, next, await indexTtlMs(state, next, nowMs));
4079
2452
  });
4080
- return getClient();
4081
2453
  }
4082
- /**
4083
- * Slack channel ID prefixes:
4084
- * - C: channel — modern private channels also use C, so the prefix never
4085
- * proves a channel is public
4086
- * - G: legacy private channel / group DM
4087
- * - D: direct message (1:1)
4088
- */
4089
- function isDmChannel(channelId) {
4090
- const normalized = normalizeSlackConversationId(channelId);
4091
- return Boolean(normalized && normalized.startsWith("D"));
2454
+ async function removeFromIndex(state, key, subscriptionId, nowMs) {
2455
+ await withIndexLock(state, key, async () => {
2456
+ const next = (await readSubscriptionIdIndex(state, key)).filter((id) => id !== subscriptionId);
2457
+ await state.set(key, next, await indexTtlMs(state, next, nowMs));
2458
+ });
4092
2459
  }
4093
- /**
4094
- * Conversation-scoped Slack contexts backed by a concrete conversation ID.
4095
- * Includes channels/groups/DMs (C/G/D).
4096
- */
4097
- function isConversationScopedChannel(channelId) {
4098
- const normalized = normalizeSlackConversationId(channelId);
4099
- if (!normalized) return false;
4100
- return normalized.startsWith("C") || normalized.startsWith("G") || normalized.startsWith("D");
2460
+ async function indexTtlMs(state, subscriptionIds, nowMs) {
2461
+ const records = await Promise.all(subscriptionIds.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
2462
+ return ttlUntil(Math.max(nowMs, ...records.filter((record) => record !== void 0 && activeAt(record, nowMs)).map((record) => record.expiresAtMs)), nowMs);
4101
2463
  }
4102
- function isConversationChannel(channelId) {
4103
- const normalized = normalizeSlackConversationId(channelId);
4104
- if (!normalized) return false;
4105
- return normalized.startsWith("C") || normalized.startsWith("G");
2464
+ function parseSubscription(value) {
2465
+ if (value === void 0 || value === null) return;
2466
+ return subscriptionSchema.parse(value);
4106
2467
  }
4107
- async function getFilePermalink(fileId) {
4108
- const client = getClient();
4109
- return (await withSlackRetries(() => client.files.info({ file: fileId }), 3, {
4110
- action: "files.info",
4111
- idempotent: true,
4112
- spanAttributes: { "app.slack.file_id": fileId }
4113
- })).file?.permalink;
2468
+ function activeAt(subscription, nowMs) {
2469
+ return subscription.status === "active" && subscription.expiresAtMs > nowMs;
4114
2470
  }
4115
- async function downloadPrivateSlackFile(url) {
4116
- const token = resolveSlackToken();
4117
- return withSpan("GET files.slack.com", "http.client", {}, async () => {
4118
- const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
4119
- setSpanAttributes({ "http.response.status_code": response.status });
4120
- if (!response.ok) {
4121
- setSpanAttributes({ "error.type": String(response.status) });
4122
- setSpanStatus("error");
4123
- throw new Error(`Slack file download failed: ${response.status}`);
4124
- }
4125
- return Buffer.from(await response.arrayBuffer());
4126
- }, {
4127
- "http.request.method": "GET",
4128
- "server.address": "files.slack.com",
4129
- "url.scheme": "https",
4130
- "app.slack.method": "files.download"
2471
+ function matchesEvent(subscription, input) {
2472
+ return subscription.namespace === input.namespace && subscription.identifier === input.identifier && subscription.events.includes(input.eventType) && activeAt(subscription, input.nowMs) && eventMatches(subscription.match, input.data);
2473
+ }
2474
+ /** Create or replace the active subscription for one conversation/resource/event set. */
2475
+ async function createWatch(input, options = {}) {
2476
+ const state = options.state ?? getStateAdapter();
2477
+ await state.connect();
2478
+ const nowMs = options.nowMs ?? Date.now();
2479
+ const events = [...new Set(input.events.map((event) => event.trim()))].filter(Boolean);
2480
+ if (events.length === 0) throw new Error("Watch requires at least one event");
2481
+ if (input.expiresAtMs <= nowMs) throw new Error("Watch expiry must be in the future");
2482
+ const match = input.match && Object.keys(input.match).length > 0 ? input.match : void 0;
2483
+ const id = buildSubscriptionId({
2484
+ conversationId: input.conversationId,
2485
+ events,
2486
+ match,
2487
+ namespace: input.namespace,
2488
+ identifier: input.identifier
2489
+ });
2490
+ const record = {
2491
+ conversationId: input.conversationId,
2492
+ createdAtMs: nowMs,
2493
+ events,
2494
+ expiresAtMs: input.expiresAtMs,
2495
+ id,
2496
+ intent: input.intent,
2497
+ label: input.label,
2498
+ ...match ? { match } : void 0,
2499
+ namespace: input.namespace,
2500
+ identifier: input.identifier,
2501
+ resourceType: input.resourceType,
2502
+ status: "active",
2503
+ updatedAtMs: nowMs
2504
+ };
2505
+ const parsed = subscriptionSchema.parse(record);
2506
+ await state.set(subscriptionKey(id), parsed, ttlUntil(parsed.expiresAtMs, nowMs));
2507
+ await addToIndex(state, resourceIndexKey(input.namespace, input.identifier), id, nowMs);
2508
+ await addToIndex(state, conversationIndexKey(input.conversationId), id, nowMs);
2509
+ return parsed;
2510
+ }
2511
+ /** Create a retry-stable timer without keeping a process open. */
2512
+ async function createTimerWatch(input) {
2513
+ const state = getStateAdapter();
2514
+ await state.connect();
2515
+ const identifier = digest(`${input.conversationId}\0${input.toolCallId}`);
2516
+ const selector = {
2517
+ conversationId: input.conversationId,
2518
+ namespace: "junior",
2519
+ identifier,
2520
+ events: ["timer.fired"]
2521
+ };
2522
+ const id = buildSubscriptionId(selector);
2523
+ return await withSubscriptionLock(state, id, async () => {
2524
+ const existing = parseSubscription(await state.get(subscriptionKey(id)));
2525
+ if (existing) return existing;
2526
+ const nowMs = Date.now();
2527
+ const firesAtMs = nowMs + input.afterMs;
2528
+ const expiresAtMs = firesAtMs + 864e5;
2529
+ const credentialSubject = input.creatorUserId ? bindTimerWatchCredentialSubject({
2530
+ conversationId: input.conversationId,
2531
+ userId: input.creatorUserId,
2532
+ watchId: id
2533
+ }) : void 0;
2534
+ const record = subscriptionSchema.parse({
2535
+ ...selector,
2536
+ ...credentialSubject ? { credentialSubject } : void 0,
2537
+ id,
2538
+ firesAtMs,
2539
+ expiresAtMs,
2540
+ resourceType: "timer",
2541
+ label: "Timer",
2542
+ intent: input.intent,
2543
+ status: "active",
2544
+ createdAtMs: nowMs,
2545
+ updatedAtMs: nowMs
2546
+ });
2547
+ await withIndexLock(state, conversationIndexKey(input.conversationId), async () => {
2548
+ const key = conversationIndexKey(input.conversationId);
2549
+ const ids = [.../* @__PURE__ */ new Set([...await readSubscriptionIdIndex(state, key), id])];
2550
+ await state.set(key, ids, Math.max(ttlUntil(expiresAtMs, nowMs), await indexTtlMs(state, ids, nowMs)));
2551
+ await state.set(resourceIndexKey("junior", identifier), [id], ttlUntil(expiresAtMs, nowMs));
2552
+ await updateTimerIndex(state, {
2553
+ kind: "add",
2554
+ id,
2555
+ atMs: firesAtMs
2556
+ });
2557
+ await state.set(subscriptionKey(id), record, ttlUntil(expiresAtMs, nowMs));
2558
+ });
2559
+ return record;
2560
+ });
2561
+ }
2562
+ /** Claim live timer Watches and discard stale due-index entries. */
2563
+ async function takeDueTimerWatches(nowMs) {
2564
+ const state = getStateAdapter();
2565
+ await state.connect();
2566
+ const watches = [];
2567
+ for (const id of await updateTimerIndex(state, {
2568
+ kind: "claim",
2569
+ nowMs
2570
+ })) await withSubscriptionLock(state, id, async () => {
2571
+ const watch = parseSubscription(await state.get(subscriptionKey(id)));
2572
+ if (watch && activeAt(watch, nowMs) && watch.firesAtMs !== void 0) {
2573
+ if (watch.firesAtMs <= nowMs) watches.push(watch);
2574
+ } else await updateTimerIndex(state, {
2575
+ kind: "remove",
2576
+ id
2577
+ });
2578
+ });
2579
+ return watches;
2580
+ }
2581
+ /** List active subscriptions bound to one conversation. */
2582
+ async function listWatches(input) {
2583
+ const state = input.state ?? getStateAdapter();
2584
+ await state.connect();
2585
+ const nowMs = input.nowMs ?? Date.now();
2586
+ const ids = await readSubscriptionIdIndex(state, conversationIndexKey(input.conversationId));
2587
+ return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && record.conversationId === input.conversationId && activeAt(record, nowMs)).sort((left, right) => left.createdAtMs - right.createdAtMs);
2588
+ }
2589
+ /** Cancel a current-conversation subscription and remove it from match indexes. */
2590
+ async function cancelWatch(input) {
2591
+ const state = input.state ?? getStateAdapter();
2592
+ await state.connect();
2593
+ return await withSubscriptionLock(state, input.id, async () => {
2594
+ const current = parseSubscription(await state.get(subscriptionKey(input.id)));
2595
+ if (!current || current.conversationId !== input.conversationId) return;
2596
+ const nowMs = input.nowMs ?? Date.now();
2597
+ if (current.status === "completed") return current;
2598
+ const next = {
2599
+ ...current,
2600
+ status: "cancelled",
2601
+ updatedAtMs: nowMs
2602
+ };
2603
+ await state.set(subscriptionKey(input.id), next, JUNIOR_THREAD_STATE_TTL_MS);
2604
+ await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), input.id, nowMs);
2605
+ await removeFromIndex(state, conversationIndexKey(current.conversationId), input.id, nowMs);
2606
+ if (current.firesAtMs !== void 0) await updateTimerIndex(state, {
2607
+ kind: "remove",
2608
+ id: input.id
2609
+ });
2610
+ return next;
4131
2611
  });
4132
2612
  }
4133
- //#endregion
4134
- //#region src/chat/credentials/subject.ts
4135
- const CREDENTIAL_SUBJECT_HMAC_CONTEXT = "junior.credential_subject.v1";
4136
- const CREDENTIAL_SUBJECT_SIGNATURE_VERSION = "v1";
4137
- function getCredentialSubjectSecret() {
4138
- return process.env.JUNIOR_SECRET?.trim() || void 0;
4139
- }
4140
- /** Encode ordered signing fields without allowing delimiter ambiguity. */
4141
- function buildPayload(parts) {
4142
- return [CREDENTIAL_SUBJECT_HMAC_CONTEXT, ...parts].join("\0");
4143
- }
4144
- function signPayload(secret, payload) {
4145
- const digest = createHmac("sha256", secret).update(payload).digest("hex");
4146
- return `${CREDENTIAL_SUBJECT_SIGNATURE_VERSION}=${digest}`;
4147
- }
4148
- function timingSafeMatch(expected, actual) {
4149
- const expectedBuffer = Buffer.from(expected);
4150
- const actualBuffer = Buffer.from(actual);
4151
- if (expectedBuffer.length !== actualBuffer.length) return false;
4152
- return timingSafeEqual(expectedBuffer, actualBuffer);
4153
- }
4154
- /** Create a delegated user credential subject for a verified Slack DM turn. */
4155
- function createSlackDirectCredentialSubject(input) {
4156
- const channelId = normalizeSlackConversationId(input.channelId);
4157
- const teamId = input.teamId?.trim();
4158
- const userId = parseActorUserId(input.userId);
4159
- if (!channelId || !teamId || !userId || !isDmChannel(channelId)) return;
4160
- return {
4161
- type: "user",
4162
- userId,
4163
- allowedWhen: "private-direct-conversation"
4164
- };
2613
+ /** Cancel every active watch bound to one conversation. */
2614
+ async function cancelSubscriptions(input) {
2615
+ const subscriptions = await listWatches(input);
2616
+ for (const subscription of subscriptions) await cancelWatch({
2617
+ conversationId: input.conversationId,
2618
+ id: subscription.id,
2619
+ nowMs: input.nowMs,
2620
+ state: input.state
2621
+ });
4165
2622
  }
4166
- /** Bind a delegated user subject to the Slack DM destination being dispatched. */
4167
- function bindSlackDirectCredentialSubject(input) {
4168
- const channelId = normalizeSlackConversationId(input.channelId);
4169
- const teamId = input.teamId.trim();
4170
- const secret = getCredentialSubjectSecret();
4171
- const { subject } = input;
4172
- const userId = parseActorUserId(subject.userId);
4173
- if (!channelId || !teamId || !secret || !isDmChannel(channelId) || subject.type !== "user" || !userId || subject.allowedWhen !== "private-direct-conversation") return;
4174
- return {
4175
- type: "user",
4176
- userId,
4177
- allowedWhen: subject.allowedWhen,
4178
- binding: {
4179
- type: "slack-direct-conversation",
4180
- teamId,
4181
- channelId,
4182
- signature: signPayload(secret, buildPayload([
4183
- subject.allowedWhen,
4184
- teamId,
4185
- channelId,
4186
- userId
4187
- ]))
2623
+ /**
2624
+ * Collect match keys from active watches for these identifiers and event types.
2625
+ * Does not evaluate match values — only reports keys that filters use.
2626
+ */
2627
+ async function collectEventMatchKeys(input) {
2628
+ const state = input.state ?? getStateAdapter();
2629
+ await state.connect();
2630
+ const nowMs = input.nowMs ?? Date.now();
2631
+ const eventTypes = new Set(input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean));
2632
+ if (eventTypes.size === 0) return [];
2633
+ const keys = /* @__PURE__ */ new Set();
2634
+ const identifiers = new Set(input.identifiers.map((value) => value.trim()).filter(Boolean));
2635
+ for (const identifier of identifiers) {
2636
+ const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, identifier));
2637
+ const records = await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))));
2638
+ for (const record of records) {
2639
+ if (!record) continue;
2640
+ if (record.namespace !== input.namespace) continue;
2641
+ if (record.identifier !== identifier) continue;
2642
+ if (!activeAt(record, nowMs)) continue;
2643
+ if (!record.events.some((eventType) => eventTypes.has(eventType))) continue;
2644
+ for (const key of Object.keys(record.match ?? {})) keys.add(key);
4188
2645
  }
4189
- };
2646
+ }
2647
+ return [...keys].sort();
4190
2648
  }
4191
- /** Verify that a delegated subject was signed for the dispatch destination. */
4192
- function verifySlackDirectCredentialSubject(input) {
4193
- const channelId = normalizeSlackConversationId(input.channelId);
4194
- const secret = getCredentialSubjectSecret();
4195
- if (!channelId || !secret) return false;
4196
- const { subject } = input;
4197
- const binding = subject.binding;
4198
- if (subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "private-direct-conversation" || !binding || binding.type !== "slack-direct-conversation" || typeof binding.signature !== "string" || !binding.signature || binding.teamId !== input.teamId || binding.channelId !== channelId) return false;
4199
- return timingSafeMatch(signPayload(secret, buildPayload([
4200
- subject.allowedWhen,
4201
- binding.teamId,
4202
- binding.channelId,
4203
- subject.userId
4204
- ])), binding.signature);
2649
+ /** Find active subscriptions interested in a normalized event. */
2650
+ async function findMatchingWatches(input) {
2651
+ const state = input.state ?? getStateAdapter();
2652
+ await state.connect();
2653
+ const nowMs = input.nowMs ?? Date.now();
2654
+ const ids = await readSubscriptionIdIndex(state, resourceIndexKey(input.namespace, input.identifier));
2655
+ return (await Promise.all(ids.map(async (id) => parseSubscription(await state.get(subscriptionKey(id)))))).filter((record) => record !== void 0 && matchesEvent(record, {
2656
+ data: input.data,
2657
+ eventType: input.eventType,
2658
+ nowMs,
2659
+ namespace: input.namespace,
2660
+ identifier: input.identifier
2661
+ }));
4205
2662
  }
4206
- /** Bind a delegated user subject to one scheduler task dispatch. */
4207
- function bindScheduledAutomationCredentialSubject(input) {
4208
- const secret = getCredentialSubjectSecret();
4209
- const plugin = input.plugin.trim();
4210
- const userId = parseActorUserId(input.subject.userId);
4211
- if (!secret || plugin !== "scheduler" || !userId || input.subject.allowedWhen !== "scheduled-automation") return;
4212
- const taskId = input.subject.taskId;
4213
- if (!taskId || taskId !== taskId.trim()) return;
4214
- return {
4215
- type: "user",
4216
- userId,
4217
- allowedWhen: "scheduled-automation",
4218
- taskId,
4219
- binding: {
4220
- type: "scheduled-automation",
4221
- plugin,
4222
- taskId,
4223
- signature: signPayload(secret, buildPayload([
4224
- "scheduled-automation",
4225
- plugin,
4226
- taskId,
4227
- userId
4228
- ]))
2663
+ /** Recheck and deliver a matched subscription while holding its status lock. */
2664
+ async function deliverWatch(input) {
2665
+ const state = input.state ?? getStateAdapter();
2666
+ await state.connect();
2667
+ return await withSubscriptionLock(state, input.subscription.id, async () => {
2668
+ const nowMs = input.nowMs ?? Date.now();
2669
+ const current = parseSubscription(await state.get(subscriptionKey(input.subscription.id)));
2670
+ if (!current || !matchesEvent(current, {
2671
+ data: input.data,
2672
+ eventType: input.eventType,
2673
+ nowMs,
2674
+ namespace: input.namespace,
2675
+ identifier: input.identifier
2676
+ })) return false;
2677
+ const delivered = await input.deliver(current);
2678
+ if (input.terminal) {
2679
+ const latest = parseSubscription(await state.get(subscriptionKey(current.id)));
2680
+ if (!latest || latest.status !== current.status || latest.updatedAtMs !== current.updatedAtMs) return delivered;
2681
+ const next = {
2682
+ ...current,
2683
+ status: "completed",
2684
+ updatedAtMs: nowMs
2685
+ };
2686
+ await state.set(subscriptionKey(current.id), next, JUNIOR_THREAD_STATE_TTL_MS);
2687
+ await removeFromIndex(state, resourceIndexKey(current.namespace, current.identifier), current.id, nowMs);
2688
+ await removeFromIndex(state, conversationIndexKey(current.conversationId), current.id, nowMs);
4229
2689
  }
4230
- };
4231
- }
4232
- /** Verify that a delegated subject was signed for one scheduler task. */
4233
- function verifyScheduledAutomationCredentialSubject(input) {
4234
- const secret = getCredentialSubjectSecret();
4235
- const { subject } = input;
4236
- const binding = subject.binding;
4237
- if (!secret || input.plugin !== "scheduler" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "scheduled-automation" || !subject.taskId || binding.type !== "scheduled-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
4238
- return timingSafeMatch(signPayload(secret, buildPayload([
4239
- "scheduled-automation",
4240
- binding.plugin,
4241
- binding.taskId,
4242
- subject.userId
4243
- ])), binding.signature);
2690
+ if (input.terminal && current.firesAtMs !== void 0) await updateTimerIndex(state, {
2691
+ kind: "remove",
2692
+ id: current.id
2693
+ });
2694
+ return delivered;
2695
+ }, input.waitDeadlineMs);
4244
2696
  }
4245
- /** Bind a delegated user subject to one event automation dispatch. */
4246
- function bindEventAutomationCredentialSubject(input) {
4247
- const secret = getCredentialSubjectSecret();
4248
- const plugin = input.plugin.trim();
4249
- const userId = parseActorUserId(input.subject.userId);
4250
- if (!secret || plugin !== "junior" || !userId || input.subject.allowedWhen !== "event-automation") return;
4251
- const taskId = input.subject.taskId;
4252
- if (!taskId || taskId !== taskId.trim()) return;
2697
+ //#endregion
2698
+ //#region src/chat/events/tool-support.ts
2699
+ /** Default lifetime for temporary watches. */
2700
+ const RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS = 12096e5;
2701
+ /** Hard upper bound for temporary watches. */
2702
+ const RESOURCE_SUBSCRIPTION_MAX_TTL_MS = 2592e6;
2703
+ const STOP_WATCHING_TOOL_NAME = "stopWatchingResources";
2704
+ const RESOURCE_WATCH_TOOL_SOURCE = {
2705
+ id: "resource-watches",
2706
+ description: "Inspect or stop watches for the current conversation."
2707
+ };
2708
+ //#endregion
2709
+ //#region src/chat/slack/tool-support/context.ts
2710
+ /** Resolve Slack tool context from the Conversation Location. */
2711
+ function getSlackToolContext(context) {
2712
+ const source = context.source.kind === "slack" ? context.source : void 0;
2713
+ const location = context.location?.provider === "slack" ? context.location : source ? {
2714
+ provider: "slack",
2715
+ teamId: source.teamId,
2716
+ channelId: source.channelId,
2717
+ threadTs: source.threadTs
2718
+ } : void 0;
2719
+ if (!location) return void 0;
2720
+ const destinationChannelId = parseSlackChannelReferenceId(context.destination.platform === "slack" ? context.destination.channelId : location.channelId);
2721
+ const locationChannelId = parseSlackChannelReferenceId(location.channelId);
2722
+ const teamId = parseSlackTeamId(location.teamId);
2723
+ if (!destinationChannelId || !locationChannelId || !teamId) return;
4253
2724
  return {
4254
- type: "user",
4255
- userId,
4256
- allowedWhen: "event-automation",
4257
- taskId,
4258
- binding: {
4259
- type: "event-automation",
4260
- plugin,
4261
- taskId,
4262
- signature: signPayload(secret, buildPayload([
4263
- "event-automation",
4264
- plugin,
4265
- taskId,
4266
- userId
4267
- ]))
4268
- }
2725
+ actor: context.actor?.platform === "slack" ? context.actor : void 0,
2726
+ destinationChannelId,
2727
+ locationChannelId,
2728
+ messageChannelId: source ? parseSlackChannelReferenceId(source.channelId) : void 0,
2729
+ messageTs: parseSlackMessageTs(source?.messageTs),
2730
+ teamId,
2731
+ threadTs: parseSlackMessageTs(location.threadTs)
4269
2732
  };
4270
2733
  }
4271
- /** Verify that a delegated subject was signed for one event automation. */
4272
- function verifyEventAutomationCredentialSubject(input) {
4273
- const secret = getCredentialSubjectSecret();
4274
- const { subject } = input;
4275
- const binding = subject.binding;
4276
- if (!secret || input.plugin !== "junior" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "event-automation" || !subject.taskId || binding.type !== "event-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
4277
- return timingSafeMatch(signPayload(secret, buildPayload([
4278
- "event-automation",
4279
- binding.plugin,
4280
- binding.taskId,
4281
- subject.userId
4282
- ])), binding.signature);
4283
- }
4284
2734
  //#endregion
4285
2735
  //#region src/chat/slack/tool-support/channel-capabilities.ts
4286
2736
  /** Resolve channel capabilities from a Slack channel ID. */
@@ -5754,4 +4204,4 @@ function createPluginHookRunner(input = {}) {
5754
4204
  };
5755
4205
  }
5756
4206
  //#endregion
5757
- export { readActorIdentity as $, getConversationMessageSearchStore as $t, getWorkspaceSnapshotBuilder as A, conversationHasParticipantUser as At, SlackActionError as B, readAttachmentVisionSummary as Bt, WorkspaceValidationError as C, sameDestination as Cn, createPluginModel as Ct, setWorkspaceSnapshotBuild as D, conversationArchivedForEmail as Dt, setWorkspaceSnapshot as E, conversationArchiveTimes as Et, bindScheduledAutomationCredentialSubject as F, readLatestConversationBrief as Ft, isConversationScopedChannel as G, listConversationAnnotations as Gt, getFilePermalink as H, resolveRootVisibility as Ht, bindSlackDirectCredentialSubject as I, readConversationEventPage as It, runWithSlackInstallationToken as J, agentsInstructionsUpdatedEvent as Jt, isDmChannel as K, listConversationAnnotationsById as Kt, verifyEventAutomationCredentialSubject as L, buildSentryConversationUrl as Lt, workspaceRepoCheckoutPath as M, conversationNotArchivedForUser as Mt, resolveChannelCapabilities as N, viewerConversationMembership as Nt, SANDBOX_RUNTIME as O, conversationArchivedForUser as Ot, bindEventAutomationCredentialSubject as P, createPluginConversationEvents as Pt, findUserByEmail as Q, getConversationEventStore as Qt, verifyScheduledAutomationCredentialSubject as R, getSlackFileId as Rt, updateWorkspace as S, requireSlackDestination as Sn, createPluginEmbedder as St, loadSnapshotsForProfile as T, readConversationAccessFromSql as Tt, getHeaderString as U, saveObjectAnnotations as Ut, downloadPrivateSlackFile as V, recordSlackAttachment as Vt, getSlackClient as W, createPluginAnnotations as Wt, getWorkspaceTeamId as X, authenticationUnlinkedEvent as Xt, withSlackRetries as Y, authenticationLinkedEvent as Yt, runWithWorkspaceTeamId as Z, db_exports as Zt, deleteWorkspace as _, saveUserTimezone as _n, getDashboardConversationLink as _t, getPluginRoutes as a, withConversationMutationLock as an, RESOURCE_WATCH_TOOL_SOURCE as at, listWorkspaces as b, createSlackDestination as bn, setDashboardConversationLinkOptions as bt, getPluginTools as c, parseSessionSource as cn, cancelWatch as ct, setPlugins as d, agentTurnTotalTokens as dn, createWatch as dt, getConversationStore as en, resolveViewerUser as et, validatePlugins as f, agentTurnUsageSchema as fn, deliverWatch as ft, createWorkspace as g, readUserTimezone as gn, JUNIOR_THREAD_STATE_TTL_MS as gt, setBriefsConfig as h, deleteProviderIdentityForSlackUser as hn, takeDueTimerWatches as ht, getPluginApiRoutes as i, withConversationEventLock as in, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as it, tryWorkspaceRepoCheckoutPath as j, conversationNotArchivedForEmail as jt, deleteWorkspaceSnapshotBuilders as k, conversationHasParticipantEmail as kt, getPluginUserPromptContributions as l, addAgentTurnUsage as ln, collectEventMatchKeys as lt, registration_exports as m, resolveRootConversationId as mn, listWatches as mt, applyPluginFormatMarkdown as n, getSqlExecutor as nn, getSlackToolContext as nt, getPluginSlackConversationLink as o, locationFromRow as on, STOP_WATCHING_TOOL_NAME as ot, coreTaskRegistrations as p, hasAgentTurnUsage as pn, findMatchingWatches as pt, normalizeSlackConversationId as q, JUNIOR_NATIVE_EVENT_NAMESPACE as qt, createPluginHookRunner as r, createSqlConversationEventStore as rn, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as rt, getPluginSystemPromptContributions as s, bindProviderConversation as sn, cancelSubscriptions as st, agent_hooks_exports as t, getDb as tn, updateViewerDisplayName as tt, getPlugins as u, agentTurnCostUsd as un, createTimerWatch as ut, getWorkspace as v, upsertIdentity as vn, getDashboardObjectIconLink as vt, invalidateReadySnapshot as w, createPluginLogger as wt, listWorkspacesByRepository as x, parseDestination as xn, createPluginState as xt, getWorkspaceByName as y, upsertLinkedIdentity as yn, getDashboardTaskLink as yt, verifySlackDirectCredentialSubject as z, matchSlackAttachments as zt };
4207
+ export { verifySlackDirectCredentialSubject as $, getWorkspaceSnapshotBuilder as A, viewerConversationMembership as At, cancelWatch as B, saveObjectAnnotations as Bt, WorkspaceValidationError as C, conversationArchiveTimes as Ct, setWorkspaceSnapshotBuild as D, conversationHasParticipantUser as Dt, setWorkspaceSnapshot as E, conversationHasParticipantEmail as Et, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as F, getSlackFileId as Ft, findMatchingWatches as G, agentsInstructionsUpdatedEvent as Gt, createTimerWatch as H, listConversationAnnotations as Ht, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as I, matchSlackAttachments as It, bindEventAutomationCredentialSubject as J, listWatches as K, authenticationLinkedEvent as Kt, RESOURCE_WATCH_TOOL_SOURCE as L, readAttachmentVisionSummary as Lt, workspaceRepoCheckoutPath as M, readLatestConversationBrief as Mt, resolveChannelCapabilities as N, readConversationEventPage as Nt, SANDBOX_RUNTIME as O, conversationNotArchivedForEmail as Ot, getSlackToolContext as P, buildSentryConversationUrl as Pt, verifyScheduledAutomationCredentialSubject as Q, STOP_WATCHING_TOOL_NAME as R, recordSlackAttachment as Rt, updateWorkspace as S, readConversationAccessFromSql as St, loadSnapshotsForProfile as T, conversationArchivedForUser as Tt, createWatch as U, listConversationAnnotationsById as Ut, collectEventMatchKeys as V, createPluginAnnotations as Vt, deliverWatch as W, JUNIOR_NATIVE_EVENT_NAMESPACE as Wt, bindSlackDirectCredentialSubject as X, bindScheduledAutomationCredentialSubject as Y, verifyEventAutomationCredentialSubject as Z, deleteWorkspace as _, setDashboardConversationLinkOptions as _t, getPluginRoutes as a, getSlackClient as at, listWorkspaces as b, createPluginModel as bt, getPluginTools as c, normalizeSlackConversationId as ct, setPlugins as d, getWorkspaceTeamId as dt, verifyTimerWatchCredentialSubject as et, validatePlugins as f, runWithWorkspaceTeamId as ft, createWorkspace as g, getDashboardTaskLink as gt, setBriefsConfig as h, getDashboardObjectIconLink as ht, getPluginApiRoutes as i, getHeaderString as it, tryWorkspaceRepoCheckoutPath as j, createPluginConversationEvents as jt, deleteWorkspaceSnapshotBuilders as k, conversationNotArchivedForUser as kt, getPluginUserPromptContributions as l, runWithSlackInstallationToken as lt, registration_exports as m, getDashboardConversationLink as mt, applyPluginFormatMarkdown as n, downloadPrivateSlackFile as nt, getPluginSlackConversationLink as o, isConversationScopedChannel as ot, coreTaskRegistrations as p, JUNIOR_THREAD_STATE_TTL_MS as pt, takeDueTimerWatches as q, authenticationUnlinkedEvent as qt, createPluginHookRunner as r, getFilePermalink as rt, getPluginSystemPromptContributions as s, isDmChannel as st, agent_hooks_exports as t, SlackActionError as tt, getPlugins as u, withSlackRetries as ut, getWorkspace as v, createPluginState as vt, invalidateReadySnapshot as w, conversationArchivedForEmail as wt, listWorkspacesByRepository as x, createPluginLogger as xt, getWorkspaceByName as y, createPluginEmbedder as yt, cancelSubscriptions as z, resolveRootVisibility as zt };