@promptbook/cli 0.112.0-133 → 0.112.0-135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/apps/agents-server/src/app/admin/chat-feedback/page.tsx +2 -4
  2. package/apps/agents-server/src/app/admin/chat-history/page.tsx +2 -4
  3. package/apps/agents-server/src/app/agents/[agentName]/api/book/route.ts +2 -0
  4. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +33 -9
  5. package/apps/agents-server/src/app/api/chat/route.ts +29 -7
  6. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
  7. package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
  8. package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
  9. package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
  10. package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
  11. package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
  12. package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
  13. package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
  14. package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
  15. package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
  16. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
  17. package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
  18. package/apps/agents-server/src/app/api/v1/agents/[agentId]/route.ts +4 -0
  19. package/apps/agents-server/src/app/api/v1/agents/route.ts +2 -0
  20. package/apps/agents-server/src/app/recycle-bin/actions.ts +3 -0
  21. package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
  22. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  23. package/apps/agents-server/src/database/migrations/2026-06-2600-agent-directory-performance-indexes.sql +14 -0
  24. package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
  25. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +92 -1
  26. package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
  27. package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
  28. package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
  29. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
  30. package/apps/agents-server/src/utils/userChat/listUserChats.ts +5 -1
  31. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
  32. package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
  33. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
  34. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
  35. package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
  36. package/esm/index.es.js +795 -39
  37. package/esm/index.es.js.map +1 -1
  38. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  39. package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  40. package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  41. package/esm/src/version.d.ts +1 -1
  42. package/package.json +1 -1
  43. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  44. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  45. package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
  46. package/src/other/templates/getTemplatesPipelineCollection.ts +759 -679
  47. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  48. package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
  49. package/src/version.ts +2 -2
  50. package/src/versions.txt +1 -0
  51. package/umd/index.umd.js +795 -39
  52. package/umd/index.umd.js.map +1 -1
  53. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  54. package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  55. package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  56. package/umd/src/version.d.ts +1 -1
@@ -69,9 +69,7 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
69
69
  const tableName = await $getTableName('Agent');
70
70
  const { data, error } = await supabase
71
71
  .from(tableName)
72
- .select(
73
- 'id, agentName, createdAt, updatedAt, permanentId, agentHash, agentSource, agentProfile, promptbookEngineVersion, folderId, sortOrder, deletedAt, visibility, userId',
74
- )
72
+ .select(OWNED_AGENT_ROW_COLUMNS)
75
73
  .eq('userId', userId as never)
76
74
  .or(buildAgentNameOrIdFilter(identifier))
77
75
  .limit(10);
@@ -80,7 +78,58 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
80
78
  throw new Error(`Failed to load agent "${identifier}": ${error.message}`);
81
79
  }
82
80
 
