@sentry/junior 0.240.0 → 0.242.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{admin-D6fRPRgC.js → admin-C54kvHVC.js} +1 -1
- package/dist/{agent-hooks-JFbgedMZ.js → agent-hooks-BVttjZvb.js} +1952 -1952
- package/dist/{agent-runner-0IjJxsL1.js → agent-runner-BTgnxJ7g.js} +336 -1125
- package/dist/api/schema.d.ts +9 -0
- package/dist/api/schema.js +2 -2
- package/dist/api.js +21 -21
- package/dist/app.js +104 -31
- package/dist/{automation-B5_c8hEb.js → automation-gznOMaQ2.js} +11 -2
- package/dist/{briefs-BGWTp-nz.js → briefs-CKUsv9F1.js} +2 -2
- package/dist/{catalog-runtime-DdigNl7s.js → catalog-runtime-C2YkfMZz.js} +1 -1
- package/dist/cli/chat.js +12 -12
- package/dist/cli/check.js +3 -3
- package/dist/cli/main.js +2 -2
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +3 -3
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-Cn73E7br.js → client-D51G-Jdg.js} +2 -2
- package/dist/{config-B0S86yw9.js → config-C6Uu3Re9.js} +2 -2
- package/dist/{conversation-privacy-BKlX7D_R.js → conversation-privacy-CzL47nXs.js} +1 -1
- package/dist/{credential-sync-DeNQm7st.js → credential-sync-DvDaE-Uj.js} +14 -14
- package/dist/{executor-BQ-A5nov.js → executor-CGUukwus.js} +4 -2
- package/dist/{images-C7OKd6uy.js → images-DSxZt5vk.js} +2 -2
- package/dist/instrumentation.js +2 -2
- package/dist/{job-queue-DgXLqoeB.js → job-queue-hLylM9cy.js} +1 -1
- package/dist/{logging-BSNF167N.js → logging-BdZ_mK3q.js} +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-authorization-BM7g61od.js → oauth-authorization-BokpeHv-.js} +1 -1
- package/dist/{oauth-callback-server-Dw-BwI6x.js → oauth-callback-server-C3BOSmT_.js} +2 -2
- package/dist/{pending-auth-Chn_EA1_.js → pending-auth-DOzL4D24.js} +2 -2
- package/dist/{profile-3t9H_IwL.js → profile-2gPzzMib.js} +4 -4
- package/dist/{registry-DLZlAhdP.js → registry-XJkXDNK2.js} +1 -1
- package/dist/{resolve-9_cIPQsq.js → resolve-DhcdtDt6.js} +2 -2
- package/dist/{runner-C_eUbFyp.js → runner-CGyrnIPm.js} +9 -9
- package/dist/{sandbox-egress-signals-C1ycxTNA.js → sandbox-egress-signals-D6F3OZJP.js} +1 -1
- package/dist/{schema-C4XwHTY4.js → schema-Cabm4jcj.js} +1 -1
- package/dist/{skills-Dqgjr_Tl.js → skills-C6PqcBPU.js} +2 -2
- package/dist/{spawn-BfEr3t7b.js → spawn-DL9x3T1o.js} +2 -2
- package/dist/{task-queue-CuFZDOXn.js → task-queue-vZnBGu9B.js} +3 -3
- package/dist/{task-runner-c7gwV8dj.js → task-runner-C-c0t5F5.js} +54 -12
- package/dist/{checkpoint-C1ZnSz7r.js → tasks-EqCtmUJ2.js} +1056 -14
- package/dist/{thread-state-exTu-G1w.js → thread-state-B8IKs_J8.js} +6 -6
- package/dist/{turn-failure-response-DFp99ziF.js → turn-failure-response-BS7OO1BE.js} +9 -8
- package/dist/{turn-lifecycle-C4TjuOzG.js → turn-lifecycle-Cxrt8qMD.js} +1 -1
- package/dist/{turn-session-routing-Vpg8jUD1.js → turn-session-routing-BlGt2AUb.js} +1 -1
- package/dist/{validation-D2lULEG9.js → validation-C8hci6pj.js} +1 -1
- package/dist/{version-BjBMCc2o.js → version-CjnHQ8aY.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-XE3nFqKM.js → viewer-BOgEy5zC.js} +3 -3
- package/dist/{web-authorization-8E8IrWOj.js → web-authorization-DSMV5tMK.js} +6 -6
- package/dist/{web-input-DF4GGJrf.js → web-input-TD5P9jdo.js} +135 -129
- package/dist/{work-Dt_JjfbX.js → work-D39OAwP1.js} +7 -7
- package/dist/{worker-BrbnLmD3.js → worker-CWiLgCWH.js} +5 -5
- package/migrations/0049_event_automation_status_reason.sql +1 -0
- package/migrations/meta/0049_snapshot.json +3581 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +5 -5
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-B4FMCO8f.js";
|
|
2
|
-
import { a as embedTexts, r as completeObject } from "./client-
|
|
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-
|
|
2
|
+
import { a as embedTexts, r as completeObject } from "./client-D51G-Jdg.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-BdZ_mK3q.js";
|
|
4
4
|
import { a as getClient$1 } from "./sentry-CgWZM_kV.js";
|
|
5
|
-
import { t as canExposeConversationPayload } from "./conversation-privacy-
|
|
6
|
-
import { i as getChatConfig, n as botConfig, o as getSlackBotToken } from "./config-
|
|
5
|
+
import { t as canExposeConversationPayload } from "./conversation-privacy-CzL47nXs.js";
|
|
6
|
+
import { i as getChatConfig, n as botConfig, o as getSlackBotToken } from "./config-C6Uu3Re9.js";
|
|
7
7
|
import { r as defaultModelId } from "./model-profile-BwU8mqwD.js";
|
|
8
8
|
import { d as parseSlackTeamId, g as parseSlackMessageTs, i as isActorUserId, o as parseActorUserId, u as parseSlackChannelReferenceId } from "./actor-BTtLaeWy.js";
|
|
9
|
-
import { O as readUserTimezone, T as hasAgentTurnUsage, c as getConversationEventStore, d as getDb, f as getSqlExecutor, h as createSqlStore, i as resolveViewerUser, k as saveUserTimezone, r as readActorIdentity } from "./viewer-
|
|
9
|
+
import { O as readUserTimezone, T as hasAgentTurnUsage, c as getConversationEventStore, d as getDb, f as getSqlExecutor, h as createSqlStore, i as resolveViewerUser, k as saveUserTimezone, r as readActorIdentity } from "./viewer-BOgEy5zC.js";
|
|
10
10
|
import { n as juniorDestinations } from "./destinations-BeBiJeEM.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-
|
|
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-CGUukwus.js";
|
|
12
12
|
import { o as decodeStoredConversationEvent } from "./history-BLTUgFYO.js";
|
|
13
|
-
import { p as credentialSubjectSchema } from "./registry-
|
|
13
|
+
import { p as credentialSubjectSchema } from "./registry-XJkXDNK2.js";
|
|
14
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-
|
|
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-2gPzzMib.js";
|
|
16
16
|
import { i as isValidTimeZone } from "./cadence-D00SCzT1.js";
|
|
17
17
|
import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-CMU-B4FA.js";
|
|
18
18
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -143,2095 +143,2095 @@ function renderJuniorNativeConversationEvent(args) {
|
|
|
143
143
|
return presentation === void 0 ? void 0 : conversationEventPresentationSchema.parse(presentation);
|
|
144
144
|
}
|
|
145
145
|
//#endregion
|
|
146
|
-
//#region src/chat/slack/
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
profile: z.object({
|
|
153
|
-
email: z.string().nullish(),
|
|
154
|
-
real_name: z.string().nullish()
|
|
155
|
-
}).optional()
|
|
156
|
-
})
|
|
157
|
-
});
|
|
158
|
-
const USER_CACHE_TTL_MS = 3e5;
|
|
159
|
-
const userCache = /* @__PURE__ */ new Map();
|
|
160
|
-
function userCacheKey(teamId, userId) {
|
|
161
|
-
return `${teamId}:${userId}`;
|
|
162
|
-
}
|
|
163
|
-
function readFromCache(teamId, userId) {
|
|
164
|
-
const hit = userCache.get(userCacheKey(teamId, userId));
|
|
165
|
-
if (!hit) return null;
|
|
166
|
-
if (hit.expiresAt < Date.now()) {
|
|
167
|
-
userCache.delete(userCacheKey(teamId, userId));
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
return hit.value;
|
|
146
|
+
//#region src/chat/slack/workspace-context.ts
|
|
147
|
+
const workspaceTeamIdStorage = new AsyncLocalStorage();
|
|
148
|
+
/** Run a callback with the Slack workspace team ID for the inbound webhook. */
|
|
149
|
+
function runWithWorkspaceTeamId(teamId, fn) {
|
|
150
|
+
if (!teamId) return fn();
|
|
151
|
+
return workspaceTeamIdStorage.run(teamId, fn);
|
|
171
152
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expiresAt: Date.now() + USER_CACHE_TTL_MS
|
|
176
|
-
});
|
|
153
|
+
/** Return the Slack workspace team ID for the current inbound webhook. */
|
|
154
|
+
function getWorkspaceTeamId() {
|
|
155
|
+
return workspaceTeamIdStorage.getStore();
|
|
177
156
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/chat/slack/client.ts
|
|
159
|
+
var SlackActionError = class extends Error {
|
|
160
|
+
code;
|
|
161
|
+
apiError;
|
|
162
|
+
needed;
|
|
163
|
+
provided;
|
|
164
|
+
statusCode;
|
|
165
|
+
requestId;
|
|
166
|
+
errorData;
|
|
167
|
+
retryAfterSeconds;
|
|
168
|
+
detail;
|
|
169
|
+
detailLine;
|
|
170
|
+
detailRule;
|
|
171
|
+
constructor(message, code, options = {}) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = "SlackActionError";
|
|
174
|
+
this.code = code;
|
|
175
|
+
this.apiError = options.apiError;
|
|
176
|
+
this.needed = options.needed;
|
|
177
|
+
this.provided = options.provided;
|
|
178
|
+
this.statusCode = options.statusCode;
|
|
179
|
+
this.requestId = options.requestId;
|
|
180
|
+
this.errorData = options.errorData;
|
|
181
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
182
|
+
this.detail = options.detail;
|
|
183
|
+
this.detailLine = options.detailLine;
|
|
184
|
+
this.detailRule = options.detailRule;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
function serializeSlackErrorData(data) {
|
|
188
|
+
if (!data || typeof data !== "object") return;
|
|
189
|
+
const filtered = Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error"));
|
|
190
|
+
if (Object.keys(filtered).length === 0) return;
|
|
185
191
|
try {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
const serialized = JSON.stringify(filtered);
|
|
193
|
+
return serialized.length <= 600 ? serialized : `${serialized.slice(0, 597)}...`;
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Extract a header value by case-insensitive name from a raw headers object. */
|
|
199
|
+
function getHeaderString(headers, name) {
|
|
200
|
+
if (!headers || typeof headers !== "object") return;
|
|
201
|
+
const key = name.toLowerCase();
|
|
202
|
+
const entries = headers;
|
|
203
|
+
for (const [entryKey, value] of Object.entries(entries)) {
|
|
204
|
+
if (entryKey.toLowerCase() !== key) continue;
|
|
205
|
+
if (typeof value === "string") return value;
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
const first = value.find((entry) => typeof entry === "string");
|
|
208
|
+
return typeof first === "string" ? first : void 0;
|
|
194
209
|
}
|
|
195
|
-
const parsed = slackUserInfoSchema.safeParse(await response.json());
|
|
196
|
-
if (!parsed.success) return null;
|
|
197
|
-
const payload = parsed.data;
|
|
198
|
-
const result = {
|
|
199
|
-
userName: payload.user.name?.trim() || void 0,
|
|
200
|
-
fullName: payload.user.profile?.real_name?.trim() || void 0,
|
|
201
|
-
email: payload.user.profile?.email?.trim() || void 0,
|
|
202
|
-
timezone: payload.user.tz?.trim() || void 0
|
|
203
|
-
};
|
|
204
|
-
writeToCache(teamId, userId, result);
|
|
205
|
-
return result;
|
|
206
|
-
} catch (error) {
|
|
207
|
-
logWarn("slack.user.lookup.failed", {
|
|
208
|
-
"enduser.id": userId,
|
|
209
|
-
"app.slack.team_id": teamId,
|
|
210
|
-
"exception.message": error instanceof Error ? error.message : String(error)
|
|
211
|
-
});
|
|
212
|
-
return null;
|
|
213
210
|
}
|
|
214
211
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
212
|
+
function parseSlackCanvasDetail(detail) {
|
|
213
|
+
if (typeof detail !== "string") return {};
|
|
214
|
+
const trimmed = detail.trim();
|
|
215
|
+
if (!trimmed) return {};
|
|
216
|
+
const parsed = { detail: trimmed };
|
|
217
|
+
const lineMatch = trimmed.match(/line\s+(\d+):/i);
|
|
218
|
+
if (lineMatch) {
|
|
219
|
+
const line = Number.parseInt(lineMatch[1] ?? "", 10);
|
|
220
|
+
if (Number.isFinite(line)) parsed.detailLine = line;
|
|
221
|
+
}
|
|
222
|
+
if (/unsupported heading depth/i.test(trimmed)) parsed.detailRule = "unsupported_heading_depth";
|
|
223
|
+
return parsed;
|
|
221
224
|
}
|
|
222
225
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
+
* Ambient destination-installation token for Slack outbound calls, bound by
|
|
227
|
+
* installation-scoped entry points so writes carry the destination
|
|
228
|
+
* workspace's credentials instead of the process-global env token.
|
|
226
229
|
*/
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
|
|
239
|
-
if (!parsed.success) return void 0;
|
|
240
|
-
return {
|
|
241
|
-
...parsed.data,
|
|
242
|
-
plugin: row.plugin,
|
|
243
|
-
createdAt: row.createdAt.toISOString(),
|
|
244
|
-
updatedAt: row.updatedAt.toISOString()
|
|
245
|
-
};
|
|
230
|
+
const installationTokenStorage = new AsyncLocalStorage();
|
|
231
|
+
const clientsByToken = /* @__PURE__ */ new Map();
|
|
232
|
+
/**
|
|
233
|
+
* Bind a workspace installation token for all Slack Web API calls inside
|
|
234
|
+
* `fn`, so multi-workspace outbound writes use the destination workspace's
|
|
235
|
+
* credentials rather than the env fallback token.
|
|
236
|
+
*/
|
|
237
|
+
function runWithSlackInstallationToken(token, fn) {
|
|
238
|
+
const trimmed = token.trim();
|
|
239
|
+
if (!trimmed) throw new SlackActionError("Slack installation token binding requires a non-empty token", "missing_token");
|
|
240
|
+
return installationTokenStorage.run({ token: trimmed }, fn);
|
|
246
241
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}).onConflictDoUpdate({
|
|
261
|
-
target: [
|
|
262
|
-
juniorConversationAnnotations.conversationId,
|
|
263
|
-
juniorConversationAnnotations.plugin,
|
|
264
|
-
juniorConversationAnnotations.kind,
|
|
265
|
-
juniorConversationAnnotations.key
|
|
266
|
-
],
|
|
267
|
-
set: {
|
|
268
|
-
annotation,
|
|
269
|
-
updatedAt: now
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
},
|
|
273
|
-
async remove(kind, key) {
|
|
274
|
-
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)));
|
|
275
|
-
},
|
|
276
|
-
async list() {
|
|
277
|
-
return (await listConversationAnnotations(args.db, args.conversationId)).filter((a) => a.plugin === args.plugin);
|
|
278
|
-
}
|
|
279
|
-
};
|
|
242
|
+
/**
|
|
243
|
+
* Token precedence: ambient installation token, then env token, then a hard
|
|
244
|
+
* failure for team-scoped calls — a call scoped to one workspace must never
|
|
245
|
+
* silently fall through to another workspace's credentials.
|
|
246
|
+
*/
|
|
247
|
+
function resolveSlackToken() {
|
|
248
|
+
const ambientToken = installationTokenStorage.getStore()?.token;
|
|
249
|
+
if (ambientToken) return ambientToken;
|
|
250
|
+
const envToken = getSlackBotToken();
|
|
251
|
+
if (envToken) return envToken;
|
|
252
|
+
const teamId = getWorkspaceTeamId();
|
|
253
|
+
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");
|
|
254
|
+
throw new SlackActionError("SLACK_BOT_TOKEN (or SLACK_BOT_USER_TOKEN) is required for Slack Web API actions in this service", "missing_token");
|
|
280
255
|
}
|
|
281
|
-
/**
|
|
282
|
-
|
|
283
|
-
return (
|
|
256
|
+
/** Normalize Junior Slack references to native Slack conversation IDs. */
|
|
257
|
+
function normalizeSlackConversationId(channelId) {
|
|
258
|
+
return parseSlackChannelReferenceId(channelId);
|
|
284
259
|
}
|
|
285
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Return the per-token cached WebClient. `withSlackRetries` owns retry
|
|
262
|
+
* classification for this boundary: the WebClient's built-in policy would
|
|
263
|
+
* re-post timed-out writes (a duplicate-message hazard) and sleep for the
|
|
264
|
+
* full unbounded Retry-After inside the request, so both internal behaviors
|
|
265
|
+
* are disabled here.
|
|
266
|
+
*/
|
|
267
|
+
function getClient() {
|
|
268
|
+
const token = resolveSlackToken();
|
|
269
|
+
let cached = clientsByToken.get(token);
|
|
270
|
+
if (!cached) {
|
|
271
|
+
cached = new WebClient(token, {
|
|
272
|
+
retryConfig: { retries: 0 },
|
|
273
|
+
rejectRateLimitedCalls: true
|
|
274
|
+
});
|
|
275
|
+
clientsByToken.set(token, cached);
|
|
296
276
|
}
|
|
297
|
-
return
|
|
277
|
+
return cached;
|
|
298
278
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
279
|
+
function mapSlackError(error) {
|
|
280
|
+
if (error instanceof SlackActionError) return error;
|
|
281
|
+
const candidate = error;
|
|
282
|
+
const apiError = candidate.data?.error;
|
|
283
|
+
const message = candidate.message ?? "Slack action failed";
|
|
284
|
+
const baseOptions = {
|
|
285
|
+
apiError,
|
|
286
|
+
statusCode: candidate.statusCode,
|
|
287
|
+
requestId: getHeaderString(candidate.headers, "x-slack-req-id"),
|
|
288
|
+
errorData: serializeSlackErrorData(candidate.data),
|
|
289
|
+
...parseSlackCanvasDetail(candidate.data?.detail)
|
|
290
|
+
};
|
|
291
|
+
if (apiError === "missing_scope") return new SlackActionError(message, "missing_scope", {
|
|
292
|
+
...baseOptions,
|
|
293
|
+
needed: candidate.data?.needed,
|
|
294
|
+
provided: candidate.data?.provided
|
|
314
295
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
296
|
+
if (apiError === "not_in_channel") return new SlackActionError(message, "not_in_channel", baseOptions);
|
|
297
|
+
if (apiError === "restricted_action_read_only_channel") return new SlackActionError(message, "read_only_channel", baseOptions);
|
|
298
|
+
if (apiError === "already_reacted") return new SlackActionError(message, "already_reacted", baseOptions);
|
|
299
|
+
if (apiError === "no_reaction") return new SlackActionError(message, "no_reaction", baseOptions);
|
|
300
|
+
if (apiError === "invalid_arguments") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
301
|
+
if (apiError === "invalid_cursor") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
302
|
+
if (apiError === "invalid_name") return new SlackActionError(message, "invalid_arguments", baseOptions);
|
|
303
|
+
if (apiError === "not_found" || apiError === "channel_not_found" || apiError === "message_not_found") return new SlackActionError(message, "not_found", baseOptions);
|
|
304
|
+
if (apiError === "feature_not_enabled" || apiError === "not_allowed_token_type") return new SlackActionError(message, "feature_unavailable", baseOptions);
|
|
305
|
+
if (apiError === "canvas_creation_failed") return new SlackActionError(message, "canvas_creation_failed", baseOptions);
|
|
306
|
+
if (apiError === "canvas_editing_failed") return new SlackActionError(message, "canvas_editing_failed", baseOptions);
|
|
307
|
+
if (candidate.code === "slack_webapi_rate_limited_error" || candidate.statusCode === 429) return new SlackActionError(message, "rate_limited", {
|
|
308
|
+
...baseOptions,
|
|
309
|
+
retryAfterSeconds: candidate.retryAfter
|
|
310
|
+
});
|
|
311
|
+
return new SlackActionError(message, "internal_error", baseOptions);
|
|
319
312
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
313
|
+
const MAX_RETRY_DELAY_MS = 1e4;
|
|
314
|
+
const MAX_TOTAL_RETRY_DELAY_MS = 2e4;
|
|
315
|
+
const CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
316
|
+
"ECONNREFUSED",
|
|
317
|
+
"ECONNRESET",
|
|
318
|
+
"ENOTFOUND",
|
|
319
|
+
"EAI_AGAIN"
|
|
320
|
+
]);
|
|
321
|
+
const TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
322
|
+
"ETIMEDOUT",
|
|
323
|
+
"ECONNABORTED",
|
|
324
|
+
"ESOCKETTIMEDOUT"
|
|
325
|
+
]);
|
|
326
|
+
function findNetworkErrorCode(error, depth = 0) {
|
|
327
|
+
if (!error || typeof error !== "object" || depth > 4) return;
|
|
328
|
+
const candidate = error;
|
|
329
|
+
if (typeof candidate.code === "string" && (CONNECTION_ERROR_CODES.has(candidate.code) || TIMEOUT_ERROR_CODES.has(candidate.code))) return candidate.code;
|
|
330
|
+
return findNetworkErrorCode(candidate.original, depth + 1) ?? findNetworkErrorCode(candidate.cause, depth + 1);
|
|
336
331
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const privacyRoot = alias(juniorConversations, "privacy_root");
|
|
340
|
-
/** Accept only a structurally valid persisted root as privacy authority. */
|
|
341
|
-
async function readRootCandidate(executor, conversationId) {
|
|
342
|
-
const row = (await executor.db().select({
|
|
343
|
-
requestedConversationId: juniorConversations.conversationId,
|
|
344
|
-
requestedParentConversationId: juniorConversations.parentConversationId,
|
|
345
|
-
requestedRootConversationId: juniorConversations.rootConversationId,
|
|
346
|
-
rootConversationId: privacyRoot.conversationId,
|
|
347
|
-
rootDestinationId: privacyRoot.destinationId,
|
|
348
|
-
rootParentConversationId: privacyRoot.parentConversationId,
|
|
349
|
-
rootRootConversationId: privacyRoot.rootConversationId
|
|
350
|
-
}).from(juniorConversations).leftJoin(privacyRoot, eq(privacyRoot.conversationId, juniorConversations.rootConversationId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
|
|
351
|
-
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 ? {
|
|
352
|
-
destinationId: row.rootDestinationId,
|
|
353
|
-
rootConversationId: row.rootConversationId
|
|
354
|
-
} : void 0;
|
|
332
|
+
function hasSocketHangUpMessage(error) {
|
|
333
|
+
return error instanceof Error && error.message.toLowerCase().includes("socket hang up");
|
|
355
334
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Classify a failed Slack call for retry: rate limits, Slack 5xx, and
|
|
337
|
+
* connection-phase failures are always retryable; timeouts are retried only
|
|
338
|
+
* for idempotent operations because Slack may already have accepted the
|
|
339
|
+
* request.
|
|
340
|
+
*/
|
|
341
|
+
function classifySlackRetry(raw, mapped) {
|
|
342
|
+
if (mapped.code === "rate_limited") return "rate_limited";
|
|
343
|
+
if (mapped.statusCode !== void 0 && mapped.statusCode >= 500) return "server_error";
|
|
344
|
+
const networkCode = findNetworkErrorCode(raw);
|
|
345
|
+
if (networkCode && CONNECTION_ERROR_CODES.has(networkCode)) return "connection";
|
|
346
|
+
if (networkCode && TIMEOUT_ERROR_CODES.has(networkCode)) return "timeout";
|
|
347
|
+
if (hasSocketHangUpMessage(raw)) return "connection";
|
|
348
|
+
return "none";
|
|
362
349
|
}
|
|
363
350
|
/**
|
|
364
|
-
*
|
|
351
|
+
* Run a Slack Web API call with bounded retries so transient platform
|
|
352
|
+
* failures do not surface as turn failures, while non-idempotent posts are
|
|
353
|
+
* never re-sent when Slack may already have accepted them.
|
|
365
354
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
355
|
+
* Retry classes: rate limits (bounded Retry-After), connection-phase network
|
|
356
|
+
* failures, and Slack 5xx responses always retry; request timeouts retry only
|
|
357
|
+
* when the caller marks the operation `idempotent`.
|
|
369
358
|
*/
|
|
370
|
-
async function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
359
|
+
async function withSlackRetries(task, maxAttempts = 3, context = {}) {
|
|
360
|
+
let attempt = 0;
|
|
361
|
+
let totalDelayMs = 0;
|
|
362
|
+
const action = context.action ?? "unknown";
|
|
363
|
+
while (attempt < maxAttempts) {
|
|
364
|
+
attempt += 1;
|
|
365
|
+
const attemptNumber = attempt;
|
|
366
|
+
try {
|
|
367
|
+
return await withSpan(`POST slack.com/api/${action}`, "http.client", {}, async () => {
|
|
368
|
+
try {
|
|
369
|
+
return await task();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
const mapped = mapSlackError(error);
|
|
372
|
+
const errorAttrs = { "error.type": mapped.code };
|
|
373
|
+
if (mapped.apiError) errorAttrs["app.slack.api_error_code"] = mapped.apiError;
|
|
374
|
+
if (mapped.code === "rate_limited") {
|
|
375
|
+
errorAttrs["http.response.status_code"] = 429;
|
|
376
|
+
if (mapped.retryAfterSeconds) errorAttrs["app.slack.retry_after_ms"] = mapped.retryAfterSeconds * 1e3;
|
|
377
|
+
} else if (mapped.statusCode != null) errorAttrs["http.response.status_code"] = mapped.statusCode;
|
|
378
|
+
setSpanAttributes(errorAttrs);
|
|
379
|
+
setSpanStatus("error");
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}, {
|
|
383
|
+
"http.request.method": "POST",
|
|
384
|
+
"server.address": "slack.com",
|
|
385
|
+
"url.scheme": "https",
|
|
386
|
+
"url.path": `/api/${action}`,
|
|
387
|
+
"app.slack.method": action,
|
|
388
|
+
"app.retry.max_attempts": maxAttempts,
|
|
389
|
+
...attemptNumber > 1 ? { "http.request.resend_count": attemptNumber - 1 } : void 0,
|
|
390
|
+
...context.attributes ?? {},
|
|
391
|
+
...context.spanAttributes ?? {}
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const mapped = mapSlackError(error);
|
|
395
|
+
const retryClass = classifySlackRetry(error, mapped);
|
|
396
|
+
const isRetryable = retryClass === "rate_limited" || retryClass === "connection" || retryClass === "server_error" || retryClass === "timeout" && context.idempotent === true;
|
|
397
|
+
const remainingDelayBudgetMs = MAX_TOTAL_RETRY_DELAY_MS - totalDelayMs;
|
|
398
|
+
const baseLogAttributes = {
|
|
399
|
+
"app.slack.action": action,
|
|
400
|
+
"app.slack.error_code": mapped.code,
|
|
401
|
+
...mapped.apiError ? { "app.slack.api_error": mapped.apiError } : void 0,
|
|
402
|
+
...mapped.detail ? { "app.slack.detail": mapped.detail } : void 0,
|
|
403
|
+
...mapped.detailLine !== void 0 ? { "app.slack.detail_line": mapped.detailLine } : void 0,
|
|
404
|
+
...mapped.detailRule ? { "app.slack.detail_rule": mapped.detailRule } : void 0,
|
|
405
|
+
...mapped.requestId ? { "app.slack.request_id": mapped.requestId } : void 0,
|
|
406
|
+
...mapped.statusCode !== void 0 ? { "http.response.status_code": mapped.statusCode } : void 0,
|
|
407
|
+
...context.attributes ?? {}
|
|
408
|
+
};
|
|
409
|
+
if (!isRetryable || attempt >= maxAttempts || remainingDelayBudgetMs <= 0) {
|
|
410
|
+
logWarn("slack.action.failed", {
|
|
411
|
+
...baseLogAttributes,
|
|
412
|
+
...mapped.errorData ? { "app.slack.error_data": mapped.errorData } : void 0
|
|
413
|
+
});
|
|
414
|
+
throw mapped;
|
|
415
|
+
}
|
|
416
|
+
logWarn("slack.action.retrying", {
|
|
417
|
+
...baseLogAttributes,
|
|
418
|
+
"app.slack.retry_attempt": attempt,
|
|
419
|
+
"app.slack.retry_class": retryClass
|
|
420
|
+
});
|
|
421
|
+
const retryAfterMs = retryClass === "rate_limited" && mapped.retryAfterSeconds && mapped.retryAfterSeconds > 0 ? mapped.retryAfterSeconds * 1e3 : void 0;
|
|
422
|
+
const backoffMs = Math.min(2e3, 250 * 2 ** (attempt - 1));
|
|
423
|
+
const delayMs = Math.min(retryAfterMs ?? backoffMs, MAX_RETRY_DELAY_MS, remainingDelayBudgetMs);
|
|
424
|
+
totalDelayMs += delayMs;
|
|
425
|
+
await sleep$1(delayMs);
|
|
426
|
+
}
|
|
397
427
|
}
|
|
398
|
-
|
|
399
|
-
//#endregion
|
|
400
|
-
//#region src/chat/slack/attachments.ts
|
|
401
|
-
/** Save the vision summary for a stored Slack attachment. */
|
|
402
|
-
async function recordSlackAttachment(args) {
|
|
403
|
-
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)));
|
|
404
|
-
}
|
|
405
|
-
/** Read a saved vision summary for one live attachment. */
|
|
406
|
-
async function readAttachmentVisionSummary(args) {
|
|
407
|
-
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)));
|
|
408
|
-
return row?.visionSummary;
|
|
409
|
-
}
|
|
410
|
-
/** Match live conversation attachments to Slack file ids. */
|
|
411
|
-
async function matchSlackAttachments(args) {
|
|
412
|
-
if (args.providerIds.length === 0) return [];
|
|
413
|
-
return await args.db.db().select({
|
|
414
|
-
id: juniorAttachments.id,
|
|
415
|
-
providerId: juniorAttachments.providerId,
|
|
416
|
-
bytes: juniorAttachments.bytes,
|
|
417
|
-
contentType: juniorAttachments.contentType,
|
|
418
|
-
filename: juniorAttachments.filename
|
|
419
|
-
}).from(juniorAttachments).where(and(eq(juniorAttachments.conversationId, args.conversationId), eq(juniorAttachments.provider, "slack"), inArray(juniorAttachments.providerId, args.providerIds), isNull(juniorAttachments.deleteRequestedAt)));
|
|
428
|
+
throw new SlackActionError("Slack action exhausted retries", "internal_error");
|
|
420
429
|
}
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (!ids.length) return;
|
|
434
|
-
const attachments = await matchSlackAttachments({
|
|
435
|
-
db,
|
|
436
|
-
conversationId,
|
|
437
|
-
providerIds: ids
|
|
430
|
+
/**
|
|
431
|
+
* Slack Web API client for the current destination workspace: the ambient
|
|
432
|
+
* installation token when one is bound, otherwise the env bot token for
|
|
433
|
+
* single-workspace deployments. Fails instead of falling back when the call
|
|
434
|
+
* is workspace-scoped and no installation token can be resolved, so a write
|
|
435
|
+
* never goes out with another workspace's credentials.
|
|
436
|
+
*/
|
|
437
|
+
function getSlackClient(options) {
|
|
438
|
+
if (options) return new WebClient(resolveSlackToken(), {
|
|
439
|
+
retryConfig: { retries: 0 },
|
|
440
|
+
rejectRateLimitedCalls: true,
|
|
441
|
+
timeout: options.timeoutMs
|
|
438
442
|
});
|
|
439
|
-
|
|
440
|
-
for (const message of messages) {
|
|
441
|
-
const stored = readMessageAttachments(message.meta?.attachments);
|
|
442
|
-
const matched = slackFileIds(message.meta).flatMap((id) => {
|
|
443
|
-
const attachment = byFileId.get(id);
|
|
444
|
-
return attachment ? [attachment] : [];
|
|
445
|
-
});
|
|
446
|
-
if (matched.length) message.meta = {
|
|
447
|
-
...message.meta,
|
|
448
|
-
attachments: [...stored, ...matched]
|
|
449
|
-
};
|
|
450
|
-
}
|
|
443
|
+
return getClient();
|
|
451
444
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
445
|
+
/**
|
|
446
|
+
* Slack channel ID prefixes:
|
|
447
|
+
* - C: channel — modern private channels also use C, so the prefix never
|
|
448
|
+
* proves a channel is public
|
|
449
|
+
* - G: legacy private channel / group DM
|
|
450
|
+
* - D: direct message (1:1)
|
|
451
|
+
*/
|
|
452
|
+
function isDmChannel(channelId) {
|
|
453
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
454
|
+
return Boolean(normalized && normalized.startsWith("D"));
|
|
455
455
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
456
|
+
/**
|
|
457
|
+
* Conversation-scoped Slack contexts backed by a concrete conversation ID.
|
|
458
|
+
* Includes channels/groups/DMs (C/G/D).
|
|
459
|
+
*/
|
|
460
|
+
function isConversationScopedChannel(channelId) {
|
|
461
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
462
|
+
if (!normalized) return false;
|
|
463
|
+
return normalized.startsWith("C") || normalized.startsWith("G") || normalized.startsWith("D");
|
|
460
464
|
}
|
|
461
|
-
function
|
|
462
|
-
|
|
465
|
+
function isConversationChannel(channelId) {
|
|
466
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
467
|
+
if (!normalized) return false;
|
|
468
|
+
return normalized.startsWith("C") || normalized.startsWith("G");
|
|
463
469
|
}
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
470
|
+
async function getFilePermalink(fileId) {
|
|
471
|
+
const client = getClient();
|
|
472
|
+
return (await withSlackRetries(() => client.files.info({ file: fileId }), 3, {
|
|
473
|
+
action: "files.info",
|
|
474
|
+
idempotent: true,
|
|
475
|
+
spanAttributes: { "app.slack.file_id": fileId }
|
|
476
|
+
})).file?.permalink;
|
|
469
477
|
}
|
|
470
|
-
function
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
478
|
+
async function downloadPrivateSlackFile(url) {
|
|
479
|
+
const token = resolveSlackToken();
|
|
480
|
+
return withSpan("GET files.slack.com", "http.client", {}, async () => {
|
|
481
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
482
|
+
setSpanAttributes({ "http.response.status_code": response.status });
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
setSpanAttributes({ "error.type": String(response.status) });
|
|
485
|
+
setSpanStatus("error");
|
|
486
|
+
throw new Error(`Slack file download failed: ${response.status}`);
|
|
487
|
+
}
|
|
488
|
+
return Buffer.from(await response.arrayBuffer());
|
|
489
|
+
}, {
|
|
490
|
+
"http.request.method": "GET",
|
|
491
|
+
"server.address": "files.slack.com",
|
|
492
|
+
"url.scheme": "https",
|
|
493
|
+
"app.slack.method": "files.download"
|
|
494
|
+
});
|
|
481
495
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/chat/slack/user.ts
|
|
498
|
+
const slackUserInfoSchema = z.object({
|
|
499
|
+
ok: z.literal(true),
|
|
500
|
+
user: z.object({
|
|
501
|
+
name: z.string().nullish(),
|
|
502
|
+
tz: z.string().nullish(),
|
|
503
|
+
profile: z.object({
|
|
504
|
+
email: z.string().nullish(),
|
|
505
|
+
real_name: z.string().nullish()
|
|
506
|
+
}).optional()
|
|
507
|
+
})
|
|
508
|
+
});
|
|
509
|
+
const USER_CACHE_TTL_MS = 3e5;
|
|
510
|
+
const userCache = /* @__PURE__ */ new Map();
|
|
511
|
+
function userCacheKey(teamId, userId) {
|
|
512
|
+
return `${teamId}:${userId}`;
|
|
485
513
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
|
|
514
|
+
function readFromCache(teamId, userId) {
|
|
515
|
+
const hit = userCache.get(userCacheKey(teamId, userId));
|
|
516
|
+
if (!hit) return null;
|
|
517
|
+
if (hit.expiresAt < Date.now()) {
|
|
518
|
+
userCache.delete(userCacheKey(teamId, userId));
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
return hit.value;
|
|
522
|
+
}
|
|
523
|
+
function writeToCache(teamId, userId, value) {
|
|
524
|
+
userCache.set(userCacheKey(teamId, userId), {
|
|
525
|
+
value,
|
|
526
|
+
expiresAt: Date.now() + USER_CACHE_TTL_MS
|
|
491
527
|
});
|
|
492
528
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return
|
|
509
|
-
...current,
|
|
510
|
-
modelId
|
|
511
|
-
};
|
|
529
|
+
/** Fetch Slack user profile info with in-memory TTL cache to avoid repeated API calls. */
|
|
530
|
+
async function lookupSlackUser(teamId, userId) {
|
|
531
|
+
if (!teamId || !userId) return null;
|
|
532
|
+
const cached = readFromCache(teamId, userId);
|
|
533
|
+
if (cached) return cached;
|
|
534
|
+
const token = getSlackBotToken();
|
|
535
|
+
if (!token) return null;
|
|
536
|
+
try {
|
|
537
|
+
const response = await fetch(`https://slack.com/api/users.info?user=${encodeURIComponent(userId)}`, { headers: { authorization: `Bearer ${token}` } });
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
logWarn("slack.user.lookup.failed", {
|
|
540
|
+
"enduser.id": userId,
|
|
541
|
+
"app.slack.team_id": teamId,
|
|
542
|
+
"http.response.status_code": response.status
|
|
543
|
+
});
|
|
544
|
+
return null;
|
|
512
545
|
}
|
|
546
|
+
const parsed = slackUserInfoSchema.safeParse(await response.json());
|
|
547
|
+
if (!parsed.success) return null;
|
|
548
|
+
const payload = parsed.data;
|
|
549
|
+
const result = {
|
|
550
|
+
userName: payload.user.name?.trim() || void 0,
|
|
551
|
+
fullName: payload.user.profile?.real_name?.trim() || void 0,
|
|
552
|
+
email: payload.user.profile?.email?.trim() || void 0,
|
|
553
|
+
timezone: payload.user.tz?.trim() || void 0
|
|
554
|
+
};
|
|
555
|
+
writeToCache(teamId, userId, result);
|
|
556
|
+
return result;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
logWarn("slack.user.lookup.failed", {
|
|
559
|
+
"enduser.id": userId,
|
|
560
|
+
"app.slack.team_id": teamId,
|
|
561
|
+
"exception.message": error instanceof Error ? error.message : String(error)
|
|
562
|
+
});
|
|
563
|
+
return null;
|
|
513
564
|
}
|
|
514
|
-
return current;
|
|
515
565
|
}
|
|
516
|
-
|
|
517
|
-
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/chat/identities/timezone.ts
|
|
568
|
+
const DEFAULT_TIMEZONE = "America/Los_Angeles";
|
|
569
|
+
/** The install timezone, used when Junior does not know a person's timezone. */
|
|
570
|
+
function defaultTimezone() {
|
|
571
|
+
return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_TIMEZONE;
|
|
518
572
|
}
|
|
519
|
-
/**
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
573
|
+
/**
|
|
574
|
+
* Pick a person's timezone: their Slack profile, then the saved user
|
|
575
|
+
* timezone, then the install default. A valid Slack timezone is saved on the
|
|
576
|
+
* linked user, so later lookups without Slack still find it.
|
|
577
|
+
*/
|
|
578
|
+
async function resolveUserTimezone(args) {
|
|
579
|
+
const timezone = (args.slack ? await lookupSlackUser(args.slack.teamId, args.slack.userId) : void 0)?.timezone;
|
|
580
|
+
if (timezone && isValidTimeZone(timezone)) {
|
|
581
|
+
if (args.userId) await saveUserTimezone(args.db, args.userId, timezone, args.nowMs);
|
|
582
|
+
return timezone;
|
|
583
|
+
}
|
|
584
|
+
return (args.userId ? await readUserTimezone(args.db, args.userId) : void 0) ?? defaultTimezone();
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/chat/plugins/annotations.ts
|
|
588
|
+
function annotationFromRow(row) {
|
|
589
|
+
const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
|
|
590
|
+
if (!parsed.success) return void 0;
|
|
526
591
|
return {
|
|
527
|
-
...
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
592
|
+
...parsed.data,
|
|
593
|
+
plugin: row.plugin,
|
|
594
|
+
createdAt: row.createdAt.toISOString(),
|
|
595
|
+
updatedAt: row.updatedAt.toISOString()
|
|
531
596
|
};
|
|
532
597
|
}
|
|
533
|
-
|
|
534
|
-
//#region src/api/conversations/events.ts
|
|
535
|
-
/** Canonical event types that can contribute to the reporting projection. */
|
|
536
|
-
const conversationReportSourceEventTypes = [
|
|
537
|
-
"message",
|
|
538
|
-
"message_handled",
|
|
539
|
-
"assistant_message",
|
|
540
|
-
"tool_result",
|
|
541
|
-
"tool_execution_started",
|
|
542
|
-
"guardian_action_reviewed",
|
|
543
|
-
"turn_started",
|
|
544
|
-
"turn_context",
|
|
545
|
-
"structured_event",
|
|
546
|
-
"attachments_delivered",
|
|
547
|
-
"turn_routed",
|
|
548
|
-
"turn_completed",
|
|
549
|
-
"turn_failed",
|
|
550
|
-
"compaction",
|
|
551
|
-
"handoff",
|
|
552
|
-
"subagent_started",
|
|
553
|
-
"subagent_ended"
|
|
554
|
-
];
|
|
555
|
-
const reportingAssistantMessageSchema = z.object({
|
|
556
|
-
type: z.literal("assistant_message"),
|
|
557
|
-
content: z.array(z.unknown())
|
|
558
|
-
}).passthrough();
|
|
559
|
-
const reportingToolCallPartSchema = z.object({
|
|
560
|
-
type: z.literal("toolCall"),
|
|
561
|
-
id: z.string().min(1),
|
|
562
|
-
name: z.string().min(1),
|
|
563
|
-
arguments: z.unknown()
|
|
564
|
-
}).passthrough();
|
|
565
|
-
const reportingToolResultMessageSchema = z.object({
|
|
566
|
-
type: z.literal("tool_result"),
|
|
567
|
-
toolCallId: z.string().min(1),
|
|
568
|
-
toolName: z.string().min(1).optional(),
|
|
569
|
-
name: z.string().min(1).optional(),
|
|
570
|
-
content: z.array(z.unknown()),
|
|
571
|
-
details: z.unknown().optional(),
|
|
572
|
-
isError: z.boolean().optional()
|
|
573
|
-
}).passthrough();
|
|
574
|
-
const reportingToolResultDetailsSchema = z.object({
|
|
575
|
-
ok: z.boolean().optional(),
|
|
576
|
-
status: z.string().optional()
|
|
577
|
-
}).passthrough();
|
|
578
|
-
const reportingTextPartSchema = z.object({
|
|
579
|
-
type: z.literal("text"),
|
|
580
|
-
text: z.string()
|
|
581
|
-
}).passthrough();
|
|
582
|
-
const reportingReasoningPartSchema = z.object({
|
|
583
|
-
type: z.literal("thinking"),
|
|
584
|
-
thinking: z.string(),
|
|
585
|
-
redacted: z.literal(true).optional()
|
|
586
|
-
}).passthrough();
|
|
587
|
-
const reportingMediaPartSchema = z.object({
|
|
588
|
-
type: z.enum(["image", "audio"]),
|
|
589
|
-
mimeType: z.string().min(1).optional()
|
|
590
|
-
}).passthrough();
|
|
591
|
-
const reportingMessageAuthorSchema = z.object({
|
|
592
|
-
fullName: z.string().min(1).optional(),
|
|
593
|
-
userId: z.string().min(1).optional(),
|
|
594
|
-
userName: z.string().min(1).optional()
|
|
595
|
-
}).passthrough();
|
|
596
|
-
/** Retain model-visible text and media descriptors, dropping all opaque fields. */
|
|
597
|
-
function sanitizeModelContentPart(value) {
|
|
598
|
-
const text = reportingTextPartSchema.safeParse(value);
|
|
599
|
-
if (text.success) return {
|
|
600
|
-
type: "text",
|
|
601
|
-
text: text.data.text
|
|
602
|
-
};
|
|
603
|
-
const media = reportingMediaPartSchema.safeParse(value);
|
|
604
|
-
if (!media.success) return void 0;
|
|
598
|
+
function createPluginAnnotations(args) {
|
|
605
599
|
return {
|
|
606
|
-
|
|
607
|
-
|
|
600
|
+
async upsert(input) {
|
|
601
|
+
const annotation = conversationAnnotationInputSchema.parse(input);
|
|
602
|
+
const now = /* @__PURE__ */ new Date();
|
|
603
|
+
await args.db.insert(juniorConversationAnnotations).values({
|
|
604
|
+
annotation,
|
|
605
|
+
conversationId: args.conversationId,
|
|
606
|
+
createdAt: now,
|
|
607
|
+
key: annotation.key,
|
|
608
|
+
kind: annotation.kind,
|
|
609
|
+
plugin: args.plugin,
|
|
610
|
+
updatedAt: now
|
|
611
|
+
}).onConflictDoUpdate({
|
|
612
|
+
target: [
|
|
613
|
+
juniorConversationAnnotations.conversationId,
|
|
614
|
+
juniorConversationAnnotations.plugin,
|
|
615
|
+
juniorConversationAnnotations.kind,
|
|
616
|
+
juniorConversationAnnotations.key
|
|
617
|
+
],
|
|
618
|
+
set: {
|
|
619
|
+
annotation,
|
|
620
|
+
updatedAt: now
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
async remove(kind, key) {
|
|
625
|
+
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)));
|
|
626
|
+
},
|
|
627
|
+
async list() {
|
|
628
|
+
return (await listConversationAnnotations(args.db, args.conversationId)).filter((a) => a.plugin === args.plugin);
|
|
629
|
+
}
|
|
608
630
|
};
|
|
609
631
|
}
|
|
610
|
-
/**
|
|
611
|
-
function
|
|
612
|
-
|
|
613
|
-
if (sanitized.length === 0) return void 0;
|
|
614
|
-
if (sanitized.length !== 1) return sanitized;
|
|
615
|
-
const only = sanitized[0];
|
|
616
|
-
return only.type === "text" ? only.text : only;
|
|
632
|
+
/** Load stored annotations for one conversation, newest update first. */
|
|
633
|
+
async function listConversationAnnotations(db, conversationId) {
|
|
634
|
+
return (await listConversationAnnotationsById(db, [conversationId])).get(conversationId) ?? [];
|
|
617
635
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
if (
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
636
|
+
/** Load stored annotations for a bounded set of conversations. */
|
|
637
|
+
async function listConversationAnnotationsById(db, conversationIds) {
|
|
638
|
+
const byConversation = /* @__PURE__ */ new Map();
|
|
639
|
+
if (conversationIds.length === 0) return byConversation;
|
|
640
|
+
const rows = await db.select().from(juniorConversationAnnotations).where(inArray(juniorConversationAnnotations.conversationId, [...conversationIds])).orderBy(desc(juniorConversationAnnotations.updatedAt));
|
|
641
|
+
for (const row of rows) {
|
|
642
|
+
const annotation = annotationFromRow(row);
|
|
643
|
+
if (!annotation) continue;
|
|
644
|
+
const current = byConversation.get(row.conversationId);
|
|
645
|
+
if (current) current.push(annotation);
|
|
646
|
+
else byConversation.set(row.conversationId, [annotation]);
|
|
647
|
+
}
|
|
648
|
+
return byConversation;
|
|
628
649
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/chat/conversations/annotation-results.ts
|
|
652
|
+
/** Save object annotations and retain returned facts in the tool result. */
|
|
653
|
+
async function saveObjectAnnotations(conversationId, plugin, value) {
|
|
654
|
+
const annotations = objectAnnotationSchema.array().parse(value);
|
|
655
|
+
await getDb().transaction(async (db) => {
|
|
656
|
+
const store = createPluginAnnotations({
|
|
657
|
+
conversationId,
|
|
658
|
+
plugin,
|
|
659
|
+
db
|
|
660
|
+
});
|
|
661
|
+
for (const annotation of annotations) {
|
|
662
|
+
await store.upsert(annotation);
|
|
663
|
+
await store.remove("resource_link", annotation.key);
|
|
664
|
+
}
|
|
644
665
|
});
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
666
|
+
return annotations.map((annotation) => ({
|
|
667
|
+
...annotation,
|
|
668
|
+
plugin
|
|
669
|
+
}));
|
|
649
670
|
}
|
|
650
|
-
/**
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
671
|
+
/** Save annotations from a successful plugin tool result and attach their cards. */
|
|
672
|
+
async function annotateToolResult(conversationId, plugin, result) {
|
|
673
|
+
if (!isRecord$1(result) || result.isError === true) return result;
|
|
674
|
+
const details = isRecord$1(result.details) ? result.details : result;
|
|
675
|
+
if (details.timed_out === true || details.isError === true || !Array.isArray(details.objectAnnotations)) return result;
|
|
676
|
+
const cards = await saveObjectAnnotations(conversationId, plugin, details.objectAnnotations);
|
|
677
|
+
const { objectAnnotations: _annotations, ...rest } = details;
|
|
678
|
+
const annotated = {
|
|
679
|
+
...rest,
|
|
680
|
+
objectCards: cards
|
|
660
681
|
};
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const reasoning = reportingReasoningPartSchema.safeParse(part);
|
|
665
|
-
if (reasoning.success) {
|
|
666
|
-
if (reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0) {
|
|
667
|
-
const projected = {
|
|
668
|
-
type: "reasoning",
|
|
669
|
-
...args.canExposePayload ? { text: reasoning.data.thinking } : { redacted: true }
|
|
670
|
-
};
|
|
671
|
-
reasoningParts.push(projected);
|
|
672
|
-
assistantParts.push(projected);
|
|
673
|
-
}
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
const call = reportingToolCallPartSchema.safeParse(part);
|
|
677
|
-
if (call.success) assistantParts.push({
|
|
678
|
-
type: "tool_call",
|
|
679
|
-
toolCallId: call.data.id
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
const toolCalls = reportToolCalls(args);
|
|
683
|
-
if (toolCalls?.type === "tool_calls") return {
|
|
684
|
-
...toolCalls,
|
|
685
|
-
assistant: { parts: assistantParts }
|
|
682
|
+
if (isRecord$1(result.details)) return {
|
|
683
|
+
...result,
|
|
684
|
+
details: annotated
|
|
686
685
|
};
|
|
687
|
-
return
|
|
688
|
-
|
|
689
|
-
|
|
686
|
+
return annotated;
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/chat/conversations/sql/privacy.ts
|
|
690
|
+
const privacyRoot = alias(juniorConversations, "privacy_root");
|
|
691
|
+
/** Accept only a structurally valid persisted root as privacy authority. */
|
|
692
|
+
async function readRootCandidate(executor, conversationId) {
|
|
693
|
+
const row = (await executor.db().select({
|
|
694
|
+
requestedConversationId: juniorConversations.conversationId,
|
|
695
|
+
requestedParentConversationId: juniorConversations.parentConversationId,
|
|
696
|
+
requestedRootConversationId: juniorConversations.rootConversationId,
|
|
697
|
+
rootConversationId: privacyRoot.conversationId,
|
|
698
|
+
rootDestinationId: privacyRoot.destinationId,
|
|
699
|
+
rootParentConversationId: privacyRoot.parentConversationId,
|
|
700
|
+
rootRootConversationId: privacyRoot.rootConversationId
|
|
701
|
+
}).from(juniorConversations).leftJoin(privacyRoot, eq(privacyRoot.conversationId, juniorConversations.rootConversationId)).where(eq(juniorConversations.conversationId, conversationId)))[0];
|
|
702
|
+
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 ? {
|
|
703
|
+
destinationId: row.rootDestinationId,
|
|
704
|
+
rootConversationId: row.rootConversationId
|
|
690
705
|
} : void 0;
|
|
691
706
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const message = reportingToolResultMessageSchema.safeParse(args.message);
|
|
695
|
-
if (!message.success) return void 0;
|
|
696
|
-
const details = reportingToolResultDetailsSchema.safeParse(message.data.details);
|
|
697
|
-
const outcome = message.data.isError === true || details.success && (details.data.ok === false || details.data.status === "error") ? "error" : "completed";
|
|
698
|
-
const output = modelVisibleToolOutput(message.data.content);
|
|
699
|
-
const name = message.data.toolName ?? message.data.name ?? args.start?.name;
|
|
700
|
-
if (!name) return void 0;
|
|
707
|
+
async function readCandidateVisibility(executor, candidate) {
|
|
708
|
+
const destinations = await executor.db().select({ visibility: juniorDestinations.visibility }).from(juniorDestinations).where(eq(juniorDestinations.id, candidate.destinationId)).for("share");
|
|
701
709
|
return {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
toolCallId: message.data.toolCallId,
|
|
705
|
-
name,
|
|
706
|
-
status: outcome,
|
|
707
|
-
...args.start ? {
|
|
708
|
-
startedAt: new Date(args.start.createdAtMs).toISOString(),
|
|
709
|
-
startedSeq: args.start.seq
|
|
710
|
-
} : void 0,
|
|
711
|
-
...args.canExposePayload && output !== void 0 ? { output } : void 0
|
|
712
|
-
}]
|
|
710
|
+
rootConversationId: candidate.rootConversationId,
|
|
711
|
+
visibility: destinations[0]?.visibility ?? null
|
|
713
712
|
};
|
|
714
713
|
}
|
|
715
|
-
/**
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
714
|
+
/**
|
|
715
|
+
* Resolve a conversation's privacy authority from its persisted root.
|
|
716
|
+
*
|
|
717
|
+
* Missing or structurally invalid root metadata fails closed. The root
|
|
718
|
+
* destination is locked so callers that keep the transaction open receive a
|
|
719
|
+
* stable visibility decision.
|
|
720
|
+
*/
|
|
721
|
+
async function resolveRootVisibility(executor, conversationId) {
|
|
722
|
+
const candidate = await readRootCandidate(executor, conversationId);
|
|
723
|
+
if (!candidate) return {
|
|
724
|
+
rootConversationId: conversationId,
|
|
725
|
+
visibility: null
|
|
726
|
+
};
|
|
727
|
+
return readCandidateVisibility(executor, candidate);
|
|
721
728
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/chat/briefs/events.ts
|
|
731
|
+
/** Durable cost and size record for one stored Brief version. */
|
|
732
|
+
const briefUpdatedEvent = defineConversationEvent({
|
|
733
|
+
name: "brief_updated",
|
|
734
|
+
version: 1,
|
|
735
|
+
schema: z.object({
|
|
736
|
+
version: z.number().int().positive(),
|
|
737
|
+
modelId: z.string().min(1),
|
|
738
|
+
costUsd: z.number().finite().nonnegative().optional(),
|
|
739
|
+
decisions: z.number().int().nonnegative(),
|
|
740
|
+
openDecisions: z.number().int().nonnegative(),
|
|
741
|
+
links: z.number().int().nonnegative()
|
|
742
|
+
}).strict(),
|
|
743
|
+
renderEvent(event) {
|
|
728
744
|
return {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
name: data.name,
|
|
732
|
-
version: data.version,
|
|
733
|
-
...data.turnId ? { turnId: data.turnId } : void 0,
|
|
734
|
-
presentation
|
|
745
|
+
icon: "activity",
|
|
746
|
+
title: `Brief updated (v${event.version})`
|
|
735
747
|
};
|
|
736
748
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
...data.costUsd !== void 0 ? { costUsd: data.costUsd } : void 0,
|
|
789
|
-
reasoningLevel: data.reasoningLevel,
|
|
790
|
-
...data.confidence !== void 0 ? { confidence: data.confidence } : void 0,
|
|
791
|
-
source: data.source
|
|
792
|
-
};
|
|
793
|
-
case "guardian_action_reviewed": return {
|
|
794
|
-
type: "guardian_action_reviewed",
|
|
795
|
-
turnId: data.turnId,
|
|
796
|
-
toolCallId: data.toolCallId,
|
|
797
|
-
toolName: data.toolName,
|
|
798
|
-
decision: data.decision,
|
|
799
|
-
riskLevel: data.riskLevel,
|
|
800
|
-
userAuthorization: data.userAuthorization
|
|
801
|
-
};
|
|
802
|
-
case "turn_completed": return {
|
|
803
|
-
type: "turn_lifecycle",
|
|
804
|
-
turnId: data.turnId,
|
|
805
|
-
state: data.outcome === "success" ? "succeeded" : "no_reply"
|
|
749
|
+
});
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/chat/slack/attachments.ts
|
|
752
|
+
/** Save the vision summary for a stored Slack attachment. */
|
|
753
|
+
async function recordSlackAttachment(args) {
|
|
754
|
+
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)));
|
|
755
|
+
}
|
|
756
|
+
/** Read a saved vision summary for one live attachment. */
|
|
757
|
+
async function readAttachmentVisionSummary(args) {
|
|
758
|
+
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)));
|
|
759
|
+
return row?.visionSummary;
|
|
760
|
+
}
|
|
761
|
+
/** Match live conversation attachments to Slack file ids. */
|
|
762
|
+
async function matchSlackAttachments(args) {
|
|
763
|
+
if (args.providerIds.length === 0) return [];
|
|
764
|
+
return await args.db.db().select({
|
|
765
|
+
id: juniorAttachments.id,
|
|
766
|
+
providerId: juniorAttachments.providerId,
|
|
767
|
+
bytes: juniorAttachments.bytes,
|
|
768
|
+
contentType: juniorAttachments.contentType,
|
|
769
|
+
filename: juniorAttachments.filename
|
|
770
|
+
}).from(juniorAttachments).where(and(eq(juniorAttachments.conversationId, args.conversationId), eq(juniorAttachments.provider, "slack"), inArray(juniorAttachments.providerId, args.providerIds), isNull(juniorAttachments.deleteRequestedAt)));
|
|
771
|
+
}
|
|
772
|
+
/** Read the Slack file identity retained by the Chat SDK. */
|
|
773
|
+
function getSlackFileId(attachment) {
|
|
774
|
+
const metadataId = attachment.fetchMetadata?.fileId;
|
|
775
|
+
if (metadataId) return metadataId;
|
|
776
|
+
return (attachment.url ?? attachment.fetchMetadata?.url ?? "").match(/(?:^|[-/])(F[A-Z0-9]+)(?:[-/]|$)/i)?.[1];
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/api/conversations/message-attachments.ts
|
|
780
|
+
/** Resolve saved Slack file references without exposing private storage details. */
|
|
781
|
+
async function hydrateMessageAttachments(db, conversationId, events) {
|
|
782
|
+
const messages = events.flatMap((event) => event.data.type === "message" ? [event.data] : []);
|
|
783
|
+
const ids = [...new Set(messages.flatMap((message) => slackFileIds(message.meta)))];
|
|
784
|
+
if (!ids.length) return;
|
|
785
|
+
const attachments = await matchSlackAttachments({
|
|
786
|
+
db,
|
|
787
|
+
conversationId,
|
|
788
|
+
providerIds: ids
|
|
789
|
+
});
|
|
790
|
+
const byFileId = new Map(attachments.map(({ providerId, ...attachment }) => [providerId, attachment]));
|
|
791
|
+
for (const message of messages) {
|
|
792
|
+
const stored = readMessageAttachments(message.meta?.attachments);
|
|
793
|
+
const matched = slackFileIds(message.meta).flatMap((id) => {
|
|
794
|
+
const attachment = byFileId.get(id);
|
|
795
|
+
return attachment ? [attachment] : [];
|
|
796
|
+
});
|
|
797
|
+
if (matched.length) message.meta = {
|
|
798
|
+
...message.meta,
|
|
799
|
+
attachments: [...stored, ...matched]
|
|
806
800
|
};
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function slackFileIds(meta) {
|
|
804
|
+
const ids = meta?.slackFileIds ?? meta?.imageFileIds;
|
|
805
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/chat/sentry-links.ts
|
|
809
|
+
function getSentryOrgSlug() {
|
|
810
|
+
return process.env.SENTRY_ORG_SLUG?.trim() || void 0;
|
|
811
|
+
}
|
|
812
|
+
function isSentrySaasDsnHost(host) {
|
|
813
|
+
return host === "sentry.io" || host.endsWith(".sentry.io");
|
|
814
|
+
}
|
|
815
|
+
function buildSentryWebBaseUrl(dsn) {
|
|
816
|
+
if (isSentrySaasDsnHost(dsn.host)) return "https://sentry.io";
|
|
817
|
+
const port = dsn.port ? `:${dsn.port}` : "";
|
|
818
|
+
const path = dsn.path ? `/${dsn.path}` : "";
|
|
819
|
+
return `${dsn.protocol}://${dsn.host}${port}${path}`;
|
|
820
|
+
}
|
|
821
|
+
function buildSentryOrgPath(args) {
|
|
822
|
+
const dsn = getClient$1()?.getDsn();
|
|
823
|
+
if (!dsn?.host || !dsn.projectId) return;
|
|
824
|
+
const orgSlug = getSentryOrgSlug();
|
|
825
|
+
if (!orgSlug) return;
|
|
826
|
+
const params = new URLSearchParams();
|
|
827
|
+
params.set("project", dsn.projectId);
|
|
828
|
+
if (args.query) for (const [key, value] of Object.entries(args.query)) params.set(key, value);
|
|
829
|
+
const path = `${args.path}?${params.toString()}`;
|
|
830
|
+
if (isSentrySaasDsnHost(dsn.host)) return `https://${orgSlug}.sentry.io/${path}`;
|
|
831
|
+
return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`;
|
|
832
|
+
}
|
|
833
|
+
/** Build a Sentry conversation URL only when the runtime has enough Sentry config. */
|
|
834
|
+
function buildSentryConversationUrl(conversationId) {
|
|
835
|
+
return buildSentryOrgPath({ path: `explore/conversations/${encodeURIComponent(conversationId)}/` });
|
|
836
|
+
}
|
|
837
|
+
/** Build a Sentry event URL only when the runtime has enough Sentry config. */
|
|
838
|
+
function buildSentryEventUrl(eventId) {
|
|
839
|
+
return buildSentryOrgPath({
|
|
840
|
+
path: "issues/",
|
|
841
|
+
query: { query: eventId }
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/api/conversations/event-model.ts
|
|
846
|
+
/** Apply recorded model choices without carrying a previous turn's settings forward. */
|
|
847
|
+
function conversationEventModel(data, current) {
|
|
848
|
+
if (data.type === "turn_started") return void 0;
|
|
849
|
+
if (data.type === "turn_routed" || data.type === "handoff") return {
|
|
850
|
+
modelId: data.modelId,
|
|
851
|
+
modelProfile: data.modelProfile,
|
|
852
|
+
...data.reasoningLevel ? { reasoningLevel: data.reasoningLevel } : void 0
|
|
853
|
+
};
|
|
854
|
+
if (data.type === "assistant_message") {
|
|
855
|
+
const model = text$1(data.model);
|
|
856
|
+
const provider = text$1(data.provider);
|
|
857
|
+
if (model) {
|
|
858
|
+
const modelId = model.includes("/") || !provider ? model : `${provider}/${model}`;
|
|
810
859
|
return {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
state: "failed",
|
|
814
|
-
failureCode: data.failureCode,
|
|
815
|
-
...data.failureReason ? { failureReason: data.failureReason } : void 0,
|
|
816
|
-
...eventId ? { eventId } : void 0,
|
|
817
|
-
...sentryEventUrl ? { sentryEventUrl } : void 0
|
|
860
|
+
...current,
|
|
861
|
+
modelId
|
|
818
862
|
};
|
|
819
863
|
}
|
|
820
|
-
case "compaction": return {
|
|
821
|
-
type: "compaction",
|
|
822
|
-
modelProfile: data.modelProfile,
|
|
823
|
-
modelId: data.modelId,
|
|
824
|
-
...args.canExposePayload && data.summary ? { summary: data.summary } : void 0,
|
|
825
|
-
...data.details ? { details: data.details } : void 0
|
|
826
|
-
};
|
|
827
|
-
default: return;
|
|
828
864
|
}
|
|
865
|
+
return current;
|
|
829
866
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
*
|
|
833
|
-
* `canExposePayload` must come from the authorized conversation-detail policy;
|
|
834
|
-
* this projection never derives visibility or authorization from event data.
|
|
835
|
-
*/
|
|
836
|
-
function projectConversationReportEventPage(args) {
|
|
837
|
-
const subagentStarts = /* @__PURE__ */ new Map();
|
|
838
|
-
for (const event of args.subagentStartEvents ?? []) if (event.data.type === "subagent_started") subagentStarts.set(event.data.subagentInvocationId, {
|
|
839
|
-
createdAtMs: event.createdAtMs,
|
|
840
|
-
data: event.data,
|
|
841
|
-
seq: event.seq
|
|
842
|
-
});
|
|
843
|
-
const toolStarts = /* @__PURE__ */ new Map();
|
|
844
|
-
for (const event of args.toolStartEvents ?? []) if (event.data.type === "tool_execution_started") {
|
|
845
|
-
const current = toolStarts.get(event.data.toolCallId);
|
|
846
|
-
if (!current || event.seq < current.seq) toolStarts.set(event.data.toolCallId, {
|
|
847
|
-
createdAtMs: event.createdAtMs,
|
|
848
|
-
name: event.data.toolName,
|
|
849
|
-
seq: event.seq
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
const projected = [];
|
|
853
|
-
let model;
|
|
854
|
-
for (const event of args.modelContextEvents ?? []) {
|
|
855
|
-
model = conversationEventModel(event.data, model);
|
|
856
|
-
if (event.data.type === "turn_completed" || event.data.type === "turn_failed") model = void 0;
|
|
857
|
-
}
|
|
858
|
-
for (const event of args.events) {
|
|
859
|
-
model = conversationEventModel(event.data, model);
|
|
860
|
-
const eventModel = model;
|
|
861
|
-
const modelCall = conversationEventModelCall(event.data);
|
|
862
|
-
if (event.data.type === "turn_completed" || event.data.type === "turn_failed") model = void 0;
|
|
863
|
-
let data;
|
|
864
|
-
if (event.data.type === "assistant_message" || event.data.type === "tool_result") {
|
|
865
|
-
const reportArgs = {
|
|
866
|
-
canExposePayload: args.canExposePayload,
|
|
867
|
-
createdAtMs: event.createdAtMs,
|
|
868
|
-
message: event.data,
|
|
869
|
-
seq: event.seq
|
|
870
|
-
};
|
|
871
|
-
const result = reportingToolResultMessageSchema.safeParse(event.data);
|
|
872
|
-
const start = result.success ? toolStarts.get(result.data.toolCallId) : void 0;
|
|
873
|
-
data = reportAssistantMessage(reportArgs) ?? reportToolResult({
|
|
874
|
-
canExposePayload: args.canExposePayload,
|
|
875
|
-
message: event.data,
|
|
876
|
-
...start && start.seq < event.seq ? { start } : void 0
|
|
877
|
-
});
|
|
878
|
-
} else if (event.data.type === "tool_execution_started") {
|
|
879
|
-
toolStarts.set(event.data.toolCallId, {
|
|
880
|
-
createdAtMs: event.createdAtMs,
|
|
881
|
-
name: event.data.toolName,
|
|
882
|
-
seq: event.seq
|
|
883
|
-
});
|
|
884
|
-
data = {
|
|
885
|
-
type: "tool_calls",
|
|
886
|
-
calls: [{
|
|
887
|
-
toolCallId: event.data.toolCallId,
|
|
888
|
-
name: event.data.toolName,
|
|
889
|
-
status: "running",
|
|
890
|
-
startedAt: new Date(event.createdAtMs).toISOString(),
|
|
891
|
-
startedSeq: event.seq
|
|
892
|
-
}]
|
|
893
|
-
};
|
|
894
|
-
} else if (event.data.type === "subagent_started") {
|
|
895
|
-
subagentStarts.set(event.data.subagentInvocationId, {
|
|
896
|
-
createdAtMs: event.createdAtMs,
|
|
897
|
-
data: event.data,
|
|
898
|
-
seq: event.seq
|
|
899
|
-
});
|
|
900
|
-
data = {
|
|
901
|
-
type: "subagent",
|
|
902
|
-
startedSeq: event.seq,
|
|
903
|
-
startedAt: new Date(event.createdAtMs).toISOString(),
|
|
904
|
-
childConversationId: event.data.childConversationId,
|
|
905
|
-
subagentKind: event.data.subagentKind,
|
|
906
|
-
status: "running",
|
|
907
|
-
...event.data.parentToolCallId ? { parentToolCallId: event.data.parentToolCallId } : void 0
|
|
908
|
-
};
|
|
909
|
-
} else if (event.data.type === "subagent_ended") {
|
|
910
|
-
const started = subagentStarts.get(event.data.subagentInvocationId);
|
|
911
|
-
if (started && started.seq < event.seq) {
|
|
912
|
-
data = {
|
|
913
|
-
type: "subagent",
|
|
914
|
-
startedSeq: started.seq,
|
|
915
|
-
startedAt: new Date(started.createdAtMs).toISOString(),
|
|
916
|
-
childConversationId: started.data.childConversationId,
|
|
917
|
-
subagentKind: started.data.subagentKind,
|
|
918
|
-
...started.data.parentToolCallId ? { parentToolCallId: started.data.parentToolCallId } : void 0,
|
|
919
|
-
status: event.data.outcome === "success" ? "completed" : event.data.outcome
|
|
920
|
-
};
|
|
921
|
-
subagentStarts.delete(event.data.subagentInvocationId);
|
|
922
|
-
}
|
|
923
|
-
} else if (event.data.type === "handoff") data = {
|
|
924
|
-
type: "handoff",
|
|
925
|
-
modelProfile: event.data.modelProfile,
|
|
926
|
-
modelId: event.data.modelId,
|
|
927
|
-
...event.data.reasoningLevel ? { reasoningLevel: event.data.reasoningLevel } : void 0,
|
|
928
|
-
...event.data.triggeringToolCallId ? { triggeringToolCallId: event.data.triggeringToolCallId } : void 0,
|
|
929
|
-
...args.canExposePayload && event.data.summary ? { summary: event.data.summary } : void 0
|
|
930
|
-
};
|
|
931
|
-
else data = reportEventData({
|
|
932
|
-
canExposePayload: args.canExposePayload,
|
|
933
|
-
data: event.data
|
|
934
|
-
});
|
|
935
|
-
if (!data) continue;
|
|
936
|
-
projected.push(conversationReportEventSchema.parse({
|
|
937
|
-
seq: event.seq,
|
|
938
|
-
createdAt: new Date(event.createdAtMs).toISOString(),
|
|
939
|
-
data,
|
|
940
|
-
...eventModel ? { model: eventModel } : void 0,
|
|
941
|
-
...modelCall ? { modelCall } : void 0
|
|
942
|
-
}));
|
|
943
|
-
}
|
|
944
|
-
return projected;
|
|
945
|
-
}
|
|
946
|
-
//#endregion
|
|
947
|
-
//#region src/api/conversations/event-page.ts
|
|
948
|
-
const conversationEventColumns = getTableColumns(juniorConversationEvents);
|
|
949
|
-
const MIN_SCAN_SIZE = 64;
|
|
950
|
-
/** Read bounded canonical rows while excluding model-only replacement history. */
|
|
951
|
-
async function readConversationEventRows(executor, args) {
|
|
952
|
-
return executor.db().select({
|
|
953
|
-
...conversationEventColumns,
|
|
954
|
-
payload: sql`case
|
|
955
|
-
when ${juniorConversationEvents.type} in ('compaction', 'handoff')
|
|
956
|
-
then jsonb_set(
|
|
957
|
-
${juniorConversationEvents.payload},
|
|
958
|
-
'{replacementHistory}',
|
|
959
|
-
'[]'::jsonb
|
|
960
|
-
)
|
|
961
|
-
else ${juniorConversationEvents.payload}
|
|
962
|
-
end`
|
|
963
|
-
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, args.conversationId), args.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, args.beforeSeq), inArray(juniorConversationEvents.type, args.types ?? [...conversationReportSourceEventTypes]), args.subagentInvocationIds === void 0 ? void 0 : inArray(sql`${juniorConversationEvents.payload}->>'subagentInvocationId'`, args.subagentInvocationIds), args.toolCallIds === void 0 ? void 0 : inArray(sql`${juniorConversationEvents.payload}->>'toolCallId'`, args.toolCallIds))).orderBy(args.direction === "forward" ? asc(juniorConversationEvents.seq) : desc(juniorConversationEvents.seq)).limit(args.limit);
|
|
964
|
-
}
|
|
965
|
-
function decodeConversationEventRow(row) {
|
|
966
|
-
return decodeStoredConversationEvent({
|
|
967
|
-
schemaVersion: row.schemaVersion,
|
|
968
|
-
seq: row.seq,
|
|
969
|
-
historyVersion: row.historyVersion,
|
|
970
|
-
...row.idempotencyKey ? { idempotencyKey: row.idempotencyKey } : void 0,
|
|
971
|
-
createdAtMs: row.createdAt.getTime(),
|
|
972
|
-
type: row.type,
|
|
973
|
-
payload: row.payload
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
/** Project stored rows and resolve entity starts that precede the page. */
|
|
977
|
-
async function projectConversationEventRows(executor, args) {
|
|
978
|
-
const events = args.rows.map(decodeConversationEventRow).sort((left, right) => left.seq - right.seq);
|
|
979
|
-
const modelContextRows = events[0] ? (await Promise.all([readConversationEventRows(executor, {
|
|
980
|
-
conversationId: args.conversationId,
|
|
981
|
-
beforeSeq: events[0].seq,
|
|
982
|
-
direction: "backward",
|
|
983
|
-
limit: 1,
|
|
984
|
-
types: [
|
|
985
|
-
"turn_started",
|
|
986
|
-
"turn_routed",
|
|
987
|
-
"handoff",
|
|
988
|
-
"turn_completed",
|
|
989
|
-
"turn_failed"
|
|
990
|
-
]
|
|
991
|
-
}), readConversationEventRows(executor, {
|
|
992
|
-
conversationId: args.conversationId,
|
|
993
|
-
beforeSeq: events[0].seq,
|
|
994
|
-
direction: "backward",
|
|
995
|
-
limit: 1,
|
|
996
|
-
types: ["assistant_message"]
|
|
997
|
-
})])).flat().sort((left, right) => left.seq - right.seq) : [];
|
|
998
|
-
const endedInvocationIds = [...new Set(events.flatMap((event) => event.data.type === "subagent_ended" ? [event.data.subagentInvocationId] : []))];
|
|
999
|
-
const subagentStartRows = endedInvocationIds.length === 0 ? [] : await readConversationEventRows(executor, {
|
|
1000
|
-
conversationId: args.conversationId,
|
|
1001
|
-
direction: "forward",
|
|
1002
|
-
limit: endedInvocationIds.length,
|
|
1003
|
-
subagentInvocationIds: endedInvocationIds,
|
|
1004
|
-
types: ["subagent_started"]
|
|
1005
|
-
});
|
|
1006
|
-
const toolResultIds = conversationReportToolResultIds(events);
|
|
1007
|
-
const toolStartRows = toolResultIds.length === 0 ? [] : await readConversationEventRows(executor, {
|
|
1008
|
-
conversationId: args.conversationId,
|
|
1009
|
-
direction: "forward",
|
|
1010
|
-
limit: toolResultIds.length,
|
|
1011
|
-
toolCallIds: toolResultIds,
|
|
1012
|
-
types: ["tool_execution_started"]
|
|
1013
|
-
});
|
|
1014
|
-
if (args.canExposePayload) await hydrateMessageAttachments(executor, args.conversationId, events);
|
|
1015
|
-
return projectConversationReportEventPage({
|
|
1016
|
-
canExposePayload: args.canExposePayload,
|
|
1017
|
-
events,
|
|
1018
|
-
subagentStartEvents: subagentStartRows.map(decodeConversationEventRow),
|
|
1019
|
-
toolStartEvents: toolStartRows.map(decodeConversationEventRow),
|
|
1020
|
-
modelContextEvents: modelContextRows.map(decodeConversationEventRow)
|
|
1021
|
-
});
|
|
867
|
+
function text$1(value) {
|
|
868
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1022
869
|
}
|
|
1023
|
-
/**
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
async function readConversationEventPage(executor, args) {
|
|
1031
|
-
const scanSize = Math.max(args.limit + 1, MIN_SCAN_SIZE);
|
|
1032
|
-
const rows = [];
|
|
1033
|
-
let beforeSeq = args.beforeSeq;
|
|
1034
|
-
let projected = [];
|
|
1035
|
-
while (projected.length <= args.limit) {
|
|
1036
|
-
const batch = await readConversationEventRows(executor, {
|
|
1037
|
-
beforeSeq,
|
|
1038
|
-
conversationId: args.conversationId,
|
|
1039
|
-
direction: "backward",
|
|
1040
|
-
limit: scanSize
|
|
1041
|
-
});
|
|
1042
|
-
if (batch.length === 0) break;
|
|
1043
|
-
rows.push(...batch);
|
|
1044
|
-
projected = await projectConversationEventRows(executor, {
|
|
1045
|
-
canExposePayload: args.canExposePayload,
|
|
1046
|
-
conversationId: args.conversationId,
|
|
1047
|
-
rows
|
|
1048
|
-
});
|
|
1049
|
-
if (projected.length > args.limit || batch.length < scanSize) break;
|
|
1050
|
-
beforeSeq = batch.at(-1).seq;
|
|
1051
|
-
}
|
|
1052
|
-
const events = projected.slice(-args.limit);
|
|
870
|
+
/** Allowlist model-call diagnostics; never expose opaque provider metadata. */
|
|
871
|
+
function conversationEventModelCall(data) {
|
|
872
|
+
if (data.type !== "assistant_message") return void 0;
|
|
873
|
+
const usage = extractGenAiUsageSummary(data);
|
|
874
|
+
const provider = text$1(data.provider);
|
|
875
|
+
const api = text$1(data.api);
|
|
876
|
+
const stopReason = text$1(data.stopReason);
|
|
1053
877
|
return {
|
|
1054
|
-
|
|
1055
|
-
...
|
|
878
|
+
...provider ? { provider } : void 0,
|
|
879
|
+
...api ? { api } : void 0,
|
|
880
|
+
...stopReason ? { stopReason } : void 0,
|
|
881
|
+
...hasAgentTurnUsage(usage) ? { usage } : void 0
|
|
1056
882
|
};
|
|
1057
883
|
}
|
|
1058
884
|
//#endregion
|
|
1059
|
-
//#region src/
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
* Build generator input from SQL through one durable event. The caller owns
|
|
1080
|
-
* the boundary, so a Turn that completes during the read cannot be covered by
|
|
1081
|
-
* a Brief that never saw it.
|
|
1082
|
-
*/
|
|
1083
|
-
async function briefInputFromSql(executor, conversationId, throughSeq) {
|
|
1084
|
-
const db = executor.db();
|
|
1085
|
-
const conversation = await createSqlStore(executor).get({ conversationId });
|
|
1086
|
-
if (!conversation) throw new Error(`Conversation ${conversationId} is unavailable`);
|
|
1087
|
-
const [events, annotations, codeChanges] = await Promise.all([
|
|
1088
|
-
readReportEventsThrough(executor, conversationId, throughSeq),
|
|
1089
|
-
listConversationAnnotations(db, conversationId),
|
|
1090
|
-
db.select({
|
|
1091
|
-
closedAt: juniorCodeChanges.closedAt,
|
|
1092
|
-
mergedAt: juniorCodeChanges.mergedAt,
|
|
1093
|
-
number: juniorCodeChanges.number,
|
|
1094
|
-
openedAt: juniorCodeChanges.openedAt,
|
|
1095
|
-
repository: juniorCodeRepositories.name,
|
|
1096
|
-
state: juniorCodeChanges.state,
|
|
1097
|
-
title: juniorCodeChanges.title,
|
|
1098
|
-
url: juniorCodeChanges.url
|
|
1099
|
-
}).from(juniorCodeChanges).innerJoin(juniorCodeRepositories, eq(juniorCodeRepositories.id, juniorCodeChanges.repositoryId)).where(and(arrayContains(juniorCodeChanges.conversationIds, [conversationId]), isNotNull(juniorCodeChanges.url)))
|
|
1100
|
-
]);
|
|
1101
|
-
const location = conversation.location;
|
|
1102
|
-
return parseBriefInput({
|
|
1103
|
-
conversationId,
|
|
1104
|
-
...conversation.title ? { title: conversation.title } : void 0,
|
|
1105
|
-
visibility: conversation.visibility ?? "private",
|
|
1106
|
-
...location ? { location: {
|
|
1107
|
-
provider: location.provider,
|
|
1108
|
-
...conversation.channelName ? { channelName: conversation.channelName } : void 0
|
|
1109
|
-
} } : void 0,
|
|
1110
|
-
entries: briefEntriesFromReportEvents(events),
|
|
1111
|
-
codeChanges: codeChanges.map((change) => ({
|
|
1112
|
-
repository: change.repository,
|
|
1113
|
-
number: change.number,
|
|
1114
|
-
...change.title ? { title: change.title } : void 0,
|
|
1115
|
-
url: change.url,
|
|
1116
|
-
state: change.state,
|
|
1117
|
-
openedAt: change.openedAt.toISOString(),
|
|
1118
|
-
...change.mergedAt ? { mergedAt: change.mergedAt.toISOString() } : void 0,
|
|
1119
|
-
...change.closedAt ? { closedAt: change.closedAt.toISOString() } : void 0
|
|
1120
|
-
})),
|
|
1121
|
-
resources: annotations.map((annotation) => ({
|
|
1122
|
-
label: annotation.label,
|
|
1123
|
-
url: annotation.url,
|
|
1124
|
-
...annotation.status ? { status: annotation.status } : void 0
|
|
1125
|
-
}))
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
//#endregion
|
|
1129
|
-
//#region src/chat/briefs/operational-report.ts
|
|
1130
|
-
const DAY_MS$1 = 864e5;
|
|
1131
|
-
const WINDOWS = [
|
|
1132
|
-
7,
|
|
1133
|
-
30,
|
|
1134
|
-
90
|
|
885
|
+
//#region src/api/conversations/events.ts
|
|
886
|
+
/** Canonical event types that can contribute to the reporting projection. */
|
|
887
|
+
const conversationReportSourceEventTypes = [
|
|
888
|
+
"message",
|
|
889
|
+
"message_handled",
|
|
890
|
+
"assistant_message",
|
|
891
|
+
"tool_result",
|
|
892
|
+
"tool_execution_started",
|
|
893
|
+
"guardian_action_reviewed",
|
|
894
|
+
"turn_started",
|
|
895
|
+
"turn_context",
|
|
896
|
+
"structured_event",
|
|
897
|
+
"attachments_delivered",
|
|
898
|
+
"turn_routed",
|
|
899
|
+
"turn_completed",
|
|
900
|
+
"turn_failed",
|
|
901
|
+
"compaction",
|
|
902
|
+
"handoff",
|
|
903
|
+
"subagent_started",
|
|
904
|
+
"subagent_ended"
|
|
1135
905
|
];
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
906
|
+
const reportingAssistantMessageSchema = z.object({
|
|
907
|
+
type: z.literal("assistant_message"),
|
|
908
|
+
content: z.array(z.unknown())
|
|
909
|
+
}).passthrough();
|
|
910
|
+
const reportingToolCallPartSchema = z.object({
|
|
911
|
+
type: z.literal("toolCall"),
|
|
912
|
+
id: z.string().min(1),
|
|
913
|
+
name: z.string().min(1),
|
|
914
|
+
arguments: z.unknown()
|
|
915
|
+
}).passthrough();
|
|
916
|
+
const reportingToolResultMessageSchema = z.object({
|
|
917
|
+
type: z.literal("tool_result"),
|
|
918
|
+
toolCallId: z.string().min(1),
|
|
919
|
+
toolName: z.string().min(1).optional(),
|
|
920
|
+
name: z.string().min(1).optional(),
|
|
921
|
+
content: z.array(z.unknown()),
|
|
922
|
+
details: z.unknown().optional(),
|
|
923
|
+
isError: z.boolean().optional()
|
|
924
|
+
}).passthrough();
|
|
925
|
+
const reportingToolResultDetailsSchema = z.object({
|
|
926
|
+
ok: z.boolean().optional(),
|
|
927
|
+
status: z.string().optional()
|
|
928
|
+
}).passthrough();
|
|
929
|
+
const reportingTextPartSchema = z.object({
|
|
930
|
+
type: z.literal("text"),
|
|
931
|
+
text: z.string()
|
|
932
|
+
}).passthrough();
|
|
933
|
+
const reportingReasoningPartSchema = z.object({
|
|
934
|
+
type: z.literal("thinking"),
|
|
935
|
+
thinking: z.string(),
|
|
936
|
+
redacted: z.literal(true).optional()
|
|
937
|
+
}).passthrough();
|
|
938
|
+
const reportingMediaPartSchema = z.object({
|
|
939
|
+
type: z.enum(["image", "audio"]),
|
|
940
|
+
mimeType: z.string().min(1).optional()
|
|
941
|
+
}).passthrough();
|
|
942
|
+
const reportingMessageAuthorSchema = z.object({
|
|
943
|
+
fullName: z.string().min(1).optional(),
|
|
944
|
+
userId: z.string().min(1).optional(),
|
|
945
|
+
userName: z.string().min(1).optional()
|
|
946
|
+
}).passthrough();
|
|
947
|
+
/** Retain model-visible text and media descriptors, dropping all opaque fields. */
|
|
948
|
+
function sanitizeModelContentPart(value) {
|
|
949
|
+
const text = reportingTextPartSchema.safeParse(value);
|
|
950
|
+
if (text.success) return {
|
|
951
|
+
type: "text",
|
|
952
|
+
text: text.data.text
|
|
953
|
+
};
|
|
954
|
+
const media = reportingMediaPartSchema.safeParse(value);
|
|
955
|
+
if (!media.success) return void 0;
|
|
1172
956
|
return {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
metrics: [
|
|
1176
|
-
{
|
|
1177
|
-
label: "briefs stored",
|
|
1178
|
-
tone: counts?.stored ? "good" : "neutral",
|
|
1179
|
-
value: formatCount(counts?.stored ?? 0)
|
|
1180
|
-
},
|
|
1181
|
-
{
|
|
1182
|
-
label: "conversations with a brief",
|
|
1183
|
-
value: formatCount(counts?.conversations ?? 0)
|
|
1184
|
-
},
|
|
1185
|
-
{
|
|
1186
|
-
label: "briefs · 30d",
|
|
1187
|
-
value: formatCount(recentCounts[0]?.stored ?? 0)
|
|
1188
|
-
},
|
|
1189
|
-
{
|
|
1190
|
-
label: "cost · 30d · retained",
|
|
1191
|
-
value: formatUsd(recentCost)
|
|
1192
|
-
},
|
|
1193
|
-
{
|
|
1194
|
-
label: "average cost per brief · 30d · retained",
|
|
1195
|
-
value: formatUsd(recentBriefs === 0 ? 0 : recentCost / recentBriefs)
|
|
1196
|
-
}
|
|
1197
|
-
],
|
|
1198
|
-
widgets: [{
|
|
1199
|
-
categories: args.briefDays.map((day) => ({
|
|
1200
|
-
id: day.date,
|
|
1201
|
-
label: day.date,
|
|
1202
|
-
values: {
|
|
1203
|
-
briefs: day.events,
|
|
1204
|
-
costUsd: day.costUsd
|
|
1205
|
-
}
|
|
1206
|
-
})),
|
|
1207
|
-
description: "Stored Brief versions and estimated model cost",
|
|
1208
|
-
id: "briefs-created",
|
|
1209
|
-
series: [{
|
|
1210
|
-
key: "briefs",
|
|
1211
|
-
label: "Briefs"
|
|
1212
|
-
}, {
|
|
1213
|
-
format: "usd",
|
|
1214
|
-
key: "costUsd",
|
|
1215
|
-
label: "Cost"
|
|
1216
|
-
}],
|
|
1217
|
-
timeRangeDays: [...WINDOWS],
|
|
1218
|
-
title: "Briefs created",
|
|
1219
|
-
type: "bar_chart"
|
|
1220
|
-
}]
|
|
957
|
+
type: media.data.type,
|
|
958
|
+
...media.data.mimeType ? { mimeType: media.data.mimeType } : void 0
|
|
1221
959
|
};
|
|
1222
960
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
return
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
961
|
+
/** Convert allowlisted Pi content parts into the reporting result value. */
|
|
962
|
+
function modelVisibleToolOutput(content) {
|
|
963
|
+
const sanitized = content.map(sanitizeModelContentPart).filter((part) => part !== void 0);
|
|
964
|
+
if (sanitized.length === 0) return void 0;
|
|
965
|
+
if (sanitized.length !== 1) return sanitized;
|
|
966
|
+
const only = sanitized[0];
|
|
967
|
+
return only.type === "text" ? only.text : only;
|
|
968
|
+
}
|
|
969
|
+
function reportMessageActorIdentity(data) {
|
|
970
|
+
if (data.role !== "user") return void 0;
|
|
971
|
+
const author = reportingMessageAuthorSchema.safeParse(data.meta?.author);
|
|
972
|
+
if (!author.success) return void 0;
|
|
973
|
+
const actorIdentity = {
|
|
974
|
+
...author.data.fullName ? { fullName: author.data.fullName } : void 0,
|
|
975
|
+
...author.data.userId ? { slackUserId: author.data.userId } : void 0,
|
|
976
|
+
...author.data.userName ? { slackUserName: author.data.userName } : void 0
|
|
1236
977
|
};
|
|
978
|
+
return Object.keys(actorIdentity).length > 0 ? actorIdentity : void 0;
|
|
1237
979
|
}
|
|
1238
|
-
/**
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
};
|
|
1254
|
-
const version = ((await tx.select({ version: max(juniorConversationBriefs.version) }).from(juniorConversationBriefs).where(eq(juniorConversationBriefs.conversationId, input.conversationId)))[0]?.version ?? 0) + 1;
|
|
1255
|
-
const row = (await tx.insert(juniorConversationBriefs).values({
|
|
1256
|
-
conversationId: input.conversationId,
|
|
1257
|
-
version,
|
|
1258
|
-
turnId: input.turnId,
|
|
1259
|
-
throughSeq: input.throughSeq,
|
|
1260
|
-
content: conversationBriefSchema.parse(input.content),
|
|
1261
|
-
searchText: input.searchText,
|
|
1262
|
-
modelId: input.modelId,
|
|
1263
|
-
costUsd: input.costUsd ?? null
|
|
1264
|
-
}).onConflictDoNothing({ target: [juniorConversationBriefs.conversationId, juniorConversationBriefs.turnId] }).returning())[0];
|
|
1265
|
-
if (!row) throw new Error("Brief insert conflicted after the Turn check");
|
|
1266
|
-
return {
|
|
1267
|
-
inserted: true,
|
|
1268
|
-
value: briefVersionFromRow(row)
|
|
1269
|
-
};
|
|
980
|
+
/** Project native assistant tool calls through the existing reporting shape. */
|
|
981
|
+
function reportToolCalls(args) {
|
|
982
|
+
const message = reportingAssistantMessageSchema.safeParse(args.message);
|
|
983
|
+
if (!message.success) return void 0;
|
|
984
|
+
const calls = message.data.content.flatMap((part) => {
|
|
985
|
+
const call = reportingToolCallPartSchema.safeParse(part);
|
|
986
|
+
if (!call.success) return [];
|
|
987
|
+
return [{
|
|
988
|
+
toolCallId: call.data.id,
|
|
989
|
+
name: call.data.name,
|
|
990
|
+
status: "running",
|
|
991
|
+
startedAt: new Date(args.createdAtMs).toISOString(),
|
|
992
|
+
startedSeq: args.seq,
|
|
993
|
+
...args.canExposePayload && call.data.arguments !== void 0 ? { input: call.data.arguments } : void 0
|
|
994
|
+
}];
|
|
1270
995
|
});
|
|
996
|
+
return calls.length > 0 ? {
|
|
997
|
+
type: "tool_calls",
|
|
998
|
+
calls
|
|
999
|
+
} : void 0;
|
|
1271
1000
|
}
|
|
1272
|
-
/**
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1001
|
+
/** Project assistant reasoning with tool ids retained only for display order. */
|
|
1002
|
+
function reportAssistantMessage(args) {
|
|
1003
|
+
const message = reportingAssistantMessageSchema.safeParse(args.message);
|
|
1004
|
+
if (!message.success) return void 0;
|
|
1005
|
+
if (!message.data.content.some((part) => {
|
|
1006
|
+
const reasoning = reportingReasoningPartSchema.safeParse(part);
|
|
1007
|
+
return reasoning.success && reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0;
|
|
1008
|
+
})) return reportToolCalls(args) ?? {
|
|
1009
|
+
type: "assistant_message",
|
|
1010
|
+
parts: []
|
|
1011
|
+
};
|
|
1012
|
+
const reasoningParts = [];
|
|
1013
|
+
const assistantParts = [];
|
|
1014
|
+
for (const part of message.data.content) {
|
|
1015
|
+
const reasoning = reportingReasoningPartSchema.safeParse(part);
|
|
1016
|
+
if (reasoning.success) {
|
|
1017
|
+
if (reasoning.data.redacted !== true && reasoning.data.thinking.trim().length > 0) {
|
|
1018
|
+
const projected = {
|
|
1019
|
+
type: "reasoning",
|
|
1020
|
+
...args.canExposePayload ? { text: reasoning.data.thinking } : { redacted: true }
|
|
1021
|
+
};
|
|
1022
|
+
reasoningParts.push(projected);
|
|
1023
|
+
assistantParts.push(projected);
|
|
1024
|
+
}
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const call = reportingToolCallPartSchema.safeParse(part);
|
|
1028
|
+
if (call.success) assistantParts.push({
|
|
1029
|
+
type: "tool_call",
|
|
1030
|
+
toolCallId: call.data.id
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const toolCalls = reportToolCalls(args);
|
|
1034
|
+
if (toolCalls?.type === "tool_calls") return {
|
|
1035
|
+
...toolCalls,
|
|
1036
|
+
assistant: { parts: assistantParts }
|
|
1037
|
+
};
|
|
1038
|
+
return reasoningParts.length > 0 ? {
|
|
1039
|
+
type: "assistant_message",
|
|
1040
|
+
parts: reasoningParts
|
|
1041
|
+
} : void 0;
|
|
1293
1042
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
return
|
|
1043
|
+
/** Project a native tool result without exposing host-only result details. */
|
|
1044
|
+
function reportToolResult(args) {
|
|
1045
|
+
const message = reportingToolResultMessageSchema.safeParse(args.message);
|
|
1046
|
+
if (!message.success) return void 0;
|
|
1047
|
+
const details = reportingToolResultDetailsSchema.safeParse(message.data.details);
|
|
1048
|
+
const outcome = message.data.isError === true || details.success && (details.data.ok === false || details.data.status === "error") ? "error" : "completed";
|
|
1049
|
+
const output = modelVisibleToolOutput(message.data.content);
|
|
1050
|
+
const name = message.data.toolName ?? message.data.name ?? args.start?.name;
|
|
1051
|
+
if (!name) return void 0;
|
|
1052
|
+
return {
|
|
1053
|
+
type: "tool_calls",
|
|
1054
|
+
calls: [{
|
|
1055
|
+
toolCallId: message.data.toolCallId,
|
|
1056
|
+
name,
|
|
1057
|
+
status: outcome,
|
|
1058
|
+
...args.start ? {
|
|
1059
|
+
startedAt: new Date(args.start.createdAtMs).toISOString(),
|
|
1060
|
+
startedSeq: args.start.seq
|
|
1061
|
+
} : void 0,
|
|
1062
|
+
...args.canExposePayload && output !== void 0 ? { output } : void 0
|
|
1063
|
+
}]
|
|
1064
|
+
};
|
|
1298
1065
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
openDecisions: stored.content.openDecisions.length,
|
|
1306
|
-
links: stored.content.links.length
|
|
1307
|
-
}));
|
|
1066
|
+
/** Return tool result ids that may need start metadata from outside a page. */
|
|
1067
|
+
function conversationReportToolResultIds(events) {
|
|
1068
|
+
return [...new Set(events.flatMap((event) => {
|
|
1069
|
+
const result = reportingToolResultMessageSchema.safeParse(event.data);
|
|
1070
|
+
return result.success ? [result.data.toolCallId] : [];
|
|
1071
|
+
}))];
|
|
1308
1072
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
return;
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1073
|
+
function reportEventData(args) {
|
|
1074
|
+
const { data } = args;
|
|
1075
|
+
if (data.type === "structured_event") {
|
|
1076
|
+
if (!args.canExposePayload) return void 0;
|
|
1077
|
+
const presentation = data.namespace === "junior" ? renderJuniorNativeConversationEvent(data) : renderPluginConversationEvent(data);
|
|
1078
|
+
if (!presentation) return void 0;
|
|
1079
|
+
return {
|
|
1080
|
+
type: "structured_event",
|
|
1081
|
+
namespace: data.namespace,
|
|
1082
|
+
name: data.name,
|
|
1083
|
+
version: data.version,
|
|
1084
|
+
...data.turnId ? { turnId: data.turnId } : void 0,
|
|
1085
|
+
presentation
|
|
1086
|
+
};
|
|
1321
1087
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
return
|
|
1088
|
+
switch (data.type) {
|
|
1089
|
+
case "message": {
|
|
1090
|
+
const actorIdentity = args.canExposePayload ? reportMessageActorIdentity(data) : void 0;
|
|
1091
|
+
return {
|
|
1092
|
+
type: "message",
|
|
1093
|
+
messageId: data.messageId,
|
|
1094
|
+
role: data.role,
|
|
1095
|
+
...data.meta?.source === "web" || data.meta?.source === "slack" ? { source: data.meta.source } : void 0,
|
|
1096
|
+
...actorIdentity ? { actorIdentity } : void 0,
|
|
1097
|
+
eventObjectType: objectTypeSchema.safeParse(data.meta?.eventObjectType).data,
|
|
1098
|
+
...typeof data.meta?.eventType === "string" ? { eventType: data.meta.eventType } : void 0,
|
|
1099
|
+
...typeof data.meta?.explicitMention === "boolean" ? { explicitMention: data.meta.explicitMention } : void 0,
|
|
1100
|
+
...args.canExposePayload && typeof data.meta?.trustedSummary === "string" ? { trustedSummary: data.meta.trustedSummary } : void 0,
|
|
1101
|
+
...args.canExposePayload && (data.meta?.cards || data.meta?.objectCards) ? { cards: readMessageCardRefs(data.meta) } : void 0,
|
|
1102
|
+
...args.canExposePayload ? {
|
|
1103
|
+
text: data.text,
|
|
1104
|
+
...data.meta?.attachments ? { attachments: readMessageAttachments(data.meta.attachments) } : void 0
|
|
1105
|
+
} : { redacted: true }
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
case "message_handled": return {
|
|
1109
|
+
type: "message_handled",
|
|
1110
|
+
messageId: data.messageId
|
|
1111
|
+
};
|
|
1112
|
+
case "turn_started": return {
|
|
1113
|
+
type: "turn_lifecycle",
|
|
1114
|
+
turnId: data.turnId,
|
|
1115
|
+
state: "started",
|
|
1116
|
+
...data.inputMessageIds.length > 0 ? { inputMessageIds: data.inputMessageIds } : void 0
|
|
1117
|
+
};
|
|
1118
|
+
case "turn_context":
|
|
1119
|
+
if (!args.canExposePayload) return;
|
|
1120
|
+
return {
|
|
1121
|
+
type: "turn_context",
|
|
1122
|
+
turnId: data.turnId,
|
|
1123
|
+
pluginName: data.pluginName,
|
|
1124
|
+
kind: data.kind,
|
|
1125
|
+
version: data.version,
|
|
1126
|
+
content: data.content
|
|
1127
|
+
};
|
|
1128
|
+
case "attachments_delivered":
|
|
1129
|
+
if (!args.canExposePayload) return;
|
|
1130
|
+
return {
|
|
1131
|
+
type: "attachments_delivered",
|
|
1132
|
+
attachments: data.attachments
|
|
1133
|
+
};
|
|
1134
|
+
case "turn_routed": return {
|
|
1135
|
+
type: "turn_routed",
|
|
1136
|
+
turnId: data.turnId,
|
|
1137
|
+
modelProfile: data.modelProfile,
|
|
1138
|
+
modelId: data.modelId,
|
|
1139
|
+
...data.costUsd !== void 0 ? { costUsd: data.costUsd } : void 0,
|
|
1140
|
+
reasoningLevel: data.reasoningLevel,
|
|
1141
|
+
...data.confidence !== void 0 ? { confidence: data.confidence } : void 0,
|
|
1142
|
+
source: data.source
|
|
1143
|
+
};
|
|
1144
|
+
case "guardian_action_reviewed": return {
|
|
1145
|
+
type: "guardian_action_reviewed",
|
|
1146
|
+
turnId: data.turnId,
|
|
1147
|
+
toolCallId: data.toolCallId,
|
|
1148
|
+
toolName: data.toolName,
|
|
1149
|
+
decision: data.decision,
|
|
1150
|
+
riskLevel: data.riskLevel,
|
|
1151
|
+
userAuthorization: data.userAuthorization
|
|
1152
|
+
};
|
|
1153
|
+
case "turn_completed": return {
|
|
1154
|
+
type: "turn_lifecycle",
|
|
1155
|
+
turnId: data.turnId,
|
|
1156
|
+
state: data.outcome === "success" ? "succeeded" : "no_reply"
|
|
1157
|
+
};
|
|
1158
|
+
case "turn_failed": {
|
|
1159
|
+
const eventId = data.eventId;
|
|
1160
|
+
const sentryEventUrl = eventId ? buildSentryEventUrl(eventId) : void 0;
|
|
1161
|
+
return {
|
|
1162
|
+
type: "turn_lifecycle",
|
|
1163
|
+
turnId: data.turnId,
|
|
1164
|
+
state: "failed",
|
|
1165
|
+
failureCode: data.failureCode,
|
|
1166
|
+
...data.failureReason ? { failureReason: data.failureReason } : void 0,
|
|
1167
|
+
...eventId ? { eventId } : void 0,
|
|
1168
|
+
...sentryEventUrl ? { sentryEventUrl } : void 0
|
|
1169
|
+
};
|
|
1326
1170
|
}
|
|
1171
|
+
case "compaction": return {
|
|
1172
|
+
type: "compaction",
|
|
1173
|
+
modelProfile: data.modelProfile,
|
|
1174
|
+
modelId: data.modelId,
|
|
1175
|
+
...args.canExposePayload && data.summary ? { summary: data.summary } : void 0,
|
|
1176
|
+
...data.details ? { details: data.details } : void 0
|
|
1177
|
+
};
|
|
1178
|
+
default: return;
|
|
1327
1179
|
}
|
|
1328
|
-
if (!hasUserInstruction(run)) {
|
|
1329
|
-
logSkip(context, run.conversationId, "no_user_instruction");
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
await context.state.withLock(`brief:${run.conversationId}`, BRIEF_LOCK_TTL_MS, async () => {
|
|
1333
|
-
const db = context.db;
|
|
1334
|
-
const existing = await readConversationBriefForTurn(db, run.conversationId, run.runId);
|
|
1335
|
-
if (existing) {
|
|
1336
|
-
await emitBriefUpdated(context, existing);
|
|
1337
|
-
logSkip(context, run.conversationId, "turn_already_stored");
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
const terminalSeq = await readTurnCompletedSeq(db, run.conversationId, run.runId);
|
|
1341
|
-
const previous = await readLatestConversationBrief(db, run.conversationId);
|
|
1342
|
-
if (previous && previous.throughSeq >= terminalSeq) {
|
|
1343
|
-
logSkip(context, run.conversationId, "turn_already_covered");
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1346
|
-
const input = await briefInputFromSql(executor, run.conversationId, terminalSeq);
|
|
1347
|
-
const modelId = defaultModelId(botConfig);
|
|
1348
|
-
const generation = await generateBrief({
|
|
1349
|
-
completeObject: (request) => completeObject({
|
|
1350
|
-
...request,
|
|
1351
|
-
modelId,
|
|
1352
|
-
promptName: "junior.brief_update"
|
|
1353
|
-
}),
|
|
1354
|
-
input,
|
|
1355
|
-
previous: previous?.content,
|
|
1356
|
-
prompt: BRIEF_PROMPT,
|
|
1357
|
-
throughIndex: terminalSeq
|
|
1358
|
-
});
|
|
1359
|
-
const stored = await appendConversationBrief(db, {
|
|
1360
|
-
conversationId: run.conversationId,
|
|
1361
|
-
turnId: run.runId,
|
|
1362
|
-
throughSeq: terminalSeq,
|
|
1363
|
-
content: generation.brief,
|
|
1364
|
-
searchText: generation.searchText,
|
|
1365
|
-
modelId,
|
|
1366
|
-
...generation.costUsd !== void 0 ? { costUsd: generation.costUsd } : void 0
|
|
1367
|
-
});
|
|
1368
|
-
await emitBriefUpdated(context, stored.value);
|
|
1369
|
-
if (!stored.inserted) logSkip(context, run.conversationId, "turn_already_stored");
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
/** Core task registration kept outside the installed plugin catalog. */
|
|
1373
|
-
const briefsTaskRegistration = {
|
|
1374
|
-
manifest: {
|
|
1375
|
-
name: "briefs",
|
|
1376
|
-
displayName: "Briefs",
|
|
1377
|
-
description: "Durable Conversation Brief generation"
|
|
1378
|
-
},
|
|
1379
|
-
conversationEvents: [briefUpdatedEvent],
|
|
1380
|
-
hooks: { async operationalReport(context) {
|
|
1381
|
-
return await buildBriefsOperationalReport({
|
|
1382
|
-
briefDays: await context.eventStats.costsByDay({
|
|
1383
|
-
days: 90,
|
|
1384
|
-
eventName: "brief_updated"
|
|
1385
|
-
}),
|
|
1386
|
-
db: context.db,
|
|
1387
|
-
nowMs: context.nowMs
|
|
1388
|
-
});
|
|
1389
|
-
} },
|
|
1390
|
-
tasks: { updateBrief: { async run(context) {
|
|
1391
|
-
await updateConversationBrief(context);
|
|
1392
|
-
} } }
|
|
1393
|
-
};
|
|
1394
|
-
//#endregion
|
|
1395
|
-
//#region src/chat/plugins/conversation-events.ts
|
|
1396
|
-
function registeredDefinition(plugin, value) {
|
|
1397
|
-
return plugin.conversationEvents?.find((definition) => definition === value.definition);
|
|
1398
|
-
}
|
|
1399
|
-
/** Keep operation idempotency stable when a registered event schema advances. */
|
|
1400
|
-
function eventIdentityVersion(plugin, definition) {
|
|
1401
|
-
let version = definition.version;
|
|
1402
|
-
for (const candidate of plugin.conversationEvents ?? []) if (candidate.eventName === definition.eventName) version = Math.min(version, candidate.version);
|
|
1403
|
-
return version;
|
|
1404
|
-
}
|
|
1405
|
-
/** Create a conversation-bound writer for one plugin operation. */
|
|
1406
|
-
function createPluginConversationEvents(args) {
|
|
1407
|
-
return { async emit(value) {
|
|
1408
|
-
const definition = registeredDefinition(args.plugin, value);
|
|
1409
|
-
if (!definition) throw new Error(`Plugin "${args.plugin.manifest.name}" cannot emit an unregistered conversation event.`);
|
|
1410
|
-
const content = definition.parse(value.data);
|
|
1411
|
-
const identityVersion = eventIdentityVersion(args.plugin, definition);
|
|
1412
|
-
await getConversationEventStore().append(args.conversationId, [{
|
|
1413
|
-
createdAtMs: Date.now(),
|
|
1414
|
-
idempotencyKey: `plugin:${args.plugin.manifest.name}:operation:${args.operationId}:event:${definition.eventName}@${identityVersion}`,
|
|
1415
|
-
data: {
|
|
1416
|
-
type: "structured_event",
|
|
1417
|
-
namespace: args.plugin.manifest.name,
|
|
1418
|
-
name: definition.eventName,
|
|
1419
|
-
version: definition.version,
|
|
1420
|
-
turnId: args.turnId,
|
|
1421
|
-
content
|
|
1422
|
-
}
|
|
1423
|
-
}], { activity: "preserve" });
|
|
1424
|
-
} };
|
|
1425
|
-
}
|
|
1426
|
-
/** Render a stored plugin event through its currently registered definition. */
|
|
1427
|
-
function renderPluginConversationEvent(args) {
|
|
1428
|
-
const definition = [briefsTaskRegistration, ...getPlugins()].find((candidate) => candidate.manifest.name === args.namespace)?.conversationEvents?.find((candidate) => candidate.eventName === args.name && candidate.version === args.version);
|
|
1429
|
-
if (!definition) return void 0;
|
|
1430
|
-
return definition.renderEvent(definition.parse(args.content));
|
|
1431
|
-
}
|
|
1432
|
-
//#endregion
|
|
1433
|
-
//#region src/api/conversations/membership.ts
|
|
1434
|
-
/** True when the viewer is a materialized participant on this root conversation. */
|
|
1435
|
-
function conversationHasParticipantUser(userId) {
|
|
1436
|
-
const participant = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorConversationParticipants.userId, userId)));
|
|
1437
|
-
return exists(participant);
|
|
1438
1180
|
}
|
|
1439
1181
|
/**
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1182
|
+
* Project canonical events into the ordered reporting boundary.
|
|
1183
|
+
*
|
|
1184
|
+
* `canExposePayload` must come from the authorized conversation-detail policy;
|
|
1185
|
+
* this projection never derives visibility or authorization from event data.
|
|
1442
1186
|
*/
|
|
1443
|
-
function
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
function conversationArchivedForEmail(email, archivedAfter) {
|
|
1459
|
-
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorUsers.primaryEmailNormalized, email), isNotNull(juniorConversationParticipants.archivedAt), archivedAfter ? gte(juniorConversationParticipants.archivedAt, archivedAfter) : void 0));
|
|
1460
|
-
return exists(archive);
|
|
1461
|
-
}
|
|
1462
|
-
/** True when one primary-email user's personal feed has not archived this root. */
|
|
1463
|
-
function conversationNotArchivedForEmail(email) {
|
|
1464
|
-
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorUsers.primaryEmailNormalized, email), isNotNull(juniorConversationParticipants.archivedAt)));
|
|
1465
|
-
return notExists(archive);
|
|
1466
|
-
}
|
|
1467
|
-
/** Root-actor ownership or a materialized participant row for the same person. */
|
|
1468
|
-
function viewerConversationMembership(args) {
|
|
1469
|
-
if (args.actorMatch && args.participantMatch) return or(args.actorMatch, args.participantMatch);
|
|
1470
|
-
return args.actorMatch ?? args.participantMatch;
|
|
1471
|
-
}
|
|
1472
|
-
/** Read personal archive timestamps for a bounded set of conversation roots. */
|
|
1473
|
-
async function conversationArchiveTimes(db, conversationIds, subject) {
|
|
1474
|
-
if (!subject || conversationIds.length === 0) return /* @__PURE__ */ new Map();
|
|
1475
|
-
const query = db.select({
|
|
1476
|
-
archivedAt: juniorConversationParticipants.archivedAt,
|
|
1477
|
-
rootConversationId: juniorConversationParticipants.rootConversationId
|
|
1478
|
-
}).from(juniorConversationParticipants);
|
|
1479
|
-
const rows = "userId" in subject ? await query.where(and(eq(juniorConversationParticipants.userId, subject.userId), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds]), isNotNull(juniorConversationParticipants.archivedAt))) : await query.innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorUsers.primaryEmailNormalized, subject.email), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds]), isNotNull(juniorConversationParticipants.archivedAt)));
|
|
1480
|
-
return new Map(rows.flatMap((row) => row.archivedAt ? [[row.rootConversationId, row.archivedAt.getTime()]] : []));
|
|
1481
|
-
}
|
|
1482
|
-
/** Find which of the supplied roots include the linked viewer as a participant. */
|
|
1483
|
-
async function conversationIdsWithParticipantUser(db, conversationIds, userId) {
|
|
1484
|
-
if (conversationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1485
|
-
const rows = await db.select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.userId, userId), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds])));
|
|
1486
|
-
return new Set(rows.map((row) => row.rootConversationId));
|
|
1487
|
-
}
|
|
1488
|
-
//#endregion
|
|
1489
|
-
//#region src/api/conversations/access.ts
|
|
1490
|
-
const rootConversation = alias(juniorConversations, "access_root_conversation");
|
|
1491
|
-
const rootDestination = alias(juniorDestinations, "access_root_destination");
|
|
1492
|
-
const rootIdentity = alias(juniorIdentities, "access_root_identity");
|
|
1493
|
-
/** Resolve viewer access for a bounded set of persisted conversations. */
|
|
1494
|
-
async function readConversationAccessFromSql(db, conversationIds, viewer) {
|
|
1495
|
-
if (conversationIds.length === 0) return /* @__PURE__ */ new Map();
|
|
1496
|
-
const normalizedViewerEmail = viewer?.email.trim().toLowerCase() || void 0;
|
|
1497
|
-
const rows = await db.select({
|
|
1498
|
-
conversationId: juniorConversations.conversationId,
|
|
1499
|
-
parentConversationId: juniorConversations.parentConversationId,
|
|
1500
|
-
storedRootConversationId: juniorConversations.rootConversationId,
|
|
1501
|
-
rootConversationId: rootConversation.conversationId,
|
|
1502
|
-
rootParentConversationId: rootConversation.parentConversationId,
|
|
1503
|
-
rootRootConversationId: rootConversation.rootConversationId,
|
|
1504
|
-
visibility: rootDestination.visibility,
|
|
1505
|
-
rootEmailNormalized: rootIdentity.emailNormalized,
|
|
1506
|
-
rootEmailVerified: rootIdentity.emailVerified,
|
|
1507
|
-
rootUserId: rootIdentity.userId
|
|
1508
|
-
}).from(juniorConversations).leftJoin(rootConversation, eq(rootConversation.conversationId, juniorConversations.rootConversationId)).leftJoin(rootDestination, eq(rootDestination.id, rootConversation.destinationId)).leftJoin(rootIdentity, eq(rootIdentity.id, rootConversation.actorIdentityId)).where(inArray(juniorConversations.conversationId, [...conversationIds]));
|
|
1509
|
-
const membershipConversationIds = /* @__PURE__ */ new Set();
|
|
1510
|
-
for (const row of rows) {
|
|
1511
|
-
membershipConversationIds.add(row.conversationId);
|
|
1512
|
-
if (row.storedRootConversationId) membershipConversationIds.add(row.storedRootConversationId);
|
|
1187
|
+
function projectConversationReportEventPage(args) {
|
|
1188
|
+
const subagentStarts = /* @__PURE__ */ new Map();
|
|
1189
|
+
for (const event of args.subagentStartEvents ?? []) if (event.data.type === "subagent_started") subagentStarts.set(event.data.subagentInvocationId, {
|
|
1190
|
+
createdAtMs: event.createdAtMs,
|
|
1191
|
+
data: event.data,
|
|
1192
|
+
seq: event.seq
|
|
1193
|
+
});
|
|
1194
|
+
const toolStarts = /* @__PURE__ */ new Map();
|
|
1195
|
+
for (const event of args.toolStartEvents ?? []) if (event.data.type === "tool_execution_started") {
|
|
1196
|
+
const current = toolStarts.get(event.data.toolCallId);
|
|
1197
|
+
if (!current || event.seq < current.seq) toolStarts.set(event.data.toolCallId, {
|
|
1198
|
+
createdAtMs: event.createdAtMs,
|
|
1199
|
+
name: event.data.toolName,
|
|
1200
|
+
seq: event.seq
|
|
1201
|
+
});
|
|
1513
1202
|
}
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
if (
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1203
|
+
const projected = [];
|
|
1204
|
+
let model;
|
|
1205
|
+
for (const event of args.modelContextEvents ?? []) {
|
|
1206
|
+
model = conversationEventModel(event.data, model);
|
|
1207
|
+
if (event.data.type === "turn_completed" || event.data.type === "turn_failed") model = void 0;
|
|
1208
|
+
}
|
|
1209
|
+
for (const event of args.events) {
|
|
1210
|
+
model = conversationEventModel(event.data, model);
|
|
1211
|
+
const eventModel = model;
|
|
1212
|
+
const modelCall = conversationEventModelCall(event.data);
|
|
1213
|
+
if (event.data.type === "turn_completed" || event.data.type === "turn_failed") model = void 0;
|
|
1214
|
+
let data;
|
|
1215
|
+
if (event.data.type === "assistant_message" || event.data.type === "tool_result") {
|
|
1216
|
+
const reportArgs = {
|
|
1217
|
+
canExposePayload: args.canExposePayload,
|
|
1218
|
+
createdAtMs: event.createdAtMs,
|
|
1219
|
+
message: event.data,
|
|
1220
|
+
seq: event.seq
|
|
1221
|
+
};
|
|
1222
|
+
const result = reportingToolResultMessageSchema.safeParse(event.data);
|
|
1223
|
+
const start = result.success ? toolStarts.get(result.data.toolCallId) : void 0;
|
|
1224
|
+
data = reportAssistantMessage(reportArgs) ?? reportToolResult({
|
|
1225
|
+
canExposePayload: args.canExposePayload,
|
|
1226
|
+
message: event.data,
|
|
1227
|
+
...start && start.seq < event.seq ? { start } : void 0
|
|
1228
|
+
});
|
|
1229
|
+
} else if (event.data.type === "tool_execution_started") {
|
|
1230
|
+
toolStarts.set(event.data.toolCallId, {
|
|
1231
|
+
createdAtMs: event.createdAtMs,
|
|
1232
|
+
name: event.data.toolName,
|
|
1233
|
+
seq: event.seq
|
|
1234
|
+
});
|
|
1235
|
+
data = {
|
|
1236
|
+
type: "tool_calls",
|
|
1237
|
+
calls: [{
|
|
1238
|
+
toolCallId: event.data.toolCallId,
|
|
1239
|
+
name: event.data.toolName,
|
|
1240
|
+
status: "running",
|
|
1241
|
+
startedAt: new Date(event.createdAtMs).toISOString(),
|
|
1242
|
+
startedSeq: event.seq
|
|
1243
|
+
}]
|
|
1244
|
+
};
|
|
1245
|
+
} else if (event.data.type === "subagent_started") {
|
|
1246
|
+
subagentStarts.set(event.data.subagentInvocationId, {
|
|
1247
|
+
createdAtMs: event.createdAtMs,
|
|
1248
|
+
data: event.data,
|
|
1249
|
+
seq: event.seq
|
|
1250
|
+
});
|
|
1251
|
+
data = {
|
|
1252
|
+
type: "subagent",
|
|
1253
|
+
startedSeq: event.seq,
|
|
1254
|
+
startedAt: new Date(event.createdAtMs).toISOString(),
|
|
1255
|
+
childConversationId: event.data.childConversationId,
|
|
1256
|
+
subagentKind: event.data.subagentKind,
|
|
1257
|
+
status: "running",
|
|
1258
|
+
...event.data.parentToolCallId ? { parentToolCallId: event.data.parentToolCallId } : void 0
|
|
1259
|
+
};
|
|
1260
|
+
} else if (event.data.type === "subagent_ended") {
|
|
1261
|
+
const started = subagentStarts.get(event.data.subagentInvocationId);
|
|
1262
|
+
if (started && started.seq < event.seq) {
|
|
1263
|
+
data = {
|
|
1264
|
+
type: "subagent",
|
|
1265
|
+
startedSeq: started.seq,
|
|
1266
|
+
startedAt: new Date(started.createdAtMs).toISOString(),
|
|
1267
|
+
childConversationId: started.data.childConversationId,
|
|
1268
|
+
subagentKind: started.data.subagentKind,
|
|
1269
|
+
...started.data.parentToolCallId ? { parentToolCallId: started.data.parentToolCallId } : void 0,
|
|
1270
|
+
status: event.data.outcome === "success" ? "completed" : event.data.outcome
|
|
1271
|
+
};
|
|
1272
|
+
subagentStarts.delete(event.data.subagentInvocationId);
|
|
1273
|
+
}
|
|
1274
|
+
} else if (event.data.type === "handoff") data = {
|
|
1275
|
+
type: "handoff",
|
|
1276
|
+
modelProfile: event.data.modelProfile,
|
|
1277
|
+
modelId: event.data.modelId,
|
|
1278
|
+
...event.data.reasoningLevel ? { reasoningLevel: event.data.reasoningLevel } : void 0,
|
|
1279
|
+
...event.data.triggeringToolCallId ? { triggeringToolCallId: event.data.triggeringToolCallId } : void 0,
|
|
1280
|
+
...args.canExposePayload && event.data.summary ? { summary: event.data.summary } : void 0
|
|
1281
|
+
};
|
|
1282
|
+
else data = reportEventData({
|
|
1283
|
+
canExposePayload: args.canExposePayload,
|
|
1284
|
+
data: event.data
|
|
1285
|
+
});
|
|
1286
|
+
if (!data) continue;
|
|
1287
|
+
projected.push(conversationReportEventSchema.parse({
|
|
1288
|
+
seq: event.seq,
|
|
1289
|
+
createdAt: new Date(event.createdAtMs).toISOString(),
|
|
1290
|
+
data,
|
|
1291
|
+
...eventModel ? { model: eventModel } : void 0,
|
|
1292
|
+
...modelCall ? { modelCall } : void 0
|
|
1549
1293
|
}));
|
|
1550
|
-
}
|
|
1294
|
+
}
|
|
1295
|
+
return projected;
|
|
1551
1296
|
}
|
|
1552
1297
|
//#endregion
|
|
1553
|
-
//#region src/
|
|
1554
|
-
const
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1298
|
+
//#region src/api/conversations/event-page.ts
|
|
1299
|
+
const conversationEventColumns = getTableColumns(juniorConversationEvents);
|
|
1300
|
+
const MIN_SCAN_SIZE = 64;
|
|
1301
|
+
/** Read bounded canonical rows while excluding model-only replacement history. */
|
|
1302
|
+
async function readConversationEventRows(executor, args) {
|
|
1303
|
+
return executor.db().select({
|
|
1304
|
+
...conversationEventColumns,
|
|
1305
|
+
payload: sql`case
|
|
1306
|
+
when ${juniorConversationEvents.type} in ('compaction', 'handoff')
|
|
1307
|
+
then jsonb_set(
|
|
1308
|
+
${juniorConversationEvents.payload},
|
|
1309
|
+
'{replacementHistory}',
|
|
1310
|
+
'[]'::jsonb
|
|
1311
|
+
)
|
|
1312
|
+
else ${juniorConversationEvents.payload}
|
|
1313
|
+
end`
|
|
1314
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, args.conversationId), args.beforeSeq === void 0 ? void 0 : lt(juniorConversationEvents.seq, args.beforeSeq), inArray(juniorConversationEvents.type, args.types ?? [...conversationReportSourceEventTypes]), args.subagentInvocationIds === void 0 ? void 0 : inArray(sql`${juniorConversationEvents.payload}->>'subagentInvocationId'`, args.subagentInvocationIds), args.toolCallIds === void 0 ? void 0 : inArray(sql`${juniorConversationEvents.payload}->>'toolCallId'`, args.toolCallIds))).orderBy(args.direction === "forward" ? asc(juniorConversationEvents.seq) : desc(juniorConversationEvents.seq)).limit(args.limit);
|
|
1564
1315
|
}
|
|
1565
|
-
function
|
|
1566
|
-
|
|
1567
|
-
|
|
1316
|
+
function decodeConversationEventRow(row) {
|
|
1317
|
+
return decodeStoredConversationEvent({
|
|
1318
|
+
schemaVersion: row.schemaVersion,
|
|
1319
|
+
seq: row.seq,
|
|
1320
|
+
historyVersion: row.historyVersion,
|
|
1321
|
+
...row.idempotencyKey ? { idempotencyKey: row.idempotencyKey } : void 0,
|
|
1322
|
+
createdAtMs: row.createdAt.getTime(),
|
|
1323
|
+
type: row.type,
|
|
1324
|
+
payload: row.payload
|
|
1325
|
+
});
|
|
1568
1326
|
}
|
|
1569
|
-
/**
|
|
1570
|
-
function
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
ELSE 0
|
|
1616
|
-
END`;
|
|
1617
|
-
const rows = await getDb().select({
|
|
1618
|
-
costUsd: sql`SUM(${cost})::double precision`.mapWith(Number),
|
|
1619
|
-
date,
|
|
1620
|
-
events: sql`COUNT(*)::integer`.mapWith(Number)
|
|
1621
|
-
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1622
|
-
const byDate = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1623
|
-
return Array.from({ length: input.days }, (_, index) => {
|
|
1624
|
-
const date = new Date(start.getTime() + index * DAY_MS).toISOString().slice(0, 10);
|
|
1625
|
-
return byDate.get(date) ?? {
|
|
1626
|
-
costUsd: 0,
|
|
1627
|
-
date,
|
|
1628
|
-
events: 0
|
|
1629
|
-
};
|
|
1630
|
-
});
|
|
1631
|
-
}
|
|
1632
|
-
};
|
|
1327
|
+
/** Project stored rows and resolve entity starts that precede the page. */
|
|
1328
|
+
async function projectConversationEventRows(executor, args) {
|
|
1329
|
+
const events = args.rows.map(decodeConversationEventRow).sort((left, right) => left.seq - right.seq);
|
|
1330
|
+
const modelContextRows = events[0] ? (await Promise.all([readConversationEventRows(executor, {
|
|
1331
|
+
conversationId: args.conversationId,
|
|
1332
|
+
beforeSeq: events[0].seq,
|
|
1333
|
+
direction: "backward",
|
|
1334
|
+
limit: 1,
|
|
1335
|
+
types: [
|
|
1336
|
+
"turn_started",
|
|
1337
|
+
"turn_routed",
|
|
1338
|
+
"handoff",
|
|
1339
|
+
"turn_completed",
|
|
1340
|
+
"turn_failed"
|
|
1341
|
+
]
|
|
1342
|
+
}), readConversationEventRows(executor, {
|
|
1343
|
+
conversationId: args.conversationId,
|
|
1344
|
+
beforeSeq: events[0].seq,
|
|
1345
|
+
direction: "backward",
|
|
1346
|
+
limit: 1,
|
|
1347
|
+
types: ["assistant_message"]
|
|
1348
|
+
})])).flat().sort((left, right) => left.seq - right.seq) : [];
|
|
1349
|
+
const endedInvocationIds = [...new Set(events.flatMap((event) => event.data.type === "subagent_ended" ? [event.data.subagentInvocationId] : []))];
|
|
1350
|
+
const subagentStartRows = endedInvocationIds.length === 0 ? [] : await readConversationEventRows(executor, {
|
|
1351
|
+
conversationId: args.conversationId,
|
|
1352
|
+
direction: "forward",
|
|
1353
|
+
limit: endedInvocationIds.length,
|
|
1354
|
+
subagentInvocationIds: endedInvocationIds,
|
|
1355
|
+
types: ["subagent_started"]
|
|
1356
|
+
});
|
|
1357
|
+
const toolResultIds = conversationReportToolResultIds(events);
|
|
1358
|
+
const toolStartRows = toolResultIds.length === 0 ? [] : await readConversationEventRows(executor, {
|
|
1359
|
+
conversationId: args.conversationId,
|
|
1360
|
+
direction: "forward",
|
|
1361
|
+
limit: toolResultIds.length,
|
|
1362
|
+
toolCallIds: toolResultIds,
|
|
1363
|
+
types: ["tool_execution_started"]
|
|
1364
|
+
});
|
|
1365
|
+
if (args.canExposePayload) await hydrateMessageAttachments(executor, args.conversationId, events);
|
|
1366
|
+
return projectConversationReportEventPage({
|
|
1367
|
+
canExposePayload: args.canExposePayload,
|
|
1368
|
+
events,
|
|
1369
|
+
subagentStartEvents: subagentStartRows.map(decodeConversationEventRow),
|
|
1370
|
+
toolStartEvents: toolStartRows.map(decodeConversationEventRow),
|
|
1371
|
+
modelContextEvents: modelContextRows.map(decodeConversationEventRow)
|
|
1372
|
+
});
|
|
1633
1373
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1374
|
+
/**
|
|
1375
|
+
* Read the latest reporting events before an exclusive boundary.
|
|
1376
|
+
*
|
|
1377
|
+
* Source rows are scanned in bounded chunks until the requested projected page
|
|
1378
|
+
* and one older event are known. This keeps ordinary pages bounded without
|
|
1379
|
+
* assuming every agent history event produces a reporting event.
|
|
1380
|
+
*/
|
|
1381
|
+
async function readConversationEventPage(executor, args) {
|
|
1382
|
+
const scanSize = Math.max(args.limit + 1, MIN_SCAN_SIZE);
|
|
1383
|
+
const rows = [];
|
|
1384
|
+
let beforeSeq = args.beforeSeq;
|
|
1385
|
+
let projected = [];
|
|
1386
|
+
while (projected.length <= args.limit) {
|
|
1387
|
+
const batch = await readConversationEventRows(executor, {
|
|
1388
|
+
beforeSeq,
|
|
1389
|
+
conversationId: args.conversationId,
|
|
1390
|
+
direction: "backward",
|
|
1391
|
+
limit: scanSize
|
|
1392
|
+
});
|
|
1393
|
+
if (batch.length === 0) break;
|
|
1394
|
+
rows.push(...batch);
|
|
1395
|
+
projected = await projectConversationEventRows(executor, {
|
|
1396
|
+
canExposePayload: args.canExposePayload,
|
|
1397
|
+
conversationId: args.conversationId,
|
|
1398
|
+
rows
|
|
1399
|
+
});
|
|
1400
|
+
if (projected.length > args.limit || batch.length < scanSize) break;
|
|
1401
|
+
beforeSeq = batch.at(-1).seq;
|
|
1402
|
+
}
|
|
1403
|
+
const events = projected.slice(-args.limit);
|
|
1638
1404
|
return {
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
"app.log.message": message,
|
|
1642
|
-
"app.plugin.name": plugin,
|
|
1643
|
-
...metadata
|
|
1644
|
-
});
|
|
1645
|
-
},
|
|
1646
|
-
warn(message, metadata) {
|
|
1647
|
-
logWarn("plugin.log.warn", {
|
|
1648
|
-
"app.log.message": message,
|
|
1649
|
-
"app.plugin.name": plugin,
|
|
1650
|
-
...metadata
|
|
1651
|
-
});
|
|
1652
|
-
},
|
|
1653
|
-
error(message, metadata) {
|
|
1654
|
-
logException(new Error(message), "plugin.log.error", {
|
|
1655
|
-
"app.plugin.name": plugin,
|
|
1656
|
-
...metadata
|
|
1657
|
-
});
|
|
1658
|
-
}
|
|
1405
|
+
events,
|
|
1406
|
+
...projected.length > events.length && events[0] ? { previousSeq: events[0].seq } : void 0
|
|
1659
1407
|
};
|
|
1660
1408
|
}
|
|
1661
1409
|
//#endregion
|
|
1662
|
-
//#region src/chat/
|
|
1663
|
-
|
|
1664
|
-
function
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
signal: runtime.signal,
|
|
1674
|
-
promptName: `${pluginName}.structured_completion`,
|
|
1675
|
-
metadata: {
|
|
1676
|
-
pluginName,
|
|
1677
|
-
pluginModelRole: "structured"
|
|
1678
|
-
}
|
|
1410
|
+
//#region src/chat/briefs/input.ts
|
|
1411
|
+
const EVENT_PAGE_SIZE = 500;
|
|
1412
|
+
async function readReportEventsThrough(executor, conversationId, throughSeq) {
|
|
1413
|
+
const pages = [];
|
|
1414
|
+
let beforeSeq = throughSeq + 1;
|
|
1415
|
+
do {
|
|
1416
|
+
const page = await readConversationEventPage(executor, {
|
|
1417
|
+
beforeSeq,
|
|
1418
|
+
canExposePayload: true,
|
|
1419
|
+
conversationId,
|
|
1420
|
+
limit: EVENT_PAGE_SIZE
|
|
1679
1421
|
});
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1422
|
+
pages.unshift(page.events);
|
|
1423
|
+
beforeSeq = page.previousSeq;
|
|
1424
|
+
} while (beforeSeq !== void 0);
|
|
1425
|
+
const bySequence = /* @__PURE__ */ new Map();
|
|
1426
|
+
for (const event of pages.flat()) bySequence.set(event.seq, event);
|
|
1427
|
+
return [...bySequence.values()].sort((left, right) => left.seq - right.seq);
|
|
1685
1428
|
}
|
|
1686
|
-
/**
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1429
|
+
/**
|
|
1430
|
+
* Build generator input from SQL through one durable event. The caller owns
|
|
1431
|
+
* the boundary, so a Turn that completes during the read cannot be covered by
|
|
1432
|
+
* a Brief that never saw it.
|
|
1433
|
+
*/
|
|
1434
|
+
async function briefInputFromSql(executor, conversationId, throughSeq) {
|
|
1435
|
+
const db = executor.db();
|
|
1436
|
+
const conversation = await createSqlStore(executor).get({ conversationId });
|
|
1437
|
+
if (!conversation) throw new Error(`Conversation ${conversationId} is unavailable`);
|
|
1438
|
+
const [events, annotations, codeChanges] = await Promise.all([
|
|
1439
|
+
readReportEventsThrough(executor, conversationId, throughSeq),
|
|
1440
|
+
listConversationAnnotations(db, conversationId),
|
|
1441
|
+
db.select({
|
|
1442
|
+
closedAt: juniorCodeChanges.closedAt,
|
|
1443
|
+
mergedAt: juniorCodeChanges.mergedAt,
|
|
1444
|
+
number: juniorCodeChanges.number,
|
|
1445
|
+
openedAt: juniorCodeChanges.openedAt,
|
|
1446
|
+
repository: juniorCodeRepositories.name,
|
|
1447
|
+
state: juniorCodeChanges.state,
|
|
1448
|
+
title: juniorCodeChanges.title,
|
|
1449
|
+
url: juniorCodeChanges.url
|
|
1450
|
+
}).from(juniorCodeChanges).innerJoin(juniorCodeRepositories, eq(juniorCodeRepositories.id, juniorCodeChanges.repositoryId)).where(and(arrayContains(juniorCodeChanges.conversationIds, [conversationId]), isNotNull(juniorCodeChanges.url)))
|
|
1451
|
+
]);
|
|
1452
|
+
const location = conversation.location;
|
|
1453
|
+
return parseBriefInput({
|
|
1454
|
+
conversationId,
|
|
1455
|
+
...conversation.title ? { title: conversation.title } : void 0,
|
|
1456
|
+
visibility: conversation.visibility ?? "private",
|
|
1457
|
+
...location ? { location: {
|
|
1458
|
+
provider: location.provider,
|
|
1459
|
+
...conversation.channelName ? { channelName: conversation.channelName } : void 0
|
|
1460
|
+
} } : void 0,
|
|
1461
|
+
entries: briefEntriesFromReportEvents(events),
|
|
1462
|
+
codeChanges: codeChanges.map((change) => ({
|
|
1463
|
+
repository: change.repository,
|
|
1464
|
+
number: change.number,
|
|
1465
|
+
...change.title ? { title: change.title } : void 0,
|
|
1466
|
+
url: change.url,
|
|
1467
|
+
state: change.state,
|
|
1468
|
+
openedAt: change.openedAt.toISOString(),
|
|
1469
|
+
...change.mergedAt ? { mergedAt: change.mergedAt.toISOString() } : void 0,
|
|
1470
|
+
...change.closedAt ? { closedAt: change.closedAt.toISOString() } : void 0
|
|
1471
|
+
})),
|
|
1472
|
+
resources: annotations.map((annotation) => ({
|
|
1473
|
+
label: annotation.label,
|
|
1474
|
+
url: annotation.url,
|
|
1475
|
+
...annotation.status ? { status: annotation.status } : void 0
|
|
1476
|
+
}))
|
|
1477
|
+
});
|
|
1699
1478
|
}
|
|
1700
1479
|
//#endregion
|
|
1701
|
-
//#region src/chat/
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1480
|
+
//#region src/chat/briefs/operational-report.ts
|
|
1481
|
+
const DAY_MS$1 = 864e5;
|
|
1482
|
+
const WINDOWS = [
|
|
1483
|
+
7,
|
|
1484
|
+
30,
|
|
1485
|
+
90
|
|
1486
|
+
];
|
|
1487
|
+
function startOfUtcDay$1(value) {
|
|
1488
|
+
const date = new Date(value);
|
|
1489
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
1490
|
+
return date;
|
|
1705
1491
|
}
|
|
1706
|
-
function
|
|
1707
|
-
|
|
1708
|
-
if (key === pluginPrefix || key.startsWith(`${pluginPrefix}:`)) return key;
|
|
1709
|
-
return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`;
|
|
1492
|
+
function formatCount(value) {
|
|
1493
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
1710
1494
|
}
|
|
1711
|
-
function
|
|
1712
|
-
|
|
1713
|
-
|
|
1495
|
+
function formatUsd(value) {
|
|
1496
|
+
const maximumFractionDigits = value > 0 && value < .01 ? 4 : 2;
|
|
1497
|
+
return new Intl.NumberFormat("en-US", {
|
|
1498
|
+
currency: "USD",
|
|
1499
|
+
maximumFractionDigits,
|
|
1500
|
+
minimumFractionDigits: 2,
|
|
1501
|
+
style: "currency"
|
|
1502
|
+
}).format(value);
|
|
1714
1503
|
}
|
|
1715
|
-
/**
|
|
1716
|
-
|
|
1717
|
-
|
|
1504
|
+
/**
|
|
1505
|
+
* Build Brief storage and model-cost diagnostics for the System page. Cost
|
|
1506
|
+
* comes from retained Conversation events, so spend on purged private
|
|
1507
|
+
* Conversations leaves the report with them; the labels say so.
|
|
1508
|
+
*/
|
|
1509
|
+
async function buildBriefsOperationalReport(args) {
|
|
1510
|
+
const newestDay = startOfUtcDay$1(args.nowMs);
|
|
1511
|
+
const windowStart = /* @__PURE__ */ new Date(newestDay.getTime() - 29 * DAY_MS$1);
|
|
1512
|
+
const windowEnd = new Date(newestDay.getTime() + DAY_MS$1);
|
|
1513
|
+
const [[counts], recentCounts] = await Promise.all([args.db.select({
|
|
1514
|
+
conversations: sql`count(distinct ${juniorConversationBriefs.conversationId})`.mapWith(Number),
|
|
1515
|
+
stored: sql`count(*)`.mapWith(Number)
|
|
1516
|
+
}).from(juniorConversationBriefs), args.db.select({ stored: sql`count(*)`.mapWith(Number) }).from(juniorConversationBriefs).where(and(gte(juniorConversationBriefs.createdAt, windowStart), lt(juniorConversationBriefs.createdAt, windowEnd)))]);
|
|
1517
|
+
const recentDays = args.briefDays.filter((day) => {
|
|
1518
|
+
const dayStart = Date.parse(`${day.date}T00:00:00.000Z`);
|
|
1519
|
+
return dayStart >= windowStart.getTime() && dayStart < windowEnd.getTime();
|
|
1520
|
+
});
|
|
1521
|
+
const recentCost = recentDays.reduce((total, day) => total + day.costUsd, 0);
|
|
1522
|
+
const recentBriefs = recentDays.reduce((total, day) => total + day.events, 0);
|
|
1718
1523
|
return {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
},
|
|
1743
|
-
async withLock(key, ttlMs, callback) {
|
|
1744
|
-
validatePluginStateKey(key);
|
|
1745
|
-
const state = getAdapter();
|
|
1746
|
-
await state.connect();
|
|
1747
|
-
const lockKey = pluginStateKey(plugin, key);
|
|
1748
|
-
const lock = await state.acquireLock(lockKey, ttlMs);
|
|
1749
|
-
if (!lock) throw new Error(`Could not acquire plugin state lock for ${key}`);
|
|
1750
|
-
try {
|
|
1751
|
-
return await callback();
|
|
1752
|
-
} finally {
|
|
1753
|
-
await state.releaseLock(lock);
|
|
1524
|
+
generatedAt: new Date(args.nowMs).toISOString(),
|
|
1525
|
+
title: "Briefs",
|
|
1526
|
+
metrics: [
|
|
1527
|
+
{
|
|
1528
|
+
label: "briefs stored",
|
|
1529
|
+
tone: counts?.stored ? "good" : "neutral",
|
|
1530
|
+
value: formatCount(counts?.stored ?? 0)
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
label: "conversations with a brief",
|
|
1534
|
+
value: formatCount(counts?.conversations ?? 0)
|
|
1535
|
+
},
|
|
1536
|
+
{
|
|
1537
|
+
label: "briefs · 30d",
|
|
1538
|
+
value: formatCount(recentCounts[0]?.stored ?? 0)
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
label: "cost · 30d · retained",
|
|
1542
|
+
value: formatUsd(recentCost)
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
label: "average cost per brief · 30d · retained",
|
|
1546
|
+
value: formatUsd(recentBriefs === 0 ? 0 : recentCost / recentBriefs)
|
|
1754
1547
|
}
|
|
1755
|
-
|
|
1548
|
+
],
|
|
1549
|
+
widgets: [{
|
|
1550
|
+
categories: args.briefDays.map((day) => ({
|
|
1551
|
+
id: day.date,
|
|
1552
|
+
label: day.date,
|
|
1553
|
+
values: {
|
|
1554
|
+
briefs: day.events,
|
|
1555
|
+
costUsd: day.costUsd
|
|
1556
|
+
}
|
|
1557
|
+
})),
|
|
1558
|
+
description: "Stored Brief versions and estimated model cost",
|
|
1559
|
+
id: "briefs-created",
|
|
1560
|
+
series: [{
|
|
1561
|
+
key: "briefs",
|
|
1562
|
+
label: "Briefs"
|
|
1563
|
+
}, {
|
|
1564
|
+
format: "usd",
|
|
1565
|
+
key: "costUsd",
|
|
1566
|
+
label: "Cost"
|
|
1567
|
+
}],
|
|
1568
|
+
timeRangeDays: [...WINDOWS],
|
|
1569
|
+
title: "Briefs created",
|
|
1570
|
+
type: "bar_chart"
|
|
1571
|
+
}]
|
|
1756
1572
|
};
|
|
1757
1573
|
}
|
|
1758
1574
|
//#endregion
|
|
1759
|
-
//#region src/chat/
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1575
|
+
//#region src/chat/briefs/store.ts
|
|
1576
|
+
function briefVersionFromRow(row) {
|
|
1577
|
+
return {
|
|
1578
|
+
conversationId: row.conversationId,
|
|
1579
|
+
version: row.version,
|
|
1580
|
+
turnId: row.turnId,
|
|
1581
|
+
throughSeq: row.throughSeq,
|
|
1582
|
+
content: conversationBriefSchema.parse(row.content),
|
|
1583
|
+
searchText: row.searchText,
|
|
1584
|
+
modelId: row.modelId,
|
|
1585
|
+
...row.costUsd !== null ? { costUsd: row.costUsd } : void 0,
|
|
1586
|
+
createdAt: row.createdAt
|
|
1587
|
+
};
|
|
1768
1588
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
return
|
|
1589
|
+
/** Append the next Brief version, or return the row for an existing Turn. */
|
|
1590
|
+
async function appendConversationBrief(db, input) {
|
|
1591
|
+
return await db.transaction(async (tx) => {
|
|
1592
|
+
const conversation = (await tx.select({
|
|
1593
|
+
conversationId: juniorConversations.conversationId,
|
|
1594
|
+
transcriptPurgedAt: juniorConversations.transcriptPurgedAt
|
|
1595
|
+
}).from(juniorConversations).where(eq(juniorConversations.conversationId, input.conversationId)).for("update"))[0];
|
|
1596
|
+
if (!conversation) throw new Error(`Conversation ${input.conversationId} is unavailable`);
|
|
1597
|
+
if (conversation.transcriptPurgedAt) {
|
|
1598
|
+
if ((await resolveRootVisibility({ db: () => tx }, input.conversationId)).visibility !== "public") throw new Error(`Cannot append a Brief to purged non-public Conversation ${input.conversationId}`);
|
|
1599
|
+
}
|
|
1600
|
+
const existing = await tx.select().from(juniorConversationBriefs).where(and(eq(juniorConversationBriefs.conversationId, input.conversationId), eq(juniorConversationBriefs.turnId, input.turnId))).limit(1);
|
|
1601
|
+
if (existing[0]) return {
|
|
1602
|
+
inserted: false,
|
|
1603
|
+
value: briefVersionFromRow(existing[0])
|
|
1604
|
+
};
|
|
1605
|
+
const version = ((await tx.select({ version: max(juniorConversationBriefs.version) }).from(juniorConversationBriefs).where(eq(juniorConversationBriefs.conversationId, input.conversationId)))[0]?.version ?? 0) + 1;
|
|
1606
|
+
const row = (await tx.insert(juniorConversationBriefs).values({
|
|
1607
|
+
conversationId: input.conversationId,
|
|
1608
|
+
version,
|
|
1609
|
+
turnId: input.turnId,
|
|
1610
|
+
throughSeq: input.throughSeq,
|
|
1611
|
+
content: conversationBriefSchema.parse(input.content),
|
|
1612
|
+
searchText: input.searchText,
|
|
1613
|
+
modelId: input.modelId,
|
|
1614
|
+
costUsd: input.costUsd ?? null
|
|
1615
|
+
}).onConflictDoNothing({ target: [juniorConversationBriefs.conversationId, juniorConversationBriefs.turnId] }).returning())[0];
|
|
1616
|
+
if (!row) throw new Error("Brief insert conflicted after the Turn check");
|
|
1617
|
+
return {
|
|
1618
|
+
inserted: true,
|
|
1619
|
+
value: briefVersionFromRow(row)
|
|
1620
|
+
};
|
|
1621
|
+
});
|
|
1772
1622
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
if (vercelProd) return stripTrailingSlashes(withHttps(vercelProd));
|
|
1778
|
-
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
1779
|
-
if (vercelUrl) return stripTrailingSlashes(withHttps(vercelUrl));
|
|
1780
|
-
return "http://localhost:3000";
|
|
1623
|
+
/** Read the Brief version produced for one completed Turn. */
|
|
1624
|
+
async function readConversationBriefForTurn(db, conversationId, turnId) {
|
|
1625
|
+
const rows = await db.select().from(juniorConversationBriefs).where(and(eq(juniorConversationBriefs.conversationId, conversationId), eq(juniorConversationBriefs.turnId, turnId))).limit(1);
|
|
1626
|
+
return rows[0] ? briefVersionFromRow(rows[0]) : void 0;
|
|
1781
1627
|
}
|
|
1782
|
-
/**
|
|
1783
|
-
function
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
return previous;
|
|
1628
|
+
/** Read the latest Brief version for one Conversation. */
|
|
1629
|
+
async function readLatestConversationBrief(db, conversationId) {
|
|
1630
|
+
const rows = await db.select().from(juniorConversationBriefs).where(eq(juniorConversationBriefs.conversationId, conversationId)).orderBy(desc(juniorConversationBriefs.version)).limit(1);
|
|
1631
|
+
return rows[0] ? briefVersionFromRow(rows[0]) : void 0;
|
|
1787
1632
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
return
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/chat/briefs/task.ts
|
|
1635
|
+
const BRIEF_LOCK_TTL_MS = 6e5;
|
|
1636
|
+
function hasUserInstruction(run) {
|
|
1637
|
+
return run.transcript.some((entry) => entry.type === "message" && entry.role === "user" && entry.provenance?.authority === "instruction");
|
|
1793
1638
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1639
|
+
function logSkip(context, conversationId, reason) {
|
|
1640
|
+
context.log.info("Brief update skipped", {
|
|
1641
|
+
conversationId,
|
|
1642
|
+
reason
|
|
1643
|
+
});
|
|
1797
1644
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1645
|
+
async function readTurnCompletedSeq(db, conversationId, turnId) {
|
|
1646
|
+
const seq = (await db.select({ seq: juniorConversationEvents.seq }).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, conversationId), eq(juniorConversationEvents.type, "turn_completed"), sql`${juniorConversationEvents.payload}->>'turnId' = ${turnId}`)).orderBy(desc(juniorConversationEvents.seq)).limit(1))[0]?.seq;
|
|
1647
|
+
if (seq === void 0) throw new Error(`Completed Turn ${turnId} has no terminal event in Conversation ${conversationId}`);
|
|
1648
|
+
return seq;
|
|
1801
1649
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1650
|
+
async function emitBriefUpdated(context, stored) {
|
|
1651
|
+
await context.events.emit(briefUpdatedEvent({
|
|
1652
|
+
version: stored.version,
|
|
1653
|
+
modelId: stored.modelId,
|
|
1654
|
+
...stored.costUsd !== void 0 ? { costUsd: stored.costUsd } : void 0,
|
|
1655
|
+
decisions: stored.content.decisions.length,
|
|
1656
|
+
openDecisions: stored.content.openDecisions.length,
|
|
1657
|
+
links: stored.content.links.length
|
|
1658
|
+
}));
|
|
1808
1659
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
const DUE_KEY = "junior:watches:due";
|
|
1816
|
-
const CLAIM_MS = 12e4;
|
|
1817
|
-
const BATCH_SIZE = 25;
|
|
1818
|
-
const entriesSchema = z.array(z.object({
|
|
1819
|
-
id: z.string(),
|
|
1820
|
-
atMs: z.number()
|
|
1821
|
-
}));
|
|
1822
|
-
/** Keep timer index operations atomic in Redis and the local memory adapter. */
|
|
1823
|
-
async function updateTimerIndex(state, operation) {
|
|
1824
|
-
const redis = await getDefaultRedisStateAdapterFor(state);
|
|
1825
|
-
if (redis) {
|
|
1826
|
-
const prefix = getChatConfig().state.keyPrefix;
|
|
1827
|
-
const key = prefix ? `${prefix}:${DUE_KEY}` : DUE_KEY;
|
|
1828
|
-
const client = redis.getClient();
|
|
1829
|
-
if (operation.kind === "add") {
|
|
1830
|
-
await client.sendCommand([
|
|
1831
|
-
"ZADD",
|
|
1832
|
-
key,
|
|
1833
|
-
String(operation.atMs),
|
|
1834
|
-
operation.id
|
|
1835
|
-
]);
|
|
1836
|
-
return [];
|
|
1837
|
-
}
|
|
1838
|
-
if (operation.kind === "remove") {
|
|
1839
|
-
await client.sendCommand([
|
|
1840
|
-
"ZREM",
|
|
1841
|
-
key,
|
|
1842
|
-
operation.id
|
|
1843
|
-
]);
|
|
1844
|
-
return [];
|
|
1845
|
-
}
|
|
1846
|
-
return await client.sendCommand([
|
|
1847
|
-
"EVAL",
|
|
1848
|
-
`local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[3])
|
|
1849
|
-
for _, id in ipairs(ids) do
|
|
1850
|
-
redis.call('ZADD', KEYS[1], ARGV[2], id)
|
|
1851
|
-
end
|
|
1852
|
-
return ids`,
|
|
1853
|
-
"1",
|
|
1854
|
-
key,
|
|
1855
|
-
String(operation.nowMs),
|
|
1856
|
-
String(operation.nowMs + CLAIM_MS),
|
|
1857
|
-
String(BATCH_SIZE)
|
|
1858
|
-
]);
|
|
1660
|
+
/** Generate and store the next Brief after one completed Turn. */
|
|
1661
|
+
async function updateConversationBrief(context) {
|
|
1662
|
+
const run = await context.run.load();
|
|
1663
|
+
if (run.source.kind !== "slack" && run.source.kind !== "web" && run.source.kind !== "local") {
|
|
1664
|
+
logSkip(context, run.conversationId, "unsupported_source");
|
|
1665
|
+
return;
|
|
1859
1666
|
}
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
atMs: operation.nowMs + CLAIM_MS
|
|
1871
|
-
} : entry);
|
|
1872
|
-
} else {
|
|
1873
|
-
entries = entries.filter((entry) => entry.id !== operation.id);
|
|
1874
|
-
if (operation.kind === "add") entries.push({
|
|
1875
|
-
id: operation.id,
|
|
1876
|
-
atMs: operation.atMs
|
|
1877
|
-
});
|
|
1667
|
+
const executor = getSqlExecutor();
|
|
1668
|
+
const conversation = await createSqlStore(executor).get({ conversationId: run.conversationId });
|
|
1669
|
+
if (conversation?.parentConversationId) {
|
|
1670
|
+
logSkip(context, run.conversationId, "child_conversation");
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (conversation?.transcriptPurgedAtMs !== void 0) {
|
|
1674
|
+
if ((await resolveRootVisibility(executor, run.conversationId)).visibility !== "public") {
|
|
1675
|
+
logSkip(context, run.conversationId, "purged_private");
|
|
1676
|
+
return;
|
|
1878
1677
|
}
|
|
1879
|
-
await state.set(DUE_KEY, entries);
|
|
1880
|
-
return ids;
|
|
1881
|
-
} finally {
|
|
1882
|
-
await state.releaseLock(lock);
|
|
1883
1678
|
}
|
|
1679
|
+
if (!hasUserInstruction(run)) {
|
|
1680
|
+
logSkip(context, run.conversationId, "no_user_instruction");
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
await context.state.withLock(`brief:${run.conversationId}`, BRIEF_LOCK_TTL_MS, async () => {
|
|
1684
|
+
const db = context.db;
|
|
1685
|
+
const existing = await readConversationBriefForTurn(db, run.conversationId, run.runId);
|
|
1686
|
+
if (existing) {
|
|
1687
|
+
await emitBriefUpdated(context, existing);
|
|
1688
|
+
logSkip(context, run.conversationId, "turn_already_stored");
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const terminalSeq = await readTurnCompletedSeq(db, run.conversationId, run.runId);
|
|
1692
|
+
const previous = await readLatestConversationBrief(db, run.conversationId);
|
|
1693
|
+
if (previous && previous.throughSeq >= terminalSeq) {
|
|
1694
|
+
logSkip(context, run.conversationId, "turn_already_covered");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const input = await briefInputFromSql(executor, run.conversationId, terminalSeq);
|
|
1698
|
+
const modelId = defaultModelId(botConfig);
|
|
1699
|
+
const generation = await generateBrief({
|
|
1700
|
+
completeObject: (request) => completeObject({
|
|
1701
|
+
...request,
|
|
1702
|
+
modelId,
|
|
1703
|
+
promptName: "junior.brief_update"
|
|
1704
|
+
}),
|
|
1705
|
+
input,
|
|
1706
|
+
previous: previous?.content,
|
|
1707
|
+
prompt: BRIEF_PROMPT,
|
|
1708
|
+
throughIndex: terminalSeq
|
|
1709
|
+
});
|
|
1710
|
+
const stored = await appendConversationBrief(db, {
|
|
1711
|
+
conversationId: run.conversationId,
|
|
1712
|
+
turnId: run.runId,
|
|
1713
|
+
throughSeq: terminalSeq,
|
|
1714
|
+
content: generation.brief,
|
|
1715
|
+
searchText: generation.searchText,
|
|
1716
|
+
modelId,
|
|
1717
|
+
...generation.costUsd !== void 0 ? { costUsd: generation.costUsd } : void 0
|
|
1718
|
+
});
|
|
1719
|
+
await emitBriefUpdated(context, stored.value);
|
|
1720
|
+
if (!stored.inserted) logSkip(context, run.conversationId, "turn_already_stored");
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
/** Core task registration kept outside the installed plugin catalog. */
|
|
1724
|
+
const briefsTaskRegistration = {
|
|
1725
|
+
manifest: {
|
|
1726
|
+
name: "briefs",
|
|
1727
|
+
displayName: "Briefs",
|
|
1728
|
+
description: "Durable Conversation Brief generation"
|
|
1729
|
+
},
|
|
1730
|
+
conversationEvents: [briefUpdatedEvent],
|
|
1731
|
+
hooks: { async operationalReport(context) {
|
|
1732
|
+
return await buildBriefsOperationalReport({
|
|
1733
|
+
briefDays: await context.eventStats.costsByDay({
|
|
1734
|
+
days: 90,
|
|
1735
|
+
eventName: "brief_updated"
|
|
1736
|
+
}),
|
|
1737
|
+
db: context.db,
|
|
1738
|
+
nowMs: context.nowMs
|
|
1739
|
+
});
|
|
1740
|
+
} },
|
|
1741
|
+
tasks: { updateBrief: { async run(context) {
|
|
1742
|
+
await updateConversationBrief(context);
|
|
1743
|
+
} } }
|
|
1744
|
+
};
|
|
1745
|
+
//#endregion
|
|
1746
|
+
//#region src/chat/plugins/conversation-events.ts
|
|
1747
|
+
function registeredDefinition(plugin, value) {
|
|
1748
|
+
return plugin.conversationEvents?.find((definition) => definition === value.definition);
|
|
1749
|
+
}
|
|
1750
|
+
/** Keep operation idempotency stable when a registered event schema advances. */
|
|
1751
|
+
function eventIdentityVersion(plugin, definition) {
|
|
1752
|
+
let version = definition.version;
|
|
1753
|
+
for (const candidate of plugin.conversationEvents ?? []) if (candidate.eventName === definition.eventName) version = Math.min(version, candidate.version);
|
|
1754
|
+
return version;
|
|
1755
|
+
}
|
|
1756
|
+
/** Create a conversation-bound writer for one plugin operation. */
|
|
1757
|
+
function createPluginConversationEvents(args) {
|
|
1758
|
+
return { async emit(value) {
|
|
1759
|
+
const definition = registeredDefinition(args.plugin, value);
|
|
1760
|
+
if (!definition) throw new Error(`Plugin "${args.plugin.manifest.name}" cannot emit an unregistered conversation event.`);
|
|
1761
|
+
const content = definition.parse(value.data);
|
|
1762
|
+
const identityVersion = eventIdentityVersion(args.plugin, definition);
|
|
1763
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1764
|
+
createdAtMs: Date.now(),
|
|
1765
|
+
idempotencyKey: `plugin:${args.plugin.manifest.name}:operation:${args.operationId}:event:${definition.eventName}@${identityVersion}`,
|
|
1766
|
+
data: {
|
|
1767
|
+
type: "structured_event",
|
|
1768
|
+
namespace: args.plugin.manifest.name,
|
|
1769
|
+
name: definition.eventName,
|
|
1770
|
+
version: definition.version,
|
|
1771
|
+
turnId: args.turnId,
|
|
1772
|
+
content
|
|
1773
|
+
}
|
|
1774
|
+
}], { activity: "preserve" });
|
|
1775
|
+
} };
|
|
1776
|
+
}
|
|
1777
|
+
/** Render a stored plugin event through its currently registered definition. */
|
|
1778
|
+
function renderPluginConversationEvent(args) {
|
|
1779
|
+
const definition = [briefsTaskRegistration, ...getPlugins()].find((candidate) => candidate.manifest.name === args.namespace)?.conversationEvents?.find((candidate) => candidate.eventName === args.name && candidate.version === args.version);
|
|
1780
|
+
if (!definition) return void 0;
|
|
1781
|
+
return definition.renderEvent(definition.parse(args.content));
|
|
1884
1782
|
}
|
|
1885
1783
|
//#endregion
|
|
1886
|
-
//#region src/
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
return workspaceTeamIdStorage.run(teamId, fn);
|
|
1784
|
+
//#region src/api/conversations/membership.ts
|
|
1785
|
+
/** True when the viewer is a materialized participant on this root conversation. */
|
|
1786
|
+
function conversationHasParticipantUser(userId) {
|
|
1787
|
+
const participant = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorConversationParticipants.userId, userId)));
|
|
1788
|
+
return exists(participant);
|
|
1892
1789
|
}
|
|
1893
|
-
/**
|
|
1894
|
-
|
|
1895
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* True when a verified primary-email user participates on this root
|
|
1792
|
+
* conversation. Used only for the actorEmail feed fallback.
|
|
1793
|
+
*/
|
|
1794
|
+
function conversationHasParticipantEmail(email) {
|
|
1795
|
+
const participant = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorUsers.primaryEmailNormalized, email)));
|
|
1796
|
+
return exists(participant);
|
|
1896
1797
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
apiError;
|
|
1902
|
-
needed;
|
|
1903
|
-
provided;
|
|
1904
|
-
statusCode;
|
|
1905
|
-
requestId;
|
|
1906
|
-
errorData;
|
|
1907
|
-
retryAfterSeconds;
|
|
1908
|
-
detail;
|
|
1909
|
-
detailLine;
|
|
1910
|
-
detailRule;
|
|
1911
|
-
constructor(message, code, options = {}) {
|
|
1912
|
-
super(message);
|
|
1913
|
-
this.name = "SlackActionError";
|
|
1914
|
-
this.code = code;
|
|
1915
|
-
this.apiError = options.apiError;
|
|
1916
|
-
this.needed = options.needed;
|
|
1917
|
-
this.provided = options.provided;
|
|
1918
|
-
this.statusCode = options.statusCode;
|
|
1919
|
-
this.requestId = options.requestId;
|
|
1920
|
-
this.errorData = options.errorData;
|
|
1921
|
-
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
1922
|
-
this.detail = options.detail;
|
|
1923
|
-
this.detailLine = options.detailLine;
|
|
1924
|
-
this.detailRule = options.detailRule;
|
|
1925
|
-
}
|
|
1926
|
-
};
|
|
1927
|
-
function serializeSlackErrorData(data) {
|
|
1928
|
-
if (!data || typeof data !== "object") return;
|
|
1929
|
-
const filtered = Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error"));
|
|
1930
|
-
if (Object.keys(filtered).length === 0) return;
|
|
1931
|
-
try {
|
|
1932
|
-
const serialized = JSON.stringify(filtered);
|
|
1933
|
-
return serialized.length <= 600 ? serialized : `${serialized.slice(0, 597)}...`;
|
|
1934
|
-
} catch {
|
|
1935
|
-
return;
|
|
1936
|
-
}
|
|
1798
|
+
/** True when one user's personal feed has archived this root after the cutoff. */
|
|
1799
|
+
function conversationArchivedForUser(userId, archivedAfter) {
|
|
1800
|
+
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorConversationParticipants.userId, userId), isNotNull(juniorConversationParticipants.archivedAt), archivedAfter ? gte(juniorConversationParticipants.archivedAt, archivedAfter) : void 0));
|
|
1801
|
+
return exists(archive);
|
|
1937
1802
|
}
|
|
1938
|
-
/**
|
|
1939
|
-
function
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
const entries = headers;
|
|
1943
|
-
for (const [entryKey, value] of Object.entries(entries)) {
|
|
1944
|
-
if (entryKey.toLowerCase() !== key) continue;
|
|
1945
|
-
if (typeof value === "string") return value;
|
|
1946
|
-
if (Array.isArray(value)) {
|
|
1947
|
-
const first = value.find((entry) => typeof entry === "string");
|
|
1948
|
-
return typeof first === "string" ? first : void 0;
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1803
|
+
/** True when one user's personal feed has not archived this root. */
|
|
1804
|
+
function conversationNotArchivedForUser(userId) {
|
|
1805
|
+
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorConversationParticipants.userId, userId), isNotNull(juniorConversationParticipants.archivedAt)));
|
|
1806
|
+
return notExists(archive);
|
|
1951
1807
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
const
|
|
1955
|
-
|
|
1956
|
-
const parsed = { detail: trimmed };
|
|
1957
|
-
const lineMatch = trimmed.match(/line\s+(\d+):/i);
|
|
1958
|
-
if (lineMatch) {
|
|
1959
|
-
const line = Number.parseInt(lineMatch[1] ?? "", 10);
|
|
1960
|
-
if (Number.isFinite(line)) parsed.detailLine = line;
|
|
1961
|
-
}
|
|
1962
|
-
if (/unsupported heading depth/i.test(trimmed)) parsed.detailRule = "unsupported_heading_depth";
|
|
1963
|
-
return parsed;
|
|
1808
|
+
/** True when one primary-email user's feed archived this root after the cutoff. */
|
|
1809
|
+
function conversationArchivedForEmail(email, archivedAfter) {
|
|
1810
|
+
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorUsers.primaryEmailNormalized, email), isNotNull(juniorConversationParticipants.archivedAt), archivedAfter ? gte(juniorConversationParticipants.archivedAt, archivedAfter) : void 0));
|
|
1811
|
+
return exists(archive);
|
|
1964
1812
|
}
|
|
1965
|
-
/**
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
*/
|
|
1970
|
-
const installationTokenStorage = new AsyncLocalStorage();
|
|
1971
|
-
const clientsByToken = /* @__PURE__ */ new Map();
|
|
1972
|
-
/**
|
|
1973
|
-
* Bind a workspace installation token for all Slack Web API calls inside
|
|
1974
|
-
* `fn`, so multi-workspace outbound writes use the destination workspace's
|
|
1975
|
-
* credentials rather than the env fallback token.
|
|
1976
|
-
*/
|
|
1977
|
-
function runWithSlackInstallationToken(token, fn) {
|
|
1978
|
-
const trimmed = token.trim();
|
|
1979
|
-
if (!trimmed) throw new SlackActionError("Slack installation token binding requires a non-empty token", "missing_token");
|
|
1980
|
-
return installationTokenStorage.run({ token: trimmed }, fn);
|
|
1813
|
+
/** True when one primary-email user's personal feed has not archived this root. */
|
|
1814
|
+
function conversationNotArchivedForEmail(email) {
|
|
1815
|
+
const archive = getDb().select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorConversationParticipants.rootConversationId, juniorConversations.conversationId), eq(juniorUsers.primaryEmailNormalized, email), isNotNull(juniorConversationParticipants.archivedAt)));
|
|
1816
|
+
return notExists(archive);
|
|
1981
1817
|
}
|
|
1982
|
-
/**
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
*/
|
|
1987
|
-
function resolveSlackToken() {
|
|
1988
|
-
const ambientToken = installationTokenStorage.getStore()?.token;
|
|
1989
|
-
if (ambientToken) return ambientToken;
|
|
1990
|
-
const envToken = getSlackBotToken();
|
|
1991
|
-
if (envToken) return envToken;
|
|
1992
|
-
const teamId = getWorkspaceTeamId();
|
|
1993
|
-
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");
|
|
1994
|
-
throw new SlackActionError("SLACK_BOT_TOKEN (or SLACK_BOT_USER_TOKEN) is required for Slack Web API actions in this service", "missing_token");
|
|
1818
|
+
/** Root-actor ownership or a materialized participant row for the same person. */
|
|
1819
|
+
function viewerConversationMembership(args) {
|
|
1820
|
+
if (args.actorMatch && args.participantMatch) return or(args.actorMatch, args.participantMatch);
|
|
1821
|
+
return args.actorMatch ?? args.participantMatch;
|
|
1995
1822
|
}
|
|
1996
|
-
/**
|
|
1997
|
-
function
|
|
1998
|
-
return
|
|
1823
|
+
/** Read personal archive timestamps for a bounded set of conversation roots. */
|
|
1824
|
+
async function conversationArchiveTimes(db, conversationIds, subject) {
|
|
1825
|
+
if (!subject || conversationIds.length === 0) return /* @__PURE__ */ new Map();
|
|
1826
|
+
const query = db.select({
|
|
1827
|
+
archivedAt: juniorConversationParticipants.archivedAt,
|
|
1828
|
+
rootConversationId: juniorConversationParticipants.rootConversationId
|
|
1829
|
+
}).from(juniorConversationParticipants);
|
|
1830
|
+
const rows = "userId" in subject ? await query.where(and(eq(juniorConversationParticipants.userId, subject.userId), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds]), isNotNull(juniorConversationParticipants.archivedAt))) : await query.innerJoin(juniorUsers, eq(juniorUsers.id, juniorConversationParticipants.userId)).where(and(eq(juniorUsers.primaryEmailNormalized, subject.email), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds]), isNotNull(juniorConversationParticipants.archivedAt)));
|
|
1831
|
+
return new Map(rows.flatMap((row) => row.archivedAt ? [[row.rootConversationId, row.archivedAt.getTime()]] : []));
|
|
1999
1832
|
}
|
|
2000
|
-
/**
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
* are disabled here.
|
|
2006
|
-
*/
|
|
2007
|
-
function getClient() {
|
|
2008
|
-
const token = resolveSlackToken();
|
|
2009
|
-
let cached = clientsByToken.get(token);
|
|
2010
|
-
if (!cached) {
|
|
2011
|
-
cached = new WebClient(token, {
|
|
2012
|
-
retryConfig: { retries: 0 },
|
|
2013
|
-
rejectRateLimitedCalls: true
|
|
2014
|
-
});
|
|
2015
|
-
clientsByToken.set(token, cached);
|
|
2016
|
-
}
|
|
2017
|
-
return cached;
|
|
1833
|
+
/** Find which of the supplied roots include the linked viewer as a participant. */
|
|
1834
|
+
async function conversationIdsWithParticipantUser(db, conversationIds, userId) {
|
|
1835
|
+
if (conversationIds.length === 0) return /* @__PURE__ */ new Set();
|
|
1836
|
+
const rows = await db.select({ rootConversationId: juniorConversationParticipants.rootConversationId }).from(juniorConversationParticipants).where(and(eq(juniorConversationParticipants.userId, userId), inArray(juniorConversationParticipants.rootConversationId, [...conversationIds])));
|
|
1837
|
+
return new Set(rows.map((row) => row.rootConversationId));
|
|
2018
1838
|
}
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/api/conversations/access.ts
|
|
1841
|
+
const rootConversation = alias(juniorConversations, "access_root_conversation");
|
|
1842
|
+
const rootDestination = alias(juniorDestinations, "access_root_destination");
|
|
1843
|
+
const rootIdentity = alias(juniorIdentities, "access_root_identity");
|
|
1844
|
+
/** Resolve viewer access for a bounded set of persisted conversations. */
|
|
1845
|
+
async function readConversationAccessFromSql(db, conversationIds, viewer) {
|
|
1846
|
+
if (conversationIds.length === 0) return /* @__PURE__ */ new Map();
|
|
1847
|
+
const normalizedViewerEmail = viewer?.email.trim().toLowerCase() || void 0;
|
|
1848
|
+
const rows = await db.select({
|
|
1849
|
+
conversationId: juniorConversations.conversationId,
|
|
1850
|
+
parentConversationId: juniorConversations.parentConversationId,
|
|
1851
|
+
storedRootConversationId: juniorConversations.rootConversationId,
|
|
1852
|
+
rootConversationId: rootConversation.conversationId,
|
|
1853
|
+
rootParentConversationId: rootConversation.parentConversationId,
|
|
1854
|
+
rootRootConversationId: rootConversation.rootConversationId,
|
|
1855
|
+
visibility: rootDestination.visibility,
|
|
1856
|
+
rootEmailNormalized: rootIdentity.emailNormalized,
|
|
1857
|
+
rootEmailVerified: rootIdentity.emailVerified,
|
|
1858
|
+
rootUserId: rootIdentity.userId
|
|
1859
|
+
}).from(juniorConversations).leftJoin(rootConversation, eq(rootConversation.conversationId, juniorConversations.rootConversationId)).leftJoin(rootDestination, eq(rootDestination.id, rootConversation.destinationId)).leftJoin(rootIdentity, eq(rootIdentity.id, rootConversation.actorIdentityId)).where(inArray(juniorConversations.conversationId, [...conversationIds]));
|
|
1860
|
+
const membershipConversationIds = /* @__PURE__ */ new Set();
|
|
1861
|
+
for (const row of rows) {
|
|
1862
|
+
membershipConversationIds.add(row.conversationId);
|
|
1863
|
+
if (row.storedRootConversationId) membershipConversationIds.add(row.storedRootConversationId);
|
|
1864
|
+
}
|
|
1865
|
+
const participantRootIds = viewer !== void 0 ? await conversationIdsWithParticipantUser(db, [...membershipConversationIds], viewer.id) : /* @__PURE__ */ new Set();
|
|
1866
|
+
return new Map(rows.map((row) => {
|
|
1867
|
+
const validRootConversationId = row.rootConversationId !== null && row.rootConversationId === row.storedRootConversationId && row.rootRootConversationId === row.rootConversationId && row.rootParentConversationId === null && (row.parentConversationId !== null || row.storedRootConversationId === row.conversationId) ? row.rootConversationId ?? void 0 : void 0;
|
|
1868
|
+
const visibility = validRootConversationId === void 0 ? null : row.visibility;
|
|
1869
|
+
const isRootActor = validRootConversationId !== void 0 && viewer !== void 0 && (row.rootUserId === viewer.id || normalizedViewerEmail !== void 0 && row.rootEmailVerified === true && row.rootEmailNormalized === normalizedViewerEmail);
|
|
1870
|
+
const isMaterializedParticipant = participantRootIds.has(row.conversationId) || validRootConversationId !== void 0 && participantRootIds.has(validRootConversationId);
|
|
1871
|
+
const isParticipant = isRootActor || isMaterializedParticipant;
|
|
1872
|
+
const canViewPrivateContent = isParticipant || validRootConversationId !== void 0 && canExposeConversationPayload({ visibility: visibility ?? void 0 });
|
|
1873
|
+
return [row.conversationId, {
|
|
1874
|
+
canViewPrivateContent,
|
|
1875
|
+
isParticipant,
|
|
1876
|
+
visibility
|
|
1877
|
+
}];
|
|
1878
|
+
}));
|
|
2052
1879
|
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
"
|
|
2057
|
-
|
|
2058
|
-
"ENOTFOUND",
|
|
2059
|
-
"EAI_AGAIN"
|
|
2060
|
-
]);
|
|
2061
|
-
const TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2062
|
-
"ETIMEDOUT",
|
|
2063
|
-
"ECONNABORTED",
|
|
2064
|
-
"ESOCKETTIMEDOUT"
|
|
2065
|
-
]);
|
|
2066
|
-
function findNetworkErrorCode(error, depth = 0) {
|
|
2067
|
-
if (!error || typeof error !== "object" || depth > 4) return;
|
|
2068
|
-
const candidate = error;
|
|
2069
|
-
if (typeof candidate.code === "string" && (CONNECTION_ERROR_CODES.has(candidate.code) || TIMEOUT_ERROR_CODES.has(candidate.code))) return candidate.code;
|
|
2070
|
-
return findNetworkErrorCode(candidate.original, depth + 1) ?? findNetworkErrorCode(candidate.cause, depth + 1);
|
|
1880
|
+
//#endregion
|
|
1881
|
+
//#region src/chat/plugins/conversation-event-reader.ts
|
|
1882
|
+
function registeredEventName$1(plugin, eventName) {
|
|
1883
|
+
if (!plugin.conversationEvents?.some((definition) => definition.eventName === eventName)) throw new TypeError(`Plugin "${plugin.manifest.name}" did not register event "${eventName}"`);
|
|
1884
|
+
return eventName;
|
|
2071
1885
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
1886
|
+
/** Create Conversation event reads bound to one plugin namespace. */
|
|
1887
|
+
function createPluginConversationEventReader(plugin) {
|
|
1888
|
+
return { async list(input) {
|
|
1889
|
+
const eventName = registeredEventName$1(plugin, input.eventName);
|
|
1890
|
+
const db = getDb();
|
|
1891
|
+
if (!(await readConversationAccessFromSql(db, [input.conversationId], input.viewer)).get(input.conversationId)?.canViewPrivateContent) return void 0;
|
|
1892
|
+
return (await db.select({
|
|
1893
|
+
content: sql`${juniorConversationEvents.payload}->'content'`,
|
|
1894
|
+
createdAt: juniorConversationEvents.createdAt,
|
|
1895
|
+
version: sql`(${juniorConversationEvents.payload}->>'version')::integer`
|
|
1896
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.conversationId, input.conversationId), eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`)).orderBy(desc(juniorConversationEvents.seq))).map((row) => ({
|
|
1897
|
+
content: row.content,
|
|
1898
|
+
createdAt: row.createdAt.toISOString(),
|
|
1899
|
+
version: row.version
|
|
1900
|
+
}));
|
|
1901
|
+
} };
|
|
2074
1902
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
if (hasSocketHangUpMessage(raw)) return "connection";
|
|
2088
|
-
return "none";
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/chat/plugins/conversation-event-stats.ts
|
|
1905
|
+
const DAY_MS = 864e5;
|
|
1906
|
+
const costRowSchema = z.object({
|
|
1907
|
+
costUsd: z.number().finite().nonnegative(),
|
|
1908
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/),
|
|
1909
|
+
events: z.number().int().nonnegative()
|
|
1910
|
+
}).strict();
|
|
1911
|
+
function startOfUtcDay(value) {
|
|
1912
|
+
const date = new Date(value);
|
|
1913
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
1914
|
+
return date;
|
|
2089
1915
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
"url.path": `/api/${action}`,
|
|
2127
|
-
"app.slack.method": action,
|
|
2128
|
-
"app.retry.max_attempts": maxAttempts,
|
|
2129
|
-
...attemptNumber > 1 ? { "http.request.resend_count": attemptNumber - 1 } : void 0,
|
|
2130
|
-
...context.attributes ?? {},
|
|
2131
|
-
...context.spanAttributes ?? {}
|
|
1916
|
+
function registeredEventName(plugin, eventName) {
|
|
1917
|
+
if (!plugin.conversationEvents?.some((definition) => definition.eventName === eventName)) throw new TypeError(`Plugin "${plugin.manifest.name}" did not register event "${eventName}"`);
|
|
1918
|
+
return eventName;
|
|
1919
|
+
}
|
|
1920
|
+
/** Create aggregate event-cost reads bound to one plugin's registered namespace. */
|
|
1921
|
+
function createPluginConversationEventStats(plugin, now = Date.now) {
|
|
1922
|
+
return {
|
|
1923
|
+
async costsByHour(input) {
|
|
1924
|
+
const eventName = registeredEventName(plugin, input.eventName);
|
|
1925
|
+
const hourCount = input.hours ?? 24;
|
|
1926
|
+
const end = new Date(now());
|
|
1927
|
+
end.setUTCMinutes(0, 0, 0);
|
|
1928
|
+
const start = /* @__PURE__ */ new Date(end.getTime() - (hourCount - 1) * 60 * 60 * 1e3);
|
|
1929
|
+
const endExclusive = new Date(end.getTime() + 36e5);
|
|
1930
|
+
const date = sql`TO_CHAR(
|
|
1931
|
+
${juniorConversationEvents.createdAt} AT TIME ZONE 'UTC',
|
|
1932
|
+
'YYYY-MM-DD"T"HH24'
|
|
1933
|
+
)`;
|
|
1934
|
+
const cost = sql`CASE
|
|
1935
|
+
WHEN jsonb_typeof(${juniorConversationEvents.payload}->'content'->'costUsd') = 'number'
|
|
1936
|
+
THEN (${juniorConversationEvents.payload}->'content'->>'costUsd')::double precision
|
|
1937
|
+
ELSE 0
|
|
1938
|
+
END`;
|
|
1939
|
+
const rows = await getDb().select({
|
|
1940
|
+
costUsd: sql`SUM(${cost})::double precision`.mapWith(Number),
|
|
1941
|
+
date,
|
|
1942
|
+
events: sql`COUNT(*)::integer`.mapWith(Number)
|
|
1943
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1944
|
+
const byHour = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1945
|
+
return Array.from({ length: hourCount }, (_, index) => {
|
|
1946
|
+
const key = new Date(start.getTime() + index * 60 * 60 * 1e3).toISOString().slice(0, 13);
|
|
1947
|
+
return byHour.get(key) ?? {
|
|
1948
|
+
costUsd: 0,
|
|
1949
|
+
date: key,
|
|
1950
|
+
events: 0
|
|
1951
|
+
};
|
|
2132
1952
|
});
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
const
|
|
2136
|
-
const
|
|
2137
|
-
const
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
1953
|
+
},
|
|
1954
|
+
async costsByDay(input) {
|
|
1955
|
+
const eventName = registeredEventName(plugin, input.eventName);
|
|
1956
|
+
const end = startOfUtcDay(now());
|
|
1957
|
+
const start = /* @__PURE__ */ new Date(end.getTime() - (input.days - 1) * DAY_MS);
|
|
1958
|
+
const endExclusive = new Date(end.getTime() + DAY_MS);
|
|
1959
|
+
const date = sql`TO_CHAR(
|
|
1960
|
+
${juniorConversationEvents.createdAt} AT TIME ZONE 'UTC',
|
|
1961
|
+
'YYYY-MM-DD'
|
|
1962
|
+
)`;
|
|
1963
|
+
const cost = sql`CASE
|
|
1964
|
+
WHEN jsonb_typeof(${juniorConversationEvents.payload}->'content'->'costUsd') = 'number'
|
|
1965
|
+
THEN (${juniorConversationEvents.payload}->'content'->>'costUsd')::double precision
|
|
1966
|
+
ELSE 0
|
|
1967
|
+
END`;
|
|
1968
|
+
const rows = await getDb().select({
|
|
1969
|
+
costUsd: sql`SUM(${cost})::double precision`.mapWith(Number),
|
|
1970
|
+
date,
|
|
1971
|
+
events: sql`COUNT(*)::integer`.mapWith(Number)
|
|
1972
|
+
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1973
|
+
const byDate = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1974
|
+
return Array.from({ length: input.days }, (_, index) => {
|
|
1975
|
+
const date = new Date(start.getTime() + index * DAY_MS).toISOString().slice(0, 10);
|
|
1976
|
+
return byDate.get(date) ?? {
|
|
1977
|
+
costUsd: 0,
|
|
1978
|
+
date,
|
|
1979
|
+
events: 0
|
|
1980
|
+
};
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
//#endregion
|
|
1986
|
+
//#region src/chat/plugins/logging.ts
|
|
1987
|
+
/** Create the host logger exposed to plugin hooks. */
|
|
1988
|
+
function createPluginLogger(plugin) {
|
|
1989
|
+
return {
|
|
1990
|
+
info(message, metadata) {
|
|
1991
|
+
logInfo("plugin.log.info", {
|
|
1992
|
+
"app.log.message": message,
|
|
1993
|
+
"app.plugin.name": plugin,
|
|
1994
|
+
...metadata
|
|
1995
|
+
});
|
|
1996
|
+
},
|
|
1997
|
+
warn(message, metadata) {
|
|
1998
|
+
logWarn("plugin.log.warn", {
|
|
1999
|
+
"app.log.message": message,
|
|
2000
|
+
"app.plugin.name": plugin,
|
|
2001
|
+
...metadata
|
|
2002
|
+
});
|
|
2003
|
+
},
|
|
2004
|
+
error(message, metadata) {
|
|
2005
|
+
logException(new Error(message), "plugin.log.error", {
|
|
2006
|
+
"app.plugin.name": plugin,
|
|
2007
|
+
...metadata
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region src/chat/plugins/model.ts
|
|
2014
|
+
/** Create the host-owned structured model capability exposed to plugins. */
|
|
2015
|
+
function createPluginModel(pluginName, options = {}, runtime = {}) {
|
|
2016
|
+
return { async completeObject(input) {
|
|
2017
|
+
const modelId = options.structuredModelId ?? (options.structuredModel === "default" ? defaultModelId(botConfig) : botConfig.fastModelId);
|
|
2018
|
+
const result = await completeObject({
|
|
2019
|
+
modelId,
|
|
2020
|
+
schema: input.schema,
|
|
2021
|
+
prompt: input.prompt,
|
|
2022
|
+
...input.system !== void 0 ? { system: input.system } : void 0,
|
|
2023
|
+
...input.maxTokens !== void 0 ? { maxTokens: input.maxTokens } : void 0,
|
|
2024
|
+
signal: runtime.signal,
|
|
2025
|
+
promptName: `${pluginName}.structured_completion`,
|
|
2026
|
+
metadata: {
|
|
2027
|
+
pluginName,
|
|
2028
|
+
pluginModelRole: "structured"
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
return {
|
|
2032
|
+
object: result.object,
|
|
2033
|
+
...result.costUsd !== void 0 ? { costUsd: result.costUsd } : void 0
|
|
2034
|
+
};
|
|
2035
|
+
} };
|
|
2036
|
+
}
|
|
2037
|
+
/** Create the host-owned embedding capability exposed to prompt hooks. */
|
|
2038
|
+
function createPluginEmbedder(pluginName, runtime = {}) {
|
|
2039
|
+
return { async embedTexts(input) {
|
|
2040
|
+
return await embedTexts({
|
|
2041
|
+
modelId: botConfig.embeddingModelId,
|
|
2042
|
+
texts: input.texts,
|
|
2043
|
+
signal: runtime.signal,
|
|
2044
|
+
metadata: {
|
|
2045
|
+
pluginName,
|
|
2046
|
+
pluginModelRole: "embedding"
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
} };
|
|
2050
|
+
}
|
|
2051
|
+
//#endregion
|
|
2052
|
+
//#region src/chat/plugins/state.ts
|
|
2053
|
+
const MAX_PLUGIN_STATE_KEY_LENGTH = 512;
|
|
2054
|
+
function hashKeyPart(value) {
|
|
2055
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
2056
|
+
}
|
|
2057
|
+
function pluginStateKey(plugin, key) {
|
|
2058
|
+
const pluginPrefix = `junior:${plugin}`;
|
|
2059
|
+
if (key === pluginPrefix || key.startsWith(`${pluginPrefix}:`)) return key;
|
|
2060
|
+
return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`;
|
|
2061
|
+
}
|
|
2062
|
+
function validatePluginStateKey(key) {
|
|
2063
|
+
if (!key.trim()) throw new Error("Plugin state key is required");
|
|
2064
|
+
if (key.length > MAX_PLUGIN_STATE_KEY_LENGTH) throw new Error("Plugin state key exceeds the maximum length");
|
|
2065
|
+
}
|
|
2066
|
+
/** Create a durable state namespace scoped to one plugin. */
|
|
2067
|
+
function createPluginState(plugin, adapter) {
|
|
2068
|
+
const getAdapter = () => adapter ?? getStateAdapter();
|
|
2069
|
+
return {
|
|
2070
|
+
async delete(key) {
|
|
2071
|
+
validatePluginStateKey(key);
|
|
2072
|
+
const state = getAdapter();
|
|
2073
|
+
await state.connect();
|
|
2074
|
+
await state.delete(pluginStateKey(plugin, key));
|
|
2075
|
+
},
|
|
2076
|
+
async get(key) {
|
|
2077
|
+
validatePluginStateKey(key);
|
|
2078
|
+
const state = getAdapter();
|
|
2079
|
+
await state.connect();
|
|
2080
|
+
return await state.get(pluginStateKey(plugin, key)) ?? void 0;
|
|
2081
|
+
},
|
|
2082
|
+
async set(key, value, ttlMs) {
|
|
2083
|
+
validatePluginStateKey(key);
|
|
2084
|
+
const state = getAdapter();
|
|
2085
|
+
await state.connect();
|
|
2086
|
+
await state.set(pluginStateKey(plugin, key), value, ttlMs);
|
|
2087
|
+
},
|
|
2088
|
+
async setIfNotExists(key, value, ttlMs) {
|
|
2089
|
+
validatePluginStateKey(key);
|
|
2090
|
+
const state = getAdapter();
|
|
2091
|
+
await state.connect();
|
|
2092
|
+
return await state.setIfNotExists(pluginStateKey(plugin, key), value, ttlMs);
|
|
2093
|
+
},
|
|
2094
|
+
async withLock(key, ttlMs, callback) {
|
|
2095
|
+
validatePluginStateKey(key);
|
|
2096
|
+
const state = getAdapter();
|
|
2097
|
+
await state.connect();
|
|
2098
|
+
const lockKey = pluginStateKey(plugin, key);
|
|
2099
|
+
const lock = await state.acquireLock(lockKey, ttlMs);
|
|
2100
|
+
if (!lock) throw new Error(`Could not acquire plugin state lock for ${key}`);
|
|
2101
|
+
try {
|
|
2102
|
+
return await callback();
|
|
2103
|
+
} finally {
|
|
2104
|
+
await state.releaseLock(lock);
|
|
2155
2105
|
}
|
|
2156
|
-
logWarn("slack.action.retrying", {
|
|
2157
|
-
...baseLogAttributes,
|
|
2158
|
-
"app.slack.retry_attempt": attempt,
|
|
2159
|
-
"app.slack.retry_class": retryClass
|
|
2160
|
-
});
|
|
2161
|
-
const retryAfterMs = retryClass === "rate_limited" && mapped.retryAfterSeconds && mapped.retryAfterSeconds > 0 ? mapped.retryAfterSeconds * 1e3 : void 0;
|
|
2162
|
-
const backoffMs = Math.min(2e3, 250 * 2 ** (attempt - 1));
|
|
2163
|
-
const delayMs = Math.min(retryAfterMs ?? backoffMs, MAX_RETRY_DELAY_MS, remainingDelayBudgetMs);
|
|
2164
|
-
totalDelayMs += delayMs;
|
|
2165
|
-
await sleep$1(delayMs);
|
|
2166
2106
|
}
|
|
2167
|
-
}
|
|
2168
|
-
throw new SlackActionError("Slack action exhausted retries", "internal_error");
|
|
2107
|
+
};
|
|
2169
2108
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
* never goes out with another workspace's credentials.
|
|
2176
|
-
*/
|
|
2177
|
-
function getSlackClient(options) {
|
|
2178
|
-
if (options) return new WebClient(resolveSlackToken(), {
|
|
2179
|
-
retryConfig: { retries: 0 },
|
|
2180
|
-
rejectRateLimitedCalls: true,
|
|
2181
|
-
timeout: options.timeoutMs
|
|
2182
|
-
});
|
|
2183
|
-
return getClient();
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region src/chat/dashboard-link.ts
|
|
2111
|
+
let dashboardConversationLinkOptions;
|
|
2112
|
+
function withHttps(host) {
|
|
2113
|
+
return /^https?:\/\//.test(host) ? host : `https://${host}`;
|
|
2184
2114
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
* - G: legacy private channel / group DM
|
|
2190
|
-
* - D: direct message (1:1)
|
|
2191
|
-
*/
|
|
2192
|
-
function isDmChannel(channelId) {
|
|
2193
|
-
const normalized = normalizeSlackConversationId(channelId);
|
|
2194
|
-
return Boolean(normalized && normalized.startsWith("D"));
|
|
2115
|
+
function stripTrailingSlashes(value) {
|
|
2116
|
+
let end = value.length;
|
|
2117
|
+
while (end > 1 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
2118
|
+
return end === value.length ? value : value.slice(0, end);
|
|
2195
2119
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
*/
|
|
2200
|
-
function isConversationScopedChannel(channelId) {
|
|
2201
|
-
const normalized = normalizeSlackConversationId(channelId);
|
|
2202
|
-
if (!normalized) return false;
|
|
2203
|
-
return normalized.startsWith("C") || normalized.startsWith("G") || normalized.startsWith("D");
|
|
2120
|
+
function normalizeDashboardPath(path, fallback) {
|
|
2121
|
+
const value = path?.trim() || fallback;
|
|
2122
|
+
return stripTrailingSlashes(value.startsWith("/") ? value : `/${value}`);
|
|
2204
2123
|
}
|
|
2205
|
-
function
|
|
2206
|
-
const
|
|
2207
|
-
if (
|
|
2208
|
-
|
|
2124
|
+
function resolveDashboardBaseURL(config) {
|
|
2125
|
+
const explicit = config.baseURL ?? process.env.JUNIOR_BASE_URL;
|
|
2126
|
+
if (explicit?.trim()) return stripTrailingSlashes(withHttps(explicit.trim()));
|
|
2127
|
+
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
2128
|
+
if (vercelProd) return stripTrailingSlashes(withHttps(vercelProd));
|
|
2129
|
+
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
2130
|
+
if (vercelUrl) return stripTrailingSlashes(withHttps(vercelUrl));
|
|
2131
|
+
return "http://localhost:3000";
|
|
2209
2132
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
spanAttributes: { "app.slack.file_id": fileId }
|
|
2216
|
-
})).file?.permalink;
|
|
2133
|
+
/** Configure core dashboard links. */
|
|
2134
|
+
function setDashboardConversationLinkOptions(options) {
|
|
2135
|
+
const previous = dashboardConversationLinkOptions;
|
|
2136
|
+
dashboardConversationLinkOptions = options?.disabled ? void 0 : options;
|
|
2137
|
+
return previous;
|
|
2217
2138
|
}
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2139
|
+
function resolveDashboardPath(segmentPath) {
|
|
2140
|
+
if (!dashboardConversationLinkOptions) return;
|
|
2141
|
+
const baseURL = resolveDashboardBaseURL(dashboardConversationLinkOptions);
|
|
2142
|
+
const basePath = normalizeDashboardPath(dashboardConversationLinkOptions.basePath, "/");
|
|
2143
|
+
return `${baseURL}${basePath === "/" ? segmentPath : `${basePath}${segmentPath}`}`;
|
|
2144
|
+
}
|
|
2145
|
+
/** Build the dashboard conversation URL when the core dashboard is enabled. */
|
|
2146
|
+
function getDashboardConversationLink(conversationId) {
|
|
2147
|
+
return resolveDashboardPath(`/conversations/${encodeURIComponent(conversationId)}`);
|
|
2148
|
+
}
|
|
2149
|
+
/** Build the dashboard task detail URL when the core dashboard is enabled. */
|
|
2150
|
+
function getDashboardTaskLink(taskId) {
|
|
2151
|
+
return resolveDashboardPath(`/automations/${encodeURIComponent(taskId)}`);
|
|
2152
|
+
}
|
|
2153
|
+
/** Public icon asset URL. Headless and local installs keep text-only identity. */
|
|
2154
|
+
function getDashboardObjectIconLink(icon) {
|
|
2155
|
+
if (!dashboardConversationLinkOptions) return void 0;
|
|
2156
|
+
const url = new URL(objectIconPath(icon), resolveDashboardBaseURL(dashboardConversationLinkOptions));
|
|
2157
|
+
if (url.protocol !== "https:" || url.hostname === "localhost") return void 0;
|
|
2158
|
+
return url.href;
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/chat/state/ttl.ts
|
|
2162
|
+
/** Retain expiring conversation/runtime state long enough for resume and dashboard history. */
|
|
2163
|
+
const JUNIOR_THREAD_STATE_TTL_MS = 6048e5;
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region src/chat/events/timer-index.ts
|
|
2166
|
+
const DUE_KEY = "junior:watches:due";
|
|
2167
|
+
const CLAIM_MS = 12e4;
|
|
2168
|
+
const BATCH_SIZE = 25;
|
|
2169
|
+
const entriesSchema = z.array(z.object({
|
|
2170
|
+
id: z.string(),
|
|
2171
|
+
atMs: z.number()
|
|
2172
|
+
}));
|
|
2173
|
+
/** Keep timer index operations atomic in Redis and the local memory adapter. */
|
|
2174
|
+
async function updateTimerIndex(state, operation) {
|
|
2175
|
+
const redis = await getDefaultRedisStateAdapterFor(state);
|
|
2176
|
+
if (redis) {
|
|
2177
|
+
const prefix = getChatConfig().state.keyPrefix;
|
|
2178
|
+
const key = prefix ? `${prefix}:${DUE_KEY}` : DUE_KEY;
|
|
2179
|
+
const client = redis.getClient();
|
|
2180
|
+
if (operation.kind === "add") {
|
|
2181
|
+
await client.sendCommand([
|
|
2182
|
+
"ZADD",
|
|
2183
|
+
key,
|
|
2184
|
+
String(operation.atMs),
|
|
2185
|
+
operation.id
|
|
2186
|
+
]);
|
|
2187
|
+
return [];
|
|
2227
2188
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2189
|
+
if (operation.kind === "remove") {
|
|
2190
|
+
await client.sendCommand([
|
|
2191
|
+
"ZREM",
|
|
2192
|
+
key,
|
|
2193
|
+
operation.id
|
|
2194
|
+
]);
|
|
2195
|
+
return [];
|
|
2196
|
+
}
|
|
2197
|
+
return await client.sendCommand([
|
|
2198
|
+
"EVAL",
|
|
2199
|
+
`local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[3])
|
|
2200
|
+
for _, id in ipairs(ids) do
|
|
2201
|
+
redis.call('ZADD', KEYS[1], ARGV[2], id)
|
|
2202
|
+
end
|
|
2203
|
+
return ids`,
|
|
2204
|
+
"1",
|
|
2205
|
+
key,
|
|
2206
|
+
String(operation.nowMs),
|
|
2207
|
+
String(operation.nowMs + CLAIM_MS),
|
|
2208
|
+
String(BATCH_SIZE)
|
|
2209
|
+
]);
|
|
2210
|
+
}
|
|
2211
|
+
const lock = await state.acquireLock(`${DUE_KEY}:lock`, 1e4);
|
|
2212
|
+
if (!lock) throw new Error("Could not acquire timer index lock");
|
|
2213
|
+
try {
|
|
2214
|
+
let entries = entriesSchema.parse(await state.get(DUE_KEY) ?? []);
|
|
2215
|
+
let ids = [];
|
|
2216
|
+
if (operation.kind === "claim") {
|
|
2217
|
+
ids = entries.filter((entry) => entry.atMs <= operation.nowMs).sort((a, b) => a.atMs - b.atMs).slice(0, BATCH_SIZE).map((entry) => entry.id);
|
|
2218
|
+
const claimed = new Set(ids);
|
|
2219
|
+
entries = entries.map((entry) => claimed.has(entry.id) ? {
|
|
2220
|
+
...entry,
|
|
2221
|
+
atMs: operation.nowMs + CLAIM_MS
|
|
2222
|
+
} : entry);
|
|
2223
|
+
} else {
|
|
2224
|
+
entries = entries.filter((entry) => entry.id !== operation.id);
|
|
2225
|
+
if (operation.kind === "add") entries.push({
|
|
2226
|
+
id: operation.id,
|
|
2227
|
+
atMs: operation.atMs
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
await state.set(DUE_KEY, entries);
|
|
2231
|
+
return ids;
|
|
2232
|
+
} finally {
|
|
2233
|
+
await state.releaseLock(lock);
|
|
2234
|
+
}
|
|
2235
2235
|
}
|
|
2236
2236
|
//#endregion
|
|
2237
2237
|
//#region src/chat/credentials/subject.ts
|
|
@@ -4310,4 +4310,4 @@ function createPluginHookRunner(input = {}) {
|
|
|
4310
4310
|
};
|
|
4311
4311
|
}
|
|
4312
4312
|
//#endregion
|
|
4313
|
-
export { verifySlackDirectCredentialSubject as $, getWorkspaceSnapshotBuilder as A,
|
|
4313
|
+
export { verifySlackDirectCredentialSubject as $, getWorkspaceSnapshotBuilder as A, listConversationAnnotations as At, cancelWatch as B, isConversationScopedChannel as Bt, WorkspaceValidationError as C, getSlackFileId as Ct, setWorkspaceSnapshotBuild as D, resolveRootVisibility as Dt, setWorkspaceSnapshot as E, recordSlackAttachment as Et, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as F, SlackActionError as Ft, findMatchingWatches as G, getWorkspaceTeamId as Gt, createTimerWatch as H, normalizeSlackConversationId as Ht, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as I, downloadPrivateSlackFile as It, bindEventAutomationCredentialSubject as J, agentsInstructionsUpdatedEvent as Jt, listWatches as K, runWithWorkspaceTeamId as Kt, RESOURCE_WATCH_TOOL_SOURCE as L, getFilePermalink as Lt, workspaceRepoCheckoutPath as M, defaultTimezone as Mt, resolveChannelCapabilities as N, resolveUserTimezone as Nt, SANDBOX_RUNTIME as O, saveObjectAnnotations as Ot, getSlackToolContext as P, lookupSlackUser as Pt, verifyScheduledAutomationCredentialSubject as Q, STOP_WATCHING_TOOL_NAME as R, getHeaderString as Rt, updateWorkspace as S, buildSentryConversationUrl as St, loadSnapshotsForProfile as T, readAttachmentVisionSummary as Tt, createWatch as U, runWithSlackInstallationToken as Ut, collectEventMatchKeys as V, isDmChannel as Vt, deliverWatch as W, withSlackRetries as Wt, bindSlackDirectCredentialSubject as X, authenticationUnlinkedEvent as Xt, bindScheduledAutomationCredentialSubject as Y, authenticationLinkedEvent as Yt, verifyEventAutomationCredentialSubject as Z, deleteWorkspace as _, conversationNotArchivedForUser as _t, getPluginRoutes as a, setDashboardConversationLinkOptions as at, listWorkspaces as b, readLatestConversationBrief as bt, getPluginTools as c, createPluginModel as ct, setPlugins as d, conversationArchiveTimes as dt, verifyTimerWatchCredentialSubject as et, validatePlugins as f, conversationArchivedForEmail as ft, createWorkspace as g, conversationNotArchivedForEmail as gt, setBriefsConfig as h, conversationHasParticipantUser as ht, getPluginApiRoutes as i, getDashboardTaskLink as it, tryWorkspaceRepoCheckoutPath as j, listConversationAnnotationsById as jt, deleteWorkspaceSnapshotBuilders as k, createPluginAnnotations as kt, getPluginUserPromptContributions as l, createPluginLogger as lt, registration_exports as m, conversationHasParticipantEmail as mt, applyPluginFormatMarkdown as n, getDashboardConversationLink as nt, getPluginSlackConversationLink as o, createPluginState as ot, coreTaskRegistrations as p, conversationArchivedForUser as pt, takeDueTimerWatches as q, JUNIOR_NATIVE_EVENT_NAMESPACE as qt, createPluginHookRunner as r, getDashboardObjectIconLink as rt, getPluginSystemPromptContributions as s, createPluginEmbedder as st, agent_hooks_exports as t, JUNIOR_THREAD_STATE_TTL_MS as tt, getPlugins as u, readConversationAccessFromSql as ut, getWorkspace as v, viewerConversationMembership as vt, invalidateReadySnapshot as w, matchSlackAttachments as wt, listWorkspacesByRepository as x, readConversationEventPage as xt, getWorkspaceByName as y, createPluginConversationEvents as yt, cancelSubscriptions as z, getSlackClient as zt };
|