83
- const rows = (data || []) as unknown as OwnedAgentRow[];
81
+ return pickAgentRowByIdentifier((data || []) as unknown as OwnedAgentRow[], identifier, 'for this owner');
82
+ }
83
+
84
+ /**
85
+ * Loads one agent by permanent id or agent name without an owner restriction.
86
+ *
87
+ * Owner-aware callers should use {@link findOwnedAgentByIdentifier}; this helper is reserved for administrative
88
+ * overrides where the caller has been independently authorized to read or mutate any agent.
89
+ *
90
+ * @param identifier - Permanent id or agent name.
91
+ * @returns Matching agent row or `null` when nothing matches.
92
+ */
93
+ export async function findAgentByIdentifier(identifier: string): Promise<OwnedAgentRow | null> {
94
+ const supabase = $provideSupabaseForServer();
95
+ const tableName = await $getTableName('Agent');
96
+ const { data, error } = await supabase
97
+ .from(tableName)
98
+ .select(OWNED_AGENT_ROW_COLUMNS)
99
+ .or(buildAgentNameOrIdFilter(identifier))
100
+ .limit(10);
101
+
102
+ if (error) {
103
+ throw new Error(`Failed to load agent "${identifier}": ${error.message}`);
104
+ }
105
+
106
+ return pickAgentRowByIdentifier((data || []) as unknown as OwnedAgentRow[], identifier, 'across agents');
107
+ }
108
+
109
+ /**
110
+ * Database columns selected for every agent row exposed by ownership-aware helpers.
111
+ */
112
+ const OWNED_AGENT_ROW_COLUMNS =
113
+ 'id, agentName, createdAt, updatedAt, permanentId, agentHash, agentSource, agentProfile, promptbookEngineVersion, folderId, sortOrder, deletedAt, visibility, userId';
114
+
115
+ /**
116
+ * Disambiguates a list of agent rows that all matched the same identifier.
117
+ *
118
+ * Resolution order:
119
+ * 1. Exact `permanentId` match — always preferred because permanent ids are unique.
120
+ * 2. Single `agentName` match — preserves the legacy lookup by human-readable name.
121
+ * 3. Single remaining row — accepts the only candidate found.
122
+ *
123
+ * @param rows - Candidate agent rows returned by Supabase.
124
+ * @param identifier - Identifier that was searched.
125
+ * @param ambiguityScope - Human-readable scope used in the ambiguity error message.
126
+ * @returns Best-matching row or `null` when no candidate matches.
127
+ */
128
+ function pickAgentRowByIdentifier(
129
+ rows: OwnedAgentRow[],
130
+ identifier: string,
131
+ ambiguityScope: string,
132
+ ): OwnedAgentRow | null {
84
133
  if (rows.length === 0) {
85
134
  return null;
86
135
  }
@@ -100,7 +149,7 @@ export async function findOwnedAgentByIdentifier(userId: number, identifier: str
100
149
  }
101
150
 
102
151
  throw new Error(
103
- `Agent identifier "${identifier}" is ambiguous for this owner. Use the stable \`permanentId\` returned by the API.`,
152
+ `Agent identifier "${identifier}" is ambiguous ${ambiguityScope}. Use the stable \`permanentId\` returned by the API.`,
104
153
  );
105
154
  }
106
155
 
@@ -0,0 +1,36 @@
1
+ import { findAgentByIdentifier, findOwnedAgentByIdentifier, type OwnedAgentRow } from './agentOwnership';
2
+ import { getCurrentUser } from './getCurrentUser';
3
+ import { isUserAdmin } from './isUserAdmin';
4
+
5
+ /**
6
+ * Loads one agent for the current caller, enforcing ownership-or-admin write access.
7
+ *
8
+ * Resolution order:
9
+ * 1. Anonymous callers receive `null` so routes can return `403`.
10
+ * 2. Owners are matched via {@link findOwnedAgentByIdentifier} using the authenticated user id.
11
+ * 3. Administrators (resolved via {@link isUserAdmin}) fall through to {@link findAgentByIdentifier} so they can manage any agent — including legacy `ADMIN_PASSWORD` admins that have no database user id.
12
+ *
13
+ * @param identifier - Permanent id or agent name supplied by the route.
14
+ * @returns Matching agent row when the caller owns it or is an administrator, otherwise `null`.
15
+ *
16
+ * @private internal helper of Agents Server route handlers
17
+ */
18
+ export async function findAgentForCallerWriteAccess(identifier: string): Promise<OwnedAgentRow | null> {
19
+ const [currentUser, isAdmin] = await Promise.all([getCurrentUser(), isUserAdmin()]);
20
+ if (!currentUser) {
21
+ return null;
22
+ }
23
+
24
+ if (typeof currentUser.id === 'number') {
25
+ const ownedAgent = await findOwnedAgentByIdentifier(currentUser.id, identifier);
26
+ if (ownedAgent) {
27
+ return ownedAgent;
28
+ }
29
+ }
30
+
31
+ if (!isAdmin) {
32
+ return null;
33
+ }
34
+
35
+ return findAgentByIdentifier(identifier);
36
+ }
@@ -0,0 +1,241 @@
1
+ import { createHash } from 'crypto';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { spaceTrim } from 'spacetrim';
4
+ import { getRequestIp } from '../middleware/createMiddlewareRequestContext/getRequestIp';
5
+ import { getSession } from './session';
6
+ import { validateApiKey } from './validateApiKey';
7
+
8
+ /**
9
+ * Categorizes the kind of paid third-party AI call a route performs.
10
+ *
11
+ * Each tier maps to its own per-identity sliding-window budget because the
12
+ * marginal cost (and therefore acceptable rate) differs significantly between
13
+ * speech-to-text, text-to-speech, and image generation.
14
+ *
15
+ * @private internal helper of paid AI proxy routes
16
+ */
17
+ export type PaidApiRequestTier = 'AUDIO_TRANSCRIPTION' | 'TEXT_TO_SPEECH' | 'IMAGE_GENERATION';
18
+
19
+ /**
20
+ * Per-tier rate limit definition.
21
+ *
22
+ * @private internal helper of paid AI proxy routes
23
+ */
24
+ type PaidApiRateLimitDefinition = {
25
+ /**
26
+ * Maximum number of paid calls permitted per identity in a single window.
27
+ */
28
+ readonly maxRequestsPerWindow: number;
29
+ /**
30
+ * Length of the sliding window in milliseconds.
31
+ */
32
+ readonly windowMs: number;
33
+ };
34
+
35
+ /**
36
+ * Per-identity, per-tier sliding-window budgets for paid third-party calls.
37
+ *
38
+ * Numbers are intentionally generous for normal interactive UI use while still
39
+ * blocking sustained abuse — e.g. an attacker who has obtained a valid API key
40
+ * or signed-in account cannot loop millions of image generations.
41
+ *
42
+ * @private internal constant of paid AI proxy routes
43
+ */
44
+ const PAID_API_RATE_LIMITS: Readonly<Record<PaidApiRequestTier, PaidApiRateLimitDefinition>> = {
45
+ AUDIO_TRANSCRIPTION: { maxRequestsPerWindow: 60, windowMs: 60_000 },
46
+ TEXT_TO_SPEECH: { maxRequestsPerWindow: 60, windowMs: 60_000 },
47
+ IMAGE_GENERATION: { maxRequestsPerWindow: 20, windowMs: 60_000 },
48
+ };
49
+
50
+ /**
51
+ * In-memory map of request timestamps keyed by `tier|identity`.
52
+ *
53
+ * In-memory state is intentional: the sliding window is a defense in depth on
54
+ * top of authentication, and we do not want to add a Redis dependency just for
55
+ * this guard. Operators who run multiple replicas should still configure a
56
+ * front-door rate limiter; this layer prevents single-process abuse.
57
+ *
58
+ * @private internal constant of paid AI proxy routes
59
+ */
60
+ const PAID_API_REQUEST_TIMESTAMPS: Map<string, Array<number>> = new Map();
61
+
62
+ /**
63
+ * Successful guard outcome describing the authenticated identity.
64
+ *
65
+ * @private internal helper of paid AI proxy routes
66
+ */
67
+ export type PaidApiRequestGuardSuccess = {
68
+ readonly ok: true;
69
+ /**
70
+ * Stable identity key used for rate limiting and audit logs.
71
+ *
72
+ * Derived from (in order): the signed session username, the bearer API key
73
+ * hash, and finally the request IP for anonymous fallbacks (which should
74
+ * not happen because `authenticatedOnly` rejects them).
75
+ */
76
+ readonly identityKey: string;
77
+ };
78
+
79
+ /**
80
+ * Failure outcome containing the response that should be returned verbatim.
81
+ *
82
+ * @private internal helper of paid AI proxy routes
83
+ */
84
+ export type PaidApiRequestGuardFailure = {
85
+ readonly ok: false;
86
+ readonly response: NextResponse;
87
+ };
88
+
89
+ /**
90
+ * Guard outcome returned by `guardPaidApiRequest`.
91
+ *
92
+ * @private internal helper of paid AI proxy routes
93
+ */
94
+ export type PaidApiRequestGuardResult = PaidApiRequestGuardSuccess | PaidApiRequestGuardFailure;
95
+
96
+ /**
97
+ * Combined authentication and per-identity rate-limit check for routes that
98
+ * proxy directly to paid third-party AI providers (OpenAI, ElevenLabs, ...).
99
+ *
100
+ * The guard enforces two invariants in a single place so that every paid
101
+ * endpoint stays consistent (DRY) and no new endpoint accidentally forgets
102
+ * authentication or rate limiting:
103
+ *
104
+ * 1. The caller must be authenticated via a signed session cookie or a valid
105
+ * `Authorization: Bearer ptbk_...` API key.
106
+ * 2. The caller must stay within the per-tier sliding-window budget defined
107
+ * in `PAID_API_RATE_LIMITS`.
108
+ *
109
+ * @param request - Incoming HTTP request.
110
+ * @param tier - Paid call category determining the applicable budget.
111
+ * @returns Either `{ ok: true, identityKey }` or `{ ok: false, response }`.
112
+ *
113
+ * @private internal helper of paid AI proxy routes
114
+ */
115
+ export async function guardPaidApiRequest(
116
+ request: NextRequest,
117
+ tier: PaidApiRequestTier,
118
+ ): Promise<PaidApiRequestGuardResult> {
119
+ const apiKeyValidation = await validateApiKey(request);
120
+ if (!apiKeyValidation.isValid) {
121
+ return {
122
+ ok: false,
123
+ response: NextResponse.json(
124
+ {
125
+ error: {
126
+ message: apiKeyValidation.error || 'Authentication required',
127
+ type: 'authentication_error',
128
+ },
129
+ },
130
+ { status: 401 },
131
+ ),
132
+ };
133
+ }
134
+
135
+ const identityKey = await resolvePaidApiIdentityKey(request, apiKeyValidation.token);
136
+
137
+ const rateLimitCheck = checkPaidApiRateLimit(tier, identityKey);
138
+ if (!rateLimitCheck.isAllowed) {
139
+ return {
140
+ ok: false,
141
+ response: NextResponse.json(
142
+ {
143
+ error: {
144
+ message: spaceTrim(`
145
+ Rate limit exceeded for paid AI proxy.
146
+
147
+ **Tier:** \`${tier}\`
148
+ **Limit:** ${rateLimitCheck.maxRequestsPerWindow} requests per ${Math.round(
149
+ rateLimitCheck.windowMs / 1000,
150
+ )} seconds.
151
+
152
+ Try again in **${rateLimitCheck.retryAfterSeconds}** seconds.
153
+ `),
154
+ type: 'rate_limit_exceeded',
155
+ },
156
+ },
157
+ {
158
+ status: 429,
159
+ headers: {
160
+ 'Retry-After': String(rateLimitCheck.retryAfterSeconds),
161
+ },
162
+ },
163
+ ),
164
+ };
165
+ }
166
+
167
+ return { ok: true, identityKey };
168
+ }
169
+
170
+ /**
171
+ * Resolves a stable per-identity key used to bucket rate-limit counts.
172
+ *
173
+ * @param request - Incoming HTTP request.
174
+ * @param apiKeyToken - Optional bearer token recovered by `validateApiKey`.
175
+ * @returns Identity key prefixed with the source kind (`user:`, `apikey:`, or `ip:`).
176
+ *
177
+ * @private internal helper of `guardPaidApiRequest`
178
+ */
179
+ async function resolvePaidApiIdentityKey(request: NextRequest, apiKeyToken: string | undefined): Promise<string> {
180
+ const session = await getSession();
181
+ if (session?.username) {
182
+ return `user:${session.username}`;
183
+ }
184
+
185
+ if (apiKeyToken) {
186
+ return `apikey:${createHash('sha256').update(apiKeyToken).digest('hex')}`;
187
+ }
188
+
189
+ return `ip:${getRequestIp(request)}`;
190
+ }
191
+
192
+ /**
193
+ * Sliding-window rate-limit check shared by all paid AI proxy tiers.
194
+ *
195
+ * @param tier - Paid call category determining the applicable budget.
196
+ * @param identityKey - Stable per-identity key from `resolvePaidApiIdentityKey`.
197
+ * @returns Decision describing whether the request is allowed and, if not, how long to wait.
198
+ *
199
+ * @private internal helper of `guardPaidApiRequest`
200
+ */
201
+ function checkPaidApiRateLimit(
202
+ tier: PaidApiRequestTier,
203
+ identityKey: string,
204
+ ): {
205
+ readonly isAllowed: boolean;
206
+ readonly maxRequestsPerWindow: number;
207
+ readonly windowMs: number;
208
+ readonly retryAfterSeconds: number;
209
+ } {
210
+ const { maxRequestsPerWindow, windowMs } = PAID_API_RATE_LIMITS[tier];
211
+ const bucketKey = `${tier}|${identityKey}`;
212
+ const now = Date.now();
213
+ const windowStart = now - windowMs;
214
+
215
+ const existingTimestamps = PAID_API_REQUEST_TIMESTAMPS.get(bucketKey) || [];
216
+ const recentTimestamps = existingTimestamps.filter((timestamp) => timestamp > windowStart);
217
+
218
+ if (recentTimestamps.length >= maxRequestsPerWindow) {
219
+ const oldestRelevantTimestamp = recentTimestamps[0]!;
220
+ const retryAfterMs = Math.max(1_000, oldestRelevantTimestamp + windowMs - now);
221
+ PAID_API_REQUEST_TIMESTAMPS.set(bucketKey, recentTimestamps);
222
+ return {
223
+ isAllowed: false,
224
+ maxRequestsPerWindow,
225
+ windowMs,
226
+ retryAfterSeconds: Math.ceil(retryAfterMs / 1_000),
227
+ };
228
+ }
229
+
230
+ recentTimestamps.push(now);
231
+ PAID_API_REQUEST_TIMESTAMPS.set(bucketKey, recentTimestamps);
232
+
233
+ return {
234
+ isAllowed: true,
235
+ maxRequestsPerWindow,
236
+ windowMs,
237
+ retryAfterSeconds: 0,
238
+ };
239
+ }
240
+
241
+ // Note: [🟢] Code for the Agents Server paid AI request guard should never be published into packages that could be imported into browser environment
@@ -27,7 +27,8 @@ import type { ChatMessage, ChatPrompt, LlmToolDefinition, ToolCall } from '@prom
27
27
  import { resolveTeamInternalAgentAccessToken } from '../../../../../src/commitments/_common/teamInternalAgentAccess';
28
28
  import type { ChatPromptResult } from '../../../../../src/execution/PromptResult';
29
29
  import { createUserChatMessagePrompt } from './createUserChatMessagePrompt';
30
- import type { UserChatProgressPhase } from './userChatProgressCard';
30
+ import { resolveUserChatProgressToolHighlights } from './resolveUserChatProgressToolHighlights';
31
+ import type { UserChatProgressContext, UserChatProgressPhase } from './userChatProgressCard';
31
32
  import type { UserChatJobRecord } from './UserChatJobRecord';
32
33
 
33
34
  /**
@@ -55,9 +56,13 @@ type RunUserChatJobPromptSnapshotFactory = (
55
56
  ) => NonNullable<ChatMessage['prompt']>;
56
57
 
57
58
  /**
58
- * Reports one user-facing durable chat progress phase.
59
+ * Reports one user-facing durable chat progress phase together with the real runtime
60
+ * details already known at that point in the worker lifecycle.
59
61
  */
60
- type RunUserChatJobProgressReporter = (phase: UserChatProgressPhase) => Promise<void>;
62
+ type RunUserChatJobProgressReporter = (
63
+ phase: UserChatProgressPhase,
64
+ context?: UserChatProgressContext,
65
+ ) => Promise<void>;
61
66
 
62
67
  /**
63
68
  * Prepares the full agent/runtime context required to execute one durable user chat job.
@@ -75,7 +80,12 @@ export async function createRunUserChatJobExecutionContext(options: {
75
80
  promptContent: ChatPrompt['content'];
76
81
  reportProgress?: RunUserChatJobProgressReporter;
77
82
  }) {
78
- await options.reportProgress?.('reading_context');
83
+ const initialProgressContext: UserChatProgressContext = {
84
+ agentPermanentId: options.job.agentPermanentId,
85
+ attachmentCount: options.userMessageAttachments?.length ?? 0,
86
+ };
87
+
88
+ await options.reportProgress?.('reading_context', initialProgressContext);
79
89
 
80
90
  const [localServerUrl, collection, baseAgentReferenceResolver] = await Promise.all([
81
91
  resolveCurrentOrInternalServerOrigin(),
@@ -83,7 +93,7 @@ export async function createRunUserChatJobExecutionContext(options: {
83
93
  $provideAgentReferenceResolver(),
84
94
  ]);
85
95
 
86
- await options.reportProgress?.('preparing_agent');
96
+ await options.reportProgress?.('preparing_agent', initialProgressContext);
87
97
 
88
98
  const resolvedAgentContext = await resolveCachedServerAgentContext({
89
99
  collection,
@@ -103,8 +113,22 @@ export async function createRunUserChatJobExecutionContext(options: {
103
113
  const projectRepositories = extractProjectRepositoriesFromAgentSource(agentSource);
104
114
  const calendarConnections = extractUseCalendarConnectionsFromAgentSource(agentSource);
105
115
  const useEmailConfiguration = extractUseEmailConfigurationFromAgentSource(agentSource);
116
+ const agentToolDefinitions = preparedAgentModelRequirements.modelRequirements.tools ?? [];
117
+ const agentKnowledgeSources = preparedAgentModelRequirements.modelRequirements.knowledgeSources ?? [];
106
118
 
107
- await options.reportProgress?.('checking_capabilities');
119
+ const checkingCapabilitiesProgressContext: UserChatProgressContext = {
120
+ ...initialProgressContext,
121
+ agentName: resolvedAgentName,
122
+ agentPermanentId,
123
+ knowledgeSourceCount: agentKnowledgeSources.length,
124
+ toolCount: agentToolDefinitions.length,
125
+ toolHighlights: resolveUserChatProgressToolHighlights(agentToolDefinitions),
126
+ hasCalendarAccess: calendarConnections.length > 0,
127
+ hasEmailAccess: useEmailConfiguration.isEnabled,
128
+ hasProjectAccess: projectRepositories.length > 0,
129
+ };
130
+
131
+ await options.reportProgress?.('checking_capabilities', checkingCapabilitiesProgressContext);
108
132
 
109
133
  const { projectGithubToken, calendarGoogleAccessToken, emailSmtpCredential } =
110
134
  await resolveRunUserChatJobCredentials({
@@ -179,8 +203,6 @@ export async function createRunUserChatJobExecutionContext(options: {
179
203
  });
180
204
  }
181
205
 
182
- await options.reportProgress?.('starting_response');
183
-
184
206
  const agentKitResult = await agentKitCacheManager.getOrCreateAgentKitAgent(
185
207
  agentSource,
186
208
  resolvedAgentName,
@@ -203,6 +225,12 @@ export async function createRunUserChatJobExecutionContext(options: {
203
225
  teacherAgent: await getTeacherRemoteAgent(),
204
226
  });
205
227
 
228
+ await options.reportProgress?.('starting_response', {
229
+ ...checkingCapabilitiesProgressContext,
230
+ provider: provider ?? undefined,
231
+ isAgentKitCached: agentKitResult.fromCache,
232
+ });
233
+
206
234
  return {
207
235
  collection,
208
236
  agent,
@@ -2,6 +2,7 @@ import { $getTableName } from '@/src/database/$getTableName';
2
2
  import { $provideClientSql } from '@/src/database/$provideClientSql';
3
3
  import { isAgentsServerSqliteMode } from '@/src/database/agentsServerDatabaseMode';
4
4
  import { $provideAgentsServerSqliteDatabase } from '@/src/database/sqlite/$provideAgentsServerSqliteDatabase';
5
+ import { ensureLocalSqliteTableReadIndexes } from '@/src/database/sqlite/$provideLocalSqliteSupabase';
5
6
  import type { ListUserChatsOptions, UserChatRecord } from './UserChatRecord';
6
7
  import type { UserChatSource } from './UserChatSource';
7
8
  import type { UserChatRow } from './UserChatRow';
@@ -168,7 +169,10 @@ export async function listUserChatSummarySeeds(options: ListUserChatsOptions): P
168
169
  * @private function of `userChat`
169
170
  */
170
171
  async function listUserChatSummarySeedsViaSqlite(options: ListUserChatsOptions): Promise<Array<UserChatSummarySeed>> {
171
- const userChatTableName = quoteIdentifier(await $getTableName('UserChat'));
172
+ const rawUserChatTableName = await $getTableName('UserChat');
173
+ ensureLocalSqliteTableReadIndexes(rawUserChatTableName);
174
+
175
+ const userChatTableName = quoteIdentifier(rawUserChatTableName);
172
176
  const shouldLoadExternalChats = options.viewerIsAdmin && options.includeExternalChats;
173
177
  const whereClause = shouldLoadExternalChats
174
178
  ? `
@@ -1,8 +1,16 @@
1
- import { createUserChatProgressCard, type UserChatProgressPhase } from './userChatProgressCard';
1
+ import {
2
+ createUserChatProgressCard,
3
+ type UserChatProgressContext,
4
+ type UserChatProgressPhase,
5
+ } from './userChatProgressCard';
2
6
  import { updateUserChatAssistantMessage } from './updateUserChatAssistantMessage';
3
7
 
4
8
  /**
5
9
  * Persists one durable chat progress phase onto the active assistant placeholder.
10
+ *
11
+ * The optional `context` carries real runtime details (agent name, provider, tool count,
12
+ * attachments, integrations) so the persisted progress card reflects what the worker is
13
+ * actually doing on this turn instead of a fixed scripted summary.
6
14
  */
7
15
  export async function persistUserChatJobProgressCard(options: {
8
16
  readonly userId: number;
@@ -10,6 +18,7 @@ export async function persistUserChatJobProgressCard(options: {
10
18
  readonly chatId: string;
11
19
  readonly assistantMessageId: string;
12
20
  readonly phase: UserChatProgressPhase;
21
+ readonly context?: UserChatProgressContext;
13
22
  }): Promise<void> {
14
23
  await updateUserChatAssistantMessage({
15
24
  userId: options.userId,
@@ -24,7 +33,7 @@ export async function persistUserChatJobProgressCard(options: {
24
33
  lifecycleState,
25
34
  lifecycleError: undefined,
26
35
  isComplete: false,
27
- progressCard: createUserChatProgressCard(options.phase),
36
+ progressCard: createUserChatProgressCard(options.phase, options.context),
28
37
  };
29
38
  },
30
39
  });
@@ -0,0 +1,100 @@
1
+ import type { LlmToolDefinition } from '@promptbook-local/types';
2
+
3
+ /**
4
+ * Maximum number of tool highlight labels surfaced in the progress card text.
5
+ *
6
+ * Keeps the rendered sentence readable when an agent exposes many tools.
7
+ */
8
+ const MAX_TOOL_HIGHLIGHTS = 4;
9
+
10
+ /**
11
+ * Maps technical tool identifiers to short user-facing labels shown in the
12
+ * durable chat progress card while capabilities are being checked.
13
+ */
14
+ const USER_CHAT_PROGRESS_TOOL_LABEL_BY_NAME: Record<string, string> = {
15
+ web_search: 'web search',
16
+ deep_search: 'deep research',
17
+ useSearchEngine: 'web search',
18
+ search: 'web search',
19
+ useBrowser: 'web browser',
20
+ browse: 'web browser',
21
+ fetch_url_content: 'web browser',
22
+ run_browser: 'web browser',
23
+ get_current_time: 'time awareness',
24
+ useTime: 'time awareness',
25
+ set_timeout: 'timers',
26
+ cancel_timeout: 'timers',
27
+ list_timeouts: 'timers',
28
+ update_timeout: 'timers',
29
+ get_user_location: 'user location',
30
+ send_email: 'email sending',
31
+ useEmail: 'email sending',
32
+ spawn_agent: 'teammate agents',
33
+ retrieve_user_memory: 'long-term memory',
34
+ store_user_memory: 'long-term memory',
35
+ project_list_files: 'project files',
36
+ project_read_file: 'project files',
37
+ project_upsert_file: 'project files',
38
+ project_delete_file: 'project files',
39
+ project_create_branch: 'project repository',
40
+ project_create_pull_request: 'project repository',
41
+ };
42
+
43
+ /**
44
+ * Tool identifiers that should never appear as user-facing capability highlights.
45
+ *
46
+ * `agent_progress` is an internal runtime tool used by the worker to update this
47
+ * very progress card, and `assistant_preparation` is the engine-level chip shown
48
+ * while the model is being prepared. Neither describes a user-relevant capability.
49
+ */
50
+ const HIDDEN_PROGRESS_TOOL_NAMES: ReadonlySet<string> = new Set(['agent_progress', 'assistant_preparation']);
51
+
52
+ /**
53
+ * Derives a short, deduplicated list of user-facing tool labels for one durable chat turn.
54
+ *
55
+ * @public exported for `createRunUserChatJobExecutionContext`
56
+ */
57
+ export function resolveUserChatProgressToolHighlights(
58
+ tools: ReadonlyArray<LlmToolDefinition>,
59
+ ): ReadonlyArray<string> {
60
+ const highlights: Array<string> = [];
61
+ const seenLabels = new Set<string>();
62
+
63
+ for (const tool of tools) {
64
+ if (!tool || typeof tool.name !== 'string') {
65
+ continue;
66
+ }
67
+
68
+ if (HIDDEN_PROGRESS_TOOL_NAMES.has(tool.name)) {
69
+ continue;
70
+ }
71
+
72
+ const label = USER_CHAT_PROGRESS_TOOL_LABEL_BY_NAME[tool.name] ?? humanizeToolName(tool.name);
73
+ if (!label || seenLabels.has(label)) {
74
+ continue;
75
+ }
76
+
77
+ seenLabels.add(label);
78
+ highlights.push(label);
79
+
80
+ if (highlights.length >= MAX_TOOL_HIGHLIGHTS) {
81
+ break;
82
+ }
83
+ }
84
+
85
+ return highlights;
86
+ }
87
+
88
+ /**
89
+ * Converts a technical tool identifier into a short readable label as a fallback.
90
+ *
91
+ * @private internal helper of `resolveUserChatProgressToolHighlights`
92
+ */
93
+ function humanizeToolName(name: string): string {
94
+ const normalized = name.replace(/[_-]+/g, ' ').trim().toLowerCase();
95
+ if (!normalized) {
96
+ return '';
97
+ }
98
+
99
+ return normalized;
100
+ }
@@ -22,7 +22,7 @@ import { heartbeatUserChatJob } from './heartbeatUserChatJob';
22
22
  import { persistUserChatJobTerminalState } from './persistUserChatJobTerminalState';
23
23
  import { persistUserChatJobProgressCard } from './persistUserChatJobProgressCard';
24
24
  import type { UserChatJobRecord } from './UserChatJobRecord';
25
- import type { UserChatProgressPhase } from './userChatProgressCard';
25
+ import type { UserChatProgressContext, UserChatProgressPhase } from './userChatProgressCard';
26
26
  import { createReplyAwareUserChatPromptContent, createReplyAwareUserChatPromptMessage } from './userChatReplies';
27
27
  import { resolvePromptThreadBeforeUserMessage } from './userChatMessageLifecycle';
28
28
  import { isUserChatNotFoundScopeError } from './UserChatScopeError';
@@ -132,14 +132,15 @@ export async function runUserChatJob(job: UserChatJobRecord): Promise<RunUserCha
132
132
  */
133
133
  function createRunUserChatJobProgressReporter(
134
134
  job: Pick<UserChatJobRecord, 'userId' | 'agentPermanentId' | 'chatId' | 'assistantMessageId' | 'id'>,
135
- ): (phase: UserChatProgressPhase) => Promise<void> {
136
- return async (phase) => {
135
+ ): (phase: UserChatProgressPhase, context?: UserChatProgressContext) => Promise<void> {
136
+ return async (phase, context) => {
137
137
  await persistUserChatJobProgressCard({
138
138
  userId: job.userId,
139
139
  agentPermanentId: job.agentPermanentId,
140
140
  chatId: job.chatId,
141
141
  assistantMessageId: job.assistantMessageId,
142
142
  phase,
143
+ context,
143
144
  }).catch((error) => {
144
145
  console.warn('[user-chat-job] progress_update_failed', {
145
146
  chatId: job.chatId